Files
cozystack/hack/check-commit-trailers.bats
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

455 lines
12 KiB
Bash

#!/usr/bin/env bats
# -----------------------------------------------------------------------------
# Unit tests for hack/check-commit-trailers.sh.
#
# Each test builds a throwaway git repository, writes commits with the message
# shape under test, and runs the checker over base..HEAD — the same range shape
# the workflow computes from the merge base. Real commits rather than a canned
# list, because merge commits and the trailer block are git's behaviour, not the
# checker's.
#
# cozytest.sh's awk parser recognizes only @test blocks and a bare `}` on its
# own line; there is no bats `run` or `$status`. Assertions are direct shell
# tests, and an expected failure is written as `if CHECK ...; then ... exit 1`.
# Each test runs in its own subshell, so the `cd` into the fixture does not leak.
# -----------------------------------------------------------------------------
REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)"
CHECK="$REPO_ROOT/hack/check-commit-trailers.sh"
# A repository with one seed commit on `base`, left as the current directory.
# Signing and hooks are off so the fixture behaves the same on a maintainer's
# machine as on a runner: core.hooksPath is inherited from the global config,
# and a global commit-msg hook would otherwise rewrite the messages under test.
make_repo() {
dir="$(mktemp -d)"
cd "$dir"
git init -q .
git config user.email ci@example.invalid
git config user.name CI
git config commit.gpgsign false
git config core.hooksPath /dev/null
echo seed > file
git add file
git commit -q -m "seed"
git branch -f base HEAD
git tag start
}
# Message on stdin. A merge test moves `base`, so ranges are taken from the
# `start` tag, which nothing moves.
commit() {
echo change >> file
git add file
git commit -q -F -
}
# Last line of a test body rather than a trap, which the runners forbid: both
# set -e, so a failing test never reaches this and its repository survives for
# inspection, which is what a failed test wants.
cleanup() {
cd /
rm -rf "$dir"
}
@test "accepts the documented Assisted-by: LLM trailer" {
make_repo
commit <<'EOF'
feat(ci): add a thing
Signed-off-by: CI <ci@example.invalid>
Assisted-by: LLM
EOF
"$CHECK" base..HEAD
cleanup
}
@test "accepts a commit with no trailer at all" {
make_repo
commit <<'EOF'
fix(ci): repair a thing
A body with no trailer block, because no model took part.
EOF
"$CHECK" base..HEAD
cleanup
}
@test "accepts an empty range" {
make_repo
"$CHECK" base..HEAD
cleanup
}
@test "rejects a vendor-named attribution trailer and names the commit" {
make_repo
commit <<'EOF'
feat(ci): add a thing
Signed-off-by: CI <ci@example.invalid>
Assisted-By: Sonnet <noreply@example.invalid>
EOF
sha="$(git rev-parse --short HEAD)"
if out="$("$CHECK" base..HEAD 2>&1)"; then
echo "expected a vendor-named trailer to fail" >&2
exit 1
fi
printf '%s\n' "$out" | grep -q "$sha"
printf '%s\n' "$out" | grep -q "must be exactly LLM"
printf '%s\n' "$out" | grep -q "Assisted-by: LLM"
cleanup
}
@test "rejects an attribution value that is not LLM even without a vendor name" {
make_repo
commit <<'EOF'
feat(ci): add a thing
Assisted-by: AI
EOF
if "$CHECK" base..HEAD >/dev/null 2>&1; then
echo "expected Assisted-by: AI to fail" >&2
exit 1
fi
cleanup
}
@test "ignores the casing of the trailer key but not of the value" {
make_repo
commit <<'EOF'
feat(ci): add a thing
Assisted-By: LLM
EOF
"$CHECK" base..HEAD
commit <<'EOF'
feat(ci): add another thing
Assisted-by: llm
EOF
if "$CHECK" base..HEAD >/dev/null 2>&1; then
echo "expected a lowercase value to fail" >&2
exit 1
fi
cleanup
}
@test "rejects two attribution trailers on one commit" {
make_repo
commit <<'EOF'
feat(ci): add a thing
Assisted-by: LLM
Assisted-by: LLM
EOF
if out="$("$CHECK" base..HEAD 2>&1)"; then
echo "expected a duplicated trailer to fail" >&2
exit 1
fi
printf '%s\n' "$out" | grep -q "at most one is accepted"
cleanup
}
@test "rejects a session trailer" {
make_repo
commit <<'EOF'
feat(ci): add a thing
Assisted-by: LLM
Claude-Session: https://example.invalid/s/abc123
EOF
if out="$("$CHECK" base..HEAD 2>&1)"; then
echo "expected a session trailer to fail" >&2
exit 1
fi
printf '%s\n' "$out" | grep -q "session trailer is not accepted"
cleanup
}
@test "rejects a link to an assistant session anywhere in the message" {
make_repo
commit <<'EOF'
feat(ci): add a thing
Worked out in https://claude.ai/chat/00000000-0000-0000-0000-000000000000
EOF
if out="$("$CHECK" base..HEAD 2>&1)"; then
echo "expected a session link to fail" >&2
exit 1
fi
printf '%s\n' "$out" | grep -q "links to an assistant session"
cleanup
}
@test "reads an assistant host as prose when it is not a URL" {
make_repo
commit <<'EOF'
docs(agents): say where the rule comes from
The trailer rule applies to every assistant, chatgpt.com included.
EOF
"$CHECK" base..HEAD
cleanup
}
@test "passes a merge commit that carries no trailer of its own" {
make_repo
git checkout -q -b side
commit <<'EOF'
feat(ci): add a thing on a side branch
Assisted-by: LLM
EOF
git checkout -q base
commit <<'EOF'
fix(ci): move base along
EOF
git merge -q --no-ff --no-edit side
n="$(git rev-list --count start..HEAD)"
[ "$n" -ge 3 ]
git rev-list --min-parents=2 start..HEAD | grep -q .
"$CHECK" start..HEAD
cleanup
}
@test "rejects a bad trailer carried by a merge commit itself" {
make_repo
git checkout -q -b side
commit <<'EOF'
feat(ci): add a thing on a side branch
EOF
git checkout -q base
git merge -q --no-ff -m "Merge branch 'side'
Assisted-By: Sonnet <noreply@example.invalid>" side
if "$CHECK" start..HEAD >/dev/null 2>&1; then
echo "expected a merge commit with a bad trailer to fail" >&2
exit 1
fi
cleanup
}
@test "reports every offending commit in the range, not just the first" {
make_repo
commit <<'EOF'
feat(ci): first
Assisted-By: Sonnet <noreply@example.invalid>
EOF
first="$(git rev-parse --short HEAD)"
commit <<'EOF'
feat(ci): second
Assisted-By: Opus <noreply@example.invalid>
EOF
second="$(git rev-parse --short HEAD)"
if out="$("$CHECK" base..HEAD 2>&1)"; then
echo "expected two bad commits to fail" >&2
exit 1
fi
printf '%s\n' "$out" | grep -q "$first"
printf '%s\n' "$out" | grep -q "$second"
cleanup
}
@test "requires a range argument" {
# Grepping the usage line, not just the exit code: with the guard removed
# an empty range makes git fail anyway, so the status alone proves nothing.
make_repo
if out="$("$CHECK" 2>&1)"; then
echo "expected a missing range to fail" >&2
exit 1
fi
printf '%s\n' "$out" | grep -q '^usage: '
cleanup
}
@test "fails on a range git cannot resolve instead of reporting success" {
# A misspelled remote, or a clone that never fetched origin/main, is the
# shape a developer meets when running this by hand. Walking zero commits
# and printing OK would be the worst possible answer.
make_repo
if "$CHECK" no-such-ref..HEAD >/dev/null 2>&1; then
echo "expected an unresolvable range to fail" >&2
exit 1
fi
cleanup
}
@test "accepts the documented form in a message with CRLF line endings" {
# Trailing whitespace is stripped from a message under git's default
# cleanup, but --cleanup=verbatim keeps it, and so does a message written by
# an editor on Windows. The carriage return must not turn LLM into LLM\r.
make_repo
echo change >> file
git add file
printf 'feat(ci): add a thing\r\n\r\nAssisted-by: LLM\r\n' \
| git commit -q --cleanup=verbatim -F -
git log -1 --format=%B | grep -q "$(printf 'LLM\r')"
"$CHECK" start..HEAD
cleanup
}
@test "rejects a continuation line folded into the attribution trailer" {
# git reads the two lines as the single value "LLM continuation", which is
# not LLM. Reading them as two lines would accept it.
make_repo
commit <<'EOF'
feat(ci): add a thing
Assisted-by: LLM
continuation
EOF
git log -1 --format=%B | git interpret-trailers --parse \
| grep -qx 'Assisted-by: LLM continuation'
if "$CHECK" start..HEAD >/dev/null 2>&1; then
echo "expected a folded continuation to fail" >&2
exit 1
fi
cleanup
}
@test "rejects a generation byline naming the tool" {
make_repo
commit <<'EOF'
feat(ci): add a thing
🤖 Generated with [Some Tool](https://example.invalid/tool)
EOF
if out="$("$CHECK" start..HEAD 2>&1)"; then
echo "expected a generation byline to fail" >&2
exit 1
fi
printf '%s\n' "$out" | grep -q "generation byline"
cleanup
}
@test "leaves ordinary prose about generated files alone" {
# Both shapes are real lines from this repository's history, and both keep
# their small g. Failing either one would make the byline rule unusable.
make_repo
commit <<'EOF'
feat(ci): add a thing
Regenerated with cozyvalues-gen v1.5.0, which the CI pin has moved past. The
README in packages/apps/redis was
generated with cozyvalues-gen 1.5.0 while CI pins 1.6.0, and the older
version orders the table differently.
EOF
"$CHECK" start..HEAD
cleanup
}
@test "leaves the trailer keys it does not judge alone" {
# These block at review, and the script says why it does not decide them:
# telling a model from a person in a Co-authored-by line needs a list of
# model names, and Generated-by has never appeared here. A later change
# that starts failing these is a scope change, not a fix.
make_repo
commit <<'EOF'
feat(ci): add a thing
Co-authored-by: Sonnet <noreply@example.invalid>
Generated-by: Sonnet
EOF
"$CHECK" start..HEAD
cleanup
}
@test "matches a session link whose scheme and host are uppercase" {
make_repo
commit <<'EOF'
feat(ci): add a thing
See HTTPS://CLAUDE.AI/chat/00000000-0000-0000-0000-000000000000
EOF
if "$CHECK" start..HEAD >/dev/null 2>&1; then
echo "expected an uppercase session link to fail" >&2
exit 1
fi
cleanup
}
@test "leaves a matching host that is only userinfo alone" {
# The host of this URL is somewhere.example, not chatgpt.com.
make_repo
commit <<'EOF'
feat(ci): add a thing
Reported at https://chatgpt.com@somewhere.example/path
EOF
"$CHECK" start..HEAD
cleanup
}
@test "matches a session link carrying userinfo of its own" {
# Here the host really is claude.ai; user@ in front of it changes nothing.
make_repo
commit <<'EOF'
feat(ci): add a thing
Worked out in https://user@claude.ai/chat/00000000-0000-0000-0000-000000000000
EOF
if "$CHECK" start..HEAD >/dev/null 2>&1; then
echo "expected a session link with userinfo to fail" >&2
exit 1
fi
cleanup
}
@test "reads a space before the separator as git does" {
make_repo
commit <<'EOF'
feat(ci): add a thing
Assisted-by : Sonnet <noreply@example.invalid>
EOF
git log -1 --format=%B | git interpret-trailers --parse \
| grep -q '^Assisted-by: Sonnet'
if "$CHECK" start..HEAD >/dev/null 2>&1; then
echo "expected a spaced key to fail" >&2
exit 1
fi
cleanup
}
@test "leaves a documentation link on a vendor site alone" {
# cursor.com and gemini.google.com serve manuals and pricing, not only
# transcripts. A citation must not turn a pull request red.
make_repo
commit <<'EOF'
docs(agents): cite the tooling
Background at https://cursor.com/docs and https://gemini.google.com/about
EOF
"$CHECK" start..HEAD
cleanup
}
@test "leaves a lookalike host alone" {
# notchatgpt.com and chatgpt.com.evil belong to somebody else. Matching a
# substring of the host would hand them a failing check they cannot fix.
make_repo
commit <<'EOF'
feat(ci): add a thing
Reported at https://notchatgpt.com/x and https://chatgpt.com.evil/y
EOF
"$CHECK" start..HEAD
cleanup
}
@test "matches a session link served from a subdomain" {
make_repo
commit <<'EOF'
feat(ci): add a thing
See https://www.chatgpt.com/share/00000000-0000-0000-0000-000000000000
EOF
if "$CHECK" start..HEAD >/dev/null 2>&1; then
echo "expected a subdomain session link to fail" >&2
exit 1
fi
cleanup
}