macOS release signing: Developer ID + notarization + stapling - #95
Merged
Conversation
Every release so far shipped ad-hoc-signed, unnotarized DMGs -- the longest-standing known limitation on the release train. With the Apple Developer account in place, the release workflow now produces Gatekeeper-clean builds: - package_app.sh's signing block is identity-parameterized (SCANSTUDIO_SIGNING_IDENTITY, default "-"): local builds and PR CI keep the exact ad-hoc behavior they have always had, while a real identity switches to notarization-grade signing -- every Mach-O in the bundle signed individually with a secure timestamp (notarytool rejects any unsigned Mach-O, and --deep never descends into Resources where the whole BridgeRuntime CPython tree lives), hardened runtime on every executable, and executables carrying the one entitlement the bridge's ctypes loader needs to dlopen the bundled same-team-signed libusb under library validation. - New scripts/notarize_dmg.sh signs the DMG, submits it through notarytool with the App Store Connect API key, treats anything but "status: Accepted" as failure (notarytool can exit 0 on a rejected submission -- the status line is the authority, and the per-file rejection log is fetched before failing), staples, and finishes with Gatekeeper's own spctl verdict on the stapled artifact. - The release DMG job imports the certificate into a throwaway keychain (deleted in an always() cleanup step), fails loudly when any signing secret is missing rather than silently shipping another ad-hoc build, and notarizes BEFORE SHA256SUMS/latest.json are emitted -- stapling mutates the DMG, so every published checksum is computed from the stapled artifact. Secrets consumed (names only; values live in repository secrets): MACOS_SIGNING_CERT_P12_BASE64, MACOS_SIGNING_CERT_PASSWORD, APPSTORE_CONNECT_API_KEY_P8, APPSTORE_CONNECT_API_KEY_ID, APPSTORE_CONNECT_API_ISSUER_ID. The GitHub Actions pin policy passes over the edited workflow (the new steps are plain run: steps); both zsh scripts pass zsh -n; the ad-hoc default path is exercised by the existing PR-CI package jobs.
…harden the pipeline Two independent adversarial reviews of the first commit; every required finding addressed: - The .app is now notarized and stapled BEFORE the DMG is built (then the DMG again, as before): the in-app updater's publisher-trust gate requires a stapled ticket on the INSTALLED app -- a DMG ticket does not travel with an app copied out of it -- and an app-level ticket is also what makes offline first launch work. notarize_dmg.sh is generalized into notarize_artifact.sh (app|dmg modes; apps submit as a ditto zip and staple onto the bundle). - packaging/Info.plist stamps ScanStudioUpdateTeamIdentifier with the real Team ID -- the second independent blocker on the same updater gate, called out by docs/AUTO-UPDATE.md and skipped by the first commit. - The notarytool submission capture no longer loses its diagnostics when notarytool exits non-zero (auth failure, network error, timeout): the capture tolerates the exit code and the status line in the output stays the single authority, so the log always shows WHY. - The .p8 encoding contract is explicit: the secret is raw PEM (env renamed from _B64, misleading), written once per job with a BEGIN PRIVATE KEY guard that fails in seconds instead of after a 40-minute build; stapler validate runs after every staple (the direct ticket check spctl cannot make); key material is also removed in the always() cleanup; the release job gains a timeout now that it waits on Apple round-trips. - New signing-dry-run.yml (workflow_dispatch): rehearses the ENTIRE path on one arch without tagging -- keychain import, notarization-grade package, an assertion that the disable-library-validation entitlement landed on the bridge interpreter and ONLY there, app notarize + staple, a live proof that the hardened entitled interpreter can ctypes-dlopen the bundled libusb (the check the notary service cannot make), DMG build/notarize/staple, artifact upload for manual inspection. The release path's first execution is no longer a published tag. - The entitlement's justification comment now cites the real reason (host libsane via find_library is foreign-team-signed -- exactly what library validation blocks; the bundled same-team libusb would pass on its own); the Mach-O sweep gains a fork-saving pre-filter (executable-bit or *.so/*.dylib; file(1) stays the authority) and a provenance note that release signing rewrites files whose hashes were pinned pre-signing; package_dmg.sh labels its digest pre-staple; docs/AUTO-UPDATE.md now describes the shipped trust state and the real secret names.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Turns the release train's macOS DMGs from ad-hoc-signed/unnotarized into Developer-ID-signed, notarized, stapled, Gatekeeper-clean builds — closing the oldest known-limitation on every release since beta.1.
package_app.sh: signing block parameterized bySCANSTUDIO_SIGNING_IDENTITY(default-= the exact ad-hoc behavior local builds and PR CI have always had). A real identity signs every Mach-O in the bundle individually with a secure timestamp (--deepnever descends intoResources, where the entire BridgeRuntime CPython tree lives, and notarytool rejects any unsigned Mach-O), hardened runtime on executables, and thedisable-library-validationentitlement the bridge's ctypes loader needs to dlopen the bundled same-team-signed libusb.scripts/notarize_dmg.sh(new): signs the DMG, submits via notarytool with the App Store Connect API key, treats anything butstatus: Acceptedas failure (notarytool can exit 0 on a rejected submission — the status line is the authority; the per-file rejection log is fetched before failing), staples, then runsspctl -a -t open— Gatekeeper's own verdict on the artifact a user will double-click.release.ymlDMG job: imports the certificate into a throwaway keychain (deletedif: always()), fails loudly if any signing secret is missing (a release may no longer silently ship ad-hoc), and notarizes beforeSHA256SUMS/latest.jsonare emitted — stapling mutates the DMG, so published checksums are computed from the stapled artifact.Secrets consumed (names only)
MACOS_SIGNING_CERT_P12_BASE64,MACOS_SIGNING_CERT_PASSWORD,APPSTORE_CONNECT_API_KEY_P8,APPSTORE_CONNECT_API_KEY_ID,APPSTORE_CONNECT_API_ISSUER_ID— all present in repository secrets.Verification
run:).zsh -n; the ad-hoc default path is byte-for-byte the previous behavior and stays exercised by the existing PR-CI package jobs.Round 2 (two independent adversarial reviews)
Both reviews confirmed the mechanics (empirically:
--deepdoes not clobber the Resources entitlements, the file(1) sweep classifies the real CPython tree correctly, empty-array zsh expansion keeps the ad-hoc path byte-identical) and found the things that mattered:notarize_artifact.shhandles both artifacts.ScanStudioUpdateTeamIdentifieris stamped into the packaged Info.plist with the real Team ID — the second independent blocker on the updater gate, named bydocs/AUTO-UPDATE.mdstep 1 and missed in round 1..p8contract made explicit: raw PEM,BEGIN PRIVATE KEYguard that fails in seconds rather than after a 40-minute build;stapler validateafter every staple; key material also removed in thealways()cleanup; the release job gainstimeout-minutes.signing-dry-run.yml(manual dispatch): the whole path — keychain, notarization-grade package, an assertion the entitlement landed on the bridge interpreter and only there, app notarize/staple, a livectypes.CDLLof the bundled libusb under the hardened entitled interpreter (the check the notary service cannot make), DMG notarize/staple, inspectable artifact. The release path's first execution is no longer a published tag.libsaneviafind_libraryis foreign-team — the actual thing library validation blocks), sweep pre-filter, pre-staple digest labeling,AUTO-UPDATE.mdupdated to the shipped trust state.Run order after merge: trigger Signing dry run once from the Actions tab and eyeball/install its artifact; then the next tag ships signed.