Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,15 +56,37 @@ 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
#
# 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 }}
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
15 changes: 10 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 23 additions & 28 deletions docs/signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.<org>.<runner>.plist
cp "$p" "$p.bak"
/usr/libexec/PlistBuddy -c "Set :SessionCreate false" "$p"
launchctl bootout gui/$(id -u)/actions.runner.<org>.<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 <TEAMID> --password <app-specific>
--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:
Expand Down
Loading