ci(commits): enforce Conventional Commits via commit-msg hook and PR gate (#70) #93

Merged
james merged 1 commit from 70-conventional-commits into main 2026-06-18 02:11:55 +00:00
Owner

Closes #70. Resolves ADR-0014's "convention isn't enforced via a commit-msg hook today" caveat.

What lands

  • lefthook.yml — new commit-msg hook (conventional). Inline regex (zero new dep). Lenient locally: allows fixup! / squash! / amend! autosquash markers and Revert "..." shapes; skips during git merge / git rebase. Rejects with a helpful message that quotes the offending subject, lists allowed types, and points at the LEFTHOOK_EXCLUDE=conventional escape.
  • .forgejo/workflows/commits.yml — new PR-only workflow. Walks git rev-list --no-merges BASE..HEAD, applies the stricter regex (no fixup/squash). Catches contributors who skipped local hooks (--no-verify, no lefthook installed, LEFTHOOK_EXCLUDE).
  • CLAUDE.mdCommit messages follow Conventional Commits bullet updated to point at both enforcement layers; Local git hooks bullet generalized so it doesn't get stale every time a new hook lands.
  • docs/ci.md — new Conventional Commits section explaining the two layers, the type list (mirroring cliff.toml exactly), why regex vs commitlint, and the bypass.

Why a regex, not commitlint

commitlint is the obvious tool but pulls ~125 transitives onto the install graph and would need a new lavamoat.allowScripts entry under ADR-0010. The regex above covers every variation cliff.toml's commit_parsers recognise; the per-commit subject is the only thing being gated, so the cost/benefit favoured zero new deps. The ticket explicitly OK'd this option. Documented in docs/ci.md as a Reconsider if condition.

Why local is lenient, CI is strict

git commit --fixup=<sha> creates a fixup! <subject> commit during autosquash workflows — these are valid in-progress commits, expected to be rebased out before push. Blocking them locally would break the workflow. Locally the hook allows them; the CI regex does not, so a fixup that leaks into a PR fails the gate.

Acceptance criteria

  • git commit -m "did a thing" is rejected locally with a message pointing at the convention doc — verified, output captured below.
  • git commit -m "feat(release): ..."-style messages succeed — verified.
  • Operational: a PR with a non-conventional commit fails the new CI check; one with conventional messages passes — verified once this PR's own CI runs.
  • Documentation in CLAUDE.md and docs/ci.md updated to point at the enforcement, not just the convention.
  • No new lavamoat.allowScripts entry needed — regex approach, zero new deps.

Local verification

Rejection path:

$ git commit -m "did a thing"
🥊 lefthook v2.1.8 hook: commit-msg
  conventional ❯
✗ Commit message doesn't match Conventional Commits.

  Got:    did a thing
  Format: <type>(<scope>)!?: <subject>
  Types:  feat, fix, perf, refactor, docs, test, build, ci, chore, revert

  See CLAUDE.md "Conventions" and docs/ci.md "Conventional Commits".
  Last-resort skip for one commit: LEFTHOOK_EXCLUDE=conventional git commit ...

exit status 1

Regex matrix (allowed forms):

✓ feat: simple
✓ fix(scope): scoped
✓ feat!: breaking
✓ feat(auth)!: scoped breaking
✓ chore: bump
✓ Revert "feat(x): undo"
✓ fixup! feat(x): in-progress
✓ squash! feat(x): in-progress
✗ did a thing
✗ Update README
✗ feature: typo in type

Composes with

  • #16 / ADR-0014 — the release pipeline. This PR closes the not enforced via a commit-msg hook caveat captured there.
  • #39 / PR #61 — lefthook itself. New hook reuses the same LEFTHOOK_EXCLUDE escape pattern as gitleaks and actionlint.
  • #88 / #89 — actionlint hook + PR check (recently merged). The commit-msg hook is the third entry in the same lefthook config; the CI workflow follows the same secrets.yml-style file-per-concern pattern.

Test plan

  • CI green: this PR's own commits pass the new gate (it's titled ci(commits): ... so it should).
  • After merge: a deliberately-bad commit on a throwaway branch trips the PR gate.
  • If the type list ever needs to grow, the three sync points (cliff.toml, lefthook.yml, commits.yml) are documented in docs/ci.md.
Closes #70. Resolves ADR-0014's "convention isn't enforced via a commit-msg hook today" caveat. ## What lands - **`lefthook.yml`** — new `commit-msg` hook (`conventional`). Inline regex (zero new dep). Lenient locally: allows `fixup!` / `squash!` / `amend!` autosquash markers and `Revert "..."` shapes; skips during `git merge` / `git rebase`. Rejects with a helpful message that quotes the offending subject, lists allowed types, and points at the `LEFTHOOK_EXCLUDE=conventional` escape. - **`.forgejo/workflows/commits.yml`** — new PR-only workflow. Walks `git rev-list --no-merges BASE..HEAD`, applies the *stricter* regex (no fixup/squash). Catches contributors who skipped local hooks (`--no-verify`, no lefthook installed, `LEFTHOOK_EXCLUDE`). - **`CLAUDE.md`** — *Commit messages follow Conventional Commits* bullet updated to point at both enforcement layers; *Local git hooks* bullet generalized so it doesn't get stale every time a new hook lands. - **`docs/ci.md`** — new *Conventional Commits* section explaining the two layers, the type list (mirroring `cliff.toml` exactly), why regex vs commitlint, and the bypass. ## Why a regex, not commitlint `commitlint` is the obvious tool but pulls ~125 transitives onto the install graph and would need a new `lavamoat.allowScripts` entry under ADR-0010. The regex above covers every variation `cliff.toml`'s `commit_parsers` recognise; the per-commit subject is the only thing being gated, so the cost/benefit favoured zero new deps. The ticket explicitly OK'd this option. Documented in `docs/ci.md` as a *Reconsider if* condition. ## Why local is lenient, CI is strict `git commit --fixup=<sha>` creates a `fixup! <subject>` commit during autosquash workflows — these are valid in-progress commits, expected to be rebased out before push. Blocking them locally would break the workflow. Locally the hook allows them; the CI regex does not, so a fixup that leaks into a PR fails the gate. ## Acceptance criteria - [x] `git commit -m "did a thing"` is rejected locally with a message pointing at the convention doc — verified, output captured below. - [x] `git commit -m "feat(release): ..."`-style messages succeed — verified. - [ ] *Operational:* a PR with a non-conventional commit fails the new CI check; one with conventional messages passes — verified once this PR's own CI runs. - [x] Documentation in CLAUDE.md and `docs/ci.md` updated to point at the enforcement, not just the convention. - [x] No new `lavamoat.allowScripts` entry needed — regex approach, zero new deps. ## Local verification Rejection path: ``` $ git commit -m "did a thing" 🥊 lefthook v2.1.8 hook: commit-msg conventional ❯ ✗ Commit message doesn't match Conventional Commits. Got: did a thing Format: <type>(<scope>)!?: <subject> Types: feat, fix, perf, refactor, docs, test, build, ci, chore, revert See CLAUDE.md "Conventions" and docs/ci.md "Conventional Commits". Last-resort skip for one commit: LEFTHOOK_EXCLUDE=conventional git commit ... exit status 1 ``` Regex matrix (allowed forms): ``` ✓ feat: simple ✓ fix(scope): scoped ✓ feat!: breaking ✓ feat(auth)!: scoped breaking ✓ chore: bump ✓ Revert "feat(x): undo" ✓ fixup! feat(x): in-progress ✓ squash! feat(x): in-progress ✗ did a thing ✗ Update README ✗ feature: typo in type ``` ## Composes with - #16 / ADR-0014 — the release pipeline. This PR closes the *not enforced via a commit-msg hook* caveat captured there. - #39 / PR #61 — lefthook itself. New hook reuses the same `LEFTHOOK_EXCLUDE` escape pattern as gitleaks and actionlint. - #88 / #89 — actionlint hook + PR check (recently merged). The `commit-msg` hook is the third entry in the same lefthook config; the CI workflow follows the same `secrets.yml`-style file-per-concern pattern. ## Test plan - [ ] CI green: this PR's own commits pass the new gate (it's titled `ci(commits): ...` so it should). - [ ] After merge: a deliberately-bad commit on a throwaway branch trips the PR gate. - [ ] If the type list ever needs to grow, the three sync points (`cliff.toml`, `lefthook.yml`, `commits.yml`) are documented in `docs/ci.md`.
ci(commits): enforce Conventional Commits via commit-msg hook and PR gate (#70)
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 14s
PR / OSV-Scanner (pull_request) Successful in 31s
Secrets / gitleaks (pull_request) Successful in 23s
PR / Static analysis (pull_request) Successful in 47s
PR / Trivy (image) (pull_request) Successful in 1m17s
PR / Lint (pull_request) Successful in 4m10s
PR / npm audit (pull_request) Successful in 4m37s
PR / Build (pull_request) Successful in 4m57s
PR / Typecheck (pull_request) Successful in 5m18s
PR / Test (postgres) (pull_request) Successful in 5m25s
PR / Test (sqlite) (pull_request) Successful in 17m46s
bab9138d4a
The release pipeline (#16 / ADR-0014) parses commits with git-cliff
into grouped release notes; non-conventional commits land in the
catch-all "Other" bucket. ADR-0014 flagged enforcement as a
follow-up; this is it.

Two layers, sharing one regex (modulo fixup/squash allowance):

- lefthook.yml commit-msg hook (`conventional`) — rejects bad
  subjects locally, with a helpful message that quotes the offending
  subject, lists allowed types, and points at the
  `LEFTHOOK_EXCLUDE=conventional` escape hatch. Lenient: allows
  `fixup!` / `squash!` / `amend!` markers so `git commit --fixup`
  workflows aren't broken; skips during `git merge` / `git rebase`.

- .forgejo/workflows/commits.yml — PR-time gate that walks
  `git rev-list --no-merges BASE..HEAD` and applies the stricter
  regex (no fixup/squash). Catches contributors who skipped the
  local hook (--no-verify, no lefthook, LEFTHOOK_EXCLUDE).

Both regexes mirror cliff.toml's allowed types exactly:
feat, fix, perf, refactor, docs, test, build, ci, chore, revert.
Format `<type>(<scope>)!?: <subject>`; `Revert "..."` also accepted
for git revert's auto-message.

Zero new deps — a small regex covers every variation cliff.toml
parses. The ticket's "zero new tool is a fine answer" option.

CLAUDE.md / docs/ci.md updated to point at the enforcement, not
just the convention.

Verified locally on this PR:
- `git commit -m "did a thing"` → rejected with the helpful message
- `git commit -m "ci(commits): wire ..."` → accepted
- Regex sanity-tested against `feat: x`, `fix(scope): x`,
  `feat!: x`, `feat(scope)!: x`, `Revert "x"`, `fixup! x`,
  `squash! x` (all pass); `did a thing`, `Update README`,
  `feature: typo` (all rejected)
james merged commit d70a557621 into main 2026-06-18 02:11:55 +00:00
james deleted branch 70-conventional-commits 2026-06-18 02:11:56 +00:00
Sign in to join this conversation.
No description provided.