Files
cozystack/hack/check-commit-trailers.sh
Alexey Artamonov 7fd8376ccb fix(ci): fail the trailer check on an empty commit range
The pre-push recipe resolves origin/main..HEAD in the process working
directory, so an agent whose shell sits in a checkout at or behind the
base resolves it to zero commits and the check printed "Commit trailers
OK" with exit 0. A run that examined nothing was indistinguishable from
a clean one, defeating the recipe exactly when the checkout is wrong.
Treat an empty range as a named failure so the misconfiguration is loud;
the CI path is unaffected because it builds the range from a pull
request's merge base and head, which is empty only once nothing is left
to merge.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
2026-09-21 12:20:22 +03:00

158 lines
7.3 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. An empty range fails:
# a check that examined nothing must not read as one that found nothing wrong.
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")"
# An empty range is not a pass. The pre-push recipe in docs/agents/contributing.md
# resolves origin/main..HEAD against the working directory's HEAD, so an agent
# whose shell sits in a checkout at or behind the base resolves it to nothing
# here, and an "OK" over zero commits is a green nobody can tell from a real one.
# (A checkout on an unrelated branch resolves to that branch's commits instead,
# not to nothing — a wrong-branch check this guard does not claim to catch.) The CI
# path stays sound: it builds the range from the merge base and the head SHA of a
# pull request, which GitHub refuses to open with no commits between the two. It
# does go empty once the base branch has absorbed the head's commits, but only
# after the PR has nothing left to merge, where a red trailer check is harmless.
if [ -z "$SHAS" ]; then
echo "check-commit-trailers: range '$RANGE' is empty; no commits were checked" >&2
exit 1
fi
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."