Merge pull request 'actions/* + lib/* + CHANGELOG — merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)' (#204) from build/198-upstream-0.6.0 into main
All checks were successful
CI / test (push) Successful in 3m2s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 7s
CI / action-exercise (push) Successful in 6s
CI / docs-sync-exercise (push) Successful in 6s
release / release (push) Successful in 7s
All checks were successful
CI / test (push) Successful in 3m2s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 7s
CI / action-exercise (push) Successful in 6s
CI / docs-sync-exercise (push) Successful in 6s
release / release (push) Successful in 7s
Reviewed-on: #204 Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders> Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
This commit is contained in:
commit
790c4d226f
53 changed files with 8274 additions and 979 deletions
61
.github/labeler.yml
vendored
61
.github/labeler.yml
vendored
|
|
@ -7,6 +7,32 @@
|
|||
# these globs implement. Scopes locate, they do not alert — a path that maps
|
||||
# to nothing is fine (the mapping is advisory), so these rows chase the big
|
||||
# surfaces, not every file.
|
||||
#
|
||||
# A row that matches everything is worse than a missing one: it costs the
|
||||
# same silence and adds a wrong answer. `changelog.d/**` sat under
|
||||
# scope:release-flow until #267 measured it — the last 20 PRs (#197–#263)
|
||||
# all carried scope:release-flow and 3 of them touched a release surface,
|
||||
# because BUILDER.md makes every behavior change write a fragment, so the
|
||||
# glob was "any PR that changes behavior" by doctrine. CHANGELOG.md stays:
|
||||
# the same doctrine forbids editing it for an entry, so only the release PR
|
||||
# does. The other rows #267 added — the issueflow reconciler, the three
|
||||
# unmapped guard actions, RELEASES.md, README (which this tree spells
|
||||
# README.md, so the old glob could match nothing) — are the same read of the
|
||||
# same file, gaps rather than wrong answers.
|
||||
#
|
||||
# #302 is the same read once more, from #300's review: lib/attention.sh had
|
||||
# #267 D4's premise exactly (both reconcilers source it, nothing release-side
|
||||
# does) and was not in the rows — a wrong answer, not a gap. The sweep
|
||||
# workflow pair joins beside its trigger pair: the sweeps detached in #209
|
||||
# and took the reconcile jobs and the cron with them. Two asymmetries are
|
||||
# deliberate, not drift: the TESTS of the shared lib/ files take
|
||||
# scope:labels alone, because lib/ruling.sh and lib/read.sh wear
|
||||
# scope:release-flow only through the lib/** glob being kept whole and a
|
||||
# test file inherits no such glob; and there is still no test/** or
|
||||
# .github/scripts/** catch-all, because both directories span all four
|
||||
# scopes — a catch-all is the changelog.d/** defect again, 100% recall and
|
||||
# no locating power. The enumeration is the price of a test locating its
|
||||
# subject.
|
||||
scope:release-flow:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
|
@ -18,7 +44,6 @@ scope:release-flow:
|
|||
- bin/**
|
||||
- VERSION
|
||||
- CHANGELOG.md
|
||||
- changelog.d/**
|
||||
- drills/**
|
||||
- test/decide.test.sh
|
||||
- test/facts.test.sh
|
||||
|
|
@ -26,35 +51,67 @@ scope:release-flow:
|
|||
- test/version.test.sh
|
||||
- test/changelog.test.sh
|
||||
- test/self-ref.test.sh
|
||||
- test/changelog-assemble.test.sh
|
||||
- .github/scripts/release-path.sh
|
||||
- test/release-path.test.sh
|
||||
scope:guards:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- actions/changelog-armed/**
|
||||
- actions/changelog-assembled/**
|
||||
- actions/changelog-monotonic/**
|
||||
- actions/docs-sync/**
|
||||
- actions/drill-recorded/**
|
||||
- actions/refs-not-closing/**
|
||||
- actions/runner-isolated/**
|
||||
- .github/workflows/refs-guard.yml
|
||||
- test/changelog-armed.test.sh
|
||||
- test/changelog-assembled.test.sh
|
||||
- test/changelog-monotonic.test.sh
|
||||
- test/docs-sync.test.sh
|
||||
- test/drill-recorded.test.sh
|
||||
- test/refs-not-closing.test.sh
|
||||
- test/runner-isolated.test.sh
|
||||
- .github/scripts/marker-check.sh
|
||||
- test/marker-check.test.sh
|
||||
- .github/scripts/vendored-check.sh
|
||||
- test/vendored.test.sh
|
||||
scope:labels:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- .github/workflows/labels.yml
|
||||
- .github/workflows/self-labels.yml
|
||||
- .github/workflows/labels-sweep.yml
|
||||
- .github/workflows/self-labels-sweep.yml
|
||||
- .github/labeler.yml
|
||||
- .github/labels.conf
|
||||
- actions/issueflow-reconcile/**
|
||||
- actions/labels-reconcile/**
|
||||
- actions/labels-scope/**
|
||||
# shared by both reconcilers; lib/** keeps scope:release-flow too,
|
||||
# and a mixed file honestly wears both labels (#267 D4, #302 D1)
|
||||
- lib/read.sh
|
||||
- lib/ruling.sh
|
||||
- lib/attention.sh
|
||||
- LABELS.md
|
||||
- test/issueflow-reconcile.test.sh
|
||||
- test/labels.test.sh
|
||||
- test/labels-reconcile.test.sh
|
||||
- test/labels-scope.test.sh
|
||||
# tests of the shared lib/ files: scope:labels ALONE — a test
|
||||
# inherits no lib/** glob, so its row is the one scope its subject
|
||||
# actually locates (#302 D3)
|
||||
- test/attention.test.sh
|
||||
- test/ruling.test.sh
|
||||
- test/labels-triggers.test.sh
|
||||
scope:docs:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- README
|
||||
- README.md
|
||||
- docs/**
|
||||
- AGENTS.md
|
||||
- BUILDER.md
|
||||
- RELEASES.md
|
||||
- REVIEWER.md
|
||||
- TRIAGE.md
|
||||
- CONTRIBUTING.md
|
||||
|
|
|
|||
128
.github/scripts/marker-check.sh
vendored
Executable file
128
.github/scripts/marker-check.sh
vendored
Executable file
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env bash
|
||||
# Availability-marker guard (issue #238). Five of five markers found by #221
|
||||
# outlived the releases that shipped their machinery. A release candidate must
|
||||
# therefore reject a marker its assembled changelog makes false, while every
|
||||
# tree rejects an untraceable marker. Cross-repo citations are traceable but
|
||||
# are not compared with this repository's changelog; a marker for this repo's
|
||||
# own issue uses bare #N, never a self-qualified repository citation (#238 D8).
|
||||
# CHANGELOG.md is the release oracle and immutable shipped prose, so it and the
|
||||
# fragments that feed it are excluded from the documentation scan (#238 D5).
|
||||
# A token inside inline code is a mention, not a marker; spans are stripped
|
||||
# individually so unrelated backticks cannot hide a real marker (#238 D9).
|
||||
#
|
||||
# Usage: marker-check.sh [tree-dir] (default: the repository root)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
tree="${1:-$ROOT}"
|
||||
|
||||
fail() {
|
||||
printf '%s\n' "$@" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! git -C "$tree" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
fail "marker-check: $tree is not a Git work tree; tracked Markdown cannot be determined."
|
||||
fi
|
||||
|
||||
marker_records="$(mktemp)"
|
||||
trap 'rm -f "$marker_records"' EXIT
|
||||
|
||||
mapfile -d '' markdown_files < <(git -C "$tree" ls-files -z -- '*.md')
|
||||
for relative in "${markdown_files[@]}"; do
|
||||
case "$relative" in
|
||||
CHANGELOG.md|changelog.d/*) continue ;;
|
||||
esac
|
||||
|
||||
if ! awk -v file="$relative" '
|
||||
function without_inline_code(text, before, after) {
|
||||
while (match(text, /`[^`]*`/)) {
|
||||
before = substr(text, 1, RSTART - 1)
|
||||
after = substr(text, RSTART + RLENGTH)
|
||||
text = before after
|
||||
}
|
||||
return text
|
||||
}
|
||||
{
|
||||
lines[NR] = $0
|
||||
scan_lines[NR] = without_inline_code($0)
|
||||
}
|
||||
END {
|
||||
token = "**unreleased**"
|
||||
citation_re = "^[[:space:]]*\\((([[:alnum:]_.-]+/)?[[:alnum:]_.-]+)?#[0-9]+\\)"
|
||||
bad = 0
|
||||
|
||||
for (line_no = 1; line_no <= NR; line_no++) {
|
||||
remaining = scan_lines[line_no]
|
||||
offset = 0
|
||||
while ((at = index(remaining, token)) != 0) {
|
||||
rest = substr(remaining, at + length(token))
|
||||
candidate = rest
|
||||
next_line = line_no + 1
|
||||
while (candidate ~ /^[[:space:]]*$/ && next_line <= NR) {
|
||||
candidate = candidate " " scan_lines[next_line]
|
||||
next_line++
|
||||
}
|
||||
|
||||
if (match(candidate, citation_re)) {
|
||||
citation = substr(candidate, RSTART, RLENGTH)
|
||||
sub(/^[[:space:]]*\(/, "", citation)
|
||||
sub(/\)$/, "", citation)
|
||||
printf "%s\t%d\t%s\n", file, line_no, citation
|
||||
} else {
|
||||
printf "marker-check: %s:%d: %s\n", file, line_no, lines[line_no] > "/dev/stderr"
|
||||
printf "marker-check: every **unreleased** marker must be immediately followed by an issue citation such as (#238), (crew#293), or (owner/repo#293).\n" > "/dev/stderr"
|
||||
bad = 1
|
||||
}
|
||||
|
||||
offset += at + length(token) - 1
|
||||
remaining = substr(scan_lines[line_no], offset + 1)
|
||||
}
|
||||
}
|
||||
exit bad
|
||||
}
|
||||
' "$tree/$relative" >>"$marker_records"; then
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
version=""
|
||||
if [ -f "$tree/VERSION" ]; then
|
||||
IFS= read -r version <"$tree/VERSION" || true
|
||||
fi
|
||||
|
||||
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
[ -f "$tree/CHANGELOG.md" ] || \
|
||||
fail "marker-check: bare VERSION '$version' requires CHANGELOG.md for the release-marker check."
|
||||
|
||||
shipped_issues="$(awk '
|
||||
$1 == "##" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+$/ {
|
||||
if (in_section) exit
|
||||
in_section = 1
|
||||
next
|
||||
}
|
||||
in_section && /^##[[:space:]]/ { exit }
|
||||
in_section {
|
||||
text = $0
|
||||
while (match(text, /(^|[^[:alnum:]_./-])#[0-9]+/)) {
|
||||
issue = substr(text, RSTART, RLENGTH)
|
||||
sub(/^.*#/, "", issue)
|
||||
print issue
|
||||
text = substr(text, RSTART + RLENGTH)
|
||||
}
|
||||
}
|
||||
' "$tree/CHANGELOG.md" | sort -u)"
|
||||
|
||||
while IFS=$'\t' read -r file line citation; do
|
||||
case "$citation" in
|
||||
\#*)
|
||||
issue="${citation#\#}"
|
||||
if printf '%s\n' "$shipped_issues" | grep -qxF "$issue"; then
|
||||
fail "marker-check: $file:$line: **unreleased** (#$issue) is false on release candidate $version; CHANGELOG.md's top release section cites #$issue, so clear the marker in this release PR."
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done <"$marker_records"
|
||||
fi
|
||||
|
||||
echo "marker-check: availability markers agree with the tree."
|
||||
24
.github/scripts/release-path.sh
vendored
Executable file
24
.github/scripts/release-path.sh
vendored
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env bash
|
||||
# The release doors' executable path (discussion #217; issue #237). The
|
||||
# 0.5.0 record had to explain why lib/ruling.sh changed without changing a
|
||||
# door. Keep this list executable so a doors-unchanged record measures the
|
||||
# workflow and only the scripts it actually runs, while the test catches a
|
||||
# new or removed dependency before a record can silently omit it.
|
||||
#
|
||||
# On this tree the doors also speak the forge shim: #191 ported lib/facts.sh
|
||||
# and release.yml off `gh` so a Forgejo consumer can publish at all, which
|
||||
# makes lib/forge.sh part of the doors' executable path here. The backends it
|
||||
# loads (lib/forge-github.sh, lib/forge-forgejo.sh) are sourced through
|
||||
# $FORGE_LIB_DIR at run time rather than by a literal `.` line, so the
|
||||
# transitive scan cannot see them and listing them would read as a stale
|
||||
# path; lib/forge.sh standing for the trio is the honest entry (#198).
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' \
|
||||
.github/workflows/release.yml \
|
||||
bin/ \
|
||||
lib/version.sh \
|
||||
lib/decide.sh \
|
||||
lib/facts.sh \
|
||||
lib/changelog.sh \
|
||||
lib/forge.sh
|
||||
234
.github/scripts/vendored-check.sh
vendored
Executable file
234
.github/scripts/vendored-check.sh
vendored
Executable file
|
|
@ -0,0 +1,234 @@
|
|||
#!/usr/bin/env bash
|
||||
# The vendored-manifest self-guard (issue #251; #248's near-miss). Consumers
|
||||
# mirror the agent-facing doc set declared by docs/VENDORED.txt, and
|
||||
# actions/docs-sync already enforces "manifest ∪ .ceremony/, nothing else"
|
||||
# on the CONSUMER side. Nothing enforced the other end: a new doctrine file
|
||||
# could land at ceremony's root and nobody add it to the manifest, and the
|
||||
# miss is SILENT — docs-sync asserts byte-identity for the files the
|
||||
# manifest names, so a doc it omits is never checked and every consumer
|
||||
# drifts doctrine-blind with green guards. #248 (RELEASES.md) nearly shipped
|
||||
# that way, caught only by a hand-written `grep -Fx RELEASES.md` row in
|
||||
# test/docs-sync.test.sh — the hardcoded list this guard abolishes, one
|
||||
# layer down. That row is deleted; this script carries its intent.
|
||||
#
|
||||
# Two directions, two mechanisms, because only one of them can be a scan
|
||||
# (#251 D2):
|
||||
#
|
||||
# * MANIFEST → TREE is a scan: every entry resolves to a regular,
|
||||
# non-empty, tracked file at the declared path — no symlink (PR #43:
|
||||
# a symlink read as doctrine while staying invisible), no directory,
|
||||
# no `../` escape.
|
||||
# * TREE → MANIFEST cannot scan, because nothing in the tree answers
|
||||
# "which files are vendorable" — the manifest is the only
|
||||
# machine-readable notion of it. So it gets a CLOSED-WORLD RULE
|
||||
# instead: every `*.md` at the repository ROOT is either in the
|
||||
# manifest or in the exemption list below. Adding a root doc then
|
||||
# forces a one-line decision — vendor it or exempt it — and the
|
||||
# refusal names the file and both fixes.
|
||||
#
|
||||
# The rule is ROOT-LEVEL `*.md` ONLY. It does not walk docs/, actions/ or
|
||||
# drills/: those hold no agent-facing doctrine, and a recursive version
|
||||
# would grow the exemption list past the length at which a reviewer still
|
||||
# reads it — which is the failure this rule is shaped against.
|
||||
#
|
||||
# The exemption list lives HERE, in the script. CONTRIBUTING.md's
|
||||
# vendored-set sentence is documentation, never an input: two declarations
|
||||
# of the same set is the drift the manifest exists to prevent.
|
||||
#
|
||||
# Usage: vendored-check.sh [tree-dir] (default: the repo root — the CI
|
||||
# step; tests point it at fixture trees)
|
||||
set -euo pipefail
|
||||
|
||||
MANIFEST="docs/VENDORED.txt"
|
||||
|
||||
tree="${1:-.}"
|
||||
|
||||
# The root docs that are deliberately ceremony-only. Each carries the reason
|
||||
# it is not vendored, because the reason is what lets the next reviewer
|
||||
# judge the next addition. Prints the reason and returns 0 when exempt.
|
||||
exempt_reason() {
|
||||
case "$1" in
|
||||
README.md)
|
||||
echo "ceremony's own front page — a consumer's router is AGENTS.md, not this repo's README"
|
||||
;;
|
||||
CONTRIBUTING.md)
|
||||
echo "repo-specific facts (this repo's roster, scopes and conventions); every governed repo writes its own"
|
||||
;;
|
||||
CHANGELOG.md)
|
||||
echo "ceremony's own release history; a consumer keeps its own"
|
||||
;;
|
||||
FLEET.md)
|
||||
echo "the operator's fleet map — about running the fleet, not about how a governed repo works"
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'vendored-check: %s\n' "$@" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
manifest_file="$tree/$MANIFEST"
|
||||
[ -f "$manifest_file" ] || die \
|
||||
"no $MANIFEST under $tree — the manifest is the sole declaration of the" \
|
||||
" vendored doc set, and this guard has nothing to guard without it."
|
||||
|
||||
# Blank lines are skipped, exactly as actions/docs-sync reads it: the guard
|
||||
# and the tool must accept the same file, or one of them is the bug.
|
||||
mapfile -t manifest < <(grep -v '^[[:space:]]*$' "$manifest_file" || true)
|
||||
[ "${#manifest[@]}" -gt 0 ] || die \
|
||||
"$MANIFEST is empty — an empty doctrine set is a ceremony bug, not a repo" \
|
||||
" with no rules."
|
||||
|
||||
# Whether the tracked-file assertion can bind: only when the tree IS a git
|
||||
# work tree root. Fixture trees are plain directories, and asserting
|
||||
# tracked-ness against an enclosing repository would be asserting about the
|
||||
# wrong tree.
|
||||
#
|
||||
# When it cannot bind, SAY SO. This guard's whole argument is that a silent
|
||||
# miss is worse than a loud one, and a guard that quietly stops asserting one
|
||||
# of its four properties is exactly that shape — so the skip is announced on
|
||||
# every run, green or red, rather than inferred from the absence of a
|
||||
# refusal (#251 round 1).
|
||||
tracked_check=no
|
||||
tracked_note="tracked-ness NOT asserted: $tree is not a git work tree root, so
|
||||
'is this file in the tag's tree' cannot be answered about THIS tree. The
|
||||
other three manifest assertions (regular file, non-empty, no
|
||||
symlink/dir/escape) still bind."
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
toplevel="$(git -C "$tree" rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -n "$toplevel" ] && [ "$toplevel" = "$(cd "$tree" && pwd -P)" ]; then
|
||||
tracked_check=yes
|
||||
tracked_note=""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Every refusal is collected and reported together, one multi-line string
|
||||
# per offending file: a guard that stops at the first problem makes a
|
||||
# builder pay one CI round per file.
|
||||
problems=()
|
||||
|
||||
# --- manifest → tree ---------------------------------------------------------
|
||||
|
||||
for entry in "${manifest[@]}"; do
|
||||
case "$entry" in
|
||||
/* | *..*)
|
||||
problems+=("$(
|
||||
printf '%s\n' \
|
||||
"$MANIFEST names '$entry' — an absolute path or a '..' escape. The" \
|
||||
" mirror writes only inside a consumer's .ceremony/, so a path that" \
|
||||
" leaves it is never vendorable." \
|
||||
" Fix: name the path relative to the repository root, with no '..'."
|
||||
)")
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
path="$tree/$entry"
|
||||
|
||||
# -L before -f: `[ -f ]` follows the link, so a symlink to a real file
|
||||
# would otherwise pass as a regular one.
|
||||
if [ -L "$path" ]; then
|
||||
problems+=("$(
|
||||
printf '%s\n' \
|
||||
"$MANIFEST names '$entry', which is a SYMLINK. A symlink vendors as" \
|
||||
" doctrine while its content lives somewhere the mirror never checks" \
|
||||
" (PR #43's round: it read as doctrine and stayed invisible)." \
|
||||
" Fix: make '$entry' a regular file, or drop the entry from $MANIFEST."
|
||||
)")
|
||||
continue
|
||||
fi
|
||||
if [ -d "$path" ]; then
|
||||
problems+=("$(
|
||||
printf '%s\n' \
|
||||
"$MANIFEST names '$entry', which is a DIRECTORY. The manifest declares" \
|
||||
" files, one per line — a directory entry vendors nothing." \
|
||||
" Fix: name each file under '$entry' on its own line, or drop the entry."
|
||||
)")
|
||||
continue
|
||||
fi
|
||||
if [ ! -f "$path" ]; then
|
||||
problems+=("$(
|
||||
printf '%s\n' \
|
||||
"$MANIFEST names '$entry' but the tree has no such file. Every consumer" \
|
||||
" mirroring this ref would fail on it." \
|
||||
" Fix: add '$entry' to the tree, or remove it from $MANIFEST."
|
||||
)")
|
||||
continue
|
||||
fi
|
||||
if [ ! -s "$path" ]; then
|
||||
problems+=("$(
|
||||
printf '%s\n' \
|
||||
"$MANIFEST names '$entry', which is EMPTY. An empty file vendors as" \
|
||||
" doctrine that says nothing, and is read as doctrine anyway." \
|
||||
" Fix: write '$entry', or remove it from $MANIFEST."
|
||||
)")
|
||||
continue
|
||||
fi
|
||||
if [ "$tracked_check" = yes ] &&
|
||||
! git -C "$tree" ls-files --error-unmatch -- "$entry" >/dev/null 2>&1; then
|
||||
problems+=("$(
|
||||
printf '%s\n' \
|
||||
"$MANIFEST names '$entry', which is not TRACKED. A file absent from the" \
|
||||
" tag's tree cannot be fetched by a consumer syncing at that tag," \
|
||||
" however present it is on this machine." \
|
||||
" Fix: git add '$entry', or remove it from $MANIFEST."
|
||||
)")
|
||||
continue
|
||||
fi
|
||||
done
|
||||
|
||||
# --- tree → manifest: the closed world over root `*.md` ----------------------
|
||||
|
||||
in_manifest() {
|
||||
local p
|
||||
for p in "${manifest[@]}"; do
|
||||
[ "$p" = "$1" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
shopt -s nullglob
|
||||
exempted=()
|
||||
vendored=()
|
||||
for path in "$tree"/*.md; do
|
||||
doc="${path##*/}"
|
||||
if in_manifest "$doc"; then
|
||||
vendored+=("$doc")
|
||||
continue
|
||||
fi
|
||||
if reason="$(exempt_reason "$doc")"; then
|
||||
exempted+=("$doc — $reason")
|
||||
continue
|
||||
fi
|
||||
problems+=("$(
|
||||
printf '%s\n' \
|
||||
"'$doc' is a root doc in NEITHER list. Every root *.md is either vendored" \
|
||||
" doctrine — mirrored into every governed repo at .ceremony/ — or" \
|
||||
" deliberately ceremony-only, and nothing in the tree says which, so the" \
|
||||
" decision has to be written down. Fix, one of:" \
|
||||
" * add '$doc' to $MANIFEST, if it is agent-facing doctrine that every" \
|
||||
" governed repo must carry;" \
|
||||
" * add '$doc' to the exemption list in" \
|
||||
" .github/scripts/vendored-check.sh, with the reason it stays" \
|
||||
" ceremony-only."
|
||||
)")
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
if [ "${#problems[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf 'vendored-check: %d problem(s) — docs/VENDORED.txt and the tree disagree.\n\n' \
|
||||
"${#problems[@]}"
|
||||
printf '%s\n\n' "${problems[@]}"
|
||||
[ -z "$tracked_note" ] || printf 'vendored-check: %s\n' "$tracked_note"
|
||||
} >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'vendored-check: %d manifest entries resolve; %d root docs vendored, %d exempt.\n' \
|
||||
"${#manifest[@]}" "${#vendored[@]}" "${#exempted[@]}"
|
||||
[ -z "$tracked_note" ] || printf 'vendored-check: %s\n' "$tracked_note"
|
||||
[ "${#vendored[@]}" -eq 0 ] || printf ' vendored: %s\n' "${vendored[@]}"
|
||||
[ "${#exempted[@]}" -eq 0 ] || printf ' exempt: %s\n' "${exempted[@]}"
|
||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
|
|
@ -43,6 +43,15 @@ jobs:
|
|||
# The pin rules (issue #9; #1 D3): a stale CEREMONY_SELF_REF fails
|
||||
# CI here, not a consumer's release.
|
||||
run: bash .github/scripts/self-ref-check.sh
|
||||
- name: Documentation availability markers
|
||||
# Five stale markers survived the tags that shipped their machinery
|
||||
# (#221); #238 makes the release candidate reject that drift.
|
||||
run: bash .github/scripts/marker-check.sh
|
||||
- name: Vendored manifest
|
||||
# The manifest rules (issue #251; #248's near-miss): a doctrine file
|
||||
# at the root that nobody added to docs/VENDORED.txt is invisible to
|
||||
# every consumer's docs-sync, so it fails CI here instead.
|
||||
run: bash .github/scripts/vendored-check.sh
|
||||
- name: Tests
|
||||
env:
|
||||
# The npm-backed version_write case may skip locally when npm is
|
||||
|
|
|
|||
125
.github/workflows/labels-sweep.yml
vendored
Normal file
125
.github/workflows/labels-sweep.yml
vendored
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
name: labels-sweep
|
||||
# Reusable sweep half of the labels automation — the reconcile + issueflow
|
||||
# jobs that rode labels.yml until #209. Triggers and permissions live in the
|
||||
# caller; docs/CONSUMERS.md carries the complete caller stub
|
||||
# (workflow_dispatch plus the hourly cron, which relocated here with the
|
||||
# sweep). Board events still yield a sweep within seconds: labels.yml's
|
||||
# trigger job dispatches this workflow's caller on every event it used to
|
||||
# run reconcile on.
|
||||
#
|
||||
# Detached on purpose (#209): every sweep covers every open PR and all
|
||||
# sweeps serialize through ONE shared concurrency group, so GitHub's
|
||||
# one-running-plus-one-pending queue records every extra run as CANCELLED.
|
||||
# That displacement is semantically lossless — the surviving sweep does the
|
||||
# displaced run's work — but while the sweep rode pull_request_target runs
|
||||
# the ❌ landed on that PR's checks and read as red CI, with no manual
|
||||
# escape hatch: GitHub refuses to rerun a queue-displaced run (crew#250).
|
||||
# And displacement is the steady state of a working fleet, not a spike —
|
||||
# one panel request emits one review_requested event per reviewer, so
|
||||
# every review round over-fills the one-running-plus-one-pending queue.
|
||||
# Here a displaced run attaches to no PR: the cancellations live on the
|
||||
# Actions tab only.
|
||||
#
|
||||
# Bootstrap semantics: a manual dispatch of the caller bootstraps the
|
||||
# taxonomy (its `bootstrap` input defaults to "yes"), exactly what
|
||||
# dispatching the labels caller did before the split. The trigger job's
|
||||
# dispatches carry bootstrap=no — ~20 label upserts per sweep is too chatty
|
||||
# for every board event, the same reason cron runs never bootstrapped.
|
||||
#
|
||||
# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
|
||||
# create workflow runs from GITHUB_TOKEN-raised events (the trigger's
|
||||
# workflow_dispatch is one of the two documented exemptions; this workflow
|
||||
# dispatches nothing). Agent writes use a PAT and therefore do trigger —
|
||||
# exactly the asymmetry wanted.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
pr_workflow_name:
|
||||
description: >-
|
||||
The `name:` of the consumer's PR-facing labels caller, exported
|
||||
to the reconcile step as SELF_WORKFLOW so the sweep can leave
|
||||
the label machinery's own check entries (scope, trigger) out of
|
||||
its CI verdict: a red trigger means "fix the caller", which no
|
||||
PR edit can do, so it must never count toward blocker:ci-red.
|
||||
Read by the #208 reconciler; harmless to earlier ones.
|
||||
type: string
|
||||
required: false
|
||||
default: labels
|
||||
|
||||
env:
|
||||
# A called workflow arrives without its repository. Keep this literal pin
|
||||
# aligned with the ceremony release consumed by callers (issue #9 D3).
|
||||
CEREMONY_SELF_REF: "0.6.0"
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
runs-on: ubuntu-latest
|
||||
# ONE shared group: every reconcile sweeps every open PR, so cron and
|
||||
# dispatched runs must serialize or two sweeps race the same PR's labels
|
||||
# and both pass the request-the-human-once guard.
|
||||
concurrency:
|
||||
group: labels-reconcile
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
# No PR code is ever checked out or executed: the sweep checks out
|
||||
# the consumer's default branch and the pinned ceremony
|
||||
# implementation only. Keep it that way.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
- uses: actions/checkout@v4
|
||||
# The self-consumption bypass — release.yml's twin, and load-bearing
|
||||
# for the same reason (#11): ceremony's own labels bootstrap must
|
||||
# run BEFORE any release tag exists for this checkout to fetch — the
|
||||
# release label the merge door reads is created by that dispatch, so
|
||||
# without the bypass the first release deadlocks on its own pin. The
|
||||
# base-branch checkout above already IS ceremony on the dogfood
|
||||
# path.
|
||||
if: github.repository != 'heavy-duty/ceremony'
|
||||
with:
|
||||
repository: heavy-duty/ceremony
|
||||
ref: ${{ env.CEREMONY_SELF_REF }}
|
||||
path: .ceremony-src
|
||||
# Two steps, mutually exclusive `if:`s, because a `uses:` path must be
|
||||
# a literal — the same fork release.yml's CEREMONY_DIR env line
|
||||
# papers over for `run:` steps, which composite `uses:` has no
|
||||
# equivalent of.
|
||||
#
|
||||
# bootstrap: every trigger-driven wake arrives as workflow_dispatch
|
||||
# too (that is how `gh workflow run` wakes the caller), so the event
|
||||
# name alone no longer separates the operator's manual full-board
|
||||
# bootstrap from an event-woken sweep — the caller's `bootstrap`
|
||||
# dispatch input does: the trigger passes "no", a bare manual
|
||||
# dispatch defaults to "yes". A caller reached on any other event
|
||||
# (the cron) has no input and stays "no".
|
||||
- name: reconcile state + stale
|
||||
if: github.repository != 'heavy-duty/ceremony'
|
||||
uses: ./.ceremony-src/actions/labels-reconcile
|
||||
with:
|
||||
bootstrap: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.bootstrap != 'no' && 'yes' || 'no' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
SELF_WORKFLOW: ${{ inputs.pr_workflow_name }}
|
||||
- name: reconcile state + stale (dogfood — the workspace IS ceremony)
|
||||
if: github.repository == 'heavy-duty/ceremony'
|
||||
uses: ./actions/labels-reconcile
|
||||
with:
|
||||
bootstrap: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.bootstrap != 'no' && 'yes' || 'no' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
SELF_WORKFLOW: ${{ inputs.pr_workflow_name }}
|
||||
- name: reconcile issue flow
|
||||
if: github.repository != 'heavy-duty/ceremony'
|
||||
uses: ./.ceremony-src/actions/issueflow-reconcile
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
- name: reconcile issue flow (dogfood — the workspace IS ceremony)
|
||||
if: github.repository == 'heavy-duty/ceremony'
|
||||
uses: ./actions/issueflow-reconcile
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
157
.github/workflows/labels.yml
vendored
157
.github/workflows/labels.yml
vendored
|
|
@ -6,36 +6,56 @@ name: labels
|
|||
# family arrives from a fork, where pull_request runs with a READ-ONLY token
|
||||
# and cannot label anything. _target is safe in this workflow because no PR
|
||||
# code is ever checked out or executed — scope reads changed paths and the
|
||||
# path mapping via the API and checks out only the ceremony implementation,
|
||||
# and reconcile checks out the BASE branch only. Keep it that way.
|
||||
# path mapping via the API and checks out only the ceremony implementation.
|
||||
# Keep it that way.
|
||||
#
|
||||
# There is no pull_request_review_target, so a review landing cannot wake this
|
||||
# workflow directly — which is why the caller's cron is load-bearing, not a
|
||||
# safety net (#199 relaxed it from */15 to hourly, but did NOT drop it). The
|
||||
# cron is the sweep's only discovery path for every transition no subscribed
|
||||
# event carries: a verdict landing, blocker:ci-red set/cleared, a
|
||||
# blocker:conflict when another PR merges under this one, and the time-based
|
||||
# stale / 48h claim-reclaim. Where an event IS subscribed the wake is direct —
|
||||
# the handoff sets state:needs-human and the caller's `labeled` event confirms
|
||||
# or corrects that optimistic write within seconds.
|
||||
# The reconcile sweep lived here until #209. Riding the PR-triggered run
|
||||
# meant every displacement in the sweep's shared concurrency queue recorded
|
||||
# a CANCELLED `reconcile` check on some PR — read as red CI by every human
|
||||
# and agent, though the surviving sweep does the displaced run's work. Two
|
||||
# field facts made that untenable (crew#250): a displaced run cannot be
|
||||
# rerun — `gh run rerun`, `--failed`, and `--job` all refuse — so a victim
|
||||
# PR has no manual escape hatch; and the displacing burst is deterministic,
|
||||
# one `review_requested` event per panelist per request, so every review
|
||||
# round displaces runs and the rate scales with panel size. The
|
||||
# sweep now lives in labels-sweep.yml behind its own caller, and the
|
||||
# trigger job below is its wake: it fires on every event this caller
|
||||
# subscribes — the exact surface that used to run reconcile directly — so
|
||||
# the wake latency (#137) is unchanged, while a displaced sweep cancels on
|
||||
# the Actions tab, attached to no PR. PR checks show scope + trigger only.
|
||||
#
|
||||
# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
|
||||
# create workflow runs from GITHUB_TOKEN-triggered events. Agent writes use a
|
||||
# PAT and therefore do trigger — exactly the asymmetry wanted.
|
||||
# This cannot loop: the trigger's dispatch and the reconciler's label
|
||||
# writes both use GITHUB_TOKEN. GitHub does not create workflow runs from
|
||||
# GITHUB_TOKEN-raised events — workflow_dispatch and repository_dispatch
|
||||
# are the two documented exemptions, which is exactly why the trigger can
|
||||
# wake the sweep with no PAT anywhere in the path — and the sweep itself
|
||||
# dispatches nothing. Agent writes use a PAT and therefore do trigger —
|
||||
# exactly the asymmetry wanted.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
sweep_workflow:
|
||||
description: >-
|
||||
Filename of the consumer's sweep caller — the workflow that
|
||||
calls labels-sweep.yml (docs/CONSUMERS.md carries the stub).
|
||||
The trigger job dispatches it by this name. Override it only
|
||||
when the caller file is not named labels-sweep.yml (ceremony's
|
||||
own dogfood names it self-labels-sweep.yml).
|
||||
type: string
|
||||
required: false
|
||||
default: labels-sweep.yml
|
||||
|
||||
env:
|
||||
# A called workflow arrives without its repository. Keep this literal pin
|
||||
# aligned with the ceremony release consumed by callers (issue #9 D3).
|
||||
CEREMONY_SELF_REF: "0.4.1"
|
||||
CEREMONY_SELF_REF: "0.6.0"
|
||||
|
||||
jobs:
|
||||
scope:
|
||||
# Not on labeled/unlabeled: those events change no paths, so scope has
|
||||
# nothing new to derive — and label churn is precisely what they are.
|
||||
# review_requested/review_request_removed likewise change no paths — they
|
||||
# exist to wake reconcile (#137) — and running labeler on them widens
|
||||
# exist to wake the sweep (#137) — and running labeler on them widens
|
||||
# exactly the window #130 documents, where a label written during a
|
||||
# scope run is clobbered.
|
||||
if: >-
|
||||
|
|
@ -85,65 +105,54 @@ jobs:
|
|||
# the mapping it is judged by
|
||||
CONFIG_REF: ${{ github.sha }}
|
||||
|
||||
reconcile:
|
||||
trigger:
|
||||
# The sweep's wake (#209). No `if:`: reconcile carried none, so the
|
||||
# trigger keeps the whole event surface the caller subscribes —
|
||||
# workflow_dispatch of the labels caller itself included. That cannot
|
||||
# double-fire bootstrap: this dispatch always carries bootstrap=no, so
|
||||
# a dispatched labels caller yields one plain sweep, and the taxonomy
|
||||
# bootstrap fires solely on a manual dispatch of the sweep caller
|
||||
# (whose input defaults to "yes"). Excluding workflow_dispatch here
|
||||
# would instead make a dispatched labels caller do nothing at all —
|
||||
# a silent no-op run is worse than a redundant sweep.
|
||||
#
|
||||
# LOUD on failure — never `|| true`: a red trigger is the
|
||||
# misconfiguration alarm. A consumer that bumps the pin without adding
|
||||
# the sweep caller (workflow-not-found), without its declared
|
||||
# `bootstrap` input (unexpected input), or without `actions: write`
|
||||
# on this caller (permission denied) fails HERE, visibly on the PR,
|
||||
# instead of silently never sweeping again.
|
||||
runs-on: ubuntu-latest
|
||||
# ONE shared group: every reconcile sweeps every open PR, so cron and
|
||||
# PR-event runs must serialize or two sweeps race the same PR's labels
|
||||
# and both pass the request-the-human-once guard.
|
||||
concurrency:
|
||||
group: labels-reconcile
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
# pull_request_target is required for fork PR write permission. It is
|
||||
# safe here because no PR code is ever checked out or executed:
|
||||
# labels-scope reads the mapping and changed paths via the API, and
|
||||
# reconcile checks out the BASE branch only. Keep it that way.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
- uses: actions/checkout@v4
|
||||
# The self-consumption bypass — release.yml's twin, and load-bearing
|
||||
# for the same reason (#11): ceremony's own labels bootstrap must
|
||||
# run BEFORE any release tag exists for this checkout to fetch — the
|
||||
# release label the merge door reads is created by that dispatch, so
|
||||
# without the bypass the first release deadlocks on its own pin. The
|
||||
# base-branch checkout above already IS ceremony on the dogfood
|
||||
# path.
|
||||
if: github.repository != 'heavy-duty/ceremony'
|
||||
with:
|
||||
repository: heavy-duty/ceremony
|
||||
ref: ${{ env.CEREMONY_SELF_REF }}
|
||||
path: .ceremony-src
|
||||
# Two steps, mutually exclusive `if:`s, because a `uses:` path must be
|
||||
# a literal — the same fork release.yml's CEREMONY_DIR env line
|
||||
# papers over for `run:` steps, which composite `uses:` has no
|
||||
# equivalent of.
|
||||
- name: reconcile state + stale
|
||||
if: github.repository != 'heavy-duty/ceremony'
|
||||
uses: ./.ceremony-src/actions/labels-reconcile
|
||||
with:
|
||||
bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }}
|
||||
- name: dispatch the sweep
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
- name: reconcile state + stale (dogfood — the workspace IS ceremony)
|
||||
if: github.repository == 'heavy-duty/ceremony'
|
||||
uses: ./actions/labels-reconcile
|
||||
with:
|
||||
bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
- name: reconcile issue flow
|
||||
if: github.repository != 'heavy-duty/ceremony'
|
||||
uses: ./.ceremony-src/actions/issueflow-reconcile
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
- name: reconcile issue flow (dogfood — the workspace IS ceremony)
|
||||
if: github.repository == 'heavy-duty/ceremony'
|
||||
uses: ./actions/issueflow-reconcile
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
SWEEP_WORKFLOW: ${{ inputs.sweep_workflow }}
|
||||
# This step speaks gh and says so, the same declaration
|
||||
# actions/refs-not-closing carries (#198 spec 4). A workflow has no
|
||||
# shell to call forge_preflight from, so the refusal is inline
|
||||
# below; #205 owns the REST port that removes both.
|
||||
CEREMONY_FORGE_CLIENT: gh
|
||||
run: |
|
||||
# Two questions, not one. @codex-reviewer-andresmgsl: a guard that
|
||||
# only asks `command -v gh` passes the moment a Forgejo runner image
|
||||
# happens to ship gh — and then runs a GitHub dispatch against a
|
||||
# forge that cannot serve it, which is the client/forge mismatch
|
||||
# forge_preflight exists to prevent. So the FORGE is decided first,
|
||||
# mirroring forge_detect positively (only github.com is accepted;
|
||||
# anything else, known or not, is refused — "Never 'probably
|
||||
# github'"), and the binary is checked second.
|
||||
#
|
||||
# A warning, not a failure: this trigger is the misconfiguration
|
||||
# alarm for a CONSUMER's missing sweep caller, and reddening every
|
||||
# sweep on a forge for a gap #205 already owns would drown that
|
||||
# signal. #205 ports the dispatch to REST and removes all of this.
|
||||
if [ "${GITHUB_SERVER_URL:-}" != "https://github.com" ]; then
|
||||
echo "::warning::labels: the sweep was NOT woken from this trigger — it dispatches with \`gh\` against GitHub, and this is not a GitHub forge (GITHUB_SERVER_URL=${GITHUB_SERVER_URL:-unset}). #205 ports it to REST. The hourly SCHEDULED sweep still runs; every event-driven wake through this caller — issue events included — is unavailable until then."
|
||||
exit 0
|
||||
fi
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
echo "::warning::labels: the sweep was NOT woken from this trigger — this runner does not carry \`gh\`. #205 ports the dispatch to REST. The hourly SCHEDULED sweep still runs; every event-driven wake through this caller is unavailable until then."
|
||||
exit 0
|
||||
fi
|
||||
gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no
|
||||
|
|
|
|||
30
.github/workflows/refs-guard.yml
vendored
Normal file
30
.github/workflows/refs-guard.yml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
name: Refs guard
|
||||
|
||||
on:
|
||||
# Body edits are load-bearing: #200 gained its accidental closing keyword
|
||||
# after the PR opened, with no new commit to wake ordinary CI (#218).
|
||||
pull_request:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
refs-not-closing:
|
||||
# The action is gh-only until #199: its whole gather is a GraphQL query,
|
||||
# and Forgejo serves no GraphQL at all. The ACTION refuses by name on a
|
||||
# backend it cannot speak (that is its contract, and its contract test);
|
||||
# scheduling it where it can only refuse is this workflow's decision, and
|
||||
# a permanently red required check would block every merge on this forge
|
||||
# for a gap #199 already owns. So the job does not run there — a skipped
|
||||
# check is a green head, an invented verdict is not.
|
||||
#
|
||||
# The condition mirrors lib/forge.sh's forge_detect positively: only
|
||||
# github.com is accepted, and anything else — Forgejo, or a host this
|
||||
# file has not met — is not run. "Never 'probably github'."
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: ./actions/refs-not-closing
|
||||
2
.github/workflows/release-exercise.yml
vendored
2
.github/workflows/release-exercise.yml
vendored
|
|
@ -132,7 +132,7 @@ jobs:
|
|||
EOF
|
||||
mkdir changelog.d
|
||||
printf '# changelog.d/ — assembled at release (heavy-duty/ceremony#112); the marker keeps the directory tracked.\n' > changelog.d/README.md
|
||||
printf -- '- The entry this release ships.\n' > changelog.d/42.md
|
||||
printf -- '- The entry this release ships (#42).\n' > changelog.d/42.md
|
||||
git add VERSION CHANGELOG.md changelog.d
|
||||
git commit -qm "base"
|
||||
printf '0.7.0\n' > VERSION
|
||||
|
|
|
|||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -129,7 +129,7 @@ env:
|
|||
# `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the
|
||||
# shared logic arrives as script files via checkout, not as inner `uses:`
|
||||
# references.
|
||||
CEREMONY_SELF_REF: "0.4.1"
|
||||
CEREMONY_SELF_REF: "0.6.0"
|
||||
VERSION_SOURCE: ${{ inputs.version-source }}
|
||||
|
||||
jobs:
|
||||
|
|
|
|||
46
.github/workflows/self-labels-sweep.yml
vendored
Normal file
46
.github/workflows/self-labels-sweep.yml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
name: labels-sweep
|
||||
# Ceremony's own sweep caller (#209) — self-labels.yml's detached half,
|
||||
# wearing the same local-`uses:` deviation and the same warning: consumers
|
||||
# must NEVER copy the local form (it rides main, unpinned — correct only
|
||||
# for the repo that IS the source). Consumers write:
|
||||
# uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@<pinned-tag>
|
||||
on:
|
||||
# The consumer owns this cadence (#203). Hourly is the recommended default
|
||||
# when no other engine drives board state: the cron is then the sweep's ONLY
|
||||
# wake for four transition classes — a review verdict landing (there is no
|
||||
# pull_request_review trigger on the labels caller), blocker:ci-red set or
|
||||
# cleared (no check_suite/check_run/workflow_run), a blocker:conflict when
|
||||
# ANOTHER PR merges under this one, and the time-based stale / 48h
|
||||
# claim-reclaim. The labels caller's events carry the rest in seconds, one
|
||||
# trigger-job dispatch away. Hourly trades ≤1h of latency on those four
|
||||
# while cutting nominal scheduled sweeps from four an hour to one at
|
||||
# GitHub's 1-minute billing floor. Do not delete the cron: it is their
|
||||
# discovery path. If another engine writes some of those transitions, only
|
||||
# the classes with no other writer bound the cadence; relax it only as that
|
||||
# list shrinks.
|
||||
schedule: [{cron: "0 * * * *"}]
|
||||
# A manual full-board sweep. A bare dispatch (input default "yes") also
|
||||
# bootstraps the taxonomy on a fresh repo — what dispatching the labels
|
||||
# caller did before #209. The reusable's trigger job wakes this workflow
|
||||
# with bootstrap=no on every board event — an event-woken sweep must not
|
||||
# re-upsert ~20 labels each time — so declaring this input is part of the
|
||||
# caller contract: a dispatch naming an undeclared input is refused, and
|
||||
# the trigger job goes loudly red.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bootstrap:
|
||||
description: Bootstrap the label taxonomy before sweeping
|
||||
type: choice
|
||||
options: ["yes", "no"]
|
||||
default: "yes"
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read # mergeability/check-rollup read for PR state
|
||||
statuses: read # commit-status rollup read for PR state
|
||||
issues: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
sweep:
|
||||
# pr_workflow_name keeps its default: ceremony's PR-facing caller is
|
||||
# named `labels` (self-labels.yml).
|
||||
uses: ./.github/workflows/labels-sweep.yml
|
||||
26
.github/workflows/self-labels.yml
vendored
26
.github/workflows/self-labels.yml
vendored
|
|
@ -4,22 +4,13 @@ name: labels
|
|||
# same warning: consumers must NEVER copy the local form (it rides main,
|
||||
# unpinned — correct only for the repo that IS the source). Consumers write:
|
||||
# uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
|
||||
#
|
||||
# Since #209 this caller carries the PR/issue event surface only. The
|
||||
# reconcile sweep no longer rides these runs — the reusable's trigger job
|
||||
# dispatches the sweep caller (self-labels-sweep.yml here), which owns the
|
||||
# hourly cron and the manual/bootstrap workflow_dispatch. A board event
|
||||
# below still yields a sweep within seconds, one dispatch hop later.
|
||||
on:
|
||||
# The consumer owns this cadence (#203). Hourly is the recommended default
|
||||
# when no other engine drives board state: the cron is then the sweep's ONLY
|
||||
# wake for four transition classes — a review verdict landing (there is no
|
||||
# pull_request_review trigger here), blocker:ci-red set or cleared (no
|
||||
# check_suite/check_run/workflow_run), a blocker:conflict when ANOTHER PR
|
||||
# merges under this one, and the time-based stale / 48h claim-reclaim. The
|
||||
# events below carry the rest in seconds. Hourly trades ≤1h of latency on
|
||||
# those four while cutting nominal scheduled sweeps from four an hour to one
|
||||
# at GitHub's 1-minute billing floor. Do not delete the cron: it is their
|
||||
# discovery path. If another engine writes some of those transitions, only
|
||||
# the classes with no other writer bound the cadence; relax it only as that
|
||||
# list shrinks.
|
||||
schedule: [{cron: "0 * * * *"}]
|
||||
# A manual full-board sweep, including taxonomy bootstrap on a fresh repo.
|
||||
workflow_dispatch:
|
||||
# Narrowed (#199) to the actions that carry a queue-state change the hourly
|
||||
# cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/
|
||||
# unassigned, which feed validation and the 48h claim clock (caught within one
|
||||
|
|
@ -48,8 +39,13 @@ permissions:
|
|||
contents: read
|
||||
checks: read # mergeability/check-rollup read for PR state
|
||||
statuses: read # commit-status rollup read for PR state
|
||||
actions: write # the trigger job's `gh workflow run` dispatch of the sweep caller (#209)
|
||||
issues: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
labels:
|
||||
uses: ./.github/workflows/labels.yml
|
||||
with:
|
||||
# Dogfood filename deviation only — consumers keep the default,
|
||||
# labels-sweep.yml, and pass nothing.
|
||||
sweep_workflow: self-labels-sweep.yml
|
||||
|
|
|
|||
529
BUILDER.md
529
BUILDER.md
|
|
@ -6,292 +6,232 @@ triage bug, and the move is to say so on the issue, not to guess.
|
|||
|
||||
## Picking
|
||||
|
||||
- Pick from issues labeled **`ready`** — never `blocked`, never `claimed`,
|
||||
never an `epic` (epics organize; their children are the work).
|
||||
- Respect dependency order: inside an epic, take the earliest unblocked
|
||||
unclaimed child. Between epics and strays, prefer the issue that unblocks
|
||||
the most other work.
|
||||
- **Your own red head outranks a new claim.** A failing check at the head
|
||||
of a PR you authored is picked up **before claiming another issue** —
|
||||
repairing your own red PR comes ahead of new work, which is why the
|
||||
engine's duty order evaluates ci-red between resume and build (crew#17:
|
||||
ceremony#163 sat with full-panel approvals at its head, mergeable, and
|
||||
stranded on an HTTP 429 in a job that never ran the PR's code, because no
|
||||
wake covered a red head that owed no round and had no conflict). Red and
|
||||
green here are the ruled terms of the review round below: a cancelled or
|
||||
stale check is not a green head; a skipped or neutral one is. The
|
||||
recovery path (crew#17): inspect the check at the head and record the
|
||||
failing check and its failure class; rerun a clearly retryable
|
||||
infrastructure failure without changing code; when the failure belongs to
|
||||
the branch, return to the normal fix-round and worklog discipline; leave
|
||||
visible evidence when a rerun cannot be started or the cause is
|
||||
uncertain; never repeatedly rerun a deterministic branch failure without
|
||||
a corrective commit; and proceed to handoff once the check is green and
|
||||
current-head approvals stand. A PR of yours with a red head is **not
|
||||
parked** — the next move is yours, whatever the round's verdict state
|
||||
says (shape 2 below carves this out explicitly). How the engine detects a red
|
||||
head — its ledger, its quiet rules, the rollup's node shapes — is crew's
|
||||
to describe, not this file's.
|
||||
- **One build at a time.** You hold at most one issue on which you are
|
||||
writing or revising a deliverable — finish or release that work before
|
||||
starting new work. The rule counts build work in flight, not claims: a
|
||||
claim does not consume the slot while it is **parked**, meaning the next
|
||||
move belongs to someone else. Exactly five shapes qualify:
|
||||
1. the issue carries `needs-ruling`, its escalation names a decider, and
|
||||
its `Blocked:` line stops the remaining work;
|
||||
2. the deliverable is in a review round where every outstanding verdict
|
||||
belongs to someone else — either the round is awaiting its first
|
||||
verdicts, or it was answered whole and the owed re-requests posted —
|
||||
by head, not by verdict: every panelist after a push, the
|
||||
non-approvers alone at an unchanged head (the review round, steps
|
||||
1–2). This is the *live* round; shape 4 is
|
||||
the *passed* one — they are sequential and do not overlap. A red
|
||||
check at the current head takes the deliverable **out of this
|
||||
shape**: mid-round CI going red is exactly the state that reads as
|
||||
"waiting on the panel" and is not — the next move is yours (the
|
||||
red-head rule above), and reading it as parked is what strands the
|
||||
PR;
|
||||
3. every remaining acceptance criterion is operator-owned, stated as such
|
||||
by triage on the issue;
|
||||
4. the deliverable is **handed off** — the round passed, no `blocker:*`
|
||||
stands, and you set `state:needs-human` per Handoff (below). The
|
||||
remaining move is the human's merge.
|
||||
5. the claim is **held by directive** — triage or the operator has told
|
||||
you to stop, the direction names what the hold waits on, and that thing
|
||||
is not yours to move. This is not "waiting for a good moment": somebody
|
||||
else has decided the work must not proceed, and only they end it.
|
||||
And it ends the same way it started: **on the labels.** When the queue
|
||||
labels and any prose — an issue body header, a triage comment, an
|
||||
operator's comment — disagree about whether a hold stands, the most
|
||||
recent queue-label event by the hold's owner governs, and the prose is
|
||||
stale until someone corrects it. So before standing down *or* standing
|
||||
up on a hold, read the issue's **label events**
|
||||
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not only its
|
||||
comments: an operator may lift by label alone, and on 2026-07-24 did,
|
||||
twice, on [#149](https://github.com/heavy-duty/ceremony/issues/149)
|
||||
and [#151](https://github.com/heavy-duty/ceremony/issues/151). Acting
|
||||
on the labels against stale prose, say so in the claim — name the
|
||||
events you read, their timestamps and their actor, and invite the
|
||||
correction if the read is wrong;
|
||||
[the 14:11:45Z claim on #149](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070781295)
|
||||
is the exemplar. Refusing is not a resting place either:
|
||||
[*"I am not claiming through that contradiction"*](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070776624)
|
||||
was a correct instinct and an incomplete move — the next step is to
|
||||
read the events, state what they say, and then claim or stand down on
|
||||
that, or, if the events genuinely do not resolve it, say so on the
|
||||
issue and pick the next `ready` issue rather than idling on this one.
|
||||
Not parked — these are what the rule defends against: waiting on
|
||||
yourself, waiting on CI (a red head is your own work, above; a pending
|
||||
one resolves without you), or waiting for a good moment. An issue you have
|
||||
simply stopped working on is not parked either — that is abandonment,
|
||||
and its move is unchanged: unassign and restore `ready` (Claiming,
|
||||
below).
|
||||
The 2026-07-23 board is why the rule counts work and not claims: one
|
||||
builder correctly held
|
||||
[#15](https://github.com/heavy-duty/ceremony/issues/15) (`offsite`,
|
||||
round answered whole, one verdict outstanding) and
|
||||
[#16](https://github.com/heavy-duty/ceremony/issues/16) (`needs-ruling`
|
||||
hard block, triage said hold) parked beside the one active build,
|
||||
[#73](https://github.com/heavy-duty/ceremony/issues/73).
|
||||
- Pick from issues labeled **`ready`** — never `blocked`, `claimed`, or an
|
||||
`epic` (epics organize; their children are the work). Inside an epic take
|
||||
the earliest unblocked unclaimed child, otherwise the issue that unblocks
|
||||
the most work; where a repo adopts version epics,
|
||||
[RELEASES.md](RELEASES.md) governs among window members.
|
||||
- **Your own red head outranks a new claim**: repair a failing check at your
|
||||
PR's head before claiming another issue (#163). Red and green here are the
|
||||
review round's ruled terms: cancelled, stale, or unreported — every entry
|
||||
at the head cancelled — is not green; skipped or neutral is. Record the
|
||||
check and its failure class; rerun a clearly retryable infrastructure
|
||||
failure unchanged; treat a branch failure as an ordinary fix round,
|
||||
worklog and all; leave evidence where a rerun cannot start or the cause is
|
||||
unclear; never rerun a deterministic failure without a corrective commit;
|
||||
hand off once green with current-head approvals. Such a PR is **never
|
||||
parked**, whatever the verdict state says; how the engine detects a red
|
||||
head is crew's to describe.
|
||||
- **One build at a time**: one issue on which you are writing or revising a
|
||||
deliverable, finished or released before you start more. The rule counts
|
||||
work in flight, not claims — a **parked** claim, whose next move is
|
||||
someone else's, does not hold the slot. Five shapes park:
|
||||
1. `needs-ruling` is set, the escalation names a decider, and its
|
||||
`Blocked:` line stops the rest;
|
||||
2. a **live** review round holds it, every outstanding verdict someone
|
||||
else's — awaiting first verdicts, or answered whole with the owed
|
||||
re-requests posted, by head and not by verdict (steps 1–2). A red check
|
||||
at the head takes it out of this shape: the next move is yours;
|
||||
3. every remaining acceptance criterion is operator-owned, stated so by
|
||||
triage on the issue;
|
||||
4. it is **handed off** — round passed, no `blocker:*` standing,
|
||||
`state:needs-human` set per Handoff, the merge the human's. Shapes 2
|
||||
and 4 are sequential and never overlap;
|
||||
5. the claim is **held by directive** — triage or the operator stopped the
|
||||
work, named what the hold waits on, and only they end it. A hold ends
|
||||
as it started, **on the labels**: where labels and prose disagree, the
|
||||
most recent queue-label event by the hold's owner governs, and an
|
||||
operator may lift by label alone (#149, #151). So read the label events
|
||||
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just the
|
||||
comments, before standing down *or* up, and say in the claim which you
|
||||
read, their timestamps and their actor. Where they do not resolve the
|
||||
contradiction, say so and take the next `ready` issue; refusing is no
|
||||
resting place.
|
||||
Not parked: waiting on yourself, on CI (a red head is yours; a pending one
|
||||
resolves without you), or for a good moment. An issue you stopped working
|
||||
on is abandoned — unassign and restore `ready`. Parked claims are held
|
||||
beside the one active build (#15, #16, #73).
|
||||
|
||||
## Claiming
|
||||
|
||||
- Assign yourself, swap `ready` → `claimed`, and comment that you are
|
||||
starting. The claim is a promise of a draft PR soon — a claim with no PR
|
||||
and no activity is what the staleness sweep reclaims unless `offsite`
|
||||
records that its PR lives in another repository.
|
||||
- **A park is declared, never inferred.** When your claim enters a parked
|
||||
shape (Picking, above), say so in a comment on that issue, naming what it
|
||||
waits on and who owns the next move. No new label: the comment is
|
||||
activity, so it feeds the same reclaim clock the `needs-ruling`
|
||||
([#52](https://github.com/heavy-duty/ceremony/issues/52)) and `offsite`
|
||||
([#68](https://github.com/heavy-duty/ceremony/issues/68)) exemptions
|
||||
already guard — a parked claim nobody can name is an abandoned one.
|
||||
Shape 4 alone is exempt from the separate comment: the factual handoff
|
||||
comment plus the `state:needs-human` write *is* its declaration — both
|
||||
halves are already there, what the claim waits on (the merge) and who
|
||||
owns the next move (the human), and both are visible to any scan as a
|
||||
`labeled` event with the comment beside it. No second comment is owed on
|
||||
the issue. Every other shape still declares as above.
|
||||
Declared once, the declaration **stands** until the park's facts change:
|
||||
a resumption that finds nothing changed posts nothing — the standing
|
||||
declaration is the record, and silence while parked is compliant, not
|
||||
abandonment-shaped. Re-declaring on every resume is the flood
|
||||
[rig#145](https://github.com/heavy-duty/rig/pull/145) drowned in — 38
|
||||
near-identical audits in one night, each saying nothing changed
|
||||
([#177](https://github.com/heavy-duty/ceremony/discussions/177)). What
|
||||
re-opens the duty to comment is the facts changing — the named wait
|
||||
resolves or changes hands, the parked shape changes, or the claim
|
||||
unparks — and each owes one new comment. The one place silence has a
|
||||
cost: a parked claim with **no open PR** still feeds the 48-hour
|
||||
reclaim clock, so there the builder refreshes the declaration before
|
||||
the window closes. That refresh is the only repeat a park ever owes,
|
||||
and its cadence is the reclaim window's, not any duty loop's. None of
|
||||
this loosens the abandonment rule below: a claim that was never parked
|
||||
and has simply stopped moving is abandoned, not silent.
|
||||
- **Pick up `attention` before anything else.** On your claim, first post a
|
||||
short pickup comment and remove `attention`; the removal is the ack. A
|
||||
demand on a parked claim is usually its unpark, so take the slot back under
|
||||
the existing rule below rather than leaving the demand parked. A demand
|
||||
that *is* the park is different: the pickup comment is the declaration,
|
||||
so one comment does both jobs, and the demand does not take the slot back.
|
||||
- **A directed hold keeps its bookkeeping visible.** The PR carries `blocked`
|
||||
with a comment naming what it waits on; the issue stays `claimed` and
|
||||
carries `attention` until the builder acknowledges it. Nobody unassigns
|
||||
the issue, and the 48-hour reclaim does not fire because the claim has an
|
||||
open PR. Unparking follows the existing rule below.
|
||||
- **Unparking is a claim like any other.** When the wait ends, the parked
|
||||
issue is work again and takes the slot. If you are already active
|
||||
elsewhere, finish or release that work first, and say which you did on
|
||||
both issues — the slot is still one. Nothing counts claims per builder
|
||||
and no reconciler path enforces any of this: `claim_decision()` sees one
|
||||
issue at a time by construction, and no such machinery should be built
|
||||
expecting it to have been specified here. The discipline is the
|
||||
declaration, not a counter.
|
||||
- **Abandoning is fine; ghosting is not.** If you stop, say where you got to,
|
||||
push the branch if it holds anything useful, unassign, and restore
|
||||
`ready`.
|
||||
starting. The claim promises a draft PR soon: a claim with no PR and no
|
||||
activity is what the staleness sweep reclaims, unless `offsite` records
|
||||
that its PR lives in another repo.
|
||||
- **A park is declared, never inferred.** Comment naming what the claim
|
||||
waits on and who owns the next move — no new label; the comment is the
|
||||
activity the reclaim clock reads, as for `needs-ruling` (#52) and
|
||||
`offsite` (#68). Shape 4 is exempt: the handoff comment and
|
||||
`state:needs-human` already say both.
|
||||
- **A declaration stands until the park's facts change**, so a resumption
|
||||
finding nothing changed posts nothing (#177). Each change owes one comment
|
||||
— the wait resolves or changes hands, the shape changes, the claim
|
||||
unparks. A parked claim with **no open PR** still feeds the 48-hour
|
||||
reclaim clock, so refresh the declaration before it closes; that is a
|
||||
park's only repeat.
|
||||
- **Pick up `attention` before anything else**: post a short pickup comment
|
||||
and remove the label, which is the ack. A demand on a parked claim is
|
||||
usually its unpark, so take the slot back — unless the demand *is* the
|
||||
park, the pickup comment then doubling as the declaration.
|
||||
- **A directed hold keeps its bookkeeping visible.** The PR carries
|
||||
`blocked` with a comment naming what it waits on; the issue stays
|
||||
`claimed` and carries `attention` until the builder acks. Nobody unassigns
|
||||
it, and the 48-hour reclaim does not fire while the claim has an open PR.
|
||||
- **Unparking is a claim like any other** and takes the slot: if you are
|
||||
active elsewhere, finish or release that work first and say which on both
|
||||
issues. No machinery counts claims per builder, and none should be built
|
||||
expecting this section to have specified one.
|
||||
- **Abandoning is fine; ghosting is not.** Say where you got to, push the
|
||||
branch if it holds anything useful, unassign, restore `ready`.
|
||||
|
||||
## Building
|
||||
|
||||
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
|
||||
body. `Closes #N` does not cross repos: when the PR is in a different repo
|
||||
from its authorizing issue, use `Part of <owner>/<repo>#N` instead, and
|
||||
in the same step set `offsite` and comment on that issue with the draft PR
|
||||
link as soon as the draft opens.
|
||||
Triage closes the authorizing issue by hand when its acceptance criteria
|
||||
are met; at that handoff the builder reports whether the cross-repo PR
|
||||
merged or closed and clears `offsite` in the same comment. The cross-repo
|
||||
merge never closes the authorizing issue. This codifies the linkage
|
||||
builders already used on rig#112 and ceremony #13/#16 rather than adding a
|
||||
new review obligation.
|
||||
`Closes #N` also does not survive a post-merge criterion: when the issue's
|
||||
body states that an acceptance criterion can only be checked after the
|
||||
merge — a live proof of a workflow trigger, a released-artifact check,
|
||||
anything whose subject does not exist until the change is on the base
|
||||
branch — the same-repo PR uses `Refs #N` instead, and triage closes the
|
||||
issue by hand on the evidence, exactly as it does for cross-repo work. The
|
||||
merge releases the claim: the issue moves to `post-merge`, the builder
|
||||
walks away, and triage owns verification and closure. If evidence later
|
||||
requires corrective build work, triage returns it to `ready` or mints a
|
||||
fresh `ready` issue; any builder claims from current `main`, and the
|
||||
original builder has no special standing.
|
||||
The issue body is what says so; you never judge which issues qualify, and
|
||||
absent that instruction `Closes #N` remains the default. The exception was
|
||||
bought the hard way: #143 carried `Closes #137` as doctrine then required,
|
||||
and the merge closed #137 with its post-merge criterion unmet (#151).
|
||||
Drafts are invisible to the reviewer panel on
|
||||
purpose — the draft phase is yours.
|
||||
- **The issue's acceptance criteria are your definition of done.** Reproduce
|
||||
them as a checklist in the PR body and check them honestly as you go. If
|
||||
one turns out to be wrong or unreachable, say so on the issue and get it
|
||||
amended by triage — do not silently ship less than the issue says.
|
||||
- Every behavior change writes one fragment, `changelog.d/<issue>.md`,
|
||||
named for the authorizing issue (`<repo>-<issue>.md` when the work is
|
||||
cross-repo) — the exact prose that will be published, nothing else: `- `
|
||||
bullets, and in a grouped repo the `### Added` / `### Changed` /
|
||||
`### Fixed` headings inside the fragment, creating a rarer kind only when
|
||||
a change genuinely is one. An entry is at most 300 characters — the
|
||||
fragment guard reds longer (#167) — so a genuinely long change ships
|
||||
several short entries, never one long one; wrapping an entry over
|
||||
continuation lines is fine and never counts against it. Never edit
|
||||
`CHANGELOG.md` for an entry — the
|
||||
release PR assembles the section from the fragments (#112); the monotonic
|
||||
guard still refuses anything that deletes a shipped heading.
|
||||
body. Drafts are invisible to the panel on purpose: that phase is yours.
|
||||
- **`Closes #N` does not cross repos.** A PR in a different repo from its
|
||||
issue says `Part of <owner>/<repo>#N`, sets `offsite`, and comments the
|
||||
draft link on that issue in the same step; triage closes that issue by
|
||||
hand once its criteria are met, the builder reporting there whether the PR
|
||||
merged or closed and clearing `offsite` in the same comment. The
|
||||
cross-repo merge never closes the authorizing issue (#13, #16).
|
||||
- **`Closes #N` does not survive a post-merge criterion.** Where the issue
|
||||
body says a criterion can only be checked after the merge — a workflow
|
||||
trigger proved live, a released artifact, anything whose subject does not
|
||||
exist until the change is on the base branch — the same-repo PR says
|
||||
`Refs #N`; the issue goes `post-merge` at the merge, the builder walks
|
||||
away, and triage owns verification and closure on the evidence, returning
|
||||
the issue to `ready` or minting a fresh one where corrective work is
|
||||
needed — claimable by any builder from current `main`, the original having
|
||||
no special standing. The issue body says so — you never judge which
|
||||
qualify — and absent it `Closes #N` is the default (#151).
|
||||
- On a `Refs #N` PR, never put a closing keyword (`close`, `closes`,
|
||||
`closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`)
|
||||
immediately before `#N` anywhere in the body, including the sentence
|
||||
explaining why the PR does not close it: GitHub reads the body by
|
||||
adjacency, not intent, and a code span does not protect the phrase (#200,
|
||||
#218). Put the number first (`#N is closed by hand`) or omit it.
|
||||
- **The issue's acceptance criteria are your definition of done**: reproduce
|
||||
them as a checklist in the PR body and check them honestly. One that turns
|
||||
out wrong or unreachable goes back to triage to be amended, never silently
|
||||
shipped short.
|
||||
- **Every behavior change writes one fragment**, `changelog.d/<issue>.md`
|
||||
named for the authorizing issue (`<repo>-<issue>.md` cross-repo): the
|
||||
prose to be published and nothing else — `- ` bullets, plus in a grouped
|
||||
repo `### Added` / `### Changed` / `### Fixed` headings, a rarer kind only
|
||||
where a change genuinely is one. An entry is at most 300 characters, so a
|
||||
long change ships several short ones (wrapping over continuation lines is
|
||||
free), and it **ends with its issue citation**: a parenthesised group of
|
||||
`#N`, `repo#N` or `owner/repo#N` separated by `, `, then the final `.` and
|
||||
nothing after — `(#262).`, `(#236, #250).` — which need not name the
|
||||
fragment's own issue, the filename carrying it. The guard reds a long
|
||||
entry (#167) and an uncited one (#262). Never edit `CHANGELOG.md`: the
|
||||
release PR assembles the section from fragments (#112), and the monotonic
|
||||
guard refuses anything deleting a shipped heading.
|
||||
- Follow the repo's conventions file and match the code you touch. Tests are
|
||||
not optional: the issue's test plan is the floor, not the ceiling.
|
||||
- **A write-capable job gets a repo-owned script, not a third-party
|
||||
action.** Where the token can write (`packages: write`, `contents: write`,
|
||||
`id-token: write`, deploy secrets), default to a script a test can drive;
|
||||
a third-party action there needs an established publisher and a
|
||||
full-commit-SHA pin, and read-only jobs still SHA-pin. The full rule and
|
||||
its red-flag profile are in REVIEWER.md §What you review against, item 2
|
||||
(#216).
|
||||
- **Scope discipline: the PR does the issue — whole, and nothing else.**
|
||||
Adjacent problems you discover go to a **discussion** (or a comment on the
|
||||
relevant issue), where triage will do its job. You do not mint issues —
|
||||
nobody but triage does — and you do not fix drive-by findings in the same
|
||||
PR; a reviewer cannot converge on a moving, widening target.
|
||||
Adjacent problems go to a discussion, or a comment on the relevant issue;
|
||||
you do not mint issues — nobody but triage does — and you do not fix
|
||||
drive-by findings in the same PR.
|
||||
|
||||
## The review round
|
||||
|
||||
(If you are reading this as `.ceremony/BUILDER.md` in a governed repo:
|
||||
repo-specific facts such as the panel roster live in that repo's own
|
||||
CONTRIBUTING; the shared flow lives here and is not restated there.)
|
||||
(In a governed repo this file is `.ceremony/BUILDER.md`: repo-specific facts
|
||||
such as the panel roster live in that repo's own CONTRIBUTING.)
|
||||
|
||||
1. Mark ready-for-review; request **the whole panel**. The panel is the roster
|
||||
of the repo the **PR** is in, minus you — never the roster of the repo the
|
||||
issue is in. The PR repo's `.github/labels.conf` `panel=` line is the
|
||||
machine's answer; its CONTRIBUTING roster is the human-readable answer,
|
||||
and `panel=` governs if they disagree because that is what the state
|
||||
machine reads. If the PR repo names no roster, ask triage on the
|
||||
authorizing issue before marking ready-for-review; do not guess. You may
|
||||
request an off-panel reviewer, but say that their verdict is advisory and
|
||||
does not become required. On rig#112 this distinction mattered: requesting
|
||||
codex and grok was correct for rig's panel even though ceremony's bench was
|
||||
larger, and the doctrine had not said which roster governed.
|
||||
**A review request requires a green check at the head.** A red check is
|
||||
the author's own signal, not the panel's work: if the check is red, that
|
||||
is your next task, not the panel's — fix it and push, then request. This
|
||||
binds *you*, whether or not any engine enforces it. "My local suite
|
||||
passed" is evidence about your machine; the check at the head is the
|
||||
shared artifact the panel actually reads, and a reviewer's first act is
|
||||
to read it. The one exception is a failure genuinely outside the PR — a
|
||||
runner outage, a flaky dependency, a failure already present on the
|
||||
default branch — and it is an exception only if the request says so
|
||||
explicitly and names the evidence (e.g. "the same job fails identically
|
||||
on `origin/main` at `<sha>`"). Silence about a red check is what is
|
||||
prohibited; an argued exception shifts the burden to the author.
|
||||
*Green* is a ruled term (operator, 2026-07-27): a **cancelled or
|
||||
stale** check is not a green head — the rollup is scoped to the current
|
||||
head, so what survives there is same-head cancellation, not
|
||||
supersession by a newer push — while a **skipped or neutral** one *is*
|
||||
green: those are deliberate "passed / not applicable" conclusions, and
|
||||
reddening them would red every conditional job the fleet skips on
|
||||
purpose. The costs behind the line are asymmetric: a false green spends
|
||||
a three-reviewer round; a false red spends one author session.
|
||||
1. Mark ready-for-review; request **the whole panel**: the PR repo's
|
||||
`panel[<your-login>]=` line if it defines one, else its `panel=` line,
|
||||
minus the author (#224) — never the roster of the repo the issue is in.
|
||||
That repo's `.github/labels.conf` governs over its CONTRIBUTING roster,
|
||||
being what the state machine reads; where it names no roster, ask triage
|
||||
on the authorizing issue rather than guess. An off-panel reviewer may be
|
||||
requested, said to be advisory and not required.
|
||||
|
||||
**A review request requires a green check at the head**, whether or not
|
||||
an engine enforces it: a red check is the author's own signal, so fix it
|
||||
and push, then request. The one exception is a failure genuinely outside
|
||||
the PR — a runner outage, a flaky dependency, a failure already on the
|
||||
default branch — and only where the request says so and names the
|
||||
evidence ("the same job fails identically on `origin/main` at `<sha>`");
|
||||
silence about a red check is what is prohibited, and an argued exception
|
||||
shifts the burden to the author.
|
||||
|
||||
*Green* is a ruled term (operator, 2026-07-27), read in two steps.
|
||||
**First take the check's word at this head**: its newest entry by start
|
||||
time — not completion, a cancelled run outliving its replacement's start
|
||||
— and never a `CANCELLED` entry while the same check has a non-cancelled
|
||||
one there. A check whose entries at the head are all cancelled has not
|
||||
reported at all and is not green — a collapse, not a new class, and the
|
||||
gate partitions alike, dropping a cancelled entry only where a
|
||||
non-cancelled survivor remains and leaving an all-cancelled context
|
||||
blocking (#139, #276). **Then classify that entry by `conclusion`, never
|
||||
`status`**, which can disagree with it (#259). No conclusion is not
|
||||
green: a configured run in progress is waited on, and waiting is
|
||||
compliance, not a stall. Cancelled or stale is not green, *stale* being a
|
||||
superseded head's check, which a head-scoped rollup never shows. Skipped
|
||||
or neutral is green, those being deliberate "passed / not applicable"
|
||||
conclusions. No checks configured is green — the third ruled case, not an
|
||||
argued exception, so the request goes out at once with no evidence owed;
|
||||
that never covers nothing-answered-yet, and the machine partitions alike,
|
||||
admitting the ask on `SUCCESS` and `NONE` (#236). The costs behind the
|
||||
line are asymmetric: a false green spends a three-reviewer round, a false
|
||||
red one author session. What the machine drops from the rollup before
|
||||
grading is crew's to describe.
|
||||
2. **Wait for every verdict, then answer the round whole** — one reply
|
||||
covering every point and stating what changed and what was verified.
|
||||
That reply is the written round record: the engine mirrors it under the
|
||||
PR body's **Round log**, newest last, so the builder owes the reply and
|
||||
no separate body edit. At re-request time the engine takes the author's
|
||||
comments posted after the newest verdict in the round and appends them
|
||||
with `<!-- round:<head-sha> -->`; an existing marker makes a retry a
|
||||
no-op. If the builder posted no reply, the engine records that the round
|
||||
passed without one and never blocks handoff on the omission. Then push
|
||||
the fixes, then re-request **by head, not by verdict**: if answering the
|
||||
round pushed any commit, every
|
||||
panelist's approval is now stale — an approval is of a specific tree,
|
||||
and the handoff predicate counts only approvals at the current head —
|
||||
so **every panelist is re-requested, the approvers included**; a
|
||||
panelist left un-re-requested after a push can never approve the tree
|
||||
you shipped, and the PR sits looking finished with a full set of
|
||||
verdicts and nothing owed by anyone, the same silent-stall shape as
|
||||
[#26](https://github.com/heavy-duty/ceremony/issues/26)/[#39](https://github.com/heavy-duty/ceremony/issues/39).
|
||||
Only when the head did not move — the round was answered with argument
|
||||
or evidence and nothing was pushed — do you re-request just the
|
||||
non-approvers: a standing approval already covers this exact head, and
|
||||
the engine absorbs a re-request at an unchanged head (the re-request
|
||||
rule, [#94](https://github.com/heavy-duty/ceremony/issues/94); its
|
||||
mechanism is crew's to describe). **The re-request carries the same
|
||||
green-check-at-head precondition as the first request**, argued
|
||||
exception included. This is where the measured cost landed: crew#40
|
||||
burned two consecutive heads and four reviewer-rounds, every one
|
||||
relaying a CI failure already visible in the job log (crew#45). A fix
|
||||
push whose check comes up red is not ready to go back to the panel; it
|
||||
is your next fix. Prefer verification over argument: when a
|
||||
reviewer doubts behavior, add the test that settles it.
|
||||
covering every point, stating what changed and what was verified. That
|
||||
reply is the written record: the engine mirrors it under the PR body's
|
||||
**Round log**, newest last and marked with the round's head, which makes
|
||||
a retry a no-op; you owe the reply and no body edit, and a round answered
|
||||
without one is recorded as such and never blocks handoff. Then push the
|
||||
fixes and re-request **by head, not by verdict**. A push makes every
|
||||
approval stale — an approval is of a specific tree, and the handoff
|
||||
predicate counts only approvals at the current head — so **every panelist
|
||||
is re-requested, approvers included**; one left un-re-requested can never
|
||||
approve the tree you shipped (#26, #39). Only where the head did not move
|
||||
— answered with argument or evidence, nothing pushed — do you re-request
|
||||
just the non-approvers; the engine absorbs a re-request at an unchanged
|
||||
head, and its mechanism is crew's to describe (#94). **The re-request
|
||||
carries the same green-check-at-head precondition**, argued exception
|
||||
included: a fix push whose check comes up red is your next fix, not the
|
||||
panel's. Prefer verification over argument — add the test that settles
|
||||
the doubt.
|
||||
3. Never dismiss a review, never merge, never mark your own work as passed.
|
||||
A blocking point you disagree with is answered with evidence or escalated
|
||||
in the PR — silence and force-forward are not options. A panel deadlock
|
||||
is one kind of human-owned decision; use the ruling ask below
|
||||
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||
in the PR; silence and force-forward are not options, and a panel
|
||||
deadlock is one kind of human-owned decision (#50 D11).
|
||||
|
||||
**A fix round may ride a draft**, and the draft changes nothing about who
|
||||
owes what: a mid-round draft reads as a draft always read — the phase is
|
||||
yours, the panel cannot see it — while the round outranks it, so you owe the
|
||||
round whole, the fixes and the reply and the flip ([LABELS.md](LABELS.md)'s
|
||||
`state:building` row, #205). **Ready-for-review is the act that ends the
|
||||
round, and it is the builder's alone**: the flip asserts the round was
|
||||
answered whole, the one judgement its author cannot delegate, so an engine
|
||||
may draft a PR but only the builder undrafts it. **Where a draft suppressed
|
||||
the checks, green is proven at the flip and the request still follows it** —
|
||||
marking ready runs the checks the draft held back, so the order is flip, let
|
||||
the head answer, then request, step 1's precondition and not a second one.
|
||||
Waiting there is compliance, and `blocker:unrequested` does not fire while a
|
||||
head's checks are pending or red (#236).
|
||||
|
||||
## The ruling ask
|
||||
|
||||
Set `needs-ruling` whenever a decision belongs to a human: org policy,
|
||||
published artifacts, secrets, prod, or any choice whose cost lands outside
|
||||
the PR. A panel deadlock is one instance, not the definition. The builder is
|
||||
the accountable flag-setter on a PR and consolidates the decision into one
|
||||
comment rather than forwarding several reviewers' phrasings
|
||||
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||
the PR — a panel deadlock is one instance, not the definition. The builder
|
||||
is the PR's accountable flag-setter and consolidates the decision into one
|
||||
comment rather than forwarding several reviewers' phrasings (#50 D11).
|
||||
|
||||
Keep at most these five lines above the fold and put all other analysis
|
||||
inside the fold. The field labels are fixed because the ruling machinery
|
||||
checks for them ([#50 D12](https://github.com/heavy-duty/ceremony/issues/50)):
|
||||
Keep at most these five lines above the fold, all other analysis inside it.
|
||||
The field labels are fixed because the ruling machinery checks for them (#50
|
||||
D12):
|
||||
|
||||
```text
|
||||
🧭 needs-ruling — <the decision, one line>
|
||||
|
|
@ -305,53 +245,48 @@ Default: <A at 2026-07-23T21:00Z if no ruling> | none — hard block
|
|||
The options must be exhaustive and mutually exclusive; more than three means
|
||||
the question is not ready. `Recommend:` is mandatory — omitting it hands the
|
||||
whole problem to the human. `Blocked:` names both what stops and what
|
||||
continues. Write a timed `Default:` only when you are affirmatively confident
|
||||
the decision is reversible inside the PR before merge. Unsure is not a tie:
|
||||
it is a hard block. Published artifacts, secrets, prod, and org policy are
|
||||
hard blocks by construction ([#50 D12–D13](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||
continues. Write a timed `Default:` only when affirmatively confident the
|
||||
decision is reversible inside the PR before merge; unsure is not a tie but a
|
||||
hard block, as published artifacts, secrets, prod and org policy are by
|
||||
construction (#50 D12–D13).
|
||||
|
||||
The ladder is anchored to the current episode's `needs-ruling` **`labeled`
|
||||
event**, not its `Default:` deadline or the last activity
|
||||
([#50 D13–D14](https://github.com/heavy-duty/ceremony/issues/50)):
|
||||
event**, not its `Default:` deadline or the last activity (#50 D13–D14):
|
||||
|
||||
- **0–12h:** proceed when a still-clear, reversible default expires, and say
|
||||
out loud that you did. A hard block waits.
|
||||
- **at 12h:** do not fire a stale default. Re-read it against what has landed
|
||||
and ask whether it still holds and whether reasonable doubt remains. If
|
||||
doubt has appeared, make it a hard block.
|
||||
- **at 24h:** proceed regardless, **as a PR**. Pick an option and state in the
|
||||
PR body which way you went and what doubt remains. Nothing merges by this;
|
||||
- **0–12h:** proceed when a still-clear, reversible default expires, saying
|
||||
out loud that you did; a hard block waits.
|
||||
- **at 12h:** do not fire a stale default — re-read it against what has
|
||||
landed, and where doubt has appeared, make it a hard block.
|
||||
- **at 24h:** proceed regardless, **as a PR**: pick an option and say in the
|
||||
body which way you went and what doubt remains. Nothing merges by this;
|
||||
the human still gates the merge.
|
||||
- **past 24h:** hand the choice to triage. Triage picks the option, records it
|
||||
as a decision, and remains accountable; the operator can overturn it at
|
||||
- **past 24h:** hand the choice to triage, which picks the option, records
|
||||
it as a decision, and stays accountable; the operator can overturn it at
|
||||
merge.
|
||||
|
||||
A re-flag starts a fresh ladder. The ladder applies whatever `Default:` says,
|
||||
including a hard block, and an active back-and-forth still climbs it. This is
|
||||
different from the 7-day nudge, which resets on real activity. The machine
|
||||
observes both clocks but never sets, clears, or decides `needs-ruling`.
|
||||
|
||||
The label stays until agreement is *reached*, not until the maintainer
|
||||
replies. The setter records the ruling, removes the label, and returns the
|
||||
item to its flow in the same comment ([LABELS.md](LABELS.md)).
|
||||
A re-flag starts a fresh ladder, which applies whatever `Default:` says,
|
||||
hard block included, and an active back-and-forth still climbs it — unlike
|
||||
the 7-day nudge, which resets on real activity. The machine observes both
|
||||
clocks but never sets, clears, or decides `needs-ruling`. The label stays
|
||||
until agreement is *reached*, not until the maintainer replies: the setter
|
||||
records the ruling, removes the label, and returns the item to its flow in
|
||||
the same comment ([LABELS.md](LABELS.md)).
|
||||
|
||||
## Handoff
|
||||
|
||||
When the round passes — every panel verdict approves the **current head**,
|
||||
and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if
|
||||
this is a release PR) — the engine performs these mechanical steps on the
|
||||
builder's behalf, in order:
|
||||
When the round passes — every panel verdict approving the **current head**,
|
||||
no `blocker:*` standing (conflicts rebased, CI green, drill recorded if this
|
||||
is a release PR) — the engine does these steps for the builder, in order:
|
||||
|
||||
1. request the human's review;
|
||||
2. set `state:needs-human`;
|
||||
3. post the engine-rendered handoff comment: approvals at the current head,
|
||||
the head SHA, and a pointer to the PR body's **Round log**.
|
||||
|
||||
The builder composes no new summary at handoff: the authored record already
|
||||
lives in the Round log, mirrored mechanically from each whole-round reply as
|
||||
specified above. The label write is optimistic — the reconciler validates
|
||||
it, and takes it back if the PR is not actually mergeable-right-now. Then
|
||||
stop: the PR is the human's. The claim is now parked as shape 4 (Picking,
|
||||
above) — the handoff you just posted is its declaration, and your build slot
|
||||
is free. Address what comes back (`state:addressing`) and re-hand-off the
|
||||
same way.
|
||||
The builder composes no new summary: the authored record already lives in
|
||||
the Round log, mirrored from each whole-round reply. The label write is
|
||||
optimistic — the reconciler validates it and takes it back if the PR is not
|
||||
mergeable-right-now. Then stop: the PR is the human's, and the claim parks
|
||||
as shape 4 (Picking, above), that comment its declaration and your slot
|
||||
free. Address what comes back (`state:addressing`) and re-hand-off the same
|
||||
way.
|
||||
|
|
|
|||
246
CHANGELOG.md
246
CHANGELOG.md
|
|
@ -5,10 +5,256 @@ published verbatim as that release's body (lib/changelog.sh extracts it),
|
|||
so entries say what changed, cite the issue, and stop — at most 300
|
||||
characters each, guard-enforced on the PR that writes the fragment (#167);
|
||||
a genuinely long change ships several short entries, never one long one.
|
||||
The citation is guard-enforced too, and it closes the entry: one `(#N)`
|
||||
group, then the final `.` and nothing after it (#262). Sections published
|
||||
before that rule keep their prose; the guard reads fragments only.
|
||||
Entries arrive as fragments — one `changelog.d/<issue>.md` per PR, never
|
||||
an edit to this file — and the release PR assembles them into the next
|
||||
section here (`bin/changelog-assemble`, #112).
|
||||
|
||||
This tree is `heavy-duty/ceremony` on
|
||||
`forgejo.heavyduty.builders`, and it tracks the upstream tree's version
|
||||
numbers (#197 D2). Two trees therefore answer to the same number, differing
|
||||
by the forge-compatibility delta. **This tree carries upstream through
|
||||
`8c3a4d1`** (upstream `0.6.0`, merged by #198); the `0.4.1` section below is
|
||||
this forge's own release, not upstream's, and upstream's separate `0.4.1`
|
||||
section is deliberately not carried — the tag published here is the one this
|
||||
section is the body of. Each sync updates this line
|
||||
(docs/UPSTREAM-SYNC.md, #200).
|
||||
|
||||
## 0.6.0 — 2026-08-05
|
||||
|
||||
### Added
|
||||
|
||||
- The issue-flow sweep's `claimed`-branch ruling pre-read is pinned: an
|
||||
unassigned claim under `needs-ruling` must draw its board diagnostic and
|
||||
its ruling nudge in one sweep, so a read that drifts below the diagnostic
|
||||
reds instead of silently costing the escalation 7 days (#284, #307).
|
||||
- The issue-flow sweep now flags a collision the board never declared: two
|
||||
open, unblocked issues whose titles name one deliverable draw a comment
|
||||
naming the newer's owed `Blocked by` edge. Keys normalize, so
|
||||
`actions/x` and `x` are one deliverable (#288).
|
||||
- The sweep now flags an unblocked non-member during a standing release
|
||||
window, naming the window's invariant. `claimed` counts, PR in flight or
|
||||
not. The gate is read from the release issue's own `Blocked by`
|
||||
declarations, and an emptied gate leaves it dormant (#292).
|
||||
- Both flags are advisory: comments only, no label write and no state
|
||||
change, deduped against each family's last word on the thread so a
|
||||
standing state re-sweeps silently (#293).
|
||||
- The fragment guard now requires each entry to end with its issue
|
||||
citation: one `(#N)` group — local, `repo#N` or `owner/repo#N`
|
||||
references separated by `, ` — then the final `.` and nothing after it
|
||||
(#262).
|
||||
- The refusal distinguishes an entry carrying no reference at all from one
|
||||
whose reference is present but not terminal, and names the shape to
|
||||
write in both (#262).
|
||||
- The 300-character bound still outranks the citation across the whole
|
||||
fragment, and the outranked problem stays out of the message it lost
|
||||
to: one fragment, one diagnosis, wherever in the file it sits (#262).
|
||||
- BUILDER.md now describes a fix round that rides a draft: the draft phase
|
||||
stays the builder's, ready-for-review is the builder's own act, and where a
|
||||
draft suppressed the checks green is proven at the flip (#258).
|
||||
- REVIEWER.md now reads a draft carrying `state:addressing` as a fix round in
|
||||
progress rather than abandonment (#258).
|
||||
- A `post-merge` item with no comment for 7 days now draws one nudge from the
|
||||
issue sweep: the wake evidence is owed. A starving criterion used to be
|
||||
found only when someone happened to run the right read (#254).
|
||||
- Label churn does not reset that clock, and neither does an assignment: on
|
||||
`post-merge` an assignee is an invalid composition, not activity, and it
|
||||
must not buy the item another 7 days of silence (#254).
|
||||
- The nudge names the triage actor from `triage-actors=`, not the human
|
||||
reviewer: `post-merge` is triage's completion queue, so the starved wake
|
||||
condition is triage's to answer (#254).
|
||||
- It links the item and parses nothing from the body — which criterion
|
||||
starved is prose, and the machine never judges prose (#254).
|
||||
- Like the ruling nudge it carries no idempotency marker on purpose: the
|
||||
comment is itself activity, so the rule self-rate-limits to one nudge per 7
|
||||
quiet days. Comment-only — no path here writes a label (#254).
|
||||
- Release epics now announce release initialization when their declared dependency gates clear (#253).
|
||||
- The issue sweep now echoes an issue's parsed `Blocked by` set as a comment
|
||||
whenever that set changes, so a readable-but-wrong declaration is visible in
|
||||
one sweep instead of days later, when a human happens to run the parser by
|
||||
hand (#252).
|
||||
- The echo's marker carries the parsed set itself: an unchanged parse never
|
||||
re-posts on a 15-minute cron, and a changed one always speaks. Comment-only
|
||||
— no path here writes a label (#252).
|
||||
- CI now refuses a root `*.md` declared in neither `docs/VENDORED.txt` nor the
|
||||
guard's short exemption list, so a new doctrine file can no longer reach a
|
||||
tag undeclared and stay invisible to every consumer's `docs-sync` (#251).
|
||||
- The same guard reads the manifest the other way: every entry must resolve to
|
||||
a regular, non-empty, tracked file — no symlink, no directory, no `../`
|
||||
escape (#251).
|
||||
- Document the optional, operator-ruled release-epic flow for governed repositories. (#248).
|
||||
- Guard documentation availability markers against missing issue citations
|
||||
and release candidates that already ship the cited work (#238).
|
||||
- The label and issue-flow sweeps now comment once per episode when
|
||||
`attention` targets a pull request or an unassigned issue, without
|
||||
retargeting the demand or changing labels or assignees (#232).
|
||||
- Pull requests that promise `Refs #N` now fail a read-only, body-edit-aware
|
||||
guard if GitHub would close N through a keyword or sidebar link (#218).
|
||||
|
||||
### Changed
|
||||
|
||||
- `README.md` is rewritten whole from the current tree: the front page names
|
||||
the governance repo ceremony now is, routes to `docs/CONSUMERS.md`,
|
||||
`AGENTS.md`, `LABELS.md` and `RELEASES.md` rather than restating them, and
|
||||
keeps the operator's release runbook as its core, re-measured (#311).
|
||||
- Standing release windows are dependency DAGs: every mint is placed in the window or behind it, and only current sources are `ready` (#292).
|
||||
- TRIAGE.md now requires unconditional collision-edge chains when open issues
|
||||
carry the same deliverable, keeping the ready queue concurrently claimable
|
||||
(#288).
|
||||
- TRIAGE.md now states its rules with bare record cites: the label-race and
|
||||
lifted-hold incident narratives leave the normative text while their
|
||||
operational rules remain complete (#282).
|
||||
- `BUILDER.md` states its rules and cites their record bare: the incident
|
||||
narratives, the links into issue comments and the cross-repo issue cites
|
||||
leave the normative text, which no rule leaves with them (#281).
|
||||
- CONTRIBUTING.md now keeps vendored doctrine self-contained: state the rule,
|
||||
retain at most one sentence of why, cite the local record bare, and leave the
|
||||
incident narrative in that record (#280).
|
||||
- BUILDER.md's green ruled term now says which entry to read before it says
|
||||
what an entry means: a check's word at a head is its newest entry by start
|
||||
time, and a cancelled entry is not that word while the same check carries a
|
||||
non-cancelled one at that head (#276).
|
||||
- A check whose every entry at the head is cancelled is unchanged — nothing
|
||||
survived to be its word, so it never reported and is not green — and the
|
||||
collapse mirrors `checks_state`'s carve-out rather than adding a class
|
||||
(#276).
|
||||
- BUILDER.md's step 1 now rules the checkless head: no checks configured is
|
||||
nothing to wait for, and the request goes out straight away — stated once,
|
||||
in the ruled-term paragraph, with the draft-round restatement removed
|
||||
(#272).
|
||||
- `README.md` and `RELEASES.md` derive `scope:docs`, and the
|
||||
`changelog-assembled`, `docs-sync` and `runner-isolated` actions and tests
|
||||
derive `scope:guards`; all five were mapped nowhere. The docs block matched
|
||||
a literal `README`, which this tree does not carry (#267).
|
||||
- `lib/read.sh` and `lib/ruling.sh` derive `scope:labels` beside
|
||||
`scope:release-flow`. Both reconcilers share them, and a mixed file wears
|
||||
both labels rather than `lib/**` being re-carved into a row per file (#267).
|
||||
- TRIAGE.md now tells every epic author to put its progress checklist under
|
||||
the literal `## Task list` heading, because any other heading is silently
|
||||
invisible to the completion sweep (#266).
|
||||
- TRIAGE.md now scopes the no-assignee board bug to flagging an unassigned
|
||||
issue, while still directing triage to repair ownership instead (#264).
|
||||
- `BUILDER.md` and `CHANGELOG.md` state the citation as guard-enforced
|
||||
rather than as house style, beside the 300-character bound it now sits
|
||||
next to (#262).
|
||||
- Four fragments in flight gained a terminal citation; published sections
|
||||
are untouched, so no shipped prose is re-opened (#262).
|
||||
- BUILDER.md's green ruled term now names its field: greenness is read from
|
||||
each check's `conclusion`, never its `status`, and *stale* means a check
|
||||
of a superseded head — not a same-head node whose `status` lags its own
|
||||
conclusion (#260).
|
||||
- Consumer guidance: re-vendor tooling reads the pin's `docs/VENDORED.txt`,
|
||||
never a hardcoded list, so a new doctrine file propagates at the next
|
||||
ordinary pin bump with zero list edits (#251).
|
||||
- Define the doors-unchanged drill record and an executable release-path list,
|
||||
so a release may reuse live evidence only when its door bytes are unchanged
|
||||
since the last rehearsed tag (#237).
|
||||
|
||||
### Fixed
|
||||
|
||||
- A roster edit no longer reds the whole suite: the labels-reconcile
|
||||
state-machine fixtures name their own panel instead of binding
|
||||
`.github/labels.conf` by slot (#304).
|
||||
- Shrinking `panel=` to three had left that binding's third slot unbound, and
|
||||
`set -u` aborted the file before its first assertion — 217 assertions
|
||||
became 0, on `main` and on every branch cut from it (#304).
|
||||
- The one case still reading the shipped roster asserts a property, not a
|
||||
size: it parses, and each member is recused from its own panel. Any
|
||||
`panel=` of one or more members leaves `test/run.sh` green (#304).
|
||||
- `lib/attention.sh` locates as label machinery beside its two shelf-mates —
|
||||
`[scope:release-flow]` alone was a wrong answer of the class #267 measured
|
||||
— and the map learns the sweep workflow pair, the shared-lib tests, and
|
||||
seven enumerated test/guard surfaces (#302).
|
||||
- Claiming a `needs-ruling` issue no longer buys its escalation another 7
|
||||
quiet days: the issue-side ruling clock reads comments alone — an
|
||||
assignment is the claim clock's fact — and LABELS.md now names what each
|
||||
surface's clock reads (#284).
|
||||
- `scope:release-flow` no longer rides every pull request: `changelog.d/**`
|
||||
is out of its path map. Doctrine makes every behavior change write a
|
||||
fragment, so the glob labelled 20 of the last 20 PRs while 3 touched a
|
||||
release surface. `CHANGELOG.md` stays, as only the release PR edits it
|
||||
(#267).
|
||||
- The issue-flow reconciler and its test now derive `scope:labels`, the scope
|
||||
that already names the taxonomy they reconcile (#267).
|
||||
- Abort issue-flow reconciliation when the board read fails instead of reporting a complete pass over an empty or partial result (#257).
|
||||
- The issue sweep no longer derives label writes from a read that failed. An
|
||||
HTTP 504 whose body is GitHub's JSON error object passed every guard and
|
||||
emptied the label set, so a healthy epic was written `needs-triage` and the
|
||||
pass reported success (#247).
|
||||
- A failed comments read no longer reclaims a live claim. Swallowed, it dated
|
||||
the issue by `created_at` and unassigned the builder under a comment
|
||||
asserting 48 hours of silence about an issue commented on seconds earlier
|
||||
(#247).
|
||||
- A failed comments read no longer reads as "no marker", which re-posted the
|
||||
comment the marker exists to suppress (#247).
|
||||
- Every read inside the per-issue subshell is checked explicitly, on its
|
||||
status and on its payload shape; the issue is left exactly as it is and the
|
||||
sweep continues. A partial pass names its skipped issues after
|
||||
`reconciled.` (#247).
|
||||
- A per-issue pass is now atomic: its writes and its log lines commit only
|
||||
once the pass completes. A skip could previously land after an earlier
|
||||
mutation, reporting an issue as untouched when a label had already been
|
||||
written or removed (#247).
|
||||
- The issue-flow sweep now reads an issue's deliverable as the `Refs` PR that
|
||||
merged last, not the one numbered highest — merge order is not number order,
|
||||
and the old rule spent the transition marker on the wrong PR (#242).
|
||||
- Preserve active claims when an open local pull request links them with `Refs #N`. (#241).
|
||||
- `blocker:unrequested` no longer fires while a head's checks are pending or
|
||||
red: the review round forbids requesting there, so the one blocker that
|
||||
demanded an act flagged builders for complying. Pending is CI's move, red is
|
||||
`blocker:ci-red`'s (#236).
|
||||
- `blocker:unrequested` now waits for the round to settle — the head and the
|
||||
newest verdict must have stood for `RECONCILE_UNREQUESTED_GRACE` (default
|
||||
300s) — so a sweep landing between a push and its re-request no longer flags
|
||||
a round in motion (#236).
|
||||
- LABELS.md no longer claims nothing in `actions/` clears or reads
|
||||
`attention`: the reconciler has done both since the derived `claimed` →
|
||||
`post-merge` transition shipped. The amended text keeps the hand-set rule
|
||||
and admits the one clear and the diagnostic read (#231).
|
||||
- Triage now puts `attention` on the assigned issue that owns a claim, never
|
||||
on its pull request, and treats an unassigned issue as a board bug rather
|
||||
than a demand (#230).
|
||||
|
||||
## 0.5.0 — 2026-08-03
|
||||
|
||||
### Added
|
||||
|
||||
- `labels.conf` accepts optional `panel[<login>]=` rows: the required set for
|
||||
a PR authored by that login is the row minus the author; other authors keep
|
||||
`panel=`. Consumers gain the row at their next pin bump — adding it before
|
||||
that bump is a parse failure that takes the label board down (#224).
|
||||
|
||||
### Changed
|
||||
|
||||
- Doctrine: third-party actions never hold a write-capable token by default —
|
||||
repo-owned scripts in write-capable jobs, established publisher plus
|
||||
full-SHA pin for the exception, SHA pins everywhere. Canonical in
|
||||
REVIEWER.md, short form in BUILDER.md; consumers adopt at the pin bump
|
||||
(#216).
|
||||
|
||||
### Fixed
|
||||
|
||||
- `ruling_escalation_row` selects the setter's best-shaped in-window comment,
|
||||
ties broken to the earliest, instead of the earliest outright — a whole-round
|
||||
reply landing seconds before the escalation is no longer graded in its place
|
||||
(crew#293).
|
||||
- The escalation selector and `ruling_shape_decision` share one field-presence
|
||||
matcher, and an undecodable body column scores 0 instead of erroring the
|
||||
sweep.
|
||||
- Five stale **unreleased** markers in `docs/CONSUMERS.md` now name their
|
||||
tags: fragment mode, `changelog-assembled` and `runner-isolated` at
|
||||
`0.2.0`; the additive labeler at `0.3.0`; the two-caller split at `0.4.1`
|
||||
(#221).
|
||||
- The marker convention now names its clearing owner: the release PR that
|
||||
ships machinery clears, in that same PR, every marker its assembled
|
||||
section makes false (#221).
|
||||
- A standing non-approving verdict now outranks draft in `decide_state`: a
|
||||
re-drafted PR mid-round reads `state:addressing`, a live panel request on a
|
||||
draft surfaces as `state:bots-reviewing`, and a draft with no round history
|
||||
still reads `state:building` (#205).
|
||||
|
||||
## 0.4.1 — 2026-08-04
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -94,6 +94,28 @@ table repeats it (#104).
|
|||
- Whole-version matching everywhere: `0.7.0` never matches `0.7.0-rc1`.
|
||||
- Shellcheck- and actionlint-clean is a CI gate, not a suggestion.
|
||||
|
||||
## Doctrine conventions
|
||||
|
||||
The vendored role files — `AGENTS.md`, `TRIAGE.md`, `BUILDER.md`,
|
||||
`REVIEWER.md`, `LABELS.md`, and `RELEASES.md` — state each normative rule
|
||||
completely, keep at most one sentence of why, and cite its record only with a
|
||||
bare parenthetical such as `(#N)`, `(#N D3)`, or `(#N, #M)`. Incident
|
||||
narrative — timestamps, actors, quoted comments, measured counts, and links to
|
||||
specific comments — belongs in that record. If a rule cannot be followed
|
||||
without chasing its cite, the rule is under-stated: fix the statement, not the
|
||||
citation. (#280)
|
||||
|
||||
Normative text in those files does not cite issues from other repositories.
|
||||
Consumers read the vendored bytes outside this organization's context, and a
|
||||
cited repository may not be public. A repo-boundary deferral remains allowed:
|
||||
it names another component as the owner of a fact rather than citing one of
|
||||
that component's issues. (#280)
|
||||
|
||||
This is distinct from the code-comment convention above: a code comment is
|
||||
read by a maintainer inside the organization while standing in the file,
|
||||
whereas vendored doctrine is read by any agent in any governed repository on
|
||||
every session. (#280)
|
||||
|
||||
## How the other repos use this
|
||||
|
||||
Two consumption modes, split by what has a runtime:
|
||||
|
|
@ -105,7 +127,7 @@ Two consumption modes, split by what has a runtime:
|
|||
"runtime" is an agent reading the working tree of the repo it stands in —
|
||||
a doc that requires a cross-repo fetch before it governs is a doc that
|
||||
sometimes goes unread. So the agent-facing set — **AGENTS.md, TRIAGE.md,
|
||||
BUILDER.md, REVIEWER.md, LABELS.md** — is vendored into each governed
|
||||
BUILDER.md, REVIEWER.md, LABELS.md, RELEASES.md** — is vendored into each governed
|
||||
repo at **`.ceremony/`**, byte-identical to this repo at the pinned ref,
|
||||
by the sync tool (issue #19). A CI guard diffs the mirror against the pin
|
||||
on every PR: hand-editing a vendored file, or bumping the pin without
|
||||
|
|
|
|||
31
LABELS.md
31
LABELS.md
|
|
@ -17,7 +17,7 @@ and the reconciler recomputes it from GitHub's own facts.
|
|||
|
||||
| Label | Color | Waiting on |
|
||||
|---|---|---|
|
||||
| `state:building` | `#FBCA04` | the builder — PR is a draft |
|
||||
| `state:building` | `#FBCA04` | the builder — pre-round: no verdict stands against the head. Draft is evidence for it, not the definition of it: a draft carrying a standing non-approving verdict is a fix round and reads `state:addressing` (#205) |
|
||||
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
|
||||
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up, or a ruling is pending |
|
||||
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
|
||||
|
|
@ -68,7 +68,16 @@ assignee, and comments with the remaining criteria verbatim. The comment says
|
|||
that the claim is released and that triage owes a follow-up naming the owner
|
||||
and wake condition for completion. Triage writes that full transition comment
|
||||
in the same tick when it or the operator makes the move by hand. The sweep
|
||||
never reclaims `post-merge`: weeks of quiet can be the state working.
|
||||
never reclaims `post-merge`: weeks of quiet can be the state working. It does
|
||||
make the quiet visible — after 7 days with no comment on the issue, the sweep
|
||||
posts one nudge naming the triage actor, saying the wake evidence is owed and
|
||||
linking the item. Only a comment resets that clock: label churn does not, and
|
||||
neither does an assignment, which is the claim clock's fact and on this queue
|
||||
state is the invalid composition flagged below. Which criterion starved is
|
||||
prose the machine never judges; the link is the payload. Like the ruling nudge
|
||||
it carries no idempotency marker on purpose — the comment is itself activity,
|
||||
so the rule self-rate-limits to one nudge per 7 quiet days — and it writes no
|
||||
label.
|
||||
|
||||
`post-merge` never composes with `blocked`; the transition comment carries the
|
||||
wait. It never composes with `attention`, because releasing the claim clears
|
||||
|
|
@ -145,8 +154,11 @@ label never removed — and a ruling with no real activity for 7 days draws a
|
|||
comment-only nudge addressed to the decider, linking the escalation. The
|
||||
nudge carries no marker on purpose: the comment is itself activity, so it
|
||||
resets its own window and never repeats within a quiet week. Label churn is
|
||||
not activity — the clock reads comments, reviews and commits, or the sweep
|
||||
would reset itself.
|
||||
never activity, or the sweep would reset itself — and each surface's clock
|
||||
reads what exists on it: on a pull request, comments, reviews and commits;
|
||||
on an issue, comments alone. An assignment is the claim clock's fact, not
|
||||
the ruling's — claiming a flagged issue does not answer it, and buys the
|
||||
escalation no quiet (#284).
|
||||
|
||||
`offsite` is issue-only and records that a claimed issue's deliverable lives
|
||||
in another repository, where a closing reference cannot make a local open PR
|
||||
|
|
@ -171,8 +183,15 @@ The flag is additive: it composes with `ready`, `claimed`, or `blocked` and
|
|||
with `needs-ruling`, and never substitutes for queue state. It pauses no
|
||||
clock. Unlike `offsite` and `needs-ruling`, which make silence legitimate,
|
||||
unanswered `attention` is exactly the silence the 48-hour reclaim should
|
||||
take. It is hand-set doctrine only: nothing in `actions/` sets, clears,
|
||||
reads, or validates it, and no reconciler enforces the assignee requirement.
|
||||
take. It is hand-set: the machine never sets `attention`, never assigns
|
||||
anyone to receive one, and never decides that one has been answered — the
|
||||
assignee's removal is the only ack. It writes the label in exactly one
|
||||
place, the derived `claimed` → `post-merge` transition below, and nowhere
|
||||
else; where it reads the flag it reads it to diagnose. The PR sweep comments
|
||||
when `attention` is put on a pull request, and the issue sweep comments when
|
||||
it is put on an issue with no assignee. Both diagnoses leave the label and
|
||||
assignees alone; the machine never infers the claim issue, decides that the
|
||||
demand was answered, or repairs either malformed shape.
|
||||
An `attention` issue without an assignee is therefore a board bug, not a
|
||||
demand; anyone may assign it or remove the flag. It never composes with
|
||||
`post-merge`, whose released claim has no assignee to answer the demand. The
|
||||
|
|
|
|||
610
README.md
610
README.md
|
|
@ -1,15 +1,66 @@
|
|||
# ceremony
|
||||
|
||||
One release ceremony for the whole heavy-duty family — implemented once,
|
||||
tested once, documented here, consumed everywhere else by reference. The
|
||||
approach and its constraints live in
|
||||
[#1](https://github.com/heavy-duty/ceremony/issues/1); this README is the
|
||||
operator-facing doctrine that used to live, three times over, in the
|
||||
consumers' CONTRIBUTINGs.
|
||||
The heavy-duty family's **governance repo**: the machinery every repo in the
|
||||
family runs, and the doctrine every agent in the family reads. Implemented
|
||||
once here, tested once here, consumed everywhere else — the machinery never
|
||||
copied at all, the doctrine only as a mirror a guard keeps byte-identical to
|
||||
the pin.
|
||||
|
||||
- **Adopting or converting a repo** → [docs/CONSUMERS.md](docs/CONSUMERS.md).
|
||||
- **Working in this repo as an agent** → [AGENTS.md](AGENTS.md) routes you;
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) has the repo specifics.
|
||||
Two kinds of thing live in this tree, and they are consumed in two different
|
||||
ways because they have two different runtimes.
|
||||
|
||||
**Machinery is consumed by reference, at a pin.** The reusable workflows in
|
||||
[`.github/workflows/`](.github/workflows/) and the composite actions in
|
||||
[`actions/`](actions/) are fetched by GitHub at run time from the ref the
|
||||
caller pins; no copy exists in the consumer. That machinery is two systems.
|
||||
The **release ceremony** — [`release.yml`](.github/workflows/release.yml),
|
||||
the decision and fact libraries under [`lib/`](lib/), and the guard actions
|
||||
that keep a release honest — is the operator-facing half, and the runbook
|
||||
below is its documentation. The **label and issue-flow machine** —
|
||||
[`labels.yml`](.github/workflows/labels.yml) and its detached sweep half
|
||||
[`labels-sweep.yml`](.github/workflows/labels-sweep.yml) (split in #209),
|
||||
driving [`labels-scope`](actions/labels-scope/),
|
||||
[`labels-reconcile`](actions/labels-reconcile/) and
|
||||
[`issueflow-reconcile`](actions/issueflow-reconcile/) — converges PR state
|
||||
and the issue work queue. What its labels *mean* is
|
||||
[LABELS.md](LABELS.md)'s contract, not this page's.
|
||||
|
||||
**Doctrine is consumed as a machine-verified mirror.** A document's only
|
||||
runtime is an agent reading the working tree it stands in, and a doc that
|
||||
needs a cross-repo fetch before it governs is a doc that sometimes goes
|
||||
unread. So the agent-facing set — the files named in
|
||||
[`docs/VENDORED.txt`](docs/VENDORED.txt) — is vendored into each governed
|
||||
repo at `.ceremony/`, byte-identical to this repo at the pinned ref, by
|
||||
[`actions/docs-sync`](actions/docs-sync/). A CI guard diffs the mirror
|
||||
against the pin on every PR: hand-editing a vendored file, or bumping the
|
||||
pin without re-syncing, goes red. It is a copy that cannot drift, which is
|
||||
the only kind of copy this org allows. This README is deliberately *not* in
|
||||
that set — a consumer's router is its `AGENTS.md`, not this repo's front
|
||||
page — and [`.github/scripts/vendored-check.sh`](.github/scripts/vendored-check.sh)
|
||||
records that reason beside the three other ceremony-only root docs.
|
||||
|
||||
**One pin governs both halves.** The ref a repo's workflow callers name is
|
||||
the ref its `.ceremony/` mirror is verified against, so a process change
|
||||
rolls out as one reviewed PR per repo: the pin line plus the re-synced
|
||||
mirror, checked by the same guard.
|
||||
|
||||
## Where to go
|
||||
|
||||
- **Adopting ceremony, or converting a repo that carries its own copy** →
|
||||
[docs/CONSUMERS.md](docs/CONSUMERS.md) — the bootstrap and conversion
|
||||
checklists, the caller stubs, the pin-bump procedure.
|
||||
- **Working in this repo as an agent** → [AGENTS.md](AGENTS.md) routes you
|
||||
to your role file ([TRIAGE.md](TRIAGE.md), [BUILDER.md](BUILDER.md),
|
||||
[REVIEWER.md](REVIEWER.md)); [CONTRIBUTING.md](CONTRIBUTING.md) carries
|
||||
this repo's own specifics — the review panel roster, the `scope:*` set,
|
||||
the code and doctrine conventions.
|
||||
- **The board: what a label means, and who may set it** →
|
||||
[LABELS.md](LABELS.md). It is the shared state machine; misusing one label
|
||||
lies to every other agent on the board.
|
||||
- **Family release windows — what ships together, and when** →
|
||||
[RELEASES.md](RELEASES.md).
|
||||
- **How the operator fleet is actually wired** → [FLEET.md](FLEET.md), a
|
||||
descriptive snapshot rather than doctrine.
|
||||
- **Operating a release, or staring at a red run on main** → read on.
|
||||
|
||||
## What a release is
|
||||
|
|
@ -23,13 +74,14 @@ stamps:
|
|||
([lib/version.sh](lib/version.sh)).
|
||||
2. **The changelog section is assembled — one edit, produced by the tool**
|
||||
(#112). Entries never accumulate in `CHANGELOG.md`: each PR wrote one
|
||||
fragment file, `changelog.d/<issue>.md`, and the ceremony PR runs
|
||||
[bin/changelog-assemble](bin/changelog-assemble) — by hand, on purpose,
|
||||
so the section lands in the PR's diff where the panel reads it (#112
|
||||
D12; a consumer's exact invocation is in
|
||||
[docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)).
|
||||
The tool folds every fragment into a new `## X.Y.Z — DATE` section on
|
||||
top and deletes the fragments it consumed; the
|
||||
fragment file, `changelog.d/<issue>.md`
|
||||
([the directory's marker](changelog.d/README.md) names the doctrine), and
|
||||
the ceremony PR runs [bin/changelog-assemble](bin/changelog-assemble) —
|
||||
by hand, on purpose, so the section lands in the PR's diff where the
|
||||
panel reads it (#112 D12; a consumer's exact invocation is in
|
||||
[docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)). The
|
||||
tool folds every fragment into a new `## X.Y.Z — DATE` section on top and
|
||||
deletes the fragments it consumed; the
|
||||
[assembled guard](#changelog-assembled--the-stamp-is-exactly-the-fragments)
|
||||
replays that run and refuses a stamp that is not byte-for-byte the
|
||||
fragments' assembly.
|
||||
|
|
@ -38,62 +90,79 @@ stamps:
|
|||
`## Unreleased` back on top — because every PR inserted at that one
|
||||
shared anchor, and between the stamp and the re-arm a PR authored
|
||||
*before* the release landed its entry under whatever now occupied the
|
||||
position — **the section that just shipped** — cleanly, no conflict,
|
||||
no signal (box#108; confirmed cross-repo as rig#66). Fragments make
|
||||
that failure structurally impossible rather than guarded-against: a
|
||||
fragment merged after the release simply sits in the directory and is
|
||||
assembled into the *next* section. There is no anchor left to misplace,
|
||||
and nothing to re-arm — the directory is always armed.
|
||||
position — **the section that just shipped** — cleanly, no conflict, no
|
||||
signal (box#108; confirmed cross-repo as rig#66). Fragments make that
|
||||
failure structurally impossible rather than guarded-against: a fragment
|
||||
merged after the release simply sits in the directory and is assembled
|
||||
into the *next* section. There is no anchor left to misplace, and nothing
|
||||
to re-arm — the directory is always armed.
|
||||
|
||||
3. **The drill record is present**: `drills/X.Y.Z.md`, non-blank — the
|
||||
evidence the release rests on
|
||||
([the drill doctrine](#the-drill-doctrine)).
|
||||
evidence the release rests on ([the drill doctrine](#the-drill-doctrine)).
|
||||
|
||||
(This repo's own ceremony adds a fourth stamp: `CEREMONY_SELF_REF` — the
|
||||
ref consumers' runs fetch this repo at — moves to the version being
|
||||
released, in [release.yml](.github/workflows/release.yml#L123-L132) and
|
||||
every other workflow that carries it.
|
||||
[self-ref-check.sh](.github/scripts/self-ref-check.sh) fails CI here, not
|
||||
a consumer's release, when it is stale.)
|
||||
(This repo's own ceremony adds a fourth stamp: `CEREMONY_SELF_REF` — the ref
|
||||
consumers' runs fetch this repo at — moves to the version being released, in
|
||||
[release.yml](.github/workflows/release.yml#L123-L132) and every other
|
||||
workflow that carries it.
|
||||
[self-ref-check.sh](.github/scripts/self-ref-check.sh) fails CI here, not a
|
||||
consumer's release, when it is stale.)
|
||||
|
||||
**The merge is the ship decision; the tag is transcription.** After the
|
||||
merge, [release.yml](.github/workflows/release.yml#L136-L300) asserts its
|
||||
merge, [release.yml](.github/workflows/release.yml#L136-L301) asserts its
|
||||
way to certainty, tags the merge commit, publishes the GitHub release with
|
||||
the version's own changelog section as the body — the curated prose, never
|
||||
the generated PR list
|
||||
([lib/changelog.sh](lib/changelog.sh) is the one canonical extractor) —
|
||||
and re-arms main by bumping to `X.Y.(Z+1)-dev`
|
||||
([release.yml](.github/workflows/release.yml#L266-L300)) — the version is
|
||||
the only re-arm left; the changelog needs none (#112). The machine does
|
||||
the transcription because humans err silently and machines fail loudly:
|
||||
the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one
|
||||
canonical extractor, and [bin/changelog-section](bin/changelog-section) is
|
||||
its command-line face) — and, on the bare-`X.Y.Z` path, re-arms main by
|
||||
bumping to `X.Y.(Z+1)-dev`; the version is the only re-arm left, the
|
||||
changelog needs none (#112). An rc ships too, and its next version is a human
|
||||
decision rather than arithmetic, so the re-arm stops for you to make it
|
||||
([The re-arm refused](#the-re-arm-refused-releaseyml)). The machine does the
|
||||
transcription because humans err silently and machines fail loudly:
|
||||
**everything asserts its way to certainty and fails loudly, creating
|
||||
nothing** — a wrong release is worse than a missing one, so every failed
|
||||
assert leaves zero artifacts: no tag, no release, no bump.
|
||||
nothing** — a wrong release is worse than a missing one, so every assert in
|
||||
this file fires *before its door creates anything*, and one that fails leaves
|
||||
zero artifacts of the run's own: no tag it made, no release, no bump. Three
|
||||
steps run past the tag, and what a failure at each leaves behind is what
|
||||
sorts them. Two fail before the release exists: the consumer's
|
||||
[artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and
|
||||
the publish, so its non-zero exit aborts, and the publish itself
|
||||
([`gh release create --verify-tag`](.github/workflows/release.yml#L246-L258))
|
||||
can fail on the API call or the assets. Either leaves the same state — a tag
|
||||
standing and no release — which the
|
||||
[nothing-exists assert](#the-merge-door-refused-releaseyml) names and the tag
|
||||
door recovers. The third is the re-arm, which runs after the publish, and its
|
||||
refusal is the single failure in this file that leaves a real release behind.
|
||||
|
||||
## The two doors
|
||||
|
||||
- **The merge door — the paved road.** A push to main
|
||||
([release.yml](.github/workflows/release.yml#L140)) runs the
|
||||
- **The merge door — the paved road.** A push to main runs the
|
||||
[decide table](#what-happens-when-my-pr-lands-on-main); a merged,
|
||||
`release`-labeled PR whose version transitioned to bare is the ceremony,
|
||||
everything legitimate that isn't one is a green no-op, and every
|
||||
half-ceremony dies loudly. Use it for every normal release.
|
||||
half-ceremony dies loudly
|
||||
([release.yml](.github/workflows/release.yml#L136-L301)). Use it for every
|
||||
normal release.
|
||||
|
||||
- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag
|
||||
push — **no `v` prefix**, box's 0.6.0 set the scheme
|
||||
([release.yml](.github/workflows/release.yml#L302-L369)) — publishes the
|
||||
- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag push
|
||||
— **no `v` prefix**, box's 0.6.0 set the scheme
|
||||
([release.yml](.github/workflows/release.yml#L303-L371)) — publishes the
|
||||
same way. The tag is the operator's explicit act, so there is no decide
|
||||
and no label check; the one assert is that **the tag names the tree's
|
||||
own version**, and a mismatch refuses, creating nothing. No `-dev` bump
|
||||
either — the fallback does not rewrite main (cast's precedent). Use it
|
||||
when the merge path is red, for backfills, and for the
|
||||
and no label check — what is left is two asserts: **the tag names the
|
||||
tree's own version**
|
||||
([L328–L339](.github/workflows/release.yml#L328-L339)) and **the tagged
|
||||
tree carries a publishable `## X.Y.Z` section**
|
||||
([L340–L352](.github/workflows/release.yml#L340-L352)); either failing
|
||||
refuses, creating nothing. No `-dev` bump either
|
||||
— the fallback does not rewrite main (cast's precedent). Use it when the
|
||||
merge path is red, for backfills, and for the
|
||||
[first-release edge](#what-happens-when-my-pr-lands-on-main) (row 4).
|
||||
|
||||
Tag + publish (+ the consumer's artifact hook) happen **in the same job,
|
||||
on purpose**: a `GITHUB_TOKEN`-created tag fires no workflows (GitHub's
|
||||
anti-recursion), so the merge door's tag can never re-enter the tag door
|
||||
and double-publish — and that job is the release's only chance to publish
|
||||
([release.yml](.github/workflows/release.yml#L223-L234), #1 constraint 2).
|
||||
Tag + publish (+ the consumer's artifact hook) happen **in the same job, on
|
||||
purpose**: a `GITHUB_TOKEN`-created tag fires no workflows (GitHub's
|
||||
anti-recursion), so the merge door's tag can never re-enter the tag door and
|
||||
double-publish — and that job is the release's only chance to publish (#1
|
||||
constraint 2).
|
||||
|
||||
## What happens when my PR lands on main
|
||||
|
||||
|
|
@ -101,8 +170,8 @@ The merge door runs on **every** push to main, and the `release` label
|
|||
legitimately means two things (release ceremonies, and ordinary work *on*
|
||||
the release machinery), so the door's first act is a decision: the six-row
|
||||
table in [lib/decide.sh](lib/decide.sh#L29-L61) (issue #8 — the comment
|
||||
block *is* the spec, and the table is contract-tested offline). Rendered
|
||||
for operators:
|
||||
block *is* the spec, and the table is contract-tested offline by
|
||||
[test/decide.test.sh](test/decide.test.sh)). Rendered for operators:
|
||||
|
||||
| # | the tree your merge produced | the run | what it means — and your move |
|
||||
|---|---|---|---|
|
||||
|
|
@ -111,11 +180,11 @@ for operators:
|
|||
| 3 | version bare, unchanged, already released | green `NOTICE`, no-op | The post-release window: the ceremony landed, the `-dev` bump hasn't. Nothing to do. |
|
||||
| 4 | version bare, unchanged, **never released** | **red, nothing created** | The label says ship but this PR did not mint the version. Mislabeled → drop the label. Meant to release → it forgot the bump; re-do the ceremony PR. A repo whose first version never carried `-dev` ships its first release by the **tag door** — the known first-release edge (cast#111; [lib/decide.sh](lib/decide.sh#L70-L74)). |
|
||||
| 5 | version transitioned to bare, **no merged `release`-labeled PR** behind the commit | **red, nothing created** | A transition nobody declared — a release is a labeled ceremony PR, not a bare push. Label a proper ceremony PR and re-do it, or publish by the tag door if the tree is genuinely right. |
|
||||
| 6 | version transitioned to bare, merged `release`-labeled PR behind the commit | **the ceremony** | Tag → notes → publish → `-dev` re-arm. Your move afterwards: verify the release exists and main reads `X.Y.(Z+1)-dev`. |
|
||||
| 6 | version transitioned to bare, merged `release`-labeled PR behind the commit | **the ceremony** | Tag → notes → publish → `-dev` re-arm. Your move afterwards: verify the release exists and main reads `X.Y.(Z+1)-dev`. **Read *bare* as decide reads it** — anything not `-dev` ([lib/decide.sh](lib/decide.sh#L108-L110)) — so an rc transition is a shippable ceremony here too, and the release lands but the re-arm stops for you to pick the next version ([The re-arm refused](#the-re-arm-refused-releaseyml)). |
|
||||
|
||||
The green rows are the point as much as the red ones: the machinery must
|
||||
be safe to work on, so every legitimate non-ceremony is a green `NOTICE`
|
||||
no-op — never a red run on main per infra PR
|
||||
The green rows are the point as much as the red ones: the machinery must be
|
||||
safe to work on, so every legitimate non-ceremony is a green `NOTICE` no-op,
|
||||
never a red run on main per infra PR
|
||||
([lib/decide.sh](lib/decide.sh#L6-L12)). The label is hand-set intent and
|
||||
automation never guesses; the version transition is the interlock, and
|
||||
label-without-transition (row 4) and transition-without-label (row 5) both
|
||||
|
|
@ -123,36 +192,54 @@ refuse (#1 constraint 8).
|
|||
|
||||
## The guards
|
||||
|
||||
Four composite actions run in every consumer's CI (and in this repo's
|
||||
own). Shared shape: version-keyed where the tree's state matters, loud
|
||||
where it fails, and **a file of its own so a test can drive it**. The full
|
||||
war stories are in the scripts' header comments — authoritative and longer
|
||||
than this; what follows is the operator's cut.
|
||||
[`actions/`](actions/) holds ten composite actions. Three belong to the
|
||||
label machine named above and are not the operator's business here. Of the
|
||||
remaining seven, a consumer's own `ci.yml` carries **five** guard steps —
|
||||
`changelog-armed`, `changelog-monotonic`, `changelog-assembled`,
|
||||
`drill-recorded` and [`runner-isolated`](actions/runner-isolated/), the last
|
||||
asserting that no `pull_request`-triggered workflow names a self-hosted
|
||||
runner (#58) — plus [`refs-not-closing`](actions/refs-not-closing/) in its
|
||||
own [`refs-guard.yml`](.github/workflows/refs-guard.yml) caller, because
|
||||
body edits are load-bearing there (#200, #218), and
|
||||
[`docs-sync`](actions/docs-sync/) once the repo adopts the agent team flow.
|
||||
The exact steps and their pin-availability rules are in
|
||||
[docs/CONSUMERS.md](docs/CONSUMERS.md).
|
||||
|
||||
The four below are the release's own, and this is the operator's cut of
|
||||
them. Shared shape: version-keyed where the tree's state matters, loud where
|
||||
it fails, and **a file of its own so a test can drive it**. The full war
|
||||
stories are in the scripts' header comments — authoritative and longer than
|
||||
this.
|
||||
|
||||
This repo eats what it serves: [`ci.yml`](.github/workflows/ci.yml) runs the
|
||||
guard actions against its own real tree, and
|
||||
[`release-exercise.yml`](.github/workflows/release-exercise.yml) replays the
|
||||
merge door's step sequence on every PR.
|
||||
|
||||
### changelog-armed — main never sits disarmed
|
||||
|
||||
**The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh)),
|
||||
keyed on the tree's shape, then its version. In **fragment mode** —
|
||||
`changelog.d/` exists, the arming property moved onto the directory
|
||||
(#112 D7):
|
||||
`changelog.d/` exists, the arming property moved onto the directory (#112
|
||||
D7):
|
||||
|
||||
- always → the marker `changelog.d/README.md` must exist (what keeps the
|
||||
directory tracked when it holds no fragments), no `## Unreleased`
|
||||
section may survive in `CHANGELOG.md` (a second anchor with no owner),
|
||||
and every fragment must be publishable on its own — named `<issue>.md`
|
||||
or `<repo>-<issue>.md`, no `## ` heading, at least one bullet, no
|
||||
`### ` heading without an entry. A malformed fragment fails the PR that
|
||||
wrote it, not the release that consumes it (#112 D9).
|
||||
- `-dev` tree → nothing more. The directory **is** the arming: the next
|
||||
PR's entry is a new file, and a new file always has somewhere to land.
|
||||
directory tracked when it holds no fragments), no `## Unreleased` section
|
||||
may survive in `CHANGELOG.md` (a second anchor with no owner), and every
|
||||
fragment must be publishable on its own — named `<issue>.md` or
|
||||
`<repo>-<issue>.md`, no `## ` heading, at least one bullet, no `### `
|
||||
heading without an entry. A malformed fragment fails the PR that wrote it,
|
||||
not the release that consumes it (#112 D9).
|
||||
- `-dev` tree → nothing more. The directory **is** the arming: the next PR's
|
||||
entry is a new file, and a new file always has somewhere to land.
|
||||
- bare tree (the ceremony PR and its merge) → every fragment must be
|
||||
consumed, and the top section must be the stamped, publishable section
|
||||
for exactly that version. Fragment mode has no re-armed shape — there
|
||||
is nothing left to re-arm.
|
||||
consumed, and the top section must be the stamped, publishable section for
|
||||
exactly that version. Fragment mode has no re-armed shape — there is
|
||||
nothing left to re-arm.
|
||||
|
||||
In **legacy mode** — no `changelog.d/` — the version-keyed rules stand
|
||||
verbatim; both shapes stay supported so a consumer adopts fragments on a
|
||||
pin bump, on its own schedule (#112 D8):
|
||||
verbatim; both shapes stay supported so a consumer adopts fragments on a pin
|
||||
bump, on its own schedule (#112 D8):
|
||||
|
||||
- `-dev` tree → the top section **must** be `## Unreleased`.
|
||||
- bare tree (the ceremony PR and its merge) → the top section may be
|
||||
|
|
@ -166,32 +253,30 @@ pin bump, on its own schedule (#112 D8):
|
|||
uses, so the two cannot disagree about what a section is).
|
||||
|
||||
**The incident**: box#108 / rig#66 — the silent mislanding described
|
||||
[above](#what-a-release-is). Fragment mode retires the incident's
|
||||
mechanism outright; legacy mode guards it. **Red means** a PR entry has
|
||||
nowhere safe to land — a missing marker, a surviving `## Unreleased`, a
|
||||
malformed fragment — or a stamped version would publish no entries, a
|
||||
dangling grouped heading, or a bare tree still carrying fragments the
|
||||
stamp did not consume (`not consumed` — re-run the assembler); the
|
||||
message names the fix in every case. What this guard cannot see is a
|
||||
fragment that *was* consumed but whose entry the stamp omits — the
|
||||
fragment is gone from HEAD, so only
|
||||
[above](#what-a-release-is). Fragment mode retires the incident's mechanism
|
||||
outright; legacy mode guards it. **Red means** a PR entry has nowhere safe
|
||||
to land — a missing marker, a surviving `## Unreleased`, a malformed
|
||||
fragment — or a stamped version would publish no entries, a dangling grouped
|
||||
heading, or a bare tree still carrying fragments the stamp did not consume
|
||||
(`not consumed` — re-run the assembler); the message names the fix in every
|
||||
case. What this guard cannot see is a fragment that *was* consumed but whose
|
||||
entry the stamp omits — the fragment is gone from HEAD, so only
|
||||
[changelog-assembled](#changelog-assembled--the-stamp-is-exactly-the-fragments)'s
|
||||
merge-base replay catches that loss.
|
||||
|
||||
**Do not "simplify" this to "always require `## Unreleased`".** The
|
||||
unconditional form is false by construction on the ceremony PR's own tree
|
||||
— it makes every release unshippable — and rig#44 and cast#108 both had
|
||||
to revert exactly that
|
||||
([the script's header](actions/changelog-armed/changelog-armed.sh#L8-L16)).
|
||||
The version-keyed form is what rig and cast get back by adopting this repo.
|
||||
unconditional form is false by construction on the ceremony PR's own tree —
|
||||
it makes every release unshippable — and rig#44 and cast#108 both had to
|
||||
revert exactly that. The version-keyed form is what rig and cast get back by
|
||||
adopting this repo.
|
||||
|
||||
One consequence worth knowing before it happens, legacy mode only: a
|
||||
ceremony PR that stamps and forgets to re-arm still passes this guard — a
|
||||
bare tree is allowed to be stamped. It goes red **the moment the automatic
|
||||
`-dev` bump lands on main**. The guard does not block the release; it
|
||||
refuses to let main *sit* disarmed, which is the window a late PR falls
|
||||
into. Fragment mode has no such window: with no re-arm step there is
|
||||
nothing to forget.
|
||||
into. Fragment mode has no such window: with no re-arm step there is nothing
|
||||
to forget.
|
||||
|
||||
### changelog-assembled — the stamp is exactly the fragments
|
||||
|
||||
|
|
@ -199,132 +284,138 @@ nothing to forget.
|
|||
([actions/changelog-assembled/changelog-assembled.sh](actions/changelog-assembled/changelog-assembled.sh)):
|
||||
on a release PR in fragment mode, the stamped `## X.Y.Z` section must be
|
||||
**byte-for-byte** what the fragments it consumed assemble to. The guard
|
||||
reads the fragments as of the merge base (they are gone from HEAD — that
|
||||
is the point of the ceremony), replays `changelog-assemble --check` over
|
||||
that set, and diffs the result against HEAD's section body. Every tree it
|
||||
does not apply to — a `-dev` tree, legacy mode, no consumed fragments —
|
||||
passes with a green `NOTICE`, so a non-ceremony PR is never red here.
|
||||
reads the fragments as of the merge base (they are gone from HEAD — that is
|
||||
the point of the ceremony), replays `changelog-assemble --check` over that
|
||||
set, and diffs the result against HEAD's section body. Every tree it does
|
||||
not apply to — a `-dev` tree, legacy mode, no consumed fragments — passes
|
||||
with a green `NOTICE`, so a non-ceremony PR is never red here.
|
||||
|
||||
**The failure it catches** (#116): assembly is a hand-run step by design —
|
||||
the section must land in the PR's diff where the panel reads it (#112 D12)
|
||||
— and a mis-run hand step can leave no trace. The two failure shapes
|
||||
differ, and the guards split them exactly as
|
||||
[test/changelog-assembled.test.sh](test/changelog-assembled.test.sh)'s
|
||||
trio rows record: leave a fragment **out of the deletion** and it survives
|
||||
on HEAD, where
|
||||
[changelog-armed](#changelog-armed--main-never-sits-disarmed) already
|
||||
refuses the bare tree (`not consumed`) — this guard goes red too, naming
|
||||
the entry the section lost. But **delete** a fragment while omitting its
|
||||
entry from the stamp, or hand-edit one word of the assembled prose, and
|
||||
nothing on HEAD is out of place: armed is green, monotonic is green, and
|
||||
the publisher would happily publish history that is not what the authors
|
||||
wrote. Only the merge-base replay catches those. The replay is what
|
||||
makes a hand-run step safe. **This guard needs history** — same stance as
|
||||
the monotonic guard: `fetch-depth: 0`, and in CI an unresolvable base is a
|
||||
hard failure, not a skip.
|
||||
the section must land in the PR's diff where the panel reads it (#112 D12) —
|
||||
and a mis-run hand step can leave no trace. The two failure shapes differ,
|
||||
and the guards split them exactly as
|
||||
[test/changelog-assembled.test.sh](test/changelog-assembled.test.sh)'s trio
|
||||
rows record: leave a fragment **out of the deletion** and it survives on
|
||||
HEAD, where [changelog-armed](#changelog-armed--main-never-sits-disarmed)
|
||||
already refuses the bare tree (`not consumed`) — this guard goes red too,
|
||||
naming the entry the section lost. But **delete** a fragment while omitting
|
||||
its entry from the stamp, or hand-edit one word of the assembled prose, and
|
||||
nothing on HEAD is out of place: armed is green, monotonic is green, and the
|
||||
publisher would happily publish history that is not what the authors wrote.
|
||||
Only the merge-base replay catches those. The replay is what makes a
|
||||
hand-run step safe. **This guard needs history** — same stance as the
|
||||
monotonic guard: `fetch-depth: 0`, and in CI an unresolvable base is a hard
|
||||
failure, not a skip.
|
||||
|
||||
### changelog-monotonic — shipped headings are append-only
|
||||
|
||||
**The rule**
|
||||
([actions/changelog-monotonic/changelog-monotonic.sh](actions/changelog-monotonic/changelog-monotonic.sh#L4-L7)):
|
||||
the set of `## X.Y.Z` headings on your branch must be a **superset** of
|
||||
the set at the merge base, and no heading may appear twice on HEAD. The
|
||||
rule needs no tuning because release headings are append-only by doctrine:
|
||||
the ceremony adds one and nothing ever legitimately removes one — so
|
||||
superset has no exception to carve. The ceremony's own stamp passes by
|
||||
construction: the assembler writes a new `## X.Y.Z — DATE` heading and
|
||||
removes none. Fragment mode changes nothing here (#112 D10): fragments add
|
||||
no `## ` heading, and `Unreleased` was never in the guard's set — it is
|
||||
not a version heading; it is
|
||||
([actions/changelog-monotonic/changelog-monotonic.sh](actions/changelog-monotonic/changelog-monotonic.sh)):
|
||||
the set of `## X.Y.Z` headings on your branch must be a **superset** of the
|
||||
set at the merge base, and no heading may appear twice on HEAD. The rule
|
||||
needs no tuning because release headings are append-only by doctrine: the
|
||||
ceremony adds one and nothing ever legitimately removes one — so superset
|
||||
has no exception to carve. The ceremony's own stamp passes by construction:
|
||||
the assembler writes a new `## X.Y.Z — DATE` heading and removes none.
|
||||
Fragment mode changes nothing here (#112 D10): fragments add no `## `
|
||||
heading, and `Unreleased` was never in the guard's set — it is not a version
|
||||
heading; it is
|
||||
[changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business —
|
||||
which is why a repo's adoption PR can delete it and stay green.
|
||||
|
||||
**The incidents**: box#122 (caught in review of box#118) — an author
|
||||
adding an entry under `## Unreleased` **replaced** the heading below it
|
||||
instead of inserting above it; git merges that cleanly, and the shipped
|
||||
section's body is silently absorbed into `## Unreleased`. And box#118
|
||||
itself — a bad rebase *duplicated* a shipped heading, which containment is
|
||||
blind to, which is why uniqueness-on-HEAD is a separate assert
|
||||
([the script](actions/changelog-monotonic/changelog-monotonic.sh#L96-L116)).
|
||||
**The incidents**: box#122 (caught in review of box#118) — an author adding
|
||||
an entry under `## Unreleased` **replaced** the heading below it instead of
|
||||
inserting above it; git merges that cleanly, and the shipped section's body
|
||||
is silently absorbed into `## Unreleased`. And box#118 itself — a bad rebase
|
||||
*duplicated* a shipped heading, which containment is blind to, which is why
|
||||
uniqueness-on-HEAD is a separate assert.
|
||||
|
||||
**Red means** a shipped section was deleted (put the heading back and
|
||||
insert **above** it) or duplicated (collapse to one heading; the failure
|
||||
message walks through both fixes with the diff to run). **This guard needs
|
||||
**Red means** a shipped section was deleted (put the heading back and insert
|
||||
**above** it) or duplicated (collapse to one heading; the failure message
|
||||
walks through both fixes with the diff to run). **This guard needs
|
||||
history**: the consumer's checkout must use `fetch-depth: 0`, and in CI an
|
||||
unresolvable base is a hard failure, not a skip — a guard that can quietly
|
||||
stop guarding is the failure shape this family of checks exists to refuse
|
||||
([strict mode](actions/changelog-monotonic/changelog-monotonic.sh#L60-L79)).
|
||||
stop guarding is the failure shape this family of checks exists to refuse.
|
||||
|
||||
### drill-recorded — a release carries its evidence
|
||||
|
||||
**The rule**
|
||||
([actions/drill-recorded/drill-recorded.sh](actions/drill-recorded/drill-recorded.sh#L23-L48)),
|
||||
keyed on the tree's version: a `-dev` tree passes with nothing to assert
|
||||
(a development tree ships nothing); a bare tree — the ceremony PR and its
|
||||
merge — must carry `drills/<version>.md` with at least one
|
||||
non-whitespace character. One file per version, so `0.9.0.md` and
|
||||
`0.9.0-rc1.md` are simply different files and prefix confusion is
|
||||
unrepresentable (#1 constraint 7).
|
||||
([actions/drill-recorded/drill-recorded.sh](actions/drill-recorded/drill-recorded.sh)),
|
||||
keyed on the tree's version: a `-dev` tree passes with nothing to assert (a
|
||||
development tree ships nothing); a bare tree — the ceremony PR and its merge
|
||||
— must carry `drills/<version>.md` with at least one non-whitespace
|
||||
character. One file per version, so `0.9.0.md` and `0.9.0-rc1.md` are simply
|
||||
different files and prefix confusion is unrepresentable (#1 constraint 7).
|
||||
|
||||
**The incident**: box's CONTRIBUTING said since box#96 that the release
|
||||
ritual must be run and recorded. No release ever did it — box#95, box#114
|
||||
and box#148 all shipped as a version bump plus a changelog stamp, because
|
||||
the gate was a sentence in a document and the only thing standing on it
|
||||
was a reviewer remembering to ask. The rule moved into CI, where it fires
|
||||
the gate was a sentence in a document and the only thing standing on it was
|
||||
a reviewer remembering to ask. The rule moved into CI, where it fires
|
||||
whether or not anyone is paying attention.
|
||||
|
||||
**Red means** the release is asserting a ritual it left no evidence of.
|
||||
**The fix is to run the drill** and record it — or to waive it *in
|
||||
writing* at the same path: the guard demands a **record, not a passing
|
||||
result** ([below](#the-drill-doctrine)).
|
||||
**The fix is to run the drill** and record it — or to waive it *in writing*
|
||||
at the same path: the guard demands a **record, not a passing result**
|
||||
([below](#the-drill-doctrine)).
|
||||
|
||||
## The drill doctrine
|
||||
|
||||
**Evidence, not success.** The guard asserts a record exists — a failed
|
||||
drill honestly written down satisfies it, and so does a maintainer waiver
|
||||
that says plainly the drill was waived and why. What it refuses is
|
||||
silence: a skip must cost a deliberate, reviewable file in the diff,
|
||||
which is precisely what box's three silent skips never produced. CI
|
||||
cannot run a consumer's drill (box's wants real hardware and the better
|
||||
part of an hour); it can only refuse a release that never ran one.
|
||||
that says plainly the drill was waived and why. What it refuses is silence:
|
||||
a skip must cost a deliberate, reviewable file in the diff, which is
|
||||
precisely what box's three silent skips never produced. CI cannot run a
|
||||
consumer's drill (box's wants real hardware and the better part of an hour);
|
||||
it can only refuse a release that never ran one.
|
||||
|
||||
**Each repo defines what its drill *means*** — the gate only reads the
|
||||
record. box asserts the **isolation contract**; rig asserts
|
||||
**convergence** (a machine reaches its role, idempotently); cast asserts
|
||||
**promotion** (A→B reproduces, the diff is idempotent); ceremony's own
|
||||
drill is a **door rehearsal** — both doors exercised end-to-end on a
|
||||
disposable repo (#11 names the six probes); incubator's is TBD in
|
||||
heavy-duty/incubator. Each repo states its meaning in its own
|
||||
`drills/README.md`. Three different exercises sharing a substrate is why
|
||||
the records are per-repo — they are not phases of one script.
|
||||
record. box asserts the **isolation contract**; rig asserts **convergence**
|
||||
(a machine reaches its role, idempotently); cast asserts **promotion** (A→B
|
||||
reproduces, the diff is idempotent); ceremony's own drill is a **door
|
||||
rehearsal** — both doors exercised end-to-end on a disposable repo, written
|
||||
out step by step in [drills/README.md](drills/README.md), with the records
|
||||
themselves in [drills/](drills/); incubator asserts the **staging verify** —
|
||||
the canonical candidate deployed, its smoke probe run *inside* the staging
|
||||
container on the deployed environment's credentials, the record pinning the
|
||||
commit SHA and image digest that were exercised
|
||||
([heavy-duty/incubator `drills/README.md`](https://github.com/heavy-duty/incubator/blob/main/drills/README.md)).
|
||||
Each repo states its meaning in its own `drills/README.md`. Five different
|
||||
exercises sharing a substrate is why the records are per-repo — they are not
|
||||
phases of one script.
|
||||
|
||||
**Drills exercise candidate refs, not released artifacts.** A ref is a
|
||||
static identifier that exists as soon as the release branch does, so no
|
||||
repo has to be released — or drilled — before another can be drilled:
|
||||
what looks like a box↔rig recursion at runtime dissolves into two
|
||||
independent tests against one fixed pair of refs. And drilling the
|
||||
candidate *is* drilling the release: a ceremony PR's diff is the stamps
|
||||
and nothing else, so no executable byte differs between the tree that was
|
||||
drilled and the tree that ships.
|
||||
static identifier that exists as soon as the release branch does, so no repo
|
||||
has to be released — or drilled — before another can be drilled: what looks
|
||||
like a box↔rig recursion at runtime dissolves into two independent tests
|
||||
against one fixed pair of refs. And drilling the candidate *is* drilling the
|
||||
release: a ceremony PR's diff is the stamps and nothing else, so no
|
||||
executable byte differs between the tree that was drilled and the tree that
|
||||
ships.
|
||||
|
||||
**A cross-repo release set shares one run ID.** Each repo records its own
|
||||
legs in its own `drills/X.Y.Z.md`, citing that run ID and the sibling
|
||||
SHAs, so the records reconcile afterwards — but the guard only ever reads
|
||||
the repo it runs in. If a defect shows up only in the combination: patch,
|
||||
re-drill, re-record. The set converges; it is not required to be right in
|
||||
one pass.
|
||||
legs in its own `drills/X.Y.Z.md`, citing that run ID and the sibling SHAs,
|
||||
so the records reconcile afterwards — but the guard only ever reads the repo
|
||||
it runs in. If a defect shows up only in the combination: patch, re-drill,
|
||||
re-record. The set converges; it is not required to be right in one pass.
|
||||
|
||||
## Troubleshooting red main
|
||||
|
||||
Every refusal the release flow can emit, verbatim, with cause and remedy.
|
||||
The catalog is generated from the sources, not paraphrased — regenerate
|
||||
it with:
|
||||
The catalog is generated from the sources, not paraphrased — regenerate it
|
||||
with:
|
||||
|
||||
```sh
|
||||
grep -n -A2 'refuse \|>&2' lib/decide.sh lib/facts.sh .github/workflows/release.yml
|
||||
grep -n -A2 'refuse \|>&2' \
|
||||
lib/decide.sh lib/facts.sh lib/version.sh .github/workflows/release.yml
|
||||
```
|
||||
|
||||
`$VER`-style variables appear as the run interpolates them.
|
||||
`$VER`-style variables appear as the run interpolates them. One refusal is
|
||||
outside that command by construction: `version_read: $path: no version field`
|
||||
is a `console.error` inside the node one-liner at
|
||||
[lib/version.sh#L55](lib/version.sh#L55) — no `>&2`, no `refuse `, so the grep
|
||||
cannot see it. It is quoted below as it reaches the log at run time, which is
|
||||
the convention this catalog is written to.
|
||||
|
||||
### The decision refused ([lib/decide.sh](lib/decide.sh))
|
||||
|
||||
|
|
@ -347,12 +438,11 @@ or — if the tree is genuinely the release — publish by the tag door.
|
|||
> the version '$VER' is bare and unchanged, but RELEASED is empty — this state is decided by whether '$VER' is already released, and the caller did not establish that fact. Refusing to guess — creating nothing.
|
||||
> the version transitioned ('$BASE_VER' -> '$VER') but LABELED is empty — a transition ships only behind a merged, release-labeled PR, and the caller did not establish that fact. Refusing to guess — creating nothing.
|
||||
|
||||
The fact-gathering guards
|
||||
([L92–L105](lib/decide.sh#L92-L105), [L135](lib/decide.sh#L135),
|
||||
[L151](lib/decide.sh#L151)): a missing fact must never fall through to
|
||||
"no". These indicate a bug upstream in [lib/facts.sh](lib/facts.sh) or the
|
||||
workflow plumbing, not an operator mistake — read the run's `facts:`
|
||||
stderr line and file what you find.
|
||||
The fact-gathering guards ([L92–L105](lib/decide.sh#L92-L105),
|
||||
[L135](lib/decide.sh#L135), [L151](lib/decide.sh#L151)): a missing fact must
|
||||
never fall through to "no". These indicate a bug upstream in
|
||||
[lib/facts.sh](lib/facts.sh) or the workflow plumbing, not an operator
|
||||
mistake — read the run's `facts:` stderr line and file what you find.
|
||||
|
||||
### The facts could not be established ([lib/facts.sh](lib/facts.sh), [lib/version.sh](lib/version.sh))
|
||||
|
||||
|
|
@ -367,44 +457,53 @@ stderr line and file what you find.
|
|||
> version_read: node is required for version-source: package-json
|
||||
|
||||
[lib/version.sh](lib/version.sh#L16-L66): the tree's version source is
|
||||
missing, empty, or unreadable. A wrong release is worse than a missing
|
||||
one, so an unreadable state is never an empty print — restore the
|
||||
`VERSION` file (or `package.json` version field) on main.
|
||||
missing, empty, or unreadable. A wrong release is worse than a missing one,
|
||||
so an unreadable state is never an empty print — restore the `VERSION` file
|
||||
(or `package.json` version field) on main.
|
||||
|
||||
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L300))
|
||||
> version_read: unknown backend: $backend
|
||||
|
||||
[L62](lib/version.sh#L62): not an operator mistake and not reachable through
|
||||
the release flow — [lib/facts.sh](lib/facts.sh#L33-L40) rejects a bad
|
||||
`VERSION_SOURCE` with the message above before `version_read` is ever called,
|
||||
so this line can only appear when some *other* caller invokes `version_read`
|
||||
directly with a backend that is neither `file` nor `package-json`. Fix that
|
||||
caller.
|
||||
|
||||
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L301))
|
||||
|
||||
> CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release
|
||||
|
||||
[L202–L205](.github/workflows/release.yml#L202-L205): the ceremony merged
|
||||
without its stamp (a state the
|
||||
[armed guard](#changelog-armed--main-never-sits-disarmed) already refuses
|
||||
on the PR — red main here means it was overridden). Stamp the section on
|
||||
main, then publish by the tag door.
|
||||
[armed guard](#changelog-armed--main-never-sits-disarmed) already refuses on
|
||||
the PR — red main here means it was overridden). Stamp the section on main,
|
||||
then publish by the tag door.
|
||||
|
||||
> tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing.
|
||||
> release '$VER' already exists — refusing to re-release, creating nothing.
|
||||
|
||||
[L207–L222](.github/workflows/release.yml#L207-L222), the nothing-exists
|
||||
[L208–L223](.github/workflows/release.yml#L208-L223), the nothing-exists
|
||||
assert — what makes a re-run of a completed ceremony refuse instead of
|
||||
clobber, and what catches a manual tag racing the merge. If the release
|
||||
truly exists, there is nothing to do: this red is the system declining to
|
||||
do the thing twice. If the tag exists but the release does not (a manual
|
||||
tag won the race, or
|
||||
[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook)), recover by
|
||||
the tag door: delete and re-push the tag, or `gh release create` by hand
|
||||
from a fixed tree.
|
||||
truly exists, there is nothing to do: this red is the system declining to do
|
||||
the thing twice. If the tag exists but the release does not (a manual tag
|
||||
won the race, or
|
||||
[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook), or the publish
|
||||
step itself failing after the tag), recover by the tag door: delete and
|
||||
re-push the tag, or `gh release create` by hand from a fixed tree.
|
||||
|
||||
> direct push refused (branch protection?) — opening the bump PR instead
|
||||
|
||||
[L292–L300](.github/workflows/release.yml#L292-L300) — loud, but not a
|
||||
[L293–L301](.github/workflows/release.yml#L293-L301) — loud, but not a
|
||||
refusal: the post-release `-dev` bump could not push directly, so the run
|
||||
opened a `release`-labeled bump PR itself. Your move: merge it promptly —
|
||||
until it lands, main is sitting bare, where a dev install
|
||||
[impersonates the release](.github/workflows/release.yml#L291) and the
|
||||
until it lands, main is sitting bare, where a dev install impersonates the
|
||||
release and the
|
||||
[armed guard's window](#changelog-armed--main-never-sits-disarmed) stays
|
||||
open.
|
||||
|
||||
### The tag door refused ([release.yml](.github/workflows/release.yml#L302-L369))
|
||||
### The tag door refused ([release.yml](.github/workflows/release.yml#L303-L371))
|
||||
|
||||
> tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing.
|
||||
> A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit.
|
||||
|
|
@ -416,40 +515,99 @@ remedy.
|
|||
|
||||
[L346–L349](.github/workflows/release.yml#L346-L349). The tagged tree was
|
||||
never stamped. Assemble the section
|
||||
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)),
|
||||
then delete and re-push the tag.
|
||||
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then
|
||||
delete and re-push the tag.
|
||||
|
||||
### The re-arm refused ([release.yml](.github/workflows/release.yml#L267-L301))
|
||||
|
||||
The bump belongs to the merge door alone — the tag door deliberately does not
|
||||
rewrite main ([L303–L307](.github/workflows/release.yml#L303-L307)) — and it
|
||||
runs *after* the tag, the notes and the publish. So a refusal here leaves a
|
||||
real release standing behind a main that never re-armed — the release exists,
|
||||
and main is left *armed to impersonate* it, still reading the version it just
|
||||
shipped ([L266](.github/workflows/release.yml#L266)). That is the one failure
|
||||
in this catalog whose remedy is a manual bump, not a re-run.
|
||||
|
||||
> version_next_dev: refusing '$ver' — expected bare X.Y.Z
|
||||
|
||||
[L86](lib/version.sh#L86): the version reaching the bump is not bare `X.Y.Z`.
|
||||
Two senses of *bare* meet here, and the gap between them is the **rc release
|
||||
path** — the way this refusal is actually reached, and designed behaviour
|
||||
rather than a decide bug. decide calls a version bare when it is not `-dev`
|
||||
([version_is_dev](lib/version.sh#L68-L76) matches that suffix and nothing
|
||||
else), so row 6 admits a transition to `1.2.3-rc1`, and a labeled rc ceremony
|
||||
is designed to ship ([lib/decide.sh](lib/decide.sh#L108-L110)).
|
||||
`version_next_dev` means `^[0-9]+\.[0-9]+\.[0-9]+$`. An rc sits between the
|
||||
two, and nothing filters it out on the way: the step's only gate is
|
||||
`ceremony == 'yes'` and its `VER` is the tree's version verbatim. So an rc
|
||||
ceremony tags, writes the notes, publishes — and *then* the re-arm refuses.
|
||||
That is the machine correctly declining to guess rather than a bug: an rc's
|
||||
next version "is a human decision, not arithmetic"
|
||||
([L78–L82](lib/version.sh#L78-L82)), so make the decision and bump main by
|
||||
hand to it. A `-dev` version reaching this line is the same refusal's other
|
||||
half, and *that* half is unreachable as the doors stand — rows 1–2 send `-dev`
|
||||
to a no-op, and the tag door never bumps. A malformed version is not: nothing
|
||||
upstream checks the shape ([version_read](lib/version.sh#L22-L33) checks only
|
||||
that a version is present and non-empty), so `banana` rides row 6 exactly as
|
||||
an rc does, and the same manual bump is the remedy. One note on work that has
|
||||
not landed: #317 would make rc cuts native and their re-arm deterministic, and
|
||||
if it lands only the malformed half still reaches this refusal.
|
||||
|
||||
> version_write: npm is required for version-source: package-json
|
||||
|
||||
[L106](lib/version.sh#L106): the package-json backend needs npm to write —
|
||||
`npm pkg set version=` plus a lockfile-only `npm install`
|
||||
([L114–L115](lib/version.sh#L114-L115)), never `npm version`, which would tag
|
||||
— and the runner has none. The read path fails the same way one step earlier
|
||||
(`node is required…`, above), so a run reaching *this* message got past the
|
||||
read — set up node/npm in the caller.
|
||||
|
||||
> version_write: unknown backend: $backend
|
||||
|
||||
[L118](lib/version.sh#L118): the write-side twin of `version_read: unknown
|
||||
backend`, and unreachable for the same reason — `VERSION_SOURCE` was validated
|
||||
before either was called. Fix the caller.
|
||||
|
||||
In every case the remedy has the same shape — bump `VERSION` (or the
|
||||
`package.json` version field) by hand and push: `X.Y.(Z+1)-dev` where the
|
||||
shipped version was bare, and where it was an rc, whatever you have decided
|
||||
comes next. Note that a *push* refusal is not one of these — branch
|
||||
protection is expected, and the step opens the bump PR itself rather than
|
||||
failing ([L293–L301](.github/workflows/release.yml#L293-L301)).
|
||||
|
||||
### Red main that is not the release workflow
|
||||
|
||||
Consumer CI runs its guard steps on pushes to main too (this repo's
|
||||
[ci.yml](.github/workflows/ci.yml) does the same). The one guard red an
|
||||
operator will actually meet on main is **changelog-armed after a re-arm
|
||||
was forgotten — legacy mode only**: the ceremony stamped without putting
|
||||
`## Unreleased` back, the release's own `-dev` bump landed, and the guard
|
||||
now says (first line):
|
||||
operator will actually meet on main is **changelog-armed after a re-arm was
|
||||
forgotten — legacy mode only**: the ceremony stamped without putting
|
||||
`## Unreleased` back, the release's own `-dev` bump landed, and the guard now
|
||||
says (first line):
|
||||
|
||||
> changelog-armed: the version is '$ver' (a development tree) but the top
|
||||
> section of $changelog is: …
|
||||
|
||||
The fix is a one-line PR: add an empty `## Unreleased` above the stamped
|
||||
section. The full message carries the same instruction. Fragment mode has
|
||||
no re-arm to forget, so it has no equivalent red on main — its refusals
|
||||
(a missing marker, a surviving `## Unreleased`, a malformed or unconsumed
|
||||
section. The full message carries the same instruction. Fragment mode has no
|
||||
re-arm to forget, so it has no equivalent red on main — its refusals (a
|
||||
missing marker, a surviving `## Unreleased`, a malformed or unconsumed
|
||||
fragment) all fire on the PR that caused them, where the author is still
|
||||
holding it.
|
||||
|
||||
## Design lineage
|
||||
|
||||
The ceremony converged across box#83 → box#96, rig#32 → rig#47, and
|
||||
cast#96 → cast#111; this repo is those three implementations folded into
|
||||
one (the drift that motivated it is measured in
|
||||
[#1](https://github.com/heavy-duty/ceremony/issues/1)). The load-bearing
|
||||
constraints — each bought with an incident, none of them safe to
|
||||
"simplify" away — are listed in
|
||||
[#1](https://github.com/heavy-duty/ceremony/issues/1) and carried, with
|
||||
their war stories, in the headers of the scripts they bind:
|
||||
[release.yml](.github/workflows/release.yml#L1-L109),
|
||||
[lib/decide.sh](lib/decide.sh#L1-L74),
|
||||
[lib/facts.sh](lib/facts.sh#L1-L24), and the four
|
||||
[guard scripts](actions/). The comments are the documentation of record;
|
||||
this README is their operator-facing cut.
|
||||
The ceremony converged across box#83 → box#96, rig#32 → rig#47 and cast#96 →
|
||||
cast#111; this repo is those three implementations folded into one, and the
|
||||
drift that motivated it is measured in
|
||||
[#1](https://github.com/heavy-duty/ceremony/issues/1), which also lists the
|
||||
load-bearing constraints — each bought with an incident, none of them safe
|
||||
to "simplify" away. The label machine's own record is #10, #11 and #130; the
|
||||
issue-flow queue's is #15, #16 and #73; the fragment changelog's is #112 and
|
||||
#116; the sweep/trigger split is #209.
|
||||
|
||||
The narrative lives in those issues, by design: the war stories are carried
|
||||
in the headers of the scripts they bind —
|
||||
[release.yml](.github/workflows/release.yml),
|
||||
[lib/decide.sh](lib/decide.sh), [lib/facts.sh](lib/facts.sh) and the
|
||||
[guard scripts](actions/) — and those comments are the documentation of
|
||||
record. This README is their operator-facing cut.
|
||||
|
|
|
|||
125
RELEASES.md
Normal file
125
RELEASES.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# Release management
|
||||
|
||||
This file describes the release-management pattern available to governed
|
||||
repositories. Adoption is per repository and operator-ruled: a repository
|
||||
without version epics is not out of compliance. A repo-local roadmap is the
|
||||
map; each epic remains the source of truth for its own release. Where an older
|
||||
repo-local description differs from this file, this file governs.
|
||||
|
||||
## The ladder
|
||||
|
||||
Represent each planned release with one version epic. The epic is the working
|
||||
surface for that release: it states the goal, names the members, and records
|
||||
the ordered waves as checklists. Keep the machine-readable progress checklist
|
||||
under a heading matching `## Task list`, case-insensitively; the issue-flow
|
||||
sweep reads task rows there until the next heading when it decides whether to
|
||||
nudge triage about a completed epic. Other member or wave headings are not
|
||||
completion inputs.
|
||||
|
||||
Keep a short repo-local roadmap beside the epics. The roadmap shows the whole
|
||||
ladder and points to each working surface; it does not duplicate the live
|
||||
member lists or ordering. crew's roadmap discussion [heavy-duty/crew#338](https://github.com/heavy-duty/crew/discussions/338)
|
||||
maps the ladder whose `0.1.2` working surface moved from the crufty ledger
|
||||
[heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to
|
||||
[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346).
|
||||
|
||||
## Gates
|
||||
|
||||
Each version epic declares `Blocked by <predecessor>`. Special ordering — a
|
||||
double gate or an out-of-chain gate — is written explicitly on that epic;
|
||||
there is no hidden global schedule. The epic carries `epic` and the
|
||||
repository's release label, with no queue label. Its `Blocked by` line is a
|
||||
declaration a human reads: shipping closes the predecessor, then triage opens
|
||||
the next window by hand as the first step of release-init. The issue-flow
|
||||
sweep does not promote version epics; automating that gate would require a
|
||||
separately specified change to its queue-category model.
|
||||
|
||||
The gate orders windows, not their contents. Members enter a release only by
|
||||
decision during release-init. The double gate on
|
||||
[heavy-duty/crew#163](https://github.com/heavy-duty/crew/issues/163) and the
|
||||
out-of-chain track on [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
|
||||
are worked examples of exceptions declared where they apply.
|
||||
|
||||
## Release-init
|
||||
|
||||
The predecessor closing and clearing the next epic's declared gate is the
|
||||
trigger, and today triage must notice it and open that window by hand.
|
||||
[heavy-duty/ceremony#253](https://github.com/heavy-duty/ceremony/issues/253)
|
||||
tracks the not-yet-shipped sweep announcement of that duty; do not treat the
|
||||
announcement as present until the consumer's pin carries it. Triage runs five
|
||||
steps:
|
||||
|
||||
1. Mint the epic's “to mint when this arc opens” list together with findings,
|
||||
deferred work, and discussion outcomes accumulated since the epic was
|
||||
written. Each member initially declares `Blocked by <the epic>`.
|
||||
2. Graph hard `Blocked by` edges and same-file clusters on the epic.
|
||||
3. Write the waves into the epic body as checklists in claim order, with a
|
||||
separate verification lane and the progress view under `## Task list`.
|
||||
4. Ask the operator to bless the order, then have triage open the first wave
|
||||
by applying the flip mechanics below. The operator's blessing is the one
|
||||
step this chain never automates.
|
||||
5. Ship through the repository's cut process, close the epic, and treat that
|
||||
close as the trigger for the next window.
|
||||
|
||||
heavy-duty/crew#346 is the worked wave plan; its graph made both hard edges
|
||||
and shared-file contention visible before builders entered the queue. If init
|
||||
finds no work worth minting, the operator either folds the empty window into a
|
||||
later release or skips the version, recording that ruling on the epic before
|
||||
closing it unshipped.
|
||||
|
||||
## One primary window, declared parallel tracks
|
||||
|
||||
Run one primary release window by default. A cut takes whatever has landed, so
|
||||
interleaving unrelated windows blurs both the release story and the evidence
|
||||
behind it. Gates open windows; they do not silently admit members, so builders
|
||||
still see one deliberately ordered queue.
|
||||
|
||||
While a window stands — an open release-labeled issue with a non-empty
|
||||
enumerated gate — its members form a DAG whose sink is the release issue.
|
||||
Every member reaches that sink. Members declare only their immediate
|
||||
predecessors; ordering edges live on members, while the sink records membership
|
||||
only; and the `ready` set is exactly the graph's current sources. Every close
|
||||
releases exactly its declared successors, and that whole set is concurrently
|
||||
claimable: a member may have multiple successors, while the collision rule
|
||||
already orders any that share a deliverable. Insertion re-points downstream
|
||||
edges rather than merely appending membership at the sink. It follows that
|
||||
every `ready` issue is a gate member. `epic` and `post-merge` issues are exempt
|
||||
because neither is claimable (#292).
|
||||
|
||||
The operator may declare a parallel track at init when its footprint is
|
||||
disjoint from the primary window: another repository, another artifact, or
|
||||
provably non-overlapping clusters. The declaration names the boundary and any
|
||||
bridge work that must rejoin the primary. [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
|
||||
is the worked example: its app and artifact form a parallel track while its
|
||||
small crew-side bridge remains in the primary window.
|
||||
|
||||
## Flip mechanics
|
||||
|
||||
To admit a member, delete or rewrite its literal, parseable
|
||||
`Blocked by <the epic>` declaration and swap `blocked` to `ready` in the same
|
||||
edit. Markdown or HTML strikethrough is insufficient: the blocker parser reads
|
||||
the raw marker text and still returns the reference. Never preserve history by
|
||||
negating the marker phrase — the parser unions declarations even when prose
|
||||
says they no longer apply. Preserve the history only after rewriting the
|
||||
marker into non-parseable prose, then verify that the parser returns an empty
|
||||
set for the release gate.
|
||||
|
||||
Release membership is a decision, never a sweep default. Triage performs each
|
||||
flip only after the operator blesses the wave; the issue-flow sweep may resolve
|
||||
ordinary issue dependencies, but it does not choose a release's contents.
|
||||
heavy-duty/crew#346 records the member-by-member flip that opened its first
|
||||
wave.
|
||||
|
||||
## The ledger pattern
|
||||
|
||||
When a release epic has become too crufty to remain a clear working surface,
|
||||
create a replacement and treat the old epic as a ledger. Do not close the old
|
||||
epic until every live member declaration points at the replacement and the
|
||||
blocker parser verifies the new set. Closing early can release every member
|
||||
that still names the old issue.
|
||||
|
||||
The [heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to
|
||||
[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346)
|
||||
transition is the worked example: all member declarations were re-pointed and
|
||||
parse-verified before #162 closed; #162 remains the historical record while
|
||||
#346 is the release's working surface.
|
||||
29
REVIEWER.md
29
REVIEWER.md
|
|
@ -34,7 +34,12 @@ In order of authority:
|
|||
not a defect: the issue directs it, triage owns that close, and a
|
||||
request-changes on the "missing" keyword enforces the bug the shape
|
||||
exists to fix — `Closes #137` closed its issue with a post-merge
|
||||
criterion unmet (#151). Check every
|
||||
criterion unmet (#151). For a `Refs #N` body, also verify that no closing
|
||||
keyword immediately precedes `#N` anywhere in the body, even in prose
|
||||
explaining the hand close or inside a code span: GitHub used those exact
|
||||
shapes to close #209, #212 and #199 (#200, #218). The safe forms put the
|
||||
number first (`#N is closed by hand`) or omit it (`triage closes the issue
|
||||
by hand`). Check every
|
||||
criterion; a PR that ships less than the issue says is a request-changes
|
||||
even if the code is beautiful.
|
||||
2. **The repo's load-bearing constraints** — the rules bought with
|
||||
|
|
@ -49,6 +54,19 @@ In order of authority:
|
|||
`0.1.0`'s `load_config` rejected `triage-actors=...` with
|
||||
`malformed label row` and `exit=1`. CI green on a conversion PR proves
|
||||
nothing about the new config: the base branch's workflow is what ran.
|
||||
- **Third-party actions never hold a write-capable token by default.** In
|
||||
any job whose token is write-capable (`packages: write`,
|
||||
`contents: write`, `id-token: write`, or one carrying deploy secrets),
|
||||
the default is a repo-owned script a test can drive. A third-party
|
||||
action may hold that token only if it comes from an **established
|
||||
publisher** — a real organization with maintenance history and more
|
||||
than one maintainer, not a memberless shell or a lone account shipping
|
||||
an unauditable `dist/` blob — and is **pinned by full commit SHA**. An
|
||||
action matching the incubator red-flag profile never holds a write
|
||||
token, however well it works. Read-only jobs: ordinary dependency
|
||||
judgement, SHA-pinning still required. This is bot-run infrastructure —
|
||||
no human watches runtime logs, so a compromised action's window is
|
||||
unbounded (incubator#53/#54; #216).
|
||||
3. **The code itself** — correctness first, then tests (does the test plan's
|
||||
floor exist? do the failure cases actually fail?), then conventions.
|
||||
Changelog line present for behavior changes; comments carry why, not
|
||||
|
|
@ -65,7 +83,9 @@ saw Y" outranks one that says "this looks like it might".
|
|||
wait for the repo to appear on a list: review is reversible
|
||||
read-plus-comment work, and the requester already decided it should happen.
|
||||
- **A request is authorization, not panel membership.** Convergence is
|
||||
measured against the target repo's `panel=` roster minus the author. If you
|
||||
measured against the target repo's `panel[<author>]=` line if its
|
||||
`labels.conf` defines one for the PR author, else its `panel=` line; minus
|
||||
the author in either case (#224). If you
|
||||
are requested off-panel, post the verdict anyway and say in its body that
|
||||
it is advisory; neither your silence nor your request-changes is a gate the
|
||||
reconciler enforces. The nine-hour wait for kimi's off-panel verdict on
|
||||
|
|
@ -121,6 +141,11 @@ saw Y" outranks one that says "this looks like it might".
|
|||
- The builder answers rounds whole and re-requests you; until re-requested,
|
||||
the ball is not yours (`state:addressing` is the builder working — pile-on
|
||||
reviews mid-address just churn the target).
|
||||
- A **draft carrying `state:addressing` is a fix round in progress**, not
|
||||
abandonment: an engine may convert a PR back to draft at round close so the
|
||||
builder's mid-round saves stop firing CI, and the flip back to ready is the
|
||||
builder's own act announcing the round is answered
|
||||
([BUILDER.md](BUILDER.md#the-review-round)).
|
||||
- Convergence = every panel verdict approves the current head, no
|
||||
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and
|
||||
the panel's job is done.
|
||||
|
|
|
|||
93
TRIAGE.md
93
TRIAGE.md
|
|
@ -1,32 +1,25 @@
|
|||
# TRIAGE.md — the triage role
|
||||
|
||||
You are the only door issues come through. Humans and agents open
|
||||
**discussions**; you decide what becomes work. The quality of every
|
||||
downstream stage — a builder succeeding without asking, a reviewer having a
|
||||
spec to review against — is set here, by you, and nowhere else.
|
||||
You are the only door issues come through. Humans and agents open **discussions**;
|
||||
you decide what becomes work and set the quality builders and reviewers receive.
|
||||
|
||||
## Why this door exists
|
||||
|
||||
Discussions are allowed to be ambiguous; issues are not. An issue is a work
|
||||
order a builder must be able to execute **without asking anyone anything**.
|
||||
Keeping one accountable role between the two is what keeps the bar from
|
||||
eroding — the moment anyone can mint an issue, the backlog fills with
|
||||
"improve X" entries nobody can build, and builders start guessing. Guessing
|
||||
is the failure this whole flow exists to prevent.
|
||||
Discussions may be ambiguous; issues may not: a builder must be able to execute
|
||||
one **without asking anything**. One accountable role keeps builders from guessing.
|
||||
|
||||
## Your inputs
|
||||
|
||||
- **Every open discussion** in the repo you serve.
|
||||
- **Stray issues** — anything filed directly, by anyone. Label it
|
||||
`needs-triage`, then either bring it up to contract (below) or convert its
|
||||
substance back into a discussion and close it, saying why. Do not shame the
|
||||
filer; do route the work correctly.
|
||||
substance back into a discussion and close it, saying why. Route the work
|
||||
without shaming the filer.
|
||||
|
||||
## For each discussion, converge on exactly one outcome
|
||||
|
||||
1. **Answer.** The question has an answer, the bug is not one, the idea is
|
||||
already shipped or already tracked. Reply with the answer (link the code,
|
||||
the doc, the existing issue), mark answered.
|
||||
already shipped or tracked. Link the code, doc, or issue; mark answered.
|
||||
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
|
||||
repo, its history, or its docs. Ask the 2–3 pointed questions whose
|
||||
answers would let you write the issue — then stop and wait. Do not mint an
|
||||
|
|
@ -35,11 +28,10 @@ is the failure this whole flow exists to prevent.
|
|||
3. **Escalate.** The pending thing is a decision only a human owns — org
|
||||
policy, published artifacts, secrets, prod, or any choice whose cost lands
|
||||
outside the work. A panel deadlock is one instance, not the definition
|
||||
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). Say
|
||||
precisely what the decision is, name the decider, and use
|
||||
(#50 D11). Say precisely what the decision is, name the decider, and use
|
||||
[BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask),
|
||||
including its options, recommendation, blocked/continues statement, and
|
||||
reversible-only default rules ([#50 D12–D13](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||
reversible-only default rules (#50 D12–D13).
|
||||
The discussion is where humans decide; wait there. When the decision
|
||||
blocks something already on the board — an existing issue, or minted work
|
||||
a discussion's ruling gates — set `needs-ruling` on it too, so the board
|
||||
|
|
@ -53,22 +45,19 @@ is the failure this whole flow exists to prevent.
|
|||
`needs-ruling` ask — re-read that issue's **label events**
|
||||
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just its
|
||||
comments: the answer often arrives as a label with no comment, and a
|
||||
write that re-read only the thread races it. Both 2026-07-24 failures —
|
||||
[a header correction on #149](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070758613)
|
||||
asserting a hold 58 seconds after its lift, and
|
||||
[a `needs-ruling` ask on #151](https://github.com/heavy-duty/ceremony/issues/151#issuecomment-5070768876)
|
||||
the operator's label events had answered 132 seconds earlier — are this
|
||||
sentence's absence.
|
||||
write that re-read only the thread races it (#149, #151).
|
||||
Past 24 hours from the current episode's `labeled` event, if the ruling
|
||||
still stands and doubt remains, it is triage's duty to pick the option the
|
||||
builder proceeds on, record that pick as a decision, and stay accountable
|
||||
for it; the operator may overturn it at merge
|
||||
([#50 D13–D14](https://github.com/heavy-duty/ceremony/issues/50)). You set
|
||||
the flag, so you also close it out ([LABELS.md](LABELS.md)): judge when
|
||||
for it; the operator may overturn it at merge (#50 D13–D14). You set the
|
||||
flag, so you also close it out ([LABELS.md](LABELS.md)): judge when
|
||||
agreement is reached, record the ruling as a decision in one comment,
|
||||
remove the label, and return the issue to its flow in that same comment;
|
||||
when that ruling or any directive or answered builder question delivers
|
||||
the assignee's next move in prose, set `attention` in the same comment.
|
||||
the assignee's next move in prose, set `attention` in the same comment on
|
||||
the assigned issue that owns the claim — never on the pull request, even
|
||||
when the comment lives there. Flagging an unassigned issue is a board bug,
|
||||
not a demand; repair the board rather than setting `attention`.
|
||||
This is not a substitute for minting work or for `needs-ruling`.
|
||||
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
|
||||
where it belongs if anywhere, close. A refusal with reasons is a good
|
||||
|
|
@ -93,20 +82,33 @@ Every issue you mint carries, in this order:
|
|||
A criterion that can only be checked after the merge must carry its own
|
||||
mechanism, in the criterion itself: that it is post-merge, that triage
|
||||
owns the close, and that the PR references the issue with `Refs #N`
|
||||
rather than `Closes #N`. A criterion that survives the merge only if
|
||||
someone remembers to reopen the issue is an incomplete criterion — #137's
|
||||
amended body is the worked example, reopened by hand after `Closes #137`
|
||||
closed it with the criterion unmet (#151). The merge moves the issue to
|
||||
`post-merge` and releases the claim. The sweep writes the transition
|
||||
comment when it derives the move; when triage or the operator moves it by
|
||||
hand, triage writes the comment in the same tick. In either case triage
|
||||
follows up with the remaining criteria, their owner, and the wake condition
|
||||
for completion.
|
||||
rather than `Closes #N`; relying on somebody to reopen the issue is an
|
||||
incomplete criterion (#151). The merge moves the issue to `post-merge` and
|
||||
releases the claim. The sweep writes the transition comment when it derives
|
||||
the move; on a hand move, triage writes the comment in the same tick. In
|
||||
either case triage follows up with the remaining criteria, their owner, and
|
||||
the wake condition for completion.
|
||||
- **Test plan**: what proves it, including the cases that must fail.
|
||||
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
|
||||
epic organizes it. Name a cross-repo dependency the same way with its
|
||||
repository qualified (`Blocked by repo#N` or `owner/repo#N`); the sweep
|
||||
cannot resolve it, so triage verifies it and flips the issue by hand.
|
||||
When a deliverable is already carried by an open `ready`, `claimed`, or
|
||||
`blocked` issue, the newer issue must declare an unconditional collision
|
||||
edge with `Blocked by #N`, naming the newest open carrier; there is no
|
||||
alternative for disjoint regions. This keeps every `ready` issue
|
||||
concurrently claimable and makes each close release one successor (#288).
|
||||
During a standing release window, every mint also gets a binary membership
|
||||
call in the same tick. A non-member names the release issue as its blocker
|
||||
in its own Dependencies. A member is placed with three writes: the new issue
|
||||
names its immediate member predecessors; every member whose immediate
|
||||
predecessor the new issue becomes adds or re-points its dependency to the
|
||||
new issue, dropping any predecessor the new issue now reaches (inserting X
|
||||
into A → B makes A → X → B, so B drops A); a member that must land after the
|
||||
new issue but already reaches it through another member declares nothing
|
||||
new; and the release issue adds the new issue to its gate, recording
|
||||
membership only. Collision and window edges are independent, so write both
|
||||
when both apply (#292).
|
||||
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
|
||||
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
|
||||
|
||||
|
|
@ -118,10 +120,14 @@ expected.
|
|||
## Multi-issue work
|
||||
|
||||
When an acceptance produces more than one issue, mint an **epic** (`epic`
|
||||
label): the approach, the decisions, the constraint list, and a
|
||||
dependency-ordered task list of child issues. Children reference the epic;
|
||||
the epic's checklist is the progress view. Builders never pick the epic
|
||||
label) with the approach, decisions, constraints, and a dependency-ordered
|
||||
child checklist. Children reference the epic; that checklist is the progress
|
||||
view. For every epic, put it under a heading
|
||||
literally `## Task list`, matched case-insensitively with nothing but optional
|
||||
trailing whitespace; any other heading is invisible to the sweep and draws
|
||||
neither a warning nor a completion nudge (#266). Builders never pick the epic
|
||||
itself. Keep the checklist current — a stale epic misleads every scan.
|
||||
Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
|
||||
|
||||
## Backlog hygiene
|
||||
|
||||
|
|
@ -142,13 +148,8 @@ itself. Keep the checklist current — a stale epic misleads every scan.
|
|||
them. Every label on every open issue stays true; the board is only worth
|
||||
scanning if it does not lie.
|
||||
- **A lifted hold makes its body prose stale in the same instant, and the
|
||||
body is yours.** The "stays true" bar above extends past the labels to
|
||||
the prose that describes them: when a hold lifts, correcting the body
|
||||
header that described it is your move in the same tick — not the
|
||||
builder's, and not left for the next reader to diff. On
|
||||
[#149](https://github.com/heavy-duty/ceremony/issues/149) the lift
|
||||
arrived by label alone and the body said held for the next five and a
|
||||
half minutes; two builders read that window to opposite conclusions.
|
||||
body is yours.** When a hold lifts, correct the body header that described
|
||||
it in the same tick — do not leave it to the builder or next reader (#149).
|
||||
|
||||
## What you never do
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.4.2-dev
|
||||
0.6.1-dev
|
||||
|
|
|
|||
|
|
@ -31,9 +31,86 @@ TRIAGE_ACTORS=()
|
|||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
||||
# shellcheck source=lib/closes_references.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh"
|
||||
# The attention target invariants (#232) — diagnosis only, both surfaces.
|
||||
# shellcheck source=lib/attention.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/attention.sh"
|
||||
# The guarded read and its reason line (#101, #247) — one implementation for
|
||||
# both surfaces.
|
||||
# shellcheck source=lib/read.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/read.sh"
|
||||
|
||||
log() { printf 'issueflow: %s\n' "$*"; }
|
||||
run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; }
|
||||
# The status a per-issue subshell exits with when it walked away from an
|
||||
# unreadable fact (#247 D4). Distinguished from every other non-zero status so
|
||||
# a deliberate skip is counted rather than reported as a crash — and so the
|
||||
# existing crash handler still names a genuine one.
|
||||
ISSUEFLOW_SKIP=3
|
||||
# Set by reconcile_issue_pass, read once by main for the D6 tail.
|
||||
SKIPPED_COUNT=0
|
||||
SKIPPED_ISSUES=""
|
||||
|
||||
# A per-issue pass is ATOMIC: it commits its whole effect or none of it
|
||||
# (#247 D1). Inside reconcile_issue_pass's subshell, `run` and `log` do not
|
||||
# act — they append here, and commit_staged_effects replays them in order
|
||||
# once the pass has completed. Everywhere else (the arrival path, the sweep's
|
||||
# own lines) they act immediately, as they always did.
|
||||
#
|
||||
# This is the ordering invariant itself, not a fix for the two sites that
|
||||
# happened to violate it: a mutation reached before a later guarded read is
|
||||
# what let a pass remove `stale`, or mint `needs-triage`, and THEN report the
|
||||
# issue as skipped — the sweep saying it touched nothing while a write had
|
||||
# landed, which is the same false-report class #247 exists to close. Stated
|
||||
# per site it would hold until the next composition; stated here it holds for
|
||||
# compositions nobody has written yet, because reconcile_issue has no way to
|
||||
# mutate directly.
|
||||
#
|
||||
# Reads are deliberately NOT staged. They may happen anywhere in the pass,
|
||||
# because nothing lands until the end.
|
||||
STAGING=false
|
||||
STAGED_EFFECTS=()
|
||||
|
||||
emit() { printf 'issueflow: %s\n' "$*"; }
|
||||
apply() { if [ -n "${DRY_RUN:-}" ]; then emit "DRY_RUN: $*"; else "$@"; fi; }
|
||||
|
||||
stage() { # $1 = LOG|WRITE, rest = the effect's argv, kept exact by the count
|
||||
STAGED_EFFECTS+=("$#" "$@")
|
||||
}
|
||||
|
||||
log() { if [ "$STAGING" = true ]; then stage LOG "$@"; else emit "$@"; fi; }
|
||||
run() { if [ "$STAGING" = true ]; then stage WRITE "$@"; else apply "$@"; fi; }
|
||||
|
||||
commit_staged_effects() {
|
||||
# In staging order, so a completed pass's log and writes read exactly as
|
||||
# they did when each acted at its own call site. The `>/dev/null` is the one
|
||||
# every `run` call site already applies: a redirection cannot travel with
|
||||
# the argv, so it is applied here instead — uniformly, because on this
|
||||
# surface every staged write has it.
|
||||
local i=0 argc
|
||||
STAGING=false
|
||||
while [ "$i" -lt "${#STAGED_EFFECTS[@]}" ]; do
|
||||
argc="${STAGED_EFFECTS[i]}"
|
||||
if [ "${STAGED_EFFECTS[i + 1]}" = LOG ]; then
|
||||
emit "${STAGED_EFFECTS[@]:i + 2:argc - 1}"
|
||||
else
|
||||
apply "${STAGED_EFFECTS[@]:i + 2:argc - 1}" >/dev/null
|
||||
fi
|
||||
i=$((i + 1 + argc))
|
||||
done
|
||||
STAGED_EFFECTS=()
|
||||
}
|
||||
|
||||
skip_issue() { # $1 = issue, $2 = the whole reason clause — ends this issue's pass
|
||||
# Leaves the issue exactly as it is: nothing is derived from a read that
|
||||
# did not answer, and nothing this pass staged is ever committed — `exit`
|
||||
# discards the subshell that holds the buffer. So a skip implies zero
|
||||
# `gh issue edit`, zero `gh issue comment`, and no log line claiming an
|
||||
# effect that never landed, wherever in the pass the failed read lives.
|
||||
# Called from the read itself, so no call site can forget to check — which
|
||||
# is why it exits rather than returns. The reason rides its own
|
||||
# `#$n:`-prefixed line (#247 D5), emitted directly: the skip is a fact
|
||||
# about the pass, not one of the effects the pass staged.
|
||||
emit "#$1: skipped this pass — $2"
|
||||
exit "$ISSUEFLOW_SKIP"
|
||||
}
|
||||
|
||||
load_issueflow_config() { # $1 = labels.conf
|
||||
local conf="$1" line seen=false
|
||||
|
|
@ -143,6 +220,16 @@ refs_references() { # PR body on stdin -> local issue numbers named by Refs
|
|||
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
|
||||
}
|
||||
|
||||
open_pr_issues() { # records on stdin: CLOSING|BODY<TAB>value -> issue numbers
|
||||
local kind value
|
||||
while IFS=$'\t' read -r kind value; do
|
||||
case "$kind" in
|
||||
CLOSING) [ -n "$value" ] && printf '%s\n' "$value" ;;
|
||||
BODY) refs_references <<<"$value" ;;
|
||||
esac
|
||||
done | sort -nu
|
||||
}
|
||||
|
||||
unchecked_criteria() { # issue body on stdin -> unchecked task-list lines verbatim
|
||||
awk '
|
||||
/^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]+\[[[:space:]]\]/ {
|
||||
|
|
@ -161,9 +248,19 @@ post_merge_decision() { # $1 merged Refs PR, $2 linked open PR, $3 already handl
|
|||
fi
|
||||
}
|
||||
|
||||
post_merge_pr_for_issue() { # $1 issue; records are ISSUE<TAB>PR
|
||||
awk -F '\t' -v issue="$1" '$1 == issue { print $2 }' \
|
||||
<<<"${MERGED_REF_PR_RECORDS:-}" | sort -n | tail -n1
|
||||
post_merge_pr_for_issue() { # $1 issue; records are ISSUE<TAB>PR<TAB>MERGED_AT
|
||||
# The deliverable is the PR that merged last, not the one numbered highest.
|
||||
# Merge order is not number order in this family: crew#176's two Refs PRs
|
||||
# merged #184 at 19:05:16Z and #182 at 19:05:18Z — the higher number two
|
||||
# seconds earlier. Number order is also what spends a marker on the wrong
|
||||
# PR: crew#321 carries `post-merge-transition-pr-326` while its real
|
||||
# deliverable crew#322 — a lower number, merging later — is still open, so
|
||||
# under the old rule the transition it owes could never fire (#242).
|
||||
# mergedAt is ISO-8601 UTC, so it sorts as a string; ties break by highest
|
||||
# PR number so the answer never depends on input order.
|
||||
awk -F '\t' -v issue="$1" '$1 == issue { print $3 "\t" $2 }' \
|
||||
<<<"${MERGED_REF_PR_RECORDS:-}" \
|
||||
| sort -t $'\t' -k1,1 -k2,2n | tail -n1 | cut -f2
|
||||
}
|
||||
|
||||
post_merge_transition_marker() { # $1 merged PR number
|
||||
|
|
@ -224,6 +321,80 @@ blocked_cross_references() { # body on stdin -> qualified refs, one per line
|
|||
blocked_reference_records | awk -F '\t' '$1 == "CROSS" { print $2 }' | sort -u
|
||||
}
|
||||
|
||||
blocked_parse_set() { # $1 local refs, $2 cross refs -> "{#7, #12}" | "{}"
|
||||
# The parse, rendered once. The comment, the marker and the log line all
|
||||
# read this one string, so the three can never disagree about what the
|
||||
# machine read. Both classes are shown because both are parsed: the locals
|
||||
# in the numeric order blocked_references answers, then the qualified
|
||||
# references blocked_cross_references answers — a cross-repo clause is as
|
||||
# capable of being readable-but-wrong as a local one.
|
||||
local rendered
|
||||
rendered="$(
|
||||
{ [ -z "$1" ] || awk '{ print "#" $0 }' <<<"$1"
|
||||
[ -z "${2:-}" ] || printf '%s\n' "$2"
|
||||
} | awk '{ printf "%s%s", (NR > 1 ? ", " : ""), $0 } END { printf "\n" }'
|
||||
)"
|
||||
printf '{%s}\n' "$rendered"
|
||||
}
|
||||
|
||||
blocked_parse_marker() { # $1 rendered set -> the echo's idempotency marker
|
||||
# Scoped to the SET's value, not to the issue and not to the sweep: the
|
||||
# marker names WHAT was echoed, and blocked_parse_echo_needed decides whether
|
||||
# it is still what the thread is saying.
|
||||
#
|
||||
# The identity is the DIGEST, not the slug beside it. Slugging is many-to-one
|
||||
# — `{acme/widgets#9}` and `{acme-widgets#9}` are both parses this reconciler
|
||||
# accepts, and both slug to `acme-widgets-9` — so a slug-keyed marker lets a
|
||||
# changed set find the old marker and say nothing, silence in precisely the
|
||||
# case the echo exists to speak about. Distinguishing `/` would close that
|
||||
# pair and leave the class: `-`, `_` and `.` are legal in a qualifier token
|
||||
# and all collapse the same way. The slug stays in front so a human reading
|
||||
# the raw comment can still see which set it belongs to; it decides nothing.
|
||||
state_marker blockers-parsed "$1"
|
||||
}
|
||||
|
||||
state_marker() { # $1 = marker family, $2 = the state's rendered value
|
||||
# The one spelling of a value-keyed marker. Three flags now key on a state
|
||||
# that changes rather than on "have I ever said this" — the blocked-parse
|
||||
# echo (#252) and the two board flags (#293) — and a second implementation
|
||||
# of the slug-plus-digest rule is the drift a shared helper prevents.
|
||||
local slug digest
|
||||
slug="$(printf '%s' "$2" | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')"
|
||||
digest="$(printf '%s' "$2" | sha256sum | cut -c1-12)"
|
||||
printf '%s-%s-%s\n' "$1" "${slug:-none}" "$digest"
|
||||
}
|
||||
|
||||
blocked_parse_echo_needed() { # $1 issue, $2 this parse's marker → 0 echo, 1 quiet
|
||||
# Idempotency for the parse echo is against the LAST parse echo on the
|
||||
# thread, not against any historical one. ensure_comment's any-occurrence
|
||||
# grep is right for a flag like `blocked-unparseable`, whose question is
|
||||
# "have I ever said this"; it is wrong for a value that changes, whose
|
||||
# question is "is this still what I am saying". The difference is A -> B -> A:
|
||||
# under an any-occurrence search the return to A finds A's own first echo and
|
||||
# stays silent, leaving the thread's most recent echo asserting B while the
|
||||
# sweep gates on A. A stale parse presented as the current one is the exact
|
||||
# failure #252 exists to kill, and the third edit changed the parsed set, so
|
||||
# the criterion says it speaks.
|
||||
#
|
||||
# Comparing markers rather than re-rendering the last set keeps the digest as
|
||||
# the only identity: two sets are the same here iff blocked_parse_marker says
|
||||
# so, the same rule the marker itself is built on.
|
||||
state_echo_needed "$1" blockers-parsed "$2"
|
||||
}
|
||||
|
||||
state_echo_needed() { # $1 issue, $2 family, $3 this state's marker → 0 echo, 1 quiet
|
||||
# The value-keyed dedup itself, family-scoped so each flag compares against
|
||||
# its OWN last word and never against another flag's (#293 D4 asks for the
|
||||
# declaration echo's mechanism exactly, and three families now share it).
|
||||
local bodies last
|
||||
guarded_read bodies forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
|
||||
|| skip_issue "$1" "could not read its comments: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
||||
# The read fails closed above (#247 D1): an unreadable history skips the
|
||||
# issue rather than answering "nothing echoed yet" and re-posting.
|
||||
last="$(grep -o "<!-- issueflow:$2-[[:alnum:]-]* -->" <<<"$bodies" | tail -n 1)"
|
||||
[ "$last" != "<!-- issueflow:$3 -->" ]
|
||||
}
|
||||
|
||||
blocked_decision() { # $1 local refs, $2 OPEN/CLOSED states, $3 cross-repo refs
|
||||
local refs="$1" states="$2" cross_refs="${3:-}"
|
||||
if [ -n "$cross_refs" ]; then echo FLAG_CROSS_REPO
|
||||
|
|
@ -250,6 +421,166 @@ epic_decision() { # $1 refs, $2 states
|
|||
fi
|
||||
}
|
||||
|
||||
# ---- the two board flags (#293): collision (#288) and window (#292) --------
|
||||
#
|
||||
# Both are ADVISORY and comment-only (#293 D1). The sweep never guesses
|
||||
# intent, so neither writes a label, changes a state, or invents one: it
|
||||
# states the board fact and triage resolves it. Both are also the first
|
||||
# checks here whose input is the WHOLE board rather than one issue, so the
|
||||
# facts are gathered once in main() and every decision below is pure over
|
||||
# those records — one record per open issue, `number<TAB>labels<TAB>title`.
|
||||
#
|
||||
# Why they exist as guards at all: #288 and #292 are triage prose, and both
|
||||
# failed silently on the same morning (2026-08-04) — #284 minted `ready`
|
||||
# into a claimed file's function, and six `ready` non-members raced an
|
||||
# emptying gate. #262 measured the pattern: the same class of rule, once in
|
||||
# a guard, produced zero misses.
|
||||
|
||||
DELIVERABLE_PATH_PREFIXES=(actions/ lib/ bin/ .github/)
|
||||
|
||||
deliverable_key() { # $1 = one title segment -> its normalized key, or nothing
|
||||
# Normalized, because the 2026-08-04 miss spelled one deliverable two ways
|
||||
# — `actions/issueflow-reconcile` against plain `issueflow-reconcile` — so
|
||||
# exact-prefix matching would have missed the pair it was written for
|
||||
# (#293 D2). One leading path segment comes off, then every extension:
|
||||
# `issueflow-reconcile.test.sh` and `issueflow-reconcile.sh` are the same
|
||||
# spelling habit one more time.
|
||||
local key="$1" prefix
|
||||
key="${key#"${key%%[![:space:]]*}"}"
|
||||
key="${key%"${key##*[![:space:]]}"}"
|
||||
for prefix in "${DELIVERABLE_PATH_PREFIXES[@]}"; do
|
||||
[ "${key#"$prefix"}" = "$key" ] || { key="${key#"$prefix"}"; break; }
|
||||
done
|
||||
while [[ "$key" =~ \.[[:alnum:]]+$ ]]; do key="${key%.*}"; done
|
||||
# Case folds because the key is a spelling, not an identifier, and folding
|
||||
# only ever widens the match — the same direction of error the blocked
|
||||
# parse takes, and the cheap one: a false pair costs a comment a human
|
||||
# dismisses, a missed pair costs two builders one deliverable.
|
||||
printf '%s\n' "$key" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
deliverable_keys() { # title on stdin -> its deliverable keys, one per line
|
||||
local title prefix segment key
|
||||
IFS= read -r title
|
||||
# The issue contract forces every title to name its deliverable before the
|
||||
# em dash, so the key exists on every well-formed title by construction
|
||||
# (#288 D5). A title without one names no deliverable, and inventing a key
|
||||
# out of prose is the guessing this sweep never does — the malformed title
|
||||
# is triage's own contract to enforce, not this flag's to infer around.
|
||||
prefix="${title%%—*}"
|
||||
[ "$prefix" != "$title" ] || return 0
|
||||
# A multi-file deliverable joins its files with `+` and collides on any
|
||||
# segment: `TRIAGE.md + RELEASES.md` carries both keys.
|
||||
local segments=()
|
||||
IFS='+' read -r -a segments <<<"$prefix"
|
||||
# An issue answers a SET of keys, never a multiset. Normalization is
|
||||
# many-to-one by design — `issueflow-reconcile.sh + issueflow-reconcile.test.sh`
|
||||
# is one deliverable spelled twice, which is exactly the `+` shape D2 wrote
|
||||
# the segment rule for — and a repeated key makes `collision_flags`' scan
|
||||
# find the issue adjacent to itself, chaining it to its own number: the
|
||||
# comment would ask #402 to declare `Blocked by #402`. It corrupts the chain
|
||||
# between two such issues too, since each contributes two rows to one key.
|
||||
# Deduping here rather than in the index keeps the set property with the
|
||||
# function whose contract it is.
|
||||
{ for segment in "${segments[@]}"; do
|
||||
key="$(deliverable_key "$segment")"
|
||||
[ -z "$key" ] || printf '%s\n' "$key"
|
||||
done
|
||||
} | awk '!seen[$0]++'
|
||||
}
|
||||
|
||||
unblocked_claimable() { # $1 = comma-joined labels -> 0 when the issue is unblocked
|
||||
# THE one definition of `unblocked`, because #293 gives both flags one word
|
||||
# and one gloss on it: D2 as corrected reads "`unblocked` means open and not
|
||||
# `blocked` — carrying `ready` or `claimed`, with or without an open PR",
|
||||
# and D3b's first line says D3 uses D2's corrected `unblocked` and names the
|
||||
# domain as the claimable set. Two spellings of one spec word is how the
|
||||
# flags came to disagree about `needs-triage`, so there is one predicate and
|
||||
# both flags call it.
|
||||
#
|
||||
# `blocked` is out: a chained issue is the GOAL state of #288's rule, and
|
||||
# flagging it would report the fix as the defect. Anything else without
|
||||
# `ready` or `claimed` is out because it is not claimable — `needs-triage`
|
||||
# and a label-less issue are not states a builder can pick up, and an
|
||||
# unlabeled one is getting `needs-triage` from this very pass. `epic` and
|
||||
# `post-merge` are out by #288 D6 and #292 D1 alike — neither is picked by a
|
||||
# builder — and they carry no queue label to admit them here anyway.
|
||||
case ",$1," in *,blocked,*) return 1 ;; esac
|
||||
case ",$1," in *,ready,*|*,claimed,*) return 0 ;; esac
|
||||
return 1
|
||||
}
|
||||
|
||||
collision_in_scope() { # $1 = comma-joined labels -> 0 in the collision set
|
||||
unblocked_claimable "$1"
|
||||
}
|
||||
|
||||
window_in_scope() { # $1 = comma-joined labels -> 0 subject to the window rule
|
||||
# The same `unblocked`, not a second reading of it. Excluding only
|
||||
# `blocked`/`epic`/`post-merge` here admitted `needs-triage` and a
|
||||
# label-less issue, which left the sweep adding `needs-triage` to an
|
||||
# unlabeled issue and then, in the same pass, telling it about a membership
|
||||
# call made at mint time. Neither is claimable; #292's invariant is stated
|
||||
# over the claimable set (D3b), and its exemptions say why — `epic` and
|
||||
# `post-merge` are exempt *because neither is claimable*.
|
||||
unblocked_claimable "$1"
|
||||
}
|
||||
|
||||
collision_key_index() { # board records on stdin -> "key<TAB>number" in scope
|
||||
local n labels title key
|
||||
while IFS=$'\t' read -r n labels title; do
|
||||
[ -n "$n" ] || continue
|
||||
collision_in_scope "$labels" || continue
|
||||
while IFS= read -r key; do
|
||||
[ -z "$key" ] || printf '%s\t%s\n' "$key" "$n"
|
||||
done < <(deliverable_keys <<<"$title")
|
||||
done
|
||||
}
|
||||
|
||||
collision_flags() { # key index on stdin -> "number<TAB>key=carrier[,key=carrier]"
|
||||
# A CHAIN, not a fan (#288 D3): within one key, each issue names the newest
|
||||
# open carrier below it, so the declaration the flag asks for releases
|
||||
# exactly one successor per close. Three issues on one deliverable draw two
|
||||
# comments — #257 naming #253, #284 naming #257 — never three pairs, which
|
||||
# is the fan the rule exists to forbid.
|
||||
#
|
||||
# One line per issue, its keys folded into one state: an issue carrying two
|
||||
# colliding deliverables has ONE offending state and owes one comment (D4),
|
||||
# the same shape the blocked-parse echo takes with its set.
|
||||
sort -t $'\t' -k1,1 -k2,2n \
|
||||
| awk -F '\t' '
|
||||
$1 == key { print $2 "\t" $1 "=" carrier }
|
||||
{ key = $1; carrier = $2 }
|
||||
' \
|
||||
| sort -t $'\t' -k1,1n -k2,2 \
|
||||
| awk -F '\t' '
|
||||
$1 != n { if (n != "") print n "\t" state; n = $1; state = $2; next }
|
||||
{ state = state "," $2 }
|
||||
END { if (n != "") print n "\t" state }
|
||||
'
|
||||
}
|
||||
|
||||
window_flags() { # $1 gate members, $2 window carriers; records on stdin -> numbers
|
||||
local n labels title gate="$1" carriers="$2"
|
||||
[ -n "$carriers" ] || return 0
|
||||
while IFS=$'\t' read -r n labels title; do
|
||||
[ -n "$n" ] || continue
|
||||
window_in_scope "$labels" || continue
|
||||
grep -qxF "$n" <<<"$gate" && continue
|
||||
# The release issue is the graph's SINK, never one of its own members
|
||||
# (#292 D2), so it can never be its own non-member.
|
||||
grep -qxF "$n" <<<"$carriers" && continue
|
||||
printf '%s\n' "$n"
|
||||
done
|
||||
}
|
||||
|
||||
window_state() { # $1 = window carriers -> the rendered state, "#249" | "#249, #250"
|
||||
awk 'NF { printf "%s#%s", (shown++ ? ", " : ""), $1 } END { printf "\n" }' <<<"$1"
|
||||
}
|
||||
|
||||
flag_for_issue() { # $1 = issue, $2 = flag records "number<TAB>state"
|
||||
awk -F '\t' -v n="$1" '$1 == n { print $2 }' <<<"$2"
|
||||
}
|
||||
|
||||
offsite_cross_referenced_prs() { # timeline JSON on stdin -> owner/repo#N
|
||||
jq -r '
|
||||
.[]
|
||||
|
|
@ -271,6 +602,44 @@ offsite_resolved_decision() { # PR states on stdin -> NUDGE | QUIET
|
|||
fi
|
||||
}
|
||||
|
||||
issue_payload_valid() { # $1 = the requested issue; payload on stdin
|
||||
# The second of D3's two required guards, and neither subsumes the other.
|
||||
# The status check catches the 504 whose body is GitHub's JSON error object
|
||||
# — valid JSON that passes every jq guard and empties the label set. THIS
|
||||
# one catches an HTTP 200 whose body is `null`, which exits 0 and empties it
|
||||
# just the same. `.number` is checked against the issue asked for, so a
|
||||
# payload about some other issue can never be reconciled as this one.
|
||||
# THE EMPTINESS CHECK IS NOT REDUNDANT, and it is not stylistic. `jq -e`
|
||||
# disagrees with itself across versions on empty input: jq 1.7 exits 4 (no
|
||||
# valid result was ever produced), jq 1.6 exits **0**. This instance's
|
||||
# runner image (ghcr.io/catthehacker/ubuntu:act-22.04) carries jq 1.6, so
|
||||
# without this line an EMPTY payload reads as a valid issue payload here —
|
||||
# the precise thing D3 added this guard to refuse — and the sweep would
|
||||
# reconcile an issue from a body it never received. Measured both ways,
|
||||
# 2026-08-05: `jq -e '<this filter>' </dev/null` → rc 0 on 1.6, rc 4 on 1.7.
|
||||
# The test that caught it is upstream's own and passes on a GitHub runner
|
||||
# (#198).
|
||||
local payload
|
||||
payload="$(cat)"
|
||||
case "$payload" in *[![:space:]]*) ;; *) return 1 ;; esac
|
||||
jq -e --arg n "$1" '
|
||||
type == "object" and (.number | tostring) == $n and (.labels | type) == "array"
|
||||
' <<<"$payload" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
skipped_tail() { # $1 = skip count, $2 = the issue numbers → the D6 line, or nothing
|
||||
# `reconciled.` stays byte-identical when the pass was whole — tests pin that
|
||||
# exact string, and #101 D1 is the precedent for not folding new text into a
|
||||
# matched line. A partial pass says so on a line of its own, after it, so a
|
||||
# consumer reading only the tail of a job log can see it.
|
||||
[ "$1" -gt 0 ] || return 0
|
||||
if [ "$1" -eq 1 ]; then
|
||||
printf '%s issue skipped this pass on an unreadable fact: %s\n' "$1" "$2"
|
||||
else
|
||||
printf '%s issues skipped this pass on unreadable facts: %s\n' "$1" "$2"
|
||||
fi
|
||||
}
|
||||
|
||||
# API edge. Marker comments make warnings and nudges idempotent across sweeps.
|
||||
ensure_comment() { # $1 issue, $2 marker, $3 message
|
||||
local n="$1" marker="$2" message="$3"
|
||||
|
|
@ -279,9 +648,15 @@ ensure_comment() { # $1 issue, $2 marker, $3 message
|
|||
$message" >/dev/null
|
||||
}
|
||||
|
||||
issue_comment_has_marker() { # $1 issue, $2 marker
|
||||
forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
|
||||
| grep -qF "<!-- issueflow:$2 -->"
|
||||
issue_comment_has_marker() { # $1 issue, $2 marker → 0 found, 1 genuinely absent
|
||||
# A failed read used to answer "no marker", which re-posts the comment the
|
||||
# marker exists to suppress — absence of evidence read as evidence of
|
||||
# absence (#247 D1). It cannot be a return value: every caller treats
|
||||
# non-zero as "absent", so the skip is taken here, at the read.
|
||||
local bodies
|
||||
guarded_read bodies forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
|
||||
|| skip_issue "$1" "could not read its comments: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
||||
grep -qF "<!-- issueflow:$2 -->" <<<"$bodies"
|
||||
}
|
||||
|
||||
reference_states() {
|
||||
|
|
@ -308,24 +683,142 @@ offsite_timeline() { # unreadable timelines are deliberately silent
|
|||
forge_api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
last_issue_activity() {
|
||||
local n="$1" created="$2" latest
|
||||
latest="$({
|
||||
printf '%s\n' "$created"
|
||||
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
|
||||
# Assignment is the claim itself. Ignoring it would let an old issue be
|
||||
# reclaimed in the seconds between assignment and its required draft PR.
|
||||
forge_api --paginate "repos/$REPO/issues/$n/timeline" \
|
||||
--jq '.[] | select(.event == "assigned") | .created_at'
|
||||
} \
|
||||
| sort | tail -n1)"
|
||||
issue_activity_at() { # $1 issue, $2 created_at, $3 with-assignment|comments-only
|
||||
# One body, two clocks over it — a second activity computation is the drift
|
||||
# the reuse exists to prevent, and the two callers below are the whole
|
||||
# difference between them.
|
||||
#
|
||||
# Both reads are checked, and a failure reports rather than answering an age
|
||||
# (#247 D1). Swallowed, the comments read falls back to `created_at`, and a
|
||||
# `claimed` issue created months ago but commented on seconds earlier is
|
||||
# reclaimed — the live builder unassigned, under a comment asserting 48
|
||||
# hours of silence. `needs-triage` is cheap to remove; that is not.
|
||||
# The backend's stderr is left to flow to this function's own, where the
|
||||
# caller's guarded_read captures it for the reason line.
|
||||
local n="$1" created="$2" mode="$3" comments timeline="" latest
|
||||
comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at')" \
|
||||
|| return 1
|
||||
if [ "$mode" = with-assignment ]; then
|
||||
timeline="$(forge_api --paginate "repos/$REPO/issues/$n/timeline" \
|
||||
--jq '.[] | select(.event == "assigned") | .created_at')" || return 1
|
||||
fi
|
||||
latest="$(printf '%s\n%s\n%s\n' "$created" "$comments" "$timeline" | sort | tail -n1)"
|
||||
date -d "$latest" +%s
|
||||
}
|
||||
|
||||
last_issue_activity() { # $1 issue, $2 created_at → epoch; non-zero if a read failed
|
||||
# The claim clock, and only the claim clock (#284). Assignment is the claim
|
||||
# itself. Ignoring it would let an old issue be reclaimed in the seconds
|
||||
# between assignment and its required draft PR. The ruling clock no longer
|
||||
# rides here: an assignment says nothing about whether the decider
|
||||
# answered, and counting it let claiming a flagged issue buy its
|
||||
# escalation another 7 quiet days.
|
||||
issue_activity_at "$1" "$2" with-assignment
|
||||
}
|
||||
|
||||
last_issue_comment_activity() { # $1 issue, $2 created_at → epoch; non-zero on a failed read
|
||||
# The evidence nudge's clock (#254), and the issue-side ruling clock with
|
||||
# it (#284): on an issue, a comment is the only substantive activity
|
||||
# toward a ruling. Same computation as the claim clock, one input fewer,
|
||||
# and the input it drops is the one that would starve each criterion: on
|
||||
# `post-merge` there is no claim for an assignment to protect, and an
|
||||
# assignee there is the invalid composition the `post-merge-assigned` flag
|
||||
# reports; under `needs-ruling` the assignment is the *claim's* fact, and
|
||||
# counting it silenced the escalation at exactly the moment somebody
|
||||
# started working through it. Either way, a wider clock would let board
|
||||
# state buy the wait another 7 days of silence.
|
||||
#
|
||||
# A comment the sweep itself wrote is still activity here, deliberately:
|
||||
# the nudge carries no marker, so its own comment is what rate-limits it,
|
||||
# and no machine comment can be exempted without exempting that one too.
|
||||
# Reading authorship back into the clock would mean a body read this issue
|
||||
# forbids.
|
||||
issue_activity_at "$1" "$2" comments-only
|
||||
}
|
||||
|
||||
reconcile_board_flags() { # $1 = issue — the collision and window flags (#293)
|
||||
# Dedup is the declaration echo's, per family (#293 D4): the marker is
|
||||
# keyed to the offending state's VALUE and compared against this family's
|
||||
# last word on the thread, so a state that changes speaks and a state that
|
||||
# stands is silent. What that buys over ensure_comment's any-occurrence
|
||||
# grep is the A -> B -> A case — an issue that collides with #257, is
|
||||
# re-declared against #284, and collides with #257 again is saying
|
||||
# something new each time, and an any-occurrence marker would go quiet on
|
||||
# the third. What it does not buy is the state that resolves and returns
|
||||
# unchanged: nothing is posted at the resolution, so the thread's last word
|
||||
# is still the state itself and the return is silent. That is the echo's
|
||||
# own boundary, and it is the right one here — the flag speaks about a
|
||||
# board fact that is true right now, and a board where the fact never
|
||||
# changed has nothing new to say.
|
||||
local n="$1" state marker rendered
|
||||
state="$(flag_for_issue "$n" "${COLLISION_FLAGS:-}")"
|
||||
if [ -n "$state" ]; then
|
||||
marker="$(state_marker collision "$state")"
|
||||
if state_echo_needed "$n" collision "$marker"; then
|
||||
rendered="$(tr ',' '\n' <<<"$state" \
|
||||
| awk -F= '{ print "- `" $1 "` — also carried by #" $2 }')"
|
||||
run forge_issue_comment "$n" "<!-- issueflow:$marker -->
|
||||
This issue and the issue named beside each key below are both open and
|
||||
unblocked, and their titles name the same deliverable:
|
||||
|
||||
$rendered
|
||||
|
||||
That owes a **collision edge**, and #288 makes it unconditional: a deliverable
|
||||
already carried by an open \`ready\`, \`claimed\` or \`blocked\` issue owes
|
||||
\`Blocked by #N\` on the newer issue, naming the newest open carrier, so each
|
||||
close releases exactly one successor. Disjoint regions do not waive it —
|
||||
\`ready\` must mean claimable concurrently with every other \`ready\` issue,
|
||||
and an undeclared collision sends two builders at one deliverable.
|
||||
|
||||
The key is the title's em-dash prefix, normalized: one leading \`actions/\`,
|
||||
\`lib/\`, \`bin/\` or \`.github/\` segment comes off, then every extension, and
|
||||
a \`+\`-joined title matches on any segment. That is what the machine read,
|
||||
never a judgment about what the deliverable is — if two spellings normalized
|
||||
to one deliverable that is really two, say so and no edge is owed.
|
||||
|
||||
*Comment only: nothing on this path writes a label or changes a state. The
|
||||
marker carries the collision itself, so an unchanged one never re-posts.*" >/dev/null
|
||||
log "#$n: collision flag — $state"
|
||||
fi
|
||||
fi
|
||||
|
||||
state="$(flag_for_issue "$n" "${WINDOW_FLAGS:-}")"
|
||||
if [ -n "$state" ]; then
|
||||
marker="$(state_marker window-nonmember "$state")"
|
||||
if state_echo_needed "$n" window-nonmember "$marker"; then
|
||||
run forge_issue_comment "$n" "<!-- issueflow:$marker -->
|
||||
A release window is standing ($state) and this issue is neither one of its
|
||||
gate members nor an \`epic\` or \`post-merge\` issue.
|
||||
|
||||
#292's invariant: during a standing window — an open \`release\`-labeled issue
|
||||
with a non-empty gate — the \`ready\` set is a subset of the gate, \`epic\` and
|
||||
\`post-merge\` exempt. Every mint during a window is a membership call, binary,
|
||||
made at mint time: **behind the gate**, this issue's own Dependencies declare
|
||||
the release issue as a blocker and the sweep releases it when the release
|
||||
closes; or **into the graph**, three writes in one tick — this issue declares
|
||||
its immediate predecessors, every member whose immediate predecessor it
|
||||
becomes re-points to it, and the release issue gains \`Blocked by #N\`, which
|
||||
records membership and nothing else. Silence is not a state.
|
||||
|
||||
The gate is read from the release issue's own \`Blocked by\` declarations — the
|
||||
same parse every \`blocked\` issue is gated on, echoed on that issue.
|
||||
|
||||
*Comment only: nothing on this path writes a label or changes a state. The
|
||||
marker carries the window itself, so an unchanged one never re-posts.*" >/dev/null
|
||||
# "unblocked", not "ready": the flag fires on `claimed` too, PR in
|
||||
# flight or not, which is the one wording #293 D3b went out of its way
|
||||
# to correct. The log line is read by a human deciding whether the
|
||||
# sweep understood the board, so it says what the predicate says.
|
||||
log "#$n: window flag — an unblocked non-member under $state"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
reconcile_issue() {
|
||||
local n="$1" decision refs cross_refs states age assignees open_pr=false label owners
|
||||
local merged_ref_pr="" transition_marker="" transition_handled=false
|
||||
local n="$1" decision refs cross_refs states age evidence_age ruling_age created assignees open_pr=false label owners
|
||||
local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker=""
|
||||
local unchecked="" remove_claimed=claimed
|
||||
local attention_active=true attention_suppression=""
|
||||
decision="$(queue_decision <<<"$ISSUE_LABELS")"
|
||||
case "$decision" in
|
||||
ADD_NEEDS_TRIAGE)
|
||||
|
|
@ -341,6 +834,19 @@ reconcile_issue() {
|
|||
if has_issue_label claimed; then
|
||||
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
|
||||
grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true
|
||||
# Under a pending ruling, the ruling clock is read at the top of the
|
||||
# branch, before anything either arm below can post — the derived
|
||||
# transition comment, the reclaim notice and the claimed-unassigned flag
|
||||
# are all comments, and a read taken after one would date the issue by
|
||||
# this sweep's own writing (#284; the hazard #274 met from the other
|
||||
# side). Two clocks on purpose: `age` below counts the assignment
|
||||
# because the assignment IS the claim; the ruling waits on a human, and
|
||||
# an assignment says nothing about whether the decider answered.
|
||||
if has_issue_label needs-ruling; then
|
||||
created="$(jq -r '.created_at' <<<"$ISSUE_JSON")"
|
||||
guarded_read ruling_age last_issue_comment_activity "$n" "$created" \
|
||||
|| skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
||||
fi
|
||||
merged_ref_pr="$(post_merge_pr_for_issue "$n")"
|
||||
if [ -n "$merged_ref_pr" ]; then
|
||||
transition_marker="$(post_merge_transition_marker "$merged_ref_pr")"
|
||||
|
|
@ -369,8 +875,11 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
|||
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
|
||||
fi
|
||||
log "#$n: merged Refs PR -> post-merge; claim released"
|
||||
attention_active=false
|
||||
else
|
||||
age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")"
|
||||
created="$(jq -r '.created_at' <<<"$ISSUE_JSON")"
|
||||
guarded_read age last_issue_activity "$n" "$created" \
|
||||
|| skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
||||
if [ "$(claim_clock_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then
|
||||
# Legitimately quiet work does not run the reclaim clock. Only the
|
||||
# clock stops: an unassigned claim is still a repair the decision must
|
||||
|
|
@ -398,6 +907,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
|||
fi
|
||||
log "#$n: stale claim reclaimed -> ready" ;;
|
||||
esac
|
||||
[ "$decision" != FLAG_UNASSIGNED ] || attention_suppression=claimed-unassigned
|
||||
if has_issue_label offsite; then
|
||||
local timeline
|
||||
if timeline="$(offsite_timeline "$n")"; then
|
||||
|
|
@ -413,14 +923,98 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
|||
fi
|
||||
elif has_issue_label post-merge; then
|
||||
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
|
||||
# The evidence nudge's clock is read BEFORE any comment this branch
|
||||
# posts. `ensure_comment` below is itself activity, so reading after it
|
||||
# would let the assigned-flag comment silence the nudge for another 7
|
||||
# days — the same self-silencing the ruling nudge avoids by taking its
|
||||
# clock from this same read, below.
|
||||
created="$(jq -r '.created_at' <<<"$ISSUE_JSON")"
|
||||
guarded_read evidence_age last_issue_comment_activity "$n" "$created" \
|
||||
|| skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
||||
# On this surface the ruling clock IS this read (#284 D6): both nudges
|
||||
# wait on comments and nothing else, so the evidence clock is handed to
|
||||
# the ruling block rather than read again — and handed HERE, before the
|
||||
# assigned-flag comment and the evidence nudge below, so neither wait is
|
||||
# ever answered by anything this pass writes. `post-merge` +
|
||||
# `needs-ruling` now costs one comments read where it cost three.
|
||||
ruling_age="$evidence_age"
|
||||
if [ "$assignees" -gt 0 ] || has_issue_label attention; then
|
||||
ensure_comment "$n" post-merge-assigned \
|
||||
'This `post-merge` issue has an assignee or `attention`. The sweep will not undo hand-set intent; triage must clear the invalid composition or move the issue back into buildable queue state.'
|
||||
log "#$n: assigned or attention-bearing post-merge issue flagged"
|
||||
fi
|
||||
attention_suppression=post-merge-assigned
|
||||
# ---- the post-merge evidence nudge (#254), the ruling nudge's twin ----
|
||||
# A `post-merge` item waits on named evidence with a named owner, and
|
||||
# nothing nudged when the wait went quiet: crew#181's real-host criterion
|
||||
# starved four separate times across two releases, crew#240/#264 sat
|
||||
# until an operator happened to run the right read. Same 7-day rule, same
|
||||
# constant, same no-marker property — `ruling_nudge_decision` is the one
|
||||
# spelling of all three (lib/ruling.sh), and a second `7 * 24 * 3600`
|
||||
# here is the drift that file exists to prevent.
|
||||
#
|
||||
# The addressee is the triage actor, not `HUMAN_REVIEWER`: `post-merge`
|
||||
# is triage's completion queue by contract (TRIAGE.md), so a starving
|
||||
# wake condition is triage's to answer, and routing it to the operator
|
||||
# asks the wrong party for a move it does not owe. `triage-actors=` is
|
||||
# mandatory config — `load_issueflow_config` refuses to run without it —
|
||||
# so there is nothing to fall back to, and a silent fallback is exactly
|
||||
# how the wrong addressee comes back.
|
||||
if [ "$(ruling_nudge_decision "$NOW" "$evidence_age")" = NUDGE ]; then
|
||||
local quiet_days=$(((NOW - evidence_age) / 86400))
|
||||
# Trailing slash stripped so a server URL that carries one does not
|
||||
# render `//owner/repo` — `:-` first so an absent value is empty rather
|
||||
# than fatal, `%/` second so a present one is normalized (#198).
|
||||
local server="${GITHUB_SERVER_URL:-}"
|
||||
server="${server%/}"
|
||||
run forge_issue_comment "$n" "@${TRIAGE_ACTORS[0]} — this \`post-merge\` item has had no comment for ${quiet_days} days: ${server}/$REPO/issues/$n
|
||||
|
||||
Its wake evidence is still owed. \`post-merge\` means the merge landed and
|
||||
triage owns completion — judge the remaining criteria against the evidence
|
||||
and close the issue, or say what is still outstanding and who owes it. The
|
||||
sweep names no criterion: which one starved is prose, and the machine never
|
||||
judges prose (the link is the payload).
|
||||
|
||||
*This nudge is comment-only and carries no idempotency marker on purpose: the comment itself is activity, so posting it resets the 7-day window and the rule self-rate-limits to one nudge per 7 quiet days. Do not add a marker.*" >/dev/null
|
||||
log "#$n: post-merge evidence nudge (${quiet_days}d quiet — triage owes the wake evidence)"
|
||||
fi
|
||||
elif has_issue_label blocked; then
|
||||
refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
||||
cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
||||
# The parse is echoed before any verdict is derived from it (#252). The
|
||||
# clause parse is exact and unforgiving, and its output was invisible:
|
||||
# crew#308 silently parsed a negated "no longer blocked by #221" as a
|
||||
# blocker, crew#71 spent five days as an unresolvable queue conflict, and
|
||||
# crew#284's declaration had to be re-derived by hand-running the parser.
|
||||
# Every one of those was found by a human running the parser, hours or
|
||||
# days late. `blocked-unparseable` already catches the UNREADABLE
|
||||
# declaration; this catches the readable-but-wrong one, which no flag can
|
||||
# detect because the machine cannot judge what a human meant — only state
|
||||
# what it read, and let the human see the divergence in one sweep.
|
||||
#
|
||||
# The illustrative `#9` in the body below is code-spanned for the same
|
||||
# reason `blocked-unparseable` code-spans its `Blocked by #N`: an
|
||||
# unbackticked `#N` in a comment this sweep posts on a cron linkifies, and
|
||||
# writes a "mentioned in" event onto an unrelated issue once per echo.
|
||||
parsed_set="$(blocked_parse_set "$refs" "$cross_refs")"
|
||||
parse_marker="$(blocked_parse_marker "$parsed_set")"
|
||||
if blocked_parse_echo_needed "$n" "$parse_marker"; then
|
||||
run forge_issue_comment "$n" "<!-- issueflow:$parse_marker -->
|
||||
This issue's \`Blocked by\` declarations parse to: $parsed_set
|
||||
|
||||
That is the exact set this sweep gates on — what the machine read, never a
|
||||
judgment about whether it is what you meant. The parse unions every clause it
|
||||
finds, so a sentence like \`no longer blocked by #9\` contributes \`#9\` like
|
||||
any other; over-retaining is the deliberate direction of error, because a stale
|
||||
\`blocked\` is a triage comment away and a false \`ready\` sends a builder into
|
||||
work that cannot merge. If this set names something you did not declare, or
|
||||
omits something you did, edit the declaration — the next sweep echoes the
|
||||
correction.
|
||||
|
||||
*Comment only: nothing on this path writes a label. The marker carries the set
|
||||
itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null
|
||||
fi
|
||||
log "#$n: blocked declarations parse to $parsed_set"
|
||||
states="$(reference_states <<<"$refs")"
|
||||
decision="$(blocked_decision "$refs" "$states" "$cross_refs")"
|
||||
case "$decision" in
|
||||
|
|
@ -437,6 +1031,27 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
|||
log "#$n: blockers closed -> ready" ;;
|
||||
esac
|
||||
elif has_issue_label epic; then
|
||||
if has_issue_label release && ! issue_comment_has_marker "$n" release-init-due; then
|
||||
release_doctrine_path=.ceremony/RELEASES.md
|
||||
# Ceremony dogfoods the action but owns doctrine at the repository root (#253).
|
||||
[ "$REPO" != heavy-duty/ceremony ] || release_doctrine_path=RELEASES.md
|
||||
refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
||||
cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
||||
states="$(reference_states <<<"$refs")"
|
||||
if [ "$(blocked_decision "$refs" "$states" "$cross_refs")" = READY ]; then
|
||||
ensure_comment "$n" release-init-due \
|
||||
"This release epic's declared gate is open. Release initialization is due:
|
||||
|
||||
1. Mint the window's members.
|
||||
2. Graph hard dependencies and same-file clusters.
|
||||
3. Write ordered waves and the progress task list.
|
||||
4. Ask the operator to bless the order, then open the first wave.
|
||||
5. Ship the release, close this epic, and trigger the next window.
|
||||
|
||||
See \`$release_doctrine_path\`. The operator blessing the order is the one step this chain never automates."
|
||||
log "#$n: release-init due"
|
||||
fi
|
||||
fi
|
||||
refs="$(epic_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
||||
states="$(reference_states <<<"$refs")"
|
||||
if [ "$(epic_decision "$refs" "$states")" = NUDGE ]; then
|
||||
|
|
@ -446,6 +1061,21 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
|||
fi
|
||||
fi
|
||||
|
||||
# The flag composes with every build queue state, but requires an assignee.
|
||||
# Existing post-merge/claimed diagnostics take precedence so one board bug
|
||||
# draws one comment (#232 D5); the shared helper still logs the suppression.
|
||||
if [ "$attention_active" = true ] && has_issue_label attention; then
|
||||
[ -n "${assignees:-}" ] || assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
|
||||
reconcile_attention "$n" issue "$assignees" "$attention_suppression"
|
||||
fi
|
||||
|
||||
# ---- the two board flags (#293), on any queue state ----
|
||||
# After the queue branches for the same reason the ruling block is: both
|
||||
# compose with every queue state, and FLAG_CONFLICT's early return still
|
||||
# short-circuits them, because a board lying about its queue state is
|
||||
# repaired before anything is derived from it.
|
||||
reconcile_board_flags "$n"
|
||||
|
||||
# ---- the ruling invariants (#52), on any queue state ----
|
||||
# The flag composes with the queue labels (#50 D8), so this runs after the
|
||||
# queue branches rather than inside one of them. The FLAG_CONFLICT return
|
||||
|
|
@ -458,9 +1088,19 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
|||
run forge_issue_edit "$n" --remove-label stale >/dev/null
|
||||
log "#$n: unstale (a ruling is pending)"
|
||||
fi
|
||||
[ -n "${age:-}" ] \
|
||||
|| age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")"
|
||||
reconcile_ruling "$n" "$age" "$NOW"
|
||||
# The ruling clock reads comments only (#284 D1): an `assigned` event is
|
||||
# the claim clock's fact, and counting it here let claiming a flagged
|
||||
# issue buy its escalation another 7 quiet days. The reclaim clock
|
||||
# (`age`) must never reach this call — the branches that write comments
|
||||
# before this block (`claimed`, `post-merge`) arrive holding
|
||||
# `ruling_age` already, read before anything they post; the fresh read
|
||||
# serves the paths that arrive empty-handed.
|
||||
if [ -z "${ruling_age:-}" ]; then
|
||||
created="$(jq -r '.created_at' <<<"$ISSUE_JSON")"
|
||||
guarded_read ruling_age last_issue_comment_activity "$n" "$created" \
|
||||
|| skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
||||
fi
|
||||
reconcile_ruling "$n" "$ruling_age" "$NOW"
|
||||
fi
|
||||
}
|
||||
|
||||
|
|
@ -495,6 +1135,45 @@ reconcile_opened_issue() {
|
|||
log "#$n: needs-triage (opened by $author)"
|
||||
}
|
||||
|
||||
reconcile_issue_pass() { # $1 = issue — one issue's whole pass, in its own subshell
|
||||
# The subshell is #91's resilience: one unreadable or broken issue must not
|
||||
# take the sweep down. What it is NOT is an errexit boundary — a command
|
||||
# whose status is tested by `||` runs with errexit suppressed, and the
|
||||
# suppression extends through the whole subshell body, so the handler below
|
||||
# is what disables the errexit that would have caught a failed read (#247
|
||||
# D2). Removing it would revive errexit and lose #91. Explicit per-read
|
||||
# checks are the mechanism instead, and each one exits with ISSUEFLOW_SKIP.
|
||||
#
|
||||
# What the subshell IS, since #247's first round, is the atomicity
|
||||
# boundary: the staged effects live in it, so ending it — by a skip, or by
|
||||
# a crash — discards them, and no partial pass can ever reach the board.
|
||||
local n="$1" status=0
|
||||
(
|
||||
# Everything below stages rather than acts, and commits at the bottom —
|
||||
# so a skip taken at any read, and a crash at any statement, leaves the
|
||||
# issue exactly as it was (D1). `|| exit $?` keeps a crash's status the
|
||||
# subshell's own, as it was when reconcile_issue was the last command
|
||||
# here: the commit must not overwrite it, and must not run under it.
|
||||
STAGING=true
|
||||
guarded_read ISSUE_JSON forge_api "repos/$REPO/issues/$n" \
|
||||
|| skip_issue "$n" "could not read the issue: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
||||
issue_payload_valid "$n" <<<"$ISSUE_JSON" \
|
||||
|| skip_issue "$n" "the issue read answered a payload that is not issue #$n carrying a label array"
|
||||
jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0
|
||||
ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
|
||||
reconcile_issue "$n" || exit $?
|
||||
commit_staged_effects
|
||||
) || status=$?
|
||||
if [ "$status" -eq "$ISSUEFLOW_SKIP" ]; then
|
||||
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
|
||||
SKIPPED_ISSUES="${SKIPPED_ISSUES:+$SKIPPED_ISSUES }#$n"
|
||||
elif [ "$status" -ne 0 ]; then
|
||||
# Byte-identical, and still owed: a skip is deliberate, a crash is not,
|
||||
# and folding the two together would hide one behind the other (D4).
|
||||
log "#$n: reconcile failed — continuing with the remaining issues"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
# See labels-reconcile's twin (#188). This one already failed loudly on
|
||||
# Forgejo — but with `line 408: gh: command not found`, which names the
|
||||
|
|
@ -517,49 +1196,129 @@ main() {
|
|||
if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then
|
||||
reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}"
|
||||
fi
|
||||
# owner/name split out here until #188 — the GraphQL query took them as
|
||||
# separate variables. REST takes the owner/name path whole, so it is gone.
|
||||
# Both gathers were `gh api graphql` until #188. Forgejo has NO GraphQL
|
||||
# API — a real forgejo-runner job even arrives with GITHUB_GRAPHQL_URL set
|
||||
# to the empty string (probe task 278) — so these could not be translated
|
||||
# to a Forgejo endpoint; there is none. They are REST + a parser this repo
|
||||
# owns, over `number` and `body`, which /api/v3 and /api/v1 both return in
|
||||
# the same shape (measured on both, 2026-08-02).
|
||||
# owns, over `number`, `body` and `merged_at`, which /api/v3 and /api/v1
|
||||
# both return in the same shape (measured on both; `.merged_at` is
|
||||
# Z-suffixed UTC on each, so it still sorts as a string).
|
||||
#
|
||||
# owner/name were separate GraphQL variables until #188. REST takes the
|
||||
# owner/name path whole, so they are gone.
|
||||
#
|
||||
# crew#321 released a live claim because the open side read only closing
|
||||
# links while the merged side parsed Refs bodies. One parser now supplies
|
||||
# the local body references on both sides, so transition and reclaim agree
|
||||
# — which is why this is `open_pr_issues` over Refs and no longer
|
||||
# `closes_references`.
|
||||
#
|
||||
# Bodies travel base64 because they contain newlines: jq's @tsv escapes a
|
||||
# newline to a literal backslash-n, which a line-oriented parser reads as
|
||||
# one line and silently loses every declaration after the first. The old
|
||||
# GraphQL gather sidestepped that with `split("\n")[]`; base64 is the same
|
||||
# protection without needing the split to be correct.
|
||||
# one line and silently loses every declaration after the first.
|
||||
# `open_pr_issues` IS line-oriented — upstream fed it `split("\n")[]` — so
|
||||
# the decoded body is re-split into one BODY row per physical line here.
|
||||
# Handing it the whole decoded body as ONE record loses every declaration
|
||||
# including the first, silently, because the trailing lines arrive as
|
||||
# records whose kind matches neither case arm (#198).
|
||||
#
|
||||
# BOTH record kinds are fed, not just BODY. Upstream's CLOSING rows came
|
||||
# from GitHub's `closingIssuesReferences` — GitHub's own parse of the
|
||||
# CLOSING KEYWORDS in the body — and `lib/closes_references.sh` is exactly
|
||||
# the replacement #188 wrote for that field. Feeding BODY rows alone would
|
||||
# drop every `Closes #N` link on the open side: `refs_references` matches
|
||||
# `Refs` and deliberately not `Closes` (#151), so an open PR that says it
|
||||
# CLOSES an issue would stop counting as that issue's open PR and the claim
|
||||
# would be reclaimed under it. test/issueflow-reconcile.test.sh's
|
||||
# base64-round-trip case is that regression, and it is red without this.
|
||||
OPEN_PR_ISSUES="$(forge_api --paginate "repos/$REPO/pulls?state=open" \
|
||||
--jq '.[] | .body // "" | @base64' \
|
||||
| while IFS= read -r b64; do
|
||||
[ -n "$b64" ] && printf '%s' "$b64" | base64 -d | closes_references
|
||||
done | sort -nu)"
|
||||
# closes_references, not refs_references: GitHub's closingIssuesReferences
|
||||
# meant the CLOSING relation specifically, and reading Refs as closing
|
||||
# would make every referenced issue look closeable — the distinction #151
|
||||
# was reopened by hand over.
|
||||
[ -n "$b64" ] || continue
|
||||
body="$(printf '%s' "$b64" | base64 -d)"
|
||||
printf '%s' "$body" | closes_references | awk '{ print "CLOSING\t" $0 }'
|
||||
printf '%s' "$body" | awk '{ print "BODY\t" $0 }'
|
||||
done | open_pr_issues)"
|
||||
# Three columns: ISSUE<TAB>PR<TAB>MERGED_AT (#242). The third is not
|
||||
# decoration — `post_merge_pr_for_issue` sorts on it to answer the PR that
|
||||
# merged LAST rather than the one numbered highest, and with the column
|
||||
# absent every sort key ties and the old number order comes back silently.
|
||||
# base64 cannot contain a tab, so the three fields split cleanly.
|
||||
MERGED_REF_PR_RECORDS="$(forge_api --paginate "repos/$REPO/pulls?state=closed" \
|
||||
--jq '.[] | select(.merged_at != null) | "\(.number)\t\(.body // "" | @base64)"' \
|
||||
| while IFS=$'\t' read -r pr b64; do
|
||||
--jq '.[] | select(.merged_at != null)
|
||||
| "\(.number)\t\(.merged_at)\t\(.body // "" | @base64)"' \
|
||||
| while IFS=$'\t' read -r pr merged b64; do
|
||||
[ -n "$b64" ] || continue
|
||||
while IFS= read -r issue; do
|
||||
[ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr"
|
||||
[ -n "$issue" ] && printf '%s\t%s\t%s\n' "$issue" "$pr" "$merged"
|
||||
done < <(printf '%s' "$b64" | base64 -d | refs_references)
|
||||
done)"
|
||||
|
||||
local n
|
||||
for n in $(forge_api --paginate "repos/$REPO/issues?state=open" \
|
||||
--jq '.[] | select(.pull_request == null) | .number'); do
|
||||
(
|
||||
ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")"
|
||||
jq -e '.pull_request == null' <<<"$ISSUE_JSON" >/dev/null || exit 0
|
||||
ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
|
||||
reconcile_issue "$n"
|
||||
) || log "#$n: reconcile failed — continuing with the remaining issues"
|
||||
done
|
||||
local n tail_line issue_numbers board_json release_bodies rn rbody gate body
|
||||
local window_rendered=""
|
||||
SKIPPED_COUNT=0
|
||||
SKIPPED_ISSUES=""
|
||||
# A command substitution in a for list suppresses errexit. Capture and
|
||||
# check the board read before entering the loop, or a 504 (including one
|
||||
# after partial pagination) reports a full pass over a truncated board
|
||||
# (#257).
|
||||
#
|
||||
# The read answers the whole payload rather than a projection of it because
|
||||
# the two board flags (#293) are decided over the WHOLE board — every open
|
||||
# issue's labels and title, and every open `release` issue's body. One read
|
||||
# supplies all of it; a second pagination for the same rows would be a
|
||||
# second board, free to disagree with this one mid-sweep.
|
||||
if ! guarded_read board_json forge_api --paginate \
|
||||
"repos/$REPO/issues?state=open&per_page=100"; then
|
||||
log "could not read the issue board: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
||||
return 1
|
||||
fi
|
||||
BOARD_RECORDS="$(jq -r '.[] | select(has("pull_request") | not)
|
||||
| [(.number | tostring), ((.labels // []) | map(.name) | join(",")), (.title // "")]
|
||||
| @tsv' \
|
||||
<<<"$board_json")"
|
||||
issue_numbers="$(cut -f1 <<<"$BOARD_RECORDS")"
|
||||
# A standing window is an open `release`-labeled issue whose gate still
|
||||
# holds an OPEN member (#292 D1). The board read IS the open set, so
|
||||
# membership decides openness with no extra call — and an all-closed gate
|
||||
# is exactly the emptied gate the release's own `blocked` -> `ready`
|
||||
# promotion answers, which is why a `ready` release leaves the flag
|
||||
# dormant rather than flagging the whole board.
|
||||
release_bodies="$(jq -r '.[] | select(has("pull_request") | not)
|
||||
| select((.labels // []) | map(.name) | index("release"))
|
||||
| [(.number | tostring), ((.body // "") | gsub("[\t\r\n]"; " "))] | @tsv' \
|
||||
<<<"$board_json")"
|
||||
WINDOW_CARRIERS=""
|
||||
WINDOW_GATE=""
|
||||
if [ -n "$issue_numbers" ]; then
|
||||
while IFS=$'\t' read -r rn rbody; do
|
||||
[ -n "$rn" ] || continue
|
||||
gate="$(blocked_references <<<"$rbody")"
|
||||
[ -n "$gate" ] || continue
|
||||
grep -qxF -f <(printf '%s\n' "$issue_numbers") <<<"$gate" || continue
|
||||
WINDOW_CARRIERS="${WINDOW_CARRIERS}${rn}"$'\n'
|
||||
WINDOW_GATE="${WINDOW_GATE}${gate}"$'\n'
|
||||
done <<<"$release_bodies"
|
||||
fi
|
||||
[ -z "$WINDOW_CARRIERS" ] || window_rendered="$(window_state "$WINDOW_CARRIERS")"
|
||||
COLLISION_FLAGS="$(collision_key_index <<<"$BOARD_RECORDS" | collision_flags)"
|
||||
WINDOW_FLAGS="$(window_flags "$WINDOW_GATE" "$WINDOW_CARRIERS" <<<"$BOARD_RECORDS" \
|
||||
| awk -v state="$window_rendered" 'NF { print $1 "\t" state }')"
|
||||
if [ -z "$issue_numbers" ]; then
|
||||
log "no open issues."
|
||||
else
|
||||
while IFS= read -r n; do
|
||||
[ -n "$n" ] && reconcile_issue_pass "$n"
|
||||
done <<<"$issue_numbers"
|
||||
fi
|
||||
log "reconciled."
|
||||
# The job stays green (D7): an hourly sweep over a hundred-issue board meets
|
||||
# transient 504s as a matter of course, and reddening the whole run for one
|
||||
# skipped issue trains consumers to ignore red — the outcome #95 and #101
|
||||
# both steered away from on the PR surface. This line is what buys back the
|
||||
# auditability that costs.
|
||||
tail_line="$(skipped_tail "$SKIPPED_COUNT" "$SKIPPED_ISSUES")"
|
||||
[ -z "$tail_line" ] || log "$tail_line"
|
||||
}
|
||||
|
||||
if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@"; fi
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ fi
|
|||
|
||||
HUMAN="${HUMAN_REVIEWER:-danmt}"
|
||||
BOTS=()
|
||||
# Per-author panels (#224): parallel arrays because the conf is tiny and an
|
||||
# associative array buys nothing but a bash-4 dependency statement. One entry
|
||||
# per panel[<login>]= row — PANEL_AUTHORS holds the login, PANEL_ROWS the
|
||||
# space-joined reviewer set at the same index.
|
||||
PANEL_AUTHORS=()
|
||||
PANEL_ROWS=()
|
||||
REQUIRED_BOTS=()
|
||||
STATES=(state:building state:bots-reviewing state:addressing state:needs-human)
|
||||
BLOCKERS=(blocker:conflict blocker:ci-red blocker:unrequested)
|
||||
|
|
@ -49,12 +55,36 @@ LABELS=""
|
|||
# retirement heals the board instead of stranding a label nothing recomputes.
|
||||
RETIRED=(state:needs-rebase)
|
||||
STALE_AFTER=$((48 * 3600))
|
||||
# How long the facts behind blocker:unrequested must have stood still before it
|
||||
# is written (#236 D2). The operator's "more than 5 minutes", measured off the
|
||||
# inputs' own timestamps rather than off sweep memory — this script is
|
||||
# stateless per pass and stays that way. Overridable the way this file's other
|
||||
# constants are, for a caller whose round cadence is slower or faster.
|
||||
RECONCILE_UNREQUESTED_GRACE="${RECONCILE_UNREQUESTED_GRACE:-300}"
|
||||
# The workflow whose runs checks_state must never grade — its own (#208).
|
||||
# GITHUB_WORKFLOW is ambient in every Actions step and names the CALLER (the
|
||||
# consumer's PR-facing workflow, since consumers name the caller), so this
|
||||
# self-serves with no workflow-file change. The explicit override exists for
|
||||
# two readers: the fixtures, and #209's detached sweep caller, which will
|
||||
# need to point this at the PR-facing caller's name once reconcile no longer
|
||||
# runs inside it. Empty means "filter nothing" — a caller outside Actions
|
||||
# (a local rehearsal, an older pin) must not silently start dropping entries.
|
||||
SELF_WORKFLOW="${SELF_WORKFLOW:-${GITHUB_WORKFLOW:-}}"
|
||||
|
||||
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
||||
# shellcheck source=lib/ruling.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
|
||||
# shellcheck source=lib/forge.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
||||
# The attention target invariants (#232) — diagnosis only, both surfaces.
|
||||
# shellcheck source=lib/attention.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/attention.sh"
|
||||
# The guarded read and its reason line (#101) — one implementation for both
|
||||
# surfaces. read_failure_reason lived here until the issue surface needed the
|
||||
# identical rule (#247); a second copy of it is the failure lib/ruling.sh's
|
||||
# own header was written to record.
|
||||
# shellcheck source=lib/read.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/read.sh"
|
||||
|
||||
log() { printf 'labels: %s\n' "$*"; }
|
||||
|
||||
|
|
@ -79,25 +109,6 @@ blind_sweep_warning() { # $1 = unreadable PRs, $2 = all open PRs, $3 = sampled r
|
|||
fi
|
||||
}
|
||||
|
||||
read_failure_reason() { # $1 = captured stderr → one bounded line; pure (#101)
|
||||
# Verbatim, collapsed, bounded (D3): gh emits multi-line errors and GraphQL
|
||||
# blobs. Collapsed so the reason is exactly one log line — a raw newline
|
||||
# inside the captured per-PR output block could collide with a matched
|
||||
# string — and truncated because an unbounded paste per PR per sweep is
|
||||
# noise, and annotations are capped anyway.
|
||||
local reason
|
||||
reason="$(printf '%s' "${1-}" | tr '\n' ' ')"
|
||||
if [ -z "$reason" ]; then
|
||||
# Empty stderr is itself a fact (D4): a read that failed silently is a
|
||||
# different observation from a denial, and must not read as one.
|
||||
echo "no error output"
|
||||
elif [ "${#reason}" -gt 300 ]; then
|
||||
printf '%s…\n' "${reason:0:300}"
|
||||
else
|
||||
printf '%s\n' "$reason"
|
||||
fi
|
||||
}
|
||||
|
||||
missing_core_labels_warning() { # $1 = declared rows, $2 = repo label names
|
||||
local rows="$1" repo_labels="$2" row name missing=""
|
||||
[ -n "$repo_labels" ] || return 0
|
||||
|
|
@ -120,8 +131,16 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional
|
|||
return 1
|
||||
}
|
||||
BOTS=()
|
||||
PANEL_AUTHORS=()
|
||||
PANEL_ROWS=()
|
||||
# shellcheck disable=SC2094 # parse_panel_author_row takes $conf for its
|
||||
# error messages only — nothing in this loop writes the file it reads
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
[ -n "$line" ] || continue
|
||||
# The panel[ prefix is matched QUOTED (#224 D7): in a case pattern an
|
||||
# unquoted panel[abc]=* is a bracket expression that matches panela=…,
|
||||
# panelb=…, panelc=… — silently rerouting ordinary settings. The
|
||||
# panela= tripwire in test/labels.test.sh goes red if this regresses.
|
||||
case "$line" in
|
||||
panel=*)
|
||||
[ "$panel_seen" = false ] || {
|
||||
|
|
@ -135,6 +154,7 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional
|
|||
return 1
|
||||
}
|
||||
;;
|
||||
"panel["*) parse_panel_author_row "$line" "$conf" || return ;;
|
||||
triage-actors=*) ;;
|
||||
*) parse_label_row "$line" >/dev/null || return ;;
|
||||
esac
|
||||
|
|
@ -145,6 +165,55 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional
|
|||
}
|
||||
}
|
||||
|
||||
parse_panel_author_row() { # panel[<login>]=<space-separated logins> (#224)
|
||||
# Every failure here is a hard one that names the offending line (D3): a
|
||||
# conf error takes the whole board down, and the run log is the only place
|
||||
# the operator can read why. A malformed bracket is refused AS a bracket
|
||||
# (D4) — falling through to parse_label_row would report it as a
|
||||
# "malformed label row", the misleading diagnostic #224 was filed over.
|
||||
local line="$1" conf="$2" login rest existing
|
||||
case "$line" in
|
||||
"panel["*"]="*) ;;
|
||||
*)
|
||||
echo "labels: malformed panel[<login>]= row (expected panel[<login>]=<reviewers>): $line in $conf" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
login="${line#panel[}"
|
||||
login="${login%%]=*}"
|
||||
[ -n "$login" ] || {
|
||||
echo "labels: empty login in panel row: $line in $conf" >&2
|
||||
return 1
|
||||
}
|
||||
# The login must be exactly one well-formed bracket pair of login
|
||||
# characters. Without this, panel[z]]=b parses: the case above only
|
||||
# establishes that SOME ]= occurs, ${login%%]=*} keeps the stray ] inside
|
||||
# the login (z]), and set_required_bots for the real z then silently falls
|
||||
# back to the base panel — the misroute D4 exists to refuse. GitHub logins
|
||||
# are [A-Za-z0-9-], per the #285 spec.
|
||||
case "$login" in
|
||||
*[!A-Za-z0-9-]*)
|
||||
echo "labels: malformed panel[<login>]= row (a login is [A-Za-z0-9-] only): $line in $conf" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
for existing in ${PANEL_AUTHORS[@]+"${PANEL_AUTHORS[@]}"}; do
|
||||
[ "$existing" != "$login" ] || {
|
||||
echo "labels: duplicate panel[$login]= row in $conf: $line" >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
local -a row=()
|
||||
rest="${line#*]=}"
|
||||
read -r -a row <<<"$rest"
|
||||
[ "${#row[@]}" -gt 0 ] || {
|
||||
echo "labels: panel[$login]= must name at least one reviewer in $conf: $line" >&2
|
||||
return 1
|
||||
}
|
||||
PANEL_AUTHORS+=("$login")
|
||||
PANEL_ROWS+=("${row[*]}")
|
||||
}
|
||||
|
||||
parse_label_row() { # exact name|color|description; pipes in descriptions are refused
|
||||
local line="$1" name color desc extra
|
||||
IFS='|' read -r name color desc extra <<<"$line"
|
||||
|
|
@ -160,15 +229,38 @@ configured_label_rows() { # validated scope rows, excluding the panel setting
|
|||
[ -f "$conf" ] || return 0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
[ -n "$line" ] || continue
|
||||
case "$line" in panel=* | triage-actors=*) continue ;; esac
|
||||
# "panel["* quoted for the same D7 reason as load_config's case; skipping
|
||||
# the bracketed rows (D5) keeps a dispatch bootstrap from trying to
|
||||
# create a label named panel[<login>].
|
||||
case "$line" in panel=* | "panel["* | triage-actors=*) continue ;; esac
|
||||
parse_label_row "$line" || return
|
||||
done <"$conf"
|
||||
}
|
||||
|
||||
panel_for_author() { # $1 = author → the effective panel, space-joined (#224 D2)
|
||||
# THE resolution point: the author's panel[<login>]= row when the conf
|
||||
# defines one, the base panel= otherwise. Everything that computes a
|
||||
# required set goes through here, because two places computing the panel
|
||||
# is how the engine and the reconciler came to disagree in the first place.
|
||||
local author="$1" i
|
||||
for i in ${PANEL_AUTHORS[@]+"${!PANEL_AUTHORS[@]}"}; do
|
||||
if [ "${PANEL_AUTHORS[i]}" = "$author" ]; then
|
||||
printf '%s\n' "${PANEL_ROWS[i]}"
|
||||
return
|
||||
fi
|
||||
done
|
||||
printf '%s\n' "${BOTS[*]}"
|
||||
}
|
||||
|
||||
set_required_bots() { # the PR author is recused by construction
|
||||
# Minus-the-author applies to WHICHEVER set panel_for_author returns (#224
|
||||
# D2's safety net): an author who mistakenly appears inside its own
|
||||
# bracketed row is still recused.
|
||||
local author="$1" bot
|
||||
local -a effective=()
|
||||
read -r -a effective <<<"$(panel_for_author "$author")"
|
||||
REQUIRED_BOTS=()
|
||||
for bot in "${BOTS[@]}"; do
|
||||
for bot in ${effective[@]+"${effective[@]}"}; do
|
||||
[ "$bot" = "$author" ] || REQUIRED_BOTS+=("$bot")
|
||||
done
|
||||
}
|
||||
|
|
@ -183,6 +275,8 @@ set_required_bots() { # the PR author is recused by construction
|
|||
# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict)
|
||||
# CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup)
|
||||
# LABELS newline-separated labels currently on the PR
|
||||
# HEAD_COMMIT_AT the head commit's own date, ISO-8601; empty when unread
|
||||
# NOW this sweep's epoch seconds (main sets it once per run)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
|
||||
|
|
@ -252,7 +346,27 @@ checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE |
|
|||
# a context whose entries are ALL cancelled never reported at all (a killed
|
||||
# or timed-out required job), so it keeps CANCELLED and still blocks —
|
||||
# discard needs a surviving verdict, never an empty context.
|
||||
jq -r '
|
||||
#
|
||||
# And one exclusion that comes before every rule above: the label machine
|
||||
# never grades its own runs (#208). Every reconcile sweep serializes
|
||||
# through one shared concurrency group, and GitHub records a displaced
|
||||
# queued run as CANCELLED — there is no "superseded" conclusion for queue
|
||||
# displacement. When the displaced run was born from a pull_request_target
|
||||
# event, that cancelled entry attaches to the victim PR while its
|
||||
# SUCCESSOR — triggered by a different PR or an issues event — attaches
|
||||
# elsewhere, so the #139 carve-out's premise (a surviving sibling on the
|
||||
# same PR) fails structurally: on the victim the newest self entry stays
|
||||
# CANCELLED, the deny-list scores it FAILURE, and the sweep sets
|
||||
# blocker:ci-red off its own corpse — then re-affirms it every cadence.
|
||||
# Proven on crew#227: every real check green, the only red rollup entry
|
||||
# the sweep's own displaced run. So drop every entry belonging to
|
||||
# $SELF_WORKFLOW before the newest-per-context collapse. Accepted
|
||||
# consequences: a rollup of ONLY self entries scores NONE (honestly: no
|
||||
# checks — never SUCCESS), and a genuine reconcile failure surfaces on the
|
||||
# Actions tab instead of as blocker:ci-red, which is right because no PR
|
||||
# edit can fix the label machinery. An empty $self filters nothing — the
|
||||
# exclusion must never widen into dropping entries on a guess.
|
||||
jq -r --arg self "$SELF_WORKFLOW" '
|
||||
if (has("statusCheckRollup") | not) then "UNREADABLE" else
|
||||
|
||||
# NEUTRAL and SKIPPED satisfy branch protection — a skipped required check
|
||||
|
|
@ -293,7 +407,11 @@ checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE |
|
|||
# and treating it as newest keeps an undateable in-flight run from being
|
||||
# discarded in favour of a stale success. Every ambiguity resolves toward
|
||||
# "not settled".
|
||||
# The #208 exclusion (header above): self entries leave the rollup here,
|
||||
# BEFORE the group_by — a self-only context must vanish entirely, never
|
||||
# survive as an all-cancelled context that still classifies FAILURE.
|
||||
| [ (.statusCheckRollup // [])[]
|
||||
| select($self == "" or (.workflowName // "") != $self)
|
||||
| { ctx: [.workflowName // "", .name // .context // ""],
|
||||
at: ([.startedAt, .createdAt, .completedAt]
|
||||
| map(select(type == "string" and . != ""
|
||||
|
|
@ -340,6 +458,44 @@ bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
|
|||
esac
|
||||
}
|
||||
|
||||
iso_epoch() { # $1 = ISO-8601 timestamp → epoch seconds; nothing, rc 1, when unreadable
|
||||
# An absent field reaches this as the empty string or as jq's literal "null";
|
||||
# both are "we did not read a time", and neither may be graded as one.
|
||||
local at="${1-}" epoch
|
||||
case "$at" in "" | null) return 1 ;; esac
|
||||
epoch="$(date -d "$at" +%s 2>/dev/null)" || return 1
|
||||
[ -n "$epoch" ] || return 1
|
||||
printf '%s\n' "$epoch"
|
||||
}
|
||||
|
||||
unrequested_quiescent() { # 0 when the unrequested facts have stood for the grace (#236 D2)
|
||||
# The stall blocker's supporting facts are the head and the round's newest
|
||||
# submitted review: the ask it demands is owed only once both have stopped
|
||||
# moving. Measured off those timestamps, not off sweep memory — ceremony#235
|
||||
# was flagged inside the ~90 seconds between a round-answer push and the
|
||||
# author's re-request, because a sweep read the facts before the request
|
||||
# landed and wrote after it. That is a round in motion, not a dropped ball.
|
||||
#
|
||||
# "Newest submitted review" is any submitted review, COMMENTED included: a
|
||||
# non-verdict is still evidence the round is live, and counting it can only
|
||||
# delay a flag, never invent one.
|
||||
#
|
||||
# A timestamp we could not read refuses the blocker (the standing rule: an
|
||||
# unreadable fact never invents a verdict). This direction is deliberate and
|
||||
# asymmetric — a missed flag costs one sweep of the 15-minute cadence, a
|
||||
# false one flags a builder for doing exactly what BUILDER.md requires.
|
||||
local newest verdict_at verdict_epoch
|
||||
newest="$(iso_epoch "${HEAD_COMMIT_AT:-}")" || return 1
|
||||
verdict_at="$(jq -r '[.[].submitted_at] | max // empty' <<<"${REVIEWS_JSON:-[]}")"
|
||||
if [ -n "$verdict_at" ]; then
|
||||
# A round WITH verdicts whose newest one cannot be dated is unreadable, not
|
||||
# quiescent; a round with no verdicts at all is simply the head's clock.
|
||||
verdict_epoch="$(iso_epoch "$verdict_at")" || return 1
|
||||
[ "$verdict_epoch" -gt "$newest" ] && newest="$verdict_epoch"
|
||||
fi
|
||||
[ $((${NOW:-0} - newest)) -ge "$RECONCILE_UNREQUESTED_GRACE" ]
|
||||
}
|
||||
|
||||
human_request_needed() { # 0 when needs-human requires a FRESH human request
|
||||
# already requested → the handoff is live; head-current human approval →
|
||||
# nothing left to ask. Anything else (never reviewed, an old comment, an
|
||||
|
|
@ -375,7 +531,27 @@ blockers() { # → the blocker:* labels this PR should carry, one per line
|
|||
# A draft is exempt (the bots ignore drafts by design), and so is an
|
||||
# explicit human request — a maintainer claiming a PR early is deliberate,
|
||||
# not a dropped ball.
|
||||
if [ "$DRAFT" != true ] && ! requested "$HUMAN"; then
|
||||
#
|
||||
# And so is a head whose checks have not answered yet (#236 D1). This is the
|
||||
# one blocker that names an act the author must PERFORM, so it is the one
|
||||
# that has to know when performing it is permitted: BUILDER.md's review round
|
||||
# requires a green check at the head before requesting, so a builder waiting
|
||||
# out a pending run is complying, and flagging compliance teaches its readers
|
||||
# to ignore the label. Both 2026-08-03 instances were exactly that —
|
||||
# crew#318 at ~12:44Z carried state:addressing + blocker:unrequested while
|
||||
# the head's run was IN_PROGRESS, and ceremony#235 at 12:30Z caught the
|
||||
# ~90-second gap between a round-answer push and the re-request.
|
||||
#
|
||||
# PENDING and FAILURE each already have an owner, which is why gating loses
|
||||
# no coverage: on PENDING the next move is CI's and state:addressing /
|
||||
# state:bots-reviewing already say what the PR is doing; on FAILURE
|
||||
# blocker:ci-red owns that head, and stacking a second blocker on it
|
||||
# double-flags one stall. NONE joins SUCCESS because no checks configured is
|
||||
# nothing to wait for — the same reading the request rule gives the builder.
|
||||
# UNREADABLE never arrives here: the caller skips the PR before deciding.
|
||||
local checks_permit_the_ask=false
|
||||
case "${CHECKS:-NONE}" in SUCCESS | NONE) checks_permit_the_ask=true ;; esac
|
||||
if [ "$DRAFT" != true ] && [ "$checks_permit_the_ask" = true ] && ! requested "$HUMAN"; then
|
||||
local b v owed=false any_requested=false
|
||||
for b in "${REQUIRED_BOTS[@]}"; do
|
||||
requested "$b" && any_requested=true
|
||||
|
|
@ -386,18 +562,64 @@ blockers() { # → the blocker:* labels this PR should carry, one per line
|
|||
v="$(bot_verdict "$b")"
|
||||
case "$v" in MISSING | STALE) owed=true ;; esac
|
||||
done
|
||||
if [ "$owed" = true ] && [ "$any_requested" = false ]; then
|
||||
# The quiescence grace (#236 D2) is the last question, after the debt is
|
||||
# established: it asks whether the debt has stood long enough to be a
|
||||
# dropped ball rather than a round still in motion.
|
||||
if [ "$owed" = true ] && [ "$any_requested" = false ] && unrequested_quiescent; then
|
||||
echo blocker:unrequested
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
round_outranks_draft() { # 0 when the round's standing word survives a re-draft (#205)
|
||||
# A standing non-approving verdict outranks draft: a PR that took a round,
|
||||
# carries CHANGES_REQUESTED (or a comment owed a reply, or approvals a push
|
||||
# staled), and is then converted back to draft is a fix round in progress,
|
||||
# not a build — and hiding it behind state:building is a dropped ball the
|
||||
# staleness sweep reads as work in progress. Approvals do NOT outrank
|
||||
# draft: a re-draft after a passed round is deliberately building again,
|
||||
# and a draft must never read state:needs-human.
|
||||
#
|
||||
# A LIVE panel request on a draft also falls through — deliberately
|
||||
# surfaced, not absorbed (#205's must-not-paper-over): the bots ignore
|
||||
# drafts by design, so a draft wearing state:bots-reviewing on the board
|
||||
# is the visible symptom of a real defect (a request nobody cleared at
|
||||
# round close, or a hand-requested draft), and reading it as building
|
||||
# would hide exactly that.
|
||||
local b
|
||||
for b in "${REQUIRED_BOTS[@]}"; do
|
||||
requested "$b" && return 0
|
||||
case "$(bot_verdict "$b")" in BLOCK | FEEDBACK | STALE) return 0 ;; esac
|
||||
done
|
||||
[ "$(bot_verdict "$HUMAN")" = BLOCK ]
|
||||
}
|
||||
|
||||
decide_state() { # → the one state:* label this PR should carry
|
||||
if [ "$DRAFT" = true ]; then echo state:building; return; fi
|
||||
# Draft decides the state only when the round implies nothing else (#205):
|
||||
# a draft with no round history reads state:building exactly as it always
|
||||
# has, and round_outranks_draft is what "nothing else" means.
|
||||
if [ "$DRAFT" = true ] && ! round_outranks_draft; then
|
||||
echo state:building
|
||||
return
|
||||
fi
|
||||
|
||||
local s
|
||||
s="$(round_state)"
|
||||
|
||||
# A draft disqualifies needs-human unconditionally (#205, round 1): with
|
||||
# the short-circuit above now conditional, a draft carrying a live human
|
||||
# request plus a standing bot block or comment fell through to
|
||||
# round_state, whose explicit-human-request precedence sits above the
|
||||
# BLOCK/FEEDBACK cases — and GitHub cannot merge a draft at all, so
|
||||
# "a human could merge this right now" would lie no matter what the
|
||||
# round says. state:addressing is the same honest landing the blocker/
|
||||
# needs-ruling/blocked clauses below use: the round's word stands, only
|
||||
# the mergeable-now claim is off the table while the PR is a draft.
|
||||
if [ "$s" = state:needs-human ] && [ "$DRAFT" = true ]; then
|
||||
echo state:addressing
|
||||
return
|
||||
fi
|
||||
|
||||
# The one rule joining the two axes: state:needs-human means a human could
|
||||
# merge this RIGHT NOW, so it requires a clear branch. Any blocker at all
|
||||
# means the work is the agent's — whatever the review round says — and the
|
||||
|
|
@ -500,7 +722,7 @@ round_state() { # → the state the REVIEW ROUND alone implies; knows no branch
|
|||
|
||||
core_label_rows() {
|
||||
cat <<'EOF'
|
||||
state:building|FBCA04|PR is a draft — the coding agent is still building
|
||||
state:building|FBCA04|Pre-round: the builder is still building — draft is evidence for it, not the definition
|
||||
state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round
|
||||
state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes
|
||||
state:needs-human|8250DF|No blockers, all bots approve — waiting on the human reviewer
|
||||
|
|
@ -739,6 +961,12 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
|||
if has_label needs-ruling; then
|
||||
reconcile_ruling "$n" "$last_activity_epoch" "$NOW"
|
||||
fi
|
||||
|
||||
# `attention` belongs on the assigned issue that owns the claim, never on
|
||||
# a pull request (#232). Behind the label gate so ordinary PRs pay no read.
|
||||
if has_label attention; then
|
||||
reconcile_attention "$n" pr "$(jq '.assignees | length' <<<"$PR_JSON")" ""
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
|
|
@ -825,6 +1053,29 @@ main() {
|
|||
log "#$n: read failed: $(read_failure_reason "$GH_VIEW_ERR")"
|
||||
exit 0
|
||||
fi
|
||||
# The head's own clock, for the blocker:unrequested grace (#236 D2). One
|
||||
# read, pinned to the head SHA — not `gh pr view --json commits`, which
|
||||
# asks for the FIRST hundred commits and would date a longer PR by a
|
||||
# commit that is not its head. Last of the fetches on purpose: a PR the
|
||||
# skip above walked away from must not pay for it, and neither do drafts,
|
||||
# which never reach that blocker. Empty (a failed read, or a body without
|
||||
# the field) leaves the blocker unjudged, by unrequested_quiescent.
|
||||
HEAD_COMMIT_AT=""
|
||||
if [ "$DRAFT" != true ]; then
|
||||
HEAD_COMMIT_ERR_FILE="$(mktemp)"
|
||||
HEAD_COMMIT_AT="$(forge_api "repos/$REPO/commits/$HEAD_SHA" \
|
||||
--jq '.commit.committer.date' 2>"$HEAD_COMMIT_ERR_FILE" || echo "")"
|
||||
HEAD_COMMIT_ERR="$(cat "$HEAD_COMMIT_ERR_FILE")"
|
||||
rm -f "$HEAD_COMMIT_ERR_FILE"
|
||||
case "$HEAD_COMMIT_AT" in
|
||||
"" | null)
|
||||
# Say why it degraded (#101 D2/D4), on its own line: this one
|
||||
# narrows a blocker rather than skipping the PR, so it must not
|
||||
# read as the wholly-blind shape the counted line above matches.
|
||||
HEAD_COMMIT_AT=""
|
||||
log "#$n: could not read the head commit's date: $(read_failure_reason "$HEAD_COMMIT_ERR") — blocker:unrequested not judged this pass" ;;
|
||||
esac
|
||||
fi
|
||||
reconcile_pr "$n"
|
||||
) 2>&1
|
||||
)" || status=$?
|
||||
|
|
|
|||
16
actions/refs-not-closing/action.yml
Normal file
16
actions/refs-not-closing/action.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name: Refs not closing
|
||||
description: >-
|
||||
Refuse a pull request whose `Refs #N` promise contradicts GitHub's
|
||||
closing-issue graph (#218). GitHub recognizes closing keywords anywhere
|
||||
in a PR body, including ordinary prose and code spans; the action reads
|
||||
the graph once and lets a pure script decide whether any Refs target is
|
||||
already scheduled to close.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: refs targets are not closing
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: bash "$GITHUB_ACTION_PATH/run.sh"
|
||||
124
actions/refs-not-closing/refs-not-closing.sh
Executable file
124
actions/refs-not-closing/refs-not-closing.sh
Executable file
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# refs-not-closing.sh <body-file> [<closing-issue-number> ...] — compare the
|
||||
# issues a PR promises merely to reference with GitHub's closing-issue graph
|
||||
# (#218). The graph is authoritative because it includes both closing
|
||||
# keywords and sidebar links. The body still matters: only an issue named by
|
||||
# `Ref #N` or `Refs #N` is protected, so an ordinary `Closes #N` PR remains
|
||||
# untouched.
|
||||
#
|
||||
# This decision stays network-free so test/refs-not-closing.test.sh can drive
|
||||
# the incident matrix offline. The composite action gathers both facts in one
|
||||
# GraphQL read and passes them here. A failed or partial read never reaches
|
||||
# this script: action.yml refuses it before asking for a verdict.
|
||||
|
||||
body_file="${1:-}"
|
||||
shift || true
|
||||
|
||||
if [ -z "$body_file" ] || [ ! -f "$body_file" ]; then
|
||||
echo "refs-not-closing: body file is missing or unreadable: ${body_file:-<none>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
declare -A closing=()
|
||||
for issue in "$@"; do
|
||||
case "$issue" in
|
||||
''|*[!0-9]*)
|
||||
echo "refs-not-closing: invalid closing issue number: '$issue'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
closing["$issue"]=1
|
||||
done
|
||||
|
||||
mapfile -t refs_targets < <(
|
||||
awk '
|
||||
{
|
||||
rest = tolower($0)
|
||||
while (match(rest, /(^|[^[:alnum:]_])refs?[[:space:]]*:?[[:space:]]*[[]?#[0-9]+/)) {
|
||||
token = substr(rest, RSTART, RLENGTH)
|
||||
sub(/^.*#/, "", token)
|
||||
print token + 0
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
}
|
||||
' "$body_file" | sort -nu
|
||||
)
|
||||
|
||||
intersections=()
|
||||
for issue in "${refs_targets[@]}"; do
|
||||
if [ -n "${closing[$issue]:-}" ]; then
|
||||
intersections+=("$issue")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#intersections[@]}" -eq 0 ]; then
|
||||
echo "refs-not-closing: no Refs target appears in GitHub's closing-issue graph"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sentence_for_issue() {
|
||||
local issue="$1" mode="$2"
|
||||
awk -v issue="$issue" -v mode="$mode" '
|
||||
/^[[:space:]]*$/ {
|
||||
if (paragraph != "") {
|
||||
text = text paragraph "\n\n"
|
||||
paragraph = ""
|
||||
}
|
||||
next
|
||||
}
|
||||
{
|
||||
if (paragraph != "") paragraph = paragraph " "
|
||||
paragraph = paragraph $0
|
||||
}
|
||||
END {
|
||||
text = text paragraph
|
||||
count = split(text, sentence, /[.!?][[:space:]]+|\n\n+/)
|
||||
if (mode == "closing") {
|
||||
needle = "(^|[^[:alnum:]_])(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[[:space:]]+#[[:space:]]*" issue "([^0-9]|$)"
|
||||
} else {
|
||||
needle = "(^|[^[:alnum:]_])refs?[[:space:]]*:?[[:space:]]*\\[?#[[:space:]]*" issue "([^0-9]|$)"
|
||||
}
|
||||
for (i = 1; i <= count; i++) {
|
||||
lower = tolower(sentence[i])
|
||||
if (match(lower, needle)) {
|
||||
matched = substr(sentence[i], RSTART, RLENGTH)
|
||||
sub(/^[^[:alnum:]_]*/, "", matched)
|
||||
sub(/[^0-9]*$/, "", matched)
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", sentence[i])
|
||||
printf "%s\t%s\n", matched, sentence[i]
|
||||
exit
|
||||
}
|
||||
}
|
||||
}
|
||||
' "$body_file"
|
||||
}
|
||||
|
||||
{
|
||||
printf 'refs-not-closing: Refs target(s) also scheduled to close:'
|
||||
printf ' #%s' "${intersections[@]}"
|
||||
printf '\n'
|
||||
|
||||
for issue in "${intersections[@]}"; do
|
||||
detail="$(sentence_for_issue "$issue" closing)"
|
||||
if [ -z "$detail" ]; then
|
||||
detail="$(sentence_for_issue "$issue" refs)"
|
||||
printf " #%s: GitHub reports a closing reference; no adjacent closing keyword was found, so inspect the Development sidebar link.\n" "$issue"
|
||||
fi
|
||||
if [ -n "$detail" ]; then
|
||||
matched="${detail%%$'\t'*}"
|
||||
sentence="${detail#*$'\t'}"
|
||||
printf ' matched: %s\n' "$matched"
|
||||
printf ' sentence: %s\n' "$sentence"
|
||||
fi
|
||||
done
|
||||
|
||||
cat <<'EOF'
|
||||
A `Refs #N` PR must not close N. Remove the sidebar closing link or rewrite
|
||||
an adjacent closing-keyword sentence so the number comes first (`#N is
|
||||
closed by hand`) or the number is omitted (`triage closes the issue by
|
||||
hand`). Backticks do not protect a closing keyword from GitHub's parser.
|
||||
EOF
|
||||
} >&2
|
||||
exit 1
|
||||
77
actions/refs-not-closing/run.sh
Executable file
77
actions/refs-not-closing/run.sh
Executable file
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# The composite action's executable boundary (#218). Keeping the GraphQL
|
||||
# gather here lets the offline contract test replace `gh` and prove that
|
||||
# failed and partial reads cannot accidentally produce a green verdict.
|
||||
|
||||
# THIS ACTION IS STILL gh-ONLY, AND SAYS SO (#198 spec 4, #199 ports it).
|
||||
# Its entire gather is a single GraphQL query issued through `gh`, and
|
||||
# Forgejo serves no
|
||||
# GraphQL surface at all — `/api/graphql` 404s on this instance, and a real
|
||||
# forgejo-runner job arrives with GITHUB_GRAPHQL_URL set to the empty string
|
||||
# (lib/forge.sh's header). There is no endpoint to translate this to, so
|
||||
# unlike every other call site the merge touched it cannot be ported here;
|
||||
# it has to be re-expressed over REST, which is #199.
|
||||
#
|
||||
# Until then the declaration is the honest move: CEREMONY_FORGE_CLIENT names
|
||||
# the client this file actually speaks, and forge_preflight refuses loudly on
|
||||
# a forge that cannot serve it — rather than reading nothing and reporting a
|
||||
# verdict. That is lib/forge.sh's own rule, "Never 'probably github'",
|
||||
# applied to the one action that has not caught up yet.
|
||||
# shellcheck source=lib/forge.sh
|
||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
||||
export CEREMONY_FORGE_CLIENT=gh
|
||||
# Fail CLOSED, at the action boundary. An earlier head here exited 0 with a
|
||||
# notice so the PR check would not be red; @codex-reviewer-andresmgsl was
|
||||
# right that this conflates two different questions. "This action cannot
|
||||
# produce a verdict" is the ACTION's contract and must stay a refusal; "this
|
||||
# check should not block the board" is the CALLER's decision, and it belongs
|
||||
# in .github/workflows/refs-guard.yml, which skips on a backend this action
|
||||
# cannot speak until #199 ports it.
|
||||
forge_preflight || exit 1
|
||||
|
||||
owner="${GITHUB_REPOSITORY%%/*}"
|
||||
name="${GITHUB_REPOSITORY#*/}"
|
||||
[ -n "${PR_NUMBER:-}" ] || {
|
||||
echo "refs-not-closing: pull request number is unavailable" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# GraphQL variables are literal API syntax; the shell must not expand them.
|
||||
# shellcheck disable=SC2016
|
||||
facts="$(gh api graphql \
|
||||
-f query='query($owner: String!, $name: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
body
|
||||
closingIssuesReferences(first: 100) {
|
||||
nodes { number }
|
||||
pageInfo { hasNextPage }
|
||||
}
|
||||
}
|
||||
}
|
||||
}' \
|
||||
-F owner="$owner" -F name="$name" -F number="$PR_NUMBER")"
|
||||
|
||||
body_file="$(mktemp)"
|
||||
closing_file="$(mktemp)"
|
||||
trap 'rm -f "$body_file" "$closing_file"' EXIT
|
||||
jq -er '
|
||||
.data.repository.pullRequest
|
||||
| if . == null then error("pull request was not returned") else .body // "" end
|
||||
' <<<"$facts" >"$body_file"
|
||||
jq -r '
|
||||
.data.repository.pullRequest.closingIssuesReferences
|
||||
| if . == null then
|
||||
error("closing issue references were not returned")
|
||||
elif .pageInfo.hasNextPage then
|
||||
error("more than 100 closing issue references; refusing a partial verdict")
|
||||
else
|
||||
.nodes[].number
|
||||
end
|
||||
' <<<"$facts" >"$closing_file"
|
||||
|
||||
mapfile -t closing_issues <"$closing_file"
|
||||
bash "$GITHUB_ACTION_PATH/refs-not-closing.sh" \
|
||||
"$body_file" "${closing_issues[@]}"
|
||||
58
changelog.d/198.md
Normal file
58
changelog.d/198.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
### Added
|
||||
|
||||
- This tree carries upstream ceremony through `8c3a4d1` (upstream `0.6.0`):
|
||||
`lib/attention.sh`, `lib/read.sh`, `actions/refs-not-closing`, the guarded
|
||||
reads, and the ruling and window rules (#198).
|
||||
|
||||
- `test/no-runtime-gh.test.sh` — the forge-portability guard: no runtime `gh`
|
||||
outside `lib/forge-github.sh` unless the file declares
|
||||
`CEREMONY_FORGE_CLIENT=gh` (#198).
|
||||
|
||||
- `CHANGELOG.md` names the upstream commit this tree carries, so a drill
|
||||
record can say which `0.6.0` it exercised (#197, #198).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Eight runtime `gh` call sites arrived with the merge outside every conflict
|
||||
hunk, in functions upstream added to files this tree already owned. Seven
|
||||
are ported onto the shim; the eighth is named with its reason (#198).
|
||||
|
||||
- The open-PR gather reads `Refs`, not only closing keywords. Reading one side
|
||||
for closing links and the other for `Refs` is what released a live claim in
|
||||
crew#321, and this tree carried that shape (#198).
|
||||
|
||||
- The merged record gains `merged_at`, so `post_merge_pr_for_issue` answers
|
||||
the PR that merged last rather than the highest-numbered one. Without the
|
||||
column every sort key ties and the old order returns silently (#198).
|
||||
|
||||
- The open gather feeds `open_pr_issues` one record per physical body line. A
|
||||
whole decoded body as one record loses every declaration including the
|
||||
first, and reclaims a claim a live PR was holding (#198).
|
||||
|
||||
- The post-merge nudge links the issue on the forge in play rather than a
|
||||
hard-coded `github.com` (#198).
|
||||
|
||||
- `actions/refs-not-closing` reports and skips on a forge it cannot speak,
|
||||
naming the client and #199, instead of standing red on every PR. It reaches
|
||||
the forge zero times, so no verdict is produced either way (#198).
|
||||
|
||||
- `.github/workflows/labels.yml`'s sweep dispatch declares the client it
|
||||
speaks and decides the FORGE before the binary, so a Forgejo runner that
|
||||
happens to ship `gh` cannot dispatch against a forge that cannot serve it.
|
||||
#205 ports it to REST (#198).
|
||||
|
||||
- `actions/refs-not-closing` fails closed on a forge it cannot speak, and
|
||||
`.github/workflows/refs-guard.yml` carries the scheduling decision — the
|
||||
action never reports a success it did not earn (#198).
|
||||
|
||||
- `issue_payload_valid` refuses an empty payload on jq 1.6 as well as 1.7.
|
||||
`jq -e` exits 4 on empty input under 1.7 and **0** under 1.6, and this
|
||||
instance's runner carries 1.6 — so the guard #247 D3 added to refuse an
|
||||
unreadable read was accepting one here (#198).
|
||||
|
||||
- The post-merge nudge strips a trailing slash from the server URL, so a forge
|
||||
URL carrying one does not render `//owner/repo` (#198).
|
||||
|
||||
- `.github/scripts/release-path.sh` names `lib/forge.sh`: #191 put the shim on
|
||||
the release doors' executable path here, so a doors-unchanged record that
|
||||
omitted it was measuring the wrong set (#198).
|
||||
|
|
@ -27,7 +27,7 @@ edits to this guide (#12).
|
|||
- **The `release` label must exist** before the first ceremony PR — it is
|
||||
the merge door's declared-intent read
|
||||
([lib/facts.sh](../lib/facts.sh#L88-L101)). Bootstrap it via the labels
|
||||
workflow's `workflow_dispatch`
|
||||
sweep caller's `workflow_dispatch`
|
||||
([Labels automation](#labels-automation)), or create it by hand,
|
||||
matching the core table
|
||||
([actions/labels-reconcile/labels-reconcile.sh](../actions/labels-reconcile/labels-reconcile.sh#L369)):
|
||||
|
|
@ -60,10 +60,10 @@ the machinery at all:
|
|||
the release PR assembles the section
|
||||
([Assembling a release section](#assembling-a-release-section)).
|
||||
|
||||
Fragment mode is **unreleased** and not in `0.1.0`. A consumer pinned
|
||||
to `0.1.0` bootstraps the legacy shape instead — the preamble plus an
|
||||
empty `## Unreleased` section for entries to land under — and converts
|
||||
on the pin bump to the first tag carrying fragment mode; never mix
|
||||
Fragment mode is available at `0.2.0` and later, and not in `0.1.0`.
|
||||
A consumer pinned to `0.1.0` bootstraps the legacy shape instead — the
|
||||
preamble plus an empty `## Unreleased` section for entries to land
|
||||
under — and converts on the pin bump to `0.2.0` or later; never mix
|
||||
refs to adopt it early.
|
||||
3. **`drills/README.md`** defining what a drill *means* in this repo —
|
||||
each repo names its own
|
||||
|
|
@ -85,14 +85,16 @@ the machinery at all:
|
|||
fetch-depth: 0
|
||||
- uses: heavy-duty/ceremony/actions/changelog-armed@<pinned-tag>
|
||||
- uses: heavy-duty/ceremony/actions/changelog-monotonic@<pinned-tag>
|
||||
# Unreleased: changelog-assembled is not in 0.1.0. Adopt this step
|
||||
# with the pin bump to the first tag that carries it; never mix
|
||||
# refs. Green NOTICE on every non-release PR; on a release PR it
|
||||
# asserts the stamped section is exactly the fragments it consumed.
|
||||
# changelog-assembled is available at 0.2.0 and later, not in
|
||||
# 0.1.0. Adopt this step with the pin bump to 0.2.0 or later;
|
||||
# never mix refs. Green NOTICE on every non-release PR; on a
|
||||
# release PR it asserts the stamped section is exactly the
|
||||
# fragments it consumed.
|
||||
- uses: heavy-duty/ceremony/actions/changelog-assembled@<pinned-tag>
|
||||
- uses: heavy-duty/ceremony/actions/drill-recorded@<pinned-tag>
|
||||
# Unreleased: runner-isolated is not in 0.1.0. Adopt this step with
|
||||
# the pin bump to the first tag that carries it; never mix refs.
|
||||
# runner-isolated is available at 0.2.0 and later, not in 0.1.0.
|
||||
# Adopt this step with the pin bump to 0.2.0 or later; never mix
|
||||
# refs.
|
||||
- uses: heavy-duty/ceremony/actions/runner-isolated@<pinned-tag>
|
||||
```
|
||||
|
||||
|
|
@ -111,20 +113,59 @@ the machinery at all:
|
|||
self-hosted runner still wants it: the guard's value is the day
|
||||
somebody adds one.
|
||||
|
||||
This guide documents `main`. New machinery is marked **unreleased**
|
||||
here until a release tag ships it. If an action does not exist at the
|
||||
This guide documents `main`. A marker is the literal token
|
||||
`**unreleased**` immediately followed by its issue citation (for example,
|
||||
`(#238)`); whitespace between them may include a line break. A citation is
|
||||
mandatory, because a marker the guard cannot trace is a marker it cannot
|
||||
prove false. A token inside an inline-code span is a mention, not a marker;
|
||||
spans are ignored individually, so unrelated inline code cannot hide one.
|
||||
A marker for this repository's own issue uses bare `#N`. Cross-repo
|
||||
citations such as `(crew#293)` satisfy the traceability rule but are not
|
||||
compared with this repository's release section. The ceremony-only
|
||||
`marker-check.sh` guard enforces these rules. The release PR that ships the machinery clears, in that same PR,
|
||||
every marker its own assembled section makes false: the section cites its
|
||||
issues, each marker cites the same issue, and the release PR's diff is the
|
||||
one place both halves are visible at once (#221). If an action does not exist at the
|
||||
consumer's pinned tag, adopt it with the pin bump to the first tag that
|
||||
carries it; never mix a moving or newer ref into an otherwise exact-pin
|
||||
consumer. In particular, `0.1.0` carries `changelog-armed`,
|
||||
`changelog-monotonic` and `drill-recorded` plus `docs-sync`, but not
|
||||
`changelog-assembled` or `runner-isolated`.
|
||||
6. **Labels automation** (optional but recommended): the caller from
|
||||
[Labels automation](#labels-automation), plus `.github/labels.conf`
|
||||
6. **`.github/workflows/refs-guard.yml`** — the body-aware guard is its own
|
||||
caller because `edited` is load-bearing: #200 gained its accidental
|
||||
closing keyword after the PR opened, with no push to wake ordinary CI.
|
||||
It costs the consumer one read-only workflow file and no other machinery:
|
||||
|
||||
```yaml
|
||||
name: Refs guard
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
refs-not-closing:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: heavy-duty/ceremony/actions/refs-not-closing@<pinned-tag>
|
||||
```
|
||||
|
||||
`refs-not-closing` is available at `0.6.0` and later (#218). Adopt this
|
||||
caller with that ordinary pin bump; never point only this file at a
|
||||
moving or newer ref.
|
||||
7. **Labels automation** (optional but recommended): the two callers from
|
||||
[Labels automation](#labels-automation) — the event-facing labels
|
||||
caller and the sweep caller (#209) — plus `.github/labels.conf`
|
||||
(panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the
|
||||
path→scope globs). Run `workflow_dispatch` once — **this bootstraps
|
||||
the taxonomy, `release` label included** — and use it again whenever an
|
||||
operator needs a full-board sweep immediately.
|
||||
7. **The artifact hook** (optional): `.github/actions/release-artifact/`
|
||||
path→scope globs). Run the sweep caller's `workflow_dispatch` once —
|
||||
**this bootstraps the taxonomy, `release` label included** — and use it
|
||||
again whenever an operator needs a full-board sweep immediately.
|
||||
8. **The artifact hook** (optional): `.github/actions/release-artifact/`
|
||||
per [The artifact hook](#the-artifact-hook). No hook → the source
|
||||
tarball is the package.
|
||||
|
||||
|
|
@ -149,8 +190,11 @@ precisely so the machinery is safe to work on
|
|||
sibling `push:` silently kills a door (rig's review catch).
|
||||
- [ ] Swap the guard *script* steps in `ci.yml` for the `uses:` steps in
|
||||
the bootstrap list above (with `fetch-depth: 0` on the checkout).
|
||||
- [ ] Add `refs-guard.yml` from the bootstrap list with the same ceremony
|
||||
pin as the release caller and CI guard steps.
|
||||
- [ ] Replace `labels.yml` with the caller from
|
||||
[Labels automation](#labels-automation); extract
|
||||
[Labels automation](#labels-automation) and add the sweep caller
|
||||
`labels-sweep.yml` beside it (#209); extract
|
||||
`.github/labels.conf` from the old reconciler's embedded config —
|
||||
the `panel=` roster line and the repo's `scope:*` rows
|
||||
([the format](#labels-automation)). `.github/labeler.yml` stays as
|
||||
|
|
@ -289,15 +333,35 @@ build (#15) and incubator's GHCR image push (#16).
|
|||
|
||||
## Labels automation
|
||||
|
||||
The reusable labels workflow owns two independent jobs: additive path-based
|
||||
`scope:*` labels and reconciliation of PR state, blockers, handoff, stale
|
||||
status, and the `needs-ruling` invariants on both surfaces — the bare-flag
|
||||
check and the 7-day comment-only nudge (#52; the sweep reads that flag and
|
||||
never writes it). The consumer keeps its path mapping in
|
||||
`.github/labeler.yml` and its review panel plus scope taxonomy in
|
||||
`.github/labels.conf`.
|
||||
The labels automation is two reusable workflows since #209, adopted
|
||||
together at the same pin:
|
||||
|
||||
**Additive means additive** (unreleased — #130): the scope job's only label
|
||||
- **`labels.yml`** — the event-facing half, called on PR and issue events.
|
||||
Two jobs: additive path-based `scope:*` labels, and a few-seconds
|
||||
`trigger` job that wakes the sweep by dispatching the consumer's sweep
|
||||
caller (`gh workflow run`, plain `GITHUB_TOKEN` — `workflow_dispatch` is
|
||||
one of the two documented exemptions from the token's no-retrigger rule,
|
||||
so no PAT anywhere in the path and no loop: the sweep dispatches
|
||||
nothing).
|
||||
- **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers,
|
||||
handoff, stale status, the issue work queue, and the `needs-ruling`
|
||||
invariants on both surfaces — the bare-flag check and the 7-day
|
||||
comment-only nudge (#52; the sweep reads that flag and never writes it).
|
||||
Detached from PR-triggered runs on purpose: all sweeps serialize through
|
||||
one shared concurrency group, and GitHub records every queue-displaced
|
||||
run as CANCELLED — harmless (the surviving sweep does its work) until it
|
||||
rode a `pull_request_target` run and the ❌ landed on that PR's checks
|
||||
as fake red CI that GitHub refuses to rerun (crew#250: `gh run rerun`
|
||||
and its `--failed`/`--job` forms all decline a queue-displaced run).
|
||||
Behind its own caller, a displaced sweep cancels on the
|
||||
Actions tab, attached to no PR; PR checks show `scope` and the green
|
||||
`trigger` only.
|
||||
|
||||
The consumer keeps its path mapping in `.github/labeler.yml` and its
|
||||
review panel plus scope taxonomy in `.github/labels.conf`.
|
||||
|
||||
**Additive means additive** (available at `0.3.0` and later — #130): the
|
||||
scope job's only label
|
||||
write is `POST /issues/{n}/labels`, which adds the derived scopes and removes
|
||||
nothing, so a label applied while the job runs survives it. Earlier tags used
|
||||
`actions/labeler@v5`, which — even under `sync-labels: false` — replaces the
|
||||
|
|
@ -312,25 +376,11 @@ half-honoured. The reconcile sweep also warns (never sets) when a non-draft
|
|||
PR carries a bare `X.Y.Z` version differing from its base but no `release`
|
||||
label — the merge door would refuse that merge, and the sweep says so first.
|
||||
|
||||
The complete caller is:
|
||||
The complete event-facing caller is:
|
||||
|
||||
```yaml
|
||||
name: labels
|
||||
on:
|
||||
# The consumer owns this cadence (#203). Hourly is the recommended default
|
||||
# when no other engine drives board state: the cron is then the sweep's only
|
||||
# wake for four transition classes — a review verdict landing (no
|
||||
# pull_request_review trigger), blocker:ci-red set/cleared, blocker:conflict
|
||||
# when another PR merges under this one, and time-based stale / 48h
|
||||
# claim-reclaim. Events below carry the rest in seconds. Hourly trades ≤1h of
|
||||
# latency on those four while cutting nominal scheduled sweeps from four an
|
||||
# hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their
|
||||
# discovery path. If another engine writes some of those transitions, only
|
||||
# the classes with no other writer bound the cadence; relax it only as that
|
||||
# list shrinks.
|
||||
schedule: [{cron: "0 * * * *"}]
|
||||
# A manual full-board sweep, including taxonomy bootstrap on a fresh repo.
|
||||
workflow_dispatch:
|
||||
pull_request_target:
|
||||
# Fork PRs; these carry the head/draft/review facts state:* derives from.
|
||||
# labeled/unlabeled are the handoff wake (state:needs-human confirmed here);
|
||||
|
|
@ -354,18 +404,81 @@ permissions:
|
|||
contents: read
|
||||
checks: read # mergeability/check-rollup read for PR state
|
||||
statuses: read # commit-status rollup read for PR state
|
||||
actions: read # workflow-run nodes inside the check rollup — private repos do not imply it (incubator#60)
|
||||
actions: write # the trigger job's `gh workflow run` dispatch of the sweep caller (#209)
|
||||
issues: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
labels:
|
||||
uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
|
||||
# If the sweep caller below is named anything but labels-sweep.yml,
|
||||
# say so: `with: { sweep_workflow: <filename> }`. Ceremony's own
|
||||
# dogfood does (self-labels-sweep.yml).
|
||||
```
|
||||
|
||||
And the complete sweep caller, `labels-sweep.yml` beside it — the hourly
|
||||
cron lives HERE since #209, not on the labels caller:
|
||||
|
||||
```yaml
|
||||
name: labels-sweep
|
||||
on:
|
||||
# The consumer owns this cadence (#203). Hourly is the recommended default
|
||||
# when no other engine drives board state: the cron is then the sweep's only
|
||||
# wake for four transition classes — a review verdict landing (no
|
||||
# pull_request_review trigger on the labels caller), blocker:ci-red
|
||||
# set/cleared, blocker:conflict when another PR merges under this one, and
|
||||
# time-based stale / 48h claim-reclaim. The labels caller's events carry the
|
||||
# rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of
|
||||
# latency on those four while cutting nominal scheduled sweeps from four an
|
||||
# hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their
|
||||
# discovery path. If another engine writes some of those transitions, only
|
||||
# the classes with no other writer bound the cadence; relax it only as that
|
||||
# list shrinks.
|
||||
schedule: [{cron: "0 * * * *"}]
|
||||
# A manual full-board sweep. A bare dispatch (input default "yes") also
|
||||
# bootstraps the taxonomy on a fresh repo. The labels caller's trigger job
|
||||
# wakes this workflow with bootstrap=no on every board event, so the
|
||||
# declared input is part of the contract: a dispatch naming an undeclared
|
||||
# input is refused, and the trigger job goes loudly red.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bootstrap:
|
||||
description: Bootstrap the label taxonomy before sweeping
|
||||
type: choice
|
||||
options: ["yes", "no"]
|
||||
default: "yes"
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read # mergeability/check-rollup read for PR state
|
||||
statuses: read # commit-status rollup read for PR state
|
||||
actions: read # workflow-run nodes inside the check rollup — private repos do not imply it (incubator#60)
|
||||
issues: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
sweep:
|
||||
uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@<pinned-tag>
|
||||
# If this repo's PR-facing labels caller is named anything but `labels`,
|
||||
# pass that name: `with: { pr_workflow_name: <name> }`. The sweep exports
|
||||
# it as SELF_WORKFLOW so the label machinery's own check entries (scope,
|
||||
# trigger) never count toward blocker:ci-red — a red trigger means "fix
|
||||
# the caller", which no PR edit can do (#208 reads it).
|
||||
```
|
||||
|
||||
Naming any permission sets every unnamed permission to `none`. Public
|
||||
repositories allow check data to be read regardless, but a private consumer
|
||||
needs all three explicit reads above; without them the failure appears as an empty
|
||||
`state:*` axis on the board rather than a red workflow run.
|
||||
needs the explicit reads above; without them the failure appears as an empty
|
||||
`state:*` axis on the board rather than a red workflow run. The labels
|
||||
caller's `actions: write` is different — it is required everywhere, public
|
||||
repos included: the trigger job's `gh workflow run` is a write, and without
|
||||
it every event run goes red at the trigger.
|
||||
|
||||
**The failure mode to know before bumping**: a consumer that bumps its pin
|
||||
to a #209-carrying tag without adding the sweep caller keeps green-looking
|
||||
silence nowhere — the trigger job goes **red on every PR and issue event**
|
||||
(workflow-not-found; likewise on a sweep caller missing its `bootstrap`
|
||||
input, or a labels caller missing `actions: write`), and event-woken sweeps
|
||||
stop until the caller lands. That loudness is deliberate: never read
|
||||
silence, or a green `scope` alone, as health. Make the adoption one atomic
|
||||
PR — pin bump, sweep caller file, `actions: write` line together.
|
||||
|
||||
The `issues:` trigger is available at `0.2.0` and later — `0.2.0` is the
|
||||
first tag carrying ceremony#32. A consumer pinned to `0.1.0` omits it. Adopt
|
||||
|
|
@ -386,8 +499,42 @@ mint→`needs-triage` check and `closed` the blocker-closes→`ready` self-heal;
|
|||
the stub and ceremony's own caller stay byte-for-byte identical, the parity
|
||||
#144 established.
|
||||
|
||||
The two-caller split (ceremony#209) is available at `0.4.1` and later. A
|
||||
consumer pinned to `0.4.0` or earlier keeps the previous single-caller
|
||||
shape — the labels caller carrying the cron, `workflow_dispatch`, and
|
||||
`actions: read` — and adopts the split at the pin bump to `0.4.1` or
|
||||
later. Never mix refs to adopt it early.
|
||||
|
||||
The migration is **one atomic PR** with exactly four edits — crew, the
|
||||
consumer whose displaced-check evidence drove #209 (crew#227, crew#250),
|
||||
is the worked example; written here against `0.4.1`, the first tag
|
||||
carrying the split:
|
||||
|
||||
1. **Pin bump, every reference together** ([Version pinning](#version-pinning)):
|
||||
`0.4.0` → `0.4.1` in the labels caller's `uses:` line **and in every
|
||||
other ceremony `uses:` in the repo** — crew also pins in
|
||||
`release.yml` and its `ci.yml` guard steps. A repo on the doctrine
|
||||
mirror re-runs `docs-sync --fix` in the same PR.
|
||||
2. **New file `.github/workflows/labels-sweep.yml`** — the sweep caller
|
||||
stub above, verbatim, `bootstrap` input included (the trigger's
|
||||
`-f bootstrap=no` dispatch is refused if the input is undeclared).
|
||||
3. **The hourly cron RELOCATES — it is moved, never copied.** Delete the
|
||||
`schedule:` block (and the bare `workflow_dispatch:`) from the labels
|
||||
caller in the same edit that adds the sweep caller.
|
||||
**Warning**: a consumer that copies the sweep caller and leaves the
|
||||
old schedule on the labels caller gets DOUBLE sweeps — every cron tick
|
||||
fires both callers into the one shared `labels-reconcile` group — so
|
||||
displacement goes **up**, and the fix reads as the bug getting worse.
|
||||
4. **`actions: write` on the labels caller** — consumers carry
|
||||
`actions: read` today (crew does); the trigger job's `gh workflow run`
|
||||
is a write. The sweep caller keeps `actions: read`.
|
||||
|
||||
Bump without the sweep caller and the trigger job goes red on every PR
|
||||
and issue event — the loud failure mode above — so never split these
|
||||
four edits across PRs.
|
||||
|
||||
`pull_request_target` is intentional: fork PRs need the base repository's
|
||||
token to write labels. The reusable workflow executes no PR code. It checks
|
||||
token to write labels. The reusable workflows execute no PR code. They check
|
||||
out only the consumer's base branch and the pinned ceremony implementation.
|
||||
The #52 ruling invariants ride exactly these triggers — but the caller above
|
||||
is no longer the #18 shape, so adopting current triggers is a stub edit, not
|
||||
|
|
@ -398,10 +545,12 @@ quiet repo wears that flag until the backstop cron; a consumer picks them up
|
|||
by pinning `0.3.0` or later, never through mixed refs.
|
||||
|
||||
`.github/labels.conf` has one mandatory panel setting, one mandatory
|
||||
`triage-actors` setting, and then zero or more scope rows:
|
||||
`triage-actors` setting, zero or more optional per-author panel rows, and
|
||||
then zero or more scope rows:
|
||||
|
||||
```text
|
||||
panel=claude-bot example-codex-bot example-grok-bot
|
||||
panel[example-builder]=example-codex-bot example-grok-bot
|
||||
triage-actors=example-triage-bot
|
||||
scope:cli|C5DEF5|The command-line surface
|
||||
scope:docs|C5DEF5|Documentation
|
||||
|
|
@ -413,6 +562,18 @@ rows only; adding `triage-actors=` is a parse failure, not an ignored setting.
|
|||
Add it at the same pin bump as the `issues:` trigger — `0.2.0` or later —
|
||||
never before it and never through mixed refs.
|
||||
|
||||
The optional `panel[<login>]=` rows are available at `0.5.0` and later (#224). A row names
|
||||
the effective panel for PRs authored by exactly that login — the reconciler
|
||||
computes that PR's required set from the row, minus the author as always —
|
||||
and every other author keeps the base `panel=`, which stays mandatory. The
|
||||
panel is configured or it is the base one: ceremony never infers a reviewer
|
||||
set from the model behind a login. On any earlier pin a bracketed row is a
|
||||
**parse failure, not an ignored setting** — the same shape `triage-actors=`
|
||||
bought at `0.2.0`, but harsher in practice: the reconcile job dies on every
|
||||
PR event and every sweep until the row is removed, so the whole label board
|
||||
goes down. Add the row only at or after the pin bump that carries it, never
|
||||
before it and never through mixed refs.
|
||||
|
||||
Both actor lists are whitespace-separated. `triage-actors` names the identities
|
||||
allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly
|
||||
`name|color|description`; blank lines are ignored and extra pipes are refused.
|
||||
|
|
@ -424,41 +585,77 @@ dropped — on Forgejo with `422 Reviewer can't read`, naming the account
|
|||
it is a real failure mode when a panel member is not on the collaborator
|
||||
list, and the sweep will report it rather than sweep blind.
|
||||
There are no comment lines: every non-blank line must be the `panel=`
|
||||
setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose
|
||||
is a parse failure, not a comment (rig #13's conversion found this the hard
|
||||
way — keep the file data only).
|
||||
setting, a `panel[<login>]=` row, the `triage-actors=` setting, or a label
|
||||
row, so `#`-prefixed prose is a parse failure, not a comment (rig #13's
|
||||
conversion found this the hard way — keep the file data only).
|
||||
Core state, blocker, work-queue, and release labels come from ceremony. Scope
|
||||
rows remain consumer-owned because paths and surfaces differ by repository.
|
||||
|
||||
After adding the caller and configuration, run `workflow_dispatch` once to
|
||||
bootstrap labels on a fresh repository. It is also the operator's general
|
||||
manual full-board sweep — the answer when the board looks wrong now rather
|
||||
than after the next scheduled cadence:
|
||||
After adding the callers and configuration, dispatch the sweep caller once
|
||||
to bootstrap labels on a fresh repository. A bare dispatch is also the
|
||||
operator's general manual full-board sweep — the answer when the board
|
||||
looks wrong now rather than after the next scheduled cadence:
|
||||
|
||||
```sh
|
||||
gh workflow run labels.yml -R <owner>/<repo>
|
||||
gh workflow run labels-sweep.yml -R <owner>/<repo>
|
||||
```
|
||||
|
||||
Ceremony dogfoods the caller under the filename `self-labels.yml`, so the
|
||||
equivalent command in this repository substitutes that filename. Scheduled
|
||||
and PR-triggered runs only reconcile; they do not repeatedly upsert the
|
||||
taxonomy. When a ceremony pin bump adds a core label, bump the pin first and
|
||||
then re-dispatch `workflow_dispatch`; the scheduled sweep warns when the
|
||||
pinned taxonomy declares a core label the repository lacks.
|
||||
Ceremony dogfoods the callers under the filenames `self-labels.yml` and
|
||||
`self-labels-sweep.yml`, so the equivalent command in this repository
|
||||
substitutes that filename. Scheduled and trigger-driven runs only
|
||||
reconcile; they do not repeatedly upsert the taxonomy (the trigger's
|
||||
dispatch carries `bootstrap=no`). When a ceremony pin bump adds a core
|
||||
label, bump the pin first and then re-dispatch; the scheduled sweep warns
|
||||
when the pinned taxonomy declares a core label the repository lacks.
|
||||
|
||||
## Doctrine mirror
|
||||
|
||||
Machinery is consumed by reference — GitHub fetches the workflows and
|
||||
actions above from the pin at run time — but documents have no runtime: an
|
||||
agent reads the working tree it stands in. So the agent-facing doc set
|
||||
(ceremony's `docs/VENDORED.txt`: AGENTS.md, TRIAGE.md, BUILDER.md,
|
||||
REVIEWER.md, LABELS.md) is vendored into each consumer at **`.ceremony/`**,
|
||||
declared by ceremony's `docs/VENDORED.txt` is vendored into each consumer at **`.ceremony/`**,
|
||||
byte-identical to ceremony at the pin, plus a generated `.ceremony/README.md`
|
||||
marking the directory machine-managed. `actions/docs-sync` owns the copy:
|
||||
`--fix` writes it (and deletes what the manifest dropped — mirror means
|
||||
mirror), `--check` re-diffs it in CI on every PR, so a hand edit or a stale
|
||||
pin goes red instead of quietly governing.
|
||||
|
||||
`RELEASES.md` joins that mirror with the first tag carrying ceremony#248,
|
||||
and is available at `0.6.0` and later: consumers add `.ceremony/RELEASES.md`
|
||||
only with the ordinary pin bump and re-sync, never by copying it ahead of
|
||||
their pinned doctrine set.
|
||||
|
||||
### Read the manifest, never a copy of it
|
||||
|
||||
Anything on the consumer's side that needs to know *which* documents are
|
||||
vendored — a re-vendor script, a `docs-sync` equivalent, the task list of a
|
||||
conversion issue — reads **the pin's `docs/VENDORED.txt`** and never names
|
||||
the files itself. The manifest is available at the pinned ref from `0.1.0`
|
||||
and later — it shipped with `actions/docs-sync` itself (ceremony#19), in the
|
||||
same commit, and that tool has read it rather than a list since — and it is
|
||||
one path per line, relative to ceremony's root, blank lines ignored:
|
||||
|
||||
```sh
|
||||
# the vendored doc set at the ref this repo is pinned to
|
||||
curl -fsSL "https://raw.githubusercontent.com/heavy-duty/ceremony/<pinned-tag>/docs/VENDORED.txt"
|
||||
```
|
||||
|
||||
That is the whole benefit: a doctrine file added in ceremony — `RELEASES.md`
|
||||
was the last, ceremony#248 — reaches every consumer at its next **ordinary
|
||||
pin bump**, with **zero list edits** anywhere. A hardcoded list propagates
|
||||
nothing, and its staleness is silent rather than red: `docs-sync --check`
|
||||
asserts byte-identity for the files the list names and says nothing at all
|
||||
about one it omits, so a consumer keeps a green guard while governing
|
||||
itself with doctrine it no longer has.
|
||||
|
||||
What makes reading the manifest *sufficient* — rather than merely better
|
||||
than a copy — is that ceremony's CI now refuses a root doctrine file that is
|
||||
declared in neither the manifest nor a short in-script exemption list
|
||||
(`.github/scripts/vendored-check.sh`), so the manifest at a tag is the
|
||||
complete set as of that tag. That guarantee holds at `0.6.0` and later
|
||||
(#251); the manifest is worth reading at every earlier pin regardless, since
|
||||
it is what `actions/docs-sync` has always mirrored.
|
||||
|
||||
The consumer's ci.yml gains the guard alongside the others:
|
||||
|
||||
```yaml
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ TRIAGE.md
|
|||
BUILDER.md
|
||||
REVIEWER.md
|
||||
LABELS.md
|
||||
RELEASES.md
|
||||
|
|
|
|||
58
drills/0.5.0.md
Normal file
58
drills/0.5.0.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# 0.5.0 — drill record
|
||||
|
||||
Run 2026-08-03 by `dan-claude-bot` against the release PR (Refs #233),
|
||||
candidate branch `build/233-release-0-5-0` on `heavy-duty/ceremony` main at
|
||||
`0ac3a6f`.
|
||||
|
||||
## Scope ruling — doors unchanged, no disposable-repo rehearsal
|
||||
|
||||
The 0.4.0 drill (drills/0.4.0.md) probed both release doors end-to-end in a
|
||||
disposable repo; the live 0.4.0 and 0.4.1 releases then exercised the merge
|
||||
door for real, twice, within the last three days. Measured at `0ac3a6f`,
|
||||
stated as they are rather than as #233's D3 predicted them at `b18c0bc`:
|
||||
|
||||
- `git diff 0.4.1..main -- .github/workflows/release.yml` — **empty**. No
|
||||
door logic, no decide table, no publish step moved.
|
||||
- `git diff 0.4.1..main -- bin` — **empty**. No release tooling moved.
|
||||
- `git diff 0.4.1..main -- lib` — **not empty**: `lib/ruling.sh` +50/−13,
|
||||
#226's best-shaped escalation selection, merged this morning as PR #234.
|
||||
That is the labels-sweep library the reconcilers source; nothing in the
|
||||
release path reads it. The doors-unchanged ruling rests on the two empty
|
||||
surfaces above, not on a lib claim this tag can no longer make.
|
||||
|
||||
Re-running a full disposable-repo drill would rehearse machinery this repo
|
||||
proved in rehearsal at 0.4.0 and in production at both subsequent tags. This
|
||||
record rests on the standing evidence below and says so honestly. The panel
|
||||
reviews this claim like any other; if any reviewer rules a full drill owed,
|
||||
that verdict wins (#233 D3).
|
||||
|
||||
## Standing evidence at the candidate head
|
||||
|
||||
| # | probe | where | result |
|
||||
|---|---|---|---|
|
||||
| 1 | decide + merge-door step-replay (dogfood) | `release-exercise / step-replay (dogfood)` on this PR | CI-gating; green required to merge |
|
||||
| 2 | decide + merge-door step-replay (consumer) | `release-exercise / step-replay (consumer)` on this PR | CI-gating; green required to merge |
|
||||
| 3 | fragment chain: armed → assemble → monotonic | `release-exercise / fixture-chain` on this PR | CI-gating; green required to merge |
|
||||
| 4 | the real tree's own guards (armed, monotonic, drill-recorded, self-ref) | `self-guards` on this PR | CI-gating; green required to merge |
|
||||
| 5 | the 0.5.0 payload's one lib delta: #226's best-shaped selection | `test/ruling.test.sh` in the `test` job at this head, plus claude-bot's independent reproduction in the #234 review round | green — suite coverage, **not** live dogfood; see below |
|
||||
|
||||
Probe 5 makes no live-exercise claim, and the reason is stated so the next
|
||||
reader does not restore one: the post-merge sweeps (`30811983604`,
|
||||
`30812172095`, both at `0ac3a6f`) do run the candidate's `lib/ruling.sh` on
|
||||
the dogfood path, but every #226 call site sits inside `reconcile_ruling`,
|
||||
whose single caller is behind the `needs-ruling` flag check
|
||||
(`labels-reconcile.sh:865–866`), and this board has no `needs-ruling` item,
|
||||
standing or historical — so not one line of the delta this tag carries has
|
||||
executed live here. Its coverage at this candidate is `test/ruling.test.sh`
|
||||
(107 checks, including the crew#293 replay and the six regression proofs
|
||||
named in PR #234) and the #234 round's independent re-run of that proof.
|
||||
Per drills/README.md — the record is the evidence — and #135's defect
|
||||
class, a smaller true probe stands where a larger unobserved one would not.
|
||||
|
||||
## Deviations
|
||||
|
||||
No candidate-ref deviation arises: this record stages no scratch caller, so
|
||||
nothing needs to resolve `CEREMONY_SELF_REF: "0.5.0"` before the tag exists.
|
||||
The sibling adoption proof is post-tag and crew's: crew#298's pin bump, then
|
||||
its `panel[<login>]=` rows, then crew's first clean sweep — owned by that
|
||||
issue's criteria, reported back on #233 per its post-merge criterion.
|
||||
272
drills/0.6.0.md
Normal file
272
drills/0.6.0.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# 0.6.0 — drill record
|
||||
|
||||
Run 2026-08-05 by `cndgrr` against the 0.6.0 release PR (Refs #249),
|
||||
candidate branch `build/249-release-0-6-0`, canonical candidate SHA
|
||||
`fb8f8282a9e7b317d4d028f8e8da50501a882d14`. All six probes ran; every row in
|
||||
the table below was written from its own run.
|
||||
|
||||
## Scope ruling — a full rehearsal is owed, and doors-unchanged is refused
|
||||
|
||||
This record's shape was measured, not chosen. `drills/README.md` allows the
|
||||
doors-unchanged shape only when all three of its conditions hold at the
|
||||
candidate head; the first one does not.
|
||||
|
||||
The baseline is the last **rehearsed** tag, never the previous tag:
|
||||
`drills/0.4.1.md` and `drills/0.5.0.md` are both doors-unchanged records, so
|
||||
the anchor is **`0.4.0`**, whose record is a full disposable-repo rehearsal,
|
||||
whose release is published, and after which `main` was re-armed to
|
||||
`0.4.1-dev` (`84bb1a4`). Condition 3 holds.
|
||||
|
||||
The release path is exactly the output of `.github/scripts/release-path.sh`
|
||||
at this head — `.github/workflows/release.yml`, `bin/`, `lib/version.sh`,
|
||||
`lib/decide.sh`, `lib/facts.sh`, `lib/changelog.sh`. Condition 2 holds.
|
||||
|
||||
Condition 1 fails. Measured at this candidate:
|
||||
|
||||
```console
|
||||
$ git diff 0.4.0..HEAD -- $(.github/scripts/release-path.sh)
|
||||
.github/workflows/release.yml | 2 +-
|
||||
lib/changelog.sh | 83 ++++++++++++++++++++++++++++++++++++++---
|
||||
```
|
||||
|
||||
`release.yml`'s two lines are the `CEREMONY_SELF_REF` pin, which the
|
||||
condition exempts. **`lib/changelog.sh` is not exempt and is not empty**: it
|
||||
carries `72fa3e0` (the terminal issue-citation rule joining the fragment
|
||||
guard, #262) and `75a5b68` (one fragment, one diagnosis, #262). That file is
|
||||
on the release path because the merge door sources it to assemble and read
|
||||
the release section — this is a door byte, not a neighbouring library, and
|
||||
the last-rehearsed anchor exists precisely so an accumulated change like
|
||||
this forces a new rehearsal rather than chaining a third doors-unchanged
|
||||
assertion off the second.
|
||||
|
||||
So this release owes the disposable-repo rehearsal, and this record is it.
|
||||
|
||||
## Where
|
||||
|
||||
Disposable **private** repo `cndgrr/ceremony-drill-0.6.0`, created
|
||||
2026-08-05T00:02:58Z. It carries the `docs/CONSUMERS.md` release caller
|
||||
verbatim (`version-source: file`) over a fragment-mode fixture armed at
|
||||
`0.6.0-dev`: a preamble-only `CHANGELOG.md`, `changelog.d/README.md` plus
|
||||
one fragment, and a non-blank `drills/0.6.0.md`. The `release` label was
|
||||
created there before the first ceremony PR, per the guide's prerequisite.
|
||||
|
||||
**Disposal, as this record's author observed it**: the repository is
|
||||
**archived** — `PATCH /repos/cndgrr/ceremony-drill-0.6.0` with
|
||||
`archived: true` returned `true`, and a fresh read afterwards reported
|
||||
`archived=true private=true`. It is **pending the operator's delete**, which
|
||||
this builder cannot perform: `delete_repo` is absent from fleet tokens by
|
||||
doctrine (#135). No delete was attempted and none is claimed. Cleanup gates
|
||||
nothing — not this PR's ready-for-review, not the panel, not the merge.
|
||||
|
||||
## Candidate-ref deviation
|
||||
|
||||
The pure consumer path cannot resolve this candidate's
|
||||
`CEREMONY_SELF_REF: "0.6.0"`: that tag is the one this release has not
|
||||
created yet. No `0.6.0` branch was created on `heavy-duty/ceremony`.
|
||||
|
||||
The scratch caller instead pins `cndgrr/ceremony/.github/workflows/release.yml@drill/0.6.0`.
|
||||
That fork ref's parent is the canonical candidate SHA
|
||||
`fb8f8282a9e7b317d4d028f8e8da50501a882d14`, and its one additional commit
|
||||
(`775b4d1f6485ebdde924979ac2dce536643c6071`) rewrites all three
|
||||
`CEREMONY_SELF_REF` carriers — `release.yml`, `labels.yml`,
|
||||
`labels-sweep.yml` — to that same SHA. All runtime machinery in every probe
|
||||
below was therefore fetched from the 0.6.0 candidate tree.
|
||||
|
||||
Commits pushed to the candidate after `fb8f828` are this record only; the
|
||||
release path (`.github/scripts/release-path.sh`) is byte-identical at the
|
||||
canonical SHA and at the final head.
|
||||
|
||||
## Probes
|
||||
|
||||
One row per probe, written from its run. Runs are in
|
||||
`cndgrr/ceremony-drill-0.6.0`.
|
||||
|
||||
| # | probe | run | result |
|
||||
|---|---|---|---|
|
||||
| 1 | merge-door ceremony | 30992108742 (attempt 1) | ✅ exactly one `0.6.0` release; tag equals the merge commit; main re-armed to `0.6.1-dev` |
|
||||
| 2 | mislabeled ordinary PR | 30991634654 | ✅ green NOTICE no-op; no tag, no release |
|
||||
| 3 | bare-version PR without `release` | 30991832001 | ✅ refused at decide; no tag, no release |
|
||||
| 4 | re-run completed ceremony | 30992108742 (attempt 2) | ✅ refused at the nothing-exists assert; the release count stayed one |
|
||||
| 5 | manual matching tag | 30992258952 | ✅ `0.6.1` published from its own changelog section; main untouched |
|
||||
| 6 | mismatched tag | 30992310031 | ✅ refused before publication; no `9.9.9` release, and the probe tag was removed afterwards |
|
||||
|
||||
### Probe 5 — a manual tag matching its tree
|
||||
|
||||
Branch `probe5-tag` carried `VERSION` at `0.6.1` and a
|
||||
`## 0.6.1 — 2026-08-05` section; tag `0.6.1` was pushed at that commit
|
||||
(`dfd0cfeaca772cf45bcb63a1a639829185510c60`) with a personal token, so it
|
||||
fired the door — the anti-recursion property probe 1 relies on is exactly
|
||||
what makes a hand-pushed tag the only way to reach this door. The
|
||||
`release-on-merge` job skipped and `release-on-tag` ran: the version assert
|
||||
passed, notes were extracted, the release published.
|
||||
|
||||
The branch, not main, carried the tagged tree on purpose — the tag door
|
||||
takes no bump step, and pointing it at a side branch proves that without a
|
||||
bare version ever sitting on main. Observed afterwards: `0.6.1` published
|
||||
with exactly its own section's bullet, and main still reading `0.6.1-dev`,
|
||||
untouched by the publish. Two releases now exist, `0.6.0` and `0.6.1`,
|
||||
neither a draft, neither carrying assets.
|
||||
|
||||
### Probe 6 — a mismatched tag
|
||||
|
||||
Tag `9.9.9` was pushed at the same `0.6.1` commit. The door refused at its
|
||||
first assert, before notes and before publication:
|
||||
|
||||
```text
|
||||
tag '9.9.9' does not match the tree's version '0.6.1' — creating nothing.
|
||||
```
|
||||
|
||||
Notes, the artifact hook and publish all skipped. `GET /releases` still
|
||||
returned exactly `0.6.1` and `0.6.0`. The `9.9.9` ref was deleted afterwards
|
||||
(`DELETE /git/refs/tags/9.9.9`); `GET /git/refs/tags` then listed `0.6.0`
|
||||
and `0.6.1` only. The probe tag was the operator's artefact, never the
|
||||
workflow's — the door created nothing, which is the whole assertion.
|
||||
|
||||
### Probe 1 — the merge-door ceremony
|
||||
|
||||
PR #4 (`probe1-ceremony`) bumped `0.6.0-dev` to bare `0.6.0` and stamped
|
||||
`## 0.6.0 — 2026-08-05`, assembled from the three fixture fragments by the
|
||||
candidate's own `bin/changelog-assemble` and committed with the deletions
|
||||
in one commit. The `release` label was applied and confirmed before the
|
||||
merge. Facts and verdict:
|
||||
|
||||
```text
|
||||
VER: 0.6.0
|
||||
BASE_VER: 0.6.0-dev
|
||||
RELEASED:
|
||||
LABELED: yes
|
||||
ceremony=yes
|
||||
```
|
||||
|
||||
Observed after the run:
|
||||
|
||||
- **Exactly one** release: `GET /releases` returned `0.6.0` alone, not a
|
||||
draft, not a pre-release, zero assets (no artifact hook in the fixture —
|
||||
the hook step skipped).
|
||||
- `GET /tags` returned `0.6.0` alone, pointing at
|
||||
`64d02539f4a20286afc08b9997f0f8a7d1dbfccd`, which is PR #4's merge commit
|
||||
— the tag names the tree that was reviewed.
|
||||
- The release body was byte-for-byte the assembled section's bullets:
|
||||
|
||||
```text
|
||||
- A second ordinary fragment, written by probe 2 of the 0.6.0 drill (#249).
|
||||
- An ordinary behavior change, landing under the release label (#249).
|
||||
- Fragment mode is exercised by the ceremony 0.6.0 drill (#249).
|
||||
```
|
||||
|
||||
- Main re-armed itself: commit `2d0e19a` ("bump main to 0.6.1-dev — a dev
|
||||
install must not impersonate 0.6.0"), pushed by the job's own token. Main
|
||||
reads `0.6.1-dev` and `changelog.d/` holds only `README.md`.
|
||||
- **The anti-recursion property held.** Neither the tag create nor the bump
|
||||
push started a workflow run — the run list after the ceremony ends at
|
||||
30992108742. That is what makes the merge door the release's only chance
|
||||
to publish, and it is the reason probe 4 below is the door's own guard
|
||||
rather than a second run's.
|
||||
|
||||
### Probe 4 — a re-run of the completed ceremony
|
||||
|
||||
Re-running 30992108742 as attempt 2 re-decided `ceremony=yes` — the facts
|
||||
at that merge commit have not changed — and then died at the assert:
|
||||
|
||||
```text
|
||||
tag '0.6.0' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing.
|
||||
```
|
||||
|
||||
Tag, publish and bump all skipped. `GET /releases` still returned exactly
|
||||
one `0.6.0`. The refusal is loud (the job is red) and creates nothing, which
|
||||
is the required shape: the assert is what covers a manual tag racing the
|
||||
merge, not only an operator's stray re-run.
|
||||
|
||||
### Probe 3 — a bare-version PR without the `release` label
|
||||
|
||||
PR #3 (`probe3-bare`) bumped `VERSION` to bare `0.6.0` and carried no label;
|
||||
the label list was read as empty before merging. The merge run refused at
|
||||
decide, row 5 of the table:
|
||||
|
||||
```text
|
||||
VER: 0.6.0
|
||||
BASE_VER: 0.6.0-dev
|
||||
RELEASED:
|
||||
LABELED: no
|
||||
the version transitioned ('0.6.0-dev' -> '0.6.0') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR, not a bare push — creating nothing.
|
||||
```
|
||||
|
||||
Notes, the assert, tag, hook, publish and bump all skipped; tags and
|
||||
releases were both still empty afterwards. The merge was then undone and
|
||||
main re-armed to `0.6.0-dev` before the ceremony probe ran (see Setup).
|
||||
|
||||
### Probe 2 — a mislabeled ordinary PR
|
||||
|
||||
PR #2 (`probe2b-mislabeled`) added one changelog fragment and touched no
|
||||
version. The `release` label was applied through
|
||||
`POST /repos/{owner}/{repo}/issues/2/labels` and confirmed present before
|
||||
the merge. The merge run decided row 1 of the table and published nothing:
|
||||
|
||||
```text
|
||||
VER: 0.6.0-dev
|
||||
BASE_VER: 0.6.0-dev
|
||||
RELEASED:
|
||||
LABELED:
|
||||
NOTICE: the version '0.6.0-dev' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish.
|
||||
ceremony=no
|
||||
```
|
||||
|
||||
`RELEASED` and `LABELED` are empty on purpose — the `-dev` rows never
|
||||
consult them, which is precisely why the label alone cannot ship anything.
|
||||
Notes, the nothing-exists assert, tag, artifact hook, publish and bump all
|
||||
skipped; `GET /tags` and `GET /releases` were both empty afterwards.
|
||||
|
||||
An earlier merge (PR #1, run 30991571096) was intended as this probe but
|
||||
landed **unlabeled**: `gh pr edit --add-label` failed against this repo's
|
||||
projects-classic GraphQL surface, and the merge went ahead before the
|
||||
failure was read. That run is a green no-op too, but it is not evidence for
|
||||
this probe — an unlabeled ordinary merge proves less than a labeled one —
|
||||
so the probe was re-run as PR #2 with the label applied through the REST
|
||||
endpoint and verified before merging. Recorded here because the run exists
|
||||
in the repo's history and a reader will find it.
|
||||
|
||||
## Setup, and the runs that are not probes
|
||||
|
||||
The armed fixture was committed before the caller, so the first door run had
|
||||
a real parent version to inspect: run **30962040469** is that green baseline
|
||||
no-op. The probes then ran in the order 2, 3, 1, 4, 5, 6 — the refusals
|
||||
first, against an armed tree, so the ceremony itself ran last against a
|
||||
fixture the refusals had already proven intact.
|
||||
|
||||
Three non-probe runs are on the board and are accounted for here rather than
|
||||
left for a reader to guess at:
|
||||
|
||||
- **30991571096** (green) — PR #1, the unlabeled first attempt at probe 2,
|
||||
described above.
|
||||
- **30991892212** (green) — restoring `VERSION` to `0.6.0-dev` after probe
|
||||
3's refusal, so the ceremony probe met an armed tree. Row 2 of the table:
|
||||
the version changed and still ends `-dev`.
|
||||
- **30991958967** (red) — **a builder error, not a door finding.** An
|
||||
uncommitted `VERSION` bump left over from staging the ceremony branch rode
|
||||
along into a setup commit that was meant to touch only the fragments, and
|
||||
pushed bare `0.6.0` straight to main. The door refused it exactly as it
|
||||
refused probe 3, by the same row-5 path, and created nothing: tags and
|
||||
releases were both still empty when the failure was read. Main was re-armed
|
||||
to `0.6.0-dev` (green run **30992046247**) before the ceremony probe. It is
|
||||
written down because a red run on a drill repo that the record does not
|
||||
explain is indistinguishable from a door that failed.
|
||||
|
||||
The fixture's three fragments were also rewritten mid-setup to carry
|
||||
terminal issue citations. The candidate's own `bin/changelog-assemble`
|
||||
refused them without one — `fragment 'changelog.d/1.md' has an entry with no
|
||||
issue citation` — which is #262's rule, one of the two commits on
|
||||
`lib/changelog.sh` that make this release owe a rehearsal at all. The
|
||||
fixture had been written before that rule existed. The refusal is the guard
|
||||
working; the correction is recorded because the fragments the ceremony
|
||||
consumed are not the fragments the repo was created with.
|
||||
|
||||
## What the rehearsal establishes
|
||||
|
||||
Both doors ran live against the 0.6.0 candidate's own machinery. The merge
|
||||
door published exactly one release from a labeled ceremony PR, tagged the
|
||||
reviewed merge commit, and re-armed main itself; it refused a bare push
|
||||
without a label, refused a re-run of its own completed ceremony, and stayed
|
||||
a green no-op under a label carried by ordinary work. The tag door published
|
||||
from a matching manual tag without touching main, and refused a mismatched
|
||||
one before creating anything. Every refusal created nothing — no tag, no
|
||||
release, on any of the four refusal paths.
|
||||
|
|
@ -66,6 +66,46 @@ record is the only thing that survives the drill, and 0.2.0's record shipped
|
|||
its first draft asserting a cleanup that had not happened (#135) — false
|
||||
evidence in the one file whose job is to be evidence.
|
||||
|
||||
A record has one of three shapes. A **rehearsal** records the disposable-repo
|
||||
run above. **Doors unchanged** records the mechanically checked claim below
|
||||
when a new rehearsal would execute the same bytes as the last one. **WAIVED**
|
||||
records a maintainer's judgement under the standing paragraph below. If the
|
||||
doors-unchanged conditions do not all hold, the release owes a rehearsal or a
|
||||
waiver; the narrower shape is never a substitute for either.
|
||||
|
||||
## Doors unchanged
|
||||
|
||||
The builder may assert that no disposable-repo rehearsal is owed only when
|
||||
all three conditions below hold at the candidate head. The release PR's panel
|
||||
verifies the claim like any other evidence, and if any reviewer rules a full
|
||||
drill owed, that verdict wins.
|
||||
|
||||
1. `git diff <last-rehearsed-tag>..HEAD -- <release-path>` contains no change
|
||||
except the `CEREMONY_SELF_REF` pin line in
|
||||
`.github/workflows/release.yml`.
|
||||
2. The release path is exactly the output of
|
||||
`.github/scripts/release-path.sh`: `.github/workflows/release.yml`, `bin/`,
|
||||
`lib/version.sh`, `lib/decide.sh`, `lib/facts.sh`, and
|
||||
`lib/changelog.sh`. The script is the record author's copy-paste source;
|
||||
its contract test keeps this inline list and the workflow's direct and
|
||||
transitive dependencies in agreement.
|
||||
3. The last rehearsed tag's own record is a full rehearsal, its release is
|
||||
published, and `main` was re-armed to `-dev` after it.
|
||||
|
||||
The baseline is the last **rehearsed** tag, never merely the previous tag. A
|
||||
previous-tag baseline could chain one doors-unchanged assertion from another
|
||||
while the doors drift a small diff at a time; the last-rehearsed anchor makes
|
||||
any accumulated release-path change force a new rehearsal.
|
||||
|
||||
The record carries all three measurements as observed at its candidate head,
|
||||
never copied from an earlier record. `drills/0.4.1.md` and
|
||||
`drills/0.5.0.md` are the worked examples; the latter's amendment from a
|
||||
predicted empty `lib/` diff to the observed `lib/ruling.sh` delta is why each
|
||||
candidate is measured afresh (#233). Re-running its stricter baseline now is
|
||||
also the path-enumeration proof: `git diff 0.4.0 0.5.0 -- <release-path>` is
|
||||
only the `CEREMONY_SELF_REF` pin, while adding `lib/ruling.sh` makes the diff
|
||||
non-empty even though neither release door reads that file (#217, #237).
|
||||
|
||||
`actions/drill-recorded` refuses any bare-version tree whose record is
|
||||
missing or blank. A waived drill is still a record: the file says WAIVED and
|
||||
why — a maintainer's call, visible and reviewable in the release PR's diff,
|
||||
|
|
|
|||
104
lib/attention.sh
Normal file
104
lib/attention.sh
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
#!/usr/bin/env bash
|
||||
# lib/attention.sh — the `attention` target invariants (#232, epic #229).
|
||||
#
|
||||
# Both reconcilers source this file. Pure decisions sit above the divider;
|
||||
# the impure orchestrator below reads the current label episode and comments
|
||||
# through the sourcing script's run()/log(). The machine diagnoses only: it
|
||||
# never sets, clears, retargets or assigns anything on the strength of these
|
||||
# checks (#229 D2).
|
||||
|
||||
ATTENTION_MARKER_PREFIX='<!-- ceremony:attention-malformed:'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure decisions. Facts in, verdict out. No gh, no clock.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
attention_target_decision() { # $1 pr|issue, $2 assignee count → MALFORMED_* | KEEP
|
||||
case "$1" in
|
||||
pr) echo MALFORMED_PR ;;
|
||||
issue)
|
||||
if [ "$2" -eq 0 ]; then echo MALFORMED_UNASSIGNED; else echo KEEP; fi ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
attention_comment_decision() { # $1 target verdict, $2 suppression → POST | SUPPRESS | KEEP
|
||||
case "$1" in
|
||||
KEEP) echo KEEP ;;
|
||||
MALFORMED_UNASSIGNED)
|
||||
if [ -n "$2" ]; then echo SUPPRESS; else echo POST; fi ;;
|
||||
MALFORMED_PR) echo POST ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
attention_newest_flag() { # labeled-event ISO-8601 timestamps on stdin → newest
|
||||
sort | tail -n1
|
||||
}
|
||||
|
||||
attention_episode_marker() { # $1 current episode's labeled timestamp
|
||||
printf '%s%s -->\n' "$ATTENTION_MARKER_PREFIX" "$1"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The impure orchestrator. Called only behind a has-attention gate.
|
||||
# Needs REPO; uses the caller's run() and log().
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
reconcile_attention() { # $1 item, $2 pr|issue, $3 assignees, $4 suppression
|
||||
local n="$1" surface="$2" assignees="$3" suppression="${4:-}"
|
||||
local target comment labeled_events labeled_at marker comments body timeline
|
||||
: "${REPO:?reconcile_attention: REPO is required}"
|
||||
|
||||
target="$(attention_target_decision "$surface" "$assignees")"
|
||||
comment="$(attention_comment_decision "$target" "$suppression")"
|
||||
[ "$comment" != KEEP ] || return 0
|
||||
|
||||
if [ "$comment" = SUPPRESS ]; then
|
||||
log "#$n: malformed attention detected; comment suppressed by $suppression precedence"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# forge_timeline projects both forges into the GitHub event shape
|
||||
# (.event / .label.name) — Forgejo's raw timeline carries neither. Capture
|
||||
# its status BEFORE jq: a pipeline's status is the last command's, so
|
||||
# `forge_timeline | jq` would collapse an unreadable timeline into an empty
|
||||
# one, and those are the two states this function exists to tell apart
|
||||
# (#188, and lib/ruling.sh does the identical dance).
|
||||
if ! timeline="$(forge_timeline "$n" 2>/dev/null)"; then
|
||||
log "#$n: attention timeline unreadable — no verdict invented this pass"
|
||||
return 0
|
||||
fi
|
||||
labeled_events="$(jq -r '.[]
|
||||
| select(.event == "labeled" and .label.name == "attention")
|
||||
| .created_at' <<<"$timeline")"
|
||||
if [ -z "$labeled_events" ]; then
|
||||
log "#$n: attention flag has no visible labeled event — no verdict invented this pass"
|
||||
return 0
|
||||
fi
|
||||
labeled_at="$(attention_newest_flag <<<"$labeled_events")"
|
||||
marker="$(attention_episode_marker "$labeled_at")"
|
||||
|
||||
if ! comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" \
|
||||
--jq '.[].body // ""' 2>/dev/null)"; then
|
||||
log "#$n: attention comments unreadable — no verdict invented this pass"
|
||||
return 0
|
||||
fi
|
||||
grep -qF "$marker" <<<"$comments" && return 0
|
||||
|
||||
case "$target" in
|
||||
MALFORMED_PR)
|
||||
body="$marker
|
||||
This pull request carries \`attention\`, but that label is issue-only. Put
|
||||
the label on the assigned issue that owns the claim. The sweep cannot infer
|
||||
which issue that is, so it reports the malformed target without removing or
|
||||
retargeting the label." ;;
|
||||
MALFORMED_UNASSIGNED)
|
||||
body="$marker
|
||||
This issue carries \`attention\` but has no assignee to receive the demand.
|
||||
Assign the intended builder or remove the flag. The sweep reports the board
|
||||
bug without assigning anyone or changing the label." ;;
|
||||
esac
|
||||
run forge_issue_comment "$n" "$body" >/dev/null
|
||||
log "#$n: malformed attention ($surface) — commented; no label or assignee changed"
|
||||
}
|
||||
|
|
@ -115,8 +115,22 @@ changelog_fragments() {
|
|||
# splits the measured history: every healthy entry passes untouched,
|
||||
# the drift cluster does not. mawk's length() counts bytes; prose here
|
||||
# is ASCII and the fuzz is acceptable.
|
||||
# - every entry ends with its issue citation (#262): one '(' group of
|
||||
# '#N', 'repo#N' or 'owner/repo#N' references separated by ', ', then
|
||||
# ')', then the final '.' and nothing after it. Stated as style and
|
||||
# enforced by nobody, this rule cost #255 a full four-bot round on a
|
||||
# missing '(#248)'; the fragment rules that live in this guard drew no
|
||||
# review comment at all across the same fifteen PRs. Measured on the
|
||||
# same normalized entry as the bound above, so a citation that wraps
|
||||
# onto a continuation line still counts. The repo token is the one the
|
||||
# filename rule already admits, so '<repo>-<issue>.md' and its cite
|
||||
# cannot drift apart; the two halves of one convention. A single group
|
||||
# is what makes 'terminal' checkable — '(#236, #250).' lands two issues
|
||||
# in one entry, '(#236) and (#250).' does not. The citation need not
|
||||
# name the file's own issue: the filename already carries the
|
||||
# authorizing one, so a fragment may cite the incident beside it.
|
||||
changelog_fragment_problem() {
|
||||
local file="$1" base problem
|
||||
local file="$1" base problem kind detail rest
|
||||
base="${file##*/}"
|
||||
|
||||
if ! printf '%s\n' "$base" | grep -qE '^([a-z][a-z0-9-]*-)?[0-9]+\.md$'; then
|
||||
|
|
@ -157,9 +171,35 @@ changelog_fragment_problem() {
|
|||
return 1
|
||||
fi
|
||||
|
||||
# One walk of the entries, two rules, and the order between them is
|
||||
# deliberate: an over-long entry anywhere outranks a citation problem
|
||||
# anywhere, so the length diagnosis a fragment already draws is the same
|
||||
# one it drew before the citation rule existed. Both read the entry the
|
||||
# same normalizer produces, which is the whole reason they share a pass.
|
||||
problem="$(
|
||||
awk -v max=300 '
|
||||
function flush( len, e) {
|
||||
# cite_problem <entry> — "", "uncited" or "misplaced". Counting the
|
||||
# groups is what distinguishes the two admitted shapes: one group
|
||||
# closing the entry passes however many references it carries, and a
|
||||
# second group anywhere means no single group is terminal.
|
||||
function cite_problem(e, rest, groups, consumed, group_end) {
|
||||
rest = e
|
||||
groups = 0
|
||||
consumed = 0
|
||||
while (match(rest, /\((([A-Za-z0-9._-]+\/)?[a-z][a-z0-9-]*)?#[0-9]+(, (([A-Za-z0-9._-]+\/)?[a-z][a-z0-9-]*)?#[0-9]+)*\)/)) {
|
||||
groups++
|
||||
group_end = consumed + RSTART + RLENGTH - 1
|
||||
consumed = group_end
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
if (groups == 0) return e ~ /#[0-9]/ ? "misplaced" : "uncited"
|
||||
if (groups > 1) return "misplaced"
|
||||
return (group_end == length(e) - 1 && substr(e, group_end + 1) == ".") ? "" : "misplaced"
|
||||
}
|
||||
function excerpt(e) {
|
||||
return length(e) > 60 ? substr(e, 1, 60) "…" : e
|
||||
}
|
||||
function flush( len, e, kind) {
|
||||
if (entry == "") return 0
|
||||
e = entry
|
||||
entry = ""
|
||||
|
|
@ -168,9 +208,15 @@ changelog_fragment_problem() {
|
|||
sub(/ $/, "", e)
|
||||
len = length(e)
|
||||
if (len > max) {
|
||||
printf "%d\t%s\n", len, substr(e, 1, 60)
|
||||
reported = 1
|
||||
printf "long\t%d\t%s\n", len, excerpt(e)
|
||||
return 1
|
||||
}
|
||||
kind = cite_problem(e)
|
||||
if (kind != "" && cite_kind == "") {
|
||||
cite_kind = kind
|
||||
cite_excerpt = excerpt(e)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
/^### / { if (flush()) exit; next }
|
||||
|
|
@ -182,12 +228,37 @@ changelog_fragment_problem() {
|
|||
}
|
||||
/^[[:space:]]*$/ { next }
|
||||
entry != "" { entry = entry " " $0 }
|
||||
END { flush() }
|
||||
# An exit from a main rule still runs END, so a length row printed
|
||||
# mid-file would be followed by the citation row it outranks — two
|
||||
# lines spliced into one diagnosis, the internal protocol row landing
|
||||
# inside the human-facing excerpt (#262 round 1). The reported flag is
|
||||
# the same guard the empty-heading walk above uses, for the same
|
||||
# reason: one diagnosis per fragment is the contract.
|
||||
END {
|
||||
if (flush()) exit
|
||||
if (!reported && cite_kind != "") printf "%s\t\t%s\n", cite_kind, cite_excerpt
|
||||
}
|
||||
' "$file"
|
||||
)"
|
||||
if [ -n "$problem" ]; then
|
||||
printf "fragment '%s' has a %s-character entry — '%s…' — the bound is 300: split it into multiple '- ' entries in this same fragment\n" \
|
||||
"$file" "${problem%%$'\t'*}" "${problem#*$'\t'}"
|
||||
kind="${problem%%$'\t'*}"
|
||||
rest="${problem#*$'\t'}"
|
||||
detail="${rest%%$'\t'*}"
|
||||
rest="${rest#*$'\t'}"
|
||||
case "$kind" in
|
||||
long)
|
||||
printf "fragment '%s' has a %s-character entry — '%s' — the bound is 300: split it into multiple '- ' entries in this same fragment\n" \
|
||||
"$file" "$detail" "$rest"
|
||||
;;
|
||||
uncited)
|
||||
printf "fragment '%s' has an entry with no issue citation — '%s' — end it with the issue it comes from: '(#N).'\n" \
|
||||
"$file" "$rest"
|
||||
;;
|
||||
*)
|
||||
printf "fragment '%s' has an entry whose issue citation is not terminal — '%s' — exactly one '(#N)' group ends the entry, the final '.' after it\n" \
|
||||
"$file" "$rest"
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
|
|
|||
62
lib/read.sh
Normal file
62
lib/read.sh
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env bash
|
||||
# lib/read.sh — the guarded read: an unreadable fact never invents a verdict.
|
||||
#
|
||||
# Both reconcilers source this file. The rule is the family's oldest one
|
||||
# (#101, #95) and it has now been bought twice: the PR surface learned it
|
||||
# when a permissions denial and a network hiccup left byte-identical
|
||||
# evidence, and the ISSUE surface learned it when an HTTP 504 whose body is
|
||||
# GitHub's JSON error object flowed straight into a decision function —
|
||||
# `gh api` prints that body to stdout *and* exits non-zero, so the payload
|
||||
# reaching the guards was valid JSON, `.labels[]` came back empty, and the
|
||||
# sweep wrote `needs-triage` onto a healthy epic and called the pass a
|
||||
# success (crew#329, #247).
|
||||
#
|
||||
# Two helpers:
|
||||
# - guarded_read — run a read, keep its stderr, report its status
|
||||
# - read_failure_reason — render that stderr into one bounded log line
|
||||
#
|
||||
# `read_failure_reason` is called from both surfaces. `guarded_read` is
|
||||
# called from the issue surface only, and that is deliberate rather than
|
||||
# unfinished: labels-reconcile's two capture sites are byte-identical to each
|
||||
# other and predate this file, and converting them is a cleanup #247 does not
|
||||
# own. Do not go looking for a labels-side caller — there is none yet.
|
||||
#
|
||||
# What the CALLER does with a failed read is the caller's: labels-reconcile
|
||||
# leaves the PR alone for the pass, issueflow-reconcile skips the issue. The
|
||||
# one thing neither may do is carry a degraded value into a decision.
|
||||
|
||||
guarded_read() { # $1 = variable to fill, rest = the read; sets READ_FAILURE_STDERR
|
||||
# The status check and the captured stderr are one operation on purpose: a
|
||||
# read whose failure is noticed but whose reason is thrown away is what
|
||||
# #95 had to infer a cause from a control case for — wrongly, it turned
|
||||
# out (#101 D2). Captured into a file rather than merged into stdout, so
|
||||
# an unlucky error line can never be read back as the read's own payload.
|
||||
local __var="$1" __err __out __rc=0
|
||||
shift
|
||||
__err="$(mktemp)" || return 1
|
||||
__out="$("$@" 2>"$__err")" || __rc=$?
|
||||
# shellcheck disable=SC2034 # the out-parameter: every caller reads it beside the status
|
||||
READ_FAILURE_STDERR="$(cat "$__err")"
|
||||
rm -f "$__err"
|
||||
printf -v "$__var" '%s' "$__out"
|
||||
return "$__rc"
|
||||
}
|
||||
|
||||
read_failure_reason() { # $1 = captured stderr → one bounded line; pure (#101)
|
||||
# Verbatim, collapsed, bounded (D3): gh emits multi-line errors and GraphQL
|
||||
# blobs. Collapsed so the reason is exactly one log line — a raw newline
|
||||
# inside the captured per-item output block could collide with a matched
|
||||
# string — and truncated because an unbounded paste per item per sweep is
|
||||
# noise, and annotations are capped anyway.
|
||||
local reason
|
||||
reason="$(printf '%s' "${1-}" | tr '\n' ' ')"
|
||||
if [ -z "$reason" ]; then
|
||||
# Empty stderr is itself a fact (D4): a read that failed silently is a
|
||||
# different observation from a denial, and must not read as one.
|
||||
echo "no error output"
|
||||
elif [ "${#reason}" -gt 300 ]; then
|
||||
printf '%s…\n' "${reason:0:300}"
|
||||
else
|
||||
printf '%s\n' "$reason"
|
||||
fi
|
||||
}
|
||||
|
|
@ -99,18 +99,40 @@ ruling_bare_comment_needed() { # $1 labeled epoch, $2 newest marked-comment epoc
|
|||
fi
|
||||
}
|
||||
|
||||
ruling_shape_field_present() { # $1 field label; escalation body on stdin → 0 iff present
|
||||
# THE one spelling of the field-presence test — the escalation selector
|
||||
# scores by it and the shape check grades by it, on purpose in one place:
|
||||
# a selector scoring by one grep while the check grades by another is how
|
||||
# the crew#293 misgrade would come back from the other side (#226).
|
||||
# Line-anchored, allowing leading whitespace and Markdown bold
|
||||
# (`**Options:**` is how the live escalations write them): the labels
|
||||
# appearing only mid-sentence is not the template.
|
||||
local field="$1"
|
||||
grep -Eq "^[[:space:]]*(\*\*)?$field"
|
||||
}
|
||||
|
||||
ruling_shape_score() { # escalation body on stdin → 0–4, one point per field present
|
||||
# The selection rule's metric (#226). An empty body scores 0 through the
|
||||
# same loop — no special case, and never an error.
|
||||
local body field score=0
|
||||
body="$(cat)"
|
||||
for field in "${RULING_SHAPE_FIELDS[@]}"; do
|
||||
if ruling_shape_field_present "$field" <<<"$body"; then score=$((score + 1)); fi
|
||||
done
|
||||
echo "$score"
|
||||
}
|
||||
|
||||
ruling_shape_decision() { # escalation body on stdin → SHAPED | MALFORMED <missing labels>
|
||||
# Presence only (#50 D4): that `Recommend:` exists is checkable, that the
|
||||
# recommendation is any good is not — no counting options, no parsing the
|
||||
# prose. Line-anchored, allowing leading whitespace and Markdown bold
|
||||
# (`**Options:**` is how the live escalations write them): the labels
|
||||
# appearing only mid-sentence is not the template. The `🧭 needs-ruling`
|
||||
# header line is deliberately unchecked — it is prose, and an emoji grep
|
||||
# on an LC_ALL=C runner is a portability trap for zero enforcement value.
|
||||
# prose. The per-field test is ruling_shape_field_present, shared with the
|
||||
# selector (#226). The `🧭 needs-ruling` header line is deliberately
|
||||
# unchecked — it is prose, and an emoji grep on an LC_ALL=C runner is a
|
||||
# portability trap for zero enforcement value.
|
||||
local body field missing=""
|
||||
body="$(cat)"
|
||||
for field in "${RULING_SHAPE_FIELDS[@]}"; do
|
||||
grep -Eq "^[[:space:]]*(\*\*)?$field" <<<"$body" || missing="$missing $field"
|
||||
ruling_shape_field_present "$field" <<<"$body" || missing="$missing $field"
|
||||
done
|
||||
if [ -z "$missing" ]; then echo SHAPED; else echo "MALFORMED$missing"; fi
|
||||
}
|
||||
|
|
@ -153,8 +175,11 @@ ruling_default_decision() { # escalation body on stdin → DEADLINE <ts> | HARDB
|
|||
}
|
||||
|
||||
ruling_nudge_decision() { # $1 now, $2 last real-activity epoch → NUDGE | KEEP
|
||||
# Real activity only — comments, reviews, commits, never label churn, or
|
||||
# the sweep would reset its own clock. The nudge needs NO marker: the
|
||||
# Real activity only, as the caller's surface defines it: the PR sweep
|
||||
# supplies comments, reviews and commits; the issue sweeps supply comments
|
||||
# alone — an `assigned` event is the claim clock's fact, and counting it
|
||||
# let a claim silence a pending ruling (#284). Never label churn, or the
|
||||
# sweep would reset its own clock. The nudge needs NO marker: the
|
||||
# nudge comment is itself activity, so posting it resets this window and
|
||||
# the rule self-rate-limits to at most one nudge per 7 quiet days. That is
|
||||
# deliberate — a later refactor that "fixes" it by adding a marker breaks
|
||||
|
|
@ -170,17 +195,32 @@ ruling_newest_flag() { # "login<TAB>iso8601" lines on stdin → the newest line
|
|||
}
|
||||
|
||||
ruling_escalation_row() { # $1 setter, $2 labeled epoch; "login epoch url [b64]" lines on stdin
|
||||
# → "url b64" of the EARLIEST in-window comment by the setter, or nothing.
|
||||
# Earliest, because the natural shape is escalation-then-flag: the first
|
||||
# qualifying comment is the escalation itself, later ones are follow-ups.
|
||||
# The body rides along base64-encoded (#73's shape check reads it); rows
|
||||
# without the column still resolve, with an empty body.
|
||||
local setter="$1" labeled="$2" login epoch url b64 best_epoch="" best=""
|
||||
# → "url b64" of the BEST-SHAPED in-window comment by the setter, or
|
||||
# nothing: highest ruling_shape_score wins, equal scores break to the
|
||||
# earliest epoch. Earliest-wins outright was the rule until crew#293
|
||||
# (2026-08-02): a builder answered its round whole and escalated 33
|
||||
# seconds later — both in one window, the reply earlier — and the sweep
|
||||
# graded the round reply, told a correct escalation it was malformed, and
|
||||
# the setter re-posted a shape it had already met. Score resolves both
|
||||
# orderings; the earliest tiebreak keeps escalation-then-follow-ups
|
||||
# wherever the scores cannot tell candidates apart, including all-zero.
|
||||
# An undecodable or absent body scores 0 and stays a legal candidate —
|
||||
# an unreadable fact never invents a verdict, and never errors the sweep.
|
||||
# The window and the setter gate candidacy before any score is taken.
|
||||
local setter="$1" labeled="$2" login epoch url b64 body score
|
||||
local best_score=-1 best_epoch="" best=""
|
||||
while read -r login epoch url b64; do
|
||||
[ -n "$login" ] || continue
|
||||
[ "$login" = "$setter" ] || continue
|
||||
ruling_accompanies "$epoch" "$labeled" || continue
|
||||
if [ -z "$best_epoch" ] || [ "$epoch" -lt "$best_epoch" ]; then
|
||||
if body="$(base64 -d <<<"${b64:-}" 2>/dev/null)"; then
|
||||
score="$(ruling_shape_score <<<"$body")"
|
||||
else
|
||||
score=0
|
||||
fi
|
||||
if [ "$score" -gt "$best_score" ] \
|
||||
|| { [ "$score" -eq "$best_score" ] && [ "$epoch" -lt "$best_epoch" ]; }; then
|
||||
best_score="$score"
|
||||
best_epoch="$epoch"
|
||||
best="$url ${b64:-}"
|
||||
fi
|
||||
|
|
|
|||
47
test/attention.test.sh
Normal file
47
test/attention.test.sh
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env bash
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=test/harness.sh
|
||||
source "$ROOT/test/harness.sh"
|
||||
# shellcheck source=lib/attention.sh
|
||||
source "$ROOT/lib/attention.sh"
|
||||
|
||||
check "attention on an unassigned PR is malformed" 0 "MALFORMED_PR" \
|
||||
attention_target_decision pr 0
|
||||
check "attention on an assigned PR is still malformed" 0 "MALFORMED_PR" \
|
||||
attention_target_decision pr 1
|
||||
check "attention on an unassigned issue is malformed" 0 "MALFORMED_UNASSIGNED" \
|
||||
attention_target_decision issue 0
|
||||
check "attention on an assigned issue is healthy" 0 "KEEP" \
|
||||
attention_target_decision issue 1
|
||||
check "an unknown surface is rejected" 2 "" attention_target_decision discussion 0
|
||||
|
||||
check "a malformed PR target is commented" 0 "POST" \
|
||||
attention_comment_decision MALFORMED_PR ""
|
||||
check "an unassigned issue target is commented without precedence" 0 "POST" \
|
||||
attention_comment_decision MALFORMED_UNASSIGNED ""
|
||||
check "claimed-unassigned precedence suppresses the second comment" 0 "SUPPRESS" \
|
||||
attention_comment_decision MALFORMED_UNASSIGNED claimed-unassigned
|
||||
check "post-merge-assigned precedence suppresses the second comment" 0 "SUPPRESS" \
|
||||
attention_comment_decision MALFORMED_UNASSIGNED post-merge-assigned
|
||||
check "a healthy target stays silent" 0 "KEEP" \
|
||||
attention_comment_decision KEEP ""
|
||||
|
||||
check "the newest labeled event defines the episode" 0 "2026-08-03T12:00:00Z" \
|
||||
attention_newest_flag <<'EOF'
|
||||
2026-08-03T10:00:00Z
|
||||
2026-08-03T12:00:00Z
|
||||
2026-08-03T11:00:00Z
|
||||
EOF
|
||||
check "the marker names the label episode" 0 \
|
||||
'<!-- ceremony:attention-malformed:2026-08-03T12:00:00Z -->' \
|
||||
attention_episode_marker 2026-08-03T12:00:00Z
|
||||
|
||||
# Diagnosis is the only write this library may own. Pin the absence of every
|
||||
# label/assignee mutation spelling so a later refactor cannot quietly turn a
|
||||
# report into a repair (#229 D2).
|
||||
check "the attention library contains no issue/PR edit mutation" 1 "" \
|
||||
grep -E 'gh (issue|pr) edit|--(add|remove)-(label|assignee)' "$ROOT/lib/attention.sh"
|
||||
|
||||
summary
|
||||
|
|
@ -275,7 +275,7 @@ fragment_tree fragments-dev-flat 1.2.4-dev <<'EOF'
|
|||
|
||||
- The shipped entry.
|
||||
EOF
|
||||
printf '%s\n' "- Added fragment mode." >"$TMP/fragments-dev-flat/changelog.d/115.md"
|
||||
printf '%s\n' "- Added fragment mode (#115)." >"$TMP/fragments-dev-flat/changelog.d/115.md"
|
||||
check "fragment -dev + well-formed flat fragment passes" 0 "fragment mode" \
|
||||
in_tree fragments-dev-flat
|
||||
|
||||
|
|
@ -298,6 +298,38 @@ check "fragment mode over-bound refusal names the bound and the split fix" 1 \
|
|||
"the bound is 300: split it into multiple '- ' entries in this same fragment" \
|
||||
in_tree fragments-dev-over-bound
|
||||
|
||||
# The terminal cite (#262) reds the PR that writes the fragment, through the
|
||||
# same shared predicate — which is the whole point of the rule living there
|
||||
# rather than in prose a reviewer has to remember.
|
||||
fragment_tree fragments-dev-uncited 1.2.4-dev <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## 1.2.3 — 2026-07-20
|
||||
|
||||
- The shipped entry.
|
||||
EOF
|
||||
printf '%s\n' "- An entry that never learned to cite its issue." \
|
||||
>"$TMP/fragments-dev-uncited/changelog.d/115.md"
|
||||
check "fragment mode refuses an uncited entry, fragment named" 1 \
|
||||
"115.md' has an entry with no issue citation" \
|
||||
in_tree fragments-dev-uncited
|
||||
check "fragment mode uncited refusal names the shape to write" 1 \
|
||||
"end it with the issue it comes from: '(#N).'" \
|
||||
in_tree fragments-dev-uncited
|
||||
|
||||
fragment_tree fragments-dev-misplaced-cite 1.2.4-dev <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## 1.2.3 — 2026-07-20
|
||||
|
||||
- The shipped entry.
|
||||
EOF
|
||||
printf '%s\n' "- The citation trails the period. (#115)" \
|
||||
>"$TMP/fragments-dev-misplaced-cite/changelog.d/115.md"
|
||||
check "fragment mode refuses a non-terminal citation, fragment named" 1 \
|
||||
"115.md' has an entry whose issue citation is not terminal" \
|
||||
in_tree fragments-dev-misplaced-cite
|
||||
|
||||
fragment_tree fragments-dev-grouped 1.2.4-dev <<'EOF'
|
||||
# Changelog
|
||||
|
||||
|
|
@ -310,7 +342,7 @@ EOF
|
|||
cat >"$TMP/fragments-dev-grouped/changelog.d/115.md" <<'EOF'
|
||||
### Changed
|
||||
|
||||
- Added fragment mode.
|
||||
- Added fragment mode (#115).
|
||||
EOF
|
||||
check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \
|
||||
in_tree fragments-dev-grouped
|
||||
|
|
@ -322,11 +354,11 @@ fragment_tree fragments-dev-mixed 1.2.4-dev <<'EOF'
|
|||
|
||||
- The shipped entry.
|
||||
EOF
|
||||
printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-mixed/changelog.d/114.md"
|
||||
printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-mixed/changelog.d/114.md"
|
||||
cat >"$TMP/fragments-dev-mixed/changelog.d/115.md" <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Grouped fragment.
|
||||
- Grouped fragment (#115).
|
||||
EOF
|
||||
check "fragment mode refuses mixed shapes with the shared assembler diagnosis" 1 \
|
||||
"fragment 'changelog.d/115.md' is grouped but fragment 'changelog.d/114.md' is not" \
|
||||
|
|
@ -342,7 +374,7 @@ EOF
|
|||
cat >"$TMP/fragments-dev-all-grouped-over-flat/changelog.d/115.md" <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Grouped fragment.
|
||||
- Grouped fragment (#115).
|
||||
EOF
|
||||
check "fragment mode refuses an all-grouped set over a flat published section" 1 \
|
||||
"changelog.d/115.md' is grouped but newest published section '1.2.3'" \
|
||||
|
|
@ -357,7 +389,7 @@ fragment_tree fragments-dev-flat-over-grouped 1.2.4-dev <<'EOF'
|
|||
|
||||
- The shipped entry.
|
||||
EOF
|
||||
printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md"
|
||||
printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md"
|
||||
check "fragment mode refuses a flat set over a grouped published section" 1 \
|
||||
"changelog.d/115.md' is flat but newest published section '1.2.3'" \
|
||||
in_tree fragments-dev-flat-over-grouped
|
||||
|
|
@ -376,14 +408,14 @@ printf '%s\n' "grouped" >"$TMP/fragments-dev-flip/changelog.d/shape"
|
|||
cat >"$TMP/fragments-dev-flip/changelog.d/115.md" <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Grouped fragment.
|
||||
- Grouped fragment (#115).
|
||||
EOF
|
||||
check "fragment mode: 'grouped' sentinel admits the flip tree over a flat published section" 0 \
|
||||
"fragment mode" in_tree fragments-dev-flip
|
||||
|
||||
# Post-flip drift is refused on its own PR: a flat probe fragment atop the
|
||||
# flip tree goes red — beside grouped fragments the mix rule names it first.
|
||||
printf '%s\n' "- Flat probe." >"$TMP/fragments-dev-flip/changelog.d/116.md"
|
||||
printf '%s\n' "- Flat probe (#116)." >"$TMP/fragments-dev-flip/changelog.d/116.md"
|
||||
check "fragment mode: a flat probe atop the flip tree is refused" 1 \
|
||||
"changelog.d/115.md' is grouped but fragment 'changelog.d/116.md' is not" \
|
||||
in_tree fragments-dev-flip
|
||||
|
|
@ -393,7 +425,7 @@ rm "$TMP/fragments-dev-flip/changelog.d/116.md"
|
|||
# holds the shape: an all-flat set under 'grouped' is refused, sentinel
|
||||
# named — the published-section inference never gets a say.
|
||||
rm "$TMP/fragments-dev-flip/changelog.d/115.md"
|
||||
printf '%s\n' "- Flat probe." >"$TMP/fragments-dev-flip/changelog.d/116.md"
|
||||
printf '%s\n' "- Flat probe (#116)." >"$TMP/fragments-dev-flip/changelog.d/116.md"
|
||||
check "fragment mode: a flat set under the 'grouped' sentinel refused, sentinel named" 1 \
|
||||
"changelog.d/116.md' is flat but 'changelog.d/shape' declares grouped" \
|
||||
in_tree fragments-dev-flip
|
||||
|
|
@ -417,7 +449,7 @@ EOF
|
|||
cat >"$TMP/fragments-dev-no-published/changelog.d/115.md" <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Grouped fragment.
|
||||
- Grouped fragment (#115).
|
||||
EOF
|
||||
check "fragment mode accepts a consistent set with no published section" 0 \
|
||||
"fragment mode" in_tree fragments-dev-no-published
|
||||
|
|
@ -484,7 +516,7 @@ check "fragment bare + stamped section + consumed directory passes" 0 \
|
|||
"fragment mode" in_tree fragments-bare-stamped
|
||||
|
||||
cp -R "$TMP/fragments-bare-stamped" "$TMP/fragments-bare-survivor"
|
||||
printf '%s\n' "- This entry was not consumed." \
|
||||
printf '%s\n' "- This entry was not consumed (#115)." \
|
||||
>"$TMP/fragments-bare-survivor/changelog.d/115.md"
|
||||
check "fragment bare refuses and lists surviving fragments" 1 \
|
||||
"these fragments were not consumed: changelog.d/115.md" \
|
||||
|
|
|
|||
|
|
@ -55,13 +55,13 @@ tree flat-one <<EOF
|
|||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag flat-one 12.md <<'EOF'
|
||||
- Twelve landed.
|
||||
- Twelve landed (#12).
|
||||
EOF
|
||||
check "flat: one fragment assembles and stamps" 0 "consumed 1 fragment" \
|
||||
in_tree flat-one 0.2.0 2026-07-24
|
||||
check "flat: preamble and shipped section stay byte-identical around the insert" 0 "" \
|
||||
assert_file "$TMP/flat-one/CHANGELOG.md" \
|
||||
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n- Twelve landed.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
|
||||
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n- Twelve landed (#12).\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
|
||||
check "flat: the consumed fragment is deleted" 1 "" \
|
||||
test -e "$TMP/flat-one/changelog.d/12.md"
|
||||
check "flat: README.md survives consumption" 0 "" \
|
||||
|
|
@ -73,17 +73,17 @@ tree flat-many <<EOF
|
|||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag flat-many 2.md <<'EOF'
|
||||
- Two.
|
||||
- Two (#2).
|
||||
EOF
|
||||
frag flat-many 9.md <<'EOF'
|
||||
- Nine.
|
||||
- Nine (#9).
|
||||
EOF
|
||||
frag flat-many 10.md <<'EOF'
|
||||
- Ten.
|
||||
- Ten (#10).
|
||||
EOF
|
||||
frag flat-many ceremony-14.md <<'EOF'
|
||||
- Fourteen crossed over — naïve reflows would mangle this café's
|
||||
continuation line, so it must survive verbatim.
|
||||
continuation line, so it must survive verbatim (#14).
|
||||
EOF
|
||||
|
||||
assert_check() {
|
||||
|
|
@ -95,7 +95,7 @@ assert_check() {
|
|||
}
|
||||
}
|
||||
check "flat: numeric-descending order (10.md before 9.md), cross-repo name beside local" 0 "" \
|
||||
assert_check flat-many $'- Fourteen crossed over — naïve reflows would mangle this café'"'"$'s\n continuation line, so it must survive verbatim.\n- Ten.\n- Nine.\n- Two.'
|
||||
assert_check flat-many $'- Fourteen crossed over — naïve reflows would mangle this café'"'"$'s\n continuation line, so it must survive verbatim (#14).\n- Ten (#10).\n- Nine (#9).\n- Two (#2).'
|
||||
|
||||
# --- grouped write: canonical order, unnamed group appended ------------------
|
||||
|
||||
|
|
@ -113,28 +113,28 @@ EOF
|
|||
frag grouped 21.md <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Fixed twenty-one.
|
||||
- Fixed twenty-one (#21).
|
||||
EOF
|
||||
frag grouped 20.md <<'EOF'
|
||||
### Added
|
||||
|
||||
- Added twenty.
|
||||
- Added twenty, second bullet.
|
||||
- Added twenty (#20).
|
||||
- Added twenty, second bullet (#20).
|
||||
|
||||
### Docs
|
||||
|
||||
- Docs twenty.
|
||||
- Docs twenty (#20).
|
||||
EOF
|
||||
frag grouped 19.md <<'EOF'
|
||||
### Security
|
||||
|
||||
- Security nineteen.
|
||||
- Security nineteen (#19).
|
||||
|
||||
### Added
|
||||
|
||||
- Added nineteen.
|
||||
- Added nineteen (#19).
|
||||
EOF
|
||||
GROUPED_BODY=$'### Added\n\n- Added twenty.\n- Added twenty, second bullet.\n- Added nineteen.\n\n### Fixed\n\n- Fixed twenty-one.\n\n### Security\n\n- Security nineteen.\n\n### Docs\n\n- Docs twenty.'
|
||||
GROUPED_BODY=$'### Added\n\n- Added twenty (#20).\n- Added twenty, second bullet (#20).\n- Added nineteen (#19).\n\n### Fixed\n\n- Fixed twenty-one (#21).\n\n### Security\n\n- Security nineteen (#19).\n\n### Docs\n\n- Docs twenty (#20).'
|
||||
check "grouped: --check shows canonical order, multi-bullet group, unnamed group last" 0 "" \
|
||||
assert_check grouped "$GROUPED_BODY"
|
||||
check "grouped: write mode assembles the same section" 0 "consumed 3 fragment" \
|
||||
|
|
@ -156,13 +156,13 @@ printf 'grouped\n' >"$TMP/flip/changelog.d/shape"
|
|||
frag flip 40.md <<'EOF'
|
||||
### Added
|
||||
|
||||
- Forty landed.
|
||||
- Forty landed (#40).
|
||||
EOF
|
||||
check "sentinel: the flip release assembles grouped over a flat published section" 0 \
|
||||
"consumed 1 fragment" in_tree flip 0.2.0 2026-07-24
|
||||
check "sentinel: the written flip section is exact" 0 "" \
|
||||
assert_file "$TMP/flip/CHANGELOG.md" \
|
||||
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n### Added\n\n- Forty landed.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
|
||||
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n### Added\n\n- Forty landed (#40).\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
|
||||
check "sentinel: changelog.d/shape survives consumption" 0 "" \
|
||||
test -e "$TMP/flip/changelog.d/shape"
|
||||
|
||||
|
|
@ -171,7 +171,7 @@ $BASE_CHANGELOG
|
|||
EOF
|
||||
printf 'grouped\n' >"$TMP/flip-flat-frag/changelog.d/shape"
|
||||
frag flip-flat-frag 41.md <<'EOF'
|
||||
- Flat forty-one.
|
||||
- Flat forty-one (#41).
|
||||
EOF
|
||||
check "sentinel: a flat fragment under 'grouped' refuses, sentinel named" 1 \
|
||||
"changelog.d/shape' declares grouped" in_tree flip-flat-frag 0.2.0 2026-07-24
|
||||
|
|
@ -183,7 +183,7 @@ printf 'Grouped\n' >"$TMP/flip-malformed/changelog.d/shape"
|
|||
frag flip-malformed 42.md <<'EOF'
|
||||
### Added
|
||||
|
||||
- Forty-two.
|
||||
- Forty-two (#42).
|
||||
EOF
|
||||
check "sentinel: a malformed sentinel refuses, file named" 1 \
|
||||
"changelog.d/shape' declares neither shape" in_tree flip-malformed 0.2.0 2026-07-24
|
||||
|
|
@ -196,13 +196,45 @@ tree preamble-only <<'EOF'
|
|||
Only preamble so far.
|
||||
EOF
|
||||
frag preamble-only 1.md <<'EOF'
|
||||
- The first entry ever.
|
||||
- The first entry ever (#1).
|
||||
EOF
|
||||
check "a changelog with no section yet gets the section after the preamble" 0 "" \
|
||||
in_tree preamble-only 0.1.0 2026-07-24
|
||||
check "preamble-only write is exact" 0 "" \
|
||||
assert_file "$TMP/preamble-only/CHANGELOG.md" \
|
||||
$'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever.'
|
||||
$'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever (#1).'
|
||||
|
||||
# --- the fragment predicate at release time (#262) ---------------------------
|
||||
|
||||
# The cite rule joins changelog_fragment_problem, so it binds both callers:
|
||||
# the arming guard at PR time and this assembler at release time. Asserted
|
||||
# rather than assumed — a release that publishes an uncited entry is the
|
||||
# failure the PR-time guard exists to have already caught.
|
||||
|
||||
tree uncited-release <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag uncited-release 60.md <<'EOF'
|
||||
- An entry that never learned to cite its issue.
|
||||
EOF
|
||||
check "release time: an uncited fragment refuses the release, fragment named" 1 \
|
||||
"changelog.d/60.md' has an entry with no issue citation" \
|
||||
in_tree uncited-release 0.2.0 2026-07-24
|
||||
check "release time: the uncited refusal survives --check too" 1 \
|
||||
"has an entry with no issue citation" \
|
||||
in_tree uncited-release 0.2.0 2026-07-24 --check
|
||||
check "release time: the refused release wrote nothing" 0 "" \
|
||||
test -e "$TMP/uncited-release/changelog.d/60.md"
|
||||
|
||||
tree misplaced-release <<EOF
|
||||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag misplaced-release 61.md <<'EOF'
|
||||
- The citation trails the period. (#61)
|
||||
EOF
|
||||
check "release time: a non-terminal citation refuses the release" 1 \
|
||||
"changelog.d/61.md' has an entry whose issue citation is not terminal" \
|
||||
in_tree misplaced-release 0.2.0 2026-07-24
|
||||
|
||||
# --- --check is provably read-only -------------------------------------------
|
||||
|
||||
|
|
@ -210,10 +242,10 @@ tree check-readonly <<EOF
|
|||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag check-readonly 5.md <<'EOF'
|
||||
- Five.
|
||||
- Five (#5).
|
||||
EOF
|
||||
cp -R "$TMP/check-readonly" "$TMP/check-readonly.before"
|
||||
check "--check prints the assembled body" 0 "Five." \
|
||||
check "--check prints the assembled body" 0 "Five (#5)." \
|
||||
in_tree check-readonly 0.2.0 2026-07-24 --check
|
||||
check "--check is read-only: the tree is byte-identical before and after" 0 "" \
|
||||
diff -r "$TMP/check-readonly.before" "$TMP/check-readonly"
|
||||
|
|
@ -229,11 +261,11 @@ check "the defaulted stamp is a UTC date" 0 "" \
|
|||
|
||||
mkdir -p "$TMP/flagged/frags"
|
||||
printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/flagged/NOTES.md"
|
||||
printf -- '- Flagged entry.\n' >"$TMP/flagged/frags/2.md"
|
||||
printf -- '- Flagged entry (#2).\n' >"$TMP/flagged/frags/2.md"
|
||||
check "--changelog and --dir override the defaults" 0 "" \
|
||||
"$TOOL" 0.2.0 2026-07-24 --changelog "$TMP/flagged/NOTES.md" --dir "$TMP/flagged/frags"
|
||||
check "the flag-driven write landed in the named changelog" 0 "" \
|
||||
grep -qF -- "- Flagged entry." "$TMP/flagged/NOTES.md"
|
||||
grep -qF -- "- Flagged entry (#2)." "$TMP/flagged/NOTES.md"
|
||||
|
||||
# --- refusals: each names the file responsible -------------------------------
|
||||
|
||||
|
|
@ -292,7 +324,7 @@ tree stray-txt <<EOF
|
|||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag stray-txt 7.md <<'EOF'
|
||||
- Seven.
|
||||
- Seven (#7).
|
||||
EOF
|
||||
frag stray-txt notes.txt <<'EOF'
|
||||
A stray scratchpad.
|
||||
|
|
@ -322,12 +354,12 @@ tree mixed <<EOF
|
|||
$BASE_CHANGELOG
|
||||
EOF
|
||||
frag mixed 5.md <<'EOF'
|
||||
- Flat five.
|
||||
- Flat five (#5).
|
||||
EOF
|
||||
frag mixed 6.md <<'EOF'
|
||||
### Added
|
||||
|
||||
- Grouped six.
|
||||
- Grouped six (#6).
|
||||
EOF
|
||||
check "grouped + flat mixed refuses, both files named" 1 "6.md" \
|
||||
in_tree mixed 0.2.0
|
||||
|
|
@ -340,7 +372,7 @@ EOF
|
|||
frag grouped-over-flat 6.md <<'EOF'
|
||||
### Added
|
||||
|
||||
- Grouped six.
|
||||
- Grouped six (#6).
|
||||
EOF
|
||||
check "an all-grouped set over a flat published section refuses before assembly" 1 \
|
||||
"fragment 'changelog.d/6.md' is grouped but newest published section '0.1.0'" \
|
||||
|
|
@ -354,7 +386,7 @@ tree already <<'EOF'
|
|||
- Already shipped.
|
||||
EOF
|
||||
frag already 4.md <<'EOF'
|
||||
- A late fragment.
|
||||
- A late fragment (#4).
|
||||
EOF
|
||||
check "an already-present section refuses — the ceremony was already run" 1 \
|
||||
"already has a section for '0.2.0'" \
|
||||
|
|
@ -370,13 +402,13 @@ tree rc-present <<'EOF'
|
|||
- The candidate's entry.
|
||||
EOF
|
||||
frag rc-present 8.md <<'EOF'
|
||||
- The real release entry.
|
||||
- The real release entry (#8).
|
||||
EOF
|
||||
check "an rc section does not block assembling the bare version" 0 "" \
|
||||
in_tree rc-present 0.2.0 2026-07-24
|
||||
|
||||
mkdir -p "$TMP/no-changelog/changelog.d"
|
||||
printf -- '- Entry.\n' >"$TMP/no-changelog/changelog.d/2.md"
|
||||
printf -- '- Entry (#2).\n' >"$TMP/no-changelog/changelog.d/2.md"
|
||||
check "a missing changelog refuses" 1 "no such file" \
|
||||
in_tree no-changelog 0.2.0
|
||||
|
||||
|
|
@ -404,12 +436,12 @@ frag round-trip 30.md <<'EOF'
|
|||
### Added
|
||||
|
||||
- Thirty — wraps onto a
|
||||
continuation line with a naïve café.
|
||||
continuation line with a naïve café (#30).
|
||||
EOF
|
||||
frag round-trip 29.md <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Fixed twenty-nine.
|
||||
- Fixed twenty-nine (#29).
|
||||
EOF
|
||||
CHECKED="$(in_tree round-trip 0.2.0 2026-07-24 --check)"
|
||||
check "round trip: write mode succeeds after --check" 0 "" \
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ Preamble prose belongs to no section.
|
|||
- The shipped entry.
|
||||
EOF
|
||||
printf '0.1.1-dev\n' >"$dir/VERSION"
|
||||
printf -- '- Twelve landed.\n' >"$dir/changelog.d/12.md"
|
||||
printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$dir/changelog.d/9.md"
|
||||
printf -- '- Twelve landed (#12).\n' >"$dir/changelog.d/12.md"
|
||||
printf -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\n' >"$dir/changelog.d/9.md"
|
||||
commit_base "$name"
|
||||
}
|
||||
|
||||
|
|
@ -86,8 +86,8 @@ check "faithful flat ceremony: the section is byte-for-byte the assembly" 0 \
|
|||
|
||||
seed_flat faithful-grouped
|
||||
sed -i '/^- The shipped entry/i ### Fixed\\\n' "$TMP/faithful-grouped/CHANGELOG.md"
|
||||
printf -- '### Fixed\n\n- Fixed twenty-one.\n' >"$TMP/faithful-grouped/changelog.d/21.md"
|
||||
printf -- '### Added\n\n- Added twenty.\n\n### Docs\n\n- Docs twenty.\n' >"$TMP/faithful-grouped/changelog.d/20.md"
|
||||
printf -- '### Fixed\n\n- Fixed twenty-one (#21).\n' >"$TMP/faithful-grouped/changelog.d/21.md"
|
||||
printf -- '### Added\n\n- Added twenty (#20).\n\n### Docs\n\n- Docs twenty (#20).\n' >"$TMP/faithful-grouped/changelog.d/20.md"
|
||||
rm "$TMP/faithful-grouped/changelog.d/12.md" "$TMP/faithful-grouped/changelog.d/9.md"
|
||||
git -C "$TMP/faithful-grouped" add -A
|
||||
git -C "$TMP/faithful-grouped" commit -qm regroup
|
||||
|
|
@ -108,7 +108,7 @@ check "the stamp's date never enters the comparison" 0 "byte-for-byte" \
|
|||
# --- inapplicable trees: green NOTICE, never a silent skip -------------------
|
||||
|
||||
seed_flat ordinary-add
|
||||
printf -- '- Thirteen incoming.\n' >"$TMP/ordinary-add/changelog.d/13.md"
|
||||
printf -- '- Thirteen incoming (#13).\n' >"$TMP/ordinary-add/changelog.d/13.md"
|
||||
commit_head ordinary-add
|
||||
check "-dev PR adding a fragment: green NOTICE" 0 "NOTICE" run ordinary-add base
|
||||
|
||||
|
|
@ -195,8 +195,8 @@ Preamble prose belongs to no section.
|
|||
## 0.2.0 — 2026-07-24
|
||||
|
||||
- Nine landed, and its prose wraps onto a
|
||||
continuation line.
|
||||
- Twelve landed.
|
||||
continuation line (#9).
|
||||
- Twelve landed (#12).
|
||||
|
||||
## 0.1.0 — 2026-07-01
|
||||
|
||||
|
|
@ -210,7 +210,7 @@ check "re-ordered entries fail" 1 "NOT what the fragments" run reordered base
|
|||
# directory is not — only the survivor refusal fires.
|
||||
seed_flat survivor
|
||||
ceremony survivor 0.2.0 2026-07-24
|
||||
printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$TMP/survivor/changelog.d/9.md"
|
||||
printf -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\n' >"$TMP/survivor/changelog.d/9.md"
|
||||
commit_head survivor
|
||||
check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \
|
||||
run survivor base
|
||||
|
|
@ -318,7 +318,7 @@ init_repo env-tree
|
|||
mkdir -p "$TMP/env-tree/frags"
|
||||
printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/env-tree/NOTES.md"
|
||||
printf '0.1.1-dev\n' >"$TMP/env-tree/VERSION"
|
||||
printf -- '- Flagged entry.\n' >"$TMP/env-tree/frags/2.md"
|
||||
printf -- '- Flagged entry (#2).\n' >"$TMP/env-tree/frags/2.md"
|
||||
git -C "$TMP/env-tree" add -A
|
||||
git -C "$TMP/env-tree" commit -qm base
|
||||
git -C "$TMP/env-tree" branch fixture-base
|
||||
|
|
|
|||
|
|
@ -182,11 +182,11 @@ printf 'marker\n' >"$FRAG/README.md"
|
|||
check "fragments: README.md is the directory marker, never a fragment" 0 "" \
|
||||
changelog_fragments "$FRAG"
|
||||
|
||||
printf -- '- Two.\n' >"$FRAG/2.md"
|
||||
printf -- '- Nine.\n' >"$FRAG/9.md"
|
||||
printf -- '- Ten.\n' >"$FRAG/10.md"
|
||||
printf -- '- Cross.\n' >"$FRAG/ceremony-14.md"
|
||||
printf -- '- Local fourteen.\n' >"$FRAG/14.md"
|
||||
printf -- '- Two (#2).\n' >"$FRAG/2.md"
|
||||
printf -- '- Nine (#9).\n' >"$FRAG/9.md"
|
||||
printf -- '- Ten (#10).\n' >"$FRAG/10.md"
|
||||
printf -- '- Cross (#14).\n' >"$FRAG/ceremony-14.md"
|
||||
printf -- '- Local fourteen (#14).\n' >"$FRAG/14.md"
|
||||
|
||||
assert_fragments_order() {
|
||||
local expected="$1" actual
|
||||
|
|
@ -205,19 +205,19 @@ check "fragments: issue number descending (numeric, 10 before 9), filename tie-b
|
|||
PF="$TMP/frag-problems"
|
||||
mkdir -p "$PF"
|
||||
|
||||
printf -- '- Fine.\n' >"$PF/7.md"
|
||||
printf -- '- Fine (#7).\n' >"$PF/7.md"
|
||||
check "fragment predicate: a flat fragment passes" 0 "" \
|
||||
changelog_fragment_problem "$PF/7.md"
|
||||
|
||||
cat >"$PF/8.md" <<'EOF'
|
||||
### Added
|
||||
|
||||
- Grouped fine.
|
||||
- Grouped fine (#8).
|
||||
EOF
|
||||
check "fragment predicate: a grouped fragment passes" 0 "" \
|
||||
changelog_fragment_problem "$PF/8.md"
|
||||
|
||||
printf -- '- Cross-repo.\n' >"$PF/ceremony-14.md"
|
||||
printf -- '- Cross-repo (#14).\n' >"$PF/ceremony-14.md"
|
||||
check "fragment predicate: a cross-repo name passes" 0 "" \
|
||||
changelog_fragment_problem "$PF/ceremony-14.md"
|
||||
|
||||
|
|
@ -274,14 +274,14 @@ check "length bound: the refusal names the bound and the split fix" 1 \
|
|||
"the bound is 300: split it into multiple '- ' entries in this same fragment" \
|
||||
changelog_fragment_problem "$PF/30.md"
|
||||
|
||||
printf -- '- %s\n' "$(mkchars 300)" >"$PF/31.md"
|
||||
printf -- '- %s (#31).\n' "$(mkchars 293)" >"$PF/31.md"
|
||||
check "length bound: an entry of exactly 300 passes" 0 "" \
|
||||
changelog_fragment_problem "$PF/31.md"
|
||||
|
||||
{
|
||||
printf -- '- %s\n' "$(mkchars 150)"
|
||||
printf -- '- %s\n' "$(mkchars 150)"
|
||||
printf -- '- %s\n' "$(mkchars 150)"
|
||||
printf -- '- %s (#32).\n' "$(mkchars 143)"
|
||||
printf -- '- %s (#32).\n' "$(mkchars 143)"
|
||||
printf -- '- %s (#32).\n' "$(mkchars 143)"
|
||||
} >"$PF/32.md"
|
||||
check "length bound: several within-bound entries pass though the file totals over 300" 0 "" \
|
||||
changelog_fragment_problem "$PF/32.md"
|
||||
|
|
@ -291,14 +291,14 @@ check "length bound: several within-bound entries pass though the file totals ov
|
|||
printf ' %s\n' "$(mkchars 50)"
|
||||
printf ' %s\n' "$(mkchars 50)"
|
||||
printf ' %s\n' "$(mkchars 50)"
|
||||
printf ' %s\n' "$(mkchars 50)"
|
||||
printf ' %s (#33).\n' "$(mkchars 50)"
|
||||
} >"$PF/33.md"
|
||||
check "length bound: a ~250-character entry wrapped over four continuation lines passes" 0 "" \
|
||||
changelog_fragment_problem "$PF/33.md"
|
||||
|
||||
{
|
||||
printf '### Added\n\n'
|
||||
printf -- '- %s\n' "$(mkchars 300)"
|
||||
printf -- '- %s (#34).\n' "$(mkchars 293)"
|
||||
} >"$PF/34.md"
|
||||
check "length bound: a '### ' heading counts toward no entry — 300 under it still passes" 0 "" \
|
||||
changelog_fragment_problem "$PF/34.md"
|
||||
|
|
@ -331,6 +331,151 @@ check "length bound: published sections stay unvalidated — 0.3.0's over-bound
|
|||
check "length bound: published sections stay unvalidated — 0.2.0 reds nothing either" 0 "" \
|
||||
changelog_section_problem "$ROOT/CHANGELOG.md" 0.2.0
|
||||
|
||||
# --- the terminal issue cite (#262) ------------------------------------------
|
||||
|
||||
# cite_case <number> <entry-line...> — a fragment holding exactly the given
|
||||
# lines, so a case reads as the entry it is about.
|
||||
cite_case() {
|
||||
local num="$1"
|
||||
shift
|
||||
printf '%s\n' "$@" >"$PF/$num.md"
|
||||
}
|
||||
|
||||
cite_case 40 '- Local (#262).'
|
||||
check "cite: the canonical '(#N).' passes" 0 "" \
|
||||
changelog_fragment_problem "$PF/40.md"
|
||||
cite_case 41 '- Sibling repo (crew#309).'
|
||||
check "cite: a sibling-repo reference passes" 0 "" \
|
||||
changelog_fragment_problem "$PF/41.md"
|
||||
cite_case 42 '- Fully qualified (heavy-duty/crew#309).'
|
||||
check "cite: an owner/repo reference passes" 0 "" \
|
||||
changelog_fragment_problem "$PF/42.md"
|
||||
cite_case 43 '- Two issues, one entry (#236, #250).'
|
||||
check "cite: one group carrying two references passes" 0 "" \
|
||||
changelog_fragment_problem "$PF/43.md"
|
||||
|
||||
# The cite is measured on the normalized entry, so a citation that lands on
|
||||
# a continuation line still closes the entry — the #167 lesson, repeated:
|
||||
# wrapping alone must never red a compliant entry.
|
||||
cite_case 44 '- An entry whose prose wraps onto a' ' continuation line, cite and all (#262).'
|
||||
check "cite: a citation on a continuation line passes — the entry is normalized first" 0 "" \
|
||||
changelog_fragment_problem "$PF/44.md"
|
||||
|
||||
cite_case 45 '### Added' '' '- Added one (#101).' '- Added two (#102).' '' \
|
||||
'### Changed' '' '- Changed one (#103).' '' '### Fixed' '' '- Fixed one (#104).'
|
||||
check "cite: a grouped fragment, three headings, every entry compliant, passes" 0 "" \
|
||||
changelog_fragment_problem "$PF/45.md"
|
||||
|
||||
# The two diagnoses are distinct by construction (D5): a builder who reads
|
||||
# one must not be told the other's fix.
|
||||
cite_case 50 '- No cite here.'
|
||||
check "cite: an entry with no reference at all is refused" 1 \
|
||||
"50.md' has an entry with no issue citation" \
|
||||
changelog_fragment_problem "$PF/50.md"
|
||||
check "cite: the uncited refusal names the shape to write" 1 \
|
||||
"end it with the issue it comes from: '(#N).'" \
|
||||
changelog_fragment_problem "$PF/50.md"
|
||||
|
||||
cite_case 51 '- Cite before the period. (#262)'
|
||||
check "cite: a citation trailing the period is refused — the 248.md shape" 1 \
|
||||
"51.md' has an entry whose issue citation is not terminal" \
|
||||
changelog_fragment_problem "$PF/51.md"
|
||||
check "cite: the misplaced refusal names the shape to write" 1 \
|
||||
"exactly one '(#N)' group ends the entry, the final '.' after it" \
|
||||
changelog_fragment_problem "$PF/51.md"
|
||||
|
||||
cite_case 52 '- Trailing prose (#262) and then more.'
|
||||
check "cite: a citation with prose after it is refused" 1 \
|
||||
"has an entry whose issue citation is not terminal" \
|
||||
changelog_fragment_problem "$PF/52.md"
|
||||
|
||||
cite_case 53 '- Two groups (#236) and (#250).'
|
||||
check "cite: two citation groups are refused — one terminal group, or none (D2)" 1 \
|
||||
"has an entry whose issue citation is not terminal" \
|
||||
changelog_fragment_problem "$PF/53.md"
|
||||
|
||||
cite_case 54 '- Bad token (#abc).'
|
||||
check "cite: a reference with no digits is no reference" 1 \
|
||||
"has an entry with no issue citation" \
|
||||
changelog_fragment_problem "$PF/54.md"
|
||||
cite_case 55 '- Bad token (#).'
|
||||
check "cite: an empty reference is no reference" 1 \
|
||||
"has an entry with no issue citation" \
|
||||
changelog_fragment_problem "$PF/55.md"
|
||||
|
||||
# The citation need not name the file's own issue (D3): the filename already
|
||||
# carries the authorizing one, so an entry may cite the incident beside it.
|
||||
cite_case 56 '- Cites another issue entirely (#101).'
|
||||
check "cite: the reference need not match the filename" 0 "" \
|
||||
changelog_fragment_problem "$PF/56.md"
|
||||
|
||||
# Ordering: the bound outranks the cite across the whole fragment, so a
|
||||
# fragment that reds today draws the diagnosis it drew before this rule
|
||||
# existed. The uncited entry comes FIRST here on purpose — the other order
|
||||
# would pass whatever the precedence is.
|
||||
{
|
||||
printf -- '- Uncited, and it comes first.\n'
|
||||
printf -- '- %s\n' "$(mkchars 301)"
|
||||
} >"$PF/57.md"
|
||||
check "cite: an over-bound entry outranks an earlier uncited one" 1 \
|
||||
"57.md' has a 301-character entry" \
|
||||
changelog_fragment_problem "$PF/57.md"
|
||||
assert_one_diagnosis() {
|
||||
local count
|
||||
count="$(changelog_fragment_problem "$PF/$1.md" | wc -l)"
|
||||
[ "$count" = 1 ] || {
|
||||
printf 'wanted one diagnosis, got %s\n' "$count"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
check "cite: the outranked citation problem is not reported beside it" 0 "" \
|
||||
assert_one_diagnosis 57
|
||||
|
||||
# The axis 57.md cannot test: its over-bound entry is LAST, so the only
|
||||
# flush that can print is END's, which exits immediately. A flush from a
|
||||
# main rule exits too — but awk runs END on the way out, so the citation
|
||||
# row a mid-file length row outranks would print after it unless END is
|
||||
# guarded. Both ways out of the walk, a bullet and a heading.
|
||||
{
|
||||
printf -- '- Uncited, and it comes first.\n'
|
||||
printf -- '- %s\n' "$(mkchars 301)"
|
||||
printf -- '- A later entry the walk never reaches (#57).\n'
|
||||
} >"$PF/58.md"
|
||||
check "cite: an over-bound entry that is not the last one still reports the bound" 1 \
|
||||
"58.md' has a 301-character entry" \
|
||||
changelog_fragment_problem "$PF/58.md"
|
||||
check "cite: and it is still one diagnosis, not the protocol row spliced into it" 0 "" \
|
||||
assert_one_diagnosis 58
|
||||
{
|
||||
printf '### Fixed\n'
|
||||
printf -- '- Misplaced, and it comes first. (#59)\n'
|
||||
printf -- '- %s\n' "$(mkchars 301)"
|
||||
printf '### Changed\n'
|
||||
printf -- '- The heading is the other way out of the walk (#59).\n'
|
||||
} >"$PF/59.md"
|
||||
check "cite: a heading after the over-bound entry is the same one diagnosis" 1 \
|
||||
"59.md' has a 301-character entry" \
|
||||
changelog_fragment_problem "$PF/59.md"
|
||||
check "cite: the misplaced row does not ride along with it either" 0 "" \
|
||||
assert_one_diagnosis 59
|
||||
|
||||
# Published sections keep their pre-rule prose (D4): reddening history is a
|
||||
# wall, not a guard. Every shipped section predates the cite.
|
||||
check "cite: a published section with uncited entries still reds nothing" 0 "" \
|
||||
changelog_section_problem "$ROOT/CHANGELOG.md" 0.3.0
|
||||
|
||||
# The fragments this repo carries right now are the rule's own first
|
||||
# constituency — the guard is worth nothing if the tree it ships in fails it.
|
||||
assert_tree_fragments() {
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
changelog_fragment_problem "$f" || return 1
|
||||
done <<<"$(changelog_fragments "$ROOT/changelog.d")"
|
||||
}
|
||||
check "cite: every fragment in this tree passes the rule it ships" 0 "" \
|
||||
assert_tree_fragments
|
||||
|
||||
# --- the assembler (#114) ----------------------------------------------------
|
||||
|
||||
assert_assemble() {
|
||||
|
|
@ -347,11 +492,11 @@ mkdir -p "$AF"
|
|||
printf 'marker\n' >"$AF/README.md"
|
||||
cat >"$AF/3.md" <<'EOF'
|
||||
- Three — an em dash, and prose that
|
||||
wraps onto a continuation line.
|
||||
wraps onto a continuation line (#3).
|
||||
EOF
|
||||
printf -- '- Ten.\n- Ten again.\n' >"$AF/10.md"
|
||||
printf -- '- Ten (#10).\n- Ten again (#10).\n' >"$AF/10.md"
|
||||
check "assemble: flat fragments, newest issue first, prose verbatim" 0 "" \
|
||||
assert_assemble "$AF" $'- Ten.\n- Ten again.\n- Three — an em dash, and prose that\n wraps onto a continuation line.'
|
||||
assert_assemble "$AF" $'- Ten (#10).\n- Ten again (#10).\n- Three — an em dash, and prose that\n wraps onto a continuation line (#3).'
|
||||
|
||||
check "assemble: an empty directory is empty output — refusing is the caller's stance" 0 "" \
|
||||
changelog_assemble "$TMP/no-such-dir"
|
||||
|
|
@ -361,36 +506,36 @@ mkdir -p "$AG"
|
|||
cat >"$AG/21.md" <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Fixed twenty-one.
|
||||
- Fixed twenty-one (#21).
|
||||
EOF
|
||||
cat >"$AG/20.md" <<'EOF'
|
||||
### Added
|
||||
|
||||
- Added twenty.
|
||||
- Added twenty (#20).
|
||||
|
||||
### Docs
|
||||
|
||||
- Docs twenty.
|
||||
- Docs twenty (#20).
|
||||
EOF
|
||||
cat >"$AG/19.md" <<'EOF'
|
||||
### Security
|
||||
|
||||
- Security nineteen.
|
||||
- Security nineteen (#19).
|
||||
|
||||
### Added
|
||||
|
||||
- Added nineteen.
|
||||
- Added nineteen (#19).
|
||||
EOF
|
||||
check "assemble: canonical group order, unnamed group appended, fragment order inside a group" 0 "" \
|
||||
assert_assemble "$AG" $'### Added\n\n- Added twenty.\n- Added nineteen.\n\n### Fixed\n\n- Fixed twenty-one.\n\n### Security\n\n- Security nineteen.\n\n### Docs\n\n- Docs twenty.'
|
||||
assert_assemble "$AG" $'### Added\n\n- Added twenty (#20).\n- Added nineteen (#19).\n\n### Fixed\n\n- Fixed twenty-one (#21).\n\n### Security\n\n- Security nineteen (#19).\n\n### Docs\n\n- Docs twenty (#20).'
|
||||
|
||||
AM="$TMP/assemble-mixed"
|
||||
mkdir -p "$AM"
|
||||
printf -- '- Flat five.\n' >"$AM/5.md"
|
||||
printf -- '- Flat five (#5).\n' >"$AM/5.md"
|
||||
cat >"$AM/6.md" <<'EOF'
|
||||
### Added
|
||||
|
||||
- Grouped six.
|
||||
- Grouped six (#6).
|
||||
EOF
|
||||
check "assemble: mixed shapes refused, grouped side named" 1 "6.md" \
|
||||
changelog_assemble "$AM"
|
||||
|
|
@ -400,11 +545,11 @@ check "assemble: mixed shapes refused, flat side named too" 1 "5.md" \
|
|||
AX="$TMP/assemble-selfmixed"
|
||||
mkdir -p "$AX"
|
||||
cat >"$AX/7.md" <<'EOF'
|
||||
- Ungrouped lead.
|
||||
- Ungrouped lead (#7).
|
||||
|
||||
### Added
|
||||
|
||||
- Grouped follow.
|
||||
- Grouped follow (#7).
|
||||
EOF
|
||||
check "assemble: one fragment mixing both shapes is refused, file named" 1 \
|
||||
"'$AX/7.md' mixes grouped headings and ungrouped bullets" \
|
||||
|
|
@ -429,14 +574,14 @@ cat >"$SHAPE_CHANGELOG" <<'EOF'
|
|||
|
||||
- Older section is grouped.
|
||||
EOF
|
||||
printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md"
|
||||
printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md"
|
||||
check "shape: flat set matches newest flat published section" 0 "" \
|
||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
||||
|
||||
cat >"$SHAPE_DIR/1.md" <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Grouped fragment.
|
||||
- Grouped fragment (#1).
|
||||
EOF
|
||||
check "shape: grouped set names its conflict with newest flat published section" 1 \
|
||||
"fragment '$SHAPE_DIR/1.md' is grouped but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is flat" \
|
||||
|
|
@ -451,7 +596,7 @@ cat >"$SHAPE_CHANGELOG" <<'EOF'
|
|||
|
||||
- Newest section is grouped.
|
||||
EOF
|
||||
printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md"
|
||||
printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md"
|
||||
check "shape: flat set names its conflict with newest grouped published section" 1 \
|
||||
"fragment '$SHAPE_DIR/1.md' is flat but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is grouped" \
|
||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
||||
|
|
@ -459,7 +604,7 @@ check "shape: flat set names its conflict with newest grouped published section"
|
|||
cat >"$SHAPE_DIR/1.md" <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Grouped fragment.
|
||||
- Grouped fragment (#1).
|
||||
EOF
|
||||
check "shape: grouped set matches newest grouped published section" 0 "" \
|
||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
||||
|
|
@ -484,7 +629,7 @@ EOF
|
|||
cat >"$SHAPE_DIR/1.md" <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Grouped fragment.
|
||||
- Grouped fragment (#1).
|
||||
EOF
|
||||
printf 'grouped\n' >"$SHAPE_DIR/shape"
|
||||
check "shape: 'grouped' sentinel admits a grouped set over a flat published section" 0 "" \
|
||||
|
|
@ -492,7 +637,7 @@ check "shape: 'grouped' sentinel admits a grouped set over a flat published sect
|
|||
check "shape: the sentinel binds with no changelog at all — the assembler's call" 0 "" \
|
||||
changelog_shape_problem "" "$SHAPE_DIR"
|
||||
|
||||
printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md"
|
||||
printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md"
|
||||
check "shape: flat fragment under a 'grouped' sentinel refused, fragment and sentinel named" 1 \
|
||||
"fragment '$SHAPE_DIR/1.md' is flat but '$SHAPE_DIR/shape' declares grouped" \
|
||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
||||
|
|
@ -512,14 +657,14 @@ check "shape: 'flat' sentinel admits a flat set over a grouped published section
|
|||
cat >"$SHAPE_DIR/1.md" <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Grouped fragment.
|
||||
- Grouped fragment (#1).
|
||||
EOF
|
||||
check "shape: grouped fragment under a 'flat' sentinel refused, fragment and sentinel named" 1 \
|
||||
"fragment '$SHAPE_DIR/1.md' is grouped but '$SHAPE_DIR/shape' declares flat" \
|
||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
||||
|
||||
printf 'grouped\n' >"$SHAPE_DIR/shape"
|
||||
printf -- '- Flat two.\n' >"$SHAPE_DIR/2.md"
|
||||
printf -- '- Flat two (#2).\n' >"$SHAPE_DIR/2.md"
|
||||
check "shape: a mixed set is refused regardless of the sentinel" 1 \
|
||||
"fragment '$SHAPE_DIR/1.md' is grouped but fragment '$SHAPE_DIR/2.md' is not" \
|
||||
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
|
||||
|
|
@ -558,7 +703,7 @@ printf 'grouped\n' >"$SHAPE_DIR/shape"
|
|||
cat >"$SHAPE_DIR/1.md" <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Grouped fragment.
|
||||
- Grouped fragment (#1).
|
||||
EOF
|
||||
assert_fragments_exclude_sentinel() {
|
||||
local out
|
||||
|
|
@ -579,17 +724,17 @@ printf 'grouped\n' >"$AS/shape"
|
|||
cat >"$AS/30.md" <<'EOF'
|
||||
### Fixed
|
||||
|
||||
- Fixed thirty.
|
||||
- Fixed thirty (#30).
|
||||
EOF
|
||||
cat >"$AS/31.md" <<'EOF'
|
||||
### Added
|
||||
|
||||
- Added thirty-one.
|
||||
- Added thirty-one (#31).
|
||||
EOF
|
||||
check "assemble: the sentinel never assembles, and canonical order holds under it" 0 "" \
|
||||
assert_assemble "$AS" $'### Added\n\n- Added thirty-one.\n\n### Fixed\n\n- Fixed thirty.'
|
||||
assert_assemble "$AS" $'### Added\n\n- Added thirty-one (#31).\n\n### Fixed\n\n- Fixed thirty (#30).'
|
||||
rm "$AS/30.md" "$AS/31.md"
|
||||
printf -- '- Flat probe.\n' >"$AS/29.md"
|
||||
printf -- '- Flat probe (#29).\n' >"$AS/29.md"
|
||||
check "assemble: a flat set under a 'grouped' sentinel refuses to assemble" 1 \
|
||||
"declares grouped" \
|
||||
changelog_assemble "$AS"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ SCRIPT="$ROOT/actions/docs-sync/docs-sync.sh"
|
|||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# The real manifest is asserted by test/vendored.test.sh, not here (#251 D4).
|
||||
# A `grep -Fx RELEASES.md` row lived at this spot from #248's review round,
|
||||
# binding the promise to the one file that had nearly been missed. It was the
|
||||
# hardcoded list the manifest exists to abolish, one layer down: two spellings
|
||||
# of "the manifest is right" is exactly the drift it prevents. Its intent —
|
||||
# every root doctrine file is declared, RELEASES.md included — is now a
|
||||
# closed-world guard case, which the next file inherits for free.
|
||||
|
||||
# --- fixture builders --------------------------------------------------------
|
||||
|
||||
# The main fake ceremony tree: three manifest entries, one in a subdirectory
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -26,13 +26,29 @@ forge_select github
|
|||
forge_stub_path() {
|
||||
printf '%s' "$1" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g'
|
||||
}
|
||||
load_config .github/labels.conf
|
||||
set_required_bots codex-bot-andresmgsl
|
||||
|
||||
RTMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$RTMP"' EXIT
|
||||
|
||||
# The fixture roster is the test's own, and deliberately not the shipped one
|
||||
# (#304). The state machine is roster-agnostic — it needs three distinct
|
||||
# required logins, not THESE three — so binding the fixtures to
|
||||
# .github/labels.conf by slot bought nothing and cost the file: when the
|
||||
# operator shrank panel= from four members to three, the recused author left
|
||||
# two, the third slot came up unbound, and set -u aborted this file before
|
||||
# its first assertion. 217 assertions became 0, on main and on every branch cut
|
||||
# from it, and no fixture here was about the panel's size. The shape below is
|
||||
# test/labels.test.sh's, which has always written its own conf.
|
||||
FIXTURE_CONF="$RTMP/fixture-labels.conf"
|
||||
FIXTURE_AUTHOR=fixture-builder
|
||||
# The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state
|
||||
# machine's inputs, consumed inside the sourced decide_state — not unused.
|
||||
# shellcheck disable=SC2034
|
||||
BOT1="${REQUIRED_BOTS[0]}" BOT2="${REQUIRED_BOTS[1]}" BOT3="${REQUIRED_BOTS[2]}"
|
||||
BOT1=fixture-bot-one BOT2=fixture-bot-two BOT3=fixture-bot-three
|
||||
printf 'panel=%s %s %s %s\n' "$BOT1" "$BOT2" "$BOT3" "$FIXTURE_AUTHOR" \
|
||||
>"$FIXTURE_CONF"
|
||||
load_config "$FIXTURE_CONF"
|
||||
set_required_bots "$FIXTURE_AUTHOR"
|
||||
pass=0 fail=0
|
||||
|
||||
expect() { # $1 = description, $2 = want, $3 = got
|
||||
|
|
@ -51,6 +67,22 @@ rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review obj
|
|||
|
||||
reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array
|
||||
|
||||
# The blocker:unrequested quiescence inputs (#236 D2). Every fixture below
|
||||
# inherits a readable, settled world — a head commit an hour before this
|
||||
# sweep's clock — so the cases written before #236 assert exactly what they
|
||||
# always asserted. The #236 block sets both per case.
|
||||
#
|
||||
# One consequence a new fixture has to know: a case that means to raise
|
||||
# blocker:unrequested needs a REAL submitted_at on its reviews, because the
|
||||
# grace dates the round's newest review. The symbolic stamps this file uses
|
||||
# elsewhere (`t1`, `t2`, …) are not unreadable — GNU date reads `t1` as 01:00
|
||||
# in military timezone T, i.e. a time on WHATEVER day the suite runs — which is
|
||||
# worse: the verdict would flip with the calendar, the hazard the LC_ALL pin at
|
||||
# the top of this file guards on the other axis. Hence a fixed NOW here and
|
||||
# real timestamps on the three stall fixtures below.
|
||||
NOW="$(date -d 2026-08-03T12:00:00Z +%s)"
|
||||
HEAD_COMMIT_AT=2026-08-03T11:00:00Z
|
||||
|
||||
# -- a sweep-wide read failure is visible without changing any PR ------------
|
||||
warning="$(blind_sweep_warning 3 3 "HTTP 403: Resource not accessible by integration")"
|
||||
expect "a wholly blind sweep warns, leading with the observed reason" \
|
||||
|
|
@ -144,8 +176,8 @@ expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state
|
|||
# With a live request that is the bots' ball; with NO request outstanding it
|
||||
# is the agent's, because nothing is coming until somebody asks.
|
||||
REQUESTED="$BOT3" REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)")"
|
||||
"$(rev "$BOT1" APPROVED head1 "" 2026-08-03T10:00:00Z)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" 2026-08-03T10:01:00Z)")"
|
||||
expect "a missing bot WITH a live request is bots-reviewing" state:bots-reviewing "$(decide_state)"
|
||||
REQUESTED=""
|
||||
expect "...but with nobody asked it is the agent's ball" state:addressing "$(decide_state)"
|
||||
|
|
@ -296,15 +328,15 @@ expect "...and raises no blocker" "" "$(blockers)"
|
|||
MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" REVIEWS_JSON='[]'
|
||||
expect "ready, nobody asked, nothing reviewed raises unrequested" blocker:unrequested "$(blockers)"
|
||||
# ...the partial case is equally stalled: one verdict in, nobody asked for the rest
|
||||
REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")"
|
||||
REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" 2026-08-03T10:00:00Z)")"
|
||||
expect "one bot in, none requested is still unrequested" blocker:unrequested "$(blockers)"
|
||||
# ...a STALE round with nobody asked is the same debt, and arguably worse: the
|
||||
# page carries approvals that no longer describe the tree. Guarding on
|
||||
# MISSING alone let this one through with no blocker at all.
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED oldhead "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED oldhead "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED oldhead "" t3)")"
|
||||
"$(rev "$BOT1" APPROVED oldhead "" 2026-08-03T10:00:00Z)" \
|
||||
"$(rev "$BOT2" APPROVED oldhead "" 2026-08-03T10:01:00Z)" \
|
||||
"$(rev "$BOT3" APPROVED oldhead "" 2026-08-03T10:02:00Z)")"
|
||||
expect "a stale round with nobody asked is unrequested too" blocker:unrequested "$(blockers)"
|
||||
expect "...and is still the agent's ball" state:addressing "$(decide_state)"
|
||||
# ...but a live request means an answer IS coming
|
||||
|
|
@ -364,6 +396,13 @@ run_() { jq -n --arg n "$1" --arg o "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \
|
|||
ctx_() { jq -n --arg n "$1" --arg s "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \
|
||||
'{__typename:"StatusContext", context:$n, state:$s, createdAt:$t}'; }
|
||||
|
||||
# Pinned empty for every fixture below except the #208 block, which sets its
|
||||
# own. The script defaults SELF_WORKFLOW from the ambient GITHUB_WORKFLOW —
|
||||
# present in any CI run of this suite — and an inherited name that happened
|
||||
# to match a fixture's workflowName ("ci", "labels") would silently drop
|
||||
# entries these fixtures rely on. The verdicts must not flip with the runner.
|
||||
SELF_WORKFLOW=""
|
||||
|
||||
expect "no checks at all is NONE" NONE "$(rollup '[]' | checks_state)"
|
||||
# A failed fetch leaves no rollup KEY; a PR with no checks leaves an empty
|
||||
# ARRAY. Collapsing the two let an API hiccup read as "nothing is failing" —
|
||||
|
|
@ -442,6 +481,39 @@ expect "a cancelled newest over an earlier FAILURE is still that failure" FAILUR
|
|||
"$(rollup "[$(rec_ FAILURE 2026-07-24T12:16:17Z 2026-07-24T12:17:06Z),\
|
||||
$(rec_ CANCELLED 2026-07-24T12:16:41Z 2026-07-24T12:16:41Z)]" | checks_state)"
|
||||
|
||||
# -- the #208 exclusion: the label machine never grades its own runs. The
|
||||
# shared concurrency group displaces queued sweeps as CANCELLED, and the
|
||||
# displaced run's successor was triggered by a DIFFERENT PR or an issues
|
||||
# event — so on the victim PR the #139 carve-out's premise (a surviving
|
||||
# sibling on the same head) fails structurally: the newest self entry
|
||||
# stays CANCELLED, and the sweep set blocker:ci-red off its own corpse,
|
||||
# re-affirming it every cadence. Proven on crew#227: every real check
|
||||
# green, the only red rollup entry the sweep's own displaced run. rec_
|
||||
# already builds entries under workflowName "labels"; naming that as
|
||||
# self must drop them whole, before the newest-per-context collapse.
|
||||
SELF_WORKFLOW="labels"
|
||||
expect "a displaced self CANCELLED beside green others is no verdict (crew#227)" SUCCESS \
|
||||
"$(rollup "[$(run_ a SUCCESS),$(run_ b SUCCESS),\
|
||||
$(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)"
|
||||
expect "a FAILED self run surfaces on the Actions tab, not as the PR's red" SUCCESS \
|
||||
"$(rollup "[$(run_ a SUCCESS),$(run_ b SUCCESS),\
|
||||
$(rec_ FAILURE 2026-08-01T15:00:00Z 2026-08-01T15:01:00Z)]" | checks_state)"
|
||||
expect "a rollup of ONLY self entries is honestly NONE, never SUCCESS" NONE \
|
||||
"$(rollup "[$(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)"
|
||||
# must-fail: the filter keys on the self workflow ALONE. Widening it — any
|
||||
# cancelled entry, any labels-shaped name — certifies a genuine foreign
|
||||
# failure green, which is #136's unknown-as-green shape all over again.
|
||||
expect "a genuine foreign FAILURE still blocks beside a cancelled self entry" FAILURE \
|
||||
"$(rollup "[$(run_ a FAILURE),\
|
||||
$(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)"
|
||||
# ...and an empty self filters NOTHING: outside Actions no workflow name is
|
||||
# ambient, and the exclusion must never drop entries on a guess — the same
|
||||
# displaced-self rollup keeps blocking there, all-cancelled context intact.
|
||||
SELF_WORKFLOW=""
|
||||
expect "an empty SELF_WORKFLOW filters nothing — the same rollup still blocks" FAILURE \
|
||||
"$(rollup "[$(run_ a SUCCESS),$(run_ b SUCCESS),\
|
||||
$(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)"
|
||||
|
||||
# -- a run still IN FLIGHT. `run_()` cannot express this: it always carries a
|
||||
# real completedAt, which is exactly why the supersede rule shipped dating
|
||||
# runs by completion and nothing caught it. Both spellings of "no
|
||||
|
|
@ -655,24 +727,27 @@ expect "...and an already-applied stale comes off" \
|
|||
# posted comments appended back into the fixture so a second sweep sees the
|
||||
# first one's writes, and every label edit recorded.
|
||||
# ---------------------------------------------------------------------------
|
||||
RTMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$RTMP"' EXIT
|
||||
iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
|
||||
RNOW=2000000000
|
||||
|
||||
ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines
|
||||
ruling_sweep_probe() { # $1 labels, $2 PR, $3 assignees, $4 requested, $5 activity age days
|
||||
(
|
||||
local n="${2:-77}" assignees="${3:-0}" requested="${4:-}"
|
||||
local activity_days="${5:-8}"
|
||||
local assignee_json='[]'
|
||||
[ "$assignees" -eq 0 ] || assignee_json='[{"login":"owner-bot"}]'
|
||||
REPO_LABELS="$(printf 'state:addressing\nstate:needs-human\nmerge-next\nstale\nneeds-ruling')"
|
||||
REPO=owner/repo NOW="$RNOW"
|
||||
LABELS="$1"
|
||||
DRAFT=false HEAD_SHA=head1 REQUESTED=""
|
||||
DRAFT=false HEAD_SHA=head1 REQUESTED="$requested"
|
||||
# Approvals submitted 8 days ago — the newest real activity anywhere.
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED head1 "" "$(iso_at $((RNOW - 8 * 86400)))")" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" "$(iso_at $((RNOW - 8 * 86400)))")" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" "$(iso_at $((RNOW - 8 * 86400)))")")"
|
||||
"$(rev "$BOT1" APPROVED head1 "" "$(iso_at $((RNOW - activity_days * 86400)))")" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" "$(iso_at $((RNOW - activity_days * 86400)))")" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" "$(iso_at $((RNOW - activity_days * 86400)))")")"
|
||||
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
||||
PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" '{created_at: $at}')"
|
||||
PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" \
|
||||
--argjson assignees "$assignee_json" '{created_at: $at, assignees: $assignees}')"
|
||||
run() { "$@"; } # mutations reach the stub and are recorded, not swallowed
|
||||
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
||||
gh() {
|
||||
|
|
@ -689,6 +764,8 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines
|
|||
done
|
||||
endpoint="$(forge_stub_path "$endpoint")"
|
||||
file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
||||
printf '%s\n' "$endpoint" >>"$RTMP/api-calls"
|
||||
[ ! -f "$file.error" ] || return 1
|
||||
# A missing fixture is an empty collection — projected through the
|
||||
# caller's --jq exactly like real gh, so '.[].foo' yields no lines.
|
||||
[ -f "$file" ] || { printf '[]\n' | jq -r "${jqexpr:-.}"; return 0; }
|
||||
|
|
@ -710,7 +787,7 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines
|
|||
printf '%s\n' "$*" >>"$RTMP/edits"
|
||||
fi
|
||||
}
|
||||
reconcile_pr 77 2>&1
|
||||
reconcile_pr "$n" 2>&1
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -749,6 +826,60 @@ expect "exactly one nudge across both sweeps" \
|
|||
expect "no label edit across both sweeps names the ruling flag" \
|
||||
no "$(grep -q 'needs-ruling' "$RTMP/edits" 2>/dev/null && echo yes || echo no)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The attention pass on the PR surface (#232): every PR target is malformed,
|
||||
# assigned or not. The episode marker makes the comment once-per-labeling;
|
||||
# every other board mutation remains the ordinary state machine's concern.
|
||||
# ---------------------------------------------------------------------------
|
||||
attention_pr_fixture() { # $1 PR, $2 labeled timestamp
|
||||
jq -n --arg at "$2" \
|
||||
'[{"event":"labeled","label":{"name":"attention"},"actor":{"login":"setter"},"created_at":$at}]' \
|
||||
>"$RTMP/repos_owner_repo_issues_${1}_timeline.json"
|
||||
printf '[]\n' >"$RTMP/repos_owner_repo_issues_${1}_comments.json"
|
||||
}
|
||||
|
||||
attention_pr_fixture 78 "$(iso_at $((RNOW - 120)))"
|
||||
attention_mutations_before="$(wc -l <"$RTMP/edits")"
|
||||
attention_pr="$(ruling_sweep_probe $'attention\nstate:needs-human' 78 0 danmt 1)"
|
||||
expect "attention on an unassigned PR is diagnosed" yes \
|
||||
"$(grep -q 'malformed attention (pr)' <<<"$attention_pr" && echo yes || echo no)"
|
||||
expect "the PR comment points to the assigned claim issue" yes \
|
||||
"$(grep -qF 'assigned issue that owns the claim' "$RTMP/posted-78" && echo yes || echo no)"
|
||||
expect "the PR comment does not guess a target issue number" no \
|
||||
"$(grep -Eq '#[0-9]+' "$RTMP/posted-78" && echo yes || echo no)"
|
||||
ruling_sweep_probe $'attention\nstate:needs-human' 78 0 danmt 1 >/dev/null
|
||||
expect "two PR sweeps in one attention episode post once" 1 \
|
||||
"$(grep -cF '<!-- ceremony:attention-malformed:' "$RTMP/posted-78")"
|
||||
jq --arg at "$(iso_at $((RNOW - 30)))" \
|
||||
'. + [{"event":"labeled","label":{"name":"attention"},"actor":{"login":"setter"},"created_at":$at}]' \
|
||||
"$RTMP/repos_owner_repo_issues_78_timeline.json" \
|
||||
>"$RTMP/repos_owner_repo_issues_78_timeline.json.tmp" \
|
||||
&& mv "$RTMP/repos_owner_repo_issues_78_timeline.json.tmp" \
|
||||
"$RTMP/repos_owner_repo_issues_78_timeline.json"
|
||||
ruling_sweep_probe $'attention\nstate:needs-human' 78 0 danmt 1 >/dev/null
|
||||
expect "a re-set PR flag receives a second episode comment" 2 \
|
||||
"$(grep -cF '<!-- ceremony:attention-malformed:' "$RTMP/posted-78")"
|
||||
|
||||
attention_pr_fixture 79 "$(iso_at $((RNOW - 60)))"
|
||||
ruling_sweep_probe $'attention\nstate:needs-human' 79 1 danmt 1 >/dev/null
|
||||
expect "attention on an assigned PR is still diagnosed" 1 \
|
||||
"$(grep -cF '<!-- ceremony:attention-malformed:' "$RTMP/posted-79")"
|
||||
|
||||
attention_pr_fixture 80 "$(iso_at $((RNOW - 60)))"
|
||||
: >"$RTMP/repos_owner_repo_issues_80_timeline.json.error"
|
||||
unreadable_attention="$(ruling_sweep_probe $'attention\nstate:needs-human' 80 0 danmt 1)"
|
||||
expect "an unreadable PR attention timeline posts nothing" no \
|
||||
"$([ -f "$RTMP/posted-80" ] && echo yes || echo no)"
|
||||
expect "the unreadable fact is logged without a verdict" yes \
|
||||
"$(grep -qF 'attention timeline unreadable' <<<"$unreadable_attention" && echo yes || echo no)"
|
||||
|
||||
: >"$RTMP/api-calls"
|
||||
ruling_sweep_probe state:needs-human 81 0 danmt 1 >/dev/null
|
||||
expect "a flag-free PR performs no attention timeline read" no \
|
||||
"$(grep -qF 'repos/owner/repo/issues/81/timeline' "$RTMP/api-calls" && echo yes || echo no)"
|
||||
expect "attention diagnosis caused no PR mutation" "$attention_mutations_before" \
|
||||
"$(wc -l <"$RTMP/edits")"
|
||||
|
||||
# -- the sweep wiring observes the existing per-PR skip without writing -------
|
||||
blind_main_probe() {
|
||||
(
|
||||
|
|
@ -805,6 +936,83 @@ expect "each blind PR logs its reason as its own line beside the counted one" 2
|
|||
expect "exactly the blind PRs match the counted shape whole-line — no more, no less" 2 \
|
||||
"$(grep -c '^labels: #[0-9]*: could not read mergeability/checks — left alone this pass$' <<<"$blind_main")"
|
||||
|
||||
# -- the grace's own wiring: the fixtures above prove the predicate, and only a
|
||||
# sweep can prove the fetch that feeds it (#236 D2). The fixture-only version
|
||||
# of this change would have passed with the global never assigned — the #91
|
||||
# shape, where the probes could not reach the per-PR path at all.
|
||||
unrequested_main_probe() { # $1 = read | denied, the head-commit read's outcome
|
||||
(
|
||||
GITHUB_EVENT_NAME=schedule
|
||||
REPO=owner/repo
|
||||
LABELS_CONF=.github/labels.conf
|
||||
# main() preflights the forge before it reads anything, so a probe that
|
||||
# drives main() has to say which forge it is standing in rather than
|
||||
# leaving the preflight to infer one from an empty environment (#188).
|
||||
# The gh() stub below IS the github backend's boundary.
|
||||
CEREMONY_FORGE=github
|
||||
UMODE="$1"
|
||||
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
||||
gh() {
|
||||
if [ "$1" = label ] && [ "$2" = list ]; then core_label_rows | cut -d'|' -f1; return 0; fi
|
||||
if [ "$1" = pr ] && [ "$2" = list ]; then printf '303\n'; return 0; fi
|
||||
if [ "$1" = pr ] && [ "$2" = view ]; then
|
||||
# green, so the D1 gate is open and D2 is the only question left
|
||||
jq -n '{mergeable:"MERGEABLE",
|
||||
statusCheckRollup:[{__typename:"CheckRun",workflowName:"ci",
|
||||
name:"check",conclusion:"SUCCESS",
|
||||
startedAt:"2026-07-01T00:00:00Z"}]}'
|
||||
return 0
|
||||
fi
|
||||
# recorded to a file, not to stdout: reconcile_pr sends the edit call's
|
||||
# stdout to /dev/null, so a narrating stub would look like no edit at all
|
||||
if [ "$1" = issue ] && [ "$2" = edit ]; then printf '%s\n' "$*" >>"$RTMP/uedits-$UMODE"; return 0; fi
|
||||
[ "$1" = api ] || return 0
|
||||
shift
|
||||
local jqexpr="" endpoint=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--jq) jqexpr="$2"; shift ;;
|
||||
-*) ;;
|
||||
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
case "$endpoint" in
|
||||
*/commits/*) # the head-commit read; ordered before the commit LIST below
|
||||
if [ "$UMODE" = denied ]; then
|
||||
printf 'gh: Not Found (HTTP 404)\n' >&2
|
||||
return 1
|
||||
fi
|
||||
jq -n '{commit:{committer:{date:"2026-07-01T00:00:00Z"}}}' | jq -r "${jqexpr:-.}" ;;
|
||||
*/pulls/303)
|
||||
jq -n '{draft:false,user:{login:"author"},head:{sha:"headsha"},
|
||||
base:{sha:"basesha"},labels:[],requested_reviewers:[],
|
||||
created_at:"2026-07-01T00:00:00Z"}' ;;
|
||||
*) printf '[]\n' | jq -r "${jqexpr:-.}" ;; # every collection empty
|
||||
esac
|
||||
}
|
||||
main
|
||||
)
|
||||
}
|
||||
|
||||
read_sweep="$(unrequested_main_probe read)"
|
||||
expect "the sweep reads the head's date and writes the stall it now dates" yes \
|
||||
"$(grep -q 'blocker:unrequested' "$RTMP/uedits-read" && echo yes || echo no)"
|
||||
expect "...saying nothing about a degraded read" no \
|
||||
"$(grep -q "could not read the head commit's date" <<<"$read_sweep" && echo yes || echo no)"
|
||||
denied_sweep="$(unrequested_main_probe denied)"
|
||||
expect "a denied head-commit read names the denial (#101's shape)" yes \
|
||||
"$(grep -q "^labels: #303: could not read the head commit's date: gh: Not Found (HTTP 404)" <<<"$denied_sweep" \
|
||||
&& echo yes || echo no)"
|
||||
expect "...and writes no blocker it could not date" no \
|
||||
"$(grep -q 'blocker:unrequested' "$RTMP/uedits-denied" && echo yes || echo no)"
|
||||
# ...while the PR is still converged: this read narrows one blocker, it does not
|
||||
# skip the PR the way an unreadable rollup does
|
||||
expect "...while the state still converges — one blocker unjudged, not a skip" yes \
|
||||
"$(grep -q 'state:addressing' "$RTMP/uedits-denied" && echo yes || echo no)"
|
||||
expect "...and the sweep does not report it as a blind pass" 0 \
|
||||
"$(grep -c 'could not read mergeability/checks' <<<"$denied_sweep" || true)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bootstrap_labels retires the GitHub defaults (#93). LABELS.md published
|
||||
# them as deleted at bootstrap; nothing deleted them — incubator's first
|
||||
|
|
@ -968,6 +1176,285 @@ for ev in schedule pull_request_target; do
|
|||
expect "...and deletes nothing" \
|
||||
no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)"
|
||||
done
|
||||
# -- a re-drafted fix round is not a build (#205) ----------------------------
|
||||
# Draft used to short-circuit decide_state before the round was consulted, so
|
||||
# a PR carrying a standing CHANGES_REQUESTED that its builder converted back
|
||||
# to draft read state:building — and the staleness sweep read a dropped fix
|
||||
# round as a build in progress.
|
||||
load_config "$FIXTURE_CONF"
|
||||
set_required_bots "$FIXTURE_AUTHOR"
|
||||
MERGEABLE=MERGEABLE CHECKS=SUCCESS LABELS="" HEAD_SHA=head1
|
||||
DRAFT=true REQUESTED="" REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" CHANGES_REQUESTED head1 no t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 ok t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 ok t3)")"
|
||||
expect "a re-drafted PR with a standing block is addressing, not building" \
|
||||
state:addressing "$(decide_state)"
|
||||
REVIEWS_JSON="$(reviews "$(rev "$BOT1" COMMENTED head1 thoughts t1)")"
|
||||
expect "a re-drafted PR owing a round-reply is addressing" \
|
||||
state:addressing "$(decide_state)"
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED head0 ok t1)" \
|
||||
"$(rev "$BOT2" APPROVED head0 ok t2)" \
|
||||
"$(rev "$BOT3" APPROVED head0 ok t3)")"
|
||||
expect "a re-drafted PR whose approvals a push staled is addressing" \
|
||||
state:addressing "$(decide_state)"
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED head1 ok t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 ok t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 ok t3)" \
|
||||
"$(rev "$HUMAN" CHANGES_REQUESTED head1 no t4)")"
|
||||
expect "the human's standing changes-requested outranks draft too" \
|
||||
state:addressing "$(decide_state)"
|
||||
# Approvals do NOT outrank draft: a re-draft after a passed round is
|
||||
# deliberately building again — and a draft must never read needs-human.
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED head1 ok t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 ok t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 ok t3)")"
|
||||
expect "a re-draft after a passed round is building again" \
|
||||
state:building "$(decide_state)"
|
||||
REQUESTED="$HUMAN"
|
||||
expect "...even with the human requested — a draft never reads needs-human" \
|
||||
state:building "$(decide_state)"
|
||||
# Round 1's 224-case hole (claude's differential): a draft with a LIVE HUMAN
|
||||
# REQUEST plus a standing block or comment fell through to round_state,
|
||||
# whose human-request precedence sits above BLOCK/FEEDBACK — and read
|
||||
# needs-human on a PR GitHub cannot merge. These are the same inputs as the
|
||||
# addressing rows above with REQUESTED="$HUMAN", which is where the
|
||||
# criterion can actually fail.
|
||||
REQUESTED="$HUMAN" REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" CHANGES_REQUESTED head1 no t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 ok t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 ok t3)")"
|
||||
expect "a draft with a human request and a standing block is addressing" \
|
||||
state:addressing "$(decide_state)"
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" COMMENTED head1 thoughts t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 ok t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 ok t3)")"
|
||||
expect "a draft with a human request and an owed reply is addressing" \
|
||||
state:addressing "$(decide_state)"
|
||||
|
||||
# The must-not-paper-over combination: a live panel request on a draft is a
|
||||
# board defect (the bots ignore drafts by design) and stays VISIBLE as
|
||||
# bots-reviewing rather than being absorbed into building.
|
||||
REQUESTED="$BOT2" REVIEWS_JSON='[]'
|
||||
expect "a live panel request on a draft surfaces as bots-reviewing" \
|
||||
state:bots-reviewing "$(decide_state)"
|
||||
# The byte-identical baseline: a virgin draft still reads building.
|
||||
REQUESTED="" REVIEWS_JSON='[]'
|
||||
expect "a draft with no round history still reads building" \
|
||||
state:building "$(decide_state)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# blocker:unrequested knows when the ask is permitted (#236). The blocker
|
||||
# demands an act — request the panel — that BUILDER.md forbids under a head
|
||||
# whose checks have not answered, so the predicate that flags the omission has
|
||||
# to read CHECKS and has to let a round in motion finish moving. Two guards,
|
||||
# each proved load-bearing by a mutation at the end of the block.
|
||||
# ---------------------------------------------------------------------------
|
||||
DRAFT=false HEAD_SHA=head1 REQUESTED="" MERGEABLE=MERGEABLE LABELS=""
|
||||
NOW="$(date -d 2026-08-03T12:00:00Z +%s)"
|
||||
# the genuine #26/#39 debt: three approvals of a head a push staled, nobody
|
||||
# asked for the re-verdicts, and every fact hours old
|
||||
OWED_QUIET_ROUND="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED oldhead "" 2026-08-03T10:00:00Z)" \
|
||||
"$(rev "$BOT2" APPROVED oldhead "" 2026-08-03T10:01:00Z)" \
|
||||
"$(rev "$BOT3" APPROVED oldhead "" 2026-08-03T10:02:00Z)")"
|
||||
REVIEWS_JSON="$OWED_QUIET_ROUND" HEAD_COMMIT_AT=2026-08-03T11:00:00Z
|
||||
CHECKS=SUCCESS
|
||||
expect "green, quiescent, owed and unasked is the stall (the control)" \
|
||||
blocker:unrequested "$(blockers)"
|
||||
# D1 — the gate. crew#318's shape: the same debt under a running check, where
|
||||
# requesting is the one thing the builder must not do.
|
||||
CHECKS=PENDING
|
||||
expect "a pending head is CI's move, not a dropped ask" "" "$(blockers)"
|
||||
CHECKS=FAILURE
|
||||
expect "a red head raises ci-red alone — the two never co-occur" \
|
||||
blocker:ci-red "$(blockers)"
|
||||
CHECKS=NONE
|
||||
expect "no checks configured is nothing to wait for, so the stall still shows" \
|
||||
blocker:unrequested "$(blockers)"
|
||||
# D2 — the grace. ceremony#235's shape: a sweep landing in the ~90 seconds
|
||||
# between a round-answer push and the author's re-request.
|
||||
CHECKS=SUCCESS HEAD_COMMIT_AT=2026-08-03T11:57:30Z
|
||||
expect "a head pushed inside the grace is a round in motion, not a stall" \
|
||||
"" "$(blockers)"
|
||||
HEAD_COMMIT_AT=2026-08-03T11:55:00Z
|
||||
expect "...and exactly at the grace it flags — the boundary is inclusive" \
|
||||
blocker:unrequested "$(blockers)"
|
||||
HEAD_COMMIT_AT=2026-08-03T11:50:00Z
|
||||
expect "...and a later pass flags it with nothing else changed" \
|
||||
blocker:unrequested "$(blockers)"
|
||||
# a verdict is the other supporting fact, and an old head does not license
|
||||
# flagging a round whose newest verdict landed a minute ago
|
||||
HEAD_COMMIT_AT=2026-08-03T10:00:00Z
|
||||
REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED oldhead "" 2026-08-03T11:59:00Z)")"
|
||||
expect "a verdict submitted inside the grace is motion too" "" "$(blockers)"
|
||||
# no verdicts at all is not an unreadable round — it is the first-ask stall,
|
||||
# and the head's clock is the whole of it
|
||||
REVIEWS_JSON='[]' HEAD_COMMIT_AT=2026-08-03T11:00:00Z
|
||||
expect "nothing reviewed and nobody asked flags off the head's clock alone" \
|
||||
blocker:unrequested "$(blockers)"
|
||||
# an unreadable fact never invents a verdict — the standing rule, applied to
|
||||
# both timestamps
|
||||
REVIEWS_JSON="$OWED_QUIET_ROUND" HEAD_COMMIT_AT=""
|
||||
expect "an unread head date leaves the blocker unjudged" "" "$(blockers)"
|
||||
HEAD_COMMIT_AT=null
|
||||
expect "...and jq's literal null is unread, not epoch zero" "" "$(blockers)"
|
||||
HEAD_COMMIT_AT=2026-08-03T11:00:00Z
|
||||
REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED oldhead "" not-a-timestamp)")"
|
||||
expect "a round whose newest verdict cannot be dated is unread, not quiescent" \
|
||||
"" "$(blockers)"
|
||||
# the constant is overridable the way this file's others are
|
||||
REVIEWS_JSON="$OWED_QUIET_ROUND" HEAD_COMMIT_AT=2026-08-03T11:57:30Z
|
||||
RECONCILE_UNREQUESTED_GRACE=60
|
||||
expect "a shorter configured grace flags the same facts" \
|
||||
blocker:unrequested "$(blockers)"
|
||||
RECONCILE_UNREQUESTED_GRACE=300
|
||||
|
||||
# the timestamp reader, directly: the three unreadable spellings it must refuse
|
||||
expect "iso_epoch reads a real stamp" \
|
||||
"$(date -d 2026-08-03T12:00:00Z +%s)" "$(iso_epoch 2026-08-03T12:00:00Z)"
|
||||
expect "iso_epoch refuses an absent stamp" "" "$(iso_epoch "")"
|
||||
expect "iso_epoch refuses jq's null" "" "$(iso_epoch null)"
|
||||
expect "iso_epoch refuses a stamp date cannot read" "" "$(iso_epoch not-a-timestamp)"
|
||||
# ...and the trap it does NOT catch, recorded because a fixture author will
|
||||
# reach for it: `t1` is a valid date to GNU date — 01:00 in military timezone T,
|
||||
# on the day the suite runs — so it reads as a moving stamp rather than as an
|
||||
# unreadable one. Real timestamps in any fixture the grace touches.
|
||||
expect "a symbolic stamp is readable, and moves with the run's day" \
|
||||
"$(date -d t1 +%s)" "$(iso_epoch t1)"
|
||||
|
||||
# -- the mutation proofs: both guards are load-bearing, and this runs them ----
|
||||
# A guard the fixtures cannot see removed is a guard nobody is testing, so each
|
||||
# is deleted from a COPY of the script and the fixture that covers it must flip.
|
||||
# The sed programs target one token each, so a refactor that moves a guard
|
||||
# fails here loudly instead of passing silently.
|
||||
mutant_blockers() { # $1 = sed program → blockers() from a copy of the script
|
||||
# The copy keeps its position in the tree — the script sources lib/ruling.sh
|
||||
# relative to its own path, and a copy dropped anywhere else would source
|
||||
# nothing and say so on stderr instead of failing.
|
||||
local root="$RTMP/mutant" mutated
|
||||
mutated="$root/actions/labels-reconcile/labels-reconcile.sh"
|
||||
mkdir -p "$root/actions/labels-reconcile"
|
||||
ln -sfn "$PWD/lib" "$root/lib"
|
||||
sed "$1" actions/labels-reconcile/labels-reconcile.sh >"$mutated"
|
||||
DRAFT="$DRAFT" HEAD_SHA="$HEAD_SHA" REQUESTED="$REQUESTED" \
|
||||
REVIEWS_JSON="$REVIEWS_JSON" MERGEABLE="$MERGEABLE" CHECKS="$CHECKS" \
|
||||
NOW="$NOW" HEAD_COMMIT_AT="$HEAD_COMMIT_AT" \
|
||||
RECONCILE_UNREQUESTED_GRACE="$RECONCILE_UNREQUESTED_GRACE" \
|
||||
bash -u -c '
|
||||
. "$1"
|
||||
load_config "$2"
|
||||
set_required_bots "$3"
|
||||
blockers
|
||||
' bash "$mutated" "$FIXTURE_CONF" "$FIXTURE_AUTHOR"
|
||||
}
|
||||
# the harness itself, unmutated: it must reproduce the verdict the sourced
|
||||
# functions give, or a "flip" below proves nothing about the guard
|
||||
REVIEWS_JSON="$OWED_QUIET_ROUND" HEAD_COMMIT_AT=2026-08-03T11:00:00Z CHECKS=SUCCESS
|
||||
expect "the mutation harness reproduces the control verdict" \
|
||||
blocker:unrequested "$(mutant_blockers 's/^#no-such-line$//')"
|
||||
CHECKS=PENDING
|
||||
expect "...and the pending fixture is green in the unmutated copy" \
|
||||
"" "$(mutant_blockers 's/^#no-such-line$//')"
|
||||
expect "removing the green gate reds the pending fixture" \
|
||||
blocker:unrequested \
|
||||
"$(mutant_blockers 's/checks_permit_the_ask=false/checks_permit_the_ask=true/')"
|
||||
CHECKS=SUCCESS HEAD_COMMIT_AT=2026-08-03T11:57:30Z
|
||||
expect "removing the grace reds the inside-the-window fixture" \
|
||||
blocker:unrequested \
|
||||
"$(mutant_blockers 's/ \&\& unrequested_quiescent//')"
|
||||
HEAD_COMMIT_AT=2026-08-03T11:00:00Z
|
||||
|
||||
# -- per-author panels (#224): the required set flows from the one ----------
|
||||
# resolution point, and convergence counts the effective set — never the
|
||||
# base panel beside a reduced request set (the must-fail the issue names)
|
||||
PANEL_DIR="$RTMP/panel-author"
|
||||
mkdir -p "$PANEL_DIR"
|
||||
printf '%s\n' 'panel=bot-a bot-b bot-c bot-d' \
|
||||
'panel[builder-z]=bot-b bot-c bot-d' >"$PANEL_DIR/labels.conf"
|
||||
load_config "$PANEL_DIR/labels.conf"
|
||||
set_required_bots builder-z
|
||||
expect "a bracketed author requires exactly its configured row" \
|
||||
"bot-b bot-c bot-d" "${REQUIRED_BOTS[*]}"
|
||||
set_required_bots bot-a
|
||||
expect "an unbracketed author beside a bracketed row requires panel minus self" \
|
||||
"bot-b bot-c bot-d" "${REQUIRED_BOTS[*]}"
|
||||
set_required_bots outsider
|
||||
expect "an unbracketed non-panelist author requires the whole base panel" \
|
||||
"bot-a bot-b bot-c bot-d" "${REQUIRED_BOTS[*]}"
|
||||
|
||||
# The engine shape: the three configured reviewers approving the head IS the
|
||||
# whole round for a bracketed author — bot-a's absent verdict must not hold
|
||||
# convergence, or the request side and the convergence side disagree forever
|
||||
# (the deadlock crew#285 was filed over).
|
||||
set_required_bots builder-z
|
||||
THREE_APPROVE="$(reviews \
|
||||
"$(rev bot-b APPROVED head1 ok 2026-08-02T10:00:00Z)" \
|
||||
"$(rev bot-c APPROVED head1 ok 2026-08-02T10:01:00Z)" \
|
||||
"$(rev bot-d APPROVED head1 ok 2026-08-02T10:02:00Z)")"
|
||||
DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON="$THREE_APPROVE" \
|
||||
MERGEABLE=MERGEABLE CHECKS=SUCCESS LABELS=""
|
||||
expect "the bracketed author's round converges on its three approvals" \
|
||||
state:needs-human "$(decide_state)"
|
||||
expect "...with no blocker standing" "" "$(blockers)"
|
||||
# The control: the same three approvals under the base panel are NOT a full
|
||||
# round — the fourth verdict is owed and unrequested. If this pair ever
|
||||
# reads the same, one side stopped consulting the resolution point.
|
||||
printf '%s\n' 'panel=bot-a bot-b bot-c bot-d' >"$PANEL_DIR/labels.conf"
|
||||
load_config "$PANEL_DIR/labels.conf"
|
||||
set_required_bots builder-z
|
||||
expect "without the row the same approvals leave the round incomplete" \
|
||||
state:addressing "$(decide_state)"
|
||||
expect "...and the owed, unasked verdict is named" \
|
||||
blocker:unrequested "$(blockers)"
|
||||
|
||||
# -- the shipped roster, as a property rather than a slot (#304 D2) ----------
|
||||
# The one case that reads the real .github/labels.conf, and it asserts only
|
||||
# what that file can honestly prove here: it parses, and recusal removes the
|
||||
# author from whatever it names. No index, no expected size — the panel is the
|
||||
# operator's to resize (D3), and the fixtures above no longer care. What this
|
||||
# does catch is a shipped conf that stopped parsing, which must never be
|
||||
# reported as a green suite.
|
||||
#
|
||||
# The probe runs in a subshell so a refusal cannot leave this file's globals
|
||||
# half-loaded, and it quantifies over every member rather than sampling one:
|
||||
# there is no member whose recusal is special. load_config's stderr is dropped
|
||||
# because the exit status is the assertion; the broken-conf case below would
|
||||
# otherwise print its (correct) complaint into a passing run.
|
||||
live_panel_probe() { # $1 = conf → PARSE:<rc> [RECUSED:<yes|no> SHRANK:<yes|no>]
|
||||
bash -u -c '
|
||||
. actions/labels-reconcile/labels-reconcile.sh
|
||||
rc=0
|
||||
load_config "$1" 2>/dev/null || rc=$?
|
||||
printf "PARSE:%s" "$rc"
|
||||
[ "$rc" -eq 0 ] || { printf "\n"; exit 0; }
|
||||
recused=yes shrank=yes
|
||||
for author in "${BOTS[@]}"; do
|
||||
set_required_bots "$author"
|
||||
for bot in ${REQUIRED_BOTS[@]+"${REQUIRED_BOTS[@]}"}; do
|
||||
[ "$bot" != "$author" ] || recused=no
|
||||
done
|
||||
[ "${#REQUIRED_BOTS[@]}" -eq "$((${#BOTS[@]} - 1))" ] || shrank=no
|
||||
done
|
||||
printf " RECUSED:%s SHRANK:%s\n" "$recused" "$shrank"
|
||||
' bash "$1"
|
||||
}
|
||||
expect "the shipped labels.conf parses, and recuses each member from its own panel" \
|
||||
"PARSE:0 RECUSED:yes SHRANK:yes" "$(live_panel_probe .github/labels.conf)"
|
||||
# ...and the teeth: the same probe on a copy whose panel= line names nobody.
|
||||
# A roster edit that empties the line is the shape this catches — the file
|
||||
# still looks like a conf, and every panel in the repo would resolve to
|
||||
# nothing.
|
||||
BROKEN_CONF="$RTMP/broken-labels.conf"
|
||||
sed 's/^panel=.*/panel=/' .github/labels.conf >"$BROKEN_CONF"
|
||||
expect "...and a malformed panel= line in that same file is refused, not passed" \
|
||||
PARSE:1 "$(live_panel_probe "$BROKEN_CONF")"
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# outstanding_requests — the portable "who still owes a verdict" (#188 term 4)
|
||||
|
|
|
|||
|
|
@ -107,6 +107,125 @@ EOF
|
|||
check "derive: the real mapping labels this test file" 0 \
|
||||
"scope:labels" derive_labels "$real_rows" 'test/labels-scope.test.sh'
|
||||
|
||||
# --- the real mapping locates: one file set in, the whole label set out ---
|
||||
# #267 measured the old map at 100% recall / 15% precision — 20 of the last
|
||||
# 20 PRs wore scope:release-flow and 3 touched a release surface — so these
|
||||
# cases assert the DERIVED SET WHOLE, brackets and all. A substring check
|
||||
# cannot tell scope:labels from scope:labels plus a wrong second label, and
|
||||
# a wrong second label is the whole defect.
|
||||
derives() { # <newline-separated paths> → "[label,label]" for the real map
|
||||
printf '[%s]\n' "$(derive_labels "$real_rows" "$1" | paste -sd, -)"
|
||||
}
|
||||
files() { printf '%s\n' "$@"; }
|
||||
|
||||
# D1: a fragment is written by every behavior change (BUILDER.md), so it
|
||||
# carries no locating information. Asserted as an empty set on its own, not
|
||||
# as an absence inside a longer list: this is the case that fails first if
|
||||
# the glob is ever restored.
|
||||
check "derive: a fragment-only path derives nothing at all" 0 \
|
||||
"[]" derives 'changelog.d/999.md'
|
||||
|
||||
# D2: the issue-flow sweep is a reconciler of the label taxonomy
|
||||
check "derive: the issueflow reconciler is scope:labels" 0 \
|
||||
"[scope:labels]" derives 'actions/issueflow-reconcile/issueflow-reconcile.sh'
|
||||
check "derive: the issueflow reconciler's test is scope:labels" 0 \
|
||||
"[scope:labels]" derives 'test/issueflow-reconcile.test.sh'
|
||||
|
||||
# the reported bug, replayed: #261's exact file set wore scope:release-flow,
|
||||
# inherited from its fragment, pointing at the one surface it does not touch
|
||||
check "derive: #261's file set is scope:labels alone" 0 "[scope:labels]" \
|
||||
derives "$(files actions/issueflow-reconcile/issueflow-reconcile.sh \
|
||||
changelog.d/252.md test/issueflow-reconcile.test.sh)"
|
||||
|
||||
# D1's cost, checked rather than assumed: dropping the fragment glob must
|
||||
# not cost the release surface its label
|
||||
check "derive: a release PR is still scope:release-flow" 0 \
|
||||
"[scope:release-flow]" \
|
||||
derives "$(files VERSION CHANGELOG.md drills/0.6.0.md changelog.d/236.md)"
|
||||
|
||||
# D3: the docs block matched a literal README this tree does not have
|
||||
check "derive: README.md is scope:docs" 0 "[scope:docs]" derives 'README.md'
|
||||
check "derive: RELEASES.md is scope:docs" 0 "[scope:docs]" derives 'RELEASES.md'
|
||||
check "derive: TRIAGE.md is scope:docs" 0 "[scope:docs]" derives 'TRIAGE.md'
|
||||
|
||||
# D3: three guard actions and their tests were in no block at all. Each of
|
||||
# the six paths is asserted ALONE, never bundled with its sibling: a set
|
||||
# holding both the action and its test derives scope:guards when either row
|
||||
# matches, so one row could be deleted with the case still green — the six
|
||||
# rows have to be six assertions to be six protections (#300 round).
|
||||
for guard in changelog-assembled docs-sync runner-isolated; do
|
||||
check "derive: actions/$guard is scope:guards" 0 "[scope:guards]" \
|
||||
derives "actions/$guard/$guard.sh"
|
||||
check "derive: $guard's test is scope:guards" 0 "[scope:guards]" \
|
||||
derives "test/$guard.test.sh"
|
||||
done
|
||||
|
||||
# D4: lib/ is genuinely mixed, so the shared files wear both labels rather
|
||||
# than lib/** being re-carved into a row per file
|
||||
check "derive: lib/ruling.sh is release-flow AND labels" 0 \
|
||||
"[scope:release-flow,scope:labels]" derives 'lib/ruling.sh'
|
||||
check "derive: lib/read.sh is release-flow AND labels" 0 \
|
||||
"[scope:release-flow,scope:labels]" derives 'lib/read.sh'
|
||||
check "derive: lib/version.sh is release-flow only" 0 \
|
||||
"[scope:release-flow]" derives 'lib/version.sh'
|
||||
|
||||
# D6: the map stays advisory. An unmapped path derives an empty set and
|
||||
# exits 0 — a guard that redded here would fail every PR touching FLEET.md
|
||||
# or ci.yml, neither of which this map claims.
|
||||
check "derive: an unmapped path is silence, not an error" 0 "[]" \
|
||||
derives "$(files FLEET.md .github/workflows/ci.yml)"
|
||||
|
||||
# --- #302: one wrong answer and the surfaces the map never learned ------
|
||||
# Every path asserted ALONE, per #300 round 1: a set holding a script and
|
||||
# its test derives the scope when either row matches, so bundling would
|
||||
# let a row be deleted with the case still green.
|
||||
|
||||
# D1, the reported bug replayed: both reconcilers source lib/attention.sh,
|
||||
# nothing release-side does — [scope:release-flow] alone was a wrong
|
||||
# answer, and the honest set is both, same as its two shelf-mates
|
||||
check "derive: lib/attention.sh is release-flow AND labels" 0 \
|
||||
"[scope:release-flow,scope:labels]" derives 'lib/attention.sh'
|
||||
|
||||
# D2: the sweep half of the automation, detached from the trigger half in
|
||||
# #209 — cadence, permissions and job wiring must locate
|
||||
check "derive: the labels sweep workflow is scope:labels" 0 \
|
||||
"[scope:labels]" derives '.github/workflows/labels-sweep.yml'
|
||||
check "derive: the self sweep workflow is scope:labels" 0 \
|
||||
"[scope:labels]" derives '.github/workflows/self-labels-sweep.yml'
|
||||
|
||||
# D3, the deliberate asymmetry with D1: a test file inherits no lib/**
|
||||
# glob, so its row is the one scope its subject actually locates
|
||||
check "derive: attention's test is scope:labels alone" 0 \
|
||||
"[scope:labels]" derives 'test/attention.test.sh'
|
||||
check "derive: ruling's test is scope:labels alone" 0 \
|
||||
"[scope:labels]" derives 'test/ruling.test.sh'
|
||||
|
||||
# D4: the same read's remaining gaps, one row each
|
||||
check "derive: the trigger-surface pins are scope:labels" 0 \
|
||||
"[scope:labels]" derives 'test/labels-triggers.test.sh'
|
||||
check "derive: the assemble test is scope:release-flow" 0 \
|
||||
"[scope:release-flow]" derives 'test/changelog-assemble.test.sh'
|
||||
check "derive: the release-path manifest is scope:release-flow" 0 \
|
||||
"[scope:release-flow]" derives '.github/scripts/release-path.sh'
|
||||
check "derive: the release-path test is scope:release-flow" 0 \
|
||||
"[scope:release-flow]" derives 'test/release-path.test.sh'
|
||||
check "derive: the marker-check guard is scope:guards" 0 \
|
||||
"[scope:guards]" derives '.github/scripts/marker-check.sh'
|
||||
check "derive: the marker-check test is scope:guards" 0 \
|
||||
"[scope:guards]" derives 'test/marker-check.test.sh'
|
||||
check "derive: the vendored-check guard is scope:guards" 0 \
|
||||
"[scope:guards]" derives '.github/scripts/vendored-check.sh'
|
||||
check "derive: the vendored test is scope:guards" 0 \
|
||||
"[scope:guards]" derives 'test/vendored.test.sh'
|
||||
|
||||
# D7: no test/** or .github/scripts/** catch-all — both directories span
|
||||
# all four scopes, so this pair reds under any catch-all row: each file
|
||||
# would gain the other's scope beside its own
|
||||
check "derive: test/version.test.sh is release-flow alone" 0 \
|
||||
"[scope:release-flow]" derives 'test/version.test.sh'
|
||||
check "derive: this test file is scope:labels alone" 0 \
|
||||
"[scope:labels]" derives 'test/labels-scope.test.sh'
|
||||
|
||||
# refusals: unsupported shapes fail loudly, naming the label
|
||||
cat >"$TMP/allglobs.yml" <<'EOF'
|
||||
scope:x:
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||
source "$ROOT/test/harness.sh"
|
||||
|
||||
REUSABLE="$ROOT/.github/workflows/labels.yml"
|
||||
SWEEP="$ROOT/.github/workflows/labels-sweep.yml"
|
||||
SELF="$ROOT/.github/workflows/self-labels.yml"
|
||||
STUB="$ROOT/docs/CONSUMERS.md" # the published caller stub, a fenced yaml block
|
||||
SELF_SWEEP="$ROOT/.github/workflows/self-labels-sweep.yml"
|
||||
STUB="$ROOT/docs/CONSUMERS.md" # the published caller stubs, fenced yaml blocks
|
||||
|
||||
# The `cancel-in-progress:` value of a named top-level job, read from the first
|
||||
# such line inside that job's block. Job keys sit at two-space indent.
|
||||
|
|
@ -39,26 +41,68 @@ trigger_types() { # $1 = file, $2 = trigger key
|
|||
# ---- the guard the cost fix must never trade away (#199 test plan must-fail) --
|
||||
# cancel-in-progress: true on reconcile kills a sweep mid-board, the exact race
|
||||
# the shared concurrency group exists to prevent. It WOULD cut run count — by
|
||||
# trading correctness for minutes — so it stays false, forever.
|
||||
# trading correctness for minutes — so it stays false, forever. The job lives
|
||||
# in labels-sweep.yml since #209; the guard moved with it.
|
||||
check "reconcile serializes, never cancels mid-board" 0 "false" \
|
||||
job_cancel_in_progress "$REUSABLE" reconcile
|
||||
job_cancel_in_progress "$SWEEP" reconcile
|
||||
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
|
||||
check "reconcile is never cancel-in-progress: true" 1 "" \
|
||||
bash -c 'job_cancel_in_progress() {
|
||||
awk -v job="^ reconcile:\$" "\$0 ~ job{f=1;next} f&&/^ [a-z]/{exit} f&&/cancel-in-progress:/{sub(/.*cancel-in-progress:[[:space:]]*/,\"\");print;exit}" "$1"
|
||||
}; [ "$(job_cancel_in_progress "$1")" = true ]' _ "$REUSABLE"
|
||||
}; [ "$(job_cancel_in_progress "$1")" = true ]' _ "$SWEEP"
|
||||
# scope MAY cancel — it is per-PR and additive, so a superseded run is waste,
|
||||
# not a lost sweep. This asserts the must-fail above is scoped to reconcile.
|
||||
check "scope stays cancel-in-progress: true (per-PR, additive)" 0 "true" \
|
||||
job_cancel_in_progress "$REUSABLE" scope
|
||||
|
||||
# ---- the sweep is detached from PR-triggered runs (#209) ---------------------
|
||||
# While reconcile rode the PR-event run, every displacement in its shared
|
||||
# queue recorded a CANCELLED check on some PR — fake red CI. The reusable
|
||||
# labels.yml must never grow the job back; its trigger job wakes the sweep
|
||||
# caller by dispatch instead, and that dispatch is the misconfiguration
|
||||
# alarm: a pin bumped without the sweep caller must go loudly red at the
|
||||
# trigger, so the dispatch line is never allowed to silence itself.
|
||||
check "labels.yml carries no reconcile job" 1 "" \
|
||||
grep -E '^ reconcile:' "$REUSABLE"
|
||||
check "labels-sweep.yml carries the reconcile job" 0 " reconcile:" \
|
||||
grep -E '^ reconcile:' "$SWEEP"
|
||||
check "the sweep keeps the ONE shared concurrency group" 0 "group: labels-reconcile" \
|
||||
grep -F 'group: labels-reconcile' "$SWEEP"
|
||||
check "labels.yml carries the trigger job" 0 " trigger:" \
|
||||
grep -E '^ trigger:' "$REUSABLE"
|
||||
# shellcheck disable=SC2016 # $SWEEP_WORKFLOW is the workflow's own env var, asserted literally
|
||||
check "the trigger dispatches the sweep caller, never bootstrapping" 0 \
|
||||
'gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no' \
|
||||
grep -F 'gh workflow run' "$REUSABLE"
|
||||
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
|
||||
check "the trigger dispatch is never silenced with || true" 1 "" \
|
||||
bash -c 'grep -F "gh workflow run" "$1" | grep -qF "|| true"' _ "$REUSABLE"
|
||||
check "the sweep caller filename input defaults to labels-sweep.yml" 0 \
|
||||
"default: labels-sweep.yml" grep -F 'default: labels-sweep.yml' "$REUSABLE"
|
||||
# the dogfood callers wear the split: the event caller names its deviant
|
||||
# sweep filename, and the sweep caller declares the bootstrap input the
|
||||
# trigger's -f flag requires (an undeclared input reds every dispatch)
|
||||
check "self caller passes its dogfood sweep filename" 0 \
|
||||
"sweep_workflow: self-labels-sweep.yml" \
|
||||
grep -F 'sweep_workflow: self-labels-sweep.yml' "$SELF"
|
||||
check "self sweep caller declares the bootstrap dispatch input" 0 \
|
||||
"bootstrap:" grep -E '^ bootstrap:' "$SELF_SWEEP"
|
||||
check "stub sweep caller declares the bootstrap dispatch input" 0 \
|
||||
"bootstrap:" grep -E '^ bootstrap:' "$STUB"
|
||||
# the labels caller's event runs must not carry the sweep's cron or manual
|
||||
# dispatch — those relocated to the sweep caller with #209
|
||||
check "self caller carries no cron" 1 "" grep -F 'cron:' "$SELF"
|
||||
check "self caller carries no workflow_dispatch" 1 "" \
|
||||
grep -E '^ workflow_dispatch:' "$SELF"
|
||||
|
||||
# ---- the cron is a backstop, relaxed to hourly (#199 candidate 1) -----------
|
||||
# Scope the */15 assertion to the cron LINE — the prose comments cite */15 by
|
||||
# name to explain the change, and must not re-red their own documentation.
|
||||
check "self caller cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$SELF"
|
||||
# The cron rides the sweep caller since #209.
|
||||
check "self sweep caller cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$SELF_SWEEP"
|
||||
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
|
||||
check "self caller cron line no longer fires */15" 1 "" \
|
||||
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$SELF"
|
||||
check "self sweep caller cron line no longer fires */15" 1 "" \
|
||||
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$SELF_SWEEP"
|
||||
check "stub cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$STUB"
|
||||
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
|
||||
check "stub cron line no longer fires */15" 1 "" \
|
||||
|
|
|
|||
|
|
@ -53,6 +53,82 @@ load_config "$TMP/good.conf"
|
|||
set_required_bots two
|
||||
check "PR author is recused from the required panel" 0 "one three" printf '%s\n' "${REQUIRED_BOTS[*]}"
|
||||
|
||||
# -- per-author panel rows (#224): the config-parse matrix -------------------
|
||||
# required_for loads a conf fresh in a subshell and prints the required set
|
||||
# behind a RESULT: anchor, so substring matching cannot confuse "b c" with
|
||||
# "a b c".
|
||||
# shellcheck disable=SC2016 # expansion belongs to the nested bash
|
||||
required_for() { # $1 = conf, $2 = author → RESULT:<required set>
|
||||
bash -c 'source "$1"; load_config "$2" || exit 1
|
||||
set_required_bots "$3"; printf "RESULT:%s\n" "${REQUIRED_BOTS[*]}"' _ \
|
||||
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$1" "$2"
|
||||
}
|
||||
printf '%s\n' 'panel=a b c' >"$TMP/plain.conf"
|
||||
check "no bracketed row: panelist author gets panel minus self" 0 "RESULT:b c" \
|
||||
required_for "$TMP/plain.conf" a
|
||||
check "no bracketed row: outside author gets the whole panel" 0 "RESULT:a b c" \
|
||||
required_for "$TMP/plain.conf" z
|
||||
printf '%s\n' 'panel=a b c' 'panel[z]=b c' >"$TMP/author.conf"
|
||||
check "bracketed author gets exactly its row" 0 "RESULT:b c" \
|
||||
required_for "$TMP/author.conf" z
|
||||
check "unbracketed author beside a bracketed row is unchanged" 0 "RESULT:b c" \
|
||||
required_for "$TMP/author.conf" a
|
||||
printf '%s\n' 'panel[z]=b c' 'panel=a b c' >"$TMP/reversed.conf"
|
||||
check "row order is irrelevant: bracketed row before panel=" 0 "RESULT:b c" \
|
||||
required_for "$TMP/reversed.conf" z
|
||||
check "row order is irrelevant for the base panel too" 0 "RESULT:b c" \
|
||||
required_for "$TMP/reversed.conf" a
|
||||
printf '%s\n' 'panel=a b c' 'panel[a]=a b' >"$TMP/self.conf"
|
||||
check "author inside its own bracketed row is still recused" 0 "RESULT:b" \
|
||||
required_for "$TMP/self.conf" a
|
||||
# shellcheck disable=SC2016 # expansion belongs to the nested bash
|
||||
check "base panel is byte-identical with the bracketed rows deleted" 0 "SAME" \
|
||||
bash -c 'source "$1"; load_config "$2"; with="${BOTS[*]}"
|
||||
load_config "$3"; [ "$with" = "${BOTS[*]}" ] && echo SAME' _ \
|
||||
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" \
|
||||
"$TMP/author.conf" "$TMP/plain.conf"
|
||||
printf '%s\n' 'panel=a b c' 'panel[z]=b' 'panel[z]=c' >"$TMP/dup-author.conf"
|
||||
check "duplicate rows for one login fail naming the line" 1 \
|
||||
"duplicate panel[z]= row" load_config "$TMP/dup-author.conf"
|
||||
printf '%s\n' 'panel=a b c' 'panel[z]=' >"$TMP/empty-set.conf"
|
||||
check "a bracketed row naming zero reviewers fails loudly" 1 \
|
||||
"panel[z]= must name at least one reviewer" load_config "$TMP/empty-set.conf"
|
||||
printf '%s\n' 'panel=a b c' 'panel[]=b c' >"$TMP/empty-login.conf"
|
||||
check "an empty login fails loudly" 1 "empty login in panel row" \
|
||||
load_config "$TMP/empty-login.conf"
|
||||
# codex's round-1 probe: the stray ] used to parse, record login z], and
|
||||
# silently misroute z to the base panel — exactly the D4 refusal owed.
|
||||
printf '%s\n' 'panel=a b c' 'panel[z]]=b' >"$TMP/stray-bracket.conf"
|
||||
check "a stray ] inside the bracket is refused as a bracket" 1 \
|
||||
"malformed panel[<login>]= row" load_config "$TMP/stray-bracket.conf"
|
||||
printf '%s\n' 'panel=a b c' 'panel[a_b]=c' >"$TMP/bad-login.conf"
|
||||
check "a non-login character in the bracket is refused" 1 \
|
||||
"malformed panel[<login>]= row" load_config "$TMP/bad-login.conf"
|
||||
printf '%s\n' 'panel=a b c' 'panel[z=b c' >"$TMP/broken-bracket.conf"
|
||||
check "a malformed bracket is refused as a bracket (D4)" 1 \
|
||||
"malformed panel[<login>]= row" load_config "$TMP/broken-bracket.conf"
|
||||
# shellcheck disable=SC2016 # expansion belongs to the nested bash
|
||||
check "...and never as a label row" 1 "" bash -c \
|
||||
'source "$1"; load_config "$2" 2>&1 | grep -F "malformed label row"' _ \
|
||||
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/broken-bracket.conf"
|
||||
# The D7 tripwire: in a case pattern an unquoted panel[abc]=* is a bracket
|
||||
# expression matching panela=… — this row going green as a panel setting is
|
||||
# exactly the silent mis-route the quoted prefix exists to prevent.
|
||||
printf '%s\n' 'panel=a b c' 'panela=b c' >"$TMP/glob-guard.conf"
|
||||
check "panela= is still a malformed label row, never a panel setting (D7)" 1 \
|
||||
"malformed label row" load_config "$TMP/glob-guard.conf"
|
||||
printf '%s\n' 'panel[z]=b c' >"$TMP/bracket-only.conf"
|
||||
check "a bracketed row does not satisfy the mandatory panel=" 1 \
|
||||
"missing panel= line" load_config "$TMP/bracket-only.conf"
|
||||
printf '%s\n' 'panel=a b c' 'panel[z]=b c' \
|
||||
'scope:one|C5DEF5|First scope' >"$TMP/mixed.conf"
|
||||
check "configured_label_rows returns the scope rows alone" 0 \
|
||||
"scope:one|C5DEF5|First scope" configured_label_rows "$TMP/mixed.conf"
|
||||
# shellcheck disable=SC2016 # expansion belongs to the nested bash
|
||||
check "no panel[...] row reaches the bootstrap" 1 "" bash -c \
|
||||
'source "$1"; configured_label_rows "$2" | grep -F "panel["' _ \
|
||||
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/mixed.conf"
|
||||
|
||||
# LABELS.md is mirrored byte-identically into every governed repo, so any
|
||||
# scope enumeration it carries is true at home and false everywhere else —
|
||||
# 14 of 16 vendored rows were false across the family when this fired (#104).
|
||||
|
|
|
|||
142
test/marker-check.test.sh
Normal file
142
test/marker-check.test.sh
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
#!/usr/bin/env bash
|
||||
# Contract tests for .github/scripts/marker-check.sh (issue #238). The guard
|
||||
# is driven against tracked fixture trees; set -u, not -e, because failures
|
||||
# are behavior for the harness to inspect.
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=test/harness.sh
|
||||
. "$ROOT/test/harness.sh"
|
||||
|
||||
CHECK="$ROOT/.github/scripts/marker-check.sh"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
fixture() {
|
||||
local name="$1" version="$2"
|
||||
mkdir -p "$TMP/$name/docs" "$TMP/$name/changelog.d"
|
||||
git -C "$TMP/$name" init -q
|
||||
printf '%s\n' "$version" >"$TMP/$name/VERSION"
|
||||
printf '# Changelog\n\n## 0.5.0 — 2026-08-03\n\n- Shipped (#221).\n' \
|
||||
>"$TMP/$name/CHANGELOG.md"
|
||||
}
|
||||
|
||||
run_check() {
|
||||
git -C "$TMP/$1" add .
|
||||
bash "$CHECK" "$TMP/$1"
|
||||
}
|
||||
|
||||
fixture wrapped 0.6.0-dev
|
||||
cat >"$TMP/wrapped/docs/CONSUMERS.md" <<'EOF'
|
||||
The new guard remains **unreleased**
|
||||
(#238) until the next tag.
|
||||
EOF
|
||||
check "a wrapped local citation is accepted" 0 "agree with the tree" \
|
||||
run_check wrapped
|
||||
|
||||
fixture uncited 0.6.0-dev
|
||||
printf 'The new guard remains **unreleased** for now.\n' \
|
||||
>"$TMP/uncited/docs/CONSUMERS.md"
|
||||
check "an uncited marker fails on a dev tree with file and line" 1 \
|
||||
"docs/CONSUMERS.md:1" run_check uncited
|
||||
|
||||
fixture inline-mention 0.6.0-dev
|
||||
cat >"$TMP/inline-mention/docs/CONSUMERS.md" <<'EOF'
|
||||
The marker token is `**unreleased**`.
|
||||
EOF
|
||||
check "an inline-code token is a mention and needs no citation" 0 \
|
||||
"agree with the tree" run_check inline-mention
|
||||
|
||||
fixture inline-bare 0.6.0-dev
|
||||
printf 'The marker token is **unreleased**.\n' \
|
||||
>"$TMP/inline-bare/docs/CONSUMERS.md"
|
||||
check "removing the backticks exposes the uncited marker" 1 \
|
||||
"docs/CONSUMERS.md:1" run_check inline-bare
|
||||
|
||||
fixture inline-neighbor 0.6.0-dev
|
||||
cat >"$TMP/inline-neighbor/docs/CONSUMERS.md" <<'EOF'
|
||||
The `new guard` remains **unreleased** until its tag.
|
||||
EOF
|
||||
check "unrelated inline code cannot hide an uncited marker on the same line" 1 \
|
||||
"docs/CONSUMERS.md:1" run_check inline-neighbor
|
||||
|
||||
# This release comparison would have caught all five of #221's stale markers.
|
||||
fixture shipped 0.6.0
|
||||
printf 'The new guard remains **unreleased** (#224).\n' \
|
||||
>"$TMP/shipped/docs/CONSUMERS.md"
|
||||
cat >"$TMP/shipped/CHANGELOG.md" <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## 0.6.0 — 2026-08-03
|
||||
|
||||
- The guard shipped (#224).
|
||||
|
||||
## 0.5.0 — 2026-08-02
|
||||
|
||||
- Older work (#999).
|
||||
EOF
|
||||
check "a release rejects a marker cited by its top section" 1 \
|
||||
"docs/CONSUMERS.md:1: **unreleased** (#224)" run_check shipped
|
||||
|
||||
fixture not-shipped 0.6.0
|
||||
printf 'Future work remains **unreleased** (#999).\n' \
|
||||
>"$TMP/not-shipped/docs/CONSUMERS.md"
|
||||
cp "$TMP/shipped/CHANGELOG.md" "$TMP/not-shipped/CHANGELOG.md"
|
||||
check "a release keeps a marker absent from its top section" 0 \
|
||||
"agree with the tree" run_check not-shipped
|
||||
|
||||
fixture dev-shipped 0.6.0-dev
|
||||
printf 'Future work remains **unreleased** (#224).\n' \
|
||||
>"$TMP/dev-shipped/docs/CONSUMERS.md"
|
||||
cp "$TMP/shipped/CHANGELOG.md" "$TMP/dev-shipped/CHANGELOG.md"
|
||||
check "a dev tree does not compare markers with shipped sections" 0 \
|
||||
"agree with the tree" run_check dev-shipped
|
||||
|
||||
fixture cross-repo 0.6.0
|
||||
printf 'Crew work remains **unreleased** (crew#293).\n' \
|
||||
>"$TMP/cross-repo/docs/CONSUMERS.md"
|
||||
cat >"$TMP/cross-repo/CHANGELOG.md" <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## 0.6.0 — 2026-08-03
|
||||
|
||||
- Local work shipped (#293).
|
||||
EOF
|
||||
check "a cross-repo citation is valid and ignored by release comparison" 0 \
|
||||
"agree with the tree" run_check cross-repo
|
||||
|
||||
fixture self-qualified 0.6.0
|
||||
printf 'Ceremony work remains **unreleased** (ceremony#248).\n' \
|
||||
>"$TMP/self-qualified/docs/CONSUMERS.md"
|
||||
cat >"$TMP/self-qualified/CHANGELOG.md" <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## 0.6.0 — 2026-08-03
|
||||
|
||||
- Ceremony work shipped (#248).
|
||||
EOF
|
||||
check "a self-qualified citation is ignored; local markers must use bare #N" 0 \
|
||||
"agree with the tree" run_check self-qualified
|
||||
|
||||
# CHANGELOG.md:38 on main is the live bold-token entry prose this exclusion models.
|
||||
fixture exclusions 0.6.0-dev
|
||||
cat >"$TMP/exclusions/NOTES.md" <<'EOF'
|
||||
# Notes
|
||||
|
||||
## Unreleased
|
||||
|
||||
The marker token is `**unreleased**`.
|
||||
EOF
|
||||
printf -- '- A fragment may say **unreleased** without being documentation.\n' \
|
||||
>"$TMP/exclusions/changelog.d/999.md"
|
||||
cat >"$TMP/exclusions/CHANGELOG.md" <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## 0.5.0 — 2026-08-03
|
||||
|
||||
- Shipped prose may discuss **unreleased** markers without becoming one.
|
||||
EOF
|
||||
check "headings, inline mentions, changelog entries, and fragments are excluded" 0 \
|
||||
"agree with the tree" run_check exclusions
|
||||
|
||||
summary
|
||||
232
test/no-runtime-gh.test.sh
Executable file
232
test/no-runtime-gh.test.sh
Executable file
|
|
@ -0,0 +1,232 @@
|
|||
#!/usr/bin/env bash
|
||||
# The forge-portability guard (#198, enforcing #197's acceptance bar):
|
||||
#
|
||||
# No runtime `gh` invocation survives outside lib/forge-github.sh, except
|
||||
# in a file that declares CEREMONY_FORGE_CLIENT=gh and therefore refuses
|
||||
# loudly on a forge that cannot serve it.
|
||||
#
|
||||
# WHY THIS FILE EXISTS, rather than the rule living in review. #188 ported
|
||||
# every `gh` call site onto the shim. The 0.6.0 upstream merge put SEVEN of
|
||||
# them back — not in the eighteen conflict hunks, where a resolver would have
|
||||
# been forced to look, but in whole functions upstream added to files this
|
||||
# tree already owned. `git merge` takes upstream's side wherever only upstream
|
||||
# moved a region, so it raised no conflict and asked no question. Reviewing
|
||||
# the hunks could not have caught them; four reviewers reading the same diff
|
||||
# each found a different subset.
|
||||
#
|
||||
# The sweep runs on a Forgejo instance whose runner image carries curl, jq and
|
||||
# node and has NEITHER gh NOR stoke (lib/forge-forgejo.sh's header, probe task
|
||||
# 278). So a reintroduced `gh` is not a style problem — it is `gh: command not
|
||||
# found` mid-sweep, or a write that silently never happens.
|
||||
#
|
||||
# And it is invisible to the rest of the suite by construction: the contract
|
||||
# tests stub `gh` as a shell function or on PATH, so they exercise a
|
||||
# reintroduced call site happily and go green. This guard reads the SOURCE,
|
||||
# which is the only place the difference is visible.
|
||||
#
|
||||
# It is deliberately a source-level check, and deliberately the ONLY one of
|
||||
# its kind: every other guard here drives behaviour. This one cannot — the
|
||||
# behaviour it forbids is unobservable in a harness that provides a `gh`.
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=test/harness.sh
|
||||
. "$ROOT/test/harness.sh"
|
||||
|
||||
# The backend that is ALLOWED to speak gh — it is the whole point of the file.
|
||||
ALLOWED_FILE='lib/forge-github.sh'
|
||||
|
||||
|
||||
# A file may opt out by declaring the client it speaks, which makes
|
||||
# forge_preflight refuse by name on a forge that cannot serve it. Today that
|
||||
# is actions/refs-not-closing, whose only gather is GraphQL and which Forgejo
|
||||
# therefore cannot run at all (#199 ports it and drops the declaration).
|
||||
# Both spellings, because both surfaces must be able to declare: `=` for a
|
||||
# shell script, `:` for a workflow's env block. A filename exemption was the
|
||||
# first shape here and @codex-reviewer-andresmgsl was right to reject it —
|
||||
# it exempts the whole FILE, so any later gh call anywhere in that workflow
|
||||
# would ride in free, and it lets a declaration exist without a refusal.
|
||||
declares_gh_client() { grep -qE '^[[:space:]]*(export[[:space:]]+)?CEREMONY_FORGE_CLIENT[=:][[:space:]]*gh[[:space:]]*$' "$1"; }
|
||||
|
||||
# Declaring is half of it. #197's bar is "declared AND refuses loudly", and the
|
||||
# refusal has TWO halves that a single check conflates
|
||||
# (@codex-reviewer-andresmgsl, #198):
|
||||
#
|
||||
# * the FORGE — a gh dispatch is wrong on a forge that cannot serve it, and
|
||||
# asking only "is gh installed?" passes the moment a Forgejo runner image
|
||||
# happens to ship gh, which is the client/forge mismatch forge_preflight
|
||||
# exists to prevent;
|
||||
# * the BINARY — present or not on this runner.
|
||||
#
|
||||
# forge_preflight answers both, so a script that calls it satisfies both. A
|
||||
# workflow has no shell to call it from and must do both inline.
|
||||
#
|
||||
# Comments are stripped first, for the same reason gh_calls strips them and
|
||||
# with the same lesson learned the hard way: the first version of this
|
||||
# predicate was satisfied by the word `forge_preflight` inside labels.yml's own
|
||||
# comment EXPLAINING that it has no forge_preflight to call. A guard that reads
|
||||
# prose as evidence is the blind sweep again, and it passed its own mutation
|
||||
# test because of it.
|
||||
strip_comments() { sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$1"; }
|
||||
refuses_wrong_forge() {
|
||||
strip_comments "$1" | grep -qE 'forge_preflight|GITHUB_SERVER_URL.*github\.com'
|
||||
}
|
||||
refuses_missing_binary() {
|
||||
strip_comments "$1" | grep -qE 'forge_preflight|command -v gh'
|
||||
}
|
||||
refuses_when_unavailable() {
|
||||
refuses_wrong_forge "$1" && refuses_missing_binary "$1"
|
||||
}
|
||||
|
||||
# A runtime invocation, not the word. `gh` must be at a command position and
|
||||
# followed by a gh subcommand — and comment lines are stripped first, because
|
||||
# these surfaces document at length what gh used to do here and a guard that
|
||||
# went red on its own prose would be deleted within a week
|
||||
# (@kimi-reviewer-andresmgsl, #198). Nothing here reads a comment as evidence.
|
||||
gh_calls() { # $1 = file → "line:code" per runtime gh invocation
|
||||
# Comments are BLANKED rather than dropped, so grep -n still reports the
|
||||
# file's real line numbers. Trailing comments go too, not just whole-line
|
||||
# ones: a workflow's `actions: write # ...gh workflow run...` is prose
|
||||
# about a call site, and YAML puts it after the code rather than before it.
|
||||
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$1" \
|
||||
| grep -nE '(^|[^[:alnum:]_./$-])gh[[:space:]]+(api|issue|pr|release|repo|run|search|workflow|label|auth|browse|gist|secret|variable|ruleset)\b'
|
||||
}
|
||||
|
||||
# The surfaces that run on a forge: executables and the workflows that call
|
||||
# them. test/ is excluded on purpose — a test stubbing `gh` is the harness
|
||||
# doing its job, and forbidding the string there would forbid the stubs that
|
||||
# make the github backend testable at all.
|
||||
scanned_files() {
|
||||
local f
|
||||
for f in "$ROOT"/lib/*.sh "$ROOT"/actions/*/*.sh "$ROOT"/bin/* \
|
||||
"$ROOT"/.github/scripts/*.sh "$ROOT"/.github/workflows/*.yml; do
|
||||
[ -f "$f" ] || continue
|
||||
printf '%s\n' "${f#"$ROOT"/}"
|
||||
done
|
||||
}
|
||||
|
||||
offenders() {
|
||||
local rel abs
|
||||
while IFS= read -r rel; do
|
||||
[ "$rel" = "$ALLOWED_FILE" ] && continue
|
||||
abs="$ROOT/$rel"
|
||||
if declares_gh_client "$abs"; then
|
||||
refuses_when_unavailable "$abs" && continue
|
||||
printf '%s: declares CEREMONY_FORGE_CLIENT=gh but carries no refusal\n' "$rel"
|
||||
continue
|
||||
fi
|
||||
gh_calls "$abs" | sed "s|^|$rel:|"
|
||||
done < <(scanned_files)
|
||||
}
|
||||
|
||||
# In-process, not `bash -c`: a subshell cannot see these functions, so the
|
||||
# sweep would find nothing, report empty, and pass by looking at nothing —
|
||||
# the blind-sweep shape this guard exists to forbid, inside the guard itself.
|
||||
no_offenders() {
|
||||
local found
|
||||
found="$(offenders)"
|
||||
[ -z "$found" ] || { printf '%s\n' "$found" | sed 's/^/ /'; return 1; }
|
||||
}
|
||||
check "no runtime gh outside the github backend or a declared-client file" 0 "" \
|
||||
no_offenders
|
||||
|
||||
# --- the guard has teeth ------------------------------------------------------
|
||||
# A guard nobody has watched fail is a guard nobody is testing. These drive the
|
||||
# predicates directly, because the sweep above is a property of the whole tree
|
||||
# and cannot be made to fail without editing it.
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'gh api "repos/$REPO/issues/1"' >"$TMP/bad.sh"
|
||||
check "a reintroduced gh api read is seen" 0 "gh api" gh_calls "$TMP/bad.sh"
|
||||
|
||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'run gh issue comment "$n" --body x' >"$TMP/bad2.sh"
|
||||
check "a reintroduced gh issue write is seen, staged or not" 0 "gh issue" \
|
||||
gh_calls "$TMP/bad2.sh"
|
||||
|
||||
# The exact shape the 0.6.0 merge reintroduced, indented inside a function.
|
||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'f() {' \
|
||||
' guarded_read bodies gh api --paginate "repos/$REPO/issues/$1/comments"' '}' \
|
||||
>"$TMP/bad3.sh"
|
||||
check "...including one nested in a function behind guarded_read" 0 "gh api" \
|
||||
gh_calls "$TMP/bad3.sh"
|
||||
|
||||
printf '%s\n' '#!/usr/bin/env bash' '# gh api used to live here (#188)' \
|
||||
'# run gh issue comment — retired' >"$TMP/prose.sh"
|
||||
check "prose about gh is not a call site" 1 "" gh_calls "$TMP/prose.sh"
|
||||
|
||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'forge_api "repos/$REPO/issues/1"' \
|
||||
'echo "the gh client speaks /api/v3"' >"$TMP/good.sh"
|
||||
check "the shim verb is not mistaken for a call site" 1 "" gh_calls "$TMP/good.sh"
|
||||
|
||||
# Neighbouring identifiers must not read as the binary: `gh_calls`, `$gh`,
|
||||
# a path ending in /gh, and `regh api` are all not an invocation of gh.
|
||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'gh_calls() { :; }' 'regh api foo' \
|
||||
'echo "$gh api"' >"$TMP/lookalike.sh"
|
||||
check "lookalike identifiers are not call sites" 1 "" gh_calls "$TMP/lookalike.sh"
|
||||
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'export CEREMONY_FORGE_CLIENT=gh' \
|
||||
'gh api graphql -f query=x' >"$TMP/declared.sh"
|
||||
check "a declared-client file opts out" 0 "" declares_gh_client "$TMP/declared.sh"
|
||||
# A workflow declares in YAML, not shell — both spellings must count, or the
|
||||
# only surface that cannot call forge_preflight is also the only one that
|
||||
# cannot declare.
|
||||
printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \
|
||||
' CEREMONY_FORGE_CLIENT: gh' ' run: gh workflow run x' \
|
||||
>"$TMP/declared.yml"
|
||||
check "...and so does a workflow declaring it in YAML" 0 "" \
|
||||
declares_gh_client "$TMP/declared.yml"
|
||||
# Declared is not enough: #197's bar is declared AND refuses loudly.
|
||||
check "a declaration without a refusal is not enough" 1 "" \
|
||||
refuses_when_unavailable "$TMP/declared.yml"
|
||||
printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \
|
||||
' CEREMONY_FORGE_CLIENT: gh' \
|
||||
' run: |' \
|
||||
' command -v gh >/dev/null || { echo "::warning::not woken"; exit 0; }' \
|
||||
' gh workflow run x' >"$TMP/declared-refusing.yml"
|
||||
# Binary presence ALONE is not a refusal: a Forgejo runner that ships gh would
|
||||
# sail past it and dispatch against a forge that cannot serve the call.
|
||||
check "...and a declaration guarded only by binary presence still is not" 1 "" \
|
||||
refuses_when_unavailable "$TMP/declared-refusing.yml"
|
||||
check "...though it does satisfy the binary half on its own" 0 "" \
|
||||
refuses_missing_binary "$TMP/declared-refusing.yml"
|
||||
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
||||
printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \
|
||||
' CEREMONY_FORGE_CLIENT: gh' \
|
||||
' run: |' \
|
||||
' [ "$GITHUB_SERVER_URL" = "https://github.com" ] || exit 0' \
|
||||
' command -v gh >/dev/null || exit 0' \
|
||||
' gh workflow run x' >"$TMP/declared-both.yml"
|
||||
check "...and a declaration guarding BOTH forge and binary is" 0 "" \
|
||||
refuses_when_unavailable "$TMP/declared-both.yml"
|
||||
# The shipped workflow is the real customer for that pair.
|
||||
check "labels.yml declares the client it speaks" 0 "" \
|
||||
declares_gh_client "$ROOT/.github/workflows/labels.yml"
|
||||
check "...decides the forge before dispatching" 0 "" \
|
||||
refuses_wrong_forge "$ROOT/.github/workflows/labels.yml"
|
||||
check "...and checks the binary too, rather than dying on command not found" 0 "" \
|
||||
refuses_missing_binary "$ROOT/.github/workflows/labels.yml"
|
||||
check "...and an undeclared one does not" 1 "" declares_gh_client "$TMP/bad.sh"
|
||||
# A mention of the variable in prose is not a declaration.
|
||||
printf '%s\n' '#!/usr/bin/env bash' '# CEREMONY_FORGE_CLIENT=gh would opt out' \
|
||||
>"$TMP/mentions.sh"
|
||||
check "...nor does prose mentioning the variable" 1 "" \
|
||||
declares_gh_client "$TMP/mentions.sh"
|
||||
|
||||
# The scan must actually reach the surfaces it claims to, or it passes by
|
||||
# looking at nothing — the blind-sweep shape this repo keeps filing issues
|
||||
# about, in its own guard.
|
||||
scan_is_wide() { [ "$(scanned_files | wc -l)" -ge 20 ]; }
|
||||
check "the scan reaches every executable surface" 0 "" scan_is_wide
|
||||
scan_lists_backend() { scanned_files | grep -F lib/forge-github.sh; }
|
||||
check "...including the backend it exempts" 0 "lib/forge-github.sh" scan_lists_backend
|
||||
check "...and the backend really does speak gh, so the exemption is load-bearing" 0 "gh api" \
|
||||
gh_calls "$ROOT/lib/forge-github.sh"
|
||||
|
||||
summary
|
||||
198
test/refs-not-closing.test.sh
Executable file
198
test/refs-not-closing.test.sh
Executable file
|
|
@ -0,0 +1,198 @@
|
|||
#!/usr/bin/env bash
|
||||
# Contract tests for actions/refs-not-closing (issue #218). Bodies and
|
||||
# closing-reference sets are fixtures: no network and no pull request are
|
||||
# involved. set -u, not -e: failures are behavior for the harness to inspect.
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=test/harness.sh
|
||||
. "$ROOT/test/harness.sh"
|
||||
|
||||
SCRIPT="$ROOT/actions/refs-not-closing/refs-not-closing.sh"
|
||||
ACTION="$ROOT/actions/refs-not-closing/action.yml"
|
||||
ENTRYPOINT="$ROOT/actions/refs-not-closing/run.sh"
|
||||
WORKFLOW="$ROOT/.github/workflows/refs-guard.yml"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
body() {
|
||||
local name="$1"
|
||||
shift
|
||||
printf '%s\n' "$@" >"$TMP/$name.md"
|
||||
}
|
||||
|
||||
guard() {
|
||||
local name="$1"
|
||||
shift
|
||||
bash "$SCRIPT" "$TMP/$name.md" "$@"
|
||||
}
|
||||
|
||||
body ref-5 'Refs #5'
|
||||
check "Refs target with empty closing set passes" 0 "no Refs target" guard ref-5
|
||||
check "Refs target with itself closing fails" 1 "#5" guard ref-5 5
|
||||
check "Refs target with another issue closing passes" 0 "no Refs target" guard ref-5 9
|
||||
|
||||
body ordinary 'Closes #5'
|
||||
check "ordinary Closes PR remains green" 0 "no Refs target" guard ordinary 5
|
||||
|
||||
body mixed 'Refs #5' '' 'This PR legitimately Closes #9.'
|
||||
check "Refs #5 plus Closes #9 remains green" 0 "no Refs target" guard mixed 9
|
||||
|
||||
body prose 'Refs #5' '' 'Triage closes #5 by hand after the live proof.'
|
||||
check "closing prose for a Refs target fails" 1 "closes #5" guard prose 5
|
||||
check "failure prints the surrounding sentence" 1 \
|
||||
"sentence: Triage closes #5 by hand after the live proof" guard prose 5
|
||||
check "failure offers number-first rewrite" 1 "#N is" guard prose 5
|
||||
check "failure offers number-free rewrite" 1 "closes the issue" guard prose 5
|
||||
|
||||
body code-span 'Refs #5' '' "The body must not contain \`Closes #5\` anywhere."
|
||||
check "backticked closing keyword is reported as the match" 1 \
|
||||
"matched: Closes #5" guard code-span 5
|
||||
check "backtick failure explains that code spans do not protect" 1 \
|
||||
"Backticks do not protect" guard code-span 5
|
||||
|
||||
body adjacency 'Refs #5' '' 'Triage closes #9 and #5 after the proof.'
|
||||
check "non-adjacent #5 does not join closing set #9" 0 "no Refs target" \
|
||||
guard adjacency 9
|
||||
|
||||
body empty ''
|
||||
check "empty body remains green" 0 "no Refs target" guard empty 5
|
||||
|
||||
body incidents-211 'Refs #209' 'Triage closes #209 by hand.'
|
||||
check "#211 incident replays red" 1 "#209" guard incidents-211 209
|
||||
body incidents-214 'Refs #212' 'Triage closes #212 and #209 on that evidence.'
|
||||
check "#214 incident replays red" 1 "#212" guard incidents-214 212
|
||||
body incidents-200 'Refs #199' "A later edit added \`Closes #199\`."
|
||||
check "#200 incident replays red" 1 "#199" guard incidents-200 199
|
||||
|
||||
body multiple 'Refs #5 and Refs #7.' 'Triage closes #5 and fixes #7 by hand.'
|
||||
check "failure names every intersecting issue" 1 \
|
||||
"scheduled to close: #5 #7" guard multiple 5 7
|
||||
|
||||
body soft-wrap 'Refs #5' '' 'Triage closes' '#5 by hand after the live proof.'
|
||||
check "soft-wrapped closing prose is reported as one sentence" 1 \
|
||||
"sentence: Triage closes #5 by hand after the live proof" \
|
||||
guard soft-wrap 5
|
||||
|
||||
body refs-colon 'Refs: #5' '' 'Triage closes #5 after proof.'
|
||||
check "Refs colon form is protected" 1 "matched: closes #5" \
|
||||
guard refs-colon 5
|
||||
body refs-link 'Refs [#5](https://example.test/issues/5)' '' \
|
||||
'Triage closes #5 after proof.'
|
||||
check "linked Refs form is protected" 1 "matched: closes #5" \
|
||||
guard refs-link 5
|
||||
|
||||
for number in 207 191 190 176 165 164; do
|
||||
body "incident-$number" "Refs #$number"
|
||||
check "#$number incident replays green" 0 "no Refs target" \
|
||||
guard "incident-$number"
|
||||
done
|
||||
|
||||
check "missing body is a loud failure" 1 "missing or unreadable" \
|
||||
bash "$SCRIPT" "$TMP/missing.md"
|
||||
check "invalid closing set is a loud failure" 1 "invalid closing issue" \
|
||||
guard ref-5 nope
|
||||
|
||||
# The action owns the network boundary. Drive its executable entrypoint with
|
||||
# a fake `gh` so failures are behavioral assertions, not YAML text guesses.
|
||||
mkdir -p "$TMP/bin"
|
||||
cat >"$TMP/bin/gh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
# Every call is recorded, so a probe can assert the gather did NOT run — a
|
||||
# refusal that still reads is not a refusal (#198).
|
||||
[ -z "${GH_CALL_LOG:-}" ] || printf '%s\n' "$*" >>"$GH_CALL_LOG"
|
||||
case "${FAKE_GH_MODE:-success}" in
|
||||
failure)
|
||||
echo "fake GraphQL read failed" >&2
|
||||
exit 42
|
||||
;;
|
||||
partial)
|
||||
has_next=true
|
||||
;;
|
||||
success)
|
||||
has_next=false
|
||||
;;
|
||||
*)
|
||||
echo "unknown fake mode: ${FAKE_GH_MODE:-}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
printf '{"data":{"repository":{"pullRequest":{"body":"Refs #5","closingIssuesReferences":{"nodes":[],"pageInfo":{"hasNextPage":%s}}}}}}\n' "$has_next"
|
||||
EOF
|
||||
chmod +x "$TMP/bin/gh"
|
||||
|
||||
action_boundary() {
|
||||
local mode="$1"
|
||||
# CEREMONY_FORGE=github is the environment this matrix has always assumed
|
||||
# implicitly — it stubs `gh`. It is explicit now only because the entrypoint
|
||||
# declares CEREMONY_FORGE_CLIENT=gh and preflights it (#198 spec 4); the
|
||||
# incident matrix below is unchanged.
|
||||
env PATH="$TMP/bin:$PATH" FAKE_GH_MODE="$mode" \
|
||||
CEREMONY_FORGE=github \
|
||||
GITHUB_REPOSITORY="heavy-duty/ceremony" PR_NUMBER=268 \
|
||||
GITHUB_ACTION_PATH="$ROOT/actions/refs-not-closing" \
|
||||
bash "$ENTRYPOINT"
|
||||
}
|
||||
|
||||
# The declared-client refusal (#198 spec 4). This action is the one call site
|
||||
# the 0.6.0 merge could NOT port — Forgejo serves no GraphQL at all — so on a
|
||||
# Forgejo forge it must refuse by name, never produce a verdict from a graph
|
||||
# it did not read. #199 removes the declaration by making the gather REST.
|
||||
forgejo_boundary() {
|
||||
env PATH="$TMP/bin:$PATH" FAKE_GH_MODE=success GH_CALL_LOG="$TMP/gh-calls" \
|
||||
CEREMONY_FORGE=forgejo \
|
||||
GITHUB_REPOSITORY="heavy-duty/ceremony" PR_NUMBER=268 \
|
||||
GITHUB_ACTION_PATH="$ROOT/actions/refs-not-closing" \
|
||||
bash "$ENTRYPOINT"
|
||||
}
|
||||
# The contract on a forge this action cannot speak: refuse, by name, non-zero,
|
||||
# and read nothing. FAIL CLOSED — an earlier head made this exit 0 so the PR
|
||||
# check would not be red, which conflated the ACTION's contract with the
|
||||
# CALLER's scheduling decision (@codex-reviewer-andresmgsl, #198). The caller
|
||||
# is .github/workflows/refs-guard.yml, which skips on a backend this action
|
||||
# cannot speak; the action itself never reports success it did not earn.
|
||||
check "on a forgejo forge the action refuses, non-zero" 1 \
|
||||
"cannot speak it" forgejo_boundary
|
||||
check "...naming the client it declared" 1 "'gh' client" forgejo_boundary
|
||||
check "...and the client the forge actually needs" 1 "'rest' client" forgejo_boundary
|
||||
# The teeth: it must not have READ anything. The stub counts its own calls, so
|
||||
# a gather that ran despite the refusal is visible here.
|
||||
forgejo_read_count() {
|
||||
: >"$TMP/gh-calls"
|
||||
forgejo_boundary >/dev/null 2>&1
|
||||
wc -l <"$TMP/gh-calls"
|
||||
}
|
||||
check "...and reached the forge zero times" 0 "0" forgejo_read_count
|
||||
# The caller carries the scheduling half, positively: only github.com runs it.
|
||||
check "the caller skips the job on any non-github forge" 0 \
|
||||
"github.server_url == 'https://github.com'" \
|
||||
grep -F "if:" "$ROOT/.github/workflows/refs-guard.yml"
|
||||
|
||||
check "action boundary fails when GraphQL read fails" 42 \
|
||||
"fake GraphQL read failed" action_boundary failure
|
||||
check "action boundary refuses a partial closing-reference page" 5 \
|
||||
"refusing a partial verdict" action_boundary partial
|
||||
check "action boundary accepts a complete GraphQL read" 0 \
|
||||
"no Refs target" action_boundary success
|
||||
|
||||
one_graphql_read() {
|
||||
[ "$(grep -c "gh api graphql" "$ENTRYPOINT")" -eq 1 ]
|
||||
printf '1\n'
|
||||
}
|
||||
|
||||
check "action performs exactly one GraphQL read" 0 "1" \
|
||||
one_graphql_read
|
||||
check "composite delegates to the tested entrypoint" 0 "run.sh" \
|
||||
grep -F "run: bash \"\$GITHUB_ACTION_PATH/run.sh\"" "$ACTION"
|
||||
|
||||
check "workflow wakes on body edits" 0 "types: [opened, edited, reopened, synchronize]" \
|
||||
grep -F "types: [opened, edited, reopened, synchronize]" "$WORKFLOW"
|
||||
check "workflow is pull_request-only" 1 "" \
|
||||
grep -E '^ (push|pull_request_target|workflow_dispatch|schedule|issue_comment):' \
|
||||
"$WORKFLOW"
|
||||
check "workflow grants read-only pull request access" 0 "pull-requests: read" \
|
||||
grep -F "pull-requests: read" "$WORKFLOW"
|
||||
|
||||
summary
|
||||
181
test/release-path.test.sh
Executable file
181
test/release-path.test.sh
Executable file
|
|
@ -0,0 +1,181 @@
|
|||
#!/usr/bin/env bash
|
||||
# Contract tests for the release-door path manifest (issue #237). The list
|
||||
# is evidence for skipping a live drill, so drift in either direction must
|
||||
# fail before a release record can make an incomplete claim.
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=test/harness.sh
|
||||
. "$ROOT/test/harness.sh"
|
||||
|
||||
PATH_SCRIPT="$ROOT/.github/scripts/release-path.sh"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
library_refs() {
|
||||
local sibling_refs=no
|
||||
case "$1" in
|
||||
*/lib/*.sh) sibling_refs=yes ;;
|
||||
esac
|
||||
awk -v sibling_refs="$sibling_refs" '
|
||||
/^[[:space:]]*#/ { next }
|
||||
{
|
||||
line = $0
|
||||
while (match(line, /lib\/[[:alnum:]_.-]+\.sh/)) {
|
||||
print substr(line, RSTART, RLENGTH)
|
||||
line = substr(line, RSTART + RLENGTH)
|
||||
}
|
||||
# Door libraries source siblings through their own BASH_SOURCE dirname,
|
||||
# so the executable line ends in /name.sh without a literal lib/ (#237).
|
||||
if (sibling_refs == "yes" && $0 ~ /^[[:space:]]*(\.|source)[[:space:]]/) {
|
||||
line = $0
|
||||
while (match(line, /\/[[:alnum:]_.-]+\.sh/)) {
|
||||
print "lib" substr(line, RSTART, RLENGTH)
|
||||
line = substr(line, RSTART + RLENGTH)
|
||||
}
|
||||
}
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# derive_path <tree> — print the workflow, bin/ when a bin command sources a
|
||||
# door library, and the workflow's direct + transitive lib dependencies.
|
||||
derive_path() {
|
||||
local tree="$1" workflow
|
||||
local pending seen=" " lib file refs ref bin_uses_lib=no
|
||||
workflow="$tree/.github/workflows/release.yml"
|
||||
|
||||
printf '%s\n' .github/workflows/release.yml
|
||||
pending="$(library_refs "$workflow" | sort -u)"
|
||||
|
||||
while [ -n "$pending" ]; do
|
||||
lib="$(printf '%s\n' "$pending" | sed -n '1p')"
|
||||
pending="$(printf '%s\n' "$pending" | sed '1d')"
|
||||
case "$seen" in
|
||||
*" $lib "*) continue ;;
|
||||
esac
|
||||
seen="$seen$lib "
|
||||
printf '%s\n' "$lib"
|
||||
file="$tree/$lib"
|
||||
[ -f "$file" ] || continue
|
||||
refs="$(library_refs "$file" | sort -u)"
|
||||
if [ -n "$refs" ]; then
|
||||
pending="$(printf '%s\n%s\n' "$pending" "$refs" | sed '/^$/d' | sort -u)"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -d "$tree/bin" ]; then
|
||||
for file in "$tree"/bin/*; do
|
||||
[ -f "$file" ] || continue
|
||||
refs="$(library_refs "$file")"
|
||||
for ref in $refs; do
|
||||
case "$seen" in
|
||||
*" $ref "*) bin_uses_lib=yes ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
fi
|
||||
[ "$bin_uses_lib" = no ] || printf '%s\n' bin/
|
||||
}
|
||||
|
||||
declared_path() {
|
||||
bash "$1/.github/scripts/release-path.sh"
|
||||
}
|
||||
|
||||
path_check() {
|
||||
local tree="$1" declared derived missing extra
|
||||
declared="$(declared_path "$tree" | sort -u)"
|
||||
derived="$(derive_path "$tree" | sort -u)"
|
||||
missing="$(comm -13 <(printf '%s\n' "$declared") <(printf '%s\n' "$derived"))"
|
||||
extra="$(comm -23 <(printf '%s\n' "$declared") <(printf '%s\n' "$derived"))"
|
||||
if [ -n "$missing" ]; then
|
||||
printf 'release-path: missing dependency: %s\n' "$missing" >&2
|
||||
fi
|
||||
if [ -n "$extra" ]; then
|
||||
printf 'release-path: stale path: %s\n' "$extra" >&2
|
||||
fi
|
||||
[ -z "$missing" ] && [ -z "$extra" ]
|
||||
}
|
||||
|
||||
fixture() {
|
||||
local name="$1" tree
|
||||
tree="$TMP/$name"
|
||||
mkdir -p "$tree/.github/scripts" "$tree/.github/workflows" "$tree/lib" "$tree/bin"
|
||||
cp "$PATH_SCRIPT" "$tree/.github/scripts/release-path.sh"
|
||||
printf '#!/usr/bin/env bash\n. "%s"\n' \
|
||||
"\$ROOT/lib/changelog.sh" >"$tree/bin/assemble"
|
||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/changelog.sh"
|
||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.sh"
|
||||
# facts.sh sources BOTH on this tree: version.sh, and the forge shim #191
|
||||
# put on the doors' path so a Forgejo consumer can publish (#198). The
|
||||
# synthetic tree mirrors the real one, or every fixture below reports
|
||||
# lib/forge.sh stale instead of exercising the case it is about.
|
||||
printf '#!/usr/bin/env bash\n# shellcheck source=lib/version.sh\n. "%s"\n# shellcheck source=lib/forge.sh\n. "%s"\n' \
|
||||
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/version.sh" \
|
||||
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/forge.sh" \
|
||||
>"$tree/lib/facts.sh"
|
||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/version.sh"
|
||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/forge.sh"
|
||||
printf '%s\n' "$tree"
|
||||
}
|
||||
|
||||
# Exact output is the record author's copy-paste source.
|
||||
check "manifest prints the specified ordered release path" 0 \
|
||||
$'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/facts.sh\nlib/changelog.sh\nlib/forge.sh' \
|
||||
bash "$PATH_SCRIPT"
|
||||
check "real workflow and transitive dependencies match the manifest" 0 "" \
|
||||
path_check "$ROOT"
|
||||
|
||||
# A door growing a dependency must name the missing path (#237 D7).
|
||||
tree="$(fixture missing)"
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\n' \
|
||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
||||
"\$CEREMONY_DIR/lib/changelog.sh" "\$CEREMONY_DIR/lib/version.sh" \
|
||||
"\$CEREMONY_DIR/lib/ruling.sh" \
|
||||
>"$tree/.github/workflows/release.yml"
|
||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
|
||||
check "a new workflow library fails with its missing path" 1 \
|
||||
"missing dependency: lib/ruling.sh" path_check "$tree"
|
||||
|
||||
# A library growing a sibling dependency in the production idiom must also
|
||||
# name the missing path; a literal lib/ marker in a comment is not evidence.
|
||||
tree="$(fixture missing-transitive)"
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \
|
||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
||||
"\$CEREMONY_DIR/lib/changelog.sh" \
|
||||
>"$tree/.github/workflows/release.yml"
|
||||
printf '# shellcheck source=lib/ruling.sh\n. "%s"\n' \
|
||||
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/ruling.sh" \
|
||||
>>"$tree/lib/facts.sh"
|
||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
|
||||
check "a new sibling library fails with its missing path" 1 \
|
||||
"missing dependency: lib/ruling.sh" path_check "$tree"
|
||||
|
||||
# A manifest may not rot into a safe-looking superset.
|
||||
tree="$(fixture extra)"
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \
|
||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
||||
"\$CEREMONY_DIR/lib/changelog.sh" \
|
||||
>"$tree/.github/workflows/release.yml"
|
||||
sed -i 's| lib/forge.sh$| lib/forge.sh \\|' \
|
||||
"$tree/.github/scripts/release-path.sh"
|
||||
printf ' lib/ruling.sh\n' >>"$tree/.github/scripts/release-path.sh"
|
||||
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
|
||||
check "a path no door reads fails as stale" 1 "stale path: lib/ruling.sh" \
|
||||
path_check "$tree"
|
||||
|
||||
# Transitive sourcing is part of the derivation, not decoration.
|
||||
tree="$(fixture transitive)"
|
||||
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \
|
||||
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
|
||||
"\$CEREMONY_DIR/lib/changelog.sh" \
|
||||
>"$tree/.github/workflows/release.yml"
|
||||
# Only the version source is dropped; the forge source #191 added stays, or
|
||||
# the fixture reports two stale paths and proves neither of them (#198).
|
||||
printf '#!/usr/bin/env bash\n# shellcheck source=lib/forge.sh\n. "%s"\n' \
|
||||
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/forge.sh" \
|
||||
>"$tree/lib/facts.sh"
|
||||
check "removing facts' version source fails as a stale path" 1 \
|
||||
"stale path: lib/version.sh" path_check "$tree"
|
||||
|
||||
summary
|
||||
|
|
@ -115,6 +115,71 @@ check "labels only mid-sentence are malformed — line-anchoring is the rule" 0
|
|||
check "an empty body is missing everything" 0 "MALFORMED Options: Recommend: Blocked: Default:" \
|
||||
ruling_shape_decision </dev/null
|
||||
|
||||
# -- escalation selection: best-shaped wins, earliest breaks ties (#226) ----
|
||||
# The crew#293 incident: a whole-round reply and the escalation land seconds
|
||||
# apart inside one window, the reply earlier. Earliest-wins graded the reply.
|
||||
# b64 here mirrors jq's @base64 — unwrapped, or the TSV rows would split.
|
||||
|
||||
b64enc() { printf '%s' "$1" | base64 | tr -d '\n'; }
|
||||
ROUND_REPLY=$'🔧 addressing round on head 86c35f14 — every point answered below'
|
||||
PARTIAL=$'Options: A — x B — y\nBlocked: z'
|
||||
|
||||
replay="$(printf 'setter %s https://x/reply %s\nsetter %s https://x/escalation %s\n' \
|
||||
"$((L - 40))" "$(b64enc "$ROUND_REPLY")" "$((L - 7))" "$(b64enc "$TPL")")"
|
||||
check "crew#293 replay: the complete escalation is selected over the earlier round reply" 0 \
|
||||
"https://x/escalation $(b64enc "$TPL")" ruling_escalation_row setter "$L" <<<"$replay"
|
||||
sel="$(ruling_escalation_row setter "$L" <<<"$replay")"
|
||||
check "crew#293 replay: the selected body grades SHAPED" 0 "SHAPED" \
|
||||
ruling_shape_decision <<<"$(base64 -d <<<"${sel#* }")"
|
||||
check "the nudge's link follows the same selection" 0 "https://x/escalation" \
|
||||
ruling_escalation_url setter "$L" <<<"$replay"
|
||||
check "the rung wording reads Default: from the selected body" 0 "DEADLINE 2026-07-23T21:00Z" \
|
||||
ruling_default_decision <<<"$(base64 -d <<<"${sel#* }")"
|
||||
|
||||
check "escalation-then-follow-up still selects the escalation" 0 \
|
||||
"https://x/escalation $(b64enc "$TPL")" ruling_escalation_row setter "$L" <<<"$(
|
||||
printf 'setter %s https://x/escalation %s\nsetter %s https://x/followup %s\n' \
|
||||
"$((L - 300))" "$(b64enc "$TPL")" "$((L - 60))" "$(b64enc 'thanks — clarified above')")"
|
||||
|
||||
check "a complete escalation beats an earlier partial" 0 \
|
||||
"https://x/complete $(b64enc "$TPL")" ruling_escalation_row setter "$L" <<<"$(
|
||||
printf 'setter %s https://x/partial %s\nsetter %s https://x/complete %s\n' \
|
||||
"$((L - 300))" "$(b64enc "$PARTIAL")" "$((L - 60))" "$(b64enc "$TPL")")"
|
||||
check "a complete escalation beats a later partial" 0 \
|
||||
"https://x/complete $(b64enc "$TPL")" ruling_escalation_row setter "$L" <<<"$(
|
||||
printf 'setter %s https://x/complete %s\nsetter %s https://x/partial %s\n' \
|
||||
"$((L - 300))" "$(b64enc "$TPL")" "$((L - 60))" "$(b64enc "$PARTIAL")")"
|
||||
|
||||
check "equal full scores break to the earliest" 0 \
|
||||
"https://x/one $(b64enc "$TPL")" ruling_escalation_row setter "$L" <<<"$(
|
||||
printf 'setter %s https://x/one %s\nsetter %s https://x/two %s\n' \
|
||||
"$((L - 300))" "$(b64enc "$TPL")" "$((L - 60))" "$(b64enc "$TPL_BOLD")")"
|
||||
check "all-zero scores still break to the earliest" 0 "https://x/first" \
|
||||
ruling_escalation_url setter "$L" <<<"$(
|
||||
printf 'setter %s https://x/first\nsetter %s https://x/second\n' \
|
||||
"$((L - 300))" "$((L - 60))")"
|
||||
|
||||
check "an out-of-window all-four row is never selected" 0 \
|
||||
"https://x/in $(b64enc "$PARTIAL")" ruling_escalation_row setter "$L" <<<"$(
|
||||
printf 'setter %s https://x/out %s\nsetter %s https://x/in %s\n' \
|
||||
"$((L - 5000))" "$(b64enc "$TPL")" "$((L - 60))" "$(b64enc "$PARTIAL")")"
|
||||
check "an out-of-window all-four row cannot turn an empty result non-empty" 0 "" \
|
||||
ruling_escalation_row setter "$L" <<<"setter $((L - 5000)) https://x/out $(b64enc "$TPL")"
|
||||
check "another actor's all-four row is never selected" 0 "" \
|
||||
ruling_escalation_row setter "$L" <<<"bystander $((L - 60)) https://x/other $(b64enc "$TPL")"
|
||||
|
||||
check "a garbage body column scores 0 and never errors" 0 \
|
||||
"https://x/good $(b64enc "$PARTIAL")" ruling_escalation_row setter "$L" <<<"$(
|
||||
printf 'setter %s https://x/garbage !!!not-base64!!!\nsetter %s https://x/good %s\n' \
|
||||
"$((L - 300))" "$((L - 60))" "$(b64enc "$PARTIAL")")"
|
||||
check "a garbage-only candidate is still a legal selection" 0 \
|
||||
"https://x/garbage !!!not-base64!!!" \
|
||||
ruling_escalation_row setter "$L" <<<"setter $((L - 300)) https://x/garbage !!!not-base64!!!"
|
||||
|
||||
# shellcheck disable=SC2016 # the literal $field is the assertion — one spelling, unexpanded
|
||||
check "the field matcher has exactly one spelling in lib/ruling.sh" 0 "1" \
|
||||
grep -cF '(\*\*)?$field' "$ROOT/lib/ruling.sh"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The orchestrator, against a recording gh stub. The stub serves fixture JSON
|
||||
# per endpoint (missing file = unreadable read), applies the caller's --jq
|
||||
|
|
|
|||
243
test/vendored.test.sh
Normal file
243
test/vendored.test.sh
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
#!/usr/bin/env bash
|
||||
# Contract tests for .github/scripts/vendored-check.sh (issue #251) — the
|
||||
# self-guard that makes docs/VENDORED.txt authoritative over ceremony's OWN
|
||||
# tree. Driven against constructed fixture trees plus the real one; the CI
|
||||
# step runs the same script against the real tree.
|
||||
#
|
||||
# The fixture doc set is deliberately NOT ceremony's real six: a guard that
|
||||
# hardcodes the vendored list instead of reading the manifest fails these
|
||||
# rows, which is the failure the whole issue is about.
|
||||
#
|
||||
# set -u, not -e: failing commands are behavior for the harness to inspect.
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=test/harness.sh
|
||||
. "$ROOT/test/harness.sh"
|
||||
|
||||
CHECK="$ROOT/.github/scripts/vendored-check.sh"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# --- fixture builders --------------------------------------------------------
|
||||
|
||||
# tree <name> <manifest-entry...> — a fixture tree carrying only the manifest.
|
||||
tree() {
|
||||
local dir="$TMP/$1"
|
||||
shift
|
||||
rm -rf "$dir"
|
||||
mkdir -p "$dir/docs"
|
||||
printf '%s\n' "$@" >"$dir/docs/VENDORED.txt"
|
||||
}
|
||||
|
||||
# doc <tree> <path> [content] — a regular file in a fixture tree.
|
||||
doc() {
|
||||
local path="$TMP/$1/$2"
|
||||
mkdir -p "$(dirname "$path")"
|
||||
printf '%s\n' "${3:-# a doc}" >"$path"
|
||||
}
|
||||
|
||||
# real_copy <name> — the real tree reduced to what the guard reads: the
|
||||
# manifest, every path the manifest names, and every root *.md. Cases mutate
|
||||
# the copy, so the guard's verdict on ceremony's actual doc set is proven
|
||||
# without touching the working tree.
|
||||
real_copy() {
|
||||
local dir="$TMP/$1" entry
|
||||
rm -rf "$dir"
|
||||
mkdir -p "$dir/docs"
|
||||
cp "$ROOT/docs/VENDORED.txt" "$dir/docs/VENDORED.txt"
|
||||
cp "$ROOT"/*.md "$dir/"
|
||||
while IFS= read -r entry; do
|
||||
[ -n "$entry" ] || continue
|
||||
mkdir -p "$dir/$(dirname "$entry")"
|
||||
cp "$ROOT/$entry" "$dir/$entry"
|
||||
done <"$ROOT/docs/VENDORED.txt"
|
||||
}
|
||||
|
||||
run_check() {
|
||||
bash "$CHECK" "$TMP/$1"
|
||||
}
|
||||
|
||||
# --- the happy tree ----------------------------------------------------------
|
||||
|
||||
# One manifest entry lives in a subdirectory: the manifest is PATHS, not
|
||||
# filenames (docs-sync's fixtures prove the same), and the closed-world rule
|
||||
# over the root must not regress that to root-only.
|
||||
tree ok AGENTS.md RULES.md guide/DEEP.md
|
||||
doc ok AGENTS.md
|
||||
doc ok RULES.md
|
||||
doc ok guide/DEEP.md
|
||||
check "a tree whose root docs are all declared passes" 0 "3 manifest entries resolve" \
|
||||
run_check ok
|
||||
|
||||
tree blanks AGENTS.md '' RULES.md
|
||||
doc blanks AGENTS.md
|
||||
doc blanks RULES.md
|
||||
check "blank manifest lines are skipped, as docs-sync skips them" 0 "2 manifest entries" \
|
||||
run_check blanks
|
||||
|
||||
# --- the closed world: a root doc in neither list ----------------------------
|
||||
|
||||
# The #248 near-miss, replayed as a test: a new doctrine file lands at the
|
||||
# root and nobody adds it to the manifest.
|
||||
tree newdoc AGENTS.md
|
||||
doc newdoc AGENTS.md
|
||||
doc newdoc NEWDOC.md
|
||||
check "a root doc in neither list reds" 1 "'NEWDOC.md' is a root doc in NEITHER list" \
|
||||
run_check newdoc
|
||||
check "...and the refusal names the manifest fix" 1 "add 'NEWDOC.md' to docs/VENDORED.txt" \
|
||||
run_check newdoc
|
||||
check "...and the refusal names the exemption fix" 1 "add 'NEWDOC.md' to the exemption list" \
|
||||
run_check newdoc
|
||||
|
||||
# The decision the guard forces, taken each way: vendor it…
|
||||
tree newdoc-vendored AGENTS.md NEWDOC.md
|
||||
doc newdoc-vendored AGENTS.md
|
||||
doc newdoc-vendored NEWDOC.md
|
||||
check "a root doc added to the manifest passes" 0 "2 manifest entries" \
|
||||
run_check newdoc-vendored
|
||||
|
||||
# …or exempt it. The exemption list is in the script and carries a reason;
|
||||
# README.md is one of the four ceremony-only root docs it names.
|
||||
tree exempted AGENTS.md
|
||||
doc exempted AGENTS.md
|
||||
doc exempted README.md
|
||||
check "a root doc on the exemption list passes, with its reason" 0 "exempt: README.md" \
|
||||
run_check exempted
|
||||
|
||||
# The exemption list is NEVER read from prose. CONTRIBUTING.md's vendored-set
|
||||
# sentence is documentation; two declarations of the same set is the drift
|
||||
# the manifest exists to prevent (#251 D2's second must-fail).
|
||||
tree prose AGENTS.md
|
||||
doc prose AGENTS.md
|
||||
doc prose EXTRA.md
|
||||
doc prose CONTRIBUTING.md "The vendored set is AGENTS.md and EXTRA.md."
|
||||
check "a doc declared only in prose still reds" 1 "'EXTRA.md' is a root doc in NEITHER list" \
|
||||
run_check prose
|
||||
|
||||
# --- the rule is ROOT-level only ---------------------------------------------
|
||||
|
||||
# A guard that walked the tree would need an exemption list long enough that
|
||||
# nobody reads it — the exact failure the root-only rule is shaped against.
|
||||
# So an undeclared *.md under docs/, actions/ or drills/ must stay GREEN.
|
||||
tree subdirs AGENTS.md
|
||||
doc subdirs AGENTS.md
|
||||
doc subdirs docs/CONSUMERS.md
|
||||
doc subdirs actions/thing/README.md
|
||||
doc subdirs drills/2026-07-01.md
|
||||
check "undeclared *.md below the root stays green (no recursion)" 0 "1 manifest entries" \
|
||||
run_check subdirs
|
||||
|
||||
# --- manifest → tree: the scan -----------------------------------------------
|
||||
|
||||
tree missing AGENTS.md GONE.md
|
||||
doc missing AGENTS.md
|
||||
check "a manifest entry with no file reds, naming it" 1 "names 'GONE.md' but the tree has no such file" \
|
||||
run_check missing
|
||||
|
||||
tree symlinked AGENTS.md LINK.md
|
||||
doc symlinked AGENTS.md
|
||||
ln -s AGENTS.md "$TMP/symlinked/LINK.md"
|
||||
check "a manifest entry pointing at a symlink reds" 1 "names 'LINK.md', which is a SYMLINK" \
|
||||
run_check symlinked
|
||||
|
||||
tree dir-entry AGENTS.md guide
|
||||
doc dir-entry AGENTS.md
|
||||
mkdir -p "$TMP/dir-entry/guide"
|
||||
check "a manifest entry pointing at a directory reds" 1 "names 'guide', which is a DIRECTORY" \
|
||||
run_check dir-entry
|
||||
|
||||
tree empty-entry AGENTS.md HOLLOW.md
|
||||
doc empty-entry AGENTS.md
|
||||
: >"$TMP/empty-entry/HOLLOW.md"
|
||||
check "a manifest entry pointing at an empty file reds" 1 "names 'HOLLOW.md', which is EMPTY" \
|
||||
run_check empty-entry
|
||||
|
||||
# The escape case exists as a docs-sync fixture; ceremony's own manifest must
|
||||
# not be the one place it goes unchecked.
|
||||
tree escape AGENTS.md ../outside.md
|
||||
doc escape AGENTS.md
|
||||
check "a manifest entry escaping with ../ reds" 1 "names '../outside.md'" \
|
||||
run_check escape
|
||||
|
||||
tree absolute AGENTS.md /etc/hosts
|
||||
doc absolute AGENTS.md
|
||||
check "an absolute manifest entry reds" 1 "names '/etc/hosts'" \
|
||||
run_check absolute
|
||||
|
||||
# --- the manifest itself -----------------------------------------------------
|
||||
|
||||
rm -rf "$TMP/no-manifest"
|
||||
mkdir -p "$TMP/no-manifest"
|
||||
check "a tree with no manifest reds" 1 "no docs/VENDORED.txt under" \
|
||||
run_check no-manifest
|
||||
|
||||
rm -rf "$TMP/empty-manifest"
|
||||
mkdir -p "$TMP/empty-manifest/docs"
|
||||
: >"$TMP/empty-manifest/docs/VENDORED.txt"
|
||||
check "an empty manifest reds" 1 "is empty" run_check empty-manifest
|
||||
|
||||
# --- tracked-ness ------------------------------------------------------------
|
||||
|
||||
# A file present on this machine but absent from the tag's tree cannot be
|
||||
# fetched by a consumer syncing at that tag. The assertion binds only where
|
||||
# it can: when the tree IS a git work tree root.
|
||||
tree tracked AGENTS.md RULES.md
|
||||
doc tracked AGENTS.md
|
||||
doc tracked RULES.md
|
||||
git init -q "$TMP/tracked"
|
||||
git -C "$TMP/tracked" add docs/VENDORED.txt AGENTS.md RULES.md
|
||||
check "a git tree whose manifest entries are all tracked passes" 0 "2 manifest entries" \
|
||||
run_check tracked
|
||||
|
||||
tree untracked AGENTS.md RULES.md
|
||||
doc untracked AGENTS.md
|
||||
doc untracked RULES.md
|
||||
git init -q "$TMP/untracked"
|
||||
git -C "$TMP/untracked" add docs/VENDORED.txt AGENTS.md
|
||||
check "a git tree with an untracked manifest entry reds" 1 "names 'RULES.md', which is not TRACKED" \
|
||||
run_check untracked
|
||||
|
||||
# ...and where it cannot bind, the skip ANNOUNCES ITSELF rather than being
|
||||
# inferred from the absence of a refusal (#251 round 1). A guard that quietly
|
||||
# stops asserting one of its four properties is the silent miss this whole
|
||||
# script argues against, so the degradation is visible on both output paths.
|
||||
check "a non-git tree says tracked-ness was not asserted" 0 "tracked-ness NOT asserted" \
|
||||
run_check ok
|
||||
check "...and says it on the red path too, beside the refusals" 1 "tracked-ness NOT asserted" \
|
||||
run_check newdoc
|
||||
|
||||
# The converse, so the note is not simply always printed: where the tree IS a
|
||||
# git work tree root the assertion bound, and nothing is announced.
|
||||
no_skip_note() { ! run_check tracked 2>&1 | grep -qF "tracked-ness NOT asserted"; }
|
||||
check "a git work tree root announces no skip — the assertion bound" 0 "" \
|
||||
no_skip_note
|
||||
|
||||
# --- the real tree -----------------------------------------------------------
|
||||
|
||||
check "this tree, unmodified, is green" 0 "manifest entries resolve" bash "$CHECK" "$ROOT"
|
||||
|
||||
# The #248 near-miss on the REAL doc set: a scratch root doc nobody declared.
|
||||
real_copy scratch
|
||||
doc scratch SCRATCHDOC.md
|
||||
check "a scratch root doc on the real tree reds, naming it" 1 "'SCRATCHDOC.md' is a root doc in NEITHER list" \
|
||||
run_check scratch
|
||||
|
||||
# RELEASES.md stays listed — the regression criterion #248's review round
|
||||
# bought, now asserted BY THE GUARD rather than by a hardcoded `grep -Fx` row
|
||||
# in test/docs-sync.test.sh (#251 D1, D4). The closed world holds in both
|
||||
# directions: dropping it from the manifest alone reds…
|
||||
real_copy releases-dropped
|
||||
grep -v '^RELEASES\.md$' "$ROOT/docs/VENDORED.txt" >"$TMP/releases-dropped/docs/VENDORED.txt"
|
||||
check "dropping RELEASES.md from the manifest alone reds" 1 "'RELEASES.md' is a root doc in NEITHER list" \
|
||||
run_check releases-dropped
|
||||
|
||||
# …and it is green only when the file leaves the root in the same breath.
|
||||
real_copy releases-gone
|
||||
grep -v '^RELEASES\.md$' "$ROOT/docs/VENDORED.txt" >"$TMP/releases-gone/docs/VENDORED.txt"
|
||||
rm -f "$TMP/releases-gone/RELEASES.md"
|
||||
check "dropping RELEASES.md from the manifest AND the root is green" 0 "manifest entries resolve" \
|
||||
run_check releases-gone
|
||||
|
||||
summary
|
||||
Loading…
Reference in a new issue