Code signing
Sign and notarize your app's installers on macOS, sign with a PFX certificate or Azure Artifact Signing on Windows, and GPG-sign DEB/RPM packages on Linux.
In this tutorial, you'll sign your Nucleus app's installers on macOS, Windows, and Linux, and
notarize the macOS build with Apple. Signing is configured in the same nativeDistributions { }
DSL that drives packaging: one signing { } block per operating system, or a single unified
block for all three.
Before you start
Signing ships with the Gradle plugin, so there is nothing extra to install. What you need depends on the platform you target:
- macOS — an Apple Developer account with a Developer ID Application certificate, plus
notarization credentials (an Apple ID app-specific password, a
notarytoolkeychain profile, or an App Store Connect API key). - Windows — a
.pfx/.p12code-signing certificate, or an Azure Trusted Signing account. - Linux — a GPG key pair. No paid certificate or authority is required.
Sign and notarize on macOS
Direct-distribution formats (DMG, ZIP) are signed with a Developer ID Application identity and
then submitted to Apple for notarization. Configure the identity in signing { } and the
credentials in notarization { }:
macOS {
signing {
sign.set(true)
identity.set("Developer ID Application: My Company (TEAMID)")
}
notarization {
appleID.set("dev@example.com")
password.set(System.getenv("MAC_NOTARIZATION_PASSWORD"))
teamID.set("TEAMID")
}
}Mac App Store builds (PKG) follow a separate path: a 3rd Party Mac Developer Application certificate with provisioning profiles, and no notarization — Apple reviews the upload through Transporter.
Choose a notarization mode
Notarization supports three mutually exclusive authentication modes. Configuring more than one in the same build is rejected at validation, so pick one.
-
Apple ID and an app-specific password:
notarization { appleID.set("dev@example.com") password.set(System.getenv("MAC_NOTARIZATION_PASSWORD")) teamID.set("TEAMID") } -
A
notarytoolkeychain profile. Store the credentials once withxcrun notarytool store-credentials AC_PASSWORD …, then reference the profile:notarization { keychainProfile.set("AC_PASSWORD") } -
An App Store Connect API key, which is the recommended mode for CI:
notarization { apiKey.set("/path/to/AuthKey_ABC123.p8") apiKeyId.set("ABC123") apiIssuer.set("12345678-90ab-cdef-1234-567890abcdef") }
Sign universal binaries
lipo invalidates every existing code signature, so universal builds re-sign after the merge in a
strict inside-out order: .dylib and .jnilib files with runtime entitlements, main executables
with app entitlements, then the runtime bundle, then the .app itself. All codesign invocations
use --options runtime --timestamp. The build-macos-universal composite action handles this in
CI — see CI/CD.
Sign on Windows
Use a .pfx or .p12 certificate, optionally password-protected, with a timestamp server. The
algorithm defaults to SigningAlgorithm.Sha256:
windows {
signing {
enabled = true
certificateFile.set(file(System.getenv("WIN_CSC_LINK") ?: "certs/certificate.pfx"))
certificatePassword = System.getenv("WIN_CSC_KEY_PASSWORD")
algorithm = SigningAlgorithm.Sha256
timestampServer = "http://timestamp.digicert.com"
}
}Common timestamp servers are DigiCert (http://timestamp.digicert.com), Sectigo
(http://timestamp.sectigo.com), and GlobalSign (http://timestamp.globalsign.com).
To sign with a cloud HSM instead of a local certificate file, use Azure Artifact Signing:
windows {
signing {
enabled = true
publisherName = "My Publisher"
azureTenantId = System.getenv("AZURE_TENANT_ID")
azureEndpoint = "https://eastus.codesigning.azure.net"
azureCertificateProfileName = "my-profile"
azureCodeSigningAccountName = "my-account"
}
}Sign on Linux
Linux has no OS gatekeeper. Signing lets anyone who downloads your .deb/.rpm directly — outside
an apt/dnf repository — verify that it came from you and was not tampered with. You sign with your
own GPG key, so no paid certificate or authority is involved.
-
Generate a key once, if you don't already have one:
gpg --full-generate-key # RSA 4096, your name and email gpg --list-secret-keys --keyid-format=long # sec rsa4096/AB12CD34EF56 ... the key id is "AB12CD34EF56" -
Enable signing with that key id:
build.gradle.kts linux { signing { enabled.set(true) keyId.set("AB12CD34EF56") // passphrase.set(System.getenv("LINUX_GPG_PASSPHRASE")) // only if the key has one } }The key is read from your local GPG keyring. In CI, point
keyFileat an exported key instead — see Manage signing secrets in CI. -
Build. Each package comes out signed, with its public key written alongside it:
MyApp-1.0.0-linux-x64.deb MyApp-1.0.0-linux-x64.deb.asc # detached signature (DEB) MyApp-1.0.0-linux-x64.deb.pub.asc # your public key MyApp-1.0.0-linux-x64.rpm # signature embedded in the RPM header MyApp-1.0.0-linux-x64.rpm.pub.ascPublish each package together with its
.pub.asc(and the.deb.ascfor DEB) on your download page. -
Users verify a download before trusting it:
# DEB — detached signature gpg --import MyApp-1.0.0-linux-x64.deb.pub.asc gpg --verify MyApp-1.0.0-linux-x64.deb.asc MyApp-1.0.0-linux-x64.deb # Good signature from "Your Name <you@example.com>" # RPM — header signature sudo rpm --import MyApp-1.0.0-linux-x64.rpm.pub.asc rpm -K MyApp-1.0.0-linux-x64.rpm # digests signatures OK
Signing does not change installation. A signed package installs exactly like an unsigned one, with
or without the key imported. Verification is an optional step the user runs before installing, and
becomes mandatory only when the user enforces it (for example, a debsig-verify policy or strict
RPM signature checking).
Choose a DEB signing method
debMethod selects how the DEB is signed, using a DebSignMethod value:
Detached(default) writes a detached<pkg>.deb.asc, verified withgpg --verify <pkg>.deb.asc <pkg>.deb. It needs onlygpg, so it works on every distribution. It is the default becausedpkg-sigwas removed from recent Debian and Ubuntu releases.DpkgSigembeds an_gpgoriginmember throughdpkg-sig, verified with a plaingpg --verify <pkg>.deb. It requiresdpkg-sigat build time.Debsigembeds an_gpgoriginmember throughdebsigs, verified withdebsig-verify. It also requires a per-key policy and keyring on the client.
linux {
signing {
enabled.set(true)
keyId.set("AB12CD34EF56")
debMethod = DebSignMethod.Detached // Detached | DpkgSig | Debsig
}
}The signing key never touches your real keyring during the build: Nucleus imports keyFile into a
throwaway GNUPGHOME and discards it afterwards.
Enable passwordless self-update
Once your packages are signed, the app can apply updates without a root password prompt, because
the install is gated on the signature. Add silentUpdate.set(true) to the Linux signing block; see
passwordless updates for the full flow
and security model.
Configure all platforms in one block
To keep signing in a single entry point, wrap the per-OS blocks in one signing { } under
nativeDistributions. It delegates to the same macOS.signing, windows.signing, and
linux.signing instances, so you can mix the two styles freely:
nativeDistributions {
signing {
macOS { sign.set(true); identity.set("Developer ID Application: My Company (TEAMID)") }
windows { enabled = true; certificateFile.set(file("certs/certificate.pfx")) }
linux { enabled.set(true); keyId.set("AB12CD34EF56") }
}
}Manage signing secrets in CI
Never commit certificates or private keys. In GitHub Actions, base64-encode the PFX and decode it at
build time. For macOS, the setup-macos-signing composite action creates a temporary keychain and
imports certificates from secrets — see CI/CD.
For Linux, export the private key and store it as a secret:
gpg --armor --export-secret-keys AB12CD34EF56 # paste into the LINUX_GPG_PRIVATE_KEY secretAdd LINUX_GPG_PRIVATE_KEY, LINUX_GPG_KEY_ID, and (if set) LINUX_GPG_PASSPHRASE. The release
workflow writes a temporary key file and passes these to the build through the
compose.desktop.linux.signing.* properties; when the secrets are absent, the build is left
unsigned. Configure the build to read them:
linux {
signing {
enabled.set(true)
keyId.set(System.getenv("LINUX_GPG_KEY_ID"))
keyFile.set(file(System.getenv("LINUX_GPG_KEY_FILE") ?: "build/signing-key.asc"))
passphrase.set(System.getenv("LINUX_GPG_PASSPHRASE"))
}
}Reference
macOS.signing { }—sign,identity,keychain,prefixmacOS.notarization { }—appleID+password+teamID, orkeychainProfile(+keychainPath), orapiKey+apiKeyId+apiIssuerwindows.signing { }—enabled,certificateFile,certificatePassword,certificateSha1,certificateSubjectName,algorithm,timestampServer, plus the Azure fieldslinux.signing { }—enabled,keyId,keyFile,passphrase,debMethod(Detached|DpkgSig|Debsig),silentUpdatesigning { macOS { } windows { } linux { } }— unified block over the same per-OS settings
The full DSL is documented in the Gradle DSL reference.
Notes
- Entitlements for the hardened runtime. The minimal JVM entitlements are
com.apple.security.cs.allow-jit,com.apple.security.cs.allow-unsigned-executable-memory, andcom.apple.security.cs.allow-dyld-environment-variables. See sandboxing for the sandboxed PKG entitlements. - Equivalent Gradle properties. Every notarization field has a
compose.desktop.mac.notarization.<name>Gradle property, which is useful when you pass secrets with-Pinstead of environment variables. Linux signing exposescompose.desktop.linux.signandcompose.desktop.linux.signing.{keyId,keyFile,passphrase}. - Linux signing is opt-in. It stays off until
linux.signing.enabledistrueand akeyIdis set. Repository-level signing (apt/dnfRelease.gpg,InRelease) is a separate concern and out of scope — this signs the standalone package for direct download. - PFX rotation. Update the certificate at least 30 days before it expires. A signed-but-expired binary fails SmartScreen until a fresh, timestamped signature is applied.
What's next
- CI/CD — sign builds in GitHub Actions with the composite actions.
- Auto-update — ship signed updates, including passwordless updates on Linux.
- Sandboxing — entitlements and store-format builds.
- Gradle DSL reference — the full
nativeDistributions { }surface.