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
59 changes: 37 additions & 22 deletions .claude/skills/check/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,17 @@ What you CAN run there is the portable subset:
scripts/check.sh --portable
```

It runs the repo-integrity guards (dependencies, sound catalog, site) then
It runs the repo-integrity guards (dependencies, sound catalog, site, shell
portability) then
actionlint / zizmor / prettier / xmllint / markdownlint / shellcheck / shfmt /
ruff (lint + format check) / pytest over `evals/` / `release.test.sh` (plus `swift-format lint` and `swiftlint lint` if Linux
builds are on `PATH` — under the default web network policy they are not).
That fully verifies docs, site, scripts, eval, and workflow changes. It is **not**
"green" in the CI sense: the entire Swift side is skipped, and the closing
line says so. For Swift changes, push and watch `check.yml` instead. In
line says so. For Swift changes, push and watch `check.yml` instead — its
`compile` job reports a broken test build in ~2 minutes, and `format-patch`
publishes the exact `swift-format` reflow as an artifact so you don't have to
reproduce it by hand. In
Claude Code on the web, the `SessionStart` hook installs the portable
linters automatically.

Expand All @@ -54,32 +58,43 @@ scripts/check.sh
It runs, in order (each tool skipped with a note if absent — but on a configured
Mac they're all present, so don't treat a skip as a pass):

Everything source-only runs first, then everything that builds. That ordering is
deliberate: reversed, a compile error means the cheap checks are
never reached and their findings arrive on the next 11-minute run instead.

1. repo-integrity guards: no external SPM dependencies; sound-catalog
integrity (every `SoundPackCatalog` voice has both cue files, no orphans, no
duplicate or reserved ids); and site integrity (`scripts/check-site.sh` —
the Pages site's local references, `#fragment`s, per-element hygiene,
`CNAME` agreement with canonical/og:url/sitemap/robots, CSS `url()`, and
no unreferenced assets). All run in `--portable` too
2. `swift test` with `-warnings-as-errors`
3. engine line-coverage gate (≥80%, `Tests/` excluded — see `MIN_COVERAGE`)
4. ThreadSanitizer + AddressSanitizer test passes
5. xcodegen drift check (regenerating must not change the committed `.pbxproj`)
6. codesign-skipped app build (warnings-as-errors)
7. **CI only:** `scripts/uitest.sh` (the XCUITest bundle) and `scripts/leaks.sh`
(whole-app leak check). Both drive the real app and seize the keyboard and
screen, so a local run skips them with a note and the closing line says
`ok (UI suite + leak scan NOT run …)`. That skip is by design — CI on
`macos-26` runs them on every PR and is the authority on them. To run them on
a Mac anyway: `BLURT_INTEGRATION_TESTS=1 scripts/check.sh`, or invoke the two
scripts directly. Expect to lose the machine for a few minutes if you do.
8. `swift-format lint --strict`
9. `swiftlint lint --strict` (warnings are failures), `swiftlint analyze`
(unused imports), `periphery scan --strict`
10. actionlint / zizmor (workflow security) / prettier / xmllint / markdownlint /
shellcheck / shfmt --diff
11. `ruff format --check` + `ruff check` over `evals/`, then `pytest`
over `evals/dictation-prompt/test_eval.py`
12. `release.test.sh`
2. shell portability (`scripts/check-portability.sh`): GNU-only idioms in
`scripts/*.sh` and `.claude/hooks/*.sh`, which run on BSD userland (Mac, CI)
as well as GNU (Linux sandbox). `--portable` too
3. `swift-format lint --strict`, then `swiftlint lint --strict` (warnings are
failures) — both source-only
4. actionlint / zizmor (workflow security) / prettier / xmllint / markdownlint /
shellcheck / shfmt --diff
5. `ruff format --check` + `ruff check` over `evals/`, then `pytest`
over `evals/dictation-prompt/test_eval.py`, then `release.test.sh`

Steps 1–5 are the `--portable` subset. Everything below needs a macOS toolchain:

6. `swift test` with `-warnings-as-errors`
7. engine line-coverage gate (≥80%, `Tests/` excluded — see `MIN_COVERAGE`)
8. ThreadSanitizer + AddressSanitizer test passes
9. xcodegen drift check (regenerating must not change the committed `.pbxproj`)
10. codesign-skipped app build (warnings-as-errors)
11. **CI only:** `scripts/uitest.sh` (the XCUITest bundle) and `scripts/leaks.sh`
(whole-app leak check). Both drive the real app and seize the keyboard and
screen, so a local run skips them with a note and the closing line says
`ok (UI suite + leak scan NOT run …)`. That skip is by design — CI on
`macos-26` runs them on every PR and is the authority on them. To run them on
a Mac anyway: `BLURT_INTEGRATION_TESTS=1 scripts/check.sh`, or invoke the two
scripts directly. Expect to lose the machine for a few minutes if you do.
12. `swiftlint analyze` (unused imports — needs the app build's compiler log) and
`periphery scan --strict` (unused declarations — runs its own xcodebuild).
The only two that can't move earlier.

## Interpreting the result

Expand Down
110 changes: 109 additions & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,110 @@ jobs:
CI: "true"
run: scripts/check.sh

# Fast-fail typecheck. `check` above is the authority on green and this job can
# only go red where `check` would too — so it is deliberately NOT required, and
# `gate` ignores it.
#
# It exists for latency. The web sandbox has no Swift toolchain (macOS-only
# project; the Linux toolchain isn't reachable under the default network
# policy), so CI is the first compiler to see any Swift change, and a test
# target that doesn't build is the single most common way a PR here goes red —
# PRs #122 and #124 each burned three consecutive red runs on one compile error
# at a time. `check` reaches that error only after the engine suite starts, and
# a full run is ~11 minutes. Building just the engine and its tests reports the
# same error in ~2, which is the whole point.
compile:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: macos-26
timeout-minutes: 15

steps:
- name: Checkout blurt
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: blurt
persist-credentials: false

- name: Build the engine and its tests
working-directory: blurt
# --build-tests compiles the test targets without running them, so this is
# a typecheck of Tests/ as well as Sources/. -warnings-as-errors matches
# what check.sh passes, so a warning can't pass here and fail there. No
# xcodegen, no app build, no sanitizers, no coverage — those stay in
# `check`, and duplicating any of them here would spend the latency this
# job is meant to save.
run: swift build --build-tests -Xswiftc -warnings-as-errors

# What swift-format would change, as an applicable patch. Formatting is the
# other half of what CI is sole authority over here, and `check` can only say
# *that* a file is misformatted (`swift-format lint --strict`) — leaving the
# author to reproduce the reflow by hand, blind, with no formatter on the
# machine they're editing from. Several PRs paid for that in commits of the form
# "Format X the way swift-format wants".
#
# So: run the formatter for real and publish the diff. Read-only and advisory —
# it never fails the build (`check` already does that) and never pushes, which
# keeps this job free of a writable token while it holds PR code. Applying the
# patch stays a deliberate act by the author:
# gh run download <id> -n swift-format-patch && git apply swift-format.patch
format-patch:
needs: changes
if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true'
runs-on: macos-26
timeout-minutes: 10

steps:
- name: Checkout blurt
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: blurt
persist-credentials: false

- name: Format every tracked Swift file and diff the result
id: diff
working-directory: blurt
# Same file set and same tool as check.sh's lint step (xcrun swift-format,
# bundled with Xcode), so the patch is exactly what would make that step
# pass. `git diff` after an in-place format is the patch; an empty diff
# means the tree is already clean and there is nothing to publish.
run: |
set -euo pipefail
git ls-files -z -- '*.swift' | xargs -0 xcrun swift-format format --in-place
if git diff --quiet; then
echo "swift-format would change nothing." >>"$GITHUB_STEP_SUMMARY"
echo "dirty=false" >>"$GITHUB_OUTPUT"
exit 0
fi
echo "dirty=true" >>"$GITHUB_OUTPUT"
git diff >"$RUNNER_TEMP/swift-format.patch"
{
echo "### swift-format would reformat these files"
echo
echo '```console'
git diff --stat
echo '```'
echo
echo "Apply with \`git apply swift-format.patch\` from the artifact below."
echo
echo "<details><summary>Full patch</summary>"
echo
echo '```diff'
cat "$RUNNER_TEMP/swift-format.patch"
echo '```'
echo
echo "</details>"
} >>"$GITHUB_STEP_SUMMARY"

- name: Upload the patch
if: steps.diff.outputs.dirty == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: swift-format-patch
path: ${{ runner.temp }}/swift-format.patch
if-no-files-found: error
retention-days: 14

# Every PR gets an installable build of the change, so a reviewer can try it
# instead of reading the diff and imagining it. Deliberately NOT part of the
# required gate (`gate` below ignores it): it builds the same sources `check`
Expand All @@ -119,8 +223,12 @@ jobs:
# gets a read-only token no matter what `permissions:` says, so that case has
# to happen in a workflow_run job. Both post the same marked comment, so
# whichever runs, a PR ends up with exactly one link.
# `needs: compile` so a PR whose sources don't build doesn't also pay for a
# second macOS job to discover that. It costs the ~2 minutes `compile` takes on
# a green run, which is cheap against a 30-minute timeout spent on code that was
# never going to link.
dev-build:
needs: changes
needs: [changes, compile]
if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true'
runs-on: macos-26
timeout-minutes: 30
Expand Down
Loading
Loading