DECISIONS-LOG.md was append-only in ONE file and every agent appended to its
tail, so any two concurrent branches collided on the same last hunk. Measured
against the live Gitea today: four of the five open PRs were mergeable=false,
and `git merge-tree` named DECISIONS-LOG.md as the ONLY conflicting path in
all four.
#22 fix/crafty-oauth2-proxy-signin CONFLICT: DECISIONS-LOG.md
#20 feat/conformance-job-template CONFLICT: DECISIONS-LOG.md
#19 feat/conformance-trigger-kubeconfig CONFLICT: DECISIONS-LOG.md
#18 feat/conformance-runner-image CONFLICT: DECISIONS-LOG.md
#17 feat/ci-is-the-approver clean — it wrote no handoff
Nothing was wrong with any of those changes. The log's storage format
serialised work that was otherwise parallel, and the cost grew with every
concurrent branch (N branches -> N-1 rebases, each invalidating the gate run
before it). The agents work in isolated worktrees precisely so they do not
block each other; this one file undid that.
So: stop sharing a file. Handoffs are now one-per-file under decisions/,
named YYYY-MM-DD-<slug>.md. Disjoint paths have nothing to merge. There is
deliberately no index file (a shared mutable file is the thing that just
failed) and no counter (two agents would race for the same number) — the
directory listing is the index.
DECISIONS-LOG.md is frozen as the pre-2026-09-29 archive, its SHA-256 pinned
in decisions/ARCHIVE.sha256, and a new gate stage rejects any change to it.
That is a guard and not a paragraph because the old habit is written into the
runbooks and into every agent that has read the log, and getting it wrong does
not fail: it produces a clean-looking commit that re-creates the conflict on
OTHER branches, later, misattributed.
ci/decisions-log.sh new stage: archive pinned; every entry named with
a real date, carrying date/branch/title frontmatter
that agrees with its filename, and all three of
CLAUDE.md §7's sections with a real body under each.
Exit 0 or 1 only — no skip path by construction, so
no fail-open surface on a guard that closes one.
ci/gate.sh runs it; fail and (impossible) skip both BLOCK.
No REQUIRE_DECISIONS_LOG knob: a guard with an off
switch is not a guard.
ci/tests/decisions-log.test.sh 22 assertions, incl. the one that matters —
an append to the archive is rejected, and the
message names the file to write instead.
ci/tests/gate-decision.test.sh +3 assertions for the new wiring, AND a
pre-existing bug: the stub list never included
gitea-settings, so run_stage saw exit 127 and
recorded a FAILED stage in every case. 15 of 47
assertions failed on main for that reason alone —
the suite was measuring its own missing stub.
Now 50/50.
CLAUDE.md §7 rewritten; §2b widened to the new path.
ci/Makefile, ci/README.md, .gitea/workflows/README.md — stage named where
the other stages are named.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
131 lines
5.6 KiB
Bash
Executable File
131 lines
5.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ci/tests/decisions-log.test.sh — proves ci/decisions-log.sh actually fails closed.
|
|
#
|
|
# The stage exists because appending to DECISIONS-LOG.md silently re-creates a merge conflict
|
|
# for every other open branch. A guard against a silent regression is itself worth nothing
|
|
# unless somebody has watched it reject the thing it claims to reject, so each case below
|
|
# CONSTRUCTS the violation in a throwaway tree and asserts a non-zero exit — the same reasoning
|
|
# as ci/tests/gate-decision.test.sh, one layer down.
|
|
set -uo pipefail
|
|
REAL_CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
PASS=0 FAIL=0
|
|
ok() { printf ' ok %s\n' "$*"; PASS=$((PASS+1)); }
|
|
bad() { printf ' FAIL %s\n' "$*"; FAIL=$((FAIL+1)); }
|
|
|
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
|
|
# A minimal well-formed tree: the frozen archive, its pin, and one valid entry.
|
|
mk_tree() {
|
|
local d="$1"; rm -rf "$d"; mkdir -p "$d/ci" "$d/decisions"
|
|
cp "${REAL_CI_DIR}/decisions-log.sh" "${REAL_CI_DIR}/lib.sh" "${REAL_CI_DIR}/versions.env" "$d/ci/"
|
|
printf 'frozen history\n' > "$d/DECISIONS-LOG.md"
|
|
( cd "$d" && sha256sum DECISIONS-LOG.md > decisions/ARCHIVE.sha256 )
|
|
mk_entry "$d/decisions/2026-09-29-a-good-entry.md" 2026-09-29
|
|
# REPO_ROOT in ci/lib.sh is derived from git; give the tree a repo so it resolves here.
|
|
( cd "$d" && git init -q . && git add -A && git -c user.email=t@t -c user.name=t commit -qm init )
|
|
}
|
|
|
|
mk_entry() {
|
|
cat > "$1" <<ENTRY
|
|
---
|
|
date: $2
|
|
branch: chore/example
|
|
title: An example entry
|
|
---
|
|
|
|
## What changed
|
|
|
|
Enough prose here to clear the twenty-character body floor comfortably.
|
|
|
|
## What I verified
|
|
|
|
Enough prose here to clear the twenty-character body floor comfortably.
|
|
|
|
## What I did NOT verify
|
|
|
|
Enough prose here to clear the twenty-character body floor comfortably.
|
|
ENTRY
|
|
}
|
|
|
|
# run <dir> -> sets RC and OUT
|
|
run() { OUT="$( cd "$1" && bash ci/decisions-log.sh 2>&1 )"; RC=$?; }
|
|
|
|
D="${TMP}/t"
|
|
|
|
echo "== 1. a well-formed tree passes"
|
|
mk_tree "$D"; run "$D"
|
|
[ "$RC" -eq 0 ] && ok "clean tree -> exit 0" || bad "clean tree -> exit ${RC}: ${OUT}"
|
|
|
|
echo
|
|
echo "== 2. THE REGRESSION THIS STAGE EXISTS FOR: appending to the frozen archive"
|
|
mk_tree "$D"; printf '\n## my new handoff\n' >> "$D/DECISIONS-LOG.md"; run "$D"
|
|
[ "$RC" -ne 0 ] && ok "append to DECISIONS-LOG.md -> non-zero (${RC})" \
|
|
|| bad "append to DECISIONS-LOG.md was ACCEPTED — the guard is inert"
|
|
grep -qi 'frozen archive' <<<"$OUT" && ok "the message names the archive as frozen" \
|
|
|| bad "the message does not say the archive is frozen"
|
|
grep -q 'decisions/\$(date +%F)' <<<"$OUT" && ok "the message names the file to write instead" \
|
|
|| bad "the message does not name the remedy"
|
|
|
|
echo
|
|
echo "== 3. a re-pin is a deliberate, accepted act"
|
|
mk_tree "$D"; printf '\nredacted\n' >> "$D/DECISIONS-LOG.md"
|
|
( cd "$D" && sha256sum DECISIONS-LOG.md > decisions/ARCHIVE.sha256 ); run "$D"
|
|
[ "$RC" -eq 0 ] && ok "archive changed AND re-pinned -> exit 0" || bad "re-pin rejected: ${OUT}"
|
|
|
|
echo
|
|
echo "== 4. a missing pin is unverifiable, not green"
|
|
mk_tree "$D"; rm "$D/decisions/ARCHIVE.sha256"; run "$D"
|
|
[ "$RC" -ne 0 ] && ok "no ARCHIVE.sha256 -> non-zero (${RC})" || bad "missing pin was accepted"
|
|
|
|
echo
|
|
echo "== 5. entry naming"
|
|
for badname in 2026-9-29-x.md 26-09-29-x.md 2026-09-29-Upper.md 2026-09-29-.md 2026-02-30-x.md; do
|
|
mk_tree "$D"; mk_entry "$D/decisions/${badname}" 2026-09-29; run "$D"
|
|
[ "$RC" -ne 0 ] && ok "rejects decisions/${badname}" || bad "ACCEPTED decisions/${badname}"
|
|
done
|
|
mk_tree "$D"; mk_entry "$D/decisions/2026-10-01-fix-thing-2.md" 2026-10-01; run "$D"
|
|
[ "$RC" -eq 0 ] && ok "accepts a digits-and-dashes slug" || bad "rejected a valid slug: ${OUT}"
|
|
|
|
echo
|
|
echo "== 6. frontmatter"
|
|
mk_tree "$D"; sed -i '1d' "$D/decisions/2026-09-29-a-good-entry.md"; run "$D"
|
|
[ "$RC" -ne 0 ] && ok "rejects an entry with no frontmatter" || bad "accepted a bare entry"
|
|
|
|
for key in date branch title; do
|
|
mk_tree "$D"; sed -i "/^${key}:/d" "$D/decisions/2026-09-29-a-good-entry.md"; run "$D"
|
|
[ "$RC" -ne 0 ] && ok "rejects frontmatter missing '${key}'" || bad "accepted missing '${key}'"
|
|
done
|
|
|
|
# The copied-template failure: the body was rewritten, the date never was.
|
|
mk_tree "$D"; mk_entry "$D/decisions/2026-09-30-copied.md" 2026-09-29; run "$D"
|
|
[ "$RC" -ne 0 ] && ok "rejects a frontmatter date that disagrees with the filename" \
|
|
|| bad "accepted a date/filename disagreement"
|
|
|
|
echo
|
|
echo "== 7. the three required sections, and their bodies"
|
|
for sec in 'What changed' 'What I verified' 'What I did NOT verify'; do
|
|
mk_tree "$D"; sed -i "/^## ${sec}\$/d" "$D/decisions/2026-09-29-a-good-entry.md"; run "$D"
|
|
[ "$RC" -ne 0 ] && ok "rejects an entry missing '## ${sec}'" || bad "accepted missing '## ${sec}'"
|
|
done
|
|
|
|
# A heading with nothing under it is the way this floor gets defeated in practice.
|
|
mk_tree "$D"
|
|
python3 - "$D/decisions/2026-09-29-a-good-entry.md" <<'PY'
|
|
import sys,re
|
|
p=sys.argv[1]; s=open(p).read()
|
|
s=re.sub(r'## What I did NOT verify\n\n.*', '## What I did NOT verify\n\nn/a\n', s, flags=re.S)
|
|
open(p,'w').write(s)
|
|
PY
|
|
run "$D"
|
|
[ "$RC" -ne 0 ] && ok "rejects a stub body under a required section" || bad "accepted an 'n/a' section"
|
|
|
|
echo
|
|
echo "== 8. README.md in decisions/ is not an entry and is not policed as one"
|
|
mk_tree "$D"; printf 'not an entry\n' > "$D/decisions/README.md"; run "$D"
|
|
[ "$RC" -eq 0 ] && ok "decisions/README.md ignored by the entry rules" || bad "README policed: ${OUT}"
|
|
|
|
echo
|
|
if [ "$FAIL" -eq 0 ]; then echo "decisions-log tests: PASS ($PASS assertions)"; exit 0
|
|
else echo "decisions-log tests: FAIL ($FAIL failed, $PASS passed)"; exit 1; fi
|