diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cda0bfd..f1b6b32 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.pi/skills/yellowjacket-dev/SKILL.md b/.pi/skills/yellowjacket-dev/SKILL.md index 3f8e265..ee2ea14 100644 --- a/.pi/skills/yellowjacket-dev/SKILL.md +++ b/.pi/skills/yellowjacket-dev/SKILL.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 1b004b6..d9b2280 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Makefile b/Makefile index 894e297..f26e82f 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/lefthook.yml b/lefthook.yml index e6202fa..dc64500 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -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: diff --git a/scripts/commit-check.sh b/scripts/commit-check.sh new file mode 100755 index 0000000..79509e6 --- /dev/null +++ b/scripts/commit-check.sh @@ -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 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"