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.
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
run: |
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
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.
**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
+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 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 commit-check # Fail if a commit subject is not a Conventional Commit
make lint # golangci-lint v2 (strict), all three build configurations
make test # All tests with race detector, all three build configurations
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).
- **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
@@ -656,9 +671,10 @@ push was healthy.
Two jobs, both in an `ubuntu:24.04` container:
- **`check`** — no display: `make lint` and `make test` (three build
configurations each), `tsc --noEmit`, `make ui-test`,
`make bindings-check`, `make skill-check`.
- **`check`** — no display: `make commit-check` over the push's commits,
`make lint` and `make test` (three build configurations each),
`tsc --noEmit`, `make ui-test`, `make bindings-check`,
`make skill-check`.
- **`e2e`** — under Xvfb and a private D-Bus: fixtures, a seed built by
running the app, `make dev-headless`, then the Playwright suite
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
@./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
go tool wails generate module -tags webkit2_41
@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 \
perf perf-compare \
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:
# 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
# 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:
parallel: true
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"