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
21 changes: 10 additions & 11 deletions .github/workflows/release-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,16 @@ jobs:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
missing=()
for var in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID KEYCHAIN_PASSWORD TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
for var in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APP_STORE_CONNECT_KEY_ID APP_STORE_CONNECT_ISSUER_ID APP_STORE_CONNECT_PRIVATE_KEY KEYCHAIN_PASSWORD TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
if [[ -z "${!var:-}" ]]; then
missing+=("$var")
fi
Expand Down Expand Up @@ -133,7 +133,6 @@ jobs:
- name: Build universal Tauri app (.app only — DMG is built later from embedded version)
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
STINT_GOOGLE_CLIENT_ID: ${{ secrets.STINT_GOOGLE_CLIENT_ID }}
STINT_GOOGLE_CLIENT_SECRET: ${{ secrets.STINT_GOOGLE_CLIENT_SECRET }}
run: |
Expand Down Expand Up @@ -202,9 +201,9 @@ jobs:

- name: Notarize .app
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
run: scripts/release/notarize.sh "$APP_PATH"

- name: Build .dmg
Expand All @@ -224,9 +223,9 @@ jobs:
- name: Sign + notarize .dmg
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
run: |
codesign --force --sign "$APPLE_SIGNING_IDENTITY" "$DMG_PATH"
scripts/release/notarize.sh "$DMG_PATH"
Expand Down
141 changes: 91 additions & 50 deletions docs/runbooks/release-key-rotation.md
Original file line number Diff line number Diff line change
@@ -1,68 +1,109 @@
# Runbook — Release credential rotation

Procedures for rotating each of the credentials Phase 4 introduced.
None of these are scheduled tasks — they're triggered by external events
(annual cert expiry, suspected compromise, Apple-mandated password reset).
The release pipeline holds four rotatable credentials. All four are rotated
via `scripts/release/rotate-key.sh <subcommand>`, which walks any
unavoidable manual steps (App Store Connect / Xcode UI) with explicit
checkpoints, then handles the GitHub-secret upload and verification.

## 1. APPLE_PASSWORD (app-specific password)
This document is the why-and-when reference; the script is the how.

**Trigger:** Apple invalidates the password (typically annually, or on
account-security events).
| Credential | Trigger | Cadence | Subcommand |
|---|---|---|---|
| App Store Connect API key | suspected compromise, hygiene rotation | flexible | `rotate-key.sh app-store-connect-key` |
| Developer ID Application cert | expiry, revocation | 5 years | `rotate-key.sh apple-cert` |
| Tauri updater signing key | suspected compromise ONLY | never proactively | `rotate-key.sh tauri-key` |

**Procedure:**
Plus one auto-managed credential (`KEYCHAIN_PASSWORD`) that doesn't rotate
manually — `bootstrap-secrets.sh` regenerates it whenever called.

1. Visit appleid.apple.com → Sign-In and Security → App-Specific Passwords.
2. Revoke the old "stint notary" password.
3. Generate a new one. Apple shows it once.
4. `scripts/release/bootstrap-secrets.sh APPLE_PASSWORD`
5. Trigger a smoke release to verify (or wait for the next legitimate
`feat:`/`fix:` push).
## 1. App Store Connect API key

**Blast radius:** Releases fail at the notarization step with an
authentication error until the new password is in place.
Authenticates `xcrun notarytool` against Apple's notary service. Used on
every release.

## 2. APPLE_CERTIFICATE (Developer ID Application)
**Why it exists:** stint previously used `APPLE_ID` + `APPLE_PASSWORD`
(app-specific password) + `APPLE_TEAM_ID`. App-specific passwords expire
annually and can only be generated via the appleid.apple.com web UI — no
API. Migrating to App Store Connect API key auth eliminates that recurring
manual chore: the key is created once via the App Store Connect web UI and
afterwards every rotation is scripted.

**Trigger:** Annual expiry, or cert revocation.
**Blast radius:** releases fail at notarization until the new key is in
place. Existing shipped releases unaffected (the key authenticates
submission, not the resulting signature).

**Procedure:**
**Rotation:**

1. In Xcode → Settings → Accounts → Manage Certificates → "+" → "Developer ID
Application".
2. The new cert appears in your login keychain alongside the old one.
3. Export both cert + private key as `.p12` (same procedure as Task 1.1).
4. Record the new "Common Name" — likely identical to the old one with a
different expiry date.
5. `scripts/release/bootstrap-secrets.sh APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY`
6. Optionally delete the old cert from keychain after the next successful
release.
```bash
scripts/release/rotate-key.sh app-store-connect-key
```

**Blast radius:** Releases fail at the codesign step until the new cert is
in place. Existing signed-and-shipped releases continue to work — Gatekeeper
trusts the cert's signature, not the cert's current validity.
The script walks you through creating a new key in App Store Connect,
downloading the `.p8`, then handles upload of all three secrets
(`APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`,
`APP_STORE_CONNECT_PRIVATE_KEY`). At the end it prompts you to revoke the
old key in App Store Connect so a leaked `.p8` is no longer usable.

## 3. TAURI_SIGNING_PRIVATE_KEY (updater key)
## 2. Developer ID Application cert

**Trigger:** Only suspected compromise. Never rotate proactively.
Codesigns the `.app`, `.dmg`, and embedded CLI binary. Used on every
release.

**Procedure:**
**Trigger:** Apple's Developer ID certs are valid for 5 years from
issuance. Watch for expiry warnings in build logs roughly 90 days out.

This is the dangerous one. Rotating breaks auto-update for every existing
install of stint.
**Blast radius:** releases fail at codesign until the new cert is in
place. Existing shipped releases continue to work — Gatekeeper trusts the
cert's signature at the time of signing, not its current validity.

1. Generate a new key: `cargo tauri signer generate -w ~/.tauri/stint-new.key`.
2. Update `crates/stint-app/tauri.conf.json` `plugins.updater.pubkey` with
the new public key.
3. `scripts/release/bootstrap-secrets.sh TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
4. Cut a release containing **only** the pubkey change (it gets a `fix:`
commit, triggers a patch release).
5. Existing installs run the old build, which verifies updates against the
*old* public key. The new release's `latest.json` is signed with the new
key — existing installs reject the update.
6. Push a notice via the docs site instructing users to reinstall manually
(`brew reinstall --cask stint` or rerun the install script).
7. Future releases follow the normal path; only the gap-installs (users who
manually reinstall) bridge to the new key.
**Rotation:**

**Blast radius:** Total auto-update outage until users manually reinstall.
Last resort.
```bash
scripts/release/rotate-key.sh apple-cert
```

The script walks you through generating a new cert in Xcode → Keychain
Access, exporting to `.p12`, then handles upload of all three secrets
(`APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`).

Cert generation can't be fully scripted — Apple gates `.p12` export of
private keys through Keychain Access. (Generating certs via App Store
Connect API is possible but the JWT + CSR + key-pair-stitching machinery
isn't worth writing for a 5-year cadence.)

## 3. Tauri updater signing key

Signs `latest.json` so `tauri-plugin-updater` in existing installs can
verify update payloads. Every shipped stint binary embeds the *public*
half; rotating the *private* half means existing binaries reject all
future updates as signature-mismatched.

**Trigger:** suspected compromise ONLY. Never rotate proactively.

**Blast radius:** total auto-update outage until users manually reinstall.
The runbook for that recovery — pinning the cask, pushing a
`recovery.html`, surfacing a notice in the README — is at
`docs/runbooks/release-rollback.md`.

**Rotation:**

```bash
scripts/release/rotate-key.sh tauri-key
```

The script requires typing `I understand auto-update will break` literally
before proceeding. It generates a new keypair, edits
`crates/stint-app/tauri.conf.json` with the new pubkey, uploads the new
private key, and reminds you to commit + ship the pubkey change.

## When the script can't help

If `gh auth status` fails, fix authentication before running rotation:
the script aborts up front rather than getting halfway through.

If `bootstrap-secrets.sh` doesn't exist or isn't executable, the script
aborts. (`chmod +x scripts/release/bootstrap-secrets.sh` if needed.)

For first-time setup (no existing secrets), run `bootstrap-secrets.sh`
directly with no arguments — it walks every secret in sequence.
`rotate-key.sh` only handles re-credentialing of already-set secrets.
Original file line number Diff line number Diff line change
Expand Up @@ -129,17 +129,26 @@ The Tauri updater key is **never rotated** unless compromise is suspected. Rotat
APPLE_CERTIFICATE
APPLE_CERTIFICATE_PASSWORD
APPLE_SIGNING_IDENTITY # "Developer ID Application: Mario Meyer (TEAMID)"
APPLE_ID
APPLE_PASSWORD
APPLE_TEAM_ID
APP_STORE_CONNECT_KEY_ID # 10-char alphanumeric, from App Store Connect API keys page
APP_STORE_CONNECT_ISSUER_ID # UUID, same value for every key under the team
APP_STORE_CONNECT_PRIVATE_KEY # base64 of the .p8 file
KEYCHAIN_PASSWORD # arbitrary; for ephemeral keychain unlock
TAURI_SIGNING_PRIVATE_KEY
TAURI_SIGNING_PRIVATE_KEY_PASSWORD
HOMEBREW_TAP_TOKEN # fine-grained PAT, scoped read+write to reyemtech/homebrew-tap only
RELEASE_TOKEN # fine-grained PAT for semrelease push-back to main (bypass actor required)
STINT_GOOGLE_CLIENT_ID # reuses the same client as dev .env.local (deliberate; see §11)
STINT_GOOGLE_CLIENT_SECRET
```

`xcrun notarytool` is authenticated via App Store Connect API key (the
`--key`/`--key-id`/`--issuer` flags) rather than the legacy
`--apple-id`/`--password`/`--team-id` form. The API key is generated once
via the App Store Connect web UI; subsequent rotations are fully scripted
via `rotate-key.sh app-store-connect-key`. App-specific passwords (which
expire annually and can only be regenerated via Apple's web UI) are no
longer in the pipeline.

### Ephemeral keychain in CI

CI creates a fresh keychain per run, imports the cert, signs, and deletes the keychain on cleanup (even on failure). Never touches the runner's default keychain. Sign with `--options runtime` for hardened-runtime compliance.
Expand Down Expand Up @@ -169,30 +178,59 @@ The three `cs.*` entitlements are required by WebView2/wry under hardened runtim

### Bootstrap script

Setting up twelve secrets by hand is error-prone — the most likely first-release failure mode is "I typo'd a base64-encoded cert." Phase 4 includes `scripts/release/bootstrap-secrets.sh`, an interactive walkthrough that:
Setting up the secret inventory by hand is error-prone — the most likely
first-release failure mode is "I typo'd a base64-encoded cert." Phase 4
includes `scripts/release/bootstrap-secrets.sh`, an interactive walkthrough
that:

1. Verifies `gh` CLI is authenticated and has write access to the repo.
2. Generates the Tauri updater key pair via `tauri signer generate` (interactive prompt for passphrase), prints the public key for manual paste into `tauri.conf.json`, and pushes the private key + passphrase to GitHub secrets.
3. For each Apple secret, walks the user through the manual step (e.g., "open Keychain Access, find your Developer ID Application cert, export as .p12"), waits for the resulting file, base64-encodes, and pushes to GitHub.
4. Detects existing secrets and prompts before overwriting (idempotent re-runs are safe).
2. Generates the Tauri updater key pair via `tauri signer generate`
(interactive passphrase prompt), substitutes the new public key into
`tauri.conf.json`, and pushes the private key + passphrase to GitHub.
3. For each Apple secret, walks the user through the manual step (Keychain
Access export, App Store Connect API key download), waits for the
resulting file, base64-encodes, and pushes to GitHub.
4. Detects existing secrets and prompts before overwriting (idempotent
re-runs are safe).
5. Verifies each secret was set successfully via `gh secret list`.
6. Prints a final checklist of manual one-time steps that can't be scripted (DNS record for `stint.reyem.tech`, creating the empty `reyemtech/homebrew-tap` repo, registering Apple Notary credentials at appleid.apple.com).

Not scripted because they're too security-sensitive to automate:

- Generating the Apple Developer ID Application cert (must happen in Xcode → Settings → Accounts → Manage Certificates with explicit user action).
- Creating the app-specific password at appleid.apple.com (Apple does not expose an API for this).
- Reviewing the public key against the committed `tauri.conf.json` before merging the first release.

The script is run once per maintainer setup, not per release. It's also the recovery tool when rotating any of the credentials (re-run with the specific secret name as an arg: `./bootstrap-secrets.sh APPLE_PASSWORD`).
6. Prints a final checklist of one-time manual steps that can't be
scripted (DNS record for `stint.reyem.tech`, creating the empty
`reyemtech/homebrew-tap` repo).

Not scripted because they're too security-sensitive to automate or have no
API:

- Generating the Apple Developer ID Application cert (Xcode → Settings →
Accounts → Manage Certificates with explicit user action).
- Creating the App Store Connect API key (Apple gates key creation behind
the App Store Connect web UI; no API exists). The script does handle
uploading the resulting `.p8`.
- Reviewing the new Tauri public key against the committed
`tauri.conf.json` before merging the first release.

The script runs once per maintainer setup. For ongoing rotation, use
`rotate-key.sh` (see "Key rotation runbook" below), which wraps
`bootstrap-secrets.sh` with guided pre-rotation walkthroughs and
post-rotation verification.

### Key rotation runbook

Lives at `docs/runbooks/release-key-rotation.md`. Covers:

- **`APPLE_PASSWORD` (annual)** — generate new app-specific password at appleid.apple.com, swap secret.
- **`APPLE_CERTIFICATE` (annual)** — generate new Developer ID cert in Xcode → Keychain Access, export as `.p12`, base64-encode, swap secret + `APPLE_SIGNING_IDENTITY`.
- **`TAURI_SIGNING_PRIVATE_KEY` (only if compromised)** — generate new key, ship a brew-only release telling users to reinstall manually, then rotate. Public key in `tauri.conf.json` changes; existing installs lose auto-update until manual reinstall.
Lives at `docs/runbooks/release-key-rotation.md`. Each rotation is driven by
`scripts/release/rotate-key.sh <subcommand>`, which walks unavoidable
manual steps with explicit checkpoints, delegates upload to
`bootstrap-secrets.sh`, and verifies the secret's `updatedAt` timestamp
changed.

- **App Store Connect API key (`app-store-connect-key`)** — rotates the
three `APP_STORE_CONNECT_*` secrets. Creating the key is web-UI-only
(Apple exposes no API for that); everything else is scripted.
- **Apple cert (`apple-cert`)** — rotates the three Apple cert secrets.
Cert generation happens in Xcode → Keychain Access; export + upload +
verify are scripted.
- **Tauri key (`tauri-key`)** — rotates the updater signing key. Requires
a literal "I understand auto-update will break" confirmation. Generates
the new keypair, patches `tauri.conf.json`, uploads the secret. Existing
installs lose auto-update until manual reinstall.

## 5. Homebrew cask + tap repo

Expand Down Expand Up @@ -608,8 +646,9 @@ pnpm-lock.yaml # already exists at root; semrelease d
.releaserc.json # semantic-release config
scripts/release/
bootstrap-secrets.sh # interactive walkthrough for §4 secret setup
rotate-key.sh # guided rotation wrapper around bootstrap-secrets.sh
bump-versions.sh
notarize.sh
notarize.sh # App Store Connect API key auth
generate-latest-json.sh
render-install-script.sh
update-cask.sh
Expand Down
Loading
Loading