Nucleus
Packaging & distribution

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 notarytool keychain profile, or an App Store Connect API key).
  • Windows — a .pfx/.p12 code-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 { }:

build.gradle.kts
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.

  1. Apple ID and an app-specific password:

    notarization {
        appleID.set("dev@example.com")
        password.set(System.getenv("MAC_NOTARIZATION_PASSWORD"))
        teamID.set("TEAMID")
    }
  2. A notarytool keychain profile. Store the credentials once with xcrun notarytool store-credentials AC_PASSWORD …, then reference the profile:

    notarization {
        keychainProfile.set("AC_PASSWORD")
    }
  3. 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:

build.gradle.kts
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:

build.gradle.kts
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.

  1. 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"
  2. 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 keyFile at an exported key instead — see Manage signing secrets in CI.

  3. 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.asc

    Publish each package together with its .pub.asc (and the .deb.asc for DEB) on your download page.

  4. 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 with gpg --verify <pkg>.deb.asc <pkg>.deb. It needs only gpg, so it works on every distribution. It is the default because dpkg-sig was removed from recent Debian and Ubuntu releases.
  • DpkgSig embeds an _gpgorigin member through dpkg-sig, verified with a plain gpg --verify <pkg>.deb. It requires dpkg-sig at build time.
  • Debsig embeds an _gpgorigin member through debsigs, verified with debsig-verify. It also requires a per-key policy and keyring on the client.
build.gradle.kts
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:

build.gradle.kts
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 secret

Add 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:

build.gradle.kts
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, prefix
  • macOS.notarization { }appleID + password + teamID, or keychainProfile (+ keychainPath), or apiKey + apiKeyId + apiIssuer
  • windows.signing { }enabled, certificateFile, certificatePassword, certificateSha1, certificateSubjectName, algorithm, timestampServer, plus the Azure fields
  • linux.signing { }enabled, keyId, keyFile, passphrase, debMethod (Detached | DpkgSig | Debsig), silentUpdate
  • signing { 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, and com.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 -P instead of environment variables. Linux signing exposes compose.desktop.linux.sign and compose.desktop.linux.signing.{keyId,keyFile,passphrase}.
  • Linux signing is opt-in. It stays off until linux.signing.enabled is true and a keyId is set. Repository-level signing (apt/dnf Release.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.