diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 314d182..170a43f 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -14,7 +14,18 @@ on: description: Release tag to attach the files to. Omit to only build them. required: false type: string + secrets: + MACOS_KEYCHAIN_PASSWORD: + description: Login keychain password of the runner. Needed only to sign. + required: false workflow_dispatch: + inputs: + tag: + description: >- + Release tag to build and attach the files to, for a release whose + package job failed. Omit to only build them. + required: false + type: string permissions: contents: write @@ -45,8 +56,10 @@ jobs: # Signing and notarizing are opt-in through repository variables, and both # are all-or-nothing once set. The credentials live in the runner's own - # keychain — this is a Mac on a desk, not a disposable VM — so nothing - # secret passes through GitHub and these two variables are only names. + # keychain — this is a Mac on a desk, not a disposable VM — and these two + # variables are only names. The one secret is the keychain password: + # each job runs in its own security session, so it must unlock the + # keychain itself (see the next step). # # SIGN_IDENTITY e.g. "Developer ID Application: NAME (TEAMID)" # NOTARY_PROFILE a notarytool credential profile on that Mac @@ -54,6 +67,26 @@ jobs: # Unset, a release is ad-hoc signed and unnotarized, exactly as before. # Set, a failure to sign or notarize fails the release rather than # quietly publishing files nobody can open. See docs/signing.md. + # The runner starts each job in its own security session (SessionCreate in + # its launchd plist), so the login keychain is locked here even while the + # console session has it unlocked. Unlock it for this job only. The last + # step locks it again, so a later job cannot use the key without the + # password. + - name: Unlock the signing keychain + if: vars.SIGN_IDENTITY != '' + env: + MACOS_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + if [ -z "${MACOS_KEYCHAIN_PASSWORD}" ]; then + echo "::error::The MACOS_KEYCHAIN_PASSWORD org secret is not available to this workflow." >&2 + exit 1 + fi + keychain="$HOME/Library/Keychains/login.keychain-db" + security unlock-keychain -p "${MACOS_KEYCHAIN_PASSWORD}" "${keychain}" + # Two hours, so it cannot lock again while Apple notarizes. + security set-keychain-settings -lut 7200 "${keychain}" + - name: Build monitor.app env: MONITOR_SIGN_IDENTITY: ${{ vars.SIGN_IDENTITY }} @@ -141,3 +174,10 @@ jobs: # --clobber so a re-run replaces the assets rather than failing on a # name that already exists. gh release upload "$TAG" "$DMG" "$DMG.sha256" "$ZIP" "$ZIP.sha256" --clobber + + - name: Lock the keychain again + if: always() && vars.SIGN_IDENTITY != '' + run: | + keychain="$HOME/Library/Keychains/login.keychain-db" + security set-keychain-settings "${keychain}" || true + security lock-keychain "${keychain}" || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c0ecef..06c10ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -252,3 +252,6 @@ jobs: uses: ./.github/workflows/package.yml with: tag: ${{ github.event_name == 'release' && github.event.release.tag_name || needs.tag.outputs.tag }} + # A called workflow sees no secrets unless the caller passes them. + secrets: + MACOS_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} diff --git a/AGENTS.md b/AGENTS.md index 19db62a..7bb5e62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -396,11 +396,16 @@ are no component-level AGENTS.md files. and `NOTARY_PROFILE` turn on Developer ID signing and notarization in `package.yml`; unset, a release is ad-hoc signed exactly as before. Set, failing to sign or notarize fails the release rather than publishing files - nobody can open. The credentials live in the Mac runner's keychain, not in - GitHub secrets — which is why the runner's LaunchAgent needs - `SessionCreate = false`: with the default `true`, every job gets its own - security session, cannot reach the login keychain, and `codesign` fails with - `errSecInternalComponent`. A signing runner cannot be headless. + nobody can open. The signing key and the notary profile live in the Mac + runner's keychain. The runner keeps GitHub's default `SessionCreate = true`, + so every job gets its own security session and finds the login keychain + locked: `package.yml` unlocks it with the `MACOS_KEYCHAIN_PASSWORD` org secret + and locks it again at the end, and `release.yml` must pass that secret to it. + Without the unlock, `codesign` fails with `errSecInternalComponent` or "no + identity found". A locked keychain between jobs keeps the key away from + pull-request jobs on the same runner. (Until 2026-10-05 the runner used + `SessionCreate = false` and no secret; StationCast's release job locks the + keychain after it runs, which could then break signing here.) **Staple the app before packaging it** — `stapler` writes into the bundle, not into any archive around it, so `notarize.sh` staples the app and only then does `make-dmg.sh` put it in the image, which is notarized and stapled diff --git a/docs/signing.md b/docs/signing.md index d26e6cf..910f751 100644 --- a/docs/signing.md +++ b/docs/signing.md @@ -44,9 +44,12 @@ downloads. ## One-time setup on the Mac runner Signing and notarizing happen in `package.yml`, on the self-hosted macOS runner. -The credentials live in that machine's keychain rather than in GitHub secrets: -it is a Mac on a desk, not a disposable VM, so there is no reason to put a -private key where it can leak. +The signing key and the notary profile live in that machine's keychain, not in +GitHub: it is a Mac on a desk, not a disposable VM, so there is no reason to put +a private key where it can leak. The one secret in GitHub is the keychain's +password (`MACOS_KEYCHAIN_PASSWORD`, an org secret shared with `fancontrol`), +because each job must unlock the keychain itself. The org-wide runner setup is +in `evanwtf/.github-private`, `docs/macos-runner-setup.md` section 6. **1. Install the certificate.** The identity travels as a `.p12` — the certificate and its private key together. Restore it from 1Password @@ -80,42 +83,34 @@ If the runner runs as a LaunchDaemon it has no login keychain at all. Run it as a LaunchAgent under your user, or give it a dedicated keychain and unlock that in the job. -**A LaunchAgent is not enough on its own.** GitHub's `svc.sh` writes +**Each job unlocks the keychain itself.** GitHub's `svc.sh` writes `SessionCreate = true` into the runner's plist, which spawns every job into its own security session. That session does not inherit the login session's unlocked keychain, so `codesign` finds the identity, fails to reach its private key, and reports `errSecInternalComponent` — an error that says nothing about -keychains. Turn it off and reload: +keychains. `package.yml` therefore unlocks the login keychain with the +`MACOS_KEYCHAIN_PASSWORD` secret before it signs, and locks it again in its +last step. -```sh -p=~/Library/LaunchAgents/actions.runner...plist -cp "$p" "$p.bak" -/usr/libexec/PlistBuddy -c "Set :SessionCreate false" "$p" -launchctl bootout gui/$(id -u)/actions.runner.. -launchctl bootstrap gui/$(id -u) "$p" -``` - -The job then runs in the console user's session and signs against the keychain -unlocked at login. The alternative is to keep the separate session and unlock a -keychain inside the job, which means putting its password in a GitHub secret — -the thing keeping the credentials on the runner was meant to avoid. - -This needs somebody logged in at the console on that Mac. A runner that signs -is a runner that is not headless. +Keep `SessionCreate = true`. With `false`, jobs share the console session: any +job on the runner, including a pull request's, can use the key while the +keychain is unlocked, and another repository's job that locks the keychain +breaks signing here until somebody unlocks it. -**3. Store notarization credentials.** An app-specific password from -appleid.apple.com, kept in a named profile: +**3. Store notarization credentials.** A named profile made from the App Store +Connect API key (`stationcast-asc-api-key-release` in Code Secrets: key +`F8NPMRD384`, App Manager role), not an Apple ID. An app-specific password +stops working when the Apple ID password changes; on 2026-10-05 the one in +`monitor-notary-password` failed with HTTP 401. ```sh xcrun notarytool store-credentials monitor-notary \ - --apple-id you@example.com --team-id --password + --key AuthKey_F8NPMRD384.p8 --key-id F8NPMRD384 \ + --issuer cd5de39f-2971-421b-b3c1-fc2bb49a0b96 ``` -The password is an **app-specific** one from -[appleid.apple.com](https://appleid.apple.com/account/manage), not the Apple ID -password, which `notarytool` refuses. Keep it in 1Password as -`monitor-notary-password` in Code Secrets: the profile on the runner cannot be -read back out, so rebuilding that Mac would otherwise mean generating a new one. +`store-credentials` copies the key into the keychain, so delete the `.p8` file +afterwards. Check it took, which costs nothing and answers a question that is otherwise twenty minutes away: