ci: enforce the commit format CLAUDE.md said was enforced
Build & publish Arch package / arch-package (push) Successful in 2m3s
CI / check (push) Successful in 2m42s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Failing after 2m43s

CLAUDE.md has claimed since the file was written that commitlint gates
the commit format in CI and that semantic-release runs off it. There was
no commitlint config, no workflow running one, and nothing invoking
.releaserc.yml — so the first thing every contributor and every agent
reads about this repo was false in two places.

scripts/commit-check.sh is the smaller honest answer: the grammar is one
regex, and commitlint would mean a Node dependency tree at the root of a
Go repo to run it. It is a commit-msg hook locally and a CI step over
every commit in a push, and its type list is .releaserc.yml's so the
check and the release rules cannot drift.

The semantic-release half is recorded as configured-but-not-wired rather
than implied to run.
This commit is contained in:
2026-08-12 10:44:06 -04:00
parent 3269da3e92
commit 9f03b3ff94
6 changed files with 153 additions and 5 deletions
+18
View File
@@ -88,6 +88,24 @@ jobs:
# has to be willing to operate on a directory it does not own. # has to be willing to operate on a directory it does not own.
git config --global --add safe.directory /src git config --global --add safe.directory /src
# Conventional Commits. `.releaserc.yml` has always derived the
# version from the commit type; until now nothing checked that the
# type was one it recognises, so a malformed subject silently meant
# "no release". BEFORE is the push's previous tip and is absent or
# all-zeros for a new branch, in which case only the tip is linted.
- name: Commit messages
working-directory: /src
env:
BEFORE: ${{ github.event.before }}
run: |
set -eu
if [ -n "${BEFORE:-}" ] && [ "${BEFORE#0000000}" = "$BEFORE" ] \
&& git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
make commit-check RANGE="$BEFORE..$SHA"
else
make commit-check
fi
- name: Go toolchain - name: Go toolchain
run: | run: |
set -eu set -eu
+5
View File
@@ -128,6 +128,11 @@ first four are lefthook hooks, so skipping them locally only defers the
failure; the typecheck is a hook too but only CI runs it over the test failure; the typecheck is a hook too but only CI runs it over the test
tree, which is where it has actually broken. tree, which is where it has actually broken.
The **message** is gated too: `make commit-check` (a `commit-msg` hook,
and a CI step over every commit in a push) rejects a subject that is not
`type(scope): subject`, is over 72 chars, or ends with a period. A
`--no-verify` commit skips it locally and meets it in CI.
Two things about the e2e tier that are not obvious until they bite. Two things about the e2e tier that are not obvious until they bite.
**The 36 specs share one backend process in file order**, so a spec **The 36 specs share one backend process in file order**, so a spec
that leaves the app somewhere passes alone and fails the suite — leave that leaves the app somewhere passes alone and fails the suite — leave
+20 -4
View File
@@ -41,6 +41,7 @@ make ui-visual # Same, including toMatchScreenshot comparisons
make ui-setup # Install the Vitest provider's own Chromium (once) make ui-setup # Install the Vitest provider's own Chromium (once)
make bindings-check # Fail if frontend/wailsjs is stale vs the Go bindings make bindings-check # Fail if frontend/wailsjs is stale vs the Go bindings
make skill-check # Fail if .pi/ documents a make target that doesn't exist make skill-check # Fail if .pi/ documents a make target that doesn't exist
make commit-check # Fail if a commit subject is not a Conventional Commit
make lint # golangci-lint v2 (strict), all three build configurations make lint # golangci-lint v2 (strict), all three build configurations
make test # All tests with race detector, all three build configurations make test # All tests with race detector, all three build configurations
make vulncheck # govulncheck for CVEs make vulncheck # govulncheck for CVEs
@@ -636,7 +637,21 @@ Pre-commit hooks verify generated code is fresh — always run `make generate` a
- **Go**: golangci-lint v2 with strict linters including `err113` (static errors), `nlreturn`, `wsl_v5` (whitespace), `godot` (comment periods), `sloglint`, `perfsprint`. Imports grouped: stdlib → third-party → `yellowjacket/...` (enforced by gci). - **Go**: golangci-lint v2 with strict linters including `err113` (static errors), `nlreturn`, `wsl_v5` (whitespace), `godot` (comment periods), `sloglint`, `perfsprint`. Imports grouped: stdlib → third-party → `yellowjacket/...` (enforced by gci).
- **TypeScript**: Strict mode, no implicit any, no unused locals/parameters. - **TypeScript**: Strict mode, no implicit any, no unused locals/parameters.
- **Commits**: Conventional commits format (enforced by commitlint in CI). Semantic release uses these for versioning. - **Commits**: Conventional Commits, enforced by `scripts/commit-check.sh` —
a lefthook `commit-msg` hook locally, and a CI step over every commit in
a push. It is twenty lines of shell rather than commitlint, because the
grammar is one regex and commitlint would mean a Node dependency tree at
the root of a Go repo. **Its type list is `.releaserc.yml`'s**; keep the
two in step or semantic-release will decline to release something the
check accepted.
`.releaserc.yml` is a complete semantic-release config that **nothing
currently runs** — no workflow invokes it, and `CHANGELOG.md` is not
being written by it. That is deliberate for now (wiring it means pushing
tags, committing a changelog back, and interacting with the three
publish workflows); it is recorded here rather than implied, because
this file claimed for five phases that commitlint gated CI and that
semantic release ran, and neither was true.
## Testing ## Testing
@@ -656,9 +671,10 @@ push was healthy.
Two jobs, both in an `ubuntu:24.04` container: Two jobs, both in an `ubuntu:24.04` container:
- **`check`** — no display: `make lint` and `make test` (three build - **`check`** — no display: `make commit-check` over the push's commits,
configurations each), `tsc --noEmit`, `make ui-test`, `make lint` and `make test` (three build configurations each),
`make bindings-check`, `make skill-check`. `tsc --noEmit`, `make ui-test`, `make bindings-check`,
`make skill-check`.
- **`e2e`** — under Xvfb and a private D-Bus: fixtures, a seed built by - **`e2e`** — under Xvfb and a private D-Bus: fixtures, a seed built by
running the app, `make dev-headless`, then the Playwright suite running the app, `make dev-headless`, then the Playwright suite
against **both** Chromium and WebKit. Playwright's Linux WebKit links against **both** Chromium and WebKit. Playwright's Linux WebKit links
+6 -1
View File
@@ -113,6 +113,11 @@ bindings-check: ## Fail if frontend/wailsjs is stale against the Go bindings
skill-check: ## Fail if .pi/ documents a make target that does not exist skill-check: ## Fail if .pi/ documents a make target that does not exist
@./scripts/skill-check.sh @./scripts/skill-check.sh
# Conventional Commits, which CLAUDE.md claimed CI enforced for a long
# time before anything did. RANGE=A..B lints a push; bare lints HEAD.
commit-check: ## Fail if a commit subject is not a Conventional Commit
@./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE))
bindings: ## Regenerate frontend/wailsjs from the bound Go structs bindings: ## Regenerate frontend/wailsjs from the bound Go structs
go tool wails generate module -tags webkit2_41 go tool wails generate module -tags webkit2_41
@chmod 644 frontend/wailsjs/runtime/runtime.js \ @chmod 644 frontend/wailsjs/runtime/runtime.js \
@@ -123,7 +128,7 @@ bindings: ## Regenerate frontend/wailsjs from the bound Go structs
sandbox-seed sandbox-seed-bulk sandbox-seeds e2e e2e-setup e2e-report \ sandbox-seed sandbox-seed-bulk sandbox-seeds e2e e2e-setup e2e-report \
perf perf-compare \ perf perf-compare \
ui-test ui-watch ui-visual ui-visual-update ui-setup \ ui-test ui-watch ui-visual ui-visual-update ui-setup \
bindings bindings-check skill-check bindings bindings-check skill-check commit-check
# Base directory for fresh-install sandboxes. Deliberately NOT $TMPDIR: # Base directory for fresh-install sandboxes. Deliberately NOT $TMPDIR:
# on most Linux distros /tmp is tmpfs (RAM-backed) and only a few GB, so # on most Linux distros /tmp is tmpfs (RAM-backed) and only a few GB, so
+7
View File
@@ -2,6 +2,13 @@
# Install with: lefthook install # Install with: lefthook install
# Docs: https://github.com/evilmartians/lefthook # Docs: https://github.com/evilmartians/lefthook
# Conventional Commits. CI runs the same script over every commit in a
# push, so skipping this locally only defers the failure.
commit-msg:
commands:
commit-check:
run: ./scripts/commit-check.sh {1}
pre-commit: pre-commit:
parallel: true parallel: true
commands: commands:
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
#
# Conventional Commits, enforced. CLAUDE.md has claimed for a long time
# that commitlint gates this in CI; there was no config and no workflow
# running one, so the claim was a lie in the file every contributor and
# every agent reads first. This is the smaller of the two honest
# answers to that: the grammar is twenty lines of shell, and adding
# commitlint would mean a Node dependency tree at the root of a Go repo
# purely to regex one line.
#
# The type list is the one .releaserc.yml's commit-analyzer knows about
# — keep the two in step, or semantic-release will silently decline to
# release something this accepts.
#
# Usage:
# scripts/commit-check.sh lint HEAD's message
# scripts/commit-check.sh <file> lint a message file (commit-msg hook)
# scripts/commit-check.sh --range A..B lint every commit in a range
set -euo pipefail
cd "$(dirname "$0")/.."
TYPES='build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test'
MAX_SUBJECT=72
# A subject is `type(optional-scope)!: text`. The `!` is Conventional
# Commits' breaking-change marker and is allowed with or without a scope.
SUBJECT_RE="^(${TYPES})(\([a-z0-9._/-]+\))?!?: .+$"
fail=0
check_subject() {
local subject="$1" label="$2"
# Exemptions, all of them things git or a tool writes rather than a
# person: merges, reverts of a revert, and the autosquash markers,
# which carry the *original* subject and are rewritten by the rebase
# that consumes them.
case "$subject" in
Merge\ * | Revert\ * | fixup!* | squash!* | amend!*) return 0 ;;
esac
if ! printf '%s' "$subject" | grep -qE "$SUBJECT_RE"; then
echo "commit-check: $label" >&2
echo " $subject" >&2
echo " is not 'type(scope): subject'." >&2
echo " Types: $(printf '%s' "$TYPES" | tr '|' ' ')" >&2
fail=1
return 0
fi
if [ "${#subject}" -gt "$MAX_SUBJECT" ]; then
echo "commit-check: $label" >&2
echo " $subject" >&2
echo " subject is ${#subject} chars; the limit is ${MAX_SUBJECT}." >&2
fail=1
fi
# godot for commits: the Go side of this repo lints comments for a
# trailing period, and a subject is the one line that must not have
# one.
case "$subject" in
*.)
echo "commit-check: $label" >&2
echo " $subject" >&2
echo " subject ends with a period." >&2
fail=1
;;
esac
}
case "${1:-}" in
--range)
range="${2:?--range needs A..B}"
count=0
while IFS= read -r line; do
[ -n "$line" ] || continue
sha="${line%% *}"
check_subject "${line#* }" "$sha"
count=$((count + 1))
done <<<"$(git log --no-merges --format='%H %s' "$range")"
[ "$fail" -eq 0 ] && echo "commit-check: $count commits in $range, all well-formed"
;;
"")
check_subject "$(git log -1 --format='%s')" "HEAD"
[ "$fail" -eq 0 ] && echo "commit-check: HEAD is well-formed"
;;
*)
# The commit-msg hook hands us a file that also contains the body and
# git's own comment lines; the subject is its first line.
check_subject "$(head -n 1 "$1")" "$1"
[ "$fail" -eq 0 ] && echo "commit-check: message is well-formed"
;;
esac
exit "$fail"