11# Releasing
22
3- The recommended way to install the app is to build it from source (see the
4- README). A pre-built binary can be attached to a GitHub Release, but without an
5- Apple Developer ID certificate it is ad-hoc signed and not notarized, so it has
6- to be allowed through Gatekeeper by hand .
3+ Releases are signed with a Developer ID certificate and notarized by Apple, so
4+ the download opens without a Gatekeeper prompt. All of it happens in the
5+ ` Release ` workflow when a GitHub Release is published; the one-time credential
6+ setup is at the bottom of this file .
77
88Release tags use the format ` vYYYY-MM-DD.X ` , where ` X ` counts the releases made
99that day starting at 1 (for example ` v2026-06-11.1 ` ). The app's
@@ -20,22 +20,106 @@ valid in `CFBundleShortVersionString`.
2020 next sequence number (for example ` v2026-06-11.1 ` ), target ` main ` , and paste
2121 the changelog entries as the notes. Publishing it creates the tag.
2222
23- Publishing the Release triggers the ` Release ` workflow, which builds the
24- artifact , attaches ` PassQuickAccess.zip ` to that same Release, signs it with the
23+ Publishing the Release triggers the ` Release ` workflow, which signs and notarizes
24+ the build , attaches ` PassQuickAccess.zip ` to that same Release, signs it with the
2525Sparkle EdDSA key, and publishes the new version to ` docs/appcast.xml ` (served by
2626GitHub Pages). The release notes you wrote become the changelog the app shows.
27- To build the zip locally instead:
27+
28+ The job refuses to run if the signing or notarization secrets are missing, rather
29+ than quietly publishing an ad-hoc build that every user would have to right-click
30+ past.
31+
32+ ## Dry run
33+
34+ The same workflow can be started by hand from the Actions tab (* Run workflow* ).
35+ It builds, signs and notarizes exactly as a real release does, but skips the two
36+ steps that publish: nothing is attached to a release and nothing is pushed to
37+ ` main ` . The Sparkle signing step still runs, so a dry run exercises every secret.
38+
39+ This is the only way to find out whether the credentials work without cutting a
40+ release and watching it fail, so run it after changing any of them, and after
41+ touching the workflow or the build script.
42+
43+ ## Building locally
44+
45+ ` scripts/build-release.sh ` does the whole artifact: build, signature check, zip,
46+ notarize, staple, re-zip, and a final Gatekeeper assessment. It is the same
47+ script CI runs, so a local build is the real thing rather than an approximation.
48+
49+ ``` sh
50+ ./scripts/build-release.sh
51+ ```
52+
53+ With nothing configured that builds ad-hoc and warns that the result is not
54+ distributable, which is fine for testing a Release build. To produce a signed,
55+ notarized zip:
2856
2957``` sh
58+ export PQA_SIGN_IDENTITY=" Developer ID Application: Your Name (TEAMID)"
59+ export PQA_TEAM_ID=" TEAMID"
60+ export NOTARY_KEYCHAIN_PROFILE=" NOTARY"
3061./scripts/build-release.sh
3162```
3263
33- This writes ` dist/PassQuickAccess.zip ` .
64+ ` security find-identity -v -p codesigning ` prints the identity string to use. The
65+ notarization profile is created once with:
66+
67+ ``` sh
68+ xcrun notarytool store-credentials NOTARY \
69+ --key ~ /private_keys/AuthKey_XXXXXXXXXX.p8 \
70+ --key-id XXXXXXXXXX --issuer 00000000-0000-0000-0000-000000000000
71+ ```
72+
73+ Either way it writes ` dist/PassQuickAccess.zip ` .
74+
75+ ## Signing and notarization (one-time setup)
76+
77+ ### 1. Developer ID Application certificate
78+
79+ In Xcode, * Settings → Accounts → Manage Certificates → + → Developer ID
80+ Application* , or create it in the Apple Developer portal under * Certificates,
81+ Identifiers & Profiles* . Note that Apple allows a limited number of Developer ID
82+ certificates per account and they cannot be revoked casually, so keep the export
83+ below somewhere safe: losing it means asking Apple to reset.
84+
85+ Export it from Keychain Access (the certificate together with its private key) as
86+ a ` .p12 ` with a strong password, then load both into the repository:
87+
88+ ``` sh
89+ base64 -i DeveloperID.p12 | gh secret set MACOS_CERTIFICATE_P12
90+ gh secret set MACOS_CERTIFICATE_PASSWORD
91+ gh secret set MACOS_SIGN_IDENTITY # "Developer ID Application: Your Name (TEAMID)"
92+ gh secret set APPLE_TEAM_ID # TEAMID
93+ ```
94+
95+ Delete the ` .p12 ` from disk afterwards, or move it into your password manager.
96+
97+ ### 2. App Store Connect API key for notarization
98+
99+ An API key is preferred over an app-specific password: it is scoped to a role, it
100+ can be revoked on its own without touching the Apple ID, and it never exposes
101+ account credentials to CI. In App Store Connect, * Users and Access → Integrations
102+ → App Store Connect API* , create a key with the ** Developer** role. The ` .p8 `
103+ downloads once and cannot be downloaded again.
104+
105+ ``` sh
106+ gh secret set APPLE_API_KEY_P8 < AuthKey_XXXXXXXXXX.p8
107+ gh secret set APPLE_API_KEY_ID # the key ID, e.g. XXXXXXXXXX
108+ gh secret set APPLE_API_ISSUER_ID # the issuer UUID shown above the key list
109+ ```
110+
111+ ### 3. What the workflow does with them
112+
113+ The certificate is imported into a keychain created for the job and deleted when
114+ it ends, and the ` .p8 ` is written outside the workspace and removed on the way
115+ out. Neither is ever placed in the built artifact. The workflow only runs on a
116+ published release, never from a pull request, so a fork cannot reach the secrets.
34117
35118## Sparkle update signing (one-time setup)
36119
37120The app updates itself through [ Sparkle] ( https://sparkle-project.org ) , and every
38- update is verified against an EdDSA key. Set this up once:
121+ update is verified against an EdDSA key, independently of Apple's notarization.
122+ Set this up once:
39123
401241 . Generate the key pair with Sparkle's tool (it stores the private key in your
41125 login Keychain and prints the public key):
@@ -65,24 +149,52 @@ user picks "Update Now" (see [SECURITY.md](SECURITY.md)).
65149release as an upgrade. ` MARKETING_VERSION ` is still the human version you bump in
66150` project.yml ` .
67151
68- ## Installing an ad-hoc build
152+ ## Entitlements
69153
70- A downloaded ad-hoc app is quarantined. The user opens it once with right-click
71- to "Open", or clears the quarantine attribute:
154+ There are two entitlement files, and which one is used follows the signing mode:
72155
73- ``` sh
74- xattr -dr com.apple.quarantine /Applications/PassQuickAccess.app
75- ```
156+ - ` PassQuickAccess-Distribution.entitlements ` for a Developer ID build, selected
157+ by ` build-release.sh ` when ` PQA_SIGN_IDENTITY ` is set.
158+ - ` PassQuickAccess.entitlements ` everywhere else (development, ad-hoc), which
159+ additionally disables library validation.
160+
161+ The switch is the ` PQA_ENTITLEMENTS_SUFFIX ` variable, interpolated into
162+ ` CODE_SIGN_ENTITLEMENTS ` on the app target in ` project.yml ` . Overriding the path
163+ itself on the ` xcodebuild ` command line does not work: a command-line setting
164+ applies to every target, so the SPM packages inherit it and fail the build
165+ looking for the file inside their own checkout.
166+
167+ The exemption exists because Xcode re-signs the embedded Sparkle.framework with
168+ the app's own identity: under a Developer ID both carry the same team and library
169+ validation passes, while an ad-hoc signature has no team for anything to match
170+ and the app would crash at launch. A shipped build therefore keeps the hardened
171+ runtime intact.
76172
77- ## Notarized releases
173+ ## Upgrading from an ad-hoc build
78174
79- Once an Apple Developer ID Application certificate is available, sign the Release
80- build with it (set it in ` Config/Local.xcconfig ` ), enable the hardened runtime,
81- then notarize and staple:
175+ Releases up to and including ` v2026-06-30.1 ` were ad-hoc signed, so the first
176+ signed release is the one update where the app's code signing identity changes
177+ underneath an existing install. The EdDSA key is unchanged, but Sparkle also
178+ compares the incoming build's signature against the running app's, and how it
179+ treats ad-hoc as the starting point is not obvious from the outside.
180+
181+ This was tested before the first signed release, against a local appcast signed
182+ with the real EdDSA key: an ad-hoc build updated itself to a Developer ID signed
183+ one and came back with the new team identifier. ** Sparkle accepts the
184+ transition** , so existing installs update themselves with nothing to announce.
185+
186+ Worth redoing if the signing identity ever changes again, since it is the same
187+ question in a new form. The harness is three parts: an ad-hoc build whose
188+ ` SUFeedURL ` is repointed at a local server (which breaks its seal, so re-sign the
189+ bundle ad-hoc afterwards), a Developer ID build with a much higher
190+ ` CFBundleVersion ` , and an appcast signed with ` sign_update ` . Note that a build
191+ that is signed but not notarized can still be refused at relaunch by Gatekeeper,
192+ which looks like a Sparkle failure and is not one.
193+
194+ Anyone still holding an old ad-hoc build can also just download the new one.
195+ Those older builds are quarantined until opened once with right-click → * Open* ,
196+ or cleared with:
82197
83198``` sh
84- xcrun notarytool submit dist/PassQuickAccess.zip --keychain-profile NOTARY --wait
85- xcrun stapler staple dist/dd/Build/Products/Release/PassQuickAccess.app
199+ xattr -dr com.apple.quarantine /Applications/PassQuickAccess.app
86200```
87-
88- A notarized, stapled build opens without the Gatekeeper prompt.
0 commit comments