Signing Desktop Releases in CI Without Handing Out the Key
Every platform wants your binaries signed. None of them make it pleasant. Notes from building a pipeline that signs Windows, macOS and Linux artifacts without a private key ever touching a build log.

Signing Desktop Releases in CI Without Handing Out the Key 🔏
Shipping a desktop app outside an app store means signing it yourself, on every platform, in an automated pipeline, without the signing material ever becoming visible. Here is the shape of what we learned building that.
Sign the binaries, then package — never the reverse
The mistake that costs the most time is signing the wrong artifact. An installer is a container; signing the container does not sign what is inside it, and every platform's verifier walks inside.
The correct order is always:
- Build the unsigned binaries.
- Sign every executable and dynamic library, individually.
- Package them into the installer, disk image, or archive.
- Sign the package itself.
The failure when you get this backwards is delayed and confusing: the installer verifies, the app installs, and then a nested helper binary trips verification at launch — or, worse, notarization rejects the whole submission and names a file you forgot existed.
Corollary: never rebuild after signing. A packaging step that recompiles rather than assembling silently discards signatures. If your packaging tool wants to build, split it — build once, sign, then package from the signed output. Our own pipeline's decisive fix was exactly this: package from signed binaries without rebuilding.
The three platforms want three different things
Windows wants an Authenticode signature with a timestamp. The timestamp is the part people skip and regret: without it, your signatures stop verifying when the certificate expires, including on already-distributed copies. Sign every .exe and .dll you ship, not just the main one.
macOS wants the most, and in a strict order: sign with a Developer ID identity, with hardened runtime enabled and a secure timestamp, then notarize, then staple the ticket. Each step has its own way of passing while the next one fails.
Two specifics worth having in advance:
- Hardened runtime is not on by default. A build without it signs fine, verifies fine locally, and gets rejected by the notary service. The flag lives in your build settings, not your signing command, which is why it gets missed.
- The notary log is the only real error message. The submission status tells you it failed; the log tells you which file, and why. Pull it every time:
xcrun notarytool log <submission-id> \
--key ./AuthKey_XXXXXXXX.p8 --key-id XXXXXXXX --issuer <issuer-uuid>We have twice chased a signing failure for hours that the log named in one line — an unsigned nested helper, a missing secure timestamp, a debug entitlement left enabled.
Linux wants nothing, which is its own problem: there is no platform verifier, so trust has to come from you. Publish checksums, sign them with a key whose fingerprint is on your site, and be aware that cross-architecture packaging is where the tooling is thinnest.
Keeping the key out of the pipeline
Three rules, in priority order.
Prefer a signing service to a key file. Cloud key vaults and hosted signing let the pipeline authenticate and request a signature without the private key ever existing on the runner. This is strictly better than any secret-handling discipline, because the material you are protecting is not there to leak.
When you must use a file, give it a lifetime. Import it into a temporary keychain or store, unlock it non-interactively with a password from your secret store, sign, and destroy it. On macOS this also solves the problem of interactive password prompts blocking automated signing — a dedicated keychain with an explicit key-partition-list is the reliable way to get a headless signer.
Assume every command echoes. Build logs are frequently more visible than the secret store the values came from. Never pass secrets as command-line arguments where a process listing or a verbose log can capture them; use environment variables or files, and mark them masked in your CI.
What to verify before you call it done
Signing that "succeeded" and signing that works are different claims. The gap is verification, and it is cheap:
- Check what the signature actually claims — identity, timestamp, and runtime flags — not merely that a signature exists.
- Verify on a machine that did not build the artifact. The build host trusts things a user's machine does not.
- Download through the path a user would take. Browser and network-transport metadata can mark a file in ways your local copy never carries.
- Verify after the full round trip: signed, packaged, uploaded, downloaded, installed, launched. A ticket that was never stapled fails only when the user is offline — which is to say, in front of the user and never in front of you.
That last point generalizes past code signing. The value of a release pipeline is not that each step reports success. It is that the artifact a stranger downloads is the artifact you verified, and the only way to know that is to be the stranger once.