Adding Signing and Notarization to an Alacritty Fork
· / , , 0
AI TranslationSimplified ChineseEnglish
Summary:
To spare my friends macOS security warnings, I added signing and notarization to my Alacritty fork's GitHub Actions workflow.
I previously forked Alacritty, merging in several useful patches that the upstream maintainers were unwilling to accept: independently configurable padding on all four sides, a fix for selection misalignment, and a Big Sur-style icon. I also made a few changes of my own.
CI used to produce only build artifacts, without signing or notarization. When I sent the app to friends, their Macs would warn that it could not be opened because Apple could not check it for malware. Having to allow it manually every time was inconvenient.
I already had a paid Apple developer account, so I decided to sign and notarize the app entirely in CI. I ran into quite a few pitfalls along the way, so I'm documenting them here. The implementation is in this PR.
Signing and Notarization
Signing stamps an app with a developer certificate, identifying its publisher and allowing macOS to detect changes made after signing. Notarization submits the signed app to Apple for an automated scan; if it passes, Apple records the result. When you open an app downloaded from the internet, macOS Gatekeeper checks its signature and notarization. Having both in place avoids the warning I ran into.
The CI workflow needs to build, sign, and notarize the app in that order, then package it into a DMG and upload it.
Importing the Certificate in CI
Signing requires a Developer ID Application certificate. First, export the certificate and its private key from your local keychain as a .p12 file, base64-encode it, and store it in the repository's secrets. I used three certificate-related secrets: the certificate itself, the .p12 password, and a password for the temporary keychain.
Each CI run creates a temporary keychain, imports the certificate into it, and deletes the keychain when finished.
The set-key-partition-list line must not be omitted. Without it, when codesign tries to use the private key, macOS displays a permission dialog. There is nobody to click it in CI, so the build hangs indefinitely. (A painful lesson 😭)
There is also a cleanup step at the end of the job with if: always(), so the temporary keychain is deleted whether the earlier steps succeed or fail.
I also added security find-identity -v -p codesigning before the build. It prints the available signing identities to the log, which makes debugging easier.
Editing the Makefile
When packaging the .app, Alacritty's Makefile runs codesign --sign - to apply an ad hoc signature without a certificate. Gatekeeper does not treat that as a trusted Developer ID signature, so I changed it to sign with my own certificate.
MAKE
@codesign --force --deep --entitlements $(APP_ENTITLEMENTS) --options runtime --timestamp --sign "Developer ID Application: Bojin Li (TEAMID)" -v "$(APP_DIR)/$(APP_NAME)"
Three options here matter for notarization. --options runtime enables Hardened Runtime, and --timestamp adds a timestamp from Apple's timestamp service. Both are required for notarization; without either one, the submission is rejected. --entitlements points to the entitlements file.
In Info.plist, I also changed CFBundleIdentifier from upstream's org.alacritty to my own xxxbrian.alacritty.
Entitlements
With Hardened Runtime enabled, access to protected resources such as the camera, microphone, contacts, calendars, location, and photos requires the corresponding entitlements to be declared at signing time. Without them, access is denied.
Terminals are a special case. The terminal itself may not need these resources, but programs running inside it can, such as a command-line tool that uses the camera. The permission is attributed to the terminal. Without these declarations, those tools cannot use the resources from my Alacritty, so I added a macos-entitlement.plist file declaring the common entitlements.
Next, the signed universal .app goes through notarization. Rather than write my own notarytool invocation, I used lando/notarize-action. It needs an Apple ID, an app-specific password, and a Team ID. Generate the app-specific password separately at appleid.apple.com; do not use your regular Apple ID password.
After notarization succeeds, the next step is to attach Apple's notarization ticket to the app. This is called stapling; I used BoundfoxStudios/action-xcode-staple. With the ticket stapled, macOS can verify that the app is notarized even when the user is offline.
The final DMG turned out to be the most troublesome part.
In Alacritty's Makefile, the DMG target depends on the .app target. Running make dmg-universal therefore rebuilds the .app, copying files and signing it again, even though signing, notarization, and stapling have already finished.
My first idea was to add a target that did not depend on the .app target, called dmg-universal-exist, and package the existing .app directly. But the rule I wrote was almost identical to the original, and I had not actually removed the dependency, so it accomplished nothing. I ended up deleting it and reverting to make dmg-universal. That is what the PR's final fix: corrected DMG creation process commit refers to.
Important
This describes the final state of the PR at the time, not the recommended packaging sequence. make dmg-universal still reruns the app build and signing, so do not assume the previously stapled notarization ticket survives. A more reliable process packages the app only after signing, notarization, and stapling, without rebuilding it. Before releasing, extract the app from the final DMG and run the verification commands below on the copy you will actually distribute.
Wrapping Up
The release pipeline works like this: push a tag, and CI builds the x86 and ARM versions on macOS, combines them into a universal build, signs it with my certificate, submits it for notarization, staples the ticket, then packages it into a DMG and uploads it to the GitHub release.
I previously forked Alacritty, merging in several useful patches that the upstream maintainers were unwilling to accept: independently configurable padding on all four sides, a fix for selection misalignment, and a Big Sur-style icon. I also made a few changes of my own.
CI used to produce only build artifacts, without signing or notarization. When I sent the app to friends, their Macs would warn that it could not be opened because Apple could not check it for malware. Having to allow it manually every time was inconvenient.
I already had a paid Apple developer account, so I decided to sign and notarize the app entirely in CI. I ran into quite a few pitfalls along the way, so I'm documenting them here. The implementation is in this PR.
Signing and Notarization
Signing stamps an app with a developer certificate, identifying its publisher and allowing macOS to detect changes made after signing. Notarization submits the signed app to Apple for an automated scan; if it passes, Apple records the result. When you open an app downloaded from the internet, macOS Gatekeeper checks its signature and notarization. Having both in place avoids the warning I ran into.
The CI workflow needs to build, sign, and notarize the app in that order, then package it into a DMG and upload it.
Importing the Certificate in CI
Signing requires a Developer ID Application certificate. First, export the certificate and its private key from your local keychain as a .p12 file, base64-encode it, and store it in the repository's secrets. I used three certificate-related secrets: the certificate itself, the .p12 password, and a password for the temporary keychain.
Each CI run creates a temporary keychain, imports the certificate into it, and deletes the keychain when finished.
The set-key-partition-list line must not be omitted. Without it, when codesign tries to use the private key, macOS displays a permission dialog. There is nobody to click it in CI, so the build hangs indefinitely. (A painful lesson 😭)
There is also a cleanup step at the end of the job with if: always(), so the temporary keychain is deleted whether the earlier steps succeed or fail.
I also added security find-identity -v -p codesigning before the build. It prints the available signing identities to the log, which makes debugging easier.
Editing the Makefile
When packaging the .app, Alacritty's Makefile runs codesign --sign - to apply an ad hoc signature without a certificate. Gatekeeper does not treat that as a trusted Developer ID signature, so I changed it to sign with my own certificate.
Three options here matter for notarization. --options runtime enables Hardened Runtime, and --timestamp adds a timestamp from Apple's timestamp service. Both are required for notarization; without either one, the submission is rejected. --entitlements points to the entitlements file.
In Info.plist, I also changed CFBundleIdentifier from upstream's org.alacritty to my own xxxbrian.alacritty.
Entitlements
With Hardened Runtime enabled, access to protected resources such as the camera, microphone, contacts, calendars, location, and photos requires the corresponding entitlements to be declared at signing time. Without them, access is denied.
Terminals are a special case. The terminal itself may not need these resources, but programs running inside it can, such as a command-line tool that uses the camera. The permission is attributed to the terminal. Without these declarations, those tools cannot use the resources from my Alacritty, so I added a macos-entitlement.plist file declaring the common entitlements.
Notarization and Stapling
Next, the signed universal .app goes through notarization. Rather than write my own notarytool invocation, I used lando/notarize-action. It needs an Apple ID, an app-specific password, and a Team ID. Generate the app-specific password separately at appleid.apple.com; do not use your regular Apple ID password.
After notarization succeeds, the next step is to attach Apple's notarization ticket to the app. This is called stapling; I used BoundfoxStudios/action-xcode-staple. With the ticket stapled, macOS can verify that the app is notarized even when the user is offline.
Creating the DMG
The final DMG turned out to be the most troublesome part.
In Alacritty's Makefile, the DMG target depends on the .app target. Running make dmg-universal therefore rebuilds the .app, copying files and signing it again, even though signing, notarization, and stapling have already finished.
My first idea was to add a target that did not depend on the .app target, called dmg-universal-exist, and package the existing .app directly. But the rule I wrote was almost identical to the original, and I had not actually removed the dependency, so it accomplished nothing. I ended up deleting it and reverting to make dmg-universal. That is what the PR's final fix: corrected DMG creation process commit refers to.
This describes the final state of the PR at the time, not the recommended packaging sequence. make dmg-universal still reruns the app build and signing, so do not assume the previously stapled notarization ticket survives. A more reliable process packages the app only after signing, notarization, and stapling, without rebuilding it. Before releasing, extract the app from the final DMG and run the verification commands below on the copy you will actually distribute.
Wrapping Up
The release pipeline works like this: push a tag, and CI builds the x86 and ARM versions on macOS, combines them into a universal build, signs it with my certificate, submits it for notarization, staples the ticket, then packages it into a DMG and uploads it to the GitHub release.
You can verify the resulting app locally.
If spctl outputs accepted and shows source=Notarized Developer ID, it has passed the Gatekeeper assessment.