Nucleus
Packaging & distribution

Code signing

Sign and notarize on macOS, sign with PFX or Azure Artifact Signing on Windows, and GPG-sign DEB/RPM on Linux.

Unsigned desktop apps die at the gate on macOS and Windows: Gatekeeper refuses them, SmartScreen warns users away, the Microsoft Store rejects the upload. On Linux there is no gatekeeper, but a direct-download .deb/.rpm has no built-in proof of where it came from. Nucleus wires signing and notarization into the same Gradle DSL that drives packaging — one block per OS, or a single unified signing { } block.

TL;DR

  • macOS: Developer ID Application + notarytool notarization, three auth modes.
  • Windows: .pfx certificate or Azure Artifact Signing (cloud-based).
  • Linux: GPG-sign DEB/RPM with your own key for distribution outside a store. Default DEB method is a detached .asc signature (needs only gpg); RPM is signed in its header.
  • Signing your Linux packages does not change how they install — verification stays optional for the end user.
  • CI ready — composite actions for keychain setup and secret-driven signing.

Install

Comes with the Gradle plugin.

Quickstart

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")
    }
}
windows {
    signing {
        enabled = true
        certificateFile.set(file("certs/certificate.pfx"))
        certificatePassword = System.getenv("WIN_CSC_KEY_PASSWORD")
        algorithm = SigningAlgorithm.Sha256
        timestampServer = "http://timestamp.digicert.com"
    }
}
linux {
    signing {
        enabled.set(true)
        keyId.set("AB12CD34EF56")                       // your GPG key id
        // 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 CI secret management.

How it works

macOS — Developer ID + notarization

Direct-distribution formats (DMG, ZIP) are signed with Developer ID Application, then submitted to Apple for notarization. Mac App Store builds (PKG) follow a separate path: 3rd Party Mac Developer Application + provisioning profiles, no notarization — Apple reviews via Transporter.

Notarization supports three mutually exclusive auth modes:

  1. Apple ID + app-specific password (simple, dev-friendly):

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

    notarization {
        keychainProfile.set("AC_PASSWORD")
    }
  3. App Store Connect API key — recommended for CI:

    notarization {
        apiKey.set("/path/to/AuthKey_ABC123.p8")
        apiKeyId.set("ABC123")
        apiIssuer.set("12345678-90ab-cdef-1234-567890abcdef")
    }

Configuring more than one mode in the same build is rejected at validation. Pick one.

macOS — inside-out signing for universal binaries

lipo invalidates all code signatures, so universal builds re-sign after merge in a strict inside-out order: .dylib and .jnilib 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 CI action handles this — see CI/CD.

Windows — PFX certificate

The classic path: a .pfx or .p12 file, optionally password-protected, with a timestamp server. Algorithm defaults to SHA-256:

windows {
    signing {
        enabled = true
        certificateFile.set(file(System.getenv("WIN_CSC_LINK") ?: "certs/certificate.pfx"))
        certificatePassword = System.getenv("WIN_CSC_KEY_PASSWORD")
        timestampServer = "http://timestamp.digicert.com"
    }
}

Common timestamp servers: DigiCert (http://timestamp.digicert.com), Sectigo (http://timestamp.sectigo.com), GlobalSign (http://timestamp.globalsign.com).

Windows — Azure Artifact Signing

For cloud HSM signing without local certificate files:

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"
    }
}

Linux — GPG signing for DEB/RPM

There is no OS gatekeeper to satisfy on Linux. The point of signing here is to let anyone who downloads your .deb/.rpm directly (outside an apt/dnf repository) prove it really came from you and was not tampered with. You sign with your own GPG key — no paid certificate, no authority.

1. Generate a key once (skip if you already have one):

gpg --full-generate-key          # RSA 4096, your name + email
gpg --list-secret-keys --keyid-format=long
# sec   rsa4096/AB12CD34EF56 ...  → the key id is "AB12CD34EF56"

2. Enable signing with that key id:

linux {
    signing {
        enabled.set(true)
        keyId.set("AB12CD34EF56")
    }
}

3. Build. Each package comes out signed, with the public key written next to 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 the package and its .pub.asc (plus the .deb.asc for DEB) on your download page.

4. Your users verify before trusting a download:

# 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 — sudo dpkg -i MyApp.deb, sudo rpm -i MyApp.rpm, or the equivalent apt/dnf command all work unchanged, with or without the key imported. Verification is an optional step the user runs before installing. It only becomes mandatory if the user explicitly enforces it (e.g. a debsig-verify policy, or strict RPM signature checking).

DEB signing methods

debMethod controls how the DEB is signed (DebSignMethod):

  • Detached (default) — writes a detached <pkg>.deb.asc, verified with gpg --verify <pkg>.deb.asc <pkg>.deb. Needs only gpg, so it works on every distro. This is the default because dpkg-sig was removed from recent Debian/Ubuntu.
  • DpkgSig — embeds an _gpgorigin member via dpkg-sig, verified with a plain gpg --verify <pkg>.deb. Requires dpkg-sig installed at build time.
  • Debsig — embeds an _gpgorigin member via debsigs, verified with debsig-verify. Additionally 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.

Unified signing block

Prefer one entry point for all platforms? Wrap the per-OS blocks in a single signing { }. It operates on the same settings, 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") }
    }
}

CI secret management

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

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 workflows write a temporary key file and feed these to the build via the compose.desktop.linux.signing.* properties; when the secrets are absent, the build is simply 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, prefix
  • macOS.notarization { }appleID+password+teamID, or keychainProfile(+keychainPath), or apiKey+apiKeyId+apiIssuer
  • windows.signing { }enabled, certificateFile, certificatePassword, certificateSha1, certificateSubjectName, algorithm, timestampServer, plus Azure fields
  • linux.signing { }enabled, keyId, keyFile, passphrase, debMethod (Detached | DpkgSig | Debsig)
  • signing { macOS { } windows { } linux { } } — unified block over the same per-OS settings

Full reference in the Gradle DSL reference.

Notes

  • Entitlements for hardened runtime. Minimal JVM entitlements: com.apple.security.cs.allow-jit, com.apple.security.cs.allow-unsigned-executable-memory, 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 — useful when you'd rather pass secrets via -P than env vars. Linux signing has the same: compose.desktop.linux.sign and compose.desktop.linux.signing.{keyId,keyFile,passphrase}.
  • Linux signing is opt-in. It is 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 expiry — signed-but-expired binaries fail SmartScreen until a fresh, timestamped signature is applied.