Files
cozystack/hack/check-commit-trailers.sh
T
Aleksei Sviridkin d45663f1af ci(agents): check commit trailers on every pull request
The attribution and session-link rules in docs/agents/contributing.md
are decided by the text of a commit message alone, and until now the
only reader was a human scrolling git log. That reader is expensive and
misses things: one open pull request carried eight commits naming a
model in the attribution trailer, another four, all found by hand after
the work was otherwise done. Every one of them cost a review round to
report and a rebase to fix.

The check reads what is written and requires nothing, so a commit with
no trailer passes and a merge commit that carries none passes with it.
A trailer that exists and is wrong fails, naming the commit and the
accepted form. It runs over the commits between the merge base and the
head, so it never reaches the history on main, roughly a quarter of
which predates the rule. A backport branch is exempt: its commits are
cherry-picked verbatim and their messages are not the author's to
rewrite.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-07 02:38:02 +03:00

142 lines
6.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# Reject commit messages that break the attribution rules in
# docs/agents/contributing.md, "AI Agent Attribution":
#
# - an attribution trailer (`Assisted-by:`, key matched case-insensitively)
# carries the value `LLM` and nothing else, and appears at most once;
# - no trailer names an assistant session, and no message links to one;
# - no line is a generation byline naming the tool.
#
# All three are review blockers decided by the text alone, which is exactly the
# kind of thing a human should not be spending a review round on.
#
# Two shapes from that section are left to the reviewer, each for a measured
# reason rather than a guessed one:
#
# - a model named in a `Co-authored-by:` line needs a list of model names to
# tell from a person, and a list either misses the model released next month
# or fires on somebody's colleague. Most of the 771 `Co-authored-by:` lines
# on main name a model, so the list would decide a lot of pull requests;
# - a `Generated-by:` trailer has never appeared here at all, and a rule with
# no subject is a rule nobody maintains.
#
# A check that fires on something legitimate gets switched off, and then it
# guards nothing, so every rule above was run over the whole of main before it
# was written down — 6062 commits at the time, 1300 rejected, every one of them
# a true positive.
#
# Lines are read one at a time rather than through git's trailer parser, which
# reads only the last paragraph. A bad trailer with a paragraph after it is
# invisible to that parser and caught here. The cost is that a message quoting
# a rejected form as an example is rejected too.
#
# Usage: hack/check-commit-trailers.sh <range> e.g. origin/main..HEAD
#
# Every commit in the range is checked as written; nothing is required of a
# commit, so a message with no trailer at all — a merge commit among them —
# passes. Only a trailer that exists and is wrong fails.
set -euo pipefail
RANGE="${1:-}"
if [ -z "$RANGE" ]; then
echo "usage: hack/check-commit-trailers.sh <range>" >&2
exit 2
fi
# Hosts that serve transcripts rather than documentation. Of the three, only
# claude.ai has turned up in a commit message here so far, in two of them; the
# others are the same shape and cost nothing to carry. A vendor that also serves
# docs and pricing from its main domain is left out on purpose: a link to a
# manual is not a session, and a false red on a citation is a red nobody can
# argue with. The general shape is covered by the trailer key instead (any
# `*-Session:` key), which is how a session gets recorded in the first place.
#
# Matched as a URL only, and on a whole host: prose naming a host reads as
# prose, and notchatgpt.com and chatgpt.com.evil belong to somebody else. The
# closing class excludes a dot, so a bare host at the end of a sentence
# ("see https://claude.ai.") is not matched — the cost of keeping the lookalike
# hole shut. A real session link carries a path and matches on the slash.
# Lowercase, because the line is lowercased before the match. The optional
# leading group is userinfo, so https://user@claude.ai/x is still claude.ai;
# the closing class excludes "@" so that https://chatgpt.com@somewhere.example/x,
# whose actual host is somewhere.example, is not.
SESSION_URL='https?://([^/@[:space:]]*@)?([a-z0-9-]+\\.)*(claude\\.ai|chatgpt\\.com|chat\\.openai\\.com)([^a-z0-9.@-]|$)'
# Resolved up front rather than inside the `for` list: a command substitution
# there is not a command `set -e` looks at, so an unresolvable range would walk
# zero commits and report success.
SHAS="$(git rev-list "$RANGE")"
bad=0
for sha in $SHAS; do
findings="$(
git log -1 --format=%B "$sha" | awk -v url="$SESSION_URL" '
{
line = $0
sub(/\r$/, "", line)
# git folds an indented line into the value of the trailer above it, so
# "Assisted-by: LLM" followed by " anything" is the value LLM anything.
if (folds && line ~ /^[ \t]+[^ \t]/) {
print " " line "\n continues the attribution trailer above; the value must be exactly LLM"
next
}
folds = 0
if (match(line, /^[^:]+:/)) {
key = tolower(substr(line, 1, RLENGTH - 1))
# git accepts a space before the separator and reads the key without
# it, so "Assisted-by : x" is the same trailer as "Assisted-by: x".
sub(/[ \t]+$/, "", key)
val = substr(line, RLENGTH + 1)
sub(/^[ \t]+/, "", val)
sub(/[ \t]+$/, "", val)
if (key == "assisted-by") {
seen++
folds = 1
if (val != "LLM")
print " " line "\n attribution trailer value must be exactly LLM"
}
if (key ~ /-session$/)
print " " line "\n session trailer is not accepted"
}
# Lowercased for the match because a scheme and a host are
# case-insensitive; the line is printed as written.
if (tolower(line) ~ url)
print " " line "\n links to an assistant session"
# A generation byline, as opposed to an ordinary sentence: the first
# letters on the line are a capitalised "Generated with", after any
# emoji. That is 24 lines on main, all of them the same tool byline,
# and no false positive — "Regenerated with cozyvalues-gen" and a
# sentence wrapping onto "generated with ..." both keep their small g.
if (line ~ /^[^A-Za-z]*Generated with /)
print " " line "\n generation byline names the tool; use one Assisted-by: LLM trailer"
}
END {
if (seen > 1)
print " " seen " attribution trailers; at most one is accepted"
}
'
)"
if [ -n "$findings" ]; then
bad=$((bad + 1))
git log -1 --format='%h %s' "$sha"
printf '%s\n' "$findings"
fi
done
if [ "$bad" -gt 0 ]; then
cat >&2 <<'EOF'
Rejected commits above. Attribution discloses that a model took part; it does
not say which one, and it never carries a link to the session. The accepted
form is one line, next to Signed-off-by:
Assisted-by: LLM
Reword with `git rebase --interactive` and force-push the branch.
See docs/agents/contributing.md, "AI Agent Attribution".
EOF
exit 1
fi
echo "Commit trailers OK: $(git rev-list --count "$RANGE") commit(s) checked."