macOS Code Signing and Notarization
macOS packaging is implemented in .github/workflows/build-macos.yml. The workflow is reusable and is called by both the weekly packaging job and the release workflow.
The packaging path uses two different kinds of signing:
- an ad-hoc signature that is always applied after
macdeployqtand asset copying so the modified application bundle can run on macOS; and - an optional Developer ID signing and notarization script when release credentials are available.
These are separate stages with different purposes.
Packaging order
The current workflow performs the relevant steps in this order:
- build
standard_of_iron.app; - run
macdeployqt; - copy assets and licences into the bundle;
- ad-hoc sign the complete bundle with
codesign --sign -; - run packaged renderer and OpenGL self-tests;
- create the DMG with
hdiutil create; - run
scripts/sign-and-notarize-macos.shwith the app path and the already-created DMG; - verify the DMG and write its SHA-256 checksum.
The ad-hoc signature is therefore part of every package produced by build-macos.yml, regardless of whether Developer ID credentials are configured.
Ad-hoc signing
After deployment and asset copying, the workflow runs:
codesign --force --deep --sign - "${APP_DIR}/${APP_NAME}.app"
codesign --verify --deep --strict --verbose=2 "${APP_DIR}/${APP_NAME}.app"
This reseals the bundle after macdeployqt and the asset-copy steps have changed its contents. The packaged self-tests run against that ad-hoc-signed bundle.
An ad-hoc signature is not a Developer ID signature and does not provide notarization or a verified external publisher identity.
Optional Developer ID signing
The workflow calls:
./scripts/sign-and-notarize-macos.sh \
"${APP_DIR}/${APP_NAME}.app" \
"${DMG_NAME}"
The script exits successfully without Developer ID signing when either of these values is missing:
MACOS_CERTIFICATE; orMACOS_CERTIFICATE_PASSWORD.
If they are present, the script decodes the .p12, imports it into a temporary keychain, locates a Developer ID Application identity, signs nested frameworks and plugins, signs the main executable, signs the .app bundle, and verifies the resulting application signature.
Signing credentials
| Secret | Used for |
|---|---|
MACOS_CERTIFICATE |
Base64-encoded .p12 containing the Developer ID certificate and private key |
MACOS_CERTIFICATE_PASSWORD |
Password for the .p12 export |
MACOS_KEYCHAIN_PASSWORD |
Password for the temporary CI keychain; the script generates one with uuidgen when this value is empty |
Only the certificate and certificate password determine whether Developer ID signing runs.
Optional notarization
Notarization requires all three of these values:
APPLE_ID;APPLE_ID_PASSWORD; andAPPLE_TEAM_ID.
When any of them is absent, sign-and-notarize-macos.sh still performs Developer ID signing if the certificate credentials are present, but sets SKIP_NOTARIZATION=true and skips the notary submission.
When all notarization credentials are present, the script:
- creates a ZIP of the signed
.appwithditto; - submits the ZIP through
xcrun notarytool submit --wait; - fails if Apple does not return
status: Accepted; - retrieves the notary log on failure when it can recover a submission ID;
- runs
xcrun stapler stapleon the DMG path; and - validates the stapled target with
xcrun stapler validate.
Current DMG ordering constraint
build-macos.yml creates the DMG before sign-and-notarize-macos.sh performs Developer ID signing on the .app in the build directory.
A DMG created by hdiutil create -srcfolder contains the state of the source bundle at DMG creation time. Later changes to build/bin/standard_of_iron.app do not rewrite the copy already stored in the DMG.
The repository therefore establishes these facts:
- the app used for packaged self-tests is ad-hoc signed;
- the build-directory app can receive a Developer ID signature after the DMG exists; and
- the script attempts to staple and validate the existing DMG after notarizing a ZIP of the subsequently signed app.
The workflow does not currently establish that the Developer-ID-signed copy of the app is the copy contained in the already-created DMG. Documentation and release checks should not claim that property unless the packaging order or verification path is changed to prove it.
Encoding the certificate
On macOS:
base64 -i /path/to/certificate.p12 | pbcopy
On GNU/Linux:
base64 -w 0 /path/to/certificate.p12
Store the result as MACOS_CERTIFICATE and the export password as MACOS_CERTIFICATE_PASSWORD.
Verification commands
For an app bundle:
codesign --verify --deep --strict --verbose=2 standard_of_iron.app
For Gatekeeper assessment:
spctl --assess --type execute --verbose standard_of_iron.app
For a stapled target:
xcrun stapler validate standard_of_iron-macos.dmg
The CI workflow also runs hdiutil verify on the DMG and writes a SHA-256 checksum after the optional signing/notarization step.
Temporary credential handling
sign-and-notarize-macos.sh writes the decoded certificate to $RUNNER_TEMP/certificate.p12 and imports it into a temporary keychain. The certificate file is removed after application signing, and the temporary keychain is deleted at the end of the successful path or explicitly on notarization failure.
The .p12, its password, Apple authentication values, and private key material are release credentials and must not be committed or printed in logs.
When this workflow runs
.github/workflows/build-macos.yml is called by:
.github/workflows/weekly.ymlfor weekly package validation; and.github/workflows/release.ymlfor release candidates.
Both callers use secrets: inherit, so either path can exercise the optional Developer ID and notarization stages when repository credentials are configured.
Source of truth
The package order is defined by .github/workflows/build-macos.yml. Developer ID signing and notarization behavior is defined by scripts/sign-and-notarize-macos.sh.