Signing and notarising macOS programs without a Mac
On macOS, a program downloaded from the web only starts without complaint if it is signed and notarised by Apple. Otherwise Gatekeeper blocks it on first launch. Gatekeeper is the check macOS runs on every downloaded program. My builds, though, run on Linux runners, and there is no Mac anywhere in the chain. Here is how it works anyway, and where it did not work first time.
TL;DR
TL;DR: rcodesign signs and notarises on Linux, with no Xcode at all. A single command-line program cannot be stapled, so it goes to Apple as a ZIP. Gatekeeper then fetches the ticket online on first launch. In every merge request the pipeline rehearses signing with a throwaway certificate. Only release tags of named projects can reach the real key. When I added a second project, the CI apply failed on a deny, and that deny is meant to be there.
01 — Signing on Linux
Apple ships its signing tools for macOS only. rcodesign is a free reimplementation. It runs on Linux and writes the same signatures. My pipeline uses it for two projects: s3mail and grundschutz-viewer.
s3mail is an app with a window and ships as an .app bundle, a folder holding the program and its metadata. grundschutz-viewer is a plain command-line program. It is a single Mach-O file, the format of executables on macOS. Both are signed with the same command:
rcodesign sign --p12-file id.p12 --p12-password-file id.pw \
--binary-identifier eu.ole-hartwig.grundschutz-viewer \
--for-notarization grundschutz-viewer
--for-notarization does three things at once. It demands a real Developer ID certificate, adds a timestamp and turns on the hardened runtime. The hardened runtime takes some liberties away from the program, such as loading unchecked code. If any of the three is missing, Apple refuses to notarise. --binary-identifier gives the program a fixed identifier. Without it, rcodesign derives one from the file name.
02 — Notarising without stapling
The program is signed now, but that is not enough for Gatekeeper. It also wants to see that Apple has checked the program. That check is called notarisation: the pipeline uploads the program, Apple examines it and issues a ticket. For a bundle, rcodesign attaches the ticket directly. This is called stapling, and Gatekeeper then needs no connection to Apple on first launch.
A ticket cannot be stapled to a single Mach-O file. So I put the signed program into a ZIP and submit the ZIP:
zip -q grundschutz-viewer-macos-arm64.zip grundschutz-viewer
rcodesign notary-submit --api-key-file notary.json --wait \
grundschutz-viewer-macos-arm64.zip
The ticket then stays with Apple, and Gatekeeper looks it up online on first launch. For v0.2.0, Apple reported Accepted after the upload. The notarisation log listed no issues.
A rejection that is none
While double-checking, I tripped over spctl, the tool for asking Gatekeeper by hand. spctl --assess --type execute rejected the program: the code was valid but did not seem to be an app. It says that for every command-line program, even when everything is right.
A simple test told me more. I unzipped the ZIP and set the quarantine attribute, the way a browser does on download. Then I started the program. It ran without a prompt, and codesign -dv showed the Developer ID and the timestamp.
03 — The rehearsal in the merge request
The real signing step only runs on release tags. So that is the only place it gets tested. If it breaks, you find out on release day, of all days.
That is why the same job also runs in every merge request. There it uses a throwaway certificate instead of the real Developer ID, and it skips notarisation. openssl creates the certificate inside the job:
openssl pkcs12 -export -inkey throwaway.key -in throwaway.crt \
-keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 \
-out id.p12 -passout pass:throwaway
The three switches are not decoration. rcodesign reads PKCS#12 files only in the old format with SHA1 and 3DES. OpenSSL 3 writes a newer one by default. Gatekeeper would reject a program signed like this. But the commands, the paths and the check afterwards are the same as for a real release. At the end the job unzips every ZIP again and checks that the program is executable and carries a signature.
One more detail. The signing job runs on a runner pool with IPv4. My other runners speak IPv6 only, and from there Apple's notarisation and timestamp services are not reliably reachable.
04 — Two keys, two trust boundaries
That leaves the question of who can get at the keys at all. There are two of them, and they need boundaries of different tightness.
For Linux and Windows there is no signature inside the package. There, the checksum list SHA256SUMS does that job. It lists every package and carries a cosign signature. Its key sits in KMS and never leaves it. Whoever assumes the role for it can only ask KMS to sign something. That role may therefore trust many projects.
That does not work for the Apple key. A Developer ID signature needs the private key as a file, and whoever may read the file can write it into a job log. So the role holding the signing material trusts single projects only. Its trust policy sets this out: the pipeline identifies itself with an OIDC token, and the token's sub field names project and ref. Only the full project path and release tags are allowed:
project_path:<group>/<project>:ref_type:tag:ref:v*
A wildcard for a whole group would be more convenient. It would also hand the key to every project in it. A merge request cannot reach the role at all, which is one more reason it needs the throwaway certificate.
05 — Green plan, failed apply
For grundschutz-viewer, a second pattern had to go into that trust policy. That is one line in my infrastructure repository. The merge request passed, and the plan showed exactly this change. Everything was green.
The apply on main then failed with AccessDenied on iam:UpdateAssumeRolePolicy. The cause is a permissions boundary, a ceiling on what a role may do at all. The role CI uses to apply infrastructure has such a ceiling. It explicitly denies changing the trust policy of other CI roles. Whoever can do that can point another role at themselves and so gain its permissions.
So it was not a bug but the control at work. The plan does not evaluate such denies, which is why it was green. I fixed it with a targeted apply from my machine that touches only this one role. Afterwards I read the trust policy back and found both patterns in it.
06 — What else was missing
Three smaller things came up before the first release went through.
- The signing image: the job runs in its own image with rcodesign, the AWS CLI, openssl and zip. A project may only pull another project's image if it is on that image's job-token allowlist. In the merge request the job pulled the image even without an entry, because I had started the pipeline myself. The tag pipeline, however, is started by a bot with the job token, and I did not want to find out on the first release.
- Protected tags: the token for the GitHub mirror is a protected variable. Such variables exist only in pipelines on protected branches and tags. On an unprotected tag it is missing, and the jobs for the mirror and the GitHub release are not even created. Nothing turns red; the release simply appears on one side only.
- No second upload: a second run of the tag pipeline built again and tried to upload. The package registry refused, because the version already existed. That is right, because the new macOS packages carried a new timestamp. They would no longer have matched the published
SHA256SUMS. Since then such a run is meant to pass on the published files instead of uploading again. That still has to prove itself on the next rerun.
The lesson
Signing is the smaller part. The real work lies in two questions: who can get at the key? And which rehearsal shows a broken signing step before a release depends on it? A key that has to be read as a file deserves a tighter boundary than one that can only sign.
Frequently asked questions
Do I need an Apple developer account for this?+
Yes. The Developer ID certificate and the API key for notarisation come only with an account in the Apple Developer Program. You do not need a Mac for it. I keep certificate, password and API key as three separate secrets that only the signing role may read.
Why not just put xattr in the instructions?+
Because that switches the protection off instead of meeting it. With xattr -d com.apple.quarantine even an unsigned program starts. But this is exactly the step where many people who did not build the program themselves give up, and anyone used to it will one day run it on a stranger's download too.
What about Windows?+
My Windows packages are not signed, and SmartScreen says so on launch. A download can still be checked: through SHA256SUMS and its cosign signature, which is the same for every platform.
Conclusion
Signed and notarised macOS programs do not need a Mac. rcodesign on a Linux runner is enough, and for a command-line program a ZIP takes the place of stapling. The boundaries around it need more attention: a rehearsal in every merge request and a role that gives the Apple key only to release tags of named projects.
What I like most about the story is the red apply job. It stopped me at a deny I had built myself, and that is exactly where it should hold. The packages are in the grundschutz-viewer release.
I take your programs through CI signed, without opening the key to every project.
I set up signing and notarisation for macOS, the rehearsal in the merge request and a signed checksum list for Linux and Windows. Afterwards the roles holding the signing material trust only the projects and tags that really need it.
Rather than advice on paper, I stay with the first release until the packages start on the user's machine.
About the author

Kai Ole Hartwig
Programming since 2002 – self-taught, set up my own business with KO-Web in 2012. Over 100 projects, with a focus on security, performance, automation and quality. Today freelance: DevSecOps consulting, training and software development.
