build(git-hooks): enforce Conventional Commits via a hook + CI, record history decision
The convention was documented in CONTRIBUTING.md but nothing enforced it, and no decision was on record for the pre-self-hosting `Phase` history. - tools/git-hooks/commit-msg — rejects a summary that is not a Conventional Commit. tools/git-hooks/lib.sh holds the single rule (types, scope, `!`, and the merge/revert/autosquash exemptions) so the hook and CI cannot drift. - tools/git-hooks/lint-range.sh — lints a commit range with that same rule. - .forgejo/workflows/commit-lint.yml — runs it over the new commits on every push and PR, as the backstop for contributors who have not enabled the hook. - Fix the pre-commit hook, which pointed at the old build/ludic-fmt path (the toolchain moved to bin/) and so silently no-op'd; it now finds bin/ludic-fmt. - CONTRIBUTING.md — full type/scope table, the one-line enable (`git config core.hooksPath tools/git-hooks`), and a **Git history** section recording the decision: leave the pushed `Phase`-era history as-is (a rewrite is destructive and non-reversible for anyone who cloned); enforce the convention going forward; let #33's first release tag double as the clean `v0` baseline that brackets the old prefix without touching a commit. Verified: the hook accepts feat/fix/ci/refactor(!)/merge/revert and rejects "added regex" / "Fix bug" / "WIP"; lint-range passes recent history and flags the old `Phase 8b:` commit. Closes #34 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1c1192e7c0
commit
709465cdd8
6 changed files with 193 additions and 6 deletions
48
.forgejo/workflows/commit-lint.yml
Normal file
48
.forgejo/workflows/commit-lint.yml
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
name: commit-lint
|
||||||
|
|
||||||
|
# Enforce Conventional Commits in CI, as a backstop to the local commit-msg hook
|
||||||
|
# (which a contributor only gets after `git config core.hooksPath tools/git-hooks`).
|
||||||
|
# Lints every new commit's summary line against tools/git-hooks/lib.sh — the same
|
||||||
|
# rule the hook uses, so the two can never drift.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
conventional-commits:
|
||||||
|
runs-on: docker
|
||||||
|
container: node:20-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Check out with history
|
||||||
|
env:
|
||||||
|
BEFORE: ${{ github.event.before }}
|
||||||
|
BASE: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
# Full clone so both endpoints of the range are present.
|
||||||
|
git clone https://git.workshopsoft.io/workshopsoft/ludic.git .
|
||||||
|
git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}"
|
||||||
|
|
||||||
|
- name: Lint the new commits
|
||||||
|
env:
|
||||||
|
BEFORE: ${{ github.event.before }}
|
||||||
|
BASE: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
# Pick the range of *new* commits to lint:
|
||||||
|
# - pull_request: base branch .. this commit
|
||||||
|
# - push: the pushed range (event.before .. this commit)
|
||||||
|
# - new branch / unknown: just the tip commit
|
||||||
|
if [ -n "${BASE:-}" ]; then
|
||||||
|
git fetch --quiet origin "${BASE}" 2>/dev/null || true
|
||||||
|
RANGE="origin/${BASE}..${GITHUB_SHA}"
|
||||||
|
elif [ -n "${BEFORE:-}" ] && ! printf '%s' "$BEFORE" | grep -qE '^0+$'; then
|
||||||
|
RANGE="${BEFORE}..${GITHUB_SHA}"
|
||||||
|
else
|
||||||
|
RANGE="${GITHUB_SHA}~1..${GITHUB_SHA}"
|
||||||
|
fi
|
||||||
|
echo "linting range: $RANGE"
|
||||||
|
sh tools/git-hooks/lint-range.sh "$RANGE"
|
||||||
|
|
@ -67,11 +67,43 @@ The stdlib lives in the runtime (`runtime/`) and is surfaced as namespaces
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Commits:** [Conventional Commits](https://www.conventionalcommits.org) —
|
- **Commits:** [Conventional Commits](https://www.conventionalcommits.org) —
|
||||||
`type(scope): summary`, e.g. `feat(stdlib): …`, `fix(emit): …`,
|
`type(scope): summary`, with an optional `!` before the colon for a breaking
|
||||||
`ci(docs): …`, `docs(readme): …`. Keep the summary imperative and under ~72
|
change. Keep the summary imperative and under ~72 chars. The types in use:
|
||||||
chars.
|
|
||||||
|
| type | for |
|
||||||
|
|------|-----|
|
||||||
|
| `feat` | a new user-facing capability (a stdlib namespace, a language feature) |
|
||||||
|
| `fix` | a bug fix |
|
||||||
|
| `refactor` | a change that neither fixes a bug nor adds a feature |
|
||||||
|
| `perf` | a performance improvement |
|
||||||
|
| `docs` | documentation only (`docs/`, README, comments) |
|
||||||
|
| `test` | tests only |
|
||||||
|
| `build` | the build/bootstrap machinery (seed, `bin/x`, linking) |
|
||||||
|
| `ci` | CI workflows under `.forgejo/` |
|
||||||
|
| `style` | formatting/whitespace, no behaviour change |
|
||||||
|
| `chore` | routine housekeeping with no other bucket |
|
||||||
|
| `revert` | reverts a previous commit |
|
||||||
|
|
||||||
|
Common scopes: `stdlib`, `lang`, `emit`, `runtime`, `tooling`, `docs`, `repo`.
|
||||||
|
Reference the issue you close with a `Closes #NN` trailer.
|
||||||
|
|
||||||
|
- **Enforcement:** the hooks in [`tools/git-hooks/`](tools/git-hooks) enforce this
|
||||||
|
locally, and the `commit-lint` CI job is the backstop. Turn the hooks on once,
|
||||||
|
per clone:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config core.hooksPath tools/git-hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
That activates the `commit-msg` hook (rejects a non-conforming summary) and the
|
||||||
|
`pre-commit` hook (rejects unformatted Ludic). Both read the same rules CI does,
|
||||||
|
so a green local commit is a green CI run.
|
||||||
|
|
||||||
- **Formatting:** `ludic-fmt` is the source of truth (2-space indent, LF, UTF-8);
|
- **Formatting:** `ludic-fmt` is the source of truth (2-space indent, LF, UTF-8);
|
||||||
the repo `.editorconfig` mirrors it. Run `bin/ludic-fmt` on files you touch.
|
the repo `.editorconfig` mirrors it. Run `bin/ludic-fmt -w` on files you touch.
|
||||||
|
The contract CI enforces is *idempotence* — `ludic-fmt` re-run on its own output
|
||||||
|
is a no-op — which leaves deliberate hand alignment in place; it is not a
|
||||||
|
blanket `fmt(x) == x`.
|
||||||
- **Code structure:** one job per file. Split large files by concern into
|
- **Code structure:** one job per file. Split large files by concern into
|
||||||
subfolders rather than growing a single 500+-line module (see how `selfhost/`
|
subfolders rather than growing a single 500+-line module (see how `selfhost/`
|
||||||
and `runtime/` are organised).
|
and `runtime/` are organised).
|
||||||
|
|
@ -80,6 +112,19 @@ The stdlib lives in the runtime (`runtime/`) and is surfaced as namespaces
|
||||||
(`selfhost/ludicc.seed.ll`), the hand-written `runtime/native/cocoa.ll` /
|
(`selfhost/ludicc.seed.ll`), the hand-written `runtime/native/cocoa.ll` /
|
||||||
`runtime/web/wasm.ll` shims, and the legacy Python docgen (being ported).
|
`runtime/web/wasm.ll` shims, and the legacy Python docgen (being ported).
|
||||||
|
|
||||||
|
## Git history
|
||||||
|
|
||||||
|
The log has two eras: the pre-self-hosting `Phase Nx: …` / `Merge Phase Nx: …`
|
||||||
|
commits, and the Conventional Commits used since. **Decision: the old `Phase`
|
||||||
|
history stays as-is.** Rewriting already-pushed history (`filter-repo`/rebase) is
|
||||||
|
destructive and non-reversible for anyone who has cloned, and it buys little — so
|
||||||
|
we do not rewrite it. The convention is enforced *going forward* by the hook and
|
||||||
|
the `commit-lint` job (both skip merge commits, so the old merges never trip it).
|
||||||
|
|
||||||
|
When the versioning work lands (issue #33), the first release tag doubles as a
|
||||||
|
clean `v0` baseline that brackets the `Phase`-era prefix — the safe,
|
||||||
|
non-destructive version of "tidy the history" without touching a single commit.
|
||||||
|
|
||||||
## Pull requests
|
## Pull requests
|
||||||
|
|
||||||
- Base your branch on `main`.
|
- Base your branch on `main`.
|
||||||
|
|
|
||||||
25
tools/git-hooks/commit-msg
Executable file
25
tools/git-hooks/commit-msg
Executable file
|
|
@ -0,0 +1,25 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Reject a commit whose summary line is not a Conventional Commit.
|
||||||
|
#
|
||||||
|
# Enable every hook in this directory at once with:
|
||||||
|
#
|
||||||
|
# git config core.hooksPath tools/git-hooks
|
||||||
|
#
|
||||||
|
# (git then runs the hooks straight from the tree — no per-hook symlink, and new
|
||||||
|
# hooks activate as soon as they land on your branch.)
|
||||||
|
set -e
|
||||||
|
MSG_FILE="$1"
|
||||||
|
|
||||||
|
# The header is the first line that is neither blank nor a comment (git strips
|
||||||
|
# '#' lines itself, but a commit-msg hook runs before that, so skip them here).
|
||||||
|
HEADER=$(grep -vE '^[[:space:]]*#' "$MSG_FILE" | sed '/^[[:space:]]*$/d' | head -n1)
|
||||||
|
|
||||||
|
. "$(dirname "$0")/lib.sh"
|
||||||
|
|
||||||
|
if conventional_ok "$HEADER"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "commit-msg hook: rejected." >&2
|
||||||
|
conventional_help "$HEADER" >&2
|
||||||
|
exit 1
|
||||||
34
tools/git-hooks/lib.sh
Normal file
34
tools/git-hooks/lib.sh
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# lib.sh — shared Conventional Commits validation, sourced by the commit-msg
|
||||||
|
# hook and by the CI commit-lint job so the rule lives in exactly one place.
|
||||||
|
# POSIX sh; no bashisms.
|
||||||
|
|
||||||
|
# The allowed commit types (Conventional Commits + the ones this repo uses).
|
||||||
|
CONVENTIONAL_TYPES='feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert'
|
||||||
|
|
||||||
|
# conventional_ok <header-line> -> 0 if it conforms, non-zero otherwise.
|
||||||
|
# Accepts `type(scope): summary`, an optional `!` for a breaking change, and the
|
||||||
|
# housekeeping headers git itself writes (merge/revert) or autosquash markers.
|
||||||
|
conventional_ok() {
|
||||||
|
header="$1"
|
||||||
|
[ -n "$header" ] || return 1
|
||||||
|
case "$header" in
|
||||||
|
"Merge "*|"Revert "*|"fixup! "*|"squash! "*|"amend! "*) return 0 ;;
|
||||||
|
esac
|
||||||
|
printf '%s\n' "$header" \
|
||||||
|
| grep -qE "^($CONVENTIONAL_TYPES)(\([a-z0-9][a-z0-9,._/ -]*\))?(!)?: .+"
|
||||||
|
}
|
||||||
|
|
||||||
|
# conventional_help <header-line> — print a short, actionable explanation.
|
||||||
|
conventional_help() {
|
||||||
|
echo " commit summary is not a Conventional Commit:"
|
||||||
|
echo " ${1:-<empty>}"
|
||||||
|
echo
|
||||||
|
echo " expected: type(scope): summary (scope is optional)"
|
||||||
|
echo " types: feat fix docs style refactor perf test build ci chore revert"
|
||||||
|
echo " examples: feat(stdlib): add Regex.* namespace"
|
||||||
|
echo " fix(emit): stop fixed compares widening the int literal"
|
||||||
|
echo " ci: cache the clang toolchain between runs"
|
||||||
|
echo " refactor(stdlib)!: drop the deprecated System.env alias"
|
||||||
|
echo
|
||||||
|
echo " see https://www.conventionalcommits.org"
|
||||||
|
}
|
||||||
33
tools/git-hooks/lint-range.sh
Executable file
33
tools/git-hooks/lint-range.sh
Executable file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Lint every commit summary in a git range against the Conventional Commits rule.
|
||||||
|
# Used by the commit-lint CI job; usable by hand too:
|
||||||
|
#
|
||||||
|
# tools/git-hooks/lint-range.sh origin/main..HEAD
|
||||||
|
#
|
||||||
|
# With no argument it lints just the tip commit. Reports every commit, then exits
|
||||||
|
# non-zero if any failed.
|
||||||
|
set -e
|
||||||
|
RANGE="${1:-HEAD~1..HEAD}"
|
||||||
|
|
||||||
|
. "$(dirname "$0")/lib.sh"
|
||||||
|
|
||||||
|
# Collect subjects into a file so the loop runs in this shell (a pipe would put
|
||||||
|
# it in a subshell and lose the verdict). Merge commits are exempt by the rule
|
||||||
|
# and skipped here so a merge subject never trips the lint.
|
||||||
|
tmp=$(mktemp)
|
||||||
|
git log --no-merges --format='%s' "$RANGE" > "$tmp"
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
while IFS= read -r subject; do
|
||||||
|
[ -n "$subject" ] || continue
|
||||||
|
if conventional_ok "$subject"; then
|
||||||
|
echo " ok $subject"
|
||||||
|
else
|
||||||
|
echo " FAIL $subject"
|
||||||
|
conventional_help "$subject"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done < "$tmp"
|
||||||
|
rm -f "$tmp"
|
||||||
|
|
||||||
|
[ "$fail" -eq 0 ]
|
||||||
|
|
@ -2,12 +2,14 @@
|
||||||
# Reject a commit that contains unformatted Ludic — in source or in the ```ludic
|
# Reject a commit that contains unformatted Ludic — in source or in the ```ludic
|
||||||
# fences of a Markdown file.
|
# fences of a Markdown file.
|
||||||
#
|
#
|
||||||
# ln -sf ../../tools/git-hooks/pre-commit .git/hooks/pre-commit
|
# Enable this and the commit-msg hook together with:
|
||||||
|
#
|
||||||
|
# git config core.hooksPath tools/git-hooks
|
||||||
#
|
#
|
||||||
# Checks only the files being committed, so it stays fast on a big tree.
|
# Checks only the files being committed, so it stays fast on a big tree.
|
||||||
set -e
|
set -e
|
||||||
ROOT=$(git rev-parse --show-toplevel)
|
ROOT=$(git rev-parse --show-toplevel)
|
||||||
FMT="$ROOT/build/ludic-fmt"
|
FMT="$ROOT/bin/ludic-fmt"
|
||||||
|
|
||||||
[ -x "$FMT" ] || exit 0 # toolchain not built: do not block the commit
|
[ -x "$FMT" ] || exit 0 # toolchain not built: do not block the commit
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue