drills/README.md — the standing runner-probe venue, and why the drill disposal rule does not apply to it (#202) #207

Merged
andres merged 14 commits from build/202-runner-probe-venue into main 2026-08-05 17:18:59 +00:00
53 changed files with 8274 additions and 979 deletions
Showing only changes of commit 2037275a9c - Show all commits

61
.github/labeler.yml vendored
View file

@ -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
View 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
View 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
View 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[@]}"

View file

@ -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
View 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 }}

View file

@ -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
View 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

View file

@ -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

View file

@ -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
View 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

View file

@ -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

View file

@ -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
12). 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 12). 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 D12D13](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 D12D13).
The ladder is anchored to the current episode's `needs-ruling` **`labeled`
event**, not its `Default:` deadline or the last activity
([#50 D13D14](https://github.com/heavy-duty/ceremony/issues/50)):
event**, not its `Default:` deadline or the last activity (#50 D13D14):
- **012h:** 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;
- **012h:** 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.

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -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**
([L328L339](.github/workflows/release.yml#L328-L339)) and **the tagged
tree carries a publishable `## X.Y.Z` section**
([L340L352](.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
([L92L105](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 ([L92L105](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
[L202L205](.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.
[L207L222](.github/workflows/release.yml#L207-L222), the nothing-exists
[L208L223](.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
[L292L300](.github/workflows/release.yml#L292-L300) — loud, but not a
[L293L301](.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.
[L346L349](.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 ([L303L307](.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"
([L78L82](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 12 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`
([L114L115](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 ([L293L301](.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
View 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.

View file

@ -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.

View file

@ -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 23 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 D12D13](https://github.com/heavy-duty/ceremony/issues/50)).
reversible-only default rules (#50 D12D13).
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 D13D14](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 D13D14). 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

View file

@ -1 +1 @@
0.4.2-dev
0.6.1-dev

View file

@ -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

View file

@ -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=$?

View 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"

View 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
View 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
View 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).

View file

@ -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

View file

@ -3,3 +3,4 @@ TRIAGE.md
BUILDER.md
REVIEWER.md
LABELS.md
RELEASES.md

58
drills/0.5.0.md Normal file
View 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:865866`), 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
View 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.

View file

@ -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
View 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"
}

View file

@ -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
View 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
}

View file

@ -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 → 04, 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
View 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

View file

@ -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" \

View file

@ -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 "" \

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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 "" \

View file

@ -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
View 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
View 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
View 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
View 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

View file

@ -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
View 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