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
17 changes: 8 additions & 9 deletions .claude/skills/project-guardrails/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,22 +141,21 @@ only one of stt_prompt or prompt; they are the same field`, before the audio is
- The dictation trigger is a **single lone modifier** (right ⌘ default), home-
grown via `CGEventTap` + `DictationKeyGate`. No `KeyboardShortcuts` package, no
key+modifier chord.
- **Updates are download-only** — check → open the DMG in the browser → the user
installs it. The `mxcl/AppUpdater` dependency and its in-place self-updater
were removed; don't reintroduce a self-replacing install path, a timer-driven
poll, or anything that installs on the user's behalf. Checking automatically
once at launch on a configured app (`AutomaticUpdateCheck`, ≤ once a day,
silent unless a newer release exists) is the one automatic part, and it still
ends in the same Download/Later alert. Extend `UpdateChecker` /
`UpdateCheckModel`.
- **Updates go through Sparkle** (`UpdaterModel`, app-side only) — an
EdDSA-signed `appcast.xml` published with each release, Sparkle's own UI,
daily checks. Don't hand-roll a second updater or a GitHub-API checker beside
it, don't put update code back in the engine, and don't start the updater in
anything but the shipping bundle id (a dev build must never replace itself
with the release). Silent install stays opt-in.

## Build / tests

- **Don't hand-edit `App/Blurt/Blurt.xcodeproj/project.pbxproj`** — it's
generated from `project.yml`; edit that and run `xcodegen generate`. check.sh
fails on pbxproj drift (a PreToolUse hook also blocks edits to it).
- The engine has **no external SPM dependencies** (Foundation/Security/
AVFoundation/CoreAudio only). Don't add one to `Sources/BlurtEngine/`.
AVFoundation/CoreAudio only). Don't add one to `Sources/BlurtEngine/`. The app
carries exactly one remote package, Sparkle; check.sh rejects any other.
- Unit tests use **Swift Testing**, not XCTest (the `BlurtUITests` XCUITest
bundle is the one exception — XCUIAutomation requires XCTest). **Never touch
the real Keychain in tests** — `APIKeyStore` is the production item; use an
Expand Down
37 changes: 29 additions & 8 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: release
description: Cut and publish a Blurt release (build, sign, notarize, staple, DMG, GitHub). Use when the user asks to ship/release a new version. User-invoked only — it has real side effects (notarization, git tags, GitHub release).
description: Cut and publish a Blurt release (bump, release notes, build, sign, notarize, staple, DMG, Sparkle appcast, GitHub). Use when the user asks to ship/release a new version. User-invoked only — it has real side effects (notarization, git tags, GitHub release).
disable-model-invocation: true
---

Expand All @@ -14,8 +14,8 @@ publishing is hard to undo.
## Preconditions (verify first)

- `main` is up to date and green; releases ship from `main`.
- The `release-build` environment has the signing + notary secrets, and
`release-publish` has required reviewers. See
- The `release-build` environment has the signing + notary secrets and
`SPARKLE_ED_PRIVATE_KEY`, and `release-publish` has required reviewers. See
[`RELEASE.md`](../../../RELEASE.md) — that is the source of truth for custody.
Without them the `release` workflow fails on its secrets-presence step.
- Check the highest existing `vX.Y.Z` tag, not just the version on `main`: an
Expand All @@ -39,21 +39,37 @@ publishing is hard to undo.
bump onto that same branch. Dispatching still works if you do have the
permission, and only that path accepts an empty version (next patch).

2. **Open the PR** for that branch and hand it to the user to merge. The workflow
2. **Get the release notes written.** The bump also commits
`release-notes/X.Y.Z.md`: the commits since the last release in an HTML
comment, and a `TODO` where the notes go. They are what Sparkle's update
window shows every user, `check` fails while the `TODO` is there, and the
release build refuses to run without them — there is no automatic fallback.
Read RELEASE.md → Release notes → **Writing them** first and follow it:
translate each commit into what a user notices, drop what they wouldn't
(refactors, CI, docs, reverted work), order by how noticeable it is, and match
the length and voice of the model notes there. Fewer bullets is better than
padding. Before drafting, skim the merged PRs behind the subjects
(`gh pr view <n>`) when a subject doesn't say what the user sees. Run
`scripts/check-release-notes.sh`, then **show the draft to the user — with
the commits you left out and why — and get their go-ahead before committing
it** to the release branch. Their judgment is the point of the rule.
3. **Open the PR** for that branch and hand it to the user to merge. The workflow
does not open it, on purpose: a PR created by `GITHUB_TOKEN` never triggers
`check` and so can never merge. Opening it from here works — an agent's own
credentials are not `GITHUB_TOKEN`.
3. **Merging it starts `release`** — no dispatch. `release.yml` triggers on a
4. **Merging it starts `release`** — no dispatch. `release.yml` triggers on a
push to `main` touching `project.yml`; its `resolve` job confirms the version
changed and isn't already tagged, then the `build` job does the whole Apple
path (`xcodebuild` Release → sign nested code → notarize → staple → DMG →
verify) and uploads the artifacts. Dispatch `release` by hand only to re-run
verify → EdDSA-sign the DMG → write `appcast.xml` with the notes) and uploads
the artifacts. Dispatch `release` by hand only to re-run
a failed build, to `republish`, or for a non-`main` dry run.
4. **Hand the ship gate to the user** — the `publish` job parks on the
5. **Hand the ship gate to the user** — the `publish` job parks on the
`release-publish` environment. Tell them to download the DMG from the run's
artifacts, install it, and approve once it works. Nothing is rebuilt after
approval, so what they test is what ships. `release-publish.sh` then tags,
pushes, and publishes.
pushes, and publishes — and flipping the release to latest is what moves
the Sparkle feed, so installed copies see it within a day.

**Never approve the `release-publish` deployment yourself**, even though the API
allows it. The gate exists so a human confirms the real artifact reached users in
Expand Down Expand Up @@ -83,6 +99,11 @@ There is no local orchestrator script — the workflows are the only path.
- Running the scripts directly on a Mac still works for debugging a single stage
(`release-build.sh --skip-checks`, `release-install.sh`), and uses the local
locked signing keychain rather than the CI secrets.
- **Rehearse updater changes before shipping them.** Any change to Sparkle, the
feed, signing, or the appcast should first pass the staging rehearsal
(RELEASE.md → Rehearsing an update): `release` dispatched with `staging`
checked builds for the `sparkle-staging` pre-release, and `publish` is skipped.
A shipped build with a broken updater can't be fixed by an update.
- A `release` dispatch from a non-`main` ref is build-only (publish is skipped),
but it only starts if `release-build`'s deployment-branch policy allows that
ref — with the environment restricted to `main`, there is no branch dry run.
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release-bump.yml
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ jobs:
echo
echo "[**Open the pull request**](https://github.com/$REPO/compare/main...release/v$VERSION?expand=1)"
echo
echo "First write the update-window notes: replace the TODO in"
echo "\`release-notes/$VERSION.md\` on that branch. \`check\` fails until you do,"
echo "and the release build refuses to run without them (RELEASE.md → Release notes)."
echo
echo "Merging it starts the \`release\` workflow by itself — the version landing on"
echo "\`main\` is the trigger, so there is nothing left to dispatch. Publishing then"
echo "waits on the \`release-publish\` approval once you've tested the DMG."
Expand Down
58 changes: 56 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ on:
description: "Skip the launch smoke test (use if the runner has no usable GUI session)"
type: boolean
default: false
staging:
description: "Update rehearsal: build for the sparkle-staging feed and stage it there instead of publishing (RELEASE.md → Rehearsing an update)"
type: boolean
default: false
push:
branches: [main]
# Narrow on purpose. project.yml is the only file a version bump touches
Expand Down Expand Up @@ -169,6 +173,11 @@ jobs:
# job checks out the same sha, so the tag can never land on a commit
# other than the one that was actually built.
ref: ${{ github.sha }}
# History and tags, for one path only: a staging build with no
# release-notes file falls back to the commits since the previous
# release tag (release-build.sh fails fast on a shallow clone there).
# A release reads its hand-written notes and needs neither.
fetch-depth: 0
persist-credentials: false

- name: Guard the release version
Expand Down Expand Up @@ -196,9 +205,11 @@ jobs:
P12_PASSWORD: ${{ secrets.SIGNING_P12_PASSWORD }}
NOTARY_KEY_P8: ${{ secrets.NOTARY_KEY_P8_BASE64 }}
NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }}
SPARKLE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
set -euo pipefail
missing=""
[ -n "$SPARKLE_KEY" ] || missing="$missing SPARKLE_ED_PRIVATE_KEY"
[ -n "$P12" ] || missing="$missing SIGNING_P12_BASE64"
[ -n "$P12_PASSWORD" ] || missing="$missing SIGNING_P12_PASSWORD"
[ -n "$NOTARY_KEY_P8" ] || [ -n "$NOTARY_APPLE_ID" ] \
Expand Down Expand Up @@ -242,16 +253,21 @@ jobs:
BLURT_NOTARY_KEY_P8_BASE64: ${{ secrets.NOTARY_KEY_P8_BASE64 }}
BLURT_NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }}
BLURT_NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }}
# Sparkle's EdDSA private key (base64, as `generate_keys -x` exports
# it). Signs the DMG for the appcast; handed to sign_update on stdin.
BLURT_SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
# Both are dispatch-only escape hatches, so both are empty (and the
# full checks run) on the merged-bump path. That is the right default:
# nobody is standing there to judge whether skipping was safe.
SKIP_CHECKS: ${{ inputs.skip_checks }}
SKIP_SMOKE: ${{ inputs.skip_smoke }}
STAGING: ${{ inputs.staging }}
run: |
set -euo pipefail
args=()
if [ "$SKIP_CHECKS" = "true" ]; then args+=(--skip-checks); fi
if [ "$SKIP_SMOKE" = "true" ]; then args+=(--skip-smoke); fi
if [ "$STAGING" = "true" ]; then args+=(--staging); fi
scripts/release-build.sh ${args[@]+"${args[@]}"}

- name: Upload release artifacts
Expand All @@ -264,6 +280,7 @@ jobs:
blurt/build/release/Blurt-*.dmg
blurt/build/release/Blurt-*.app.dSYM.zip
blurt/build/release/SHA256SUMS
blurt/build/release/appcast.xml
blurt/build/release/build-info.txt
blurt/build/release/notary-*-log.json
if-no-files-found: error
Expand All @@ -277,8 +294,10 @@ jobs:
publish:
needs: [resolve, build]
# Releases ship from main. A dispatch from any other ref stops after the
# build job, which makes this workflow safe to exercise as a dry run.
if: github.ref == 'refs/heads/main'
# build job, which makes this workflow safe to exercise as a dry run. A
# staging build never publishes — `stage` below takes it instead (and
# release-publish.sh would refuse it anyway).
if: github.ref == 'refs/heads/main' && inputs.staging != true
runs-on: macos-26
timeout-minutes: 30
environment: release-publish
Expand Down Expand Up @@ -319,3 +338,38 @@ jobs:
args=(--yes)
if [ "$REPUBLISH" = "true" ]; then args+=(--republish); fi
scripts/release-publish.sh "${args[@]}"

# Update rehearsal: put a --staging build on the sparkle-staging prerelease,
# the feed its app polls (RELEASE.md → Rehearsing an update). Runs from any
# ref the build job could — a rehearsal lives on a release-dry-run* branch,
# temporarily allowed on release-build — and needs no approval gate:
# the prerelease is never marked latest, so no shipped copy can see it. Holds
# no signing secrets; contents: write is only for the prerelease upload.
stage:
needs: [resolve, build]
if: inputs.staging == true
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write

steps:
- name: Checkout blurt
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: blurt
ref: ${{ github.sha }}
persist-credentials: false

- name: Download release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-${{ needs.resolve.outputs.version }}
path: blurt/build/release

- name: Stage
working-directory: blurt
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: scripts/release-stage.sh
Loading
Loading