Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

88 changed files with 1421 additions and 14080 deletions

19
.github/DISCUSSION_TEMPLATE/ideas.yml vendored Normal file
View file

@ -0,0 +1,19 @@
# Light on purpose: discussions are where ambiguity is ALLOWED — a form
# that demands rigor at the door defeats the room's purpose (issue #24,
# decision 4). If these prompts fight the flow in practice, delete them
# before adding fields to them.
body:
- type: textarea
attributes:
label: What's the itch?
description: >-
Vague is fine — a bug, an idea, a "we should…". Triage turns this
into work (or an answer); you don't have to.
validations:
required: false
- type: textarea
attributes:
label: What would "done" feel like?
description: Optional — a sketch of the outcome, if you can already see it.
validations:
required: false

15
.github/DISCUSSION_TEMPLATE/q-a.yml vendored Normal file
View file

@ -0,0 +1,15 @@
# Light on purpose — same reasoning as ideas.yml (issue #24, decision 4):
# optional prompts only, delete before hardening.
body:
- type: textarea
attributes:
label: What's the question?
description: Ask it plainly — context and links help, none are required.
validations:
required: false
- type: textarea
attributes:
label: What have you tried or read already?
description: Optional — saves the answerer a lap.
validations:
required: false

View file

@ -1,12 +1,13 @@
# The new-issue interception (issue #24, decision 1): interception over
# instruction — prose alone has already proven insufficient everywhere else
# in this org. Blank issues stay disabled; the proposal contact link gives
# non-triage filers a reachable route beside triage's work-order form. That
# chooser is native forge behavior, no automation needed.
# in this org. With blank issues disabled and exactly one form, a "New
# issue" click shows the discussion link first; that auto-suggest is native
# GitHub behavior, no automation needed.
blank_issues_enabled: false
contact_links:
- name: Ideas, bugs, questions — file a Proposal
url: https://forgejo.heavyduty.builders/heavy-duty/ceremony/issues/new?template=proposal.yml
- name: Ideas, bugs, questions — start a Discussion
url: https://github.com/heavy-duty/ceremony/discussions
about: >-
Anyone may file a proposal. Triage converts it into a work issue or
refuses it with reasons; only triage mints work issues (TRIAGE.md).
Humans (and agents) never file issues here — discussions are where
intent lives. Triage converges every discussion to an outcome, and
only triage mints issues (TRIAGE.md).

View file

@ -1,34 +0,0 @@
# This intake form applies no labels: queue labels are triage's explicit act
# (LABELS.md), and the issue-flow sweep catches non-triage authors, so the form
# must not pre-judge the proposal's queue state (#24 D2).
name: Proposal (anyone)
description: >-
Share an idea, bug, question, or rough "we should…" for triage to convert
into work or refuse with reasons.
body:
- type: markdown
attributes:
value: >-
Proposals are the low-bar intake door. Say what you noticed and why it
might matter; triage will decide whether it becomes a work issue.
- type: textarea
id: noticed
attributes:
label: What did you notice?
description: A rough idea, bug, question, or "we should…" is enough.
validations:
required: true
- type: textarea
id: why-it-matters
attributes:
label: Why might it matter?
description: Optional — describe the impact or opportunity if you can.
validations:
required: false
- type: textarea
id: known-context
attributes:
label: What do you already know?
description: Optional — add links, examples, constraints, or prior art.
validations:
required: false

View file

@ -6,8 +6,8 @@
# that (issue #24, decision 2).
name: Work order (triage only)
description: >-
The issue contract (TRIAGE.md) as a form. Only triage mints work issues —
everyone else files a proposal. Triage may still compose by hand when
The issue contract (TRIAGE.md) as a form. Only triage mints issues —
everyone else starts a Discussion. Triage may still compose by hand when
the form fights it: `gh issue create --body-file` bypasses forms and stays
legitimate for the triage identity.
body:
@ -25,7 +25,7 @@ body:
attributes:
label: Context
description: >-
Why this exists, with links — the proposal it came from, the code
Why this exists, with links — the discussion it came from, the code
it touches (permalinks at a pinned SHA, so line references cannot
rot), prior art in sibling repos.
validations:

62
.github/labeler.yml vendored
View file

@ -7,32 +7,6 @@
# 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:
@ -44,75 +18,43 @@ scope:release-flow:
- bin/**
- VERSION
- CHANGELOG.md
- changelog.d/**
- drills/**
- test/decide.test.sh
- test/preflight.test.sh
- test/facts.test.sh
- test/release-chain.test.sh
- 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.md
- README
- docs/**
- AGENTS.md
- BUILDER.md
- RELEASES.md
- REVIEWER.md
- TRIAGE.md
- CONTRIBUTING.md

4
.github/labels.conf vendored
View file

@ -1,5 +1,5 @@
panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl
triage-actors=claude-bot-andresmgsl
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl
triage-actors=dan-claude-bot
scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors
scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded
scope:labels|C5DEF5|The labels workflow, reconciler, the taxonomy

View file

@ -1,128 +0,0 @@
#!/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."

View file

@ -1,25 +0,0 @@
#!/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/preflight.sh \
lib/facts.sh \
lib/changelog.sh \
lib/forge.sh

View file

@ -1,234 +0,0 @@
#!/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,28 +43,6 @@ 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: Fetch the recorded upstream commit
# test/upstream-delta.test.sh REFUSES when the recorded object is
# absent rather than calling it unverifiable (#200). "Runs offline"
# means the test reads local evidence — it does not mean CI may omit
# the evidence and pass. This step supplies it; the test never reaches
# the network itself.
run: |
ref="$(grep -vE '^[[:space:]]*(#|$)' .upstream-ref | head -n1)"
git fetch --no-tags --depth=1 \
https://github.com/heavy-duty/ceremony.git "$ref" || {
echo "::error::could not fetch the recorded upstream commit $ref" >&2
exit 1
}
- name: Tests
env:
# The npm-backed version_write case may skip locally when npm is

View file

@ -1,145 +0,0 @@
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). Issue events and same-repository PR events still yield a sweep within
# seconds: labels.yml's trigger job dispatches this workflow's caller on those
# events. Fork-headed PR events carry a read-only token on this Forgejo, so
# state, blocker, and handoff reconciliation waits for the caller's scheduled
# cadence; the sweep does not apply path-derived scope labels (#241).
#
# 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 issue and same-repository PR wake, 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:
bootstrap:
description: >-
Bootstrap the label taxonomy before sweeping. The CALLER passes
this through from its own workflow_dispatch input. The measured
invariant (ceremony#215): the value must be DECLARED here and
EXPLICITLY passed — on this instance the called workflow did not
see the caller's event inputs as an implicit substitute (runs
459/523 bootstrapped on a bootstrap=no dispatch) while the
top-level caller received the value in both contexts (probe runs
6/7). Absent means "no": an event- or cron-woken sweep must never
re-upsert ~20 labels.
type: string
required: false
default: "no"
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.3"
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: read from the DECLARED workflow_call input and nothing
# else. The old gate read `github.event.inputs.bootstrap` from inside
# this called workflow, and on this instance that context arrived
# empty (runs 459/523: every dispatch-woken sweep bootstrapped on a
# bootstrap=no body) while the top-level caller received the value in
# both contexts (probe runs 6/7) — ceremony#215. The reliable channel
# is declare-and-pass, so that is the only one used. The caller passes
# the value through `with.bootstrap`; anything not exactly yes|no is
# fed through for labels-reconcile's own validation to judge, so a
# typo refuses loudly instead of silently bootstrapping.
- name: reconcile state + stale
if: github.repository != 'heavy-duty/ceremony'
uses: ./.ceremony-src/actions/labels-reconcile
with:
bootstrap: ${{ inputs.bootstrap }}
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: ${{ inputs.bootstrap }}
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

@ -2,73 +2,44 @@ name: labels
# Reusable half of the labels automation. Triggers and permissions live in
# the caller; docs/CONSUMERS.md carries the complete caller stub.
#
# The caller uses pull_request_target, not pull_request, so same-repository PRs
# keep the base repository's write token without running PR code. On this
# Forgejo, unlike GitHub, fork-headed _target runs still receive a read-only
# token. Those runs therefore attempt no writes. The scheduled sweep later
# reconciles state, blockers, and handoff, but it does not apply path-derived
# scope labels; consumers that require those labels on fork heads apply them
# manually. The explicit fork_head job below records that disposition as a
# successful check. Both write paths execute only for same-repository heads.
# Scope reads changed paths and the path mapping through the API and checks out
# only the ceremony implementation. Keep it that way (#241).
# The caller uses pull_request_target, not pull_request: every PR in this
# 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.
#
# 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 instant wake: it fires on every issue event and
# same-repository PR event this caller subscribes to, preserving that part of
# the surface that used to run reconcile directly. Same-repository wake latency
# (#137) remains seconds-scale, while a displaced sweep cancels on the Actions
# tab, attached to no PR. Fork-headed runs cannot dispatch with their read-only
# token, so state, blocker, and handoff reconciliation waits for the scheduled
# sweep; path-derived scope labels are not applied to fork heads. PR checks show
# scope + trigger for same-repository heads, or fork_head for fork heads.
# 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.
#
# 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.
# 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.
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.6.3"
CEREMONY_SELF_REF: "0.4.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 the sweep (#137) — and running labeler on them widens
# exist to wake reconcile (#137) — and running labeler on them widens
# exactly the window #130 documents, where a label written during a
# scope run is clobbered.
if: >-
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.action != 'labeled' &&
github.event.action != 'unlabeled' &&
github.event.action != 'review_requested' &&
@ -114,110 +85,65 @@ jobs:
# the mapping it is judged by
CONFIG_REF: ${{ github.sha }}
trigger:
# The sweep's instant wake (#209) keeps the whole non-PR event surface and
# same-repository PRs. Fork-headed PRs are excluded because this Forgejo
# gives their pull_request_target run a read-only token (#241); fork_head
# records which reconciliation waits for the sweep and that path-derived
# scope labels are not applied there. Non-PR events include workflow_dispatch:
# excluding it would make a dispatched labels caller silently do nothing.
#
# 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.
if: >-
github.event_name != 'pull_request_target' ||
github.event.pull_request.head.repo.full_name == github.repository
reconcile:
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:
- name: dispatch the sweep
# 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' }}
env:
GITHUB_TOKEN: ${{ github.token }}
SWEEP_WORKFLOW: ${{ inputs.sweep_workflow }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
# REST, not `gh` (#205). The workflow-dispatch endpoint has the SAME
# shape on both forges —
# POST {api}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches
# {"ref": "<branch>", "inputs": {...}} -> 204, empty body
# — so this step no longer decides a forge at all. That is why the
# `CEREMONY_FORGE_CLIENT: gh` declaration and both inline refusals are
# gone rather than ported: there is nothing left to refuse. Measured
# on this instance (Forgejo 8.0.3+gitea-1.22.0) and published in its
# own swagger; run 459 was raised this way.
#
# STILL LOUD on failure, per this job's contract: a consumer missing
# the sweep caller, its `bootstrap` input, or `actions: write` must
# fail HERE and visibly, not sweep silently never again.
# NEVER "probably github" (lib/forge.sh). Defaulting an unset
# GITHUB_API_URL to api.github.com would send this forge's dispatch
# to GitHub and report success — the same unset-environment guess
# #201 just refused for docs-sync. The API root is injected by the
# forge running us; if it is absent we do not know where we are, and
# a guess is worse than a red trigger
# (@codex-reviewer-andresmgsl, #205 review).
api="${GITHUB_API_URL:-}"
if [ -z "$api" ]; then
echo "::error::labels: the sweep was NOT woken — GITHUB_API_URL is unset, so the forge's API root is unknown. Refusing to guess a forge."
exit 1
fi
# `gh workflow run` defaulted the ref to the repository's default
# branch; REST has no default and 400s without one. Prefer the event
# payload, fall back to an API read: on a `pull_request_target` run
# GITHUB_REF_NAME is `<n>/merge`, which is not a branch and would
# dispatch nothing.
branch="${DEFAULT_BRANCH:-}"
if [ -z "$branch" ]; then
branch="$(curl -fsS -H "Authorization: Bearer $GITHUB_TOKEN" \
"$api/repos/$GITHUB_REPOSITORY" | jq -r '.default_branch // empty')"
fi
if [ -z "$branch" ]; then
echo "::error::labels: the sweep was NOT woken — could not determine the default branch to dispatch $SWEEP_WORKFLOW on."
exit 1
fi
out="$(mktemp)"
err="$(mktemp)"
trap 'rm -f "$out" "$err"' EXIT
# A transport failure is named, not merely propagated. Letting `set
# -e` carry curl's own exit code out of the assignment DID fail the
# job — the invariant holds — but it failed with a bare status and no
# sentence, which is the opposite of this step owning its diagnostic.
if ! code="$(curl -sS -o "$out" -w '%{http_code}' -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg ref "$branch" '{ref: $ref, inputs: {bootstrap: "no"}}')" \
"$api/repos/$GITHUB_REPOSITORY/actions/workflows/$SWEEP_WORKFLOW/dispatches" \
2>"$err")"; then
echo "::error::labels: the sweep was NOT woken — the request to $api never completed: $(tr -d '\n' <"$err")"
exit 1
fi
if [ "$code" != "204" ]; then
# Own the diagnostic rather than pass the status through. This
# Forgejo answers an unknown workflow name — and a bare ref that
# does not resolve — with `500` and an EMPTY body, so the raw
# status alone sends the reader looking for a server fault that is
# not there.
echo "::error::labels: the sweep was NOT woken — POST $api/repos/$GITHUB_REPOSITORY/actions/workflows/$SWEEP_WORKFLOW/dispatches (ref=$branch) returned HTTP $code: $(tr -d '\n' <"$out")"
echo "::error::labels: check that $SWEEP_WORKFLOW exists on $branch, declares a \`bootstrap\` workflow_dispatch input, and that this caller grants \`actions: write\`. An empty 500 body from Forgejo means the workflow name or the ref did not resolve."
exit 1
fi
echo "labels: sweep dispatched — $SWEEP_WORKFLOW on $branch (bootstrap=no)"
fork_head:
# This Forgejo keeps pull_request_target read-only for fork heads (#241),
# so name the deliberately unsupported scope write as well as the deferred
# state machine instead of letting a green no-op promise full labelling.
if: >-
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
steps:
- name: explain deferred fork labels
run: >-
echo "labels: fork head has a read-only token; state, blocker, and handoff reconciliation deferred to the scheduled sweep; path-derived scope labels are not applied to fork heads"
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 }}

View file

@ -1,34 +0,0 @@
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 portable (#199): its gather is two REST reads through the
# shim plus this repo's own closing-keyword parser, so it produces a real
# verdict on either forge. It still refuses by name when it cannot read —
# that is its contract, and its contract test.
#
# #199 removed the forge gate that used to sit here. While the action's
# only gather was GraphQL it could do nothing but refuse on Forgejo, and
# scheduling a permanently red required check would have blocked every
# merge on this forge; a skipped check is a green head, an invented
# verdict is not. The gather is REST now, so the job RUNS here and
# produces verdicts again.
#
# Deleting the action's client declaration without deleting this gate
# would have left it portable and never scheduled — a guard that passes
# by never running, which is this repo's blind-sweep shape wearing a
# different hat (@kimi-reviewer-andresmgsl, #198).
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./actions/refs-not-closing

View file

@ -94,25 +94,12 @@ jobs:
# consumes the tool end to end instead of hand-writing its output.
# Same shape as test/release-chain.test.sh. The gh stub answers the
# one API fact the ceremony path consults (the merged
# release-labeled PR) so nothing here talks to a forge.
#
# The stub is gh-shaped, so the facts step below pins
# CEREMONY_FORGE=github: since #191 facts.sh selects a backend, and
# on a Forgejo runner it would otherwise pick the forgejo backend,
# which speaks curl and would walk straight past this stub to the
# real instance — reading the exercise's fixture SHA against the
# live repository and refusing it. The exercise rehearses the
# WIRING; which backend answers is lib/forge.sh's own contract,
# covered in test/forge*.test.sh.
# release-labeled PR) so nothing here talks to GitHub.
run: |
mkdir -p "$RUNNER_TEMP/stub"
cat > "$RUNNER_TEMP/stub/gh" <<'EOF'
#!/usr/bin/env bash
# The label read is GET commits/{sha}/pulls — a JSON array (#191).
if [ "$1" = api ]; then
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
exit 0
fi
if [ "$1" = api ]; then echo true; exit 0; fi
echo "gh stub: unexpected call: gh $*" >&2
exit 97
EOF
@ -132,7 +119,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 (#42).\n' > changelog.d/42.md
printf -- '- The entry this release ships.\n' > changelog.d/42.md
git add VERSION CHANGELOG.md changelog.d
git commit -qm "base"
printf '0.7.0\n' > VERSION
@ -149,9 +136,6 @@ jobs:
# back to the merge commit's first parent (#1 constraint 10).
EVENT_BEFORE: ""
VERSION_SOURCE: file
# The stub above is gh-shaped; pin the backend that uses it.
CEREMONY_FORGE: github
GITHUB_REPOSITORY: fixture/fixture
# release.yml's step verbatim — same invocation, same
# $GITHUB_OUTPUT plumbing — cwd'd at the fixture instead of the
# workspace (the one thing a replay cannot inherit).

View file

@ -54,7 +54,7 @@ name: release
# branches: [main]
# permissions:
# contents: write # tag ref create + release create + the bump push
# pull-requests: write # the label read; the bump-fallback PR
# pull-requests: write # the label read; the bump-fallback `gh pr create`
# issues: write # --label on that fallback PR rides the issues API
# jobs:
# release:
@ -87,7 +87,7 @@ name: release
# ## The artifact hook (#1 D4)
#
# If the consumer carries .github/actions/release-artifact/action.yml, both
# doors invoke it — after the tag exists, before the publish — with
# doors invoke it — after the tag exists, before `gh release create` — with
# `version` as input and RELEASE_ASSETS_DIR exported; every file the hook
# drops there is uploaded as a release asset. Exit non-zero to abort the
# release. No hook → no assets.
@ -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.6.3"
CEREMONY_SELF_REF: "0.4.0"
VERSION_SOURCE: ${{ inputs.version-source }}
jobs:
@ -205,40 +205,24 @@ jobs:
fi
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: preflight — resume this merge, refuse every other collision
id: preflight
- name: nothing may exist yet — no tag, no release (re-runs refuse loudly)
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }}
MERGE_SHA: ${{ github.sha }}
# The pure table in lib/preflight.sh distinguishes a stranded run of
# this door from a completed release or a tag at another commit (#273).
# What makes a re-run of a completed ceremony refuse instead of
# clobber, and what catches a manual tag racing the merge.
run: |
tag_read_rc=0
tag_refs="$(git ls-remote --exit-code origin "refs/tags/$VER" "refs/tags/$VER^{}")" || tag_read_rc=$?
case "$tag_read_rc" in
0) tag_shas="$(printf '%s\n' "$tag_refs" | awk 'NF { print $1 }')" ;;
2) tag_shas="" ;;
*)
echo "could not read tag '$VER' from origin (git ls-remote exit $tag_read_rc) — refusing rather than assuming it does not exist." >&2
exit 1
;;
esac
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
if ! released="$(forge_release_exists "$VER")"; then
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
if git ls-remote --exit-code origin "refs/tags/$VER" >/dev/null 2>&1; then
echo "tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing." >&2
exit 1
fi
if gh release view "$VER" -R "$GITHUB_REPOSITORY" --json name >/dev/null 2>&1; then
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
exit 1
fi
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/preflight.sh"
out="$(TAG_SHAS="$tag_shas" RELEASED="$released" release_preflight)"
printf '%s\n' "$out"
printf '%s\n' "$out" | grep '^resume=' >> "$GITHUB_OUTPUT"
- name: tag the merge commit — same job as the publish, on purpose
if: steps.decide.outputs.ceremony == 'yes' && steps.preflight.outputs.resume != 'yes'
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }}
@ -247,10 +231,8 @@ jobs:
# the tag door cannot double-fire off this tag — and this job is
# the only chance to publish (the sources' central comment).
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
forge_tag_create "$VER" "$MERGE_SHA"
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
-f "ref=refs/tags/$VER" -f "sha=$MERGE_SHA"
- name: artifact hook — the consumer's own release-artifact action
# Runs after the tag exists, before the publish (#1 D4). The local
# path resolves in the consumer checkout at the workspace root —
@ -271,10 +253,9 @@ jobs:
for f in "$RELEASE_ASSETS_DIR"/*; do
if [ -e "$f" ]; then assets+=("$f"); fi
done
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}"
gh release create "$VER" --verify-tag --title "$VER" \
--notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \
"${assets[@]}"
# The post-release bump, folded into the release act (the sources'
# operator decision: a mechanical one-liner deserves no PR of its
# own). X.Y.(Z+1)-dev is arithmetic, not judgment (version_next_dev
@ -313,13 +294,10 @@ jobs:
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
git checkout -b "chore/bump-$next"
git push origin "chore/bump-$next"
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
forge_pr_create "chore/bump-$next" main \
"chore: bump main to $next" \
"The post-release re-arm, opened by release.yml because the direct push was refused. One version bump, nothing else — never leave main armed to impersonate $VER." \
release
gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \
--title "chore: bump main to $next" \
--body "The post-release re-arm, opened by release.yml because the direct push was refused. One version bump, nothing else — never leave main armed to impersonate $VER." \
--label release
fi
release-on-tag:
@ -372,22 +350,6 @@ jobs:
fi
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: no published release may exist
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.assert.outputs.ver }}
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
if ! exists="$(forge_release_exists "$VER")"; then
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
exit 1
fi
if [ "$exists" = yes ]; then
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
exit 1
fi
- name: artifact hook — the consumer's own release-artifact action
# After the tag exists (it fired this door), before the publish —
# the same contract as the merge door's twin step.
@ -404,7 +366,6 @@ jobs:
for f in "$RELEASE_ASSETS_DIR"/*; do
if [ -e "$f" ]; then assets+=("$f"); fi
done
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}"
gh release create "$VER" --verify-tag --title "$VER" \
--notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \
"${assets[@]}"

View file

@ -1,58 +0,0 @@
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 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, plus every state, blocker, and handoff transition for a
# fork-headed PR on this Forgejo because its pull_request_target token is
# read-only (#241). The sweep never applies path-derived scope labels. Issue
# events and same-repository PR events carry the rest in seconds, one
# trigger-job dispatch away. Hourly trades ≤1h of latency on the scheduled
# classes 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 issue and same-repository PR 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
with:
# The dispatch input crosses the workflow_call boundary HERE, or not
# at all: on this instance the called workflow did not see this
# caller's event inputs implicitly (ceremony#215), so declare-and-pass
# is the only channel used. On `schedule` the top-level context is
# empty, and empty maps to "no" EXPLICITLY — a cron that bootstraps is
# the failure kimi named before it could exist.
bootstrap: ${{ inputs.bootstrap || 'no' }}

View file

@ -4,17 +4,22 @@ 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. Issue events and
# same-repository PR events below still yield a sweep within seconds, one
# dispatch hop later. Fork-headed PRs carry a read-only token on this Forgejo,
# so their successful labels run leaves state, blocker, and handoff
# reconciliation to the hourly sweep; path-derived scope labels are not
# applied to those heads (#241).
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
@ -30,28 +35,21 @@ on:
issues:
types: [opened, closed, edited, reopened]
pull_request_target:
# These carry the head/draft/review facts the sweep derives state:* from.
# Same-repository heads wake that sweep in seconds; fork heads cannot write
# with this Forgejo's read-only token, so state, blocker, and handoff
# reconciliation waits for the scheduled cadence. The sweep does not apply
# path-derived scope labels to those heads (#241).
# labeled/unlabeled are the same-repository handoff wake — the author's
# optimistic state:needs-human write, confirmed or corrected here in
# seconds (#11); synchronize re-derives on every push; review_requested/
# review_request_removed clear (or restore) blocker:unrequested on that
# same instant path (#137).
# Every PR arrives from a fork, so these carry the head/draft/review facts
# the sweep derives state:* from. labeled/unlabeled are the handoff wake —
# the author's optimistic state:needs-human write, confirmed or corrected
# here in seconds (#11); synchronize re-derives on every push;
# review_requested/review_request_removed wake the sweep that clears (or
# restores) blocker:unrequested — without them the one event that makes
# the label false could not clear it, and a quiet repo wore the red flag
# until the advisory cron (#137).
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
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 dispatch of the sweep caller (#209, #205)
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

@ -1,7 +0,0 @@
# The upstream commit this tree carries (docs/UPSTREAM-SYNC.md).
# Full 40-char SHA, immutable: captured at fetch, merged, then recorded —
# NOT re-read from gh/main later, which moves. Read by
# test/upstream-delta.test.sh, which REFUSES when the object is absent
# rather than calling it unverifiable.
# github.com/heavy-duty/ceremony
8c3a4d1dee2bdb5ac06a632a285bb65ab2615214

View file

@ -14,7 +14,7 @@ reviewer here"). That one word is your whole onboarding:
| you are the… | read | your job in one line |
|---|---|---|
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn proposals into buildable work issues — or refuse well; you are the only door work issues come through |
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
@ -23,7 +23,7 @@ are the shared state machine, and misusing one lies to every other agent on
the board.
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
asked to implement an issue → builder; asked to process proposals or the
asked to implement an issue → builder; asked to process discussions or the
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
across roles in one session: a builder reviewing its own PR, or a reviewer
pushing fixes, breaks the separation the pipeline depends on.
@ -31,13 +31,13 @@ pushing fixes, breaks the separation the pipeline depends on.
## The pipeline you are part of
```
proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
```
Two rules bind every role:
- **Only triage mints work issues.** Found work? File or extend a proposal.
- **Only triage mints issues.** Found work? Open or extend a discussion.
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
a merge button.

View file

@ -6,249 +6,292 @@ triage bug, and the move is to say so on the issue, not to guess.
## Picking
- 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. **An operator-owned remainder parks the claim and
never the handoff**: this shape is reached only from the far side of
shape 4, because it is the state finishing the work puts you in and
would otherwise excuse the handoff it should follow (#336);
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).
- 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).
## Claiming
- Assign yourself, swap `ready``claimed`, and comment that you are
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`.
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`.
## Building
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
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.
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.
- 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 go to a proposal, or a comment on the relevant issue;
you do not mint work issues — nobody but triage does — and you do not fix
drive-by findings in the same PR.
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.
## The review round
(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.)
(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.)
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 — the wait is the **request's**, and never a
reason to withhold the declaration that a round was answered (step 2).
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.
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.
2. **Wait for every verdict, then answer the round whole** — one reply
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. **Where an engine mediates the request, that precondition binds
the engine's act and not yours**: declaring a round answered is not
requesting the panel, so declare it as soon as the round's fixes are
pushed and stop. The engine holds the request while the head is pending
or red, so an early declaration cannot produce an early request while a
withheld one is indistinguishable from a session that died (#330).
**Never wait on an event you have no wake for** — where the engine is
what observes the check settling, the wait is the engine's to keep
(#330). **Never block on a producer you cannot prove alive either**:
where a job signals its own completion, that signal is the wake and the
finished output is read afterwards, because a follow on a file nothing is
writing cannot tell *not yet* from *never* (#336). Prefer verification
over argument — add the test that settles the doubt.
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.
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, 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 — again the request's wait, not the
declaration's — and `blocker:unrequested` does not fire while a head's
checks are pending or red (#236).
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)).
## 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 PR's accountable flag-setter and consolidates the decision into one
comment rather than forwarding several reviewers' phrasings (#50 D11).
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)).
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):
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)):
```text
🧭 needs-ruling — <the decision, one line>
@ -262,48 +305,53 @@ 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 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).
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)).
The ladder is anchored to the current episode's `needs-ruling` **`labeled`
event**, not its `Default:` deadline or the last activity (#50 D13D14):
event**, not its `Default:` deadline or the last activity
([#50 D13D14](https://github.com/heavy-duty/ceremony/issues/50)):
- **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;
- **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;
the human still gates the merge.
- **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
- **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
merge.
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)).
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)).
## Handoff
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:
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:
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: 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.
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.

View file

@ -5,817 +5,10 @@ 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 `0.6.1` through `0.6.3`
were adopted by port rather than merge. 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.3 — 2026-08-26
### Changed
- The shipped 0.6.2 changelog section now carries #238's entry, which its release PR's merge base could not see; the published 0.6.2 release body is left as tagged, so tree and publication differ by that one line (#238, #231).
- Replace the unavailable intake rule with a proposal form that triage converts into work or refuses with reasons (#247).
### Fixed
- The `needs-triage` label now directs untriaged issues toward work normalization or a reasoned refusal instead of an unavailable discussion path (#265).
- Release checks now refuse a target-head fragment that the candidate did not consume, preventing late merges from misattributing shipped changes (#253).
- Kept drill doctrine's release-path instructions aligned with the executable manifest by removing its duplicate path list (#251).
- Preserve Forgejo workflow names in status rollups so the label reconciler excludes only its own checks. (#243).
- Fork-headed label runs stay green without attempting forbidden writes, while same-repository heads keep instant scope and reconciliation wakes (#241).
- Read Forgejo timelines to exhaustion so busy issues retain their newest label events despite dishonest total-count headers (#240).
- Refs-based issue-flow transitions now bind each declaration to its immediately following reference token, so later issue prose cannot release or preserve unrelated claims (#234).
## 0.6.2 — 2026-08-24
### Changed
- `upstream-0.6.1` was ported in #229: CONTRIBUTING routes the vendored set
through `docs/VENDORED.txt` — upstream#316 and upstream#311; BUILDER.md
scopes the green-check precondition to its act, upstream#330; RELEASES.md
adds the post-merge gate-member split, upstream#329 (#246).
- `upstream-0.6.2` was ported in #229: BUILDER.md orders parked claims so an
operator-owned remainder parks the claim, never the handoff — upstream#336
(#246).
- `upstream-0.6.3` was ported in #230: release windows read `## Members` with
no gate fallback, carriers leave their own gates, stale board flags stay
silent — upstream#343 and upstream#327 — and the parser accepts CommonMark
rows (#246).
- Upstream logic was ported onto this forge's Forgejo-adapted issue-flow
reconciler, test, and CONTRIBUTING; those files were never overwritten with
upstream bytes (#246).
- Upstream's drill-record fixes and the upstream `0.7.0``0.7.4` line are
deferred to the next sync campaign (#246).
- No upstream ancestry moves in this release: `.upstream-ref` remains
`8c3a4d1` from upstream `0.6.0`, merged by #198; `upstream-0.6.3` is the
content baseline, not a merge-base (#246).
- Release windows now read membership from a dedicated `## Members` record, with CommonMark-bounded rows and no fallback to predecessor gates (#230).
- Forge consumers now receive the upstream 0.6.1 and 0.6.2 doctrine for vendored-set routing, review-round signalling, operator-owned remainders, producer liveness, and post-merge release edges (#229).
- `docs/RUNNER-PROBES.md` records the delivered 0.6.1 consumer exercise in the standing Forgejo runner venue (#217).
### Fixed
- Review-round state now reads each forge's live review-request set directly, so stale Forgejo approvals no longer hand an in-progress fix round back to the panel (#238).
- Forgejo drafts and fast-forward conflict-check windows no longer surface as merge conflicts when the API has not distinguished one (#236).
- Forgejo review requests no longer count as verdicts, while its blocking and comment states now grade like their GitHub equivalents (#235).
## 0.6.1 — 2026-08-09
### Added
- `test/labels-bootstrap.test.sh` pins the bridge at every hop: the declared
boundary, both gate sites as the identity, no expression reading
`github.event.inputs`, and the caller and stub pass-throughs
byte-exact (#215).
- The same test drives the four value paths — schedule-empty, `no`, `yes`,
invalid — through the shipped expressions into the action's real
validator (#215).
- The taxonomy bootstrap keys on the `BOOTSTRAP` input, never the event name.
It tested `GITHUB_EVENT_NAME = workflow_dispatch` — correct while an
operator's manual dispatch was the only dispatch there was, inert-by-
construction from #209 on, when every machine wake became a dispatch
event (#215).
- The venue drill caught that: with the bridge delivering `no` perfectly,
drill runs 16/17 still bootstrapped, because the script never read the
input the whole chain existed to deliver (#215).
- `test/labels-reconcile.test.sh` pins the regression pair exactly: a
`workflow_dispatch` event with `BOOTSTRAP=no` (or unset) creates and
deletes nothing; only `BOOTSTRAP=yes` bootstraps (#215).
- A gather-level case drives the real board read against a Forgejo-shaped
fixture — every entry carrying the key. The existing discriminator cases
assert `jq` expressions in isolation and passed throughout this regression
(#210).
- A source pin forbids `has("pull_request")` on this surface, because the rule
was stated in a comment and violated forty lines below it. It strips comments,
so the #188 warning that explains the trap is allowed to stay (#210).
- All three sites are covered behaviourally, not only by the pin: the board
gather, the release-body gather through an observable window flag, and the
per-issue payload check (#210).
- `test/forge-backends.test.sh` pins each backend's path **and** field, because
a stubbed `forge_api` cannot catch a wrong path — which is how this shipped
and why a live sweep was what found it (#209).
- `test/labels-dispatch.test.sh` extracts the shipped step and executes it
against a recording stub, asserting the method, endpoint, ref and
`inputs.bootstrap` actually sent (#205).
- That test also drives the failure path: any non-204 still fails the job, so
the misconfiguration alarm the trigger exists to be cannot decay into a
warning (#205).
- An unset `GITHUB_API_URL` refuses before any request instead of defaulting
to `api.github.com`. Guessing sent this forge's dispatch to GitHub and
reported success — the "Never 'probably github'" rule, and the same
unset-environment refusal #201 established for docs-sync (#205).
- A dispatch that never reaches the forge names the failure. Letting `set -e`
carry curl's exit code out did fail the job, but with a bare status and no
sentence (#205).
- `docs/CONSUMERS.md` and both caller comments describe the REST dispatch, and
the manual bootstrap command carries a forge-neutral form beside the `gh`
one — a cross-forge runbook that directs this forge to a missing binary is
wrong even where the surrounding prose is right (#205).
- `docs/RUNNER-PROBES.md` documents the standing runner-probe venue,
`heavy-duty/ceremony-runner-probe` — the place runner-only facts are measured
on demand, ruled as option A by the operator (#202).
- `drills/README.md` cross-links it beside the disposal rule, so the exception
is visible where the dangerous habit lives (#202).
- The runbook states that the drill disposal rule does **not** apply to it.
Archiving it defeats its purpose, and that is exactly how the three existing
drill repos each became unavailable (#202).
- It records that a probe must run as an Actions job under the workflow token:
the same call answers 500 there and 204 under a PAT, so a probe run any other
way produces a confident wrong answer (#202).
- Creating the repo is recorded as the operator's step, measured rather than
assumed: a fleet identity gets 403 on org repo creation and 201 in its own
namespace (#202).
- It carries an executable two-layer arming procedure: an immutable candidate
code SHA and an armed workflow commit on top of it. A single layer is
self-referential — rewriting a workflow makes a new commit, and a commit
cannot contain its own object ID (#202).
- Callers are pinned by layer: composite actions to the candidate code SHA,
reusable workflows to the armed SHA, which is the only revision whose inner
checkout points at the fork (#202).
- The arming gate asserts what each carrier IS, not only that the old literal
is gone: every `repository:` equals the fork, every `CEREMONY_SELF_REF` value
equal the candidate code SHA, and callers match the layer they belong to
(#202).
- It enumerates the carriers from the tree rather than encoding a count, and
distinguishes ceremony's internal self-checkouts from the consumer checkouts
that must stay `${{ github.repository }}` (#202).
- Both published snippets are ShellCheck-clean when extracted and linted
directly, not merely as part of the repository sweep (#202).
- The checker validates the MANIFEST against the target it was given, so a
manifest that describes a wrong arming consistently — wrong fork, or the
armed SHA where the candidate belongs — refuses instead of matching a tree
rewritten to the same wrong value (#202).
- The manifest is generated from the PRE-arming tree, which is the only order
that enumerates the carriers that must change (#202).
- Both published snippets were driven against a constructed candidate/probe
pair: deletion, both role swaps, wrong owner, wrong
SHA, wrong path, a deleted caller class and an extra carrier all refuse, and
the armed control passes (#202).
- The manifest records complete caller coordinates, so a path swapped under the
right owner and SHA is caught (#202).
- Generator and checker share one domain — ceremony callers — so a third-party
`actions/checkout` is neither manifested nor reported as unrecognised (#202).
- Probe results are written to an issue in the probe repo and carried to the
ceremony issue by a human, so the probe holds no path that can write to the
live board (#202).
- `test/docs-sync.test.sh` drives the fetch path, which had no coverage at all:
every existing row passes `--source`, which overrides the fetch entirely
(#201).
- A stubbed `curl` records the requested URL and serves a tarball, so which
forge a pin resolves against is a tested decision rather than plumbing (#201).
- `docs/UPSTREAM-SYNC.md` — the recurring upstream sync as a runbook: the
standing resolutions, which side wins each and the issue that decided it
(#200).
- It names the step the 0.6.0 sync nearly shipped without: auditing what the
merge brought in that did **not** conflict. `git merge` asks no question
about a function upstream added to a file this tree owns (#200).
- It records that the same mechanic applies to state, not just to call sites: a
resolved region can silently remove a producer whose consumers auto-merged,
and every one of those consumers degrades to empty rather than erroring
(#200).
- It says to verify with the runner's tooling, because "green locally" was
wrong three times in one sync — untracked files, a pinned linter, and a
pinned `jq` whose empty-input exit code differs (#200).
- It says every branch open across a sync is stale afterwards — Forgejo never
re-tests an open PR when main moves, so a prior approval is evidence about a
tree that no longer exists (#200).
- It says to audit post-merge runs by executed steps rather than colour, and to
inventory what the sync changed about workflow triggers and jobs first (#200).
- `.upstream-ref` records the upstream commit this tree carries, in
machine-readable form beside the CHANGELOG's prose (#200).
- `test/upstream-delta.test.sh` fails the PR that scatters a forge decision
into a file the inventory does not name. Discovery is derived from the tree,
so a composite `action.yml` or a `.yaml` workflow is seen without anyone
remembering to add a glob (#200).
- Discovery is git's, not the filesystem's: `ls-files`, so the tarballs `ci.yml`
extracts into the checkout and any developer cache are not parsed as source
(#200).
- It refuses when the recorded commit is missing, absent from the object store,
or not an ancestor — three distinct refusals, none of them a skip. `ci.yml`
fetches that exact object so the test reads local evidence without CI
omitting it (#200).
- Its mutation cases drive the real check against a constructed tree, so
replacing the guard with `return 0` reds five of them (#200).
- `docs/CONSUMERS.md` states that two ceremonies answer to the same version
number, and how a consumer says which one it pinned (#200).
- `lib/issue_references.sh` — the LOCAL/CROSS classifier, moved out of
`actions/issueflow-reconcile`'s executable so a second caller can use it
without sourcing a reconciler, which would run one (#199, #61).
- `test/refs-not-closing.test.sh` drives the action's boundary on **both**
backends with stubs at the transport, proving one fixture yields the same
verdict on each — including a closing keyword that appears only in a commit
message (#199).
- 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).
- `test/labels.test.sh` holds the conf's roster and `CONTRIBUTING.md`'s roster
table to the same set, in both directions, so a roster edit that touches one
file and not the other goes red instead of drifting quietly (#195).
- `test/forge-backends.test.sh` pins the replacement contract: preserve
unrelated labels across a combined add+remove, an absent removal as a
successful no-op, the empty set as a full clear, and `forge_labels_add`
still `POST`-only, per ceremony#128 (#192).
- `test/labels-reconcile.test.sh` drives a failing write through `main()` — the
swallow was in the loop, where a fixture-level probe cannot reach (#192).
### Changed
- The review panel restores `kimi-reviewer-andresmgsl` alongside GLM. The
four-identity panel now requires three cross-vendor approvals after the PR
author recuses (#224).
- The review panel names `glm-reviewer-andresmgsl` in place of
`kimi-reviewer-andresmgsl` while that identity is unavailable. The panel
stays three, so a PR still requires two cross-vendor approvals (#222).
- `test/labels.test.sh`'s table-side roster mutation names an identity the
table actually carries. Naming a departed one would mutate nothing and the
case would pass while proving nothing — #195's rot class one layer down
(#222).
- This forge's release line runs `0.4.1 → 0.6.1`: versions 0.5.0 and 0.6.0
arrived here by merge from the read-only upstream and were never released
on this forge (#220).
- The `## 0.6.0` section this changelog carries is upstream's — its entries
describe upstream's work under upstream's issue numbers. The forge port's
own work ships first in 0.6.1 (#220).
- `docs/RUNNER-PROBES.md` records the venue's first delivered drills — the
#192 asymmetry re-observed on demand under the workflow token, the dispatch
route's 204 under both identities, and #215's boundary finding — each with
the probe-issue URL it is recorded in (#202).
- Two venue lessons join the runbook where the next probe author will look:
findings must be written to issues because the venue's log route 404s for
non-admin reads, and report content sent to the forge must never contain a
credential expression or value (#202).
### Fixed
- The sweep's `bootstrap` value crosses the `workflow_call` boundary as a
declared input, explicitly passed by the caller — the one channel measured
to work; the called workflow did not see the caller's event inputs as an
implicit substitute on this instance (#215).
- Before the bridge, `github.event.inputs` was empty inside the called
workflow, so every dispatch-woken sweep bootstrapped: ~20 label upserts on
each board event (#215).
- The caller maps an empty top-level value to `no` explicitly, so a
cron-woken sweep can never bootstrap; the declared input also defaults to
`no`, so a consumer that passes nothing gets the safe path (#215).
- The gate feeds the declared input to `labels-reconcile` unchanged, so an
invalid value meets the action's own `yes|no` refusal instead of being
silently coerced (#215).
- `docs/CONSUMERS.md`'s published sweep stub carries the same pass-through —
without it every consumer inherits the defect ceremony fixed for
itself (#215).
- `issueflow-reconcile` sees this forge's issues again. The board gather used
`has("pull_request")`, and every Forgejo entry carries that key — so it
selected zero rows on every sweep while printing `reconciled.` (#210).
- Three sites take `.pull_request == null`, the discriminator the file's own
comment already specified and that one of its four call sites already used
(#210).
- `post-merge` transitions can fire again: they could not, because the sweep
saw no issues to transition (#210).
- `blocker:unrequested` is judged on this forge again. The head-commit date was
read from `repos/{o}/{r}/commits/{sha}`, which Forgejo answers **404** — so
every sweep degraded and left the blocker unjudged (#209).
- `forge_commit_at` is a verb on both backends: GitHub serves a single commit at
the bare path with the date nested, Forgejo at `git/commits/{sha}` with it
under `.created`. The caller asks for one timestamp and knows neither shape
(#209).
- `.github/workflows/labels.yml` wakes the sweep over REST instead of
`gh workflow run`, so a board event reconciles within seconds on any forge
rather than waiting up to an hour for the scheduled sweep (#205).
- The workflow-dispatch endpoint has the same shape on both forges, so that
step no longer decides one: the `CEREMONY_FORGE_CLIENT=gh` declaration and
both inline refusals are gone rather than ported (#205).
- The dispatch supplies its `ref` explicitly, because REST has no default
branch where `gh workflow run` had one, and refuses without it (#205).
- It takes that ref from the repository, never from `GITHUB_REF_NAME` — on a
`pull_request_target` run that is `<n>/merge`, which is not a branch (#205).
- A failed dispatch names the endpoint, the ref and the status, and says that
an empty `500` body from Forgejo means the workflow name or the ref did not
resolve — a bare status sends the reader after a server fault that is not
there (#205).
- `actions/docs-sync` fetches the doctrine mirror from the forge named by
`GITHUB_SERVER_URL` instead of a hard-coded `github.com` (#201).
- The same pin ref names a different tree on each forge, so a consumer's mirror
was verified against a tree it never pinned — and with HTTP 200, so `--check`
reported drift that could not be fixed (#201).
- A fetch that cannot name its forge now refuses instead of guessing: no
`GITHUB_SERVER_URL` and no `--source` exits naming the variable, having
reached for no network (#201).
- A failed fetch names the URL it actually tried, and asks whether the ref
exists on that forge rather than in the abstract (#201).
- `actions/refs-not-closing` gathers over REST through the forge shim instead
of one GraphQL query, so it produces a real verdict on Forgejo — which
serves no GraphQL surface at all — rather than refusing (#199).
- The closing set is parsed by `lib/closes_references.sh` over the PR body
**and** every commit message, unioned. Forgejo honours closing keywords in
commit messages, so a body-only port would miss a PR that closes an issue
from a commit subject (#199).
- The `hasNextPage` refusal is carried onto the paginated commit read: an
incomplete gather refuses instead of returning a partial verdict, reusing
the backend's `x-total-count` completeness proof (#199).
- A failed read never reaches the parser. An unread body parses to an empty
closing set, which is a passing verdict the action never earned (#199).
- `.github/workflows/refs-guard.yml` no longer gates the job on the forge.
A portable action behind a github-only gate is a guard that passes by never
running (#199, #198).
- The `CEREMONY_FORGE_CLIENT=gh` declaration is gone, and the contract test
asserts its absence: an opt-out with no `gh` behind it is a standing
permission slip (#199).
- 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).
- `.github/labels.conf` names identities that exist on the forge this repo
lives on. All five it named before were absent, so `panel=` could never
converge a review round and `triage-actors=` made every issue a stray mint
nobody could normalize (#195).
- `CONTRIBUTING.md`'s roster table matches the conf: three identities, the
human row is `andres`, and the approval count states what panel-minus-author
actually resolves to on this roster rather than a stale three (#195).
- Label removal on Forgejo is a full-set `PUT`, not a per-label `DELETE`. The
workflow token gets HTTP 500 on every `DELETE .../labels/{id}` on this
instance, so the state machine could only ever ADD labels (#192).
- Every `state:*` transition that needs the previous state cleared, and every
`blocker:*` that should lift, can now actually clear. They were inert (#192).
- A label edit that fails is fatal to `labels-reconcile`, matching
`issueflow-reconcile`. One cause had two contradictory policies (#192).
- A failed write reaches the sweep's exit code: per-PR tolerance is kept for
READS, but a sweep that could not write exits non-zero and its output carries
no `reconciled.` token at all (#192).
- Every label mutation goes through one checked helper, so clearing
`merge-next` or either `stale` edit fails the sweep too — not only the
primary state edit (#192).
- A preserved label keeps the id the issue payload already carried, so
preservation does not depend on a repository-wide list that has nothing to do
with the issue (#192).
- A removal that changes nothing writes nothing, rather than replacing the set
with itself and opening a race for no state change (#192).
- Every failure diagnostic on the forgejo backend names the verb as well as the
path and the status. A read used to say `HTTP 500 from 'repos/…'`, which
cannot be told from a failed write of the same path (#192).
- The diagnostic names what was attempted and that it did not happen, instead
of blaming a missing label and telling the operator to bootstrap — a cause it
had not established (#192, #101).
- An add-label the repo does not carry refuses before any write, so a
replacement `PUT` can never drop a label nobody asked to remove (#192).
## 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
- `forge_release_exists`, `forge_commit_pulls`, `forge_tag_create`,
`forge_release_create` and `forge_pr_create` on both backends, so the
release path names no client (#191).
- The forgejo backend serves one PR object at `/commits/{sha}/pull` where
GitHub serves an array at `/pulls`; both verbs emit the array shape, so
the call site carries one expression (#191).
- Forgejo creates tags at `POST /tags` — it serves `/git/refs` GET-only,
so GitHub's ref-POST would have 404'd there forever (#191).
- `forgejo_api_base` refuses when `REPO` is empty. Every verb interpolates
it and every call reaches the network through there, so `repos//…`
whose 404 reads as "no release" and "no PRs" — is now impossible (#191).
- Release asset names are percent-encoded. The hook contract permits any
filename, and the name travels as a query value: a space made curl reject
the URL and `&`/`#`/`+`/`%` silently renamed the asset (#191).
- `lib/forge.sh` — the forge selector: `forge_detect` names the forge from
the runner's own environment, `forge_client` names the client it needs, and
`forge_preflight` refuses loudly before any sweep when the two disagree
(#188).
- The reconcilers and `labels-scope` run that preflight first, so a
GitHub-shaped client on a Forgejo instance is a named refusal instead of a
sweep that reads nothing and reports success (#188).
- `lib/closes_references.sh` — the closing-keyword parser, sibling of
`refs_references`, so "which issues does this PR close" is answered from a
PR body rather than from GitHub's GraphQL API (#188).
- `lib/forge-github.sh` and `lib/forge-forgejo.sh` — one call surface, two
backends, selected by `forge_select`; no forge branching at the call sites
(#188).
- The forgejo backend proves each paginated gather complete against the
server's `x-total-count` and refuses loudly when it cannot — a missing
header is a refusal, not a pass (#188).
### Changed
- `docs/CONSUMERS.md`'s artifact-hook recovery no longer tells operators to
run `gh release create` by hand — on a Forgejo runner there is no `gh`.
It names the forge-neutral tag-door path first, with both clients shown
(#191).
- `issueflow-reconcile` gathers open and merged PRs over REST instead of
`gh api graphql`. Forgejo serves no GraphQL at all, so the two queries were
replaced rather than translated; both forges return `number` and `body`
from `/pulls` in the same shape (#188).
- `forge_api` owns the page size, because each forge silently ignores the
other's parameter: `per_page=100` reads 30 items on Forgejo and `limit=100`
reads 30 on GitHub, both HTTP 200. No call site names one (#188).
- Outstanding review requests are derived from the reviews on the current head
rather than from `requested_reviewers`, which Forgejo never clears — read
raw there, a PR would sit at `state:bots-reviewing` forever (#188).
### Fixed
- The release doors run on a Forgejo consumer. `lib/facts.sh` and
`release.yml` gathered and published through `gh`, which the runner image
does not ship, so the merge door read `labeled=no` for a correctly
labeled ceremony PR and the tag door died at the publish (#191).
- A release fact that could not be read is no longer reported as a definite
`no`. A completed read finding no label is still `no` and still
fail-closed; a read that did not complete refuses and emits no fact
(#191).
- `labels-scope` no longer fails to compile its jq program on jq 1.6, which
the Forgejo runner image ships: `label` is a reserved word in jq's grammar,
so `$label` is a syntax error there and every scope derivation died before
reading the config (#188).
- `labels-reconcile` and `labels-scope` no longer exit 0 on a Forgejo
consumer having read zero facts — measured on `heavy-duty/rig`, where the
sweep printed `reconciled.` over an empty PR list and scope reported "no
labeler.yml" for a file that exists (#188).
- `forge_timeline` projects Forgejo's label events (`.type` / `.body` /
`.user.login`) into the GitHub shape (`.event` / `.actor.login`) so the
ruling ladder reads the same board on both forges (#188).
- `forge_pr_activity` no longer calls `/pulls/{n}/comments` on Forgejo
(HTTP 404); inline review comments come from reviews with
`comments_count > 0` (#188).
- CI installs shellcheck before linting, matching actionlint — the Forgejo
runner image does not ship it (#188).
## 0.4.0 — 2026-07-29
### Added

View file

@ -10,22 +10,22 @@ the two is a bug.
Work moves through one pipeline, and every stage has an owner:
```
proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
```
- **Proposals are where intent lives.** Anyone — human or agent — who has an
idea, a bug, a question, or a "we should…" files a **proposal**, not a work
issue. Proposals are allowed to be vague; that is what they are for.
- **Work issues are minted only by triage.** Nobody else writes work issues —
not humans, not builders, not reviewers. A work issue is a work order with a quality
- **Discussions are where intent lives.** Anyone — human or agent — who has an
idea, a bug, a question, or a "we should…" opens a **discussion**, not an
issue. Discussions are allowed to be vague; that is what they are for.
- **Issues are minted only by triage.** Nobody else writes issues — not
humans, not builders, not reviewers. An issue is a work order with a quality
bar (the issue contract in [TRIAGE.md](TRIAGE.md)), and the bar holds
because exactly one role is accountable for it. An issue that appears
through any other door gets `needs-triage` and is normalized or converted
back into a proposal.
back into a discussion.
- **Builders turn one issue into one PR.** [BUILDER.md](BUILDER.md).
- **Reviewers converge on a verdict.** [REVIEWER.md](REVIEWER.md).
- **Humans decide twice**: in the proposal (what is worth doing, and any
- **Humans decide twice**: in the discussion (what is worth doing, and any
call triage escalates back) and at the merge (whether it ships). Everything
between those two points is agent work by default.
- **Merging a release PR ships it** — the release ceremony this repo's
@ -41,38 +41,28 @@ only ceremony-specific facts such as the roster and code conventions.
### Roster
Four identities share the work (org team `agents`), each living in its own
Five identities share the work (org team `agents`), each living in its own
[box](https://github.com/heavy-duty/box) — one box per credential, because
the box is the blast-radius boundary; roles are what a session is told, and
[AGENTS.md](AGENTS.md) routes from there:
| identity | box (rig tenant) | standing work |
|---|---|---|
| `claude-bot-andresmgsl` | `triage` (claude-box) | **triage** — the only door work issues come through; this identity mints work issues and nothing else writes them (#18's `triage-actors`) — and review. It does not build. |
| `codex-bot-andresmgsl` | codex-box | build + review |
| `glm-bot-andresmgsl` | glm-box | review |
| `kimi-bot-andresmgsl` | kimi-box | review |
| `dan-claude-bot` | `triage` (claude-box) | **triage** — the only door issues come through; this identity mints issues and nothing else writes them (#18's `triage-actors`) |
| `claude-bot-andresmgsl` | claude-box | build (release-flow and guards machinery) + review |
| `codex-bot-andresmgsl` | codex-box | build (scaffolding, conversions) + review |
| `grok-bot-andresmgsl` | grok-box | review |
| `kimi-bot-andresmgsl` | kimi-box | review — builder trial on a small mechanical issue once its verdicts have a track record |
**The review panel for any PR is every bench identity except its author** —
recusal by construction, enforced by the reconciler (#10): the required
verdicts are the panel minus the PR's author. On this roster that resolves
to **three** cross-vendor approvals of the current head, because the only
builder is itself on the panel and recuses from its own PRs; the rule is
panel-minus-author, and three is what it currently comes to, not a second
rule. Builders and triage default to different models so the issue contract
is honestly exercised — a spec gap should surface as a question on the
issue, not be silently filled by shared priors. Humans (`andres`) decide in
proposals and merge; the roster is config, not doctrine — swapping a
vendor is an edit to this table (and to `panel=` in
`.github/labels.conf` once #10 lands), nothing more.
The identities named here must be the identities `.github/labels.conf`
names, and both must exist on the forge the repo lives on. A roster that
agrees with itself and disagrees with the instance is the failure #195
records: `panel=` naming absent users cannot converge and
`triage-actors=` naming an absent user makes every issue a stray mint that
nobody can normalize. `test/labels.test.sh` holds this table and the conf
to the same set, in both directions.
verdicts are the panel minus the PR's author, so convergence always means
three cross-vendor approvals of the current head. Builders and triage
default to different models so the issue contract is honestly exercised —
a spec gap should surface as a question on the issue, not be silently filled
by shared priors. Humans (`danmt`) decide in discussions and merge; the
roster is config, not doctrine — swapping a vendor is an edit to this table
(and to `panel=` in `.github/labels.conf` once #10 lands), nothing more.
Each governed repo names its own roster in its CONTRIBUTING; this one is
ceremony's. Its `scope:*` set is the same kind of repo-specific fact:
@ -95,35 +85,23 @@ 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 — the set [`docs/VENDORED.txt`](docs/VENDORED.txt)
declares — 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: **machinery by
reference**, fetched at run time from the ref a caller pins, and **doctrine
as a mirror** — the set [`docs/VENDORED.txt`](docs/VENDORED.txt) declares,
vendored at `.ceremony/` and held to the pin by a guard (issue #19). The
[README](README.md) states both modes in full, and why they differ; what
follows is only what they leave a governed repo to carry.
Two consumption modes, split by what has a runtime:
- **Machinery is consumed by reference.** Workflows and actions are fetched
by GitHub at run time from the ref the caller pins — no copy exists in the
consumer.
- **Doctrine is consumed as a machine-verified mirror.** A document's only
"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
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
re-syncing, goes red. It is a copy that cannot drift — which is the only
kind of copy this org allows.
A governed repo (box, rig, cast, incubator, …) therefore carries:
@ -143,8 +121,7 @@ A governed repo (box, rig, cast, incubator, …) therefore carries:
- the **`scope:*` label set** (`.github/labels.conf` + `.github/labeler.yml`),
- the **drill meaning** (`drills/README.md`),
- the repo's own code conventions;
- **An intake door is open**: install the proposal form and `needs-triage`
flow, or use a forge-native intake surface.
- **Discussions enabled**, so the triage door exists.
One pin governs both the machinery and the doctrine: the ref a repo's
workflows call is the ref its `.ceremony/` mirror is verified against.

View file

@ -136,7 +136,7 @@ The earlier form of this file folded handoff and rebase into the other
builder wakes; they are duties of their own.
- **Triage signals**, per registry repo: `needs-triage` issues,
queue-unlabeled strays, proposals without triage's voice, unread
queue-unlabeled strays, discussions without triage's voice, unread
`@`-mentions (their own session), and `blocked` issues whose named blockers
have all landed — a lead the session verifies, never a label the engine
flips. Backlog hygiene (stale claims, label invariants) runs hourly,

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 — 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:building` | `#FBCA04` | the builder — PR is a draft |
| `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 |
@ -27,10 +27,8 @@ and the reconciler recomputes it from GitHub's own facts.
`state:needs-human` means exactly one thing — a human could merge this now —
so it requires zero blockers and head-current approvals; anything less and
the reconciler takes it back. The author sets it at handoff (the one
hand-set state). On a same-repository head, the `labeled` event fires the
sweep that validates the write within seconds; on a fork head whose
`pull_request_target` token is read-only, validation waits for the scheduled
sweep cadence (#241).
hand-set state); the `labeled` event fires the sweep that validates the
write within seconds.
## PR blockers — what is in the way? (facts, as many as apply)
@ -51,7 +49,7 @@ strips it on sight).
| Label | Color | Means | Set by |
|---|---|---|---|
| `needs-triage` | `#FBCA04` | a proposal or stray issue that did not come through triage — it owes normalization into work or a reasoned refusal | anyone who spots one; cleared by triage |
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage |
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
@ -70,16 +68,7 @@ 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. 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.
never reclaims `post-merge`: weeks of quiet can be the state working.
`post-merge` never composes with `blocked`; the transition comment carries the
wait. It never composes with `attention`, because releasing the claim clears
@ -156,11 +145,8 @@ 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
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).
not activity — the clock reads comments, reviews and commits, or the sweep
would reset itself.
`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
@ -185,15 +171,8 @@ 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: 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.
take. It is hand-set doctrine only: nothing in `actions/` sets, clears,
reads, or validates it, and no reconciler enforces the assignee requirement.
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
@ -242,4 +221,4 @@ full-board reconcile. The sweep warns when the core taxonomy declares a label
the repository lacks. The same workflow reconciles issue-flow labels on issue
events and during the scheduled sweep. Default GitHub labels (`duplicate`,
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
deleted at bootstrap — a `question` belongs in a proposal, not a work issue.
deleted at bootstrap — a `question` is a discussion, not an issue.

632
README.md
View file

@ -1,66 +1,15 @@
# ceremony
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.
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.
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.
- **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.
- **Operating a release, or staring at a red run on main** → read on.
## What a release is
@ -74,14 +23,13 @@ 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`
([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
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
[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.
@ -90,82 +38,62 @@ 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-L310) asserts its
way to certainty, tags the merge commit, publishes the forge release with
merge, [release.yml](.github/workflows/release.yml#L136-L300) 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 [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:
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:
**everything asserts its way to certainty and fails loudly, creating
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
([`forge_release_create`](.github/workflows/release.yml#L264-L277))
can fail on the API call or the assets. Either leaves the same state — a tag
standing and no release — which the merge-door preflight recognizes and a
re-run resumes. The tag door remains the fallback when the original run is no
longer reachable or the release must come from a fixed tree. 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.
nothing** — a wrong release is worse than a missing one, so every failed
assert leaves zero artifacts: no tag, no release, no bump.
## The two doors
- **The merge door — the paved road.** A push to main runs the
- **The merge door — the paved road.** A push to main
([release.yml](.github/workflows/release.yml#L140)) 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
([release.yml](.github/workflows/release.yml#L136-L310)). Use it for every
normal release.
half-ceremony dies loudly. 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#L325-L410)) — 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#L302-L369)) — publishes the
same way. The tag is the operator's explicit act, so there is no decide
and no label check — what is left is three asserts: **the tag names the
tree's own version**
([L350L361](.github/workflows/release.yml#L350-L361)), **the tagged
tree carries a publishable `## X.Y.Z` section**
([L362L374](.github/workflows/release.yml#L362-L374)), and **no published
release already exists for the tag**
([L375L390](.github/workflows/release.yml#L375-L390)); any failure
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; 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
[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 (#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
([release.yml](.github/workflows/release.yml#L223-L234), #1 constraint 2).
## What happens when my PR lands on main
@ -173,8 +101,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 by
[test/decide.test.sh](test/decide.test.sh)). Rendered for operators:
block *is* the spec, and the table is contract-tested offline). Rendered
for operators:
| # | the tree your merge produced | the run | what it means — and your move |
|---|---|---|---|
@ -183,11 +111,11 @@ block *is* the spec, and the table is contract-tested offline by
| 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`. **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)). |
| 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`. |
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
@ -195,54 +123,36 @@ refuse (#1 constraint 8).
## The guards
[`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.
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.
### 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
@ -256,30 +166,32 @@ 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 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 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.
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
@ -287,138 +199,132 @@ 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)):
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#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
[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 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)).
**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.
stop guarding is the failure shape this family of checks exists to refuse
([strict mode](actions/changelog-monotonic/changelog-monotonic.sh#L60-L79)).
### drill-recorded — a release carries its evidence
**The rule**
([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).
([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).
**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, 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.
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.
**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 lib/version.sh .github/workflows/release.yml
grep -n -A2 'refuse \|>&2' lib/decide.sh lib/facts.sh .github/workflows/release.yml
```
`$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.
`$VER`-style variables appear as the run interpolates them.
### The decision refused ([lib/decide.sh](lib/decide.sh))
@ -441,11 +347,12 @@ 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))
@ -460,162 +367,89 @@ mistake — read the run's `facts:` 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.
> 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-L310))
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L300))
> 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.
> release '$VER' already exists — this release already happened; refusing to re-release, creating nothing.
> tag '$VER' already exists at <tag sha> but this run would tag <MERGE_SHA> — a manual tag won the race, or it names a different commit; refusing to re-release, creating nothing. Delete that tag, or re-tag the merge commit.
> NOTICE: tag '$VER' already stands at this merge commit and no release exists — a previous run of this door tagged and then failed to publish. Resuming: the tag is not recreated; the artifact hook and the publish run.
> 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.
[L208L239](.github/workflows/release.yml#L208-L239), the merge-door
preflight — the published-release refusal prevents clobbering, the
different-commit refusal diagnoses a racing or manual tag with both SHAs, and
the notice resumes this door after its tag succeeded but the artifact hook or
publish failed. Re-run the merge-door job first. If that run is no longer
reachable or the tree itself needs repair, use the tag-door fallback: delete
and re-push the tag from the fixed tree, or run `forge_release_create` by hand.
[L207L222](.github/workflows/release.yml#L207-L222), 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.
> direct push refused (branch protection?) — opening the bump PR instead
[L302L310](.github/workflows/release.yml#L302-L310) — loud, but not a
[L292L300](.github/workflows/release.yml#L292-L300) — 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 and the
until it lands, main is sitting bare, where a dev install
[impersonates the release](.github/workflows/release.yml#L291) and the
[armed guard's window](#changelog-armed--main-never-sits-disarmed) stays
open.
### The tag door refused ([release.yml](.github/workflows/release.yml#L325-L410))
### The tag door refused ([release.yml](.github/workflows/release.yml#L302-L369))
> 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.
[L356L359](.github/workflows/release.yml#L356-L359). The message is the
[L333L337](.github/workflows/release.yml#L333-L337). The message is the
remedy.
> CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release
[L368L374](.github/workflows/release.yml#L368-L374). The tagged tree was
[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.
> release '$VER' already exists — refusing to re-release, creating nothing.
[L375L390](.github/workflows/release.yml#L375-L390). A published release is
never replaced by the fallback. If it is correct, there is nothing to do; if
it is wrong, correct that published artifact deliberately before retrying.
### The re-arm refused ([release.yml](.github/workflows/release.yml#L276-L310))
The bump belongs to the merge door alone — the tag door deliberately does not
rewrite main ([L325L329](.github/workflows/release.yml#L325-L329)) — 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 ([L275](.github/workflows/release.yml#L275)). 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 ([L302L310](.github/workflows/release.yml#L302-L310)).
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)),
then delete and re-push the tag.
### 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, 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.
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.

View file

@ -1,219 +0,0 @@
# 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.
## The membership record
A release issue's `Blocked by` line answers the predecessor gate above and
nothing else. Which issues are *in* the release is a separate record on the
same issue, and the sweep reads it by heading (#343):
- the heading is literally `## Members`, matched case-insensitively, tolerant
of any run of whitespace between the `##` and the word and of trailing
whitespace after it, and the record runs to the next heading — the same
shape `## Task list` already has;
- one member per list row, under any Markdown list marker and only those:
`-`, `*`, `+`, and 1 to 9 digits followed by `.` or `)` all open a row,
because a row is whatever a reader sees as one — and a tenth digit opens
nothing, CommonMark's ordered marker being at most nine digits, so
`1234567890. #412` is narration and enrols no member. Indentation is bounded
the same way: up to three spaces still open a row, four or more open nothing,
a leading tab counting as four. The record is **flat** — one member per
top-level row — and past that bound a line is not one: standing alone it is
an indented code block, and under a row it is a sub-bullet annotating that
member, and neither is a member itself. Below the bound it enrols, an
indented row being the same bytes as a top-level one. The member is the
row's first token after the list marker and an optional checkbox, and it is
a bare local `#<number>`: `- #253` and `- [ ] #253` both enrol #253.
Everything after that token is prose and contributes nothing, so a row is
free to cite the PR that closed it, a sibling repository, or an issue it
names as explicitly *not* a member;
- a row whose first token is anything else — a qualified `repo#N`, a number
with punctuation attached, or ordinary prose — contributes no member. The
parse stays silent rather than guessing;
- a qualified reference is never a member: a window is one repository's DAG,
decided against one board read;
- a row naming the release issue itself contributes no member. The sink is
never one of its own members;
- **there is no fallback to the gate.** A release issue with no members
section enumerates no membership, is not a standing window, and draws no
window flag. A repository whose epics predate this record gets silence,
never a false flag, until its next release-init writes one.
Why a heading and not a marker phrase: the `Blocked by` parse unions every
occurrence of its marker and runs each clause to a sentence terminator, which
is the right error direction for a `blocked` issue and the wrong one for a
release body that is mostly narration *about* its members. Why the first token
and not every reference in the row: a real member row cites merged PRs, other
repositories and explicit non-members, and reading the whole row enrols all of
them.
The cost is named rather than hidden: a version epic maintains two lists — the
`## Members` record and the `## Task list` progress view — and triage writes
both in the same flip. The purchase is that the progress view stays a progress
view, prose-rich and free to carry several issues in one row or to omit a
member that is not in the build queue, while membership is a machine record
with exactly one shape.
## 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`, and
write the window's membership under `## Members` — release-init is where
that record is first written, and until it exists no window stands.
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 whose membership record
holds at least one open member — 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, in the record above and nowhere else; 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 member. `epic` and `post-merge` issues are exempt
because neither is claimable (#292).
A member that lands `post-merge` releases nothing: that exemption is about
claimability, while a `post-merge` issue is still open and an open predecessor
holds its successors, so every successor declaring on it stays held and the
window stops advancing along that edge (#329).
**When a member reaches `post-merge` and any open declaration names it, triage
splits the remainder**: mint a fresh issue carrying the outstanding criteria
verbatim, naming its owner and its wake condition and citing the original, then
close the original on what it delivered. Triage owns this because only triage
mints work issues and `post-merge` is its completion queue (#329).
**The release edge is the original's close, never the remainder's.** Each
successor's declaration names the original's number, so closing the new issue
releases nothing (#329).
**Split only when an open declaration names the issue.** The trigger is a check
rather than a judgement — run the blocker parse over every open `blocked` body
and see whether this number appears — because an issue that strands nothing is
`post-merge` working as intended (#329).
**Never close work out from under a builder.** Where the original is assigned,
`claimed`, or carrying an open PR, amend its body to hand the outstanding
criteria to the new issue and let its holder close it, so the release edge above
is reached without taking the work from them (#329).
**Do not instead teach the blocker parse that `post-merge` counts as landed.**
That promotes a successor while its predecessor still owes acceptance criteria,
inverts a parser whose deliberate error direction is to hold or flag a reference
it cannot read rather than release it, and needs label data a reference-state
lookup does not carry (#329).
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.
**The same flip adds the member's row to the release issue's membership
record.** That write is not bookkeeping to catch up on later: the record is
the only thing that makes the window stand, so a member flipped `ready`
without a row is, to the sweep, an unblocked non-member — the exact state the
window flag exists to report. Verify the flip by reading the record back and
finding the new member's row in it (#343).
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,12 +34,7 @@ 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). 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 unmet (#151). 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
@ -54,19 +49,6 @@ 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
@ -83,9 +65,7 @@ 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[<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
measured against the target repo's `panel=` roster minus the author. 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
@ -123,9 +103,9 @@ saw Y" outranks one that says "this looks like it might".
## What you do not do
- **Re-litigate the spec.** The issue's decisions were made in triage and,
above it, in a proposal where humans had their say. If you think the
above it, in a discussion where humans had their say. If you think the
spec itself is wrong, say so with reasons — as a comment pointing at the
proposal, while still reviewing the implementation against the spec as
discussion, while still reviewing the implementation against the spec as
written. Spec changes go through triage, not through a review round.
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
human; only humans merge.
@ -141,11 +121,6 @@ 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.

114
TRIAGE.md
View file

@ -1,28 +1,32 @@
# TRIAGE.md — the triage role
You are the only door work issues come through. Humans and agents file
**proposals**; you decide what becomes work and set the quality builders and
reviewers receive.
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.
## Why this door exists
Proposals may be ambiguous; work issues may not: a builder must be able to
execute one **without asking anything**. One accountable role keeps builders
from guessing.
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.
## Your inputs
- **Every open proposal** in the repo you serve.
- **Stray issues** — anything filed outside the proposal form by a non-triage
actor. Label it
- **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 into a proposal and close it, saying why. Route the work
without shaming the filer.
substance back into a discussion and close it, saying why. Do not shame the
filer; do route the work correctly.
## For each proposal, converge on exactly one outcome
## 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 tracked. Link the code, doc, or issue; mark answered.
already shipped or already tracked. Reply with the answer (link the code,
the doc, the existing 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
@ -31,13 +35,14 @@ from guessing.
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). Say precisely what the decision is, name the decider, and use
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). 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).
The proposal is where humans decide; wait there. When the decision
reversible-only default rules ([#50 D12D13](https://github.com/heavy-duty/ceremony/issues/50)).
The discussion is where humans decide; wait there. When the decision
blocks something already on the board — an existing issue, or minted work
a proposal's ruling gates — set `needs-ruling` on it too, so the board
a discussion's ruling gates — set `needs-ruling` on it too, so the board
shows where the human's turn is; the issue keeps its queue label.
When you direct a builder to hold a claim, say the claim is **parked**,
name what it waits on, and set `attention` so the assignee's ack is visible
@ -48,23 +53,26 @@ from guessing.
`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 (#149, #151).
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.
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). 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](https://github.com/heavy-duty/ceremony/issues/50)). 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 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`.
the assignee's next move in prose, set `attention` in the same comment.
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
outcome; a zombie proposal is not.
outcome; a zombie discussion is not.
5. **Accept.** It justifies work → mint the issue(s). The contract below is
the bar.
@ -74,7 +82,7 @@ Every issue you mint carries, in this order:
- **A title that names the deliverable** — "lib/version.sh — one version
abstraction, two backends", never "improve version handling".
- **Context**: why this exists, with links — the proposal it came from,
- **Context**: why this exists, with links — the discussion it came from,
the code it touches (permalinks at a pinned SHA, so line references cannot
rot), prior art in sibling repos.
- **The spec**: decisions made, not options listed. If the spec still has an
@ -85,35 +93,20 @@ 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`; 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.
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.
- **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 a row for the new issue to its membership
record, which records membership only and is the only place the sweep reads
it — a release issue's `Blocked by` line answers its predecessor gate and
never its membership (#292, #343). Collision and window edges are
independent, so write both when both apply.
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
@ -125,14 +118,10 @@ expected.
## Multi-issue work
When an acceptance produces more than one issue, mint an **epic** (`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
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
itself. Keep the checklist current — a stale epic misleads every scan.
Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
## Backlog hygiene
@ -153,8 +142,13 @@ Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
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.** 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).
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.
## What you never do
@ -162,4 +156,4 @@ Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
- Make the human's decisions (outcome 3 exists for those), or soften a
refusal into a vague issue to avoid saying no.
- Mint a work issue to explore an idea — file a proposal instead.
- Mint an issue to "discuss" something — that is a discussion.

View file

@ -1 +1 @@
0.6.4-dev
0.4.1-dev

View file

@ -33,12 +33,6 @@ set -euo pipefail
# no '## ' heading, and changelog_section extracts the body below HEAD's
# heading — so the date HEAD stamped into its heading never enters the
# comparison, and a date difference can never masquerade as a prose one.
#
# This guard narrows, but cannot close, the target-movement window: it sees a
# fragment present when CI reads the target ref, but one can still land after
# the final run and before merge. Requiring release PRs to be up to date with
# their target branch before merge is the repository setting that closes that
# residual window (#253).
base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}"
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
@ -175,37 +169,6 @@ frag_count="$(printf '%s' "$base_frags" | grep -c . || true)"
failures=0
# Refusal: the target branch gained a fragment after this release PR's merge
# base, so the ceremony could not have consumed it. Merging this tree would
# strand that fragment for the next release and misattribute when it shipped.
stranded=""
while IFS= read -r -d '' entry; do
meta="${entry%%$'\t'*}"
path="${entry#*$'\t'}"
otype="$(printf '%s\n' "$meta" | awk '{ print $2 }')"
name="${path##*/}"
case "$otype:$name" in
blob:README.md) ;;
blob:*.md)
if ! printf '%s' "$base_frags" | grep -Fxq "$path"; then
stranded="${stranded} ${path}"$'\n'
fi
;;
esac
done < <(git ls-tree -z "$base_ref" -- "$dir/")
if [ -n "$stranded" ]; then
{
echo "changelog-assembled: fragment(s) on target '$base_ref' were not consumed by this release PR:"
echo
printf '%s' "$stranded"
echo
echo " Merging now would strand these entries for the next release and"
echo " misattribute when they shipped."
echo " Fix: rebase onto the target head and re-run bin/changelog-assemble '$ver'."
} >&2
failures=$((failures + 1))
fi
# Refusal: a fragment the ceremony consumed is still present on HEAD. The
# ceremony deletes exactly what it assembles (#112) — a fragment that
# survives its own release sits in the directory and is assembled AGAIN

View file

@ -148,32 +148,11 @@ if [ -n "$source_dir" ]; then
else
# The repo is public: a plain tarball fetch, no auth, no git. Works for a
# tag, a branch, or a commit SHA alike.
#
# THE FORGE COMES FROM THE ENVIRONMENT, NEVER FROM THIS FILE (#201).
# heavy-duty/ceremony exists on two forges and the same ref names a
# DIFFERENT TREE on each: `0.4.1` on this forge carries lib/forge.sh,
# lib/forge-github.sh and lib/forge-forgejo.sh; GitHub's `0.4.1` carries
# none of them. A hard-coded host therefore verified a consumer's mirror
# against a tree it never pinned — and did it with HTTP 200, so --check
# reported drift the consumer could not fix and --fix would have rewritten
# a correct mirror into the wrong one. The version numbers agreeing is the
# hazard, not the protection (#197 decision 2).
#
# GITHUB_SERVER_URL is what Actions injects on both forges, and
# lib/forge.sh already selects the whole backend on it — so a consumer run
# that reached this line has it. Unset means we do not know which forge the
# pin refers to, and guessing is what this issue is about: refuse instead,
# the same way the pin itself is never guessed.
[ -n "${GITHUB_SERVER_URL:-}" ] || die \
"docs-sync: GITHUB_SERVER_URL is unset, so the forge holding" \
" heavy-duty/ceremony@$ref is unknown — and the same ref names a" \
" different tree on each forge. Set it to the forge this consumer is" \
" pinned against, or pass --source <dir>. This tool never guesses a forge."
fetch_tmp="$(mktemp -d)"
url="${GITHUB_SERVER_URL%/}/heavy-duty/ceremony/archive/${ref}.tar.gz"
url="https://github.com/heavy-duty/ceremony/archive/${ref}.tar.gz"
curl -fsSL "$url" | tar -xz --strip-components=1 -C "$fetch_tmp" || die \
"docs-sync: cannot fetch heavy-duty/ceremony@$ref ($url) —" \
" does the pinned ref exist on that forge?"
" does the pinned ref exist?"
src="$fetch_tmp"
origin="heavy-duty/ceremony@$ref"
fi

File diff suppressed because it is too large Load diff

View file

@ -23,8 +23,7 @@ runs:
BOOTSTRAP: ${{ inputs.bootstrap }}
LABELS_CONF: ${{ github.workspace }}/.github/labels.conf
run: |
# BOOTSTRAP passes through as-is: the script gates on the input. The
# export-the-event-name hack that lived here died with ceremony#215 —
# the script keyed on GITHUB_EVENT_NAME, which #209 made true for
# every machine wake, so "no" could never mean no.
if [ "$BOOTSTRAP" = yes ]; then
export GITHUB_EVENT_NAME=workflow_dispatch
fi
bash "$GITHUB_ACTION_PATH/labels-reconcile.sh"

View file

@ -28,9 +28,8 @@ fi
# stale approval must never promote unreviewed code to the human.
#
# DRY_RUN=1 narrates every mutation instead of performing it (how this script
# is rehearsed against the live repo). A run with BOOTSTRAP=yes also
# bootstraps the taxonomy (label create --force) — the operator's manual
# dispatch defaults the input to yes; every machine wake passes no. The cron
# is rehearsed against the live repo). A workflow_dispatch run also bootstraps
# the taxonomy (label create --force) — that heal is dispatch-only; the cron
# sweep tolerates a missing label rather than recreating it.
#
# The state machine below is pure (globals in, state out) and covered by
@ -38,12 +37,6 @@ 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)
@ -56,36 +49,12 @@ 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' "$*"; }
@ -110,6 +79,25 @@ 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
@ -132,16 +120,8 @@ 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 ] || {
@ -155,7 +135,6 @@ 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
@ -166,55 +145,6 @@ 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"
@ -230,38 +160,15 @@ 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
# "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
case "$line" in 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 ${effective[@]+"${effective[@]}"}; do
for bot in "${BOTS[@]}"; do
[ "$bot" = "$author" ] || REQUIRED_BOTS+=("$bot")
done
}
@ -270,19 +177,45 @@ set_required_bots() { # the PR author is recused by construction
# The state machine. Pure functions over these globals, set per PR:
# DRAFT true|false
# HEAD_SHA the PR's current head commit
# BASE_SHA the PR's base branch head
# MERGE_BASE_SHA the PR's merge base (the release-shape guard's ref)
# BASE_SHA the PR's base branch head (the release-shape guard's ref)
# REQUESTED newline-separated logins with a review currently requested
# REVIEWS_JSON JSON array of submitted, gradeable reviews
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews
# 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"; }
# outstanding_requests <requested-logins> — the portable "who still owes a
# verdict on THIS head" (issue #188, term 4).
#
# GitHub clears requested_reviewers when a verdict lands, so on that forge the
# field already answers this question and the filter below removes nothing.
# **Forgejo does not clear it.** Measured 2026-08-02: rig!140 listed all three
# panelists with all three verdicts in, and rig!146 still lists three while
# MERGED — the field is stale even on a closed PR, so it over-counts forever.
#
# Reading it raw on Forgejo pins a PR at state:bots-reviewing for life and
# stops blocker:unrequested from ever being true: the sweep believes a round
# is permanently live. So the requested set is intersected with "has not
# submitted a verdict for the current head", which is derived from
# /pulls/{n}/reviews — the read that is true on both forges.
#
# Pure over REVIEWS_JSON/HEAD_SHA so the fixtures can drive it; a reviewer
# whose only verdict is STALE still owes one, which is why this asks
# bot_verdict rather than merely "has any review".
outstanding_requests() {
local login
while IFS= read -r login; do
[ -n "$login" ] || continue
case "$(bot_verdict "$login")" in
APPROVE | BLOCK | FEEDBACK) continue ;;
esac
printf '%s\n' "$login"
done <<<"${1-}"
}
checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE
# UNREADABLE is the absence of the key itself, which is what a failed fetch
# leaves behind — distinct from a present-but-empty rollup, which honestly
@ -319,27 +252,7 @@ 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.
#
# 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" '
jq -r '
if (has("statusCheckRollup") | not) then "UNREADABLE" else
# NEUTRAL and SKIPPED satisfy branch protection — a skipped required check
@ -380,11 +293,7 @@ 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 . != ""
@ -416,66 +325,21 @@ bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
if [ -z "$review" ]; then echo MISSING; return; fi
state="$(jq -r '.state' <<<"$review")"
commit="$(jq -r '.commit_id' <<<"$review")"
# This case grades a submitted verdict. The ingestion allow-list answers the
# separate question of whether a row is a submitted review at all (#235).
case "$state" in
CHANGES_REQUESTED | REQUEST_CHANGES)
# blocks at ANY head — both forges' semantic: only a newer review from
# the same reviewer clears it
CHANGES_REQUESTED)
# blocks at ANY head — GitHub's own semantic: only a newer review
# from the same reviewer clears it
echo BLOCK ;;
APPROVED)
if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;;
COMMENTED | COMMENT)
# A comment is a non-verdict. The machine does not read bodies — if the
# comment is really an agreement, the AUTHOR says so by requesting the
# human's review.
echo FEEDBACK ;;
*)
# An unknown state is not evidence that a reviewer answered. Keep the
# round open and make the next forge vocabulary surprise visible (#235).
log "$1: unrecognised review state $state" >&2
echo MISSING ;;
# COMMENTED and anything else: a non-verdict. The machine does not
# read bodies — if the comment is really an agreement, the AUTHOR
# says so by requesting the human's review.
echo 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
@ -511,27 +375,7 @@ 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.
#
# 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
if [ "$DRAFT" != true ] && ! requested "$HUMAN"; then
local b v owed=false any_requested=false
for b in "${REQUIRED_BOTS[@]}"; do
requested "$b" && any_requested=true
@ -542,64 +386,18 @@ blockers() { # → the blocker:* labels this PR should carry, one per line
v="$(bot_verdict "$b")"
case "$v" in MISSING | STALE) owed=true ;; esac
done
# 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
if [ "$owed" = true ] && [ "$any_requested" = false ]; 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
# 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
if [ "$DRAFT" = true ]; 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
@ -702,7 +500,7 @@ round_state() { # → the state the REVIEW ROUND alone implies; knows no branch
core_label_rows() {
cat <<'EOF'
state:building|FBCA04|Pre-round: the builder is still building — draft is evidence for it, not the definition
state:building|FBCA04|PR is a draft — the coding agent is still building
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
@ -716,7 +514,7 @@ offsite|CFD3D7|Issue deliverable is a PR in another repository — claim clock p
needs-ruling|D4C5F9|A human decision is pending — question, options and a recommendation are in the comment
attention|D93F0B|A demand is parked here for the assignee: pick up the thread, ack by removing this label
release|0E8A16|Release flow and version/packaging work
needs-triage|FBCA04|Did not come through triage — owes normalization into work or a reasoned refusal
needs-triage|FBCA04|Did not come through triage — owes normalization or conversion to a discussion
ready|0E8A16|Triaged, spec complete, unblocked — a builder can start now and succeed
claimed|1D76DB|A builder owns it: assignee set, draft PR expected shortly
post-merge|006B75|Refs-linked PR merged; post-merge criteria remain and triage owns completion
@ -724,7 +522,7 @@ epic|5319E7|Organizes other issues via a dependency-ordered task list — builde
EOF
}
retired_label_names() { # the GitHub defaults LABELS.md retires — a `question` belongs in a proposal, not a work issue
retired_label_names() { # the GitHub defaults LABELS.md retires — a `question` is a discussion
# One registry, kept beside core_label_rows() for the same reason those rows
# are not in labels.conf: a rule that must hold in every governed repo
# cannot live in a per-repo file. The six names match LABELS.md exactly.
@ -800,27 +598,6 @@ tree_version() { # $1 = ref → that tree's version via the API, or nothing
return 0
}
# label_write <n> <args…> — every label mutation on this surface goes through
# here (#192). A write that did not happen must reach main's exit code, and the
# first version of this fix marked only the primary state edit: clearing
# `merge-next` and the two `stale` edits could still fail into the generic
# per-PR branch and finish with `reconciled.` and exit 0
# (@codex-reviewer-andresmgsl). One helper means a future call site cannot
# reopen that by forgetting to mark itself.
#
# The marker is a log line rather than a return code because reconcile_pr runs
# in a subshell whose STDOUT main reads — the same channel the degraded-read
# warning already travels on.
label_write() {
local n="$1"
shift
if run forge_issue_edit "$n" "$@" >/dev/null; then
return 0
fi
log "#$n: label edit FAILED — attempted: forge_issue_edit $n $*; the write did not happen (reason on stderr above)"
return 1
}
reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
local n="$1" desired remove s args last_activity last_activity_epoch age
@ -897,21 +674,11 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then
args=(--add-label "$desired${add:+,$add}")
[ -n "$remove" ] && args+=(--remove-label "$remove")
if label_write "$n" "${args[@]}"; then
if run forge_issue_edit "$n" "${args[@]}" >/dev/null; then
log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}"
else
# A WRITE THAT DID NOT HAPPEN IS FATAL, not a warning (#192). This was
# `log WARNING` and fell through, so the sweep printed `reconciled.` and
# exited green over an edit the forge had refused — the
# degraded-write-reports-success class #188 exists to eliminate,
# surviving inside the reconciler that reports it.
#
# The old text also diagnosed a cause it had not established: it named a
# missing label and told the operator to bootstrap, when the label was
# present and the call had returned 500. #101's rule is report, do not
# diagnose — so this says what was attempted and that it did not happen,
# and leaves the backend's own stderr to say why.
return 1
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)"
fi
fi
@ -919,8 +686,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
# Drafts are exempt (the build phase is the builder's); the version
# reads cost two API calls and only on PRs missing the label.
if [ "$DRAFT" != true ] && ! has_label release; then
release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" \
"$(tree_version "${MERGE_BASE_SHA:-$BASE_SHA}")"
release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" "$(tree_version "$BASE_SHA")"
fi
# ---- merge-next: cleared, never set ----------------------------------
@ -931,7 +697,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
# the moment the PR is no longer the thing a human should merge next, the
# claim is removed. Setting it stays with whoever owns the queue.
if has_label merge-next && [ "$desired" != state:needs-human ]; then
label_write "$n" --remove-label merge-next || return 1
run forge_issue_edit "$n" --remove-label merge-next >/dev/null
log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)"
fi
@ -957,11 +723,11 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
# (#50 D10). The 7-day nudge is #52's, once for both surfaces.
if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then
if has_label stale; then
label_write "$n" --remove-label stale || return 1
run forge_issue_edit "$n" --remove-label stale >/dev/null
log "#$n: unstale"
fi
elif ! has_label stale; then
label_write "$n" --add-label stale || return 1
run forge_issue_edit "$n" --add-label stale >/dev/null
log "#$n: stale ($((age / 3600))h quiet)"
fi
@ -973,12 +739,6 @@ 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() {
@ -1002,15 +762,8 @@ main() {
load_config "$LABELS_CONF"
NOW="$(date +%s)"
# The bootstrap keys on the INPUT, never the event name. It used to test
# GITHUB_EVENT_NAME = workflow_dispatch — correct while an operator's manual
# dispatch was the only dispatch there was, and wrong from #209 on, when the
# trigger job made EVERY event-woken sweep a workflow_dispatch run: the
# bootstrap=no input became inert by construction, and every board event
# re-upserted the taxonomy (ceremony#215 — runs 459/523, then venue drill
# runs 16/17, which bootstrapped on a delivered "no" and caught this).
if [ "${BOOTSTRAP:-no}" = yes ]; then
log "bootstrap=yes: bootstrapping the taxonomy"
if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then
log "workflow_dispatch: bootstrapping the taxonomy"
bootstrap_labels
fi
@ -1020,7 +773,7 @@ main() {
[ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered"
missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS"
local n output status total=0 unreadable=0 write_failures=0 sampled_reason=""
local n output status total=0 unreadable=0 sampled_reason=""
while IFS= read -r n; do
[ -n "$n" ] || continue
total=$((total + 1))
@ -1033,21 +786,14 @@ main() {
set_required_bots "$AUTHOR"
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
MERGE_BASE_SHA="$(jq -r '.merge_base // empty' <<<"$PR_JSON")"
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
# This allow-list answers whether a row is a submitted, gradeable review;
# bot_verdict separately answers what that submitted verdict says (#235).
# PENDING drafts and Forgejo REQUEST_REVIEW request rows are not reviews.
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
REVIEWS_JSON="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
| jq -s '[.[] | select(.state == "APPROVED"
or .state == "CHANGES_REQUESTED"
or .state == "REQUEST_CHANGES"
or .state == "COMMENTED"
or .state == "COMMENT")]')"
# Read AFTER the reviews: review_filter_probe captures REVIEWS_JSON at
# this boundary. The request set itself comes from the backend's exact
# live representation rather than being derived from verdicts (#238).
REQUESTED="$(forge_pr_review_requests "$n")"
| jq -s '[.[] | select(.state != "PENDING")]')"
# Read AFTER the reviews, because the raw field is not portable: Forgejo
# never clears it, so it is intersected with who still owes a verdict on
# this head (#188 term 4). A no-op on GitHub, which clears it itself.
REQUESTED="$(outstanding_requests "$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")")"
# mergeability + the check rollup, the two facts the state machine was
# blind to (#136). `gh pr view` rather than the REST PR object: the API's
# `mergeable` is a tri-state boolean that GitHub computes lazily, while
@ -1079,29 +825,6 @@ 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_commit_at "$HEAD_SHA" \
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=$?
@ -1113,33 +836,10 @@ main() {
sampled_reason="$(sed -n "s/^labels: #$n: read failed: //p" <<<"$output" | head -n1)"
fi
elif [ "$status" -ne 0 ]; then
# The per-PR tolerance is right and stays: one bad PR must not blind the
# sweep over the rest of the board. What was missing is the sweep-level
# accounting — a failed WRITE has to reach main's exit code, or a builder
# satisfies every task and the sweep still prints `reconciled.` over an
# edit that never happened (#192, @kimi-reviewer-andresmgsl #5189).
#
# Reads stay tolerated: an unreadable fact is already reported by the
# blind-sweep warning and leaves the board untouched. A write is
# different — the board and the tree now disagree.
if grep -q "^labels: #$n: label edit FAILED" <<<"$output"; then
write_failures=$((write_failures + 1))
log "#$n: reconcile failed on a WRITE — continuing the sweep, but it will not report success"
else
log "#$n: reconcile failed — continuing with the remaining PRs"
fi
fi
done < <(forge_pr_list)
blind_sweep_warning "$unreadable" "$total" "$sampled_reason"
if [ "$write_failures" -gt 0 ]; then
# The line must not contain the literal "reconciled." ANYWHERE — "NOT
# reconciled." still does, and a consumer grepping a job-log tail for that
# token would find it after a write that did not happen
# (@codex-reviewer-andresmgsl). The test asserts the whole output is free
# of it, not merely that the success prefix is absent.
log "$write_failures label write(s) attempted did not happen — sweep incomplete"
return 1
fi
log "reconciled."
}

View file

@ -1,16 +0,0 @@
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

@ -1,124 +0,0 @@
#!/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

View file

@ -1,88 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# The composite action's executable boundary (#218). Keeping the gather here
# lets the offline contract test replace the forge and prove that failed and
# partial reads cannot accidentally produce a green verdict.
#
# THE GATHER IS REST, THROUGH THE SHIM (#199). It was a single GraphQL query
# issued through `gh`, asking GitHub for `closingIssuesReferences` — its own
# parse of the closing keywords. 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 was nothing to translate it to, so it is re-expressed — exactly as
# #188 re-expressed its own two GraphQL sites — over two reads both backends
# already serve, plus a parser this repo owns.
#
# WHAT THE GRAPH GAVE THAT TWO READS MUST REPLACE. This file used to call the
# graph "authoritative because it includes both closing keywords and sidebar
# links". Those two halves resolve differently here:
#
# sidebar links Forgejo has no such concept — an issue is closed by a
# keyword, never by a manual link. Nothing is lost.
# commit messages Forgejo DOES honour closing keywords in commit messages.
# A body-only parse would miss a PR that closes an issue
# from a commit subject and let through exactly the
# contradiction this action exists to catch.
#
# Hence both reads, unioned. The commit half is not optional.
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
# shellcheck source=lib/issue_references.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/issue_references.sh"
# shellcheck source=lib/closes_references.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh"
# No CEREMONY_FORGE_CLIENT declaration any more (#199 removes #198's): this
# file speaks the shim, not a client. Fail CLOSED at the action boundary all
# the same — "this action cannot produce a verdict" is the ACTION's contract
# and stays a refusal, while "this check should not block the board" is the
# CALLER's decision (@codex-reviewer-andresmgsl, #198).
forge_preflight || exit 1
forge_select "" || exit 1
REPO="${REPO:-${GITHUB_REPOSITORY:-}}"
[ -n "$REPO" ] || {
echo "refs-not-closing: set REPO or GITHUB_REPOSITORY to owner/name" >&2
exit 1
}
[ -n "${PR_NUMBER:-}" ] || {
echo "refs-not-closing: pull request number is unavailable" >&2
exit 1
}
body_file="$(mktemp)"
closing_file="$(mktemp)"
trap 'rm -f "$body_file" "$closing_file"' EXIT
# A read that fails must never reach the parser: an empty body parses to an
# empty closing set, which is a PASSING verdict this action never earned.
# `set -e` covers the assignment, and the explicit checks below name which
# read failed rather than leaving the operator to guess.
if ! forge_api "repos/$REPO/pulls/$PR_NUMBER" --jq '.body // ""' >"$body_file"; then
echo "refs-not-closing: could not read PR $PR_NUMBER's body — refusing a verdict" >&2
exit 1
fi
# --paginate carries the completeness proof: the forgejo backend walks pages
# and then compares what it collected against the server's declared
# x-total-count, refusing a short gather (#188, #4699). That IS this action's
# `hasNextPage` refusal, relocated rather than reinvented — upstream refused
# past 100 closing references rather than issue a partial verdict, and an
# incomplete commit read is the same failure wearing REST's clothes.
commits_file="$(mktemp)"
trap 'rm -f "$body_file" "$closing_file" "$commits_file"' EXIT
if ! forge_api --paginate "repos/$REPO/pulls/$PR_NUMBER/commits" \
--jq '.[].commit.message' >"$commits_file"; then
echo "refs-not-closing: could not read PR $PR_NUMBER's commits completely — refusing a partial verdict" >&2
exit 1
fi
# The union. closes_references is line-oriented, so concatenating the body and
# every commit message and parsing once IS the union of parsing each — and it
# keeps one parse to reason about instead of two that could drift.
cat "$body_file" "$commits_file" | closes_references >"$closing_file"
mapfile -t closing_issues <"$closing_file"
bash "$GITHUB_ACTION_PATH/refs-not-closing.sh" \
"$body_file" "${closing_issues[@]}"

56
changelog.d/188.md Normal file
View file

@ -0,0 +1,56 @@
### Added
- `lib/forge.sh` — the forge selector: `forge_detect` names the forge from
the runner's own environment, `forge_client` names the client it needs, and
`forge_preflight` refuses loudly before any sweep when the two disagree
(#188).
- The reconcilers and `labels-scope` run that preflight first, so a
GitHub-shaped client on a Forgejo instance is a named refusal instead of a
sweep that reads nothing and reports success (#188).
- `lib/closes_references.sh` — the closing-keyword parser, sibling of
`refs_references`, so "which issues does this PR close" is answered from a
PR body rather than from GitHub's GraphQL API (#188).
- `lib/forge-github.sh` and `lib/forge-forgejo.sh` — one call surface, two
backends, selected by `forge_select`; no forge branching at the call sites
(#188).
- The forgejo backend proves each paginated gather complete against the
server's `x-total-count` and refuses loudly when it cannot — a missing
header is a refusal, not a pass (#188).
### Changed
- `issueflow-reconcile` gathers open and merged PRs over REST instead of
`gh api graphql`. Forgejo serves no GraphQL at all, so the two queries were
replaced rather than translated; both forges return `number` and `body`
from `/pulls` in the same shape (#188).
- `forge_api` owns the page size, because each forge silently ignores the
other's parameter: `per_page=100` reads 30 items on Forgejo and `limit=100`
reads 30 on GitHub, both HTTP 200. No call site names one (#188).
- Outstanding review requests are derived from the reviews on the current head
rather than from `requested_reviewers`, which Forgejo never clears — read
raw there, a PR would sit at `state:bots-reviewing` forever (#188).
### Fixed
- `labels-scope` no longer fails to compile its jq program on jq 1.6, which
the Forgejo runner image ships: `label` is a reserved word in jq's grammar,
so `$label` is a syntax error there and every scope derivation died before
reading the config (#188).
- `labels-reconcile` and `labels-scope` no longer exit 0 on a Forgejo
consumer having read zero facts — measured on `heavy-duty/rig`, where the
sweep printed `reconciled.` over an empty PR list and scope reported "no
labeler.yml" for a file that exists (#188).
- `forge_timeline` projects Forgejo's label events (`.type` / `.body` /
`.user.login`) into the GitHub shape (`.event` / `.actor.login`) so the
ruling ladder reads the same board on both forges (#188).
- `forge_pr_activity` no longer calls `/pulls/{n}/comments` on Forgejo
(HTTP 404); inline review comments come from reviews with
`comments_count > 0` (#188).
- CI installs shellcheck before linting, matching actionlint — the Forgejo
runner image does not ship it (#188).

View file

@ -1,7 +0,0 @@
### Fixed
- The upstream deferral record now names its floor and a dated measurement instead of a frozen `0.7.4` ceiling that expired before it was written (#269).
### Changed
- `docs/UPSTREAM-SYNC.md` now records unconditionally that the next sync campaign merges rather than ports, and so advances the ancestry baseline (#269).

View file

@ -1,3 +0,0 @@
### Fixed
- Forgejo release publishing now stages drafts until assets upload, rolls back failures, and recovers stranded same-tag drafts before retrying (#271).

View file

@ -1,3 +0,0 @@
### Fixed
- Merge-door release reruns resume after a matching stranded tag while completed or conflicting releases still refuse with precise diagnostics (#273).

View file

@ -1,3 +0,0 @@
### Fixed
- Compare release-shaped pull requests with their merge base so later base-branch releases do not create phantom version-change warnings (#275).

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
sweep caller's `workflow_dispatch`
workflow'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 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
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
refs to adopt it early.
3. **`drills/README.md`** defining what a drill *means* in this repo —
each repo names its own
@ -85,16 +85,14 @@ 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>
# 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.
# 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.
- uses: heavy-duty/ceremony/actions/changelog-assembled@<pinned-tag>
- uses: heavy-duty/ceremony/actions/drill-recorded@<pinned-tag>
# 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.
# 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.
- uses: heavy-duty/ceremony/actions/runner-isolated@<pinned-tag>
```
@ -113,59 +111,20 @@ the machinery at all:
self-hosted runner still wants it: the guard's value is the day
somebody adds one.
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
This guide documents `main`. New machinery is marked **unreleased**
here until a release tag ships it. 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. **`.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`
6. **Labels automation** (optional but recommended): the caller from
[Labels automation](#labels-automation), plus `.github/labels.conf`
(panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the
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/`
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/`
per [The artifact hook](#the-artifact-hook). No hook → the source
tarball is the package.
@ -190,11 +149,8 @@ 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) and add the sweep caller
`labels-sweep.yml` beside it (#209); extract
[Labels automation](#labels-automation); 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
@ -291,9 +247,8 @@ tag door instead (the known first-release edge, cast#111).
### The artifact hook
If the repository contains `.github/actions/release-artifact/action.yml`,
both doors invoke it — after the tag exists, before the release is
published — with the release `version` as input and `RELEASE_ASSETS_DIR`
exported.
both doors invoke it — after the tag exists, before `gh release create`
with the release `version` as input and `RELEASE_ASSETS_DIR` exported.
Contract for hook authors:
- Drop finished files into `$RELEASE_ASSETS_DIR`; every file there is
@ -304,28 +259,9 @@ Contract for hook authors:
A failed hook leaves the tag created but no release published. Recovery is
the tag door's semantics: fix the cause, then delete and re-push the same
tag — the tag door publishes for it. That path is forge-neutral and is the
one to prefer.
If you must publish by hand instead, use whatever your forge provides;
ceremony itself no longer names a client here, because on a Forgejo runner
there is no `gh` to name (#191):
```sh
# GitHub
gh release create "$VER" --verify-tag --title "$VER" \
--notes-file notes.md -R "$OWNER/$REPO"
# Forgejo / Gitea — POST /repos/{owner}/{repo}/releases
curl -sS -X POST -H "Authorization: token $TOKEN" \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg t "$VER" --rawfile b notes.md \
'{tag_name:$t,name:$t,body:$b}')" \
"$FORGE/api/v1/repos/$OWNER/$REPO/releases"
```
The merge door's nothing-exists assert will refuse a re-run of the
completed merge, by design.
tag (the tag door publishes for it), or run `gh release create` by hand from
a fixed tree. The merge door's nothing-exists assert will refuse a re-run of
the completed merge, by design.
No hook → no assets: for a pure-bash tree, GitHub's source tarball for the
tag IS the package. Worked examples land with the conversions: cast's tgz
@ -333,43 +269,15 @@ build (#15) and incubator's GHCR image push (#16).
## Labels automation
The labels automation is two reusable workflows since #209, adopted
together at the same pin:
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`.
- **`labels.yml`** — the event-facing half, called on PR and issue events.
Same-repository PRs keep two write-capable jobs: additive path-based
`scope:*` labels, and a few-seconds `trigger` job that wakes the sweep by
dispatching the consumer's sweep caller (a REST `POST` to the forge's own
`${GITHUB_API_URL}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches`,
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). On this Forgejo a fork-headed `pull_request_target` token is
read-only, so those two jobs do not run. A successful `fork_head` job names
the disposition: the scheduled sweep later reconciles state, blockers, and
handoff, while path-derived `scope:*` labels are not applied to fork heads.
Apply those scope labels manually when an outside contribution needs them.
- **`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. Same-repository PR checks show `scope` and
the green `trigger`; fork-headed PRs show the green `fork_head` disposition
and wait for scheduled state, blocker, and handoff reconciliation. The sweep
does not supply their path-derived scope labels.
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
**Additive means additive** (unreleased — #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
@ -384,20 +292,31 @@ 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 event-facing caller is:
The complete 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:
# These carry the head/draft/review facts state:* derives from. Same-repo
# heads take the instant write + sweep-dispatch path; this Forgejo gives
# fork heads a read-only token, so state, blocker, and handoff reconciliation
# waits for the scheduled sweep; path-derived scope labels require a manual
# write when wanted.
# labeled/unlabeled are the same-repo handoff wake; synchronize re-derives
# on every push. review_requested/review_request_removed shipped in 0.3.0
# (ceremony#137) and wake the same-repo sweep when the panel is asked.
# Fork PRs; these carry the head/draft/review facts state:* derives from.
# labeled/unlabeled are the handoff wake (state:needs-human confirmed here);
# synchronize re-derives on every push. review_requested/review_request_removed
# (shipped in 0.3.0, ceremony#137) wake the sweep that clears
# blocker:unrequested when the panel is asked.
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a
# consumer pinned to 0.1.0 omits this block.
@ -415,95 +334,18 @@ 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 dispatch of the sweep caller (#209, #205)
actions: read # workflow-run nodes inside the check rollup — private repos do not imply it (incubator#60)
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 a review verdict landing (the labels caller has 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, plus every state, blocker, and handoff
# transition for a fork-headed PR on this Forgejo. The sweep never applies
# path-derived scope labels. Issue events and same-repo PR events carry the
# rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of
# latency on the scheduled classes while cutting nominal
# 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 issue and same-repo PR
# 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>
with:
# Pass the dispatch input through the workflow_call boundary
# explicitly — a called workflow must not rely on reading the caller's
# event inputs (ceremony#215 measured that failing). Empty (schedule)
# maps to "no" explicitly, so a cron-woken sweep never re-upserts the
# taxonomy.
bootstrap: ${{ inputs.bootstrap || 'no' }}
# If this repo's PR-facing labels caller is named anything but `labels`,
# pass that name alongside: `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 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 dispatch is a write. Without it, issue and
same-repository PR event runs go red at the trigger. Fork-headed PR runs do
not enter that write path on this Forgejo; they remain green and depend on a
healthy scheduled sweep for state, blocker, and handoff reconciliation. That
sweep does not apply their path-derived scope labels.
**The failure mode to know before bumping**: a consumer that bumps its pin
to a #209-carrying tag without adding the sweep caller gets a loud red trigger
on every issue and same-repository PR event (workflow-not-found; likewise on a
sweep caller missing its `bootstrap` input, or a labels caller missing
`actions: write`). Fork-headed PR runs deliberately skip that trigger and stay
green, so their correctness is proven by the sweep caller's presence and its
latest scheduled run, not by the PR check alone. Never read a green
`fork_head` disposition as evidence that the scheduled sweep exists. Make the
adoption one atomic PR — pin bump, sweep caller file, and `actions: write` line
together.
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.
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
@ -524,63 +366,22 @@ 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 dispatch is a
write. The sweep caller keeps `actions: read`.
Bump without the sweep caller and the trigger job goes red on every issue and
same-repository PR event. Fork-headed PRs stay green, receive state, blocker,
and handoff reconciliation only from the scheduled sweep, and never receive
path-derived scope labels automatically; apply those manually when wanted.
Never split these four edits across PRs.
`pull_request_target` is intentional: same-repository PRs keep the base
repository's write token without executing PR code. This Forgejo still gives
fork-headed `_target` runs a read-only token, so they attempt no writes. The
scheduled sweep later reconciles state, blockers, and handoff; it does not
apply path-derived scope labels to those heads. The reusable workflows check
`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
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
a bare pin bump. `review_requested` and `review_request_removed` on
`pull_request_target:` shipped in `0.3.0` (ceremony#137). It clears
`blocker:unrequested` the moment the panel is asked on a same-repository head;
fork heads wait for the sweep cadence on this Forgejo. A consumer picks the
events up by pinning `0.3.0` or later, never through mixed refs.
`pull_request_target:` shipped in `0.3.0` (ceremony#137) — the wake that
clears `blocker:unrequested` the moment the panel is asked, without which a
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, zero or more optional per-author panel rows, and
then zero or more scope rows:
`triage-actors` setting, 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
@ -592,20 +393,8 @@ 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 work issues without the sweep applying `needs-triage`. Label rows use exactly
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.
**Every account in `panel=` must be able to read the repository.** Requesting a
@ -615,92 +404,41 @@ 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, 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).
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).
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 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:
On GitHub, with the `gh` CLI:
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:
```sh
gh workflow run labels-sweep.yml -R <owner>/<repo>
gh workflow run labels.yml -R <owner>/<repo>
```
On any forge — including Forgejo, whose runners carry no `gh` — the same
dispatch over REST, which is what the trigger job itself sends (#205):
```sh
curl -sS -X POST \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"ref":"main","inputs":{"bootstrap":"yes"}}' \
"$API/repos/<owner>/<repo>/actions/workflows/labels-sweep.yml/dispatches"
```
`$API` is the forge's API root — `https://api.github.com` on GitHub,
`<instance>/api/v1` on Forgejo — and success is `204` with an empty body.
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.
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.
## 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
declared by ceremony's `docs/VENDORED.txt` is vendored into each consumer at **`.ceremony/`**,
(ceremony's `docs/VENDORED.txt`: AGENTS.md, TRIAGE.md, BUILDER.md,
REVIEWER.md, LABELS.md) 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
@ -732,26 +470,6 @@ Bumping the pin re-syncs the mirror in the same PR —
## Version pinning
**Two ceremonies answer to the same version number.** `heavy-duty/ceremony`
exists on GitHub and on `forgejo.heavyduty.builders`, and the forge tree tracks
upstream's version numbers deliberately (ceremony#197 D2) — so `0.6.0` names a
different tree on each, differing by the forge-compatibility delta. They are
not forks that drifted: the forge tree carries upstream's content and adds to
it (`docs/UPSTREAM-SYNC.md`).
What that means for a consumer:
- **Name the forge you pinned, not just the tag.** `heavy-duty/ceremony@0.6.0`
is ambiguous on its own; the host in your `uses:` line is what disambiguates
it, so do not describe your pin anywhere without it.
- **A tag that exists upstream may not exist here yet.** The forge tree's
`CEREMONY_SELF_REF` takes upstream's number as soon as the sync lands, which
is *before* the release ceremony cuts that tag here. Do not bump a pin to a
version whose tag you have not confirmed on the forge you consume from.
- **The forge tree's `CHANGELOG.md` header names the upstream commit it
carries**, and `.upstream-ref` records the same SHA. That is how you tell
which `0.6.0` you are actually running.
- **Pin an exact ceremony release tag**`@0.1.0`, never a branch and
never a moving major pointer: the family pins things and reviews
updates ([#1 D2](https://github.com/heavy-duty/ceremony/issues/1)).
@ -851,16 +569,15 @@ published release.
## Adopting the agent team flow
The team flow (proposal → triage → work issue → build → review → human
The team flow (discussion → triage → issue → build → review → human
merge) is **optional per repo and separable from the release ceremony**:
a repo can adopt release-only and take the team flow later — incubator's
initial posture (#16). The model is this repo's own
[CONTRIBUTING](../CONTRIBUTING.md) ("How the other repos use this");
this is the checklist:
- [ ] **Open the intake door** — install `proposal.yml` and the automatic
`needs-triage` flow. A repo whose forge provides Discussions may keep
them as its intake door and point `config.yml`'s contact link there.
- [ ] **Enable Discussions** — the triage door exists or the pipeline
has no intake.
- [ ] **Vendor the doctrine**: run `docs-sync --fix` (#19) to materialize
`.ceremony/{AGENTS,TRIAGE,BUILDER,REVIEWER,LABELS}.md`
byte-identical to this repo at the pinned ref — plus the generated
@ -894,8 +611,7 @@ this is the checklist:
`workflow_dispatch` once ([above](#labels-automation)), or the hand
commands in [LABELS.md](../LABELS.md).
- [ ] **State the single-writer rule** in the repo's own docs: only
triage mints work issues; anyone may file a proposal, which triage
converts or refuses.
triage mints issues; everyone else opens discussions.
### The pin-bump procedure

View file

@ -1,404 +0,0 @@
# Runner probes
**Not a drill.** A drill rehearses the release doors on a disposable repo and
ends. This is the opposite shape: one **standing** repo that exists so that
runner-only facts can be measured on demand, and it is **never archived**.
`heavy-duty/ceremony-runner-probe` — private, standing, reset between probes.
Ruled by the operator as option (A) of ceremony#202 (#5631).
## Why a standing repo, when drills are disposable
Some facts are only true inside Actions, under the token Actions injects, and
no local harness or PAT can reproduce them. The worked example is ceremony#192:
```
DELETE /issues/{n}/labels/{id} -> 500 under ${{ github.token }} in a workflow
DELETE /issues/{n}/labels/{id} -> 204 under a maintainer PAT, same call
```
A probe that runs anywhere else passes and proves nothing. Before this venue
existed the answer was "un-archive a drill repo", which was requested three
times in two days across two issues and never became anything — the three
drill repos (`ceremony-drill-0.4.1`, `-0.4.1-final`, `-191`) are all archived,
and each was minted for one probe and then wanted again.
## The disposal rule above does NOT apply here
The rehearsal section says the builder archives the scratch repo and the
operator deletes it. **That rule is for drills.** Archiving this repo defeats
its entire purpose, and it is the failure mode the three archived drill repos
demonstrate — each was archived correctly, by the rule, and each then had to be
un-archived or replaced.
So: never archive it, never delete it, and if you find it archived, un-archive
it rather than minting a fourth one.
## Standing it up is the operator's step
Bot identities cannot create repositories in `heavy-duty`. Measured
2026-08-05 with a fleet identity holding the `repo` scope:
```
POST /api/v1/orgs/heavy-duty/repos -> 403 "not allowed to create repository in organization"
POST /api/v1/user/repos -> 201 (personal namespace only)
```
This is the same shape as the drill delete: a deliberate permission boundary,
not a misconfiguration. Do not retry it, and do not work around it by putting
the venue in a personal namespace — **not because a personal namespace is
proven unable to reach the org's runner** (that was not measured; the probe
repository above was deleted immediately, so nothing about runner or secret
reach was established), but because @andres ruled an **org-owned standing
venue** (#5631). A personally-owned repo is a different thing from the one that
was decided on, and cannot satisfy #202's named acceptance target.
If runner or secret reach turns out to matter, measure it once the venue
exists rather than assuming it here.
## Running a probe
1. Reset the repo to a clean state — the probe's own fixtures only, no
leftovers from the last one. A probe that inherits state is a probe whose
result you cannot attribute.
2. **Arm it against the candidate** (below) — two layers, candidate code and
armed workflow — if the probe is about ceremony's own machinery rather than
about a bare API call.
3. **Run it as an Actions job under `${{ github.token }}`.** This is the whole
point of the venue and the one step that cannot be shortcut. A `curl` from a
laptop with a PAT answers a different question — see the 204/500 split
above — and a probe run that way is worse than no probe, because it produces
a confident wrong answer.
4. **The job writes its raw results into an issue in the PROBE repo**
`heavy-duty/ceremony-runner-probe` — not into ceremony. Logs age out;
ceremony#192's run 701 survived only because the job wrote its findings
into an issue it created.
5. **A human then records the probe issue's URL and the Actions run number on
the ceremony issue the probe serves.** That hop is deliberate and is the
whole of the boundary: the probe workflow holds no credential and no code
path that can write to `heavy-duty/ceremony`, so "the probe reports its
findings" and "the probe cannot touch the live board" stay compatible
rather than contradicting each other (@codex-reviewer-andresmgsl, #202
review).
## Arming a candidate ref
A probe that exercises ceremony's own machinery needs the candidate tree
reachable from a `uses:` line. This is the fork-ref shape `drills/README.md`
step 2 points at, written out — and it has **two layers**, which is the part
that is easy to get wrong and impossible to fix afterwards.
**Why two.** The candidate's own workflows contain
`repository: heavy-duty/ceremony` beside `ref: ${{ env.CEREMONY_SELF_REF }}`,
so they must be rewritten to point at the fork and at the candidate. But
rewriting them **creates a new commit**, and a commit cannot contain its own
object ID. A single-layer arming is therefore self-referential: pin the callers
to the pre-rewrite SHA and they load the *unarmed* workflows; pin them to the
post-rewrite one and you are asking a commit to embed itself
(@codex-reviewer-andresmgsl, #202 review).
So:
| layer | what it is | what it carries |
|---|---|---|
| **candidate code SHA** | the immutable tree under test | `actions/`, `lib/` — untouched |
| **armed workflow SHA** | a small child commit on top of it | workflows rewritten to the fork + `CEREMONY_SELF_REF` = the candidate code SHA |
### The procedure
1. **Push the candidate tree** to a fork under the identity that will run the
probe — one branch, `<identity>/ceremony@probe-<issue>` — and record its
SHA. Steps 1 and 2 advance the tip of that **same** branch; there are two
commits, not two branches. That is
the **candidate code SHA**. Never create a branch on
`heavy-duty/ceremony` named like a tag: it shadows that tag for every
consumer until somebody remembers to delete it.
2. **Write the manifest FIRST, from the pre-arming tree, then commit the
arming.** The manifest enumerates the carriers *that must change*, so it is
generated before they do — running it afterwards would enumerate
already-rewritten rows and lose the canonical internal-checkout ones
entirely (@codex-reviewer-andresmgsl, #202 review). In that same
fork branch rewrite, for **every** carrier the manifest below enumerates:
ceremony's own internal `repository:` checkouts → `<identity>/ceremony`, and
**every** `CEREMONY_SELF_REF` value → the **candidate code SHA** from step 1.
There were three self-ref carriers on `main` at the time of writing and the
count is not a constant — derive it, do not remember it
(@glm-reviewer-andresmgsl, @codex-reviewer-andresmgsl, #202 review). The
**consumer** checkouts (`${{ github.repository }}`) are left alone. Record
the resulting SHA: that is the **armed workflow SHA**.
3. **Pin the probe repo's callers by layer**, because they are not the same
thing:
- composite-action callers →
`<identity>/ceremony/actions/<name>@<candidate-code-sha>`;
- reusable-workflow callers →
`<identity>/ceremony/.github/workflows/<file>@<armed-workflow-sha>`, since
that is the only revision whose inner checkout is rewritten.
4. **Gate the arming against a MANIFEST, byte for byte.** Every weaker shape
has a hole, and each of these was found in a published draft of this file
(@codex-reviewer-andresmgsl, #202 review):
| weaker check | what slips through |
|---|---|
| "the old literal is absent" | a carrier rewritten to the wrong fork, or to the *armed* SHA |
| "every extracted value equals X" | a carrier that **vanished** — nothing to compare |
| "each value is one of {fork, dynamic}" | a **role swap**: an internal checkout made dynamic, a consumer checkout pointed at the fork |
| "the SHA suffix matches" | `wrong-owner/ceremony/actions/foo@<right-sha>` |
| "known callers match" | an **unrecognised** caller, or none at all |
| "the owner and the sha are right for the kind" | a **layer swap**: `…/actions/x@<armed>` labelled a workflow caller satisfies both |
So the arming step **writes a manifest** — one line per carrier, `path`,
`kind`, `full expected value` — and the gate compares the tree's actual
carriers against it as a set. A deletion, a role swap, a wrong fork, a wrong
SHA, an extra carrier and a missing caller are then all the same kind of
failure: the sets differ.
**Generate it while arming**, from the tree you are arming, so the manifest
cannot drift from the repository:
```sh
#!/usr/bin/env bash
# write-manifest <candidate-checkout> <probe-checkout> <fork> <code-sha> <armed-sha>
#
# Run against the PRE-ARMING tree and the UNPINNED probe: this records what
# each carrier must BECOME, so it has to see them before they change.
#
# `|| true` on every extraction, for the same reason the checker needs it:
# git grep exits 1 on no-match and `set -e` would abort BEFORE the manifest
# is written — silently, which is how the first version of this generator
# produced no file and no diagnostic when a probe exercised only one layer
# (@codex-reviewer-andresmgsl). A probe need not use both.
set -euo pipefail
candidate="$1"; probe="$2"; fork="$3"; code_sha="$4"; armed_sha="$5"
# shellcheck disable=SC2016 # `${{ github.repository }}` is literal YAML, not a shell expansion
{
git -C "$candidate" grep -n 'CEREMONY_SELF_REF:' -- .github/workflows \
| cut -d: -f1,2 | sed "s|$|\tself_ref\t$code_sha|" || true
git -C "$candidate" grep -n 'repository: heavy-duty/ceremony' -- .github/workflows \
| cut -d: -f1,2 | sed "s|$|\tinternal_repo\t$fork|" || true
git -C "$candidate" grep -n 'repository: ${{ github.repository }}' -- .github/workflows \
| cut -d: -f1,2 | sed 's|$|\tconsumer_repo\t${{ github.repository }}|' || true
# Callers record the COMPLETE expected coordinate, not just the sha: the
# path is as rewritable as the owner, and a manifest that stores only the
# suffix cannot notice `…/actions/wrong-one@<right-sha>`.
git -C "$probe" grep -nE 'uses:[[:space:]]*[^[:space:]]*/ceremony/\.github/workflows/' -- .github \
| sed -E "s|^([^:]+):([0-9]+):.*/ceremony/(\.github/workflows/[^@[:space:]]+)@.*|\\1:\\2\\tworkflow_caller\\t$fork/\\3@$armed_sha|" || true
git -C "$probe" grep -nE 'uses:[[:space:]]*[^[:space:]]*/ceremony/actions/' -- .github \
| sed -E "s|^([^:]+):([0-9]+):.*/ceremony/(actions/[^@[:space:]]+)@.*|\\1:\\2\\taction_caller\\t$fork/\\3@$code_sha|" || true
} | sort >manifest.tsv
# Zero ceremony callers is a refusal by name; one layer only is fine.
callers="$(grep -cE '(workflow|action)_caller' manifest.tsv || true)"
[ "$callers" -gt 0 ] || { echo "manifest: no ceremony callers found in $probe" >&2; exit 1; }
```
Then arm — rewrite and commit — and check the result against it:
```sh
#!/usr/bin/env bash
# check-arming <armed-checkout> <probe-checkout> <fork> <code-sha> <armed-sha> <manifest.tsv>
set -euo pipefail
armed="$1"; probe="$2"; fork="$3"; code_sha="$4"; armed_sha="$5"; manifest="$6"
fail() { echo "arming incomplete: $*" >&2; exit 1; }
# `|| true` on every extraction: git grep exits 1 when nothing matches, and
# under `set -e` that would kill this script BEFORE the comparison — so a
# carrier class that vanished ENTIRELY produced silence instead of a
# refusal. Silence is the worst of the three outcomes; the comparison below
# is what must report it.
[ "$(grep -cE '(workflow|action)_caller' "$manifest" || true)" -gt 0 ] \
|| fail "manifest names no ceremony callers — it cannot prove an arming"
# THE MANIFEST ITSELF IS CHECKED AGAINST THE TARGET, not trusted. Comparing
# only tree-vs-manifest proves consistency, and a manifest generated with the
# armed SHA where the candidate SHA belonged — or with the wrong fork —
# describes a WRONG arming perfectly. The tree would then match it and the
# gate would pass (@codex-reviewer-andresmgsl, #202 review).
# shellcheck disable=SC2016 # `${{ github.repository }}` below is literal YAML
while IFS=$'\t' read -r loc kind want; do
case "$kind" in
self_ref) [ "$want" = "$code_sha" ] || fail "manifest $loc: self_ref should be the CANDIDATE sha" ;;
internal_repo) [ "$want" = "$fork" ] || fail "manifest $loc: internal repo should be $fork" ;;
consumer_repo) [ "$want" = '${{ github.repository }}' ] \
|| fail "manifest $loc: consumer checkout must stay dynamic" ;;
# THE KIND MUST BIND TO THE PATH CLASS, not only to the owner and the
# sha. The path class is what SAYS which layer a caller is, so checking
# the sha against the kind while letting the kind float free accepts a
# consistent layer swap — `…/actions/x@<armed>` declared workflow_caller
# passes every owner and sha test (@codex-reviewer-andresmgsl, #202
# review). Decompose once, then let the kind fix BOTH coordinates.
workflow_caller|action_caller)
owner="${want%%/ceremony/*}"; rest="${want#*/ceremony/}"
path="${rest%@*}"; sha="${want##*@}"
[ "$owner/ceremony" = "$fork" ] \
|| fail "manifest $loc: caller owner should be $fork"
case "$kind" in
workflow_caller)
case "$path" in .github/workflows/?*) : ;;
*) fail "manifest $loc: workflow_caller must resolve at .github/workflows/<file>, not '$path'" ;;
esac
[ "$sha" = "$armed_sha" ] || fail "manifest $loc: workflow caller should be the ARMED sha" ;;
action_caller)
case "$path" in actions/?*) : ;;
*) fail "manifest $loc: action_caller must resolve at actions/<name>, not '$path'" ;;
esac
[ "$sha" = "$code_sha" ] || fail "manifest $loc: action caller should be the CANDIDATE sha" ;;
esac ;;
*) fail "manifest $loc: unknown kind '$kind'" ;;
esac
done <"$manifest"
actual="$(mktemp)"; trap 'rm -f "$actual"' EXIT
{
git -C "$armed" grep -nP '(?<=CEREMONY_SELF_REF: ")[^"]+' -- .github/workflows \
| sed -E 's/^([^:]+):([0-9]+):.*CEREMONY_SELF_REF: "([^"]*)".*/\1:\2\tself_ref\t\3/' || true
git -C "$armed" grep -nE 'repository: .+' -- .github/workflows \
| sed -E 's|^([^:]+):([0-9]+):[[:space:]]*repository:[[:space:]]*(.*)$|\1:\2\t__repo__\t\3|' || true
# Only CEREMONY callers, matching the generator's domain exactly — a
# third-party `actions/checkout` is not this gate's business, and
# extracting it here while the generator ignores it made every probe fail
# as an "unrecognised carrier" (@codex-reviewer-andresmgsl). A wrong OWNER
# is still caught: `wrong-owner/ceremony/...` matches this pattern.
git -C "$probe" grep -nE 'uses:[[:space:]]*[^[:space:]]*/ceremony/' -- .github \
| sed -E 's|^([^:]+):([0-9]+):[[:space:]]*-?[[:space:]]*uses:[[:space:]]*(.*)$|\1:\2\t__uses__\t\3|' || true
} | sort >"$actual"
# every manifest line must be present with its EXACT expected value, and the
# kinds must match — a role swap changes the kind, not just the value.
while IFS=$'\t' read -r loc kind want; do
case "$kind" in
self_ref) have="$(awk -F'\t' -v l="$loc" '$1==l && $2=="self_ref"{print $3}' "$actual")" ;;
internal_repo|consumer_repo)
have="$(awk -F'\t' -v l="$loc" '$1==l && $2=="__repo__"{print $3}' "$actual")" ;;
workflow_caller|action_caller)
have="$(awk -F'\t' -v l="$loc" '$1==l && $2=="__uses__"{print $3}' "$actual")" ;;
esac
[ -n "$have" ] || fail "carrier vanished: $loc ($kind)"
# ONE comparison for every kind: the manifest already carries the complete
# expected value, so owner, path AND sha are checked at once. Checking the
# owner and the sha separately let `…/actions/wrong-one@<right-sha>`
# through (@codex-reviewer-andresmgsl).
[ "$have" = "$want" ] || fail "$loc ($kind): expected '$want', found '$have'"
done <"$manifest"
# and nothing UNRECOGNISED: every uses:/repository: in the trees must appear
# in the manifest, so an added carrier is a failure rather than a silence.
while IFS=$'\t' read -r loc _ _; do
grep -qF "$loc"$'\t' "$manifest" || fail "carrier not in manifest: $loc"
done <"$actual"
```
**Why a manifest rather than a longer list of assertions.** The carrier set
is a property of the tree at the moment of arming; any list written into
this document is stale the next time a workflow is added. The manifest is
generated from the tree, recorded in the result issue (step 6), and is the
thing a later reader compares against — so "what was armed" is evidence
rather than recollection.
5. **Invoke the probe by the event it is about**, and record which: a
`workflow_dispatch`, or the real board event under test. A probe that fires
a different event than the one under test proves something else.
6. **The result issue records all of it**: the fork repository, the candidate
code SHA, the armed workflow SHA, every rewritten carrier, the workflow
invoked and the run number. Those are what make the result reproducible;
without the two SHAs distinguished, a later reader cannot tell which tree
answered.
7. **Reset removes the candidate-specific EXECUTABLE state**: the caller stubs,
the probe workflow, and the fork's probe branch — whose tip carries both the
candidate commit and the armed commit on top of it — so the next probe
cannot inherit a pin it did not choose. **Result issues are never deleted.**
They may be closed or relabelled; deleting them would recreate the
expiring-log problem this venue exists to avoid.
## Who may reset it
**Operator-owned until ruled otherwise.** #202's task 4 asks who may reset the
venue, and creating the repo is the operator's step, so the access policy is
his to set at the same time (@codex-reviewer-andresmgsl, #202 review).
Two levels, deliberately separated:
- **content reset** — removing probe branches, workflows and fixtures; the
ordinary between-probes operation. It does **not** include deleting result
issues, which are the evidence and are immutable once written
(@codex-reviewer-andresmgsl, #202 review);
- **archive / delete / admin** — which is where the drill rule's damage came
from, and which no bot identity should hold here.
If fleet identities are given push access for content reset, this section
records that; until then, ask.
## What must never happen here
No probe touches `heavy-duty/ceremony`'s board. No labels, no comments, no
runs attributable to a probe. The venue exists so that the live board does not
have to be the test fixture.
## The probes this venue owes — and the records of those delivered
Delivered probes stay listed with their record: the venue's value is that a
claim like "the asymmetry reproduces" carries a URL a reader can open, not a
memory.
- **ceremony#192** — DELIVERED, first drill (2026-08-05). Under
`${{ github.token }}` in the venue:
`DELETE /issues/{n}/labels/{id}`**500**, the label still on the issue
afterward — the failure observable in the set, not merely a status — then
`PUT` full-set clear → **200**, set actually empty. Record:
[probe issue #1](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/1)
(run 1) and
[probe issue #2](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/2)
(run 4 — the clean independent repeat after the redaction incident below).
- **ceremony#205** — DELIVERED with a correction to the line above's
premise. The dispatch route answers **204** to a valid body carrying the
bare resolvable ref `main` — under the workflow token
([ceremony#205 comment #6262](https://forgejo.heavyduty.builders/heavy-duty/ceremony/issues/205#issuecomment-6262),
run 504, and again as
[probe issue #4](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/4),
run 6) and under a PAT
([probe issue #5](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/5),
run 7, and ceremony run 459). The earlier opaque `500` came from a bare
UNRESOLVABLE ref or an unknown/unparseable workflow — the diagnostic !213
ships now names this; a fully-qualified bad ref gets a clean 404 instead.
The `GET /actions/workflows` listing still 404s. Claims here are limited to
what those runs measured.
- **ceremony#215** — the discriminator drill: REST-body `inputs` DO reach a
top-level dispatched workflow, both contexts
(`inputs.*` and `github.event.inputs.*`), both identities. What loses the
value is the `workflow_call` boundary — a called workflow does not see the
caller's `event.inputs` on this instance. Records:
[probe issues #4 and #5](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/4)
(runs 6 and 7).
- **ceremony#217** — DELIVERED (2026-08-09). The standing venue used the
published consumer callers without rewrites or bypasses:
`labels.yml@0.6.1` and `labels-sweep.yml@0.6.1`. Opening fixture issue #7
drove event caller
[run 23](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/23)
and its dispatched sweep
[run 24](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/24);
the sweep wrote `needs-triage` on that issue under the workflow token. A
separate manual sweep was green in
[run 25](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/25).
Observer
[run 30](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/30)
recorded the released tag SHA `338cf5f754f0e87feefe9231b47910fb236ab4d0`,
both caller runs and the resulting label set in
[probe issue #10](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/10).
The event caller loaded its reusable workflow at `@0.6.1`; both sweep runs
completed the reusable workflow's internal `CEREMONY_SELF_REF` checkout at
the real `0.6.1` tag. There was no candidate SHA rewrite and no dogfood
bypass.
Two venue lessons from the first drills, kept where the next probe author will
look:
- **Rule 4 is load-bearing on this instance**: the probe repo's web log route
404s for non-admin reads, and a log-only observation (run 2) was lost where
issue-written ones were not.
- **Report content sent to the forge must never contain a credential
expression OR a credential value** — moving a secret from `${{ … }}` syntax
into a shell variable does not make it safe to emit. Name identities in
literal prose ("the workflow token"), allowlist the fields a report emits,
and treat anything else as unsendable. The incident this teaches from: an
escaped `${{ github.token }}` in a comment template was expanded by the
runner into the recorded text (the run's ephemeral token); it was redacted
in place and the drill repeated clean (probe issue #2, run 4).

View file

@ -1,308 +0,0 @@
# Syncing this tree with upstream ceremony
`heavy-duty/ceremony` exists on two forges and they diverge in opposite
directions on purpose:
- **upstream**`github.com/heavy-duty/ceremony`, where new ceremony features
are written. **Read-only from here.** No issue, PR, comment, review or
release is ever created there.
- **this tree**`forgejo.heavyduty.builders/heavy-duty/ceremony`, which
carries upstream's content plus the forge-compatibility delta and never
writes back.
This document is the procedure for bringing upstream's work across. It is
written to be followed without prior context; where it states a resolution, the
resolution is standing and does not get re-decided each sync.
Worked example throughout: the `0.6.0` sync (#197, #198), which merged upstream
`8c3a4d1` onto `dad99dd` and took four heads to get green.
## 0.6.2 port record
On 2026-08-24 this tree released the content carried by upstream
`upstream-0.6.1` through `upstream-0.6.3`. The content baseline is
`upstream-0.6.3`; the changes were ported onto the Forgejo-adapted tree by
#229 and #230 rather than merged from upstream.
The ancestry baseline therefore remains the full `.upstream-ref` value
`8c3a4d1dee2bdb5ac06a632a285bb65ab2615214` (upstream `0.6.0`, merged by
#198). No upstream ancestry moved in this release. Tags are disambiguated as
`upstream-0.6.x` for upstream's line and bare `0.6.x` for releases cut on this
forge.
Upstream's drill-record fixes and the upstream `0.7.x` line remain deferred to
the next sync campaign. That line has no ceiling this file can hold still —
upstream tags roughly one release a week — so what is recorded here is its
floor and the date it was last measured: `0.7.0` onward, `0.7.6` newest as
measured 2026-08-27. Run `git ls-remote --tags` for today's ceiling. The
sentence this replaced froze `0.7.4` and was already a release behind on the
day it was written.
## The next campaign merges
The next sync campaign is a merge, not a port, and it therefore advances
`.upstream-ref` to the commit it merges. The operator ruled this on #268 on
2026-08-27, when release-init found no forge-local work to fill an `0.6.4` and
opened no window.
The reason is the ancestry baseline. `.upstream-ref` has been pinned at
`8c3a4d1` — upstream `0.6.0`, merged by #198 — because 0.6.2 came across as a
port and a port moves no ancestry. Another port would leave it pinned and
guarantee a third, larger campaign against a delta that is still growing.
Size it before starting. With upstream's tags fetched read-only per step 1,
`git diff --shortstat 8c3a4d1dee2bdb5ac06a632a285bb65ab2615214 0.7.6`
was 92 files and +25,121/-971 over 488 commits when measured 2026-08-27, and
the three action scripts the forge delta is heaviest in all move:
`runner-isolated` +1619, `issueflow-reconcile` +913, `labels-reconcile` +889.
Step 4 of the procedure below — the audit of what merged *without* conflicting
— is where that size is actually paid for.
## The standing resolutions
These recur every sync. They are decided; re-deciding them is the cost this
list exists to remove.
| what | which side wins | decided by |
|---|---|---|
| `VERSION` | **upstream** — this tree tracks upstream's version numbers | #197 D2 |
| `CEREMONY_SELF_REF` (both carriers) | **upstream** | #197 D2 |
| `.github/labels.conf` | **this tree** — upstream's roster names identities that do not exist here | #195 |
| `drills/*.md` | **this tree** — a drill record is a record of a run *here* | #198 |
| `CHANGELOG.md` | **both**, upstream's new sections above this tree's | #198 |
| a section for a version **both** trees released | **this tree's** — ours is the published body of the tag that exists here | #198 |
Two consequences worth stating plainly:
- **Two trees answer to the same version number**, differing by the forge
delta. That is accepted, not accidental (#197 D2). The mitigation is
provenance in prose: `CHANGELOG.md`'s header names the upstream commit this
tree carries, and `.upstream-ref` records it in machine-readable form.
- **A tag that exists upstream may not exist here.** `CEREMONY_SELF_REF` takes
upstream's number, and both workflows carry the self-consumption bypass
(`if: github.repository != 'heavy-duty/ceremony'`), so ceremony's own CI is
unaffected. But **no consumer may bump its pin to that number until the
release ceremony cuts the tag here.**
## The procedure
### 1. Add the upstream remote, read-only, and confirm the merge base
```sh
git remote add gh https://github.com/heavy-duty/ceremony.git # if absent
git fetch gh
upstream_sha="$(git rev-parse gh/main)" # capture ONCE, in full
git merge-base main "$upstream_sha"
```
**Capture the full SHA immediately and use that value everywhere after** — the
merge, the provenance, the `.upstream-ref` write. `gh/main` is a moving
pointer: while this sync was being reviewed upstream advanced from `8c3a4d1`
to `08e2912`, and re-reading `gh/main` at recording time would have written a
commit this tree does not contain. The recorded ref is *what was merged*, never
*what upstream is now*.
**Confirm the merge base against `.upstream-ref` before merging anything.** If
it is not what the last sync recorded, something moved — stop and re-measure
rather than proceeding. A sync that starts from an unexpected base is a sync
whose conflict count means nothing.
### 2. Merge, never rebase
```sh
git merge "$upstream_sha"
```
One merge commit, conflicts resolved once (#197 D1). Rebasing the forge-only
commits onto upstream would rewrite every SHA, re-resolve the same conflicts
once per commit, and break any pin to them. A fresh re-import would discard the
provenance in this repo's issue comments, which is where its documentation
actually lives.
### 3. Resolve the conflicts
Apply the standing resolutions above. What is left is genuinely new and needs
judgement — in the `0.6.0` sync that was 5 hunks of 18.
### 4. Audit what the merge brought in that did NOT conflict
**This is the step the `0.6.0` sync nearly shipped without, and the one this
document exists for.**
`git merge` takes upstream's side wherever only upstream moved a region. So a
function upstream *added* to a file this tree already owns arrives with **no
conflict and no question asked**. Reviewing the conflict hunks cannot find
them: four reviewers read the same diff and each found a different subset.
In the `0.6.0` sync that was **eight** runtime `gh` call sites, in three files
and two file types, every one of which #188 had previously removed.
So, after resolving:
```sh
bash test/no-runtime-gh.test.sh
```
That guard is the mechanical form of #197's acceptance bar — no runtime `gh`
outside `lib/forge-github.sh` unless the file declares
`CEREMONY_FORGE_CLIENT=gh` **and** refuses when it cannot run. Do not satisfy
it by adding an exemption; a declaration without a refusal is a permission slip
for `gh: command not found`.
Then check the **variables** the same way, because the same mechanic applies to
state: if a conflicted region assigns something that auto-merged code consumes,
resolving it "to this tree's side" silently removes the producer. Every one of
those consumers degrades to empty rather than erroring, so nothing goes red.
The `0.6.0` sync had three such seams. Enumerate what each resolved region
assigns, and confirm each still has a producer.
### 5. Port or declare every new `gh` call site
Where a `forge_*` verb exists, port it in the merge itself. Where none does,
the file **declares** `CEREMONY_FORGE_CLIENT=gh` and refuses loudly, and the
port gets its own issue (#199 for `refs-not-closing`, #205 for the sweep
dispatch). "Never 'probably github'" applies to the sync as much as to a
runtime probe.
A workflow cannot call `forge_preflight`, so it declares in its `env:` block
and refuses inline — deciding the **forge** first and the **binary** second. A
guard that only asks whether `gh` is installed passes the moment a runner image
ships it.
### 6. Record the provenance
- `CHANGELOG.md`'s header: which upstream commit this tree now carries.
- `.upstream-ref`: the same **full 40-character** SHA, machine-readable,
checked by `test/upstream-delta.test.sh` — which refuses when the object is
absent or is not an ancestor, rather than reporting it unverifiable. `ci.yml`
fetches that exact object before the suite runs.
- A `changelog.d/` fragment for the sync issue.
### 7. Verify — and verify where it will actually run
`test/run.sh` green on your machine is the weakest of the checks below. The
`0.6.0` sync was "green locally" and red on the runner **three times, for three
different reasons**:
| what was green locally | why the runner disagreed |
|---|---|
| `shellcheck-all.sh` | it lints **tracked** files, and the new guard was untracked |
| the whole suite | CI pins **shellcheck 0.10.0**; a different local version reports differently |
| `issue_payload_valid` | `jq -e` on empty input exits **4** on jq 1.7 and **0** on jq 1.6 — and the runner image ships 1.6 |
That last one was not a test problem: on jq 1.6 the guard that refuses an
unreadable read was *accepting* one. **The distance between your environment
and the runner's is part of the sync's risk surface, not an inconvenience.**
So verify with the runner's own tooling:
```sh
git add -A # or shellcheck sees nothing new
CEREMONY_REQUIRE_NPM=1 CEREMONY_REQUIRE_YQ=1 bash test/run.sh
bash .github/scripts/shellcheck-all.sh # pinned 0.10.0, as ci.yml installs
bash .github/scripts/actionlint-all.sh
bash .github/scripts/self-ref-check.sh
bash .github/scripts/marker-check.sh
bash .github/scripts/vendored-check.sh
bash actions/changelog-armed/changelog-armed.sh
```
and run the suite once under the runner's `jq` as well as your own.
### Every branch that was open during the sync is now stale
Forgejo tests branch heads; it never tests what two branches produce together,
and it never re-tests an open PR when `main` moves under it. So after a sync
lands, **every PR that was open across it is green against a tree that no
longer exists** — its run did not contain the test files and rules the sync
introduced.
Both halves of that bit in this sync:
- `#206` and `#207` were cut from the pre-sync base. Their green suites had 22
test files; the merged tree has 28.
- `#206`'s changelog fragment was individually green and made the **combined**
tree red, because the terminal-citation rule (#262) arrives *with* the sync
and the fragment was written against a base without it.
So, for each PR still open:
```sh
git merge origin/main # in the branch — do not rewrite its commits
CEREMONY_REQUIRE_NPM=1 CEREMONY_REQUIRE_YQ=1 bash test/run.sh
```
or, if you are only checking rather than updating, merge them into a scratch
worktree together and run the full current suite and static guards there. A
prior approval is evidence about the tree it was given on; after a sync it is
not evidence about the tree the operator would merge.
### 8. After it merges — audit by executed steps, never by colour
The sync issue uses `Refs`, not `Closes`, and stays open until a real sweep on
the merged `main` is linked to it.
**A green run is not that evidence.** In this sync the first post-merge run was
green and had reconciled *nothing*: upstream's #209 restructure moved reconcile
out of the labels caller and behind a dispatch this forge cannot perform, so
the only job that ran was the refusal. Green, correct, and proof of the
refusal path only.
So before citing any run:
1. **Inventory what the sync changed about workflow triggers and jobs** — which
jobs exist now, which events fire them, and which of those this forge can
actually serve. A restructure upstream can move work between workflows
without touching a line of the code that does it.
2. **Read the run's executed steps**, not its status. Name the job that did the
thing, and quote the line that shows it did.
3. A green *skipped-or-refusing* path is valid evidence **for that path**, and
never evidence that the work happened.
Neither of these is caught by the no-runtime-`gh` scan in step 4: in this sync
both failures occurred with that guard green and CI green.
## Where the forge delta lives
Forge-specific behaviour is confined to the files below. Keeping it there is
what makes each sync cost 18 hunks instead of hundreds, and
`test/upstream-delta.test.sh` fails the PR that scatters it into a new file.
**What that guard actually checks**, stated precisely so the table is not read
as a stronger promise than it is: it walks every tracked file except prose
(`*.md`), the test harness and `changelog.d/`, and flags any that **decides**
the forge — the selector's verbs, `CEREMONY_FORGE*`, or a server-URL comparison
written inline. Discovery is derived from the tree rather than from a list of
directories and extensions, so a composite `action.yml` or a `.yaml` workflow
is seen without anyone remembering to add it.
It is a check on *forge decisions in executable and configuration files*. It is
**not** a diff against upstream, so it cannot see a file that differs from
upstream for some other forge-specific reason — `drills/` and
`.github/labels.conf` are in the table for that kind of reason and are listed
by judgement, not by scan.
| file | what is forge-specific about it |
|---|---|
| `lib/forge.sh` | the selector: `forge_detect`, `forge_client`, `forge_preflight` |
| `lib/forge-github.sh` | the gh backend — the one file allowed to speak `gh` |
| `lib/forge-forgejo.sh` | the Forgejo backend, `/api/v1` over curl + jq |
| `lib/closes_references.sh` | the closing-keyword parser that replaced GraphQL |
| `.github/labels.conf` | this instance's roster |
| `drills/` | records of runs on this instance |
| `actions/refs-not-closing/run.sh` | declares `CEREMONY_FORGE_CLIENT=gh` — its gather is GraphQL, which Forgejo does not serve. #199 removes the declaration |
| `.github/workflows/labels.yml` | the sweep dispatch decides the forge inline and declares a client; a workflow has no shell to call `forge_preflight` from. #205 ports it |
| `.github/workflows/refs-guard.yml` | schedules its job on GitHub only, so an action that can only refuse here does not stand red. #199 removes the gate |
| `.github/workflows/release-exercise.yml` | pins `CEREMONY_FORGE: github` deliberately: the exercise drives the GitHub path |
| `actions/docs-sync/docs-sync.sh` | fetches the doctrine mirror from the forge in `GITHUB_SERVER_URL`, and refuses rather than guessing one (#201) |
Four of those are **temporary** and say which issue removes them. That is the
point of listing them rather than exempting them: a forge-delta location with
no exit is indistinguishable from one nobody noticed.
A file that merely **calls** the shim is not a delta location — every
reconciler and `release.yml` call `forge_select`, and that is what the shim is
for. A file that **decides** or **declares** is, and belongs here.
If a sync needs forge branching somewhere else, that is a design decision, not
a detail: add the file to the inventory in the same PR, with the reason.

View file

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

View file

@ -1,125 +0,0 @@
# 0.4.1 — drill record
Two runs. The first, 2026-08-04 against release PR !190 head `9a229ee`,
**failed**: both doors were inoperable and the release could not publish at
all. The second, after #191 landed as `fda5657`, **passed**. Both are
recorded, because the first is why the second exists.
## Run 2 — against merged `main` `fda5657` (the one that counts)
Where: disposable private repo `heavy-duty/ceremony-drill-0.4.1-final`,
armed at `0.4.1-dev`, carrying the `docs/CONSUMERS.md` release caller, a
fragment-mode fixture, and — unlike run 1 — an **artifact hook** dropping two
deliberately awkward filenames, `drill asset.tgz` and `a&b.tgz`. Archived at
the end; the operator's delete is pending, and cleanup gates nothing.
Candidate ref: `cluade-reviewer-andresmgsl/ceremony@drill-main`, parent
`fda5657`, whose only extra commit rewrites both `CEREMONY_SELF_REF`
carriers to that SHA — `release.yml`'s self-checkout is hardcoded to
`heavy-duty/ceremony`, so only a SHA that resolves there can stand in for a
tag that does not exist yet. No `0.4.1` branch was created on
`heavy-duty/ceremony`.
| # | probe | run | result |
|---|---|---|---|
| 1 | merge-door ceremony | 637 | ✅ exactly one release `0.4.1`; body is the version's own changelog section; tag and release on merge commit `4a83fa1b`; **main re-armed to `0.4.2-dev`**; both assets uploaded |
| 2 | mislabeled ordinary PR | 652 | ✅ green no-op in **7s** on merge commit `e71df4e3` — release count stayed **2**, no tag created |
| 3 | bare version, no `release` label | 638 | ✅ refused at `b091aff2` — release count stayed **1** |
| 4 | re-run of the completed ceremony | 654 | ✅ refused in **8s** on merge commit `82e7d11b` — release count stayed **2**, and tag `0.4.1` **stayed on `4a83fa1b`**, the original merge commit |
| 5 | tag door, matching tag | 639 | ✅ `0.5.0` published with its own section and both assets; **main VERSION untouched** |
| 6 | mismatched tag | 640 | ✅ `9.9.9` refused — release count stayed **2**; the tag exists, the release does not |
Probes 2 and 4 were run last, at 15:2315:28Z, on the same consumer: it was
un-archived for them and archived again at the end. Nothing else about the
run changed — same candidate ref, same caller pin.
**Probe 4 diverges in mechanism, not in what it proves.** The 0.3.0 and 0.4.0
siblings re-ran the completed ceremony's workflow run (GitHub's "attempt 2").
Forgejo 8.0.3 exposes no run-rerun API — there are no `actions/runs/{id}`
routes in its swagger at all — so the ceremony was re-run by reproducing its
input instead: main re-armed to `0.4.1-dev` (setup run 653), then a second
`release`-labeled PR stamping bare `0.4.1` merged on top. That is the same
state the door refuses on, reached by a push rather than a re-trigger, and it
is stricter than the sibling shape in one way — it re-enters through
`facts``decide` rather than replaying a decided run.
Which refusal fired is measurable even without run logs. Replaying the
door's own inputs against the live consumer at `82e7d11b`:
```
facts: ver='0.4.1' base_ver='0.4.1-dev' released='' labeled='yes'
decide: ceremony=yes
```
So `decide` said **go** — a labeled bare transition is row 6 — and the stop
came from the merge door's own pre-publish assert, `release.yml:216-219`,
whose comment names this exact probe ("what makes a re-run of a completed
ceremony refuse instead of clobber"): the tag existed, so it refused before
`forge_tag_create` ever ran. The second net behind it is #191's own verb,
and it reads this consumer correctly:
```
forge_release_exists 0.4.1 -> yes 0.5.0 -> yes 9.9.9 -> no
```
`9.9.9` is the probe-6 receipt in the same breath: the tag exists, the
release does not.
Evidence for every probe here is the run conclusion plus the repository
state before and after — this instance serves no run logs (the API 404s on
every log route), so no probe's result is quoted from a log line.
End state, as observed when this was written: releases `0.4.1` and `0.5.0`
and nothing else; tags `0.4.1` on `4a83fa1b`, `0.5.0` and `9.9.9` on
`b091aff2`; the consumer's `main` left at bare `0.4.1` where probe 4 stopped
it, private and archived.
**The asset names survived intact**: `a&b.tgz` and `drill asset.tgz` both
appear under those exact names on both releases. Before #191's fix the space
made curl reject the URL outright and the `&` split the query — the failure
landing *after* the tag exists, mid-publish, which is the worst place this
door has.
## Run 1 — against `9a229ee`, before the fix (FAILED)
Recorded because the failure is the reason #191 exists — including its
deviations, which no later success retires.
Where: disposable private repo `heavy-duty/ceremony-drill-0.4.1` — a
different consumer from run 2's, archived at the end with the operator's
delete pending. Candidate ref
`cluade-reviewer-andresmgsl/ceremony@drill-0.4.1` (`f148255`), parent
`9a229ee`, its only extra commit rewriting both `CEREMONY_SELF_REF` carriers
to that SHA.
**Deviation, disclosed and not retired by run 2:** run 1's scratch repo was
flipped **public for roughly 8 minutes** to read job logs — Forgejo's web log
route 404s for a token-authenticated private repo and the `/api/v1` log
routes 404 outright — then restored to private and archived. That is a real
departure from `drills/README.md`'s "scratch **private** repo", and it stays
in the record. Run 2 did not repeat it: it read no logs at all, which is why
every run-2 row is a repository-state measurement.
| # | probe | run | result |
|---|---|---|---|
| 1 | merge-door ceremony | 12 (and 7, at `0.4.1`) | ❌ **FAILED**`decide` refused; no tag, no release, main left bare |
| 2 | `-dev` push no-op | 9, and the fixture push | ✅ green no-op, nothing created |
| 3 | bare version, no `release` label | — | ⚠️ not run independently; probe 1 refused through exactly this path, because the label fact read `no` |
| 4 | re-run of a completed ceremony | — | ⚠️ **not reachable** — no ceremony ever completed to re-run |
| 5 | tag door | 14 | ❌ **FAILED**`gh: command not found` at *publish the release*; no release |
| 6 | mismatched tag | 16 | ✅ `tag '9.9.9' does not match the tree's version '0.4.2' — creating nothing` |
Root cause, both doors: `lib/facts.sh` and `release.yml` spoke `gh`, which
the runner image does not ship. `facts.sh` read *any* failure as a definite
`no`, so a missing binary silently demoted a release ceremony to "a bare
push". Release count across the whole of run 1: **0**.
## What changed between them
#191, merged as `fda5657`: both doors onto `lib/forge.sh`; a read that did
not complete refuses instead of fabricating a `no`; `forgejo_api_base`
refuses an empty `REPO` so `repos//…` cannot be addressed; release asset
names percent-encoded.
Every refusal path, in both runs, created nothing. That property never
broke — what broke was the doors' ability to say yes.

View file

@ -1,58 +0,0 @@
# 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.

View file

@ -1,272 +0,0 @@
# 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

@ -1,116 +0,0 @@
# 0.6.1 — drill record
Run 2026-08-09 by `codex-reviewer-andresmgsl` against release PR !226,
candidate branch `release-0.6.1`, canonical candidate SHA
`ba3b17af88e0fe1ccae9eefd4b37bf2666f6cdad`. All six probes ran on this
Forgejo instance. Each result below was read from its own run and from the
repository state after that run.
## Scope ruling — a full rehearsal was owed
The last rehearsed tag was `0.6.0`. The release path at this candidate is the
exact output of `.github/scripts/release-path.sh`. Its measured diff from
`0.6.0` is not pin-only: `.github/workflows/release.yml`, `lib/facts.sh`, and
the new `lib/forge.sh` changed. These are release-door bytes, so the
doors-unchanged record shape is refused and this release carries a full live
rehearsal.
## Where
Disposable **private** repository `heavy-duty/ceremony-drill-0.6.1`, created
by the operator on 2026-08-09 after the fleet identity's personal repository
could not see the organization runner. It carries the `docs/CONSUMERS.md`
release caller with `version-source: file`, a fragment-mode fixture armed at
`0.6.1-dev`, and a non-blank `drills/0.6.1.md`. The `release` label existed
before the first probe PR.
**Disposal as observed when this record was written:** the repository is
private and **not yet archived**. This builder's archive PATCH returned 403
because it has push but not admin permission. The operator was asked to
archive it; delete remains the operator's later step. No archive or delete is
claimed, and cleanup gates neither review nor merge.
## Candidate-ref deviation
The candidate tree pins `CEREMONY_SELF_REF` to `0.6.1`, a tag that did not
exist before this release. No branch named `0.6.1` was created on
`heavy-duty/ceremony`.
The scratch caller instead uses
`codex-reviewer-andresmgsl/ceremony/.github/workflows/release.yml@drill/0.6.1`.
That ref is commit `f766752090429241c20a0d86aba6f679d713fc2c`; its parent is the canonical
candidate SHA above, and its only change rewrites all three
`CEREMONY_SELF_REF` carriers to that canonical SHA. The release path is
therefore byte-identical to the candidate except for the required candidate
pin rewrite.
## Probes
| # | probe | run | result |
|---|---|---|---|
| 1 | merge-door ceremony | 5 | ✅ one `0.6.1` release; tag points to merge commit `438097fc75b1d294b448830a3a79c00b0ee7b83f`; main re-armed to `0.6.2-dev` |
| 2 | release-labelled ordinary PR | 2 | ✅ green no-op; zero tags and zero releases |
| 3 | bare-version PR without `release` | 3 | ✅ refused; zero tags and zero releases |
| 4 | completed ceremony repeated | 7 | ✅ refused; release count stayed one and tag `0.6.1` stayed on the original merge commit |
| 5 | manual matching tag | 8 | ✅ `0.6.2` published from its own changelog section; main was unchanged |
| 6 | mismatched tag | 9 | ✅ refused before publication; no `9.9.9` release, and the operator-created probe tag was removed afterwards |
### Probe 1 — merge door
PR #4 transitioned `VERSION` from `0.6.1-dev` to `0.6.1`, assembled both
fixture fragments, and carried the `release` label. Run 5 succeeded. The
published body is exactly the assembled `0.6.1` section, tag `0.6.1` points
to PR #4's merge commit, and the workflow pushed `0.6.2-dev` to main at
`1c74d76f035da4a13f7af0d2a43d611092061204`.
### Probe 2 — labelled ordinary work
PR #1 carried the `release` label and added only `changelog.d/2.md`.
`VERSION` stayed `0.6.1-dev`. Run 2 succeeded as a no-op; the tag and release
lists were both empty afterwards.
### Probe 3 — bare version without declared intent
PR #2 assembled the two fragments and transitioned to bare `0.6.1`, but had
no `release` label. Run 3 failed. Both tag and release lists remained empty.
Setup PR #3 then reverted that probe and restored the armed fixture; run 4
was green before the ceremony probe began.
### Probe 4 — completed ceremony repeated
Forgejo exposes no run-rerun API, so this probe reproduced the completed
door's input. Setup PR #5 restored `0.6.1-dev` and run 6 was green. Release-
labelled PR #6 transitioned to bare `0.6.1` again. Run 7 failed at the
already-existing tag guard. The release count stayed one and tag `0.6.1`
still pointed to the original ceremony merge commit, not PR #6.
### Probe 5 — matching manual tag
Side branch `probe5-tag` carried bare `VERSION 0.6.2` and a publishable
`0.6.2` changelog section. Tag `0.6.2` was created at
`a4ba62aa83fdd2e259ed3c3e906b13eaab1abcd0`. Run 8 succeeded and published
only that section. Main stayed at the state left by probe 4; the tag door did
not push a version change to it.
### Probe 6 — mismatched tag
Tag `9.9.9` was created at the same `0.6.2` tree. Run 9 failed before
publication. The release list remained exactly `0.6.1` and `0.6.2`. The
operator-created `9.9.9` ref was then deleted; the remaining tag list was
exactly `0.6.1` and `0.6.2`.
## Runs that are setup, not probes
- Run 1: the initial `0.6.1-dev` fixture push; green no-op.
- Run 4: PR #3 restored the armed fixture after probe 3; green.
- Run 6: PR #5 restored `0.6.1-dev` to reproduce the completed ceremony
input for probe 4; green.
## What this rehearsal establishes
Both release doors ran against the 0.6.1 candidate's Forgejo implementation.
The merge door published once, tagged the reviewed merge commit, and re-armed
main. It stayed a green no-op for labelled ordinary work, refused a bare
unlabelled transition, and refused a repeated ceremony. The tag door
published a matching tag without changing main and refused a mismatched tag
without publishing. Every refusal created no tag or release of its own.

View file

@ -1,38 +0,0 @@
# 0.6.2 — drill record
Measured 2026-08-24 on release PR !250, candidate branch
`build/231-release-0-6-2`. The release-path measurement was taken at
`fdb7d7577b4b30c11f2db9b47f278e2a16783c8c`; this record is the only later
candidate-tree change and `drills/` is outside the release path.
## Scope ruling — doors unchanged, no disposable-repo rehearsal
The last rehearsed tag is `0.6.1`. All three doors-unchanged conditions in
`drills/README.md` hold at this candidate tree:
1. `git diff 0.6.1..HEAD -- $(sh .github/scripts/release-path.sh)` changes
only the `CEREMONY_SELF_REF` pin in `.github/workflows/release.yml`, from
`0.6.1` to `0.6.2`. No release-door logic, decision, fact gathering,
version handling, changelog handling, or publish step changed.
2. The measured release path is exactly the output of
`.github/scripts/release-path.sh`:
```text
.github/workflows/release.yml
bin/
lib/version.sh
lib/decide.sh
lib/facts.sh
lib/changelog.sh
lib/forge.sh
```
3. `drills/0.6.1.md` records a full six-probe rehearsal. The Forgejo release
API reported `0.6.1` published at `2026-08-09T19:46:56Z`, neither draft nor
prerelease, and `origin/main:VERSION` read `0.6.2-dev`, confirming that main
was re-armed after that release.
A new disposable-repo rehearsal would therefore execute the same release-door
bytes as the full 0.6.1 rehearsal. This record asserts only the mechanically
checked doors-unchanged case; the release panel may still rule that a full
rehearsal is owed.

View file

@ -1,74 +0,0 @@
# 0.6.3 — drill record
Measured 2026-08-26 on the `release-0.6.3` candidate branch, canonical
candidate SHA `03cb69d`. All three measurements below were taken at that head,
not copied from an earlier record.
## Scope ruling — doors unchanged, no disposable-repo rehearsal
**The last rehearsed tag is `0.6.1`, not `0.6.2`.** `drills/0.6.2.md` is itself
a doors-unchanged assertion, and `drills/README.md` anchors the baseline to the
last *rehearsed* tag precisely so one such assertion cannot chain from another
while the doors drift a small diff at a time. The baseline used here is
therefore `0.6.1`, which widens the measured window rather than narrowing it.
All three conditions hold at the candidate head:
1. `git diff 0.6.1..HEAD -- $(sh .github/scripts/release-path.sh)` changes only
the `CEREMONY_SELF_REF` pin line in `.github/workflows/release.yml`, from
`0.6.1` to `0.6.3`:
```diff
@@ -129,7 +129,7 @@ env:
- CEREMONY_SELF_REF: "0.6.1"
+ CEREMONY_SELF_REF: "0.6.3"
```
No release-door logic, decision table, fact gathering, version handling,
changelog handling, forge adapter or publish step changed across two
releases. The measured diff is one line.
2. The measured release path is exactly the output of
`.github/scripts/release-path.sh` at this head:
```text
.github/workflows/release.yml
bin/
lib/version.sh
lib/decide.sh
lib/facts.sh
lib/changelog.sh
lib/forge.sh
```
3. `drills/0.6.1.md` records a **full rehearsal** — run 2026-08-09 by
`codex-reviewer-andresmgsl` against release PR !226 — its release is
published, and `main` was re-armed to `0.6.2-dev` after it by
`5693bee chore: bump main to 0.6.2-dev`.
## What this release contains
Nine fragments consumed into `## 0.6.3`: #234, #240, #241, #243, #247, #251,
#253, #263, #265. None of them touches the release path; the list is
board-flow, labels-reconcile, docs and forge-adapter work.
## What is NOT asserted
This record asserts only that a fresh disposable-repo rehearsal would execute
the same release-door bytes as `0.6.1`'s did. It does not assert that the
doors were re-exercised, and it is not a substitute for the rehearsal owed at
the next release-path change. Per `drills/README.md`, the release PR's panel
verifies this claim like any other evidence, and **if any reviewer rules a full
drill owed, that verdict wins.**
## Note on 0.6.2's red `self-guards`
Recorded here because the next reader of `drills/` will see it. The `0.6.2`
tag's commit `5a8fce83` is red on `CI / self-guards``changelog-armed`
correctly refused a tree in which `changelog.d/238.md` was still unconsumed,
because that fragment landed on `main` after `changelog-assemble 0.6.2` had
already run in the release branch. The content shipped correct (the #238 entry
is in the published `0.6.2` notes) and the strand was consumed on `main` by
`f221647`. The systemic guard against the race landed in !255, *"refuse release
PRs that strand target fragments"*, which is why this candidate was checked
against it before opening.

View file

@ -66,55 +66,7 @@ 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`. Run the script and paste its output into
the record; measure the diff with
`git diff <last-rehearsed-tag>..HEAD -- $(sh .github/scripts/release-path.sh)`.
The script's contract test keeps its 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,
never a silent skip.
---
**Standing runner probes are not drills.** The disposal rule above — builder
archives, operator deletes — is for the disposable scratch repo a drill runs
in. `heavy-duty/ceremony-runner-probe` is the opposite shape: it stands, and
archiving it is the failure mode that made all three previous drill repos
unavailable. See [docs/RUNNER-PROBES.md](../docs/RUNNER-PROBES.md) (#202).

View file

@ -1,104 +0,0 @@
#!/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,22 +115,8 @@ 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 kind detail rest
local file="$1" base problem
base="${file##*/}"
if ! printf '%s\n' "$base" | grep -qE '^([a-z][a-z0-9-]*-)?[0-9]+\.md$'; then
@ -171,35 +157,9 @@ 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 '
# 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) {
function flush( len, e) {
if (entry == "") return 0
e = entry
entry = ""
@ -208,15 +168,9 @@ changelog_fragment_problem() {
sub(/ $/, "", e)
len = length(e)
if (len > max) {
reported = 1
printf "long\t%d\t%s\n", len, excerpt(e)
printf "%d\t%s\n", len, substr(e, 1, 60)
return 1
}
kind = cite_problem(e)
if (kind != "" && cite_kind == "") {
cite_kind = kind
cite_excerpt = excerpt(e)
}
return 0
}
/^### / { if (flush()) exit; next }
@ -228,37 +182,12 @@ changelog_fragment_problem() {
}
/^[[:space:]]*$/ { next }
entry != "" { entry = entry " " $0 }
# 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
}
END { flush() }
' "$file"
)"
if [ -n "$problem" ]; then
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
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'}"
return 1
fi
}

View file

@ -4,7 +4,7 @@
# lib/decide.sh (issue #8) is pure: it consumes four facts and renders the
# 5-state verdict. This script is the impure half that establishes those
# facts. It runs inside the consumer's checkout (the working directory),
# talks to git and the forge shim, and prints the facts in $GITHUB_OUTPUT form:
# talks to git and gh, and prints the facts in $GITHUB_OUTPUT form:
#
# ver=… base_ver=… released=(yes|no|empty) labeled=(yes|no|empty)
#
@ -16,8 +16,7 @@
# MERGE_SHA the pushed head (github.sha)
# EVENT_BEFORE github.event.before — may be empty or all-zeros
# GITHUB_REPOSITORY for the two API facts
# GH_TOKEN for the forge client (unused when no API state is
# consulted)
# GH_TOKEN for gh (unused when no API state is consulted)
#
# The API calls run only in the states that consult them (decide tolerates
# empty facts — issue #8): RELEASED only for a bare unchanged version,
@ -25,11 +24,8 @@
# decides on the two versions alone and never touches the API.
set -euo pipefail
_facts_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/version.sh
. "$_facts_lib/version.sh"
# shellcheck source=lib/forge.sh
. "$_facts_lib/forge.sh"
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/version.sh"
: "${VERSION_SOURCE:?facts: VERSION_SOURCE is required}"
: "${MERGE_SHA:?facts: MERGE_SHA is required}"
@ -98,45 +94,25 @@ fi
released=""
labeled=""
if ! version_is_dev "$ver"; then
# The forge is selected only in the states that consult the API — a -dev
# tree, every ordinary merge, still decides on the two versions alone and
# touches no forge at all (#8's tolerance for empty facts).
# The forgejo backend addresses the repo through REPO; the github backend
# reads GITHUB_REPOSITORY directly. Set it here from the one this script
# already documents, so the two backends address the same repository —
# missing it made every forgejo read refuse with "REPO: unbound variable"
# (caught by release-exercise on !193).
REPO="${REPO:-${GITHUB_REPOSITORY:?facts: GITHUB_REPOSITORY is required for the API facts}}"
export REPO
# "" means decide from the environment; forge_select takes an explicit
# forge only in tests.
forge_select "" || exit 1
if [ "$base_ver" = "$ver" ]; then
# Row 4's input. Before #191 any failure here read as "not released",
# which is safe only because row 4 refuses either way. It is still a
# lie about what was observed, so an unreadable answer refuses.
if ! released="$(forge_release_exists "$ver")"; then
echo "facts: could not read whether '$ver' is already released — refusing rather than reporting 'no' (#191)" >&2
exit 1
# Any gh failure reads as "not released" — the sources' semantics; the
# verdict this feeds (row 4) is a refusal, and the ceremony path
# re-checks existence in the nothing-exists assert before creating
# anything.
if gh release view "$ver" -R "$GITHUB_REPOSITORY" --json name >/dev/null 2>&1; then
released=yes
else
released=no
fi
else
# Row 5's input, and the one that cost a release: a push event carries
# no PR payload, so the label is read from the API. The old code turned
# ANY failure into labeled=no, and on a Forgejo runner — no `gh` — that
# demoted a correctly labeled, correctly merged ceremony PR into "a bare
# push", refusing the release and creating nothing. Measured in the
# 0.4.1 drill, twice (drills/0.4.1.md).
#
# Now: a completed read that finds no merged release-labeled PR is still
# `no` and still fail-closed. A read that did not complete refuses.
if ! pulls="$(forge_commit_pulls "$MERGE_SHA")"; then
echo "facts: could not read the pull requests behind '$MERGE_SHA' — refusing rather than reporting 'no label' (#191)" >&2
exit 1
fi
# One jq expression for both forges: the backends agree on the shape.
if printf '%s' "$pulls" \
| jq -e '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' >/dev/null 2>&1; then
# The sources' exact jq: merged PRs only, `release` among the label
# names. Read via the API because a push event carries no PR payload —
# and the PR itself lives on a fork (the trigger comment in the
# workflow). A failed API call reads as "no label", which row 5
# refuses: fail-closed.
if gh api "repos/$GITHUB_REPOSITORY/commits/$MERGE_SHA/pulls" \
-q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' \
| grep -qx true; then
labeled=yes
else
labeled=no

View file

@ -18,30 +18,6 @@ forgejo_api_base() {
echo "forgejo_api_base: no GITHUB_API_URL or CEREMONY_FORGE_API — cannot reach the forge (#188)" >&2
return 1
fi
# Every verb in this backend interpolates $REPO into its path, and every
# one of them reaches the network through here — so this is the one place
# that can make `repos//…` impossible.
#
# THE TRAP, measured on this instance with REPO unset (#191, caught by
# @kimi on !193 before it shipped):
#
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
# 404s; a repo-less path read
# as "the published release
# does not exist" — and the
# nothing-exists assert would
# then proceed to CREATE)
# forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged
# PR behind it, read as none)
#
# A workflow `run:` shell carries no `set -u`, so an unset REPO expands
# empty and 404s into a fabricated fact instead of crashing. That is the
# exact failure #191 exists to remove, so it refuses here rather than
# anywhere later.
if [ -z "${REPO:-}" ]; then
echo "forgejo_api_base: REPO is empty — refusing to address 'repos//…', whose 404 would read as a fact (#191)" >&2
return 1
fi
printf '%s\n' "${base%/}"
}
@ -84,7 +60,7 @@ forgejo_page_url() {
esac
}
# forge_api [--paginate | --paginate-exhaustive] <endpoint> [--jq <expr>]
# forge_api [--paginate] <endpoint> [--jq <expr>]
#
# --paginate walks page= until a short page, then PROVES the walk was
# complete by comparing what it collected against the server's declared
@ -94,26 +70,11 @@ forgejo_page_url() {
# make the completeness check compare null to a number — the guard itself
# degrading silently, which is the failure class re-entering through the
# door built to stop it.
#
# --paginate-exhaustive is the narrow alternative for an endpoint whose
# x-total-count is known not to describe the collection. It proves completion
# by reading through the first short page and never consults that header.
forge_api() {
local paginate=false paginate_exhaustive=false method=GET endpoint="" jqexpr="" have_jq=false
local paginate=false endpoint="" jqexpr="" have_jq=false
while [ $# -gt 0 ]; do
case "$1" in
--paginate) paginate=true ;;
--paginate-exhaustive) paginate_exhaustive=true ;;
-X | --method)
[ "$#" -ge 2 ] || { echo "forge_api: $1 requires a value" >&2; return 1; }
method="$2"
shift
;;
-X?*) method="${1#-X}" ;;
--method=*)
method="${1#*=}"
[ -n "$method" ] || { echo "forge_api: --method requires a value" >&2; return 1; }
;;
--jq) jqexpr="$2"; have_jq=true; shift ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
@ -121,14 +82,6 @@ forge_api() {
shift
done
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
if [ "$paginate" = true ] && [ "$paginate_exhaustive" = true ]; then
echo "forge_api: --paginate and --paginate-exhaustive are mutually exclusive" >&2
return 1
fi
if { [ "$paginate" = true ] || [ "$paginate_exhaustive" = true ]; } && [ "$method" != GET ]; then
echo "forge_api: pagination is available only for GET requests" >&2
return 1
fi
local base token
base="$(forgejo_api_base)" || return 1
@ -139,14 +92,14 @@ forge_api() {
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
trap "rm -f '$hdr' '$body'" RETURN
if [ "$paginate" = false ] && [ "$paginate_exhaustive" = false ]; then
if [ "$paginate" = false ]; then
if ! curl -sS -D "$hdr" -o "$body" \
-H "Authorization: token $token" -H 'Accept: application/json' \
"$base/$endpoint"; then
echo "forge_api: request failed: $endpoint" >&2
return 1
fi
forgejo_http_ok "$hdr" "GET $endpoint" || return 1
forgejo_http_ok "$hdr" "$endpoint" || return 1
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <"$body"; else cat "$body"; fi
return 0
fi
@ -163,9 +116,8 @@ forge_api() {
echo "forge_api: request failed: $endpoint (page $page)" >&2
return 1
fi
forgejo_http_ok "$hdr" "GET $endpoint" || return 1
forgejo_http_ok "$hdr" "$endpoint" || return 1
if [ "$paginate_exhaustive" = false ]; then
# Re-read on EVERY page, not once (#4712). A board that changes size
# under the walk was invisible: page 1 declaring 4 and page 2 declaring
# 9 stopped at 4 believing itself whole. A moving total means the read
@ -182,7 +134,6 @@ forge_api: the declared total for '$endpoint' changed between pages — $total t
EOF
return 1
fi
fi
pagejson="$(cat "$body")"
# A 200 whose body is not a collection counted as zero items (#4712),
@ -200,17 +151,13 @@ EOF
[ "$n" -gt 0 ] || break
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
got=$((got + n))
if [ "$paginate_exhaustive" = true ]; then
[ "$n" -eq 50 ] || break
else
[ "$got" -lt "$total" ] || break
fi
page=$((page + 1))
done
# The assert. A short read here is the silent-truncation bug arriving by
# another route, so it is fatal rather than a warning.
if [ "$paginate_exhaustive" = false ] && [ "$got" -ne "$total" ]; then
if [ "$got" -ne "$total" ]; then
cat >&2 <<EOF
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188).
Refusing rather than reconciling a partial board: a sweep over part of the
@ -252,14 +199,9 @@ EOF
printf '%s\n' "$total"
}
# forgejo_http_ok <header-file> <verb-and-endpoint> — a non-2xx is named, not
# forgejo_http_ok <header-file> <endpoint> — a non-2xx is named, not
# swallowed. gh exits non-zero on HTTP failure; curl does not without -f,
# and -f would throw away the body that says why.
# The second argument carries the VERB as well as the path — "GET repos/…",
# "PUT repos/…". #192's acceptance criterion is that a failure names the verb,
# the path and the status, and reads used to omit the verb: a caller reading
# `HTTP 500 from 'repos/o/r/issues/5'` could not tell a failed read from a
# failed write of the same path (@codex-reviewer-andresmgsl).
forgejo_http_ok() {
local hdr="$1" endpoint="$2" code
code="$(tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }')"
@ -332,94 +274,25 @@ forge_issue_edit() {
shift
done
# THE LABEL DELTA (#192). Removal used to be a per-label
# `DELETE .../labels/{id}` loop. On this instance that call returns HTTP 500
# for EVERY removal under the token the sweep actually holds — measured
# under a real Actions token inside a workflow, probe run 701:
#
# POST /issues/{n}/labels ["probe-a","probe-b"] -> 200
# DELETE /issues/{n}/labels/{id} -> 500 labels unchanged
# PUT /issues/{n}/labels {"labels":[<id>]} -> 200
# PUT /issues/{n}/labels {"labels":[]} -> 200 (full clear)
#
# A PAT gets 204 on the same DELETE, which is why this survived a week
# unseen: it fails only for `${{ github.token }}`, and only inside Actions.
# Net effect before this fix: on Forgejo the state machine could only ever
# ADD labels — every `state:*` transition needing the previous state cleared,
# and every `blocker:*` that should lift, was inert.
#
# So a removal is expressed as a full-set PUT, exactly as the assignee branch
# below expresses its own delta as one PATCH — read current, compute wanted,
# write once.
#
# AN ADD-ONLY CALL KEEPS ITS ADDITIVE POST, deliberately. ceremony#128 lost
# its `release` label — the merge door's declared-intent read — to a
# read-modify-write that clobbered a label set two seconds after a builder
# wrote it, and `forge_labels_add` is pinned against ever doing that
# (test/forge-backends.test.sh). The read-modify-write window is real and is
# accepted HERE and only here, where the caller has asked to REMOVE something
# and no additive verb can express that.
if [ "${#rm_labels[@]}" -gt 0 ]; then
local current want_pairs ids id name payload
local want_ids=() missing=()
# name<TAB>id straight from the ISSUE payload. Preserved labels carry
# their authoritative id here already, so they need no second lookup —
# re-resolving them through the repository-wide list would make
# preservation depend on a paginated read that has nothing to do with
# this issue, and an incomplete one would drop a bystander
# (@codex-reviewer-andresmgsl). Only ADDED names need forgejo_label_ids.
current="$(forge_api "repos/$REPO/issues/$n" \
--jq '[.labels[]? | "\(.name)\t\(.id)"] | join("\n")')" || return 1
# The rows are name<TAB>id, so removals filter on the NAME field — a
# whole-line match would never fire against a pair.
want_pairs="$(
awk -F '\t' 'NR==FNR { drop[$0]=1; next } !($1 in drop)' \
<(printf '%s\n' "${rm_labels[@]}") \
<(printf '%s\n' "$current" | grep -v '^$')
)"
while IFS=$'\t' read -r name id; do
[ -n "$name" ] || continue
want_ids+=("$id")
done <<<"$want_pairs"
if [ "${#add_labels[@]}" -gt 0 ]; then
ids="$(forgejo_label_ids)" || return 1
for name in "${add_labels[@]}"; do
# already on the issue? its id is in want_ids already
awk -F '\t' -v want="$name" '$1 == want { found=1 } END { exit !found }' \
<<<"$want_pairs" && continue
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
if [ -z "$id" ]; then missing+=("$name"); continue; fi
want_ids+=("$id")
done
fi
# An add-label the repo does not carry refuses BEFORE the write. A PUT
# that silently dropped an unresolvable name would remove a label nobody
# asked to remove — a destructive write dressed as a partial success.
if [ "${#missing[@]}" -gt 0 ]; then
echo "forge_issue_edit: #$n: no label id on $REPO for: ${missing[*]} — refusing to PUT a set that would drop it" >&2
return 1
fi
# NOTHING TO CHANGE, NOTHING TO WRITE. The reconcilers call
# --remove-label unconditionally to converge state, so most calls here ask
# to remove a label the issue does not carry. Writing the unchanged set
# back would open the read-modify-write window of ceremony#128 for no
# state change at all; the GET above is already the proof the sweep
# reached the forge (@codex-reviewer-andresmgsl). gh's own behaviour on an
# absent --remove-label is likewise a no-op.
local current_ids
current_ids="$(printf '%s\n' "$current" | grep -v '^$' | cut -f2 | sort -n | tr '\n' ' ')"
if [ "$(printf '%s\n' ${want_ids[@]+"${want_ids[@]}"} | grep -v '^$' | sort -n | tr '\n' ' ')" = "$current_ids" ]; then
return 0
fi
payload="$(printf '%s\n' ${want_ids[@]+"${want_ids[@]}"} \
| jq -R 'select(. != "") | tonumber' | jq -sc '{labels: .}')"
forgejo_write PUT "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1
elif [ "${#add_labels[@]}" -gt 0 ]; then
local payload
payload="$(printf '%s\n' "${add_labels[@]}" | jq -R . | jq -sc '{labels: .}')"
forgejo_write POST "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1
fi
if [ "${#rm_labels[@]}" -gt 0 ]; then
local ids id name
ids="$(forgejo_label_ids)" || return 1
for name in "${rm_labels[@]}"; do
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
# A label the repo does not have is not an error: the reconcilers call
# --remove-label unconditionally to converge state, and gh's own
# behaviour there is a no-op.
[ -n "$id" ] || continue
forgejo_write DELETE "repos/$REPO/issues/$n/labels/$id" '' >/dev/null || return 1
done
fi
if [ "${#add_assignees[@]}" -gt 0 ] || [ "${#rm_assignees[@]}" -gt 0 ]; then
local current want payload
current="$(forge_api "repos/$REPO/issues/$n" --jq '[.assignees[]?.login] | join("\n")')" || return 1
@ -458,20 +331,13 @@ forge_pr_view() {
status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1
jq -n --argjson pr "$pr" --argjson st "$status" '
{
# Forgejo folds checking, conflict, check error, and WIP into false.
# Draft must win because WIP makes the boolean carry no merge result (#236).
mergeable: (if $pr.draft == true then "UNKNOWN"
elif $pr.mergeable == true then "MERGEABLE"
elif $pr.merge_base == $pr.base.sha then "UNKNOWN"
else "CONFLICTING" end),
mergeable: (if $pr.mergeable == true then "MERGEABLE"
elif $pr.mergeable == false then "CONFLICTING"
else "UNKNOWN" end),
statusCheckRollup: [
$st.statuses[]? | {
__typename: "StatusContext",
context: .context,
# Forgejo carries the workflow name only as the context prefix;
# no separator means no proven workflow, so never guess (#243).
workflowName: ((.context // "")
| if contains(" / ") then split(" / ")[0] else "" end),
state: (.status | ascii_upcase),
# checks_state groups repeated contexts and takes the NEWEST by
# [.startedAt, .createdAt, .completedAt]. Without a timestamp the
@ -485,15 +351,6 @@ forge_pr_view() {
}'
}
# forge_pr_review_requests <n> — logins with a live review request.
# Forgejo review.go deletes REQUEST_REVIEW rows when the reviewer submits any
# review, so these rows are the exact live set rather than review history (#238).
forge_pr_review_requests() {
local n="${1:?forge_pr_review_requests: number required}"
forge_api --paginate "repos/$REPO/pulls/$n/reviews" \
--jq '.[] | select(.state == "REQUEST_REVIEW") | .user.login' | sort -u
}
forge_label_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; }
# forge_label_create — an UPSERT, matching `gh label create --force` (#4743).
@ -594,13 +451,7 @@ forge_request_reviewer() {
# empty timeline — the two states the ruling ladder must tell apart (#4853).
forge_timeline() {
local n="${1:?forge_timeline: number required}" raw
# Measured on this instance: limit=10 reports x-total-count=10 and limit=50
# reports 50, while crew!96 held 151 events and strict pagination returned
# only its first 50. No other measured endpoint echoes its page size this
# way. Timelines are append-only, so exhaustion can include concurrent new
# events but cannot create a deletion hole; that is why only this call site
# may bypass the header-bound completeness proof (#240).
raw="$(forge_api --paginate-exhaustive "repos/$REPO/issues/$n/timeline")" || return 1
raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1
jq '
[.[]
| select(.type == "label")
@ -630,262 +481,3 @@ forge_pr_activity() {
--jq '.[].created_at' || return 1
done < <(jq -r '.[] | select((.comments_count // 0) > 0) | .id' <<<"$reviews")
}
# --- the release door's facts (#191) --------------------------------------
# Two reads the merge and tag doors depend on. Both answer a QUESTION, and
# both distinguish "the read completed and the answer is no" from "the read
# did not complete" — the distinction lib/facts.sh got wrong before #191,
# where any failure became a definite `no` and a release ceremony was
# silently demoted to a bare push.
#
# Measured on forgejo.heavyduty.builders (8.0.3+gitea-1.22.0), 2026-08-04:
#
# GET /repos/{o}/{r}/releases/tags/0.4.0 -> 200 (present)
# GET /repos/{o}/{r}/releases/tags/9.9.9 -> 404 (absent — a real answer)
#
# GET /repos/{o}/{r}/commits/{sha}/pull -> 200, a SINGLE PR object
# GET /repos/{o}/{r}/commits/{sha}/pulls -> 404 page not found
# ...on a commit with no PR -> 404 {"message":"pull request
# does not exist …"}
#
# The singular/plural split is the asymmetry: GitHub serves an ARRAY at
# /pulls, Forgejo serves one OBJECT at /pull. Both verbs below emit the
# GitHub shape — a JSON array — so lib/facts.sh carries one jq expression
# for both forges, which is the whole point of the shim.
# forgejo_read_code <endpoint> <body-file> — the raw GET, printing the HTTP
# status on stdout. Separate from forge_api because these two call sites
# must SEE a 404 rather than have it collapsed into a failure.
forgejo_read_code() {
local endpoint="$1" body="$2" base token hdr rc
base="$(forgejo_api_base)" || return 1
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
hdr="$(mktemp)"
curl -sS -D "$hdr" -o "$body" -H "Authorization: token $token" "$base/$endpoint"
rc=$?
if [ "$rc" -ne 0 ]; then
rm -f "$hdr"
echo "forge: GET $endpoint failed to send" >&2
return 1
fi
tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }'
rm -f "$hdr"
}
# forge_release_exists <tag> — prints `yes` for a published release and `no`
# for a draft or a 404. A non-zero exit means the read did not complete and
# the answer is UNKNOWN; the caller must not treat that as `no` (#191, #271).
forge_release_exists() {
local tag="${1:?forge_release_exists: tag required}" body code draft
body="$(mktemp)"
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
case "$code" in
2*)
if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$body" 2>/dev/null)" \
|| [[ "$draft" != true && "$draft" != false ]]; then
rm -f "$body"
echo "forge_release_exists: unreadable draft state for release '$tag' — the answer is unknown, not 'no'" >&2
return 1
fi
[ "$draft" = true ] && echo no || echo yes
;;
404) echo no ;;
*)
rm -f "$body"
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
return 1
;;
esac
rm -f "$body"
}
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
# a JSON ARRAY in GitHub's shape. An empty array is a completed read that
# found nothing; a non-zero exit is a read that did not complete.
# forge_commit_at <sha> — the commit's committer date, ISO-8601, or empty.
#
# THE FOURTH ASYMMETRY (#209), measured 2026-08-05:
#
# GET /repos/{o}/{r}/commits/{sha} -> 404 (200 on GitHub)
# GET /repos/{o}/{r}/git/commits/{sha} -> 200 date under `.created`
#
# Found by the first post-merge sweep after the 0.6.0 merge, not by review:
# #198 ported this call site onto the shim with GitHub's path unchanged, and
# the block it lives in had never executed here before. Every sweep printed
# `could not read the head commit's date` and left blocker:unrequested
# unjudged.
#
# `.created` and not `.commit.committer.date`: the /git/commits payload is the
# git object, whose top-level `created` is the committer date. The verb hides
# both differences so the caller keeps asking for one timestamp.
forge_commit_at() {
local sha="${1:?forge_commit_at: sha required}"
forge_api "repos/$REPO/git/commits/$sha" --jq '.created'
}
forge_commit_pulls() {
local sha="${1:?forge_commit_pulls: sha required}" body code out
body="$(mktemp)"
code="$(forgejo_read_code "repos/$REPO/commits/$sha/pull" "$body")" || { rm -f "$body"; return 1; }
case "$code" in
2*)
# One object -> a one-element array, so the call site's jq is the
# same expression it runs against GitHub.
if ! out="$(jq -c '[.]' <"$body" 2>/dev/null)"; then
rm -f "$body"
echo "forge_commit_pulls: unreadable JSON for '$sha'" >&2
return 1
fi
printf '%s\n' "$out"
;;
404) printf '[]\n' ;;
*)
rm -f "$body"
echo "forge_commit_pulls: HTTP $code reading the PR for '$sha' — the answer is unknown, not 'none'" >&2
return 1
;;
esac
rm -f "$body"
}
# --- the release door's writes (#191) -------------------------------------
# Confirmed against this instance's own swagger, 2026-08-04:
#
# POST /repos/{o}/{r}/tags -> exists (tag creation)
# GET /repos/{o}/{r}/git/refs -> GET ONLY (no POST)
# POST /repos/{o}/{r}/releases -> exists
# POST /repos/{o}/{r}/releases/{id}/assets -> exists
#
# The asymmetry worth naming: GitHub creates a tag by POSTing a ref to
# /git/refs; Forgejo does not serve POST there at all and creates tags at
# /tags instead. A 1:1 port of the gh call would 404 forever.
# forge_tag_create <tag> <sha>
forge_tag_create() {
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
forgejo_write POST "repos/$REPO/tags" \
"$(jq -nc --arg t "$tag" --arg s "$sha" '{tag_name:$t,target:$s}')" >/dev/null
}
# forgejo_urlencode <string> — percent-encode one query VALUE. jq is already
# a hard dependency of this backend, and @uri is its one correct answer; a
# hand-rolled sed class is how the next unescaped character gets through.
forgejo_urlencode() {
jq -rn --arg s "${1-}" '$s|@uri'
}
# forgejo_release_cleanup_draft <id> <tag> — best-effort rollback after a
# post-create failure. The caller has already printed the original failure;
# cleanup can add evidence but must never replace that diagnosis (#271).
forgejo_release_cleanup_draft() {
local id="${1:?forgejo_release_cleanup_draft: id required}"
local tag="${2:?forgejo_release_cleanup_draft: tag required}" cleanup
if ! cleanup="$(forgejo_write DELETE "repos/$REPO/releases/$id" '' 2>&1)"; then
[ -z "$cleanup" ] || printf '%s\n' "$cleanup" >&2
echo "forge_release_create: cleanup failed; stranded draft $id for tag '$tag'" >&2
fi
return 0
}
# forge_release_create <tag> <title> <notes-file> [asset…] — creates a draft,
# uploads every asset, then publishes it. Any failure after create removes the
# draft, so the function leaves either a complete published release or nothing.
forge_release_create() {
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
local notes="${3:?forge_release_create: notes file required}" out id base token
local existing code draft existing_id
shift 3
# A previous rollback whose DELETE failed must not wedge the next attempt.
# Remove only a draft for this exact tag; a published release is never
# touched and its create will retain Forgejo's ordinary conflict refusal.
existing="$(mktemp)"
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$existing")" || { rm -f "$existing"; return 1; }
case "$code" in
2*)
if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$existing" 2>/dev/null)" \
|| [[ "$draft" != true && "$draft" != false ]]; then
rm -f "$existing"
echo "forge_release_create: unreadable draft state for release '$tag' — refusing to replace it" >&2
return 1
fi
if [ "$draft" = true ]; then
existing_id="$(jq -r '.id // empty' "$existing")"
if [ -z "$existing_id" ]; then
rm -f "$existing"
echo "forge_release_create: the stranded draft for tag '$tag' has no release id — refusing to replace it" >&2
return 1
fi
echo "forge_release_create: removing stranded draft $existing_id for tag '$tag' before publish" >&2
if ! forgejo_write DELETE "repos/$REPO/releases/$existing_id" '' >/dev/null; then
rm -f "$existing"
echo "forge_release_create: could not remove stranded draft $existing_id for tag '$tag'" >&2
return 1
fi
fi
;;
404) ;;
*)
rm -f "$existing"
echo "forge_release_create: HTTP $code checking for a stranded draft for tag '$tag' — refusing to publish" >&2
return 1
;;
esac
rm -f "$existing"
out="$(forgejo_write POST "repos/$REPO/releases" \
"$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \
'{tag_name:$t,name:$n,body:$b,draft:true,prerelease:false}')")" || return 1
id="$(printf '%s' "$out" | jq -r '.id // empty')"
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
if ! base="$(forgejo_api_base)"; then
forgejo_release_cleanup_draft "$id" "$tag"
return 1
fi
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
local f name
for f in "$@"; do
[ -e "$f" ] || continue
# The asset name is a QUERY VALUE, and the hook contract permits any
# file the consumer drops in RELEASE_ASSETS_DIR. Raw interpolation broke
# on a space (curl exits 3 on the malformed URL) and silently changed
# the name on '&', '#', '+' and '%' — `gh release create` handled those,
# so a 1:1 port had to as well (#191, found by @codex on !193).
name="$(forgejo_urlencode "$(basename "$f")")"
curl -sS -f -X POST -H "Authorization: token $token" \
-F "attachment=@$f" \
"$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \
|| {
echo "forge_release_create: asset upload failed for '$f'" >&2
forgejo_release_cleanup_draft "$id" "$tag"
return 1
}
done
if ! forgejo_write PATCH "repos/$REPO/releases/$id" '{"draft":false}' >/dev/null; then
forgejo_release_cleanup_draft "$id" "$tag"
return 1
fi
}
# forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes
# label IDs, not names (the same asymmetry the issue-label writes carry), so
# the names are resolved first through forgejo_label_ids.
forge_pr_create() {
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
shift 4
local ids='[]' map name id
if [ "$#" -gt 0 ]; then
map="$(forgejo_label_ids)" || return 1
ids='['
for name in "$@"; do
id="$(printf '%s\n' "$map" | awk -F'\t' -v n="$name" '$1 == n { print $2; exit }')"
[ -n "$id" ] || { echo "forge_pr_create: no label '$name' in this repo" >&2; return 1; }
ids="$ids$id,"
done
ids="${ids%,}]"
fi
forgejo_write POST "repos/$REPO/pulls" \
"$(jq -nc --arg h "$head" --arg b "$base" --arg t "$title" --arg d "$body" \
--argjson l "$ids" '{head:$h,base:$b,title:$t,body:$d,labels:$l}')" >/dev/null
}

View file

@ -101,12 +101,6 @@ forge_pr_view() {
gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup
}
# forge_pr_review_requests <n> — logins with a live review request.
forge_pr_review_requests() {
local n="${1:?forge_pr_review_requests: number required}"
forge_api "repos/$REPO/pulls/$n" --jq '.requested_reviewers[].login' | sort -u
}
# forge_label_list — every label name in the repo.
forge_label_list() {
gh label list -R "$REPO" --limit 200 --json name --jq '.[].name'
@ -162,92 +156,3 @@ forge_pr_activity() {
forge_api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' || return 1
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
}
# --- the release door's facts (#191) --------------------------------------
# The github twins of the forgejo backend's two release-door reads. Term 5
# discipline applies: these are the `gh` calls lib/facts.sh carried before
# the port, with one behaviour added — a read that did not complete is
# reported as such instead of collapsing into a definite `no`.
# forge_release_exists <tag> — prints `yes` or `no`; non-zero exit means the
# read did not complete and the answer is UNKNOWN (#191).
forge_release_exists() {
local tag="${1:?forge_release_exists: tag required}" errf err rc
errf="$(mktemp)"
if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq .tag_name >/dev/null 2>"$errf"; then
rm -f "$errf"
echo yes
return 0
fi
rc=$?
err="$(cat "$errf")"; rm -f "$errf"
# gh's 404 text is stable and is the only failure that is an ANSWER.
case "$err" in
*"HTTP 404"*) echo no; return 0 ;;
esac
echo "forge_release_exists: gh exited $rc reading release '$tag' — the answer is unknown, not 'no': $err" >&2
return 1
}
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
# a JSON array. GitHub serves the array directly; the forgejo twin builds
# one from its single-object endpoint so this call site is identical.
# forge_commit_at <sha> — the commit's committer date, ISO-8601, or empty.
#
# A VERB rather than a path at the call site, because the two forges do not
# agree on where a single commit lives: GitHub serves it at /commits/{sha},
# Forgejo 404s there and serves it at /git/commits/{sha} with the timestamp
# under a different field (#209). The caller wants one timestamp; it should not
# have to know either shape.
forge_commit_at() {
local sha="${1:?forge_commit_at: sha required}"
forge_api "repos/$REPO/commits/$sha" --jq '.commit.committer.date'
}
forge_commit_pulls() {
local sha="${1:?forge_commit_pulls: sha required}" errf out rc err
errf="$(mktemp)"
if out="$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha/pulls" 2>"$errf")"; then
rm -f "$errf"
printf '%s\n' "$out"
return 0
fi
rc=$?
err="$(cat "$errf")"; rm -f "$errf"
case "$err" in
*"HTTP 404"*) printf '[]\n'; return 0 ;;
esac
echo "forge_commit_pulls: gh exited $rc reading the PRs for '$sha' — the answer is unknown, not 'none': $err" >&2
return 1
}
# --- the release door's writes (#191) -------------------------------------
# The gh calls the workflow carried before the port, moved behind the shim
# so the call sites stop naming a client. Term 5: same flags, same order.
# forge_tag_create <tag> <sha>
forge_tag_create() {
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$tag" -f "sha=$sha" >/dev/null
}
# forge_release_create <tag> <title> <notes-file> [asset…]
forge_release_create() {
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
local notes="${3:?forge_release_create: notes file required}"
shift 3
gh release create "$tag" --verify-tag --title "$title" \
--notes-file "$notes" -R "$GITHUB_REPOSITORY" "$@"
}
# forge_pr_create <head> <base> <title> <body> <label…> — the release's
# bump-fallback PR (#191). gh takes repeated --label flags.
forge_pr_create() {
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
shift 4
local args=() l
for l in "$@"; do args+=(--label "$l"); done
gh pr create -R "$GITHUB_REPOSITORY" --head "$head" --base "$base" \
--title "$title" --body "$body" "${args[@]}"
}

View file

@ -126,12 +126,6 @@ FORGE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# environment decides via forge_detect.
forge_select() {
local forge="${1:-}"
# One default for every consumer: the forgejo backend addresses the repo
# through REPO, the github backend reads GITHUB_REPOSITORY. Defaulting
# here means no call site — workflow step or script — can forget it and
# get a repo-less path (#191). The reconcilers still assert their own.
REPO="${REPO:-${GITHUB_REPOSITORY:-}}"
export REPO
if [ -z "$forge" ]; then
forge="$(forge_detect)" || return 1
fi

View file

@ -1,28 +0,0 @@
#!/usr/bin/env bash
# lib/issue_references.sh — the LOCAL / CROSS reference classifier (#61).
#
# Sourced, never executed: no set -e/-u — the sourcing script owns its shell
# options, as lib/closes_references.sh and lib/forge.sh do.
#
# WHY IT LIVES HERE. It was defined inside actions/issueflow-reconcile's
# executable, and lib/closes_references.sh's header recorded the resulting
# wart in prose: "DEPENDENCY: issue_references, from issueflow-reconcile.sh".
# That was tolerable while the reconciler was its only caller. #199 makes
# actions/refs-not-closing a second one, and a composite action cannot source
# another action's program to borrow one function — sourcing a reconciler
# runs a reconciler.
#
# So the dependency the comment described is now a file, and both callers
# source it the same way. Nothing about the classifier changed.
#
# A qualified reference belongs to another repository. The whole token is
# classified BEFORE any number is extracted, so `rig#112` can never be read
# as local `#112` — which is the entire point of the function.
issue_references() { # text on stdin -> LOCAL/CROSS<TAB>reference
{ grep -Eo '([[:alnum:]_.-]+/)?[[:alnum:]_.-]+#[0-9]+|#[0-9]+' || true; } \
| awk '
index($0, "#") == 1 { print "LOCAL\t" substr($0, 2); next }
{ print "CROSS\t" $0 }
'
}

View file

@ -1,81 +0,0 @@
#!/usr/bin/env bash
# lib/preflight.sh — the merge door's resume decision, pure and exhaustively
# tested (issue #273).
#
# A merge-door run creates the tag before the artifact hook and release. A
# failed hook or publish therefore leaves a tag but no release. Re-running the
# same merge commit must resume after that irreversible step; a published
# release or a tag naming another commit must still refuse.
#
# Pure: no repository or forge reads. The workflow establishes four facts:
#
# VER the version being released
# MERGE_SHA the commit this door would tag
# TAG_SHAS object names returned for the direct and peeled tag refs,
# newline-separated; empty means the tag is absent
# RELEASED yes|no — whether a published release for VER exists
#
# Output: resume=yes or resume=no on stdout, notices to stdout, refusals to
# stderr, return 1 on refusal.
#
# The decision table (this IS the spec — issue #273):
#
# | # | RELEASED | TAG_SHAS contains MERGE_SHA | result |
# |---|----------|------------------------------|---------------------------|
# | 1 | yes | either | REFUSE: already released |
# | 2 | no | empty | resume=no: ordinary run |
# | 3 | no | yes | resume=yes + resume NOTICE |
# | 4 | no | non-empty, no | REFUSE: tag is elsewhere |
release_preflight() {
local tag_sha sha
if [ -z "${VER:-}" ]; then
printf '%s\n' "VER is empty — the caller failed to establish the release version. Refusing to decide — creating nothing." >&2
return 1
fi
if [ -z "${MERGE_SHA:-}" ]; then
printf '%s\n' "MERGE_SHA is empty — the caller failed to establish the merge commit. Refusing to decide — creating nothing." >&2
return 1
fi
if [ -z "${RELEASED:-}" ]; then
printf '%s\n' "RELEASED is empty — the caller failed to establish whether release '$VER' exists. Refusing to decide — creating nothing." >&2
return 1
fi
case "$RELEASED" in
yes | no) ;;
*)
printf '%s\n' "RELEASED='$RELEASED' — expected yes or no. Refusing to decide — creating nothing." >&2
return 1
;;
esac
# Row 1 comes first: deleting a tag under a standing release never makes
# that release safe to recreate.
if [ "$RELEASED" = yes ]; then
printf '%s\n' "release '$VER' already exists — this release already happened; refusing to re-release, creating nothing." >&2
return 1
fi
# Row 2: an absent tag is the ordinary first run.
if [ -z "${TAG_SHAS:-}" ]; then
printf '%s\n' 'resume=no'
return 0
fi
# Row 3: compare each object name as a whole line. For an annotated tag the
# direct ref names the tag object and the peeled ref names MERGE_SHA.
while IFS= read -r sha; do
if [ "$sha" = "$MERGE_SHA" ]; then
printf '%s\n' "NOTICE: tag '$VER' already stands at this merge commit and no release exists — a previous run of this door tagged and then failed to publish. Resuming: the tag is not recreated; the artifact hook and the publish run."
printf '%s\n' 'resume=yes'
return 0
fi
done <<<"$TAG_SHAS"
# Row 4: the first object name is enough to diagnose the conflicting tag;
# MERGE_SHA is printed beside it so the operator sees both sides.
tag_sha="${TAG_SHAS%%$'\n'*}"
printf '%s\n' "tag '$VER' already exists at $tag_sha but this run would tag $MERGE_SHA — a manual tag won the race, or it names a different commit; refusing to re-release, creating nothing. Delete that tag, or re-tag the merge commit." >&2
return 1
}

View file

@ -1,62 +0,0 @@
#!/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,40 +99,18 @@ 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. 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.
# 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.
local body field missing=""
body="$(cat)"
for field in "${RULING_SHAPE_FIELDS[@]}"; do
ruling_shape_field_present "$field" <<<"$body" || missing="$missing $field"
grep -Eq "^[[:space:]]*(\*\*)?$field" <<<"$body" || missing="$missing $field"
done
if [ -z "$missing" ]; then echo SHAPED; else echo "MALFORMED$missing"; fi
}
@ -175,11 +153,8 @@ 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, 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
# Real activity only — comments, reviews, commits, 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
@ -195,32 +170,17 @@ 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 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=""
# → "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=""
while read -r login epoch url b64; do
[ -n "$login" ] || continue
[ "$login" = "$setter" ] || continue
ruling_accompanies "$epoch" "$labeled" || continue
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"
if [ -z "$best_epoch" ] || [ "$epoch" -lt "$best_epoch" ]; then
best_epoch="$epoch"
best="$url ${b64:-}"
fi

View file

@ -1,47 +0,0 @@
#!/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 (#115)." >"$TMP/fragments-dev-flat/changelog.d/115.md"
printf '%s\n' "- Added fragment mode." >"$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,38 +298,6 @@ 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
@ -342,7 +310,7 @@ EOF
cat >"$TMP/fragments-dev-grouped/changelog.d/115.md" <<'EOF'
### Changed
- Added fragment mode (#115).
- Added fragment mode.
EOF
check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \
in_tree fragments-dev-grouped
@ -354,11 +322,11 @@ fragment_tree fragments-dev-mixed 1.2.4-dev <<'EOF'
- The shipped entry.
EOF
printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-mixed/changelog.d/114.md"
printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-mixed/changelog.d/114.md"
cat >"$TMP/fragments-dev-mixed/changelog.d/115.md" <<'EOF'
### Fixed
- Grouped fragment (#115).
- Grouped fragment.
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" \
@ -374,7 +342,7 @@ EOF
cat >"$TMP/fragments-dev-all-grouped-over-flat/changelog.d/115.md" <<'EOF'
### Fixed
- Grouped fragment (#115).
- Grouped fragment.
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'" \
@ -389,7 +357,7 @@ fragment_tree fragments-dev-flat-over-grouped 1.2.4-dev <<'EOF'
- The shipped entry.
EOF
printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md"
printf '%s\n' "- Flat fragment." >"$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
@ -408,14 +376,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 (#115).
- Grouped fragment.
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 (#116)." >"$TMP/fragments-dev-flip/changelog.d/116.md"
printf '%s\n' "- Flat probe." >"$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
@ -425,7 +393,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 (#116)." >"$TMP/fragments-dev-flip/changelog.d/116.md"
printf '%s\n' "- Flat probe." >"$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
@ -449,7 +417,7 @@ EOF
cat >"$TMP/fragments-dev-no-published/changelog.d/115.md" <<'EOF'
### Fixed
- Grouped fragment (#115).
- Grouped fragment.
EOF
check "fragment mode accepts a consistent set with no published section" 0 \
"fragment mode" in_tree fragments-dev-no-published
@ -516,7 +484,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 (#115)." \
printf '%s\n' "- This entry was not consumed." \
>"$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 (#12).
- Twelve landed.
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 (#12).\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.\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 (#2).
- Two.
EOF
frag flat-many 9.md <<'EOF'
- Nine (#9).
- Nine.
EOF
frag flat-many 10.md <<'EOF'
- Ten (#10).
- Ten.
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 (#14).
continuation line, so it must survive verbatim.
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 (#14).\n- Ten (#10).\n- Nine (#9).\n- Two (#2).'
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.'
# --- grouped write: canonical order, unnamed group appended ------------------
@ -113,28 +113,28 @@ EOF
frag grouped 21.md <<'EOF'
### Fixed
- Fixed twenty-one (#21).
- Fixed twenty-one.
EOF
frag grouped 20.md <<'EOF'
### Added
- Added twenty (#20).
- Added twenty, second bullet (#20).
- Added twenty.
- Added twenty, second bullet.
### Docs
- Docs twenty (#20).
- Docs twenty.
EOF
frag grouped 19.md <<'EOF'
### Security
- Security nineteen (#19).
- Security nineteen.
### Added
- Added nineteen (#19).
- Added nineteen.
EOF
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).'
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.'
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 (#40).
- Forty landed.
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 (#40).\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.\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 (#41).
- Flat forty-one.
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 (#42).
- Forty-two.
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,45 +196,13 @@ tree preamble-only <<'EOF'
Only preamble so far.
EOF
frag preamble-only 1.md <<'EOF'
- The first entry ever (#1).
- The first entry ever.
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 (#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
$'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever.'
# --- --check is provably read-only -------------------------------------------
@ -242,10 +210,10 @@ tree check-readonly <<EOF
$BASE_CHANGELOG
EOF
frag check-readonly 5.md <<'EOF'
- Five (#5).
- Five.
EOF
cp -R "$TMP/check-readonly" "$TMP/check-readonly.before"
check "--check prints the assembled body" 0 "Five (#5)." \
check "--check prints the assembled body" 0 "Five." \
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"
@ -261,11 +229,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 (#2).\n' >"$TMP/flagged/frags/2.md"
printf -- '- Flagged entry.\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 (#2)." "$TMP/flagged/NOTES.md"
grep -qF -- "- Flagged entry." "$TMP/flagged/NOTES.md"
# --- refusals: each names the file responsible -------------------------------
@ -324,7 +292,7 @@ tree stray-txt <<EOF
$BASE_CHANGELOG
EOF
frag stray-txt 7.md <<'EOF'
- Seven (#7).
- Seven.
EOF
frag stray-txt notes.txt <<'EOF'
A stray scratchpad.
@ -354,12 +322,12 @@ tree mixed <<EOF
$BASE_CHANGELOG
EOF
frag mixed 5.md <<'EOF'
- Flat five (#5).
- Flat five.
EOF
frag mixed 6.md <<'EOF'
### Added
- Grouped six (#6).
- Grouped six.
EOF
check "grouped + flat mixed refuses, both files named" 1 "6.md" \
in_tree mixed 0.2.0
@ -372,7 +340,7 @@ EOF
frag grouped-over-flat 6.md <<'EOF'
### Added
- Grouped six (#6).
- Grouped six.
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'" \
@ -386,7 +354,7 @@ tree already <<'EOF'
- Already shipped.
EOF
frag already 4.md <<'EOF'
- A late fragment (#4).
- A late fragment.
EOF
check "an already-present section refuses — the ceremony was already run" 1 \
"already has a section for '0.2.0'" \
@ -402,13 +370,13 @@ tree rc-present <<'EOF'
- The candidate's entry.
EOF
frag rc-present 8.md <<'EOF'
- The real release entry (#8).
- The real release entry.
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 (#2).\n' >"$TMP/no-changelog/changelog.d/2.md"
printf -- '- Entry.\n' >"$TMP/no-changelog/changelog.d/2.md"
check "a missing changelog refuses" 1 "no such file" \
in_tree no-changelog 0.2.0
@ -436,12 +404,12 @@ frag round-trip 30.md <<'EOF'
### Added
- Thirty — wraps onto a
continuation line with a naïve café (#30).
continuation line with a naïve café.
EOF
frag round-trip 29.md <<'EOF'
### Fixed
- Fixed twenty-nine (#29).
- Fixed twenty-nine.
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 (#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"
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"
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 (#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"
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"
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 (#13).\n' >"$TMP/ordinary-add/changelog.d/13.md"
printf -- '- Thirteen incoming.\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 (#9).
- Twelve landed (#12).
continuation line.
- Twelve landed.
## 0.1.0 — 2026-07-01
@ -210,56 +210,13 @@ 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 (#9).\n' >"$TMP/survivor/changelog.d/9.md"
printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\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
check "the survivor refusal names the file" 1 "changelog.d/9.md" \
run survivor base
# A release PR can be faithful to its merge base while the target branch moves
# ahead and gains a fragment during review. That target-only fragment was not
# available to the ceremony, so merging the PR would strand it for the next
# release. The guard must read the target ref as well as their merge base.
seed_flat target-stranded
ceremony target-stranded 0.2.0 2026-07-24
commit_head target-stranded
git -C "$TMP/target-stranded" switch -q base
printf -- '- Landed while the release was under review (#30).\n' \
>"$TMP/target-stranded/changelog.d/30.md"
git -C "$TMP/target-stranded" add -A
git -C "$TMP/target-stranded" commit -qm target-fragment
git -C "$TMP/target-stranded" switch -q main
check "a target-head fragment the release did not consume fails" 1 \
"changelog.d/30.md" run target-stranded base
check "the target-stranding refusal names the rebase remedy" 1 \
"rebase onto the target head" run target-stranded base
check "the target-stranding refusal names the assembler re-run" 1 \
"changelog-assemble '0.2.0'" run target-stranded base
# Removing the target-only fragment makes the same diverged fixture green:
# target drift itself is not the failure, only a stranded fragment is.
git -C "$TMP/target-stranded" switch -q base
rm "$TMP/target-stranded/changelog.d/30.md"
git -C "$TMP/target-stranded" add -A
git -C "$TMP/target-stranded" commit -qm target-fragment-removed
git -C "$TMP/target-stranded" switch -q main
check "the same target fixture is green once no fragment is stranded" 0 \
"byte-for-byte" run target-stranded base
# Spell out the common harmless case independently: the target branch moved,
# but the advancing commit added no fragment.
seed_flat target-unrelated
ceremony target-unrelated 0.2.0 2026-07-24
commit_head target-unrelated
git -C "$TMP/target-unrelated" switch -q base
printf 'unrelated target change\n' >"$TMP/target-unrelated/code.txt"
git -C "$TMP/target-unrelated" add -A
git -C "$TMP/target-unrelated" commit -qm target-unrelated
git -C "$TMP/target-unrelated" switch -q main
check "a target head advanced without a fragment stays green" 0 \
"byte-for-byte" run target-unrelated base
# Fragments consumed, section never stamped: the prose went nowhere.
seed_flat halfdone
rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md"
@ -361,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 (#2).\n' >"$TMP/env-tree/frags/2.md"
printf -- '- Flagged entry.\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 (#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"
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"
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 (#7).\n' >"$PF/7.md"
printf -- '- Fine.\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 (#8).
- Grouped fine.
EOF
check "fragment predicate: a grouped fragment passes" 0 "" \
changelog_fragment_problem "$PF/8.md"
printf -- '- Cross-repo (#14).\n' >"$PF/ceremony-14.md"
printf -- '- Cross-repo.\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 (#31).\n' "$(mkchars 293)" >"$PF/31.md"
printf -- '- %s\n' "$(mkchars 300)" >"$PF/31.md"
check "length bound: an entry of exactly 300 passes" 0 "" \
changelog_fragment_problem "$PF/31.md"
{
printf -- '- %s (#32).\n' "$(mkchars 143)"
printf -- '- %s (#32).\n' "$(mkchars 143)"
printf -- '- %s (#32).\n' "$(mkchars 143)"
printf -- '- %s\n' "$(mkchars 150)"
printf -- '- %s\n' "$(mkchars 150)"
printf -- '- %s\n' "$(mkchars 150)"
} >"$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 (#33).\n' "$(mkchars 50)"
printf ' %s\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 (#34).\n' "$(mkchars 293)"
printf -- '- %s\n' "$(mkchars 300)"
} >"$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,151 +331,6 @@ 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() {
@ -492,11 +347,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 (#3).
wraps onto a continuation line.
EOF
printf -- '- Ten (#10).\n- Ten again (#10).\n' >"$AF/10.md"
printf -- '- Ten.\n- Ten again.\n' >"$AF/10.md"
check "assemble: flat fragments, newest issue first, prose verbatim" 0 "" \
assert_assemble "$AF" $'- Ten (#10).\n- Ten again (#10).\n- Three — an em dash, and prose that\n wraps onto a continuation line (#3).'
assert_assemble "$AF" $'- Ten.\n- Ten again.\n- Three — an em dash, and prose that\n wraps onto a continuation line.'
check "assemble: an empty directory is empty output — refusing is the caller's stance" 0 "" \
changelog_assemble "$TMP/no-such-dir"
@ -506,36 +361,36 @@ mkdir -p "$AG"
cat >"$AG/21.md" <<'EOF'
### Fixed
- Fixed twenty-one (#21).
- Fixed twenty-one.
EOF
cat >"$AG/20.md" <<'EOF'
### Added
- Added twenty (#20).
- Added twenty.
### Docs
- Docs twenty (#20).
- Docs twenty.
EOF
cat >"$AG/19.md" <<'EOF'
### Security
- Security nineteen (#19).
- Security nineteen.
### Added
- Added nineteen (#19).
- Added nineteen.
EOF
check "assemble: canonical group order, unnamed group appended, fragment order inside a group" 0 "" \
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).'
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.'
AM="$TMP/assemble-mixed"
mkdir -p "$AM"
printf -- '- Flat five (#5).\n' >"$AM/5.md"
printf -- '- Flat five.\n' >"$AM/5.md"
cat >"$AM/6.md" <<'EOF'
### Added
- Grouped six (#6).
- Grouped six.
EOF
check "assemble: mixed shapes refused, grouped side named" 1 "6.md" \
changelog_assemble "$AM"
@ -545,11 +400,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 (#7).
- Ungrouped lead.
### Added
- Grouped follow (#7).
- Grouped follow.
EOF
check "assemble: one fragment mixing both shapes is refused, file named" 1 \
"'$AX/7.md' mixes grouped headings and ungrouped bullets" \
@ -574,14 +429,14 @@ cat >"$SHAPE_CHANGELOG" <<'EOF'
- Older section is grouped.
EOF
printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md"
printf -- '- Flat fragment.\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 (#1).
- Grouped fragment.
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" \
@ -596,7 +451,7 @@ cat >"$SHAPE_CHANGELOG" <<'EOF'
- Newest section is grouped.
EOF
printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md"
printf -- '- Flat fragment.\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"
@ -604,7 +459,7 @@ check "shape: flat set names its conflict with newest grouped published section"
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment (#1).
- Grouped fragment.
EOF
check "shape: grouped set matches newest grouped published section" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
@ -629,7 +484,7 @@ EOF
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment (#1).
- Grouped fragment.
EOF
printf 'grouped\n' >"$SHAPE_DIR/shape"
check "shape: 'grouped' sentinel admits a grouped set over a flat published section" 0 "" \
@ -637,7 +492,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 (#1).\n' >"$SHAPE_DIR/1.md"
printf -- '- Flat fragment.\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"
@ -657,14 +512,14 @@ check "shape: 'flat' sentinel admits a flat set over a grouped published section
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment (#1).
- Grouped fragment.
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 (#2).\n' >"$SHAPE_DIR/2.md"
printf -- '- Flat two.\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"
@ -703,7 +558,7 @@ printf 'grouped\n' >"$SHAPE_DIR/shape"
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment (#1).
- Grouped fragment.
EOF
assert_fragments_exclude_sentinel() {
local out
@ -724,17 +579,17 @@ printf 'grouped\n' >"$AS/shape"
cat >"$AS/30.md" <<'EOF'
### Fixed
- Fixed thirty (#30).
- Fixed thirty.
EOF
cat >"$AS/31.md" <<'EOF'
### Added
- Added thirty-one (#31).
- Added thirty-one.
EOF
check "assemble: the sentinel never assembles, and canonical order holds under it" 0 "" \
assert_assemble "$AS" $'### Added\n\n- Added thirty-one (#31).\n\n### Fixed\n\n- Fixed thirty (#30).'
assert_assemble "$AS" $'### Added\n\n- Added thirty-one.\n\n### Fixed\n\n- Fixed thirty.'
rm "$AS/30.md" "$AS/31.md"
printf -- '- Flat probe (#29).\n' >"$AS/29.md"
printf -- '- Flat probe.\n' >"$AS/29.md"
check "assemble: a flat set under a 'grouped' sentinel refuses to assemble" 1 \
"declares grouped" \
changelog_assemble "$AS"

View file

@ -1,9 +1,8 @@
#!/usr/bin/env bash
# Contract tests for actions/docs-sync (issue #19). Constructed SOURCE trees
# (a fake ceremony: manifest + docs) and CONSUMER trees (a release.yml
# caller with the pin line), driven offline via --source. The fetch path is
# driven too, against a PATH-stubbed curl rather than the network (#201) —
# which forge a pin resolves against is a decision, not plumbing. The fake
# caller with the pin line), driven offline via --source — the fetch path
# needs the network and is exercised by consumers, not here. The fake
# source's doc set is deliberately NOT the real five: a script that
# hardcodes the vendored list instead of reading the manifest fails these
# rows. set -u, not -e: failing commands are behavior for the harness to
@ -19,14 +18,6 @@ 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
@ -300,80 +291,4 @@ check "unknown flag refused" 1 "unknown argument" \
check "--source without a directory refused" 1 "no such directory" \
in_consumer env-wired --check --source "$TMP/does-not-exist"
# --- the fetch path: which forge, and never a guessed one (#201) ---------------
# The fetch path had no coverage at all before this: every row above passes
# --source, which overrides the fetch entirely, so the URL the tool actually
# builds was asserted nowhere. It is asserted here with a PATH-stubbed curl
# that records the URL and serves a tarball of the fake source tree — no
# network, and the real tar pipeline still runs, so --strip-components stays
# honest. CURL_FAIL makes the stub fail the way a missing ref does.
FETCHBIN="$TMP/fetchbin"
mkdir -p "$FETCHBIN"
cat >"$FETCHBIN/curl" <<'STUB'
#!/usr/bin/env bash
printf '%s\n' "${!#}" >>"$CURL_URL_LOG"
[ -z "${CURL_FAIL:-}" ] || exit 22
exec tar -cz -C "$(dirname "$CURL_SRC")" "$(basename "$CURL_SRC")"
STUB
chmod +x "$FETCHBIN/curl"
export CURL_URL_LOG="$TMP/curl-urls" CURL_SRC="$SRC"
consumer fetched 0.4.1
# fetch_sync <server-url> <args...> — the fetch path, no --source. Truncates
# the URL log first so requested_url always answers about this run.
fetch_sync() {
local server="$1"
shift
: >"$CURL_URL_LOG"
(cd "$TMP/fetched" && PATH="$FETCHBIN:$PATH" GITHUB_SERVER_URL="$server" \
bash "$SCRIPT" "$@")
}
requested_url() { cat "$CURL_URL_LOG"; }
check "the fetch mirrors the pin fetched from the forge in the environment" 0 \
"added .ceremony/RULES.md" fetch_sync https://forgejo.example.test --fix
check "...and the URL asked for names that forge, not a built-in one" 0 \
"https://forgejo.example.test/heavy-duty/ceremony/archive/0.4.1.tar.gz" \
requested_url
# One pin ref, two forges, two trees — the whole reason #201 exists. The same
# consumer must fetch from whichever forge it is running on.
fetch_sync https://github.com --fix >/dev/null 2>&1
check "the same pin on another forge fetches from that forge instead" 0 \
"https://github.com/heavy-duty/ceremony/archive/0.4.1.tar.gz" requested_url
fetch_sync https://forgejo.example.test/ --fix >/dev/null 2>&1
check "a trailing slash on the server URL does not double the separator" 0 \
"https://forgejo.example.test/heavy-duty/ceremony/archive/0.4.1.tar.gz" \
requested_url
# Unset is not github.com. A tool that never guesses a ref must not guess a
# forge either — and it must refuse BEFORE reaching for the network.
no_server_sync() {
: >"$CURL_URL_LOG"
(cd "$TMP/fetched" && PATH="$FETCHBIN:$PATH" \
env -u GITHUB_SERVER_URL bash "$SCRIPT" --check)
}
check "no GITHUB_SERVER_URL and no --source → refuse, naming the variable" 1 \
"GITHUB_SERVER_URL is unset" no_server_sync
check "...and the refusal says it never guesses a forge" 1 \
"never guesses a forge" no_server_sync
nothing_fetched() { [ ! -s "$CURL_URL_LOG" ]; }
check "...and nothing was fetched before refusing" 0 "" nothing_fetched
# A ref that does not resolve on the forge in play: the message must name the
# URL actually attempted, so "does the pinned ref exist" is answerable.
fetch_fail() {
: >"$CURL_URL_LOG"
(cd "$TMP/fetched" && PATH="$FETCHBIN:$PATH" CURL_FAIL=1 \
GITHUB_SERVER_URL=https://forgejo.example.test bash "$SCRIPT" --check)
}
check "a failed fetch names the URL it tried" 1 \
"https://forgejo.example.test/heavy-duty/ceremony/archive/0.4.1.tar.gz" fetch_fail
check "...and asks about the ref on that forge, not in the abstract" 1 \
"exist on that forge" fetch_fail
summary

View file

@ -29,39 +29,20 @@ ZEROS="0000000000000000000000000000000000000000"
mkdir -p "$TMP/stub"
cat >"$TMP/stub/gh" <<'EOF'
#!/usr/bin/env bash
# Every mode below answers the call shape the shim now makes (#191):
# labeled -> gh api repos/{r}/commits/{sha}/pulls (a JSON ARRAY)
# released -> gh api repos/{r}/releases/tags/{tag}
# The *-unreadable modes are the ones that matter: they fail the way a real
# client fails when it cannot reach the forge, and must NOT be reported as a
# definite answer.
case "${GH_STUB:-none}" in
labeled-yes)
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
labeled-yes | labeled-no)
if [ "$1" != api ]; then
echo "gh stub: expected an api call, got: gh $*" >&2
exit 97
fi
[ "${GH_STUB}" = labeled-yes ] && echo true || echo false
;;
labeled-no)
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"enhancement"}]}]'
;;
labeled-none)
# A completed read that found no PR at all — still an answer.
echo '[]'
;;
labeled-unmerged)
# A PR carrying the label but never merged: the label alone is not a
# ceremony (the `merged_at != null` half of the contract).
echo '[{"merged_at":null,"labels":[{"name":"release"}]}]'
;;
labeled-unreadable | released-unreadable)
echo "gh: Connection refused (HTTP 000)" >&2
exit 1
;;
released-yes)
echo "$2" | grep -q 'releases/tags/' || { echo "gh stub: expected a releases/tags read, got: gh $*" >&2; exit 97; }
echo "0.0.0"
;;
released-no)
echo "gh: Not Found (HTTP 404)" >&2
exit 1
released-yes | released-no)
if [ "$1" != release ]; then
echo "gh stub: expected a release call, got: gh $*" >&2
exit 97
fi
[ "${GH_STUB}" = released-yes ] && exit 0 || exit 1
;;
*)
echo "gh stub: gh must not be called in this state (gh $*)" >&2
@ -93,7 +74,6 @@ facts_in() {
shift
(cd "$TMP/$dir" \
&& env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture GH_TOKEN=stub \
CEREMONY_FORGE=github \
"$@" bash "$FACTS")
}
@ -234,37 +214,4 @@ nv_head="$(commit no-version README.md "with no version at the head either")"
check "no version at the head fails loudly" 1 "no such file" \
facts_in no-version VERSION_SOURCE=file MERGE_SHA="$nv_head" EVENT_BEFORE="$nv_base"
# --- #191: a read that did not complete is not an answer ------------------
# The bug this suite missed before: lib/facts.sh turned ANY failure of the
# label read into `labeled=no`, and decide's row 5 then refused a correctly
# labeled, correctly merged ceremony PR as "a bare push". On a Forgejo
# runner — no `gh` on the image — that was every release. Measured twice in
# the 0.4.1 drill (drills/0.4.1.md) before it was fixed.
#
# The contract now: a COMPLETED read that finds nothing is still `no` and
# still fail-closed; a read that could not complete refuses, loudly, and
# emits no fact at all.
check "a completed read with no PR behind the commit is labeled=no" 0 "labeled=no" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-none
check "a labeled but UNMERGED PR is labeled=no" 0 "labeled=no" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unmerged
check "an unreadable label read refuses instead of saying no" 1 "refusing rather than reporting 'no label'" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable
# ...and emits no fact: a refusal that still printed labeled=no would be the
# same bug wearing a diagnostic.
check "the refusal emits no labeled fact at all" 1 "" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable
if facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable 2>/dev/null | grep -q '^labeled='; then
echo "FAIL: the refusal printed a labeled= line" >&2
FAIL=$((FAIL + 1))
else
echo "ok: no labeled= line survives the refusal"
PASS=$((PASS + 1))
fi
check "an unreadable release read refuses instead of saying no" 1 "refusing rather than reporting 'no'" \
facts_in window VERSION_SOURCE=file MERGE_SHA="$win_head" EVENT_BEFORE="$win_base" GH_STUB=released-unreadable
summary

View file

@ -44,12 +44,6 @@ check "select with no argument reads the environment" 0 "" \
. "$ROOT/lib/forge-github.sh"
. "$ROOT/lib/forge-forgejo.sh"
# Every /api/v1 call ceremony makes is repo-scoped, and since #191 the
# backend refuses to build `repos//…` — so the suite names a repo up front,
# the way every real caller does.
REPO=o/r
export REPO
check "github: a bare path gets a query" 0 "" \
eq 'repos/o/r/issues?per_page=100' github_page_url 'repos/o/r/issues'
check "github: an existing query is preserved" 0 "" \
@ -257,20 +251,9 @@ stub_writes() {
esac
shift
done
# FAKE_FAIL_URL + FAKE_HTTP fault-inject one endpoint, so the refusal
# boundaries are driven rather than assumed (#192 review).
if [ -n "${FAKE_FAIL_URL:-}" ] && [ "${url##*"$FAKE_FAIL_URL"}" != "$url" ]; then
printf 'HTTP/1.1 %s Server Error\r\n\r\n' "${FAKE_HTTP:-500}" >"$hdr"
printf '{}' >"$out"
[ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
return 0
fi
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_LABEL_N:-1}" >"$hdr"
case "$url" in
*"/labels?"* | */labels) printf '%s' "${FAKE_LABELS:-[]}" >"$out" ;;
# The issue itself: the removal path reads its CURRENT label set before
# computing the set to PUT (#192).
*/issues/[0-9]*) printf '{"labels": %s}' "${FAKE_ISSUE_LABELS:-[]}" >"$out" ;;
*) printf '{}' >"$out" ;;
esac
[ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
@ -296,111 +279,19 @@ check "...carrying the updated description" 0 "" grep -q 'new text' "$WRITES"
# #4751 item 2). Live scratch-repo evidence proved these work; these prove
# they keep working, and pin the SHAPE of the requests.
# Removal is a FULL-SET PUT, not a per-label DELETE (#192). Measured under a
# real Actions token, probe run 701: DELETE .../labels/{id} -> 500 for every
# removal, PUT .../labels -> 200 including the empty set. A PAT gets 204 on the
# same DELETE, which is why it went unseen — it fails only for the identity the
# sweep holds.
ROSTER='[{"name":"state:old","id":11},{"name":"state:new","id":12},{"name":"scope:labels","id":13},{"name":"attention","id":14}]'
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"state:old","id":11},{"name":"scope:labels","id":13}]' \
REPO=o/r forge_issue_edit 5 --remove-label state:old
check "removing a label PUTs the whole wanted set" 0 "" \
grep -q '^PUT repos/o/r/issues/5/labels ' "$WRITES"
check "...and never DELETEs, which this instance answers 500" 1 "" \
grep -q '^DELETE ' "$WRITES"
check "...carrying the surviving label's id and not the removed one" 0 '{"labels":[13]}' \
cat "$WRITES"
# Removal resolves name -> id, because Forgejo takes names on add and only a
# numeric id on remove. Measured: DELETE .../labels/probe:one -> 422,
# DELETE .../labels/149 -> 204.
FAKE_LABELS='[{"name":"stale","id":11},{"name":"ready","id":12}]' FAKE_LABEL_N=2 stub_writes
FAKE_LABELS='[{"name":"stale","id":11},{"name":"ready","id":12}]' FAKE_LABEL_N=2 REPO=o/r forge_issue_edit 5 --remove-label stale
check "removing a label resolves its numeric id" 0 "" grep -q '^DELETE repos/o/r/issues/5/labels/11 ' "$WRITES"
check "...and never sends the name as the path segment" 1 "" grep -q 'labels/stale' "$WRITES"
# The contract @codex-reviewer-andresmgsl asked for (#5183): a full-set PUT
# replaces everything, so removal alone proves nothing about PRESERVATION. One
# call, a combined delta, and two bystanders that must survive it.
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 \
FAKE_ISSUE_LABELS='[{"name":"state:old","id":11},{"name":"scope:labels","id":13},{"name":"attention","id":14}]' \
REPO=o/r forge_issue_edit 5 --remove-label state:old --add-label state:new
check "a combined add+remove is ONE write" 0 "" test "$(wc -l <"$WRITES")" -eq 1
preserves_bystanders() { # the PUT keeps state:new(12), scope:labels(13), attention(14)
grep -q 12 "$WRITES" && grep -q 13 "$WRITES" && grep -q 14 "$WRITES"
}
check "...and preserves every unrelated label" 0 "" preserves_bystanders
check "...while dropping only what was asked for" 1 "" grep -qE '(^|[^0-9])11([^0-9]|$)' "$WRITES"
# A label the issue does not carry is a successful no-op that writes NOTHING,
# matching gh: the reconcilers call --remove-label unconditionally to converge
# state, so most calls here ask to remove something absent. Writing the
# unchanged set back would open ceremony#128's read-modify-write window for no
# state change at all, and the GET above is already the proof the sweep reached
# the forge (@codex-reviewer-andresmgsl, #192 review).
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"scope:labels","id":13}]' \
REPO=o/r forge_issue_edit 5 --remove-label state:old
check "removing an absent label succeeds" 0 "" test "$?" -eq 0
check "...writing nothing at all" 0 "" test ! -s "$WRITES"
# A full clear is the empty set, which this instance answers 200 (run 701).
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"state:old","id":11}]' \
REPO=o/r forge_issue_edit 5 --remove-label state:old
check "clearing the last label PUTs the empty set" 0 '{"labels":[]}' cat "$WRITES"
# An add-label the repo does not have must refuse BEFORE any write: a PUT that
# silently dropped an unresolvable name would remove a label nobody asked to
# remove — a destructive write dressed as a partial success.
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes
edit_unknown_add() {
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"state:old","id":11}]' \
REPO=o/r forge_issue_edit 5 --remove-label state:old --add-label no-such-label
}
check "an unknown add-label refuses" 1 "no label id" edit_unknown_add
check "...before writing anything" 0 "" test ! -s "$WRITES"
# The preserved-id contract, and the reason it is not merely an optimisation
# (@codex-reviewer-andresmgsl, #192 review): a bystander's id comes from the
# ISSUE payload, so preservation must not depend on a repository-wide list
# that has nothing to do with this issue. Here `attention` is on the issue with
# id 14 and is ABSENT from the repo-list fixture entirely — a resolution that
# went through forgejo_label_ids would refuse or drop it.
PARTIAL_ROSTER='[{"name":"state:old","id":11},{"name":"state:new","id":12},{"name":"scope:labels","id":13}]'
FAKE_LABELS="$PARTIAL_ROSTER" FAKE_LABEL_N=3 stub_writes
FAKE_LABELS="$PARTIAL_ROSTER" FAKE_LABEL_N=3 \
FAKE_ISSUE_LABELS='[{"name":"state:old","id":11},{"name":"attention","id":14}]' \
REPO=o/r forge_issue_edit 5 --remove-label state:old
check "a bystander absent from the repo list is still preserved by its issue id" 0 \
'{"labels":[14]}' cat "$WRITES"
# The two fault boundaries the acceptance plan names. Both must be non-zero
# with the backend's own diagnostic, and neither may report success.
fail_get() {
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_HTTP=500 FAKE_FAIL_URL='/issues/5' \
REPO=o/r forge_issue_edit 5 --remove-label state:old
}
check "a failed current-label GET refuses, non-zero" 1 "" fail_get
check "...naming the verb, the path AND the status, in one diagnostic" 1 \
"HTTP 500 from 'GET repos/o/r/issues/5'" fail_get
get_write_count() { : >"$WRITES"; fail_get >/dev/null 2>&1; wc -l <"$WRITES"; }
check "...having written nothing: the read failed before any mutation" 0 "0" \
get_write_count
fail_put() {
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 \
FAKE_ISSUE_LABELS='[{"name":"state:old","id":11},{"name":"scope:labels","id":13}]' \
FAKE_HTTP=500 FAKE_FAIL_URL='/issues/5/labels' \
REPO=o/r forge_issue_edit 5 --remove-label state:old
}
check "a failed replacement PUT refuses, non-zero" 1 "" fail_put
check "...naming the verb, the path AND the status, in one diagnostic" 1 \
"HTTP 500 from 'PUT repos/o/r/issues/5/labels'" fail_put
put_write_count() { : >"$WRITES"; fail_put >/dev/null 2>&1; wc -l <"$WRITES"; }
check "...having attempted only the one PUT" 0 "1" put_write_count
# An ADD-ONLY call keeps the additive POST (ceremony#128): a read-modify-write
# there clobbered a label set two seconds after a builder wrote it.
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"scope:labels","id":13}]' \
REPO=o/r forge_issue_edit 5 --add-label state:new
check "an add-only edit still POSTs additively" 0 "" \
grep -q '^POST repos/o/r/issues/5/labels ' "$WRITES"
check "...and never PUTs the whole set (ceremony#128)" 1 "" grep -q '^PUT ' "$WRITES"
# A label the repo does not have is a no-op, matching gh: the reconcilers
# call --remove-label unconditionally to converge state.
FAKE_LABELS='[{"name":"ready","id":12}]' FAKE_LABEL_N=1 stub_writes
FAKE_LABELS='[{"name":"ready","id":12}]' FAKE_LABEL_N=1 REPO=o/r forge_issue_edit 5 --remove-label nonexistent
check "removing an absent label writes nothing" 0 "" test ! -s "$WRITES"
# Adding takes names directly — no lookup, one request.
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
@ -581,93 +472,6 @@ check "...never deriving them from reviews, as forgejo must" 1 "" \
unset -f gh
. "$ROOT/lib/forge-forgejo.sh"
# --- forge_pr_review_requests: the forge's live request set -------------
# A stale verdict is not itself evidence that anybody was re-requested. The
# backend reads the forge's request representation directly, so the state
# machine can distinguish the builder's ball from the panel's (#238).
review_requests_stub() {
fake_forge "$FAKE_REVIEWS_N" "$FAKE_REVIEWS"
}
# crew!96 after the builder pushed: two approvals and one requested change
# belong to the older head, but no live REQUEST_REVIEW row exists.
FAKE_REVIEWS_N=3
FAKE_REVIEWS='[
{"user":{"login":"kimi-bot"},"state":"APPROVED","commit_id":"old","submitted_at":"2026-08-22T23:05:01Z"},
{"user":{"login":"glm-bot"},"state":"APPROVED","commit_id":"old","submitted_at":"2026-08-22T23:14:45Z"},
{"user":{"login":"claude-bot"},"state":"REQUEST_CHANGES","commit_id":"old","submitted_at":"2026-08-22T23:03:39Z"}
]'
review_requests_stub
check "supersedes 'a stale approval still owes a verdict': one never re-requested is not requested" 0 "" \
eq "" forge_pr_review_requests 96
# rig!146 after merge: all three panelists submitted, so the reviews endpoint
# carries no REQUEST_REVIEW row even though requested_reviewers stayed stale.
FAKE_REVIEWS_N=3
FAKE_REVIEWS='[
{"user":{"login":"kimi-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:05:01Z"},
{"user":{"login":"glm-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:14:45Z"},
{"user":{"login":"claude-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:03:39Z"}
]'
review_requests_stub
check "supersedes 'the never-cleared forgejo field collapses to who actually owes': a merged PR yields nobody" 0 "" \
eq "" forge_pr_review_requests 146
# crew!97's opening request rows. Deliberately unordered with one duplicate:
# the public contract is sorted unique logins, independent of API row order.
FAKE_REVIEWS_N=5
FAKE_REVIEWS='[
{"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"},
{"user":{"login":"claude-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:11Z"},
{"user":{"login":"codex-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:11Z"},
{"user":{"login":"glm-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"},
{"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:13Z"}
]'
review_requests_stub
check "forgejo returns the four opening request logins sorted and deduplicated" 0 "" \
eq $'claude-bot\ncodex-bot\nglm-bot\nkimi-bot' forge_pr_review_requests 97
FAKE_REVIEWS_N=2
FAKE_REVIEWS='[
{"user":{"login":"claude-bot"},"state":"REQUEST_CHANGES","commit_id":"old","submitted_at":"2026-08-22T23:03:39Z"},
{"user":{"login":"claude-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T23:16:17Z"}
]'
review_requests_stub
check "supersedes: a reviewer re-requested while holding REQUEST_CHANGES is requested" 0 "" \
eq "claude-bot" forge_pr_review_requests 97
FAKE_REVIEWS_N=1
FAKE_REVIEWS='[{"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"}]'
review_requests_stub
check "supersedes 'a Forgejo request row is not an answer and leaves the login outstanding': its login is requested" 0 "" \
eq "kimi-bot" forge_pr_review_requests 97
# GitHub already exposes the exact live set on the PR object. Stub only the
# network boundary and assert the same backend-neutral output contract.
. "$ROOT/lib/forge-github.sh"
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
gh() {
[ "$1" = api ] || return 1
shift
local jqexpr="" endpoint=""
while [ $# -gt 0 ]; do
case "$1" in
--jq) jqexpr="$2"; shift ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
[ "$endpoint" = repos/o/r/pulls/55 ] || return 1
jq -r "$jqexpr" <<'JSON'
{"requested_reviewers":[{"login":"glm-bot"},{"login":"claude-bot"}]}
JSON
}
check "github returns requested_reviewers from the PR object unchanged" 0 "" \
eq $'claude-bot\nglm-bot' forge_pr_review_requests 55
unset -f gh
. "$ROOT/lib/forge-forgejo.sh"
# --- forge_pr_view: newest verdict per context must win ------------------
# checks_state groups repeated contexts and selects the newest by
# [.startedAt, .createdAt, .completedAt]. Mapping only {context,state} left
@ -686,66 +490,23 @@ pr_view_stub() {
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 1\r\n\r\n' >"$hdr"
case "$url" in
*/status) printf '%s' "$FAKE_STATUS" >"$out" ;;
*) printf '%s' "$FAKE_PR" >"$out" ;;
*) printf '{"head":{"sha":"abc"},"mergeable":true}' >"$out" ;;
esac
return 0
}
}
# The FAILURE is older but listed second — array order would pick it.
FAKE_STATUS='{"state":"failure","statuses":[
{"context":"labels / labels (pull_request)","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
{"context":"labels / labels (pull_request)","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"},
{"context":"CI / test (pull_request)","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
{"context":"Refs guard / refs-not-closing (pull_request)","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
{"context":"codecov","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"}]}'
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":true}'
{"context":"ci / check","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
{"context":"ci / check","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"}]}'
pr_view_stub
view_json="$(REPO=o/r forge_pr_view 5)"
check "pr_view maps createdAt" 0 "" \
grep -q '"createdAt": "2026-08-02T10:00:00Z"' <<<"$view_json"
check "pr_view maps completedAt" 0 "" \
grep -q '"completedAt":' <<<"$view_json"
check "pr_view derives workflowName from the first context separator" 0 "" \
jq -e '[.statusCheckRollup[]
| select(.context == "labels / labels (pull_request)"
or .context == "CI / test (pull_request)"
or .context == "Refs guard / refs-not-closing (pull_request)")
| [.workflowName, .context]]
| unique == [
["CI", "CI / test (pull_request)"],
["Refs guard", "Refs guard / refs-not-closing (pull_request)"],
["labels", "labels / labels (pull_request)"]
]' >/dev/null <<<"$view_json"
check "pr_view leaves workflowName empty when the context has no separator" 0 "" \
jq -e 'any(.statusCheckRollup[];
.context == "codecov" and .workflowName == "")' >/dev/null <<<"$view_json"
check "pr_view maps mergeable to the UI string" 0 "" \
grep -q '"mergeable": "MERGEABLE"' <<<"$view_json"
# Forgejo folds conflict-checking, conflict-check errors, real conflicts, and
# WIP into one false boolean. These fixtures pin the distinctions the API
# object still lets the backend report honestly (#236).
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":true,"mergeable":false}'
view_json="$(REPO=o/r forge_pr_view 5)"
check "pr_view reports draft mergeability as unknown" 0 "" \
grep -q '"mergeable": "UNKNOWN"' <<<"$view_json"
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"base","draft":false,"mergeable":false}'
view_json="$(REPO=o/r forge_pr_view 5)"
check "pr_view reports a fast-forward false mergeability as unknown" 0 "" \
grep -q '"mergeable": "UNKNOWN"' <<<"$view_json"
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":false}'
view_json="$(REPO=o/r forge_pr_view 5)"
check "pr_view preserves a distinguishable real conflict" 0 "" \
grep -q '"mergeable": "CONFLICTING"' <<<"$view_json"
# Unreachable on Forgejo: Mergeable() includes !IsWorkInProgress(). This
# ordering fixture exists only to pin draft ahead of mergeable true (#236).
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":true,"mergeable":true}'
view_json="$(REPO=o/r forge_pr_view 5)"
check "pr_view reads draft before the mergeable boolean" 0 "" \
grep -q '"mergeable": "UNKNOWN"' <<<"$view_json"
# The real proof: feed it to the production classifier and confirm the newer
# SUCCESS wins over the older FAILURE regardless of array order.
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh
@ -763,7 +524,6 @@ check "no api base refuses" 1 "cannot reach the forge" \
# .user.login -> .actor.login. Mutation-verified: collapsing add/remove or
# emitting .user instead of .actor each reds its own case (#4853).
timeline_stub() {
: >"$timeline_calls"
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
curl() {
local hdr="" out="" url=""
@ -771,44 +531,14 @@ timeline_stub() {
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift
done
printf '%s\n' "$url" >>"$timeline_calls"
local page=1 page_body="$FAKE_TIMELINE" page_total="${FAKE_TL_N:-2}"
case "$url" in *page=*) page="${url##*page=}"; page="${page%%&*}" ;; esac
if [ "${#FAKE_TL_PAGES[@]}" -gt 0 ]; then
if [ "$page" -le "${#FAKE_TL_PAGES[@]}" ]; then
page_body="${FAKE_TL_PAGES[$((page - 1))]}"
else
page_body='[]'
fi
page_total="$(jq 'length' <<<"$page_body")"
fi
{
printf 'HTTP/1.1 200 OK\r\n'
[ "${FAKE_TL_HEADERS:-yes}" = no ] || printf 'X-Total-Count: %s\r\n' "$page_total"
printf '\r\n'
} >"$hdr"
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_TL_N:-2}" >"$hdr"
case "$url" in
*timeline*) printf '%s' "$page_body" >"$out" ;;
*timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;;
*) printf '[]' >"$out" ;;
esac
return 0
}
}
timeline_page() {
jq -nc --argjson first "$1" --argjson count "$2" '
[range($first; $first + $count)
| {
type: "label",
body: "1",
user: {login: "setter"},
label: {name: "needs-ruling"},
created_at: ("event-" + tostring)
}]
'
}
timeline_calls="$TMP/timeline_calls"
FAKE_TL_PAGES=()
FAKE_TL_HEADERS=yes
FAKE_TIMELINE='[
{"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"},
{"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"},
@ -825,61 +555,6 @@ check "forge_timeline drops non-label events" 0 "" \
test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0
check "forge_timeline uses .actor.login, not a bare .user" 0 "" \
jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null
# Forgejo's timeline endpoint lies consistently: x-total-count echoes the
# current page size. With 151 events its pages declare 50, 50, 50 and 1, so
# strict pagination stops successfully after page 1 and drops the newest 101
# events. Exhaustion is safe only here because timelines are append-only.
FAKE_TL_PAGES=(
"$(timeline_page 1 50)"
"$(timeline_page 51 50)"
"$(timeline_page 101 50)"
"$(timeline_page 151 1)"
)
timeline_stub
tl="$(REPO=o/r forge_timeline 188)"
check "forge_timeline exhausts all pages despite per-page total headers" 0 "" \
test "$(jq 'length' <<<"$tl")" = 151
check "forge_timeline retains the newest event beyond page one" 0 "" \
jq -e 'any(.[]; .created_at == "event-151")' <<<"$tl" >/dev/null
check "a short final page terminates without an extra empty-page read" 0 "" \
test "$(wc -l <"$timeline_calls")" = 4
# A collection exactly divisible by the page size needs one final empty read;
# stopping after the second full page cannot prove exhaustion.
FAKE_TL_PAGES=("$(timeline_page 1 50)" "$(timeline_page 51 50)")
timeline_stub
exhaustive_count="$(REPO=o/r forge_api --paginate-exhaustive 'repos/o/r/issues/188/timeline' --jq 'length')"
check "exhaustive pagination terminates after an empty page" 0 "" \
test "$exhaustive_count" = 100
check "an exactly-full exhaustive gather reads the empty third page" 0 "" \
test "$(wc -l <"$timeline_calls")" = 3
# The exhaustive path's completeness proof is the short page itself; it must
# never consult the endpoint's missing or dishonest total header.
FAKE_TL_PAGES=("$(timeline_page 1 50)" "$(timeline_page 51 1)")
FAKE_TL_HEADERS=no
timeline_stub
check "exhaustive pagination needs no x-total-count header" 0 "" \
eq 51 forge_api --paginate-exhaustive 'repos/o/r/issues/188/timeline' --jq 'length'
check "the same missing-header fixture is still refused by strict pagination" 1 \
"did not send x-total-count" \
forge_api --paginate 'repos/o/r/issues/188/timeline' --jq 'length'
FAKE_TL_HEADERS=yes
check "strict and exhaustive pagination are mutually exclusive" 1 "mutually exclusive" \
forge_api --paginate --paginate-exhaustive 'repos/o/r/issues/188/timeline'
check "exhaustive pagination refuses a non-GET method" 1 "GET" \
forge_api --paginate-exhaustive -X POST 'repos/o/r/issues/188/timeline'
check "exhaustive pagination refuses compact -XPOST too" 1 "GET" \
forge_api --paginate-exhaustive -XPOST 'repos/o/r/issues/188/timeline'
check "exhaustive pagination refuses --method=POST too" 1 "GET" \
forge_api --paginate-exhaustive --method=POST 'repos/o/r/issues/188/timeline'
check "the exhaustive flag has exactly one production call site" 0 "" \
test "$(grep -c 'paginate-exhaustive' "$ROOT/lib/forge-forgejo.sh")" = 5
check "only forge_timeline invokes exhaustive pagination" 0 "" \
test "$(grep -c 'forge_api --paginate-exhaustive' "$ROOT/lib/forge-forgejo.sh")" = 1
# Unreadable: curl fails. Status must surface through forge_timeline itself
# (not a later jq), or the ruling ladder invents a verdict on a half-read.
# shellcheck disable=SC2317
@ -941,362 +616,4 @@ check "...and never fetches a zero-comment review" 1 "" \
check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \
grep -E '/pulls/[0-9]+/comments(\?|$)' "$activity_calls"
# --- the release door's verbs, both backends (#191) -----------------------
# The five verbs the release path now goes through. These carry two measured
# asymmetries that would 404 forever if wrong, and neither is visible to a
# github-only suite:
#
# PRs behind a commit GitHub GET /commits/{sha}/pulls -> ARRAY
# Forgejo GET /commits/{sha}/pull -> ONE OBJECT
# (the plural 404s)
# tag creation GitHub POST /git/refs
# Forgejo POST /tags (/git/refs is GET-only)
# release_stub <code> <body> [<code> <body>…] — a curl stub answering a
# sequence of canned responses and recording the method+path it was asked for.
# The cursor lives in a file because forge_release_create captures
# forgejo_write in a command substitution; an in-memory counter would advance
# only in that subshell and every later request would replay response one.
release_stub() {
# Globals, not locals: the curl closure below runs long after this
# function returns, exactly as stub_writes does above.
STUB_CODES=() STUB_BODIES=()
while [ "$#" -gt 0 ]; do
STUB_CODES+=("$1") STUB_BODIES+=("$2")
shift 2
done
STUB_READ_CODE="${STUB_CODES[0]}" STUB_READ_BODY="${STUB_BODIES[0]}"
printf '0\n' >"$TMP/release-stub-cursor"
: >"$WRITES"
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
curl() {
local hdr="" out="" method=GET url="" payload="" fail_on_http=false
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift ;;
-o) out="$2"; shift ;;
-X) method="$2"; shift ;;
-d) payload="$2"; shift ;;
-F) payload="$payload -F $2"; shift ;;
-f) fail_on_http=true ;;
-H) shift ;;
-*) ;;
*) url="$1" ;;
esac
shift
done
local index code body
if [ "$method" = GET ]; then
code="$STUB_READ_CODE" body="$STUB_READ_BODY"
else
index="$(cat "$TMP/release-stub-cursor")"
code="${STUB_CODES[$index]:-${STUB_CODES[${#STUB_CODES[@]} - 1]}}"
body="${STUB_BODIES[$index]:-${STUB_BODIES[${#STUB_BODIES[@]} - 1]}}"
printf '%s\n' "$((index + 1))" >"$TMP/release-stub-cursor"
fi
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$code" >"$hdr"
[ -n "$out" ] && printf '%s' "$body" >"$out"
printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
if [ "$fail_on_http" = true ]; then
case "$code" in 2*) ;; *) return 22 ;; esac
fi
return 0
}
}
release_read() { STUB_READ_CODE="$1" STUB_READ_BODY="$2"; }
GITHUB_API_URL=https://forge.example/api/v1
export GITHUB_API_URL
# Helpers so the assertions run in THIS shell, where the verbs are defined.
pulls_is_array() { forge_commit_pulls "$1" | jq -e 'type == "array" and length == 1' >/dev/null && echo array-of-1; }
writes_after() { "$@" >/dev/null 2>&1; cat "$WRITES"; }
repo_empty_release() { REPO='' forge_release_exists 1.2.3; }
repo_empty_pulls() { REPO='' forge_commit_pulls deadbeef; }
# forge_commit_at — the FOURTH asymmetry (#209). Forgejo 404s on /commits/{sha}
# and serves the object at /git/commits/{sha}, with the committer date under
# `.created` rather than `.commit.committer.date`. A stubbed forge_api cannot
# catch a wrong PATH, which is how #198 shipped GitHub's path here and every
# sweep printed `could not read the head commit's date`.
release_stub 200 '{"created":"2026-08-05T13:11:33Z","commit":{"committer":{"date":"WRONG"}}}'
check "forgejo: the commit date comes from .created" 0 "2026-08-05T13:11:33Z" \
forge_commit_at deadbeef
fj_not_nested() { ! forge_commit_at deadbeef | grep -q WRONG; }
check "...and never from GitHub's nested field" 0 "" fj_not_nested
check "forgejo: it asks /git/commits/{sha}" 0 "git/commits/deadbeef" \
writes_after forge_commit_at deadbeef
fj_not_bare_path() { ! grep -qE 'repos/o/r/commits/deadbeef( |$)' "$WRITES"; }
check "...and never the bare /commits/{sha}, which 404s here" 0 "" fj_not_bare_path
release_stub 200 '{"number":7,"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}'
check "forgejo: one PR object becomes a one-element array" 0 '"number":7' \
forge_commit_pulls deadbeef
check "forgejo: the array is what the call site's jq expects" 0 "array-of-1" \
pulls_is_array deadbeef
check "forgejo: it reads the SINGULAR path" 0 "commits/deadbeef/pull " \
writes_after forge_commit_pulls deadbeef
release_stub 404 '{"message":"pull request does not exist"}'
check "forgejo: 404 is an empty array, not a failure" 0 "[]" forge_commit_pulls deadbeef
release_stub 500 '{}'
check "forgejo: a 500 refuses rather than saying 'none'" 1 "the answer is unknown, not 'none'" \
forge_commit_pulls deadbeef
release_stub 200 '{"tag_name":"1.2.3"}'
check "forgejo: a present release is yes" 0 "yes" forge_release_exists 1.2.3
release_stub 200 '{"tag_name":"1.2.3","draft":true}'
check "forgejo: a draft is not a present release" 0 "no" forge_release_exists 1.2.3
release_stub 200 '{"tag_name":"1.2.3","draft":false}'
check "forgejo: a published release is present" 0 "yes" forge_release_exists 1.2.3
release_stub 404 '{}'
check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3
release_stub 503 '{}'
check "forgejo: an unreadable release refuses, not 'no'" 1 "the answer is unknown, not 'no'" \
forge_release_exists 1.2.3
# THE MUST-FAIL (#191, found by @kimi on !193 before it shipped): with REPO
# empty every path becomes repos//… , whose 404 would read as a fact — "no"
# and "[]" with rc 0. That is the bug this issue exists to remove.
release_stub 404 '{}'
check "REPO empty refuses instead of fabricating 'no'" 1 "refusing to address 'repos//" \
repo_empty_release
check "REPO empty refuses instead of fabricating '[]'" 1 "refusing to address 'repos//" \
repo_empty_pulls
release_stub 201 '{"id":42}'
check "forgejo: a tag is created at /tags, not /git/refs" 0 "POST repos/o/r/tags" \
writes_after forge_tag_create 1.2.3 cafebabe
release_stub 201 '{"id":42}'
check "forgejo: the tag body names the target sha" 0 '"target":"cafebabe"' \
writes_after forge_tag_create 1.2.3 cafebabe
printf 'notes body\n' >"$TMP/notes.md"
release_writes_after() { "$@" >/dev/null 2>&1; grep -v '^GET ' "$WRITES"; }
release_stub 201 '{"id":42}' 200 '{}'
release_read 404 '{}'
check "forgejo: the publish POSTs to /releases with the notes as body" 0 '"body":"notes body' \
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
check "the create stages a draft" 0 '"draft":true' cat "$WRITES"
zero_asset_writes() {
release_stub 201 '{"id":42}' 200 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
local got
got="$(grep -v '^GET ' "$WRITES" | sed -E 's/ (\{.*|$)//')"
[ "$got" = $'POST repos/o/r/releases\nPATCH repos/o/r/releases/42' ] || { printf '%s\n' "$got"; return 1; }
}
check "the zero-asset path creates then publishes" 0 "" zero_asset_writes
# Assets: the hook contract permits any filename the consumer drops in
# RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw
# interpolation exits 3 on a space and silently renames on '&' / '#' / '+' /
# '%' — `gh release create` handled those, so the forgejo twin must too
# (#191, @codex on !193).
check "the encoder escapes a space" 0 "release%20asset.tgz" \
forgejo_urlencode 'release asset.tgz'
check "the encoder escapes the query delimiters" 0 "a%26b%23c%2Bd%25e.tgz" \
forgejo_urlencode 'a&b#c+d%e.tgz'
printf 'x\n' >"$TMP/release asset.tgz"
printf 'y\n' >"$TMP/a&b.tgz"
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "an asset with a space uploads under the encoded name" 0 "assets?name=release%20asset.tgz" \
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz"
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "an asset with '&' does not become two parameters" 0 "assets?name=a%26b.tgz" \
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "the upload targets the created release id" 0 "releases/42/assets" \
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "the asset rides as a multipart attachment" 0 "attachment=@" \
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
happy_asset_writes() {
release_stub 201 '{"id":42}' 201 '{}' 201 '{}' 200 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz" "$TMP/a&b.tgz" >/dev/null 2>&1
local got want
got="$(grep -v '^GET ' "$WRITES" | sed -E 's/ (\{.*| -F.*|$)//')"
want=$'POST repos/o/r/releases\nPOST repos/o/r/releases/42/assets?name=release%20asset.tgz\nPOST repos/o/r/releases/42/assets?name=a%26b.tgz\nPATCH repos/o/r/releases/42'
[ "$got" = "$want" ] || { printf '%s\n' "$got"; return 1; }
}
check "assets upload in argument order before the release is published" 0 "" happy_asset_writes
upload_failure() {
release_stub 201 '{"id":42}' 500 '{}' 204 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
}
check "an upload failure returns non-zero" 1 "asset upload failed" upload_failure
check "an upload failure deletes the draft" 0 "DELETE repos/o/r/releases/42" cat "$WRITES"
flip_failure() {
release_stub 201 '{"id":42}' 500 '{}' 204 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a publish flip failure returns non-zero" 1 "HTTP 500" flip_failure
check "a publish flip failure deletes the draft" 0 "DELETE repos/o/r/releases/42" cat "$WRITES"
create_failure() {
release_stub 500 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a create failure returns non-zero" 1 "HTTP 500" create_failure
check "a create failure has nothing to delete" 1 "" grep -q '^DELETE ' "$WRITES"
missing_release_id() {
release_stub 201 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a create without an id keeps its refusal" 1 "returned no release id" missing_release_id
check "a create without an id deletes nothing unknown" 1 "" grep -q '^DELETE ' "$WRITES"
stranded_cleanup_failure() {
release_stub 201 '{"id":42}' 500 '{"message":"upload refused"}' 500 '{"message":"delete refused"}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
}
check "cleanup failure preserves the original failure" 1 "asset upload failed" stranded_cleanup_failure
check "cleanup failure also names the stranded draft" 1 "stranded draft 42 for tag '1.2.3'" stranded_cleanup_failure
release_stub 204 '{}' 201 '{"id":42}' 200 '{}'
release_read 200 '{"id":41,"tag_name":"1.2.3","draft":true}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
stranded_draft_order() {
local got
got="$(grep -v '^GET ' "$WRITES" | head -2 | sed -E 's/ (\{.*|$)//')"
[ "$got" = $'DELETE repos/o/r/releases/41\nPOST repos/o/r/releases' ] || { printf '%s\n' "$got"; return 1; }
}
check "a stranded same-tag draft is deleted before create" 0 "" stranded_draft_order
release_stub 409 '{}'
release_read 200 '{"id":41,"tag_name":"1.2.3","draft":false}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
check "a published same-tag release is never deleted" 1 "" grep -q '^DELETE ' "$WRITES"
# The merge door's preflight step is extracted and executed. Its network
# edges are stubbed at the boundary, while the real pure decision library
# consumes the gathered facts (#273).
MERGE_PREFLIGHT="$TMP/merge-preflight.sh"
{
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
yq -r '.jobs.release-on-merge.steps[] | select(.id == "preflight") | .run' \
"$ROOT/.github/workflows/release.yml"
} >"$MERGE_PREFLIGHT"
chmod +x "$MERGE_PREFLIGHT"
mkdir -p "$TMP/merge-preflight-lib/lib" "$TMP/merge-preflight-bin"
ln -s "$ROOT/lib/preflight.sh" "$TMP/merge-preflight-lib/lib/preflight.sh"
# shellcheck disable=SC2016 # expanded when the generated helper runs
printf '%s\n' \
'forge_select() { :; }' \
'forge_release_exists() { case "$RELEASE_EXISTS" in error) return 1 ;; *) echo "$RELEASE_EXISTS" ;; esac; }' \
>"$TMP/merge-preflight-lib/lib/forge.sh"
# shellcheck disable=SC2016 # expanded when the PATH stub is executed
printf '%s\n' \
'#!/usr/bin/env bash' \
'case "$GIT_LS_REMOTE" in' \
' absent) exit 2 ;;' \
' error) exit 128 ;;' \
' *) printf "%s\n" "$GIT_LS_REMOTE" ;;' \
'esac' >"$TMP/merge-preflight-bin/git"
chmod +x "$TMP/merge-preflight-bin/git"
merge_preflight_extracted() { [ "$(wc -l <"$MERGE_PREFLIGHT")" -ge 8 ]; }
run_merge_preflight() {
: >"$TMP/merge-preflight-output"
CEREMONY_DIR="$TMP/merge-preflight-lib" \
GITHUB_OUTPUT="$TMP/merge-preflight-output" \
PATH="$TMP/merge-preflight-bin:$PATH" \
VER=1.2.3 MERGE_SHA=1111111111111111111111111111111111111111 \
RELEASE_EXISTS="$1" GIT_LS_REMOTE="$2" "$MERGE_PREFLIGHT"
}
merge_preflight_output_is() {
run_merge_preflight "$1" "$2" >/dev/null && \
[ "$(cat "$TMP/merge-preflight-output")" = "$3" ]
}
check "the merge door's preflight is extracted" 0 "" merge_preflight_extracted
check "the merge door refuses an unreadable release state" 1 \
"refusing rather than assuming" run_merge_preflight error absent
check "the merge door refuses an unreadable tag state" 1 \
"could not read tag '1.2.3'" run_merge_preflight no error
check "ls-remote exit 2 is tag absence and proceeds" 0 "" \
merge_preflight_output_is no absent resume=no
check "a matching peeled ref records a resume" 0 "" \
merge_preflight_output_is no \
$'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/tags/1.2.3\n1111111111111111111111111111111111111111\trefs/tags/1.2.3^{}' \
resume=yes
tag_step_uses_preflight() {
yq -r '.jobs.release-on-merge.steps[] | select(.name | test("tag the merge commit")) | .if' \
"$ROOT/.github/workflows/release.yml" | grep -q 'steps.preflight.outputs.resume'
}
check "the merge-door tag step is conditioned on the preflight output" 0 "" \
tag_step_uses_preflight
# The tag door's published-release guard is extracted and executed, like the
# other workflow-shell contracts in this repository. The tag itself is this
# door's premise, so only a published release refuses; adding the merge door's
# ls-remote half here would destroy the documented delete-and-re-push recovery.
TAG_GUARD="$TMP/tag-release-guard.sh"
{
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
yq -r '.jobs.release-on-tag.steps[] | select(.name == "no published release may exist") | .run' \
"$ROOT/.github/workflows/release.yml"
} >"$TAG_GUARD"
chmod +x "$TAG_GUARD"
mkdir -p "$TMP/tag-guard-lib/lib"
# shellcheck disable=SC2016 # expanded when the generated helper runs, not while this test writes it
printf '%s\n' \
'forge_select() { :; }' \
'forge_release_exists() { case "$RELEASE_EXISTS" in error) return 1 ;; *) echo "$RELEASE_EXISTS" ;; esac; }' \
>"$TMP/tag-guard-lib/lib/forge.sh"
tag_guard_extracted() { [ "$(wc -l <"$TAG_GUARD")" -ge 8 ]; }
run_tag_guard() {
CEREMONY_DIR="$TMP/tag-guard-lib" VER=1.2.3 RELEASE_EXISTS="$1" "$TAG_GUARD"
}
tag_door_has_no_tag_exists_guard() {
! yq -r '.jobs.release-on-tag.steps[].run // ""' "$ROOT/.github/workflows/release.yml" | grep -q 'ls-remote'
}
check "the tag door's published-release guard is extracted" 0 "" tag_guard_extracted
check "the tag door refuses an existing published release" 1 "release '1.2.3' already exists" run_tag_guard yes
check "the tag door proceeds when no published release exists" 0 "" run_tag_guard no
check "the tag door refuses when release state is unreadable" 1 "refusing rather than assuming" run_tag_guard error
check "the tag door carries no tag-exists guard" 0 "" tag_door_has_no_tag_exists_guard
# --- the github twins address their own paths ----------------------------
. "$ROOT/lib/forge-github.sh"
GITHUB_REPOSITORY=o/r
export GITHUB_REPOSITORY
GH_CALLS="$TMP/ghcalls"
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
gh() { printf '%s\n' "$*" >>"$GH_CALLS"; case "$*" in *commits/*) echo '[]' ;; esac; return 0; }
gh_after() { : >"$GH_CALLS"; "$@" >/dev/null 2>&1; cat "$GH_CALLS"; }
check "github: the tag goes to /git/refs" 0 "git/refs" \
gh_after forge_tag_create 1.2.3 cafebabe
check "github: PRs behind a commit use the PLURAL path" 0 "commits/deadbeef/pulls" \
gh_after forge_commit_pulls deadbeef
# The other half of #209's asymmetry: GitHub serves a single commit at the bare
# path, with the date nested. Swapping the two backends' paths must red one of
# these two files, which is the whole point of pinning both.
check "github: a single commit is the BARE path" 0 "commits/deadbeef" \
gh_after forge_commit_at deadbeef
gh_not_git_commits() { ! gh_after forge_commit_at deadbeef | grep -q 'git/commits'; }
check "...and never Forgejo's git/commits" 0 "" gh_not_git_commits
check "...reading the nested committer date" 0 "commit.committer.date" \
gh_after forge_commit_at deadbeef
summary

File diff suppressed because it is too large Load diff

View file

@ -1,93 +0,0 @@
#!/usr/bin/env bash
# The bootstrap bridge across the workflow_call boundary (#215).
#
# The defect: a called workflow cannot read the caller's dispatch inputs on
# this forge — `github.event.inputs.*` is empty inside `workflow_call` even
# though the top-level caller receives the value in both contexts (probe runs
# 6/7). The old gate read exactly that, so every dispatch-woken sweep
# bootstrapped (runs 459/523). The fix moves the value through a DECLARED
# `workflow_call` input, passed by the caller, with empty mapped to "no" at
# the caller so a cron can never bootstrap.
#
# These cases pin the wiring at every hop and drive the four value paths
# through the semantics of the exact expressions shipped — extracted from the
# YAML, never retyped, so an edited expression is an edited test input.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
REUSABLE="$ROOT/.github/workflows/labels-sweep.yml"
CALLER="$ROOT/.github/workflows/self-labels-sweep.yml"
CONSUMERS="$ROOT/docs/CONSUMERS.md"
# --- the declared boundary ---------------------------------------------------
decl() { yq -r ".on.workflow_call.inputs.bootstrap.$1 // \"\"" "$REUSABLE"; }
declares_boundary() { [ -n "$(yq -r '.on.workflow_call.inputs.bootstrap // ""' "$REUSABLE")" ]; }
check "labels-sweep.yml declares bootstrap as a workflow_call input" 0 "" declares_boundary
check "...typed string" 0 "string" decl type
check "...defaulting to no — an absent pass-through must never bootstrap" 0 "no" \
decl default
# --- the gate reads the declared input, and nothing else ---------------------
gate_exprs() { yq -r '.jobs[].steps[] | select(.with.bootstrap != null) | .with.bootstrap' "$REUSABLE"; }
gates_are_identity() {
[ "$(gate_exprs | sort -u)" = "\${{ inputs.bootstrap }}" ] \
&& [ "$(gate_exprs | wc -l)" -eq 2 ]
}
check "both gate sites feed the DECLARED input, unchanged" 0 "" gates_are_identity
# The forbidden context is only live inside an expression: the file NAMES it
# in comments and in the declared input's description to explain the defect,
# and both are prose. Matching raw text asserted on the explanation — the
# adjacent-assertion trap this suite keeps re-learning — so the predicate is
# scoped to `${{ … }}` bodies.
reads_event_inputs() { grep -qE '\$\{\{[^}]*github\.event\.inputs' "$REUSABLE"; }
check "no expression in the reusable reads github.event.inputs — the context this forge empties" 1 "" \
reads_event_inputs
# --- the caller passes it through, empty mapped to no ------------------------
CALLER_EXPR="$(yq -r '.jobs.sweep.with.bootstrap // ""' "$CALLER")"
check "self-labels-sweep.yml passes with.bootstrap through the boundary" 0 "" \
test -n "$CALLER_EXPR"
check "...with the exact empty-guard expression" 0 "" \
test "$CALLER_EXPR" = "\${{ inputs.bootstrap || 'no' }}"
# The published stub must carry the same bridge, or every consumer inherits
# the defect ceremony just fixed for itself.
check "the CONSUMERS.md sweep stub passes bootstrap through the boundary" 0 \
"bootstrap: \${{ inputs.bootstrap || 'no' }}" \
grep -F "bootstrap: \${{ inputs.bootstrap || 'no' }}" "$CONSUMERS"
# --- the four value paths, through the shipped expressions -------------------
# Evaluate the caller expression's semantics for a given top-level value. The
# expression is asserted byte-exact above, so modelling `x || 'no'` here is
# modelling the string the tree actually ships, not a hope about it.
caller_pass() { [ -n "$1" ] && printf '%s' "$1" || printf 'no'; }
# The reusable's gate is asserted to be the identity; the value then meets
# actions/labels-reconcile's REAL validate step, extracted and executed.
VALIDATE="$(mktemp)"
trap 'rm -f "$VALIDATE"' EXIT
{
printf '%s\n' '#!/usr/bin/env bash'
yq -r '.runs.steps[] | select(.name == "validate bootstrap input") | .run' \
"$ROOT/actions/labels-reconcile/action.yml"
} >"$VALIDATE"
chmod +x "$VALIDATE"
path() { BOOTSTRAP="$(caller_pass "$1")" bash "$VALIDATE"; }
check "schedule (empty top-level context) validates as a non-bootstrap sweep" 0 "" path ""
check "a REST event wake passing no validates as a non-bootstrap sweep" 0 "" path no
check "a manual dispatch passing yes validates as a bootstrap" 0 "" path yes
invalid_path() { BOOTSTRAP="maybe" bash "$VALIDATE"; }
check "an invalid value reaches the validator UNSANITIZED and refuses" 2 \
"bootstrap must be 'yes' or 'no'" invalid_path
# ...and the non-bootstrap/bootstrap split is what the validator's callers
# act on: prove the two accepted values are distinguished, not merely both
# accepted, by pinning what each resolves to after the caller pass.
check "empty and no resolve identically — the cron can never bootstrap" 0 "" \
test "$(caller_pass "")" = "$(caller_pass no)"
check "...and yes stays yes through the pass" 0 "" test "$(caller_pass yes)" = yes
summary

View file

@ -1,188 +0,0 @@
#!/usr/bin/env bash
# The sweep dispatch in .github/workflows/labels.yml (#205).
#
# This EXTRACTS the shipped step's `run:` script and EXECUTES it against a
# recording stub, rather than grepping the YAML for strings. A grep here would
# pass on a step that assembles a perfect request and never sends it — the
# shape of defect this repo keeps finding in its own tests. So every case
# asserts on what the step actually sent, or on what it actually did when the
# forge refused.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
WORKFLOW="$ROOT/.github/workflows/labels.yml"
TMP="$(mktemp -d)"
trap 'rm -rf "${TMP:?}"' EXIT
# --- the step under test, taken from the shipped workflow --------------------
STEP="$TMP/step.sh"
{
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
yq -r '.jobs.trigger.steps[] | select(.name == "dispatch the sweep") | .run' "$WORKFLOW"
} >"$STEP"
chmod +x "$STEP"
step_extracted() { [ "$(wc -l <"$STEP")" -ge 10 ]; }
check "the step's script was extracted from the shipped workflow" 0 "" step_extracted
# --- stubs -------------------------------------------------------------------
# `curl` records every invocation and answers with the code the case wants. It
# parses only what the step actually passes, so a step that stopped sending
# `-d`, or changed the method, fails here rather than recording nothing.
make_curl() { # make_curl <http-code> <body>
cat >"$TMP/bin/curl" <<STUB
#!/usr/bin/env bash
method=GET; data=; url=; out=; write=
while [ \$# -gt 0 ]; do
case "\$1" in
-X) method="\$2"; shift 2 ;;
-d) data="\$2"; shift 2 ;;
-o) out="\$2"; shift 2 ;;
-w) write="\$2"; shift 2 ;;
-H) printf '%s\n' "HEADER \$2" >>"$TMP/calls"; shift 2 ;;
-fsS|-sS|-s) shift ;;
*) url="\$1"; shift ;;
esac
done
printf '%s\n' "METHOD \$method" "URL \$url" "DATA \$data" >>"$TMP/calls"
# the default-branch read is a plain GET whose stdout the step pipes to jq
if [ "\$method" = GET ]; then printf '{"default_branch":"trunk"}'; exit 0; fi
[ -n "\$out" ] && printf '%s' '$2' >"\$out"
[ -n "\$write" ] && printf '%s' '$1'
exit 0
STUB
chmod +x "$TMP/bin/curl"
}
run_step() { # run_step <http-code> <body> [env assignments...]
local code="$1" body="$2"
shift 2
rm -rf "${TMP:?}/bin"
mkdir -p "$TMP/bin"
: >"$TMP/calls"
make_curl "$code" "$body"
env PATH="$TMP/bin:$PATH" \
GITHUB_TOKEN=tok \
GITHUB_API_URL=https://forge.example/api/v1 \
GITHUB_REPOSITORY=owner/repo \
SWEEP_WORKFLOW=self-labels-sweep.yml \
"$@" \
"$STEP"
}
sent() { grep -h "^$1 " "$TMP/calls" | tail -1 | cut -d' ' -f2-; }
sent_field() { jq -r "$1" <<<"$(sent DATA)"; }
# --- the success path --------------------------------------------------------
check "a 204 dispatch succeeds, naming what it woke and where" 0 \
"sweep dispatched — self-labels-sweep.yml on main" \
run_step 204 "" DEFAULT_BRANCH=main
posted() { [ "$(sent METHOD)" = POST ]; }
check "...by POST, not GET" 0 "" posted
right_endpoint() {
[ "$(sent URL)" = \
"https://forge.example/api/v1/repos/owner/repo/actions/workflows/self-labels-sweep.yml/dispatches" ]
}
check "...to the dispatches endpoint of the workflow it was told to wake" 0 "" right_endpoint
ref_is_main() { [ "$(sent_field .ref)" = main ]; }
check "...carrying a ref, because REST has no default and refuses without one" 0 "" ref_is_main
bootstrap_is_string_no() { [ "$(sent_field '.inputs.bootstrap')" = no ]; }
check "...and bootstrap=no as a STRING input, not a bare flag" 0 "" bootstrap_is_string_no
bearer_sent() { grep -qF 'HEADER Authorization: Bearer tok' "$TMP/calls"; }
check "...under the bearer header both forges accept" 0 "" bearer_sent
# --- the ref it must NOT inherit ---------------------------------------------
# On pull_request_target GITHUB_REF_NAME is `<n>/merge`. A step that reaches
# for it dispatches at something that is not a branch — and the forge answers
# that with the opaque 500, so it would look like an outage.
check "a pull_request_target run still dispatches at the branch" 0 "" \
run_step 204 "" DEFAULT_BRANCH=main GITHUB_REF_NAME=203/merge
ref_is_not_a_merge_ref() { case "$(sent_field .ref)" in *merge*) return 1 ;; *) return 0 ;; esac; }
check "...never at its merge ref" 0 "" ref_is_not_a_merge_ref
# --- the fallback ------------------------------------------------------------
check "an absent default branch is read from the forge, not guessed" 0 "" \
run_step 204 "" DEFAULT_BRANCH=
ref_is_trunk() { [ "$(sent_field .ref)" = trunk ]; }
check "...and the dispatch uses what the read returned" 0 "" ref_is_trunk
# --- failure is loud, and the diagnostic is owned ----------------------------
# `gh workflow run` failing WAS the misconfiguration alarm. The port keeps that
# contract: a consumer missing the caller, its input, or `actions: write` must
# fail here rather than sweep silently never again.
check "an empty 500 fails the step — the alarm still rings" 1 \
"/self-labels-sweep.yml/dispatches (ref=main)" \
run_step 500 "" DEFAULT_BRANCH=main
check "...explaining Forgejo's EMPTY 500 rather than passing it through" 1 \
"empty 500 body from Forgejo means the workflow name or the ref did not resolve" \
run_step 500 "" DEFAULT_BRANCH=main
check "...and naming the consumer causes the alarm exists for" 1 "actions: write" \
run_step 500 "" DEFAULT_BRANCH=main
check "any non-204 fails, not only the statuses the API documents" 1 "forbidden" \
run_step 403 '{"message":"forbidden"}' DEFAULT_BRANCH=main
# --- the API root is not guessed ---------------------------------------------
# Defaulting an unset GITHUB_API_URL to api.github.com sent this forge's
# dispatch to GitHub and reported success (@codex-reviewer-andresmgsl). The
# teeth are the call count: refusing AFTER a request is not refusing.
run_step_no_api() {
rm -rf "${TMP:?}/bin"; mkdir -p "$TMP/bin"; : >"$TMP/calls"
make_curl 204 ""
# -u, because plain `env` PRESERVES the parent environment: on the runner
# every step arrives with GITHUB_API_URL set — the premise of the fix under
# test — so without the unset this case inherits it, the refusal path never
# executes, and the case passes only in a dev shell that lacks the variable
# (@kimi-reviewer-andresmgsl, run 468).
env -u GITHUB_API_URL \
PATH="$TMP/bin:$PATH" GITHUB_TOKEN=tok GITHUB_REPOSITORY=owner/repo \
SWEEP_WORKFLOW=self-labels-sweep.yml DEFAULT_BRANCH=main "$STEP"
}
check "an unset GITHUB_API_URL refuses rather than guessing GitHub" 1 \
"GITHUB_API_URL is unset" run_step_no_api
no_calls_made() { [ ! -s "$TMP/calls" ]; }
check "...having made zero requests: refusing after a POST is not refusing" 0 "" no_calls_made
# --- a transport failure is not silence --------------------------------------
# The old `|| true` invariant was asserted by grepping the gh line this port
# removed, so it passed on any REST implementation including one that swallows
# a failed POST (@codex-reviewer-andresmgsl). Driven instead: curl itself exits
# non-zero, which `-w` cannot report because nothing is written.
make_failing_curl() {
printf '%s\n' '#!/usr/bin/env bash' 'echo "curl: (7) failed to connect" >&2' 'exit 7' \
>"$TMP/bin/curl"
chmod +x "$TMP/bin/curl"
}
run_step_curl_dies() {
rm -rf "${TMP:?}/bin"; mkdir -p "$TMP/bin"; : >"$TMP/calls"
make_failing_curl
env PATH="$TMP/bin:$PATH" GITHUB_TOKEN=tok \
GITHUB_API_URL=https://forge.example/api/v1 GITHUB_REPOSITORY=owner/repo \
SWEEP_WORKFLOW=self-labels-sweep.yml DEFAULT_BRANCH=main "$STEP"
}
check "a POST that never reaches the forge fails the step, and says so" 1 \
"never completed" run_step_curl_dies
# A code-aware guard alongside the behavioural one: no swallowing operator on
# the dispatch itself.
never_silenced() { sed 's/#.*//' "$STEP" | grep -qE '\|\|[[:space:]]*true'; }
check "...and the step carries no || true" 1 "" never_silenced
# --- what the port removed ---------------------------------------------------
# Strip comments first: the step's prose NAMES `gh workflow run` and
# CEREMONY_FORGE_CLIENT to explain what it replaced, so a raw grep asserts on
# the explanation instead of the code.
step_code() { sed 's/#.*//' "$STEP"; }
invokes_gh() { step_code | grep -qE '(^|[^[:alnum:]_])gh[[:space:]]'; }
decides_forge() { step_code | grep -qE 'GITHUB_SERVER_URL|CEREMONY_FORGE_CLIENT'; }
check "the step no longer INVOKES gh, its comments about it aside" 1 "" invokes_gh
check "...and no longer decides a forge, because REST needs no branch" 1 "" decides_forge
summary

File diff suppressed because it is too large Load diff

View file

@ -107,125 +107,6 @@ 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,10 +12,8 @@ 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"
SELF_SWEEP="$ROOT/.github/workflows/self-labels-sweep.yml"
STUB="$ROOT/docs/CONSUMERS.md" # the published caller stubs, fenced yaml blocks
STUB="$ROOT/docs/CONSUMERS.md" # the published caller stub, a fenced yaml block
# 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.
@ -41,79 +39,26 @@ 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. The job lives
# in labels-sweep.yml since #209; the guard moved with it.
# trading correctness for minutes — so it stays false, forever.
check "reconcile serializes, never cancels mid-board" 0 "false" \
job_cancel_in_progress "$SWEEP" reconcile
job_cancel_in_progress "$REUSABLE" 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 ]' _ "$SWEEP"
}; [ "$(job_cancel_in_progress "$1")" = true ]' _ "$REUSABLE"
# 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"
# #205 ported this dispatch from `gh workflow run` to REST. The assertion is
# the same one it always was — the sweep caller is woken BY NAME and never
# bootstrapped — but it now has to hold against a request rather than a CLI
# line. What the step actually SENDS is driven in test/labels-dispatch.test.sh;
# these two keep the wiring pinned here alongside the rest of the trigger.
# shellcheck disable=SC2016 # $SWEEP_WORKFLOW is the workflow's own env var, asserted literally
check "the trigger dispatches the sweep caller by name" 0 \
'actions/workflows/$SWEEP_WORKFLOW/dispatches' \
grep -F '/dispatches' "$REUSABLE"
check "...never bootstrapping" 0 'bootstrap: "no"' \
grep -F 'bootstrap' "$REUSABLE"
# The never-silenced invariant moved to test/labels-dispatch.test.sh, where it
# is BEHAVIOURAL: a curl that dies at the transport must fail the extracted
# step, plus a code-aware no-`|| true` guard on the step itself. The check that
# lived here grepped the `gh workflow run` line #205 removed, so after the port
# it passed on every implementation including one that swallows a failed POST —
# a green assertion whose name claimed an invariant its implementation could
# not observe (@codex-reviewer-andresmgsl, #213 review).
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.
# The cron rides the sweep caller since #209.
check "self sweep caller cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$SELF_SWEEP"
check "self caller cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$SELF"
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
check "self sweep caller cron line no longer fires */15" 1 "" \
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$SELF_SWEEP"
check "self caller cron line no longer fires */15" 1 "" \
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$SELF"
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 "" \
@ -150,33 +95,4 @@ done
check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \
trigger_types "$SELF" pull_request_target
# ---- fork heads carry a read-only token on this Forgejo (#241) --------------
# Same-repo heads keep the existing immediate scope + sweep-dispatch path. A
# fork-headed pull_request_target run must attempt no write: both write-capable
# jobs exclude it, while one successful job explains exactly what the scheduled
# sweep does and does not supply. Require each full normalised expression to
# appear intact, so deleting or inverting one of its clauses fails the guard.
job_if_expression() { # $1 = file, $2 = job
yq -r ".jobs.$2.if // \"\"" "$1" |
tr '\n' ' ' |
awk '{$1=$1; print}'
}
check "scope writes only for a same-repo PR head" 0 \
"github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.action != 'review_requested' && github.event.action != 'review_request_removed'" \
job_if_expression "$REUSABLE" scope
check "the sweep trigger preserves non-PR events and excludes fork heads" 0 \
"github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository" \
job_if_expression "$REUSABLE" trigger
check "a fork-headed PR selects the successful explanation job" 0 \
"github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository" \
job_if_expression "$REUSABLE" fork_head
fork_head_step() {
yq -r '.jobs.fork_head.steps[] | select(.name == "explain deferred fork labels") | .run' \
"$REUSABLE" | bash
}
check "the fork path distinguishes swept state from unsupported scope writes" 0 \
"read-only token; state, blocker, and handoff reconciliation deferred to the scheduled sweep; path-derived scope labels are not applied to fork heads" \
fork_head_step
summary

View file

@ -31,15 +31,6 @@ attention_row='attention|D93F0B|A demand is parked here for the assignee: pick u
check "attention core row is emitted once, byte-exact" 0 "1" bash -c \
'source "$1"; core_label_rows | grep -cxF "$2"' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$attention_row"
needs_triage_row='needs-triage|FBCA04|Did not come through triage — owes normalization into work or a reasoned refusal'
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "needs-triage core row is emitted once, byte-exact" 0 "1" bash -c \
'source "$1"; core_label_rows | grep -cxF "$2"' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$needs_triage_row"
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "core label rows carry no stale discussion path" 1 "" bash -c \
'source "$1"; core_label_rows | grep -i discussion' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh"
# shellcheck disable=SC2016 # fields are intentionally split in the nested shell
check "attention description survives label field splitting" 0 \
"A demand is parked here for the assignee: pick up the thread, ack by removing this label" \
@ -62,82 +53,6 @@ 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).
@ -211,52 +126,4 @@ sed 's/opened, closed/closed, opened/' "$STUB" >"$mut_stub"
check "an issue-list reorder in one file only goes red" 1 "" \
types_in_sync issues "$CALLER" "$mut_stub"
# --- #195: the conf's roster and CONTRIBUTING's roster table are one set ----
# The rot this catches: labels.conf named five identities, CONTRIBUTING named
# the same five, and none of the five existed on the forge — two files in
# perfect agreement with each other and none with reality. No offline check
# can reach the second half; what it can hold is that a roster edit touching
# one file and not the other goes red, which is the drift that turns a
# deliberate swap into a silent one.
# roster_from_conf <conf> — every identity panel= and triage-actors= name.
roster_from_conf() {
sed -nE 's/^(panel|triage-actors)=//p' "$1" | tr ' ' '\n' | sed '/^$/d' | sort -u
}
# roster_from_doc <contributing> — the identities the "### Roster" table's
# first column names. Anchored to the section rather than to the table's
# shape: another table elsewhere in the file must not be able to join the
# roster by looking like one.
# shellcheck disable=SC2016 # the backticks below are the table's, not a subshell
roster_from_doc() {
awk '/^### Roster$/ { inside = 1; next }
inside && /^#+ / { exit }
inside' "$1" |
sed -nE 's/^\| `([^`]+)`.*/\1/p' | sort -u
}
roster_in_sync() { # <conf> <contributing>
local conf="$1" doc="$2" drift
drift="$(diff <(roster_from_conf "$conf") <(roster_from_doc "$doc"))" && return 0
echo "roster drift ('<' conf only, '>' table only):" >&2
printf '%s\n' "$drift" >&2
return 1
}
CONF="$ROOT/.github/labels.conf"
CONTRIB="$ROOT/CONTRIBUTING.md"
check "the real conf and the real roster table name the same identities" 0 "" \
roster_in_sync "$CONF" "$CONTRIB"
# the failing cases, in both directions — a one-way check would have passed
# all week on the rot that produced #195
mut_conf="$TMP/mut-labels.conf" mut_contrib="$TMP/mut-contributing.md"
sed 's/^panel=/panel=ghost-bot /' "$CONF" >"$mut_conf"
check "an identity in the conf but not the table goes red" 1 "ghost-bot" \
roster_in_sync "$mut_conf" "$CONTRIB"
# shellcheck disable=SC2016 # the backticks are the table's, not a subshell
sed 's/^| `glm-bot-andresmgsl`/| `ghost-bot`/' "$CONTRIB" >"$mut_contrib"
check "an identity in the table but not the conf goes red" 1 "ghost-bot" \
roster_in_sync "$CONF" "$mut_contrib"
summary

View file

@ -1,142 +0,0 @@
#!/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

View file

@ -1,239 +0,0 @@
#!/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"
# labels.yml WAS the real customer for that pair. #205 ported its dispatch to
# REST, so it no longer speaks gh and must no longer declare a client — the
# exemption is spent, not inherited. Asserting its ABSENCE is what stops the
# declaration coming back as cover for a re-added `gh` call: an opt-out with no
# gh behind it is a standing permission slip.
check "labels.yml no longer declares a client, because it speaks none (#205)" 1 "" \
declares_gh_client "$ROOT/.github/workflows/labels.yml"
# shellcheck disable=SC2016 # `$SWEEP_WORKFLOW` is the literal the YAML must
# carry: the endpoint has to be built from the caller's input, not hardcoded.
labels_yml_dispatches_by_rest() {
grep -qF 'actions/workflows/$SWEEP_WORKFLOW/dispatches' \
"$ROOT/.github/workflows/labels.yml"
}
check "...and dispatches the sweep over REST instead" 0 "" labels_yml_dispatches_by_rest
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

View file

@ -1,121 +0,0 @@
#!/usr/bin/env bash
# Contract tests for lib/preflight.sh (issue #273) — every row of the
# merge-door resume table, offline. set -u, not -e: refusals 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"
PREFLIGHT="$ROOT/lib/preflight.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
VER=1.2.3
MERGE_SHA=1111111111111111111111111111111111111111
FOREIGN_SHA=2222222222222222222222222222222222222222
# preflight <VER> <MERGE_SHA> <TAG_SHAS> <RELEASED> — run the pure decision
# with exactly the four gathered facts in its environment.
preflight() {
# shellcheck disable=SC2016 # PREFLIGHT expands inside the isolated child
env VER="$1" MERGE_SHA="$2" TAG_SHAS="$3" RELEASED="$4" \
PREFLIGHT="$PREFLIGHT" bash -c '. "$PREFLIGHT"; release_preflight'
}
preflight_stdout() {
preflight "$@" 2>/dev/null
}
preflight_stderr() {
{ preflight "$@" >/dev/null; } 2>&1
}
refuses_without_output() {
local out rc
out="$(preflight "$@" 2>/dev/null)"
rc=$?
[ "$rc" -eq 1 ] && [ -z "$out" ]
}
# --- the four table rows ----------------------------------------------------
check "row 1: a published release refuses even with no tag" 1 \
"release '$VER' already exists — this release already happened" \
preflight_stderr "$VER" "$MERGE_SHA" "" yes
check "row 1: a published release refuses with the matching tag" 1 \
"release '$VER' already exists — this release already happened" \
preflight_stderr "$VER" "$MERGE_SHA" "$MERGE_SHA" yes
check "row 1: a published release refuses with a foreign tag" 1 \
"release '$VER' already exists — this release already happened" \
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" yes
check "row 1: refusal emits no workflow output" 0 "" \
refuses_without_output "$VER" "$MERGE_SHA" "$MERGE_SHA" yes
check "row 2: an ordinary first run proceeds" 0 "resume=no" \
preflight_stdout "$VER" "$MERGE_SHA" "" no
check "row 3: the matching tag resumes" 0 "resume=yes" \
preflight_stdout "$VER" "$MERGE_SHA" "$MERGE_SHA" no
check "row 3: resume notice names the previous failed publish" 0 \
"a previous run of this door tagged and then failed to publish" \
preflight_stdout "$VER" "$MERGE_SHA" "$MERGE_SHA" no
check "row 3: an annotated tag resumes when the peeled ref matches" 0 \
"resume=yes" preflight_stdout "$VER" "$MERGE_SHA" \
"$FOREIGN_SHA"$'\n'"$MERGE_SHA" no
check "row 3: an annotated tag resumes when the direct ref matches" 0 \
"resume=yes" preflight_stdout "$VER" "$MERGE_SHA" \
"$MERGE_SHA"$'\n'"$FOREIGN_SHA" no
check "row 4: a foreign tag refuses" 1 "tag '$VER' already exists" \
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
check "row 4: the refusal names the foreign tag SHA" 1 "$FOREIGN_SHA" \
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
check "row 4: the refusal names the merge SHA" 1 "$MERGE_SHA" \
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
check "row 4: refusal emits no workflow output" 0 "" \
refuses_without_output "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
# A ref object that merely contains MERGE_SHA is not the merge commit. Each
# ls-remote object name is compared as a whole line.
PREFIX_SHA="${MERGE_SHA%?}"
check "a prefix of MERGE_SHA does not resume" 1 "already exists at $PREFIX_SHA" \
preflight_stderr "$VER" "$MERGE_SHA" "$PREFIX_SHA" no
check "a line containing MERGE_SHA does not resume" 1 \
"already exists at x${MERGE_SHA}y" \
preflight_stderr "$VER" "$MERGE_SHA" "x${MERGE_SHA}y" no
# --- fact validation --------------------------------------------------------
check "empty VER refuses" 1 "VER is empty" \
preflight_stderr "" "$MERGE_SHA" "" no
check "empty MERGE_SHA refuses" 1 "MERGE_SHA is empty" \
preflight_stderr "$VER" "" "" no
check "empty RELEASED refuses" 1 "RELEASED is empty" \
preflight_stderr "$VER" "$MERGE_SHA" "" ""
check "malformed RELEASED refuses" 1 "RELEASED='maybe' — expected yes or no" \
preflight_stderr "$VER" "$MERGE_SHA" "" maybe
# --- stream discipline and purity ------------------------------------------
notice_stays_on_stdout() {
local stdout stderr
stdout="$(preflight "$VER" "$MERGE_SHA" "$MERGE_SHA" no 2>"$TMP/preflight.err")"
stderr="$(cat "$TMP/preflight.err")"
[ -n "$stdout" ] && [ -z "$stderr" ]
}
refusal_stays_on_stderr() {
local stdout stderr rc
stdout="$(preflight "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no 2>"$TMP/preflight.err")"
rc=$?
stderr="$(cat "$TMP/preflight.err")"
[ "$rc" -eq 1 ] && [ -z "$stdout" ] && [ -n "$stderr" ]
}
no_tool_calls() {
! grep -v '^[[:space:]]*#' "$PREFLIGHT" | grep -Ewq 'git|gh|curl|wget'
}
check "resume notice and output stay on stdout" 0 "" notice_stays_on_stdout
check "refusal stays on stderr" 0 "" refusal_stays_on_stderr
check "preflight calls no git/gh/network tools" 0 "" no_tool_calls
summary

View file

@ -1,253 +0,0 @@
#!/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. #199 made that boundary REST through
# the shim, so it is driven here on BOTH backends with stubs at the transport
# — a fake `gh api` for the github backend, a fake `curl` for the forgejo one.
# Stubbing the shim itself would prove only that the test can stub the shim.
mkdir -p "$TMP/bin"
# The fixture, one PR, expressed once and served by both stubs. The body
# REFERENCES #5 while a commit CLOSES it: the contradiction this action
# exists to catch, and the case a body-only port would miss (#199).
PR_BODY_DEFAULT='Refs #5'
COMMIT_MSG_DEFAULT='Closes #5
body text'
cat >"$TMP/bin/gh" <<'EOF'
#!/usr/bin/env bash
set -u
[ -z "${FORGE_CALL_LOG:-}" ] || printf '%s\n' "$*" >>"$FORGE_CALL_LOG"
endpoint=""; jqexpr=""
shift # `api`
while [ $# -gt 0 ]; do
case "$1" in
--paginate) ;;
--jq) jqexpr="$2"; shift ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
case "${FAKE_FORGE_MODE:-success}" in
body-fails) case "$endpoint" in */commits*) ;; *) echo "fake body read failed" >&2; exit 42 ;; esac ;;
commits-fails) case "$endpoint" in */commits*) echo "fake commit read failed" >&2; exit 42 ;; esac ;;
esac
case "$endpoint" in
*/commits*) out="$(jq -nc --arg m "${FAKE_COMMIT_MSG:-}" '[{commit:{message:$m}}]')" ;;
*) out="$(jq -nc --arg b "${FAKE_PR_BODY:-}" '{body:$b}')" ;;
esac
if [ -n "$jqexpr" ]; then printf '%s' "$out" | jq -r "$jqexpr"; else printf '%s' "$out"; fi
EOF
chmod +x "$TMP/bin/gh"
# curl as the forgejo backend calls it: -D <headers> -o <body> <url>. The
# x-total-count header is what forge_api's completeness proof compares
# against, so `partial` here is a REAL short gather, not a flag the test
# invents (#188, #4699).
cat >"$TMP/bin/curl" <<'EOF'
#!/usr/bin/env bash
set -u
hdr=""; body=""; url=""
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift 2 ;;
-o) body="$2"; shift 2 ;;
-H) shift 2 ;;
-sS|-s|-S) shift ;;
*) url="$1"; shift ;;
esac
done
[ -z "${FORGE_CALL_LOG:-}" ] || printf '%s\n' "$url" >>"$FORGE_CALL_LOG"
case "${FAKE_FORGE_MODE:-success}" in
body-fails) case "$url" in *"/commits"*) ;; *) echo "fake body read failed" >&2; exit 42 ;; esac ;;
commits-fails) case "$url" in *"/commits"*) echo "fake commit read failed" >&2; exit 42 ;; esac ;;
esac
total=1
case "$url" in
*"/commits"*)
payload="$(jq -nc --arg m "${FAKE_COMMIT_MSG:-}" '[{commit:{message:$m}}]')"
# `partial`: declare 9, serve 1 and then nothing. Serving the same item on
# every page instead would NOT be a short gather — forge_api walks until a
# short page, so a stub that never runs out lets it collect exactly the
# declared total and pass honestly. The first draft of this stub did that
# and the case passed while proving nothing.
if [ "${FAKE_FORGE_MODE:-success}" = partial ]; then
total=9
grep -q 'page=1\b' <<<"$url" || payload='[]'
fi
;;
*) payload="$(jq -nc --arg b "${FAKE_PR_BODY:-}" '{body:$b}')" ;;
esac
printf 'HTTP/1.1 200 OK\r\nx-total-count: %s\r\n\r\n' "$total" >"$hdr"
printf '%s' "$payload" >"$body"
EOF
chmod +x "$TMP/bin/curl"
# boundary <forge> <mode> [body] [commit-message]
boundary() {
local forge="$1" mode="$2"
local body="${3-$PR_BODY_DEFAULT}" msg="${4-$COMMIT_MSG_DEFAULT}"
env PATH="$TMP/bin:$PATH" \
FAKE_FORGE_MODE="$mode" FAKE_PR_BODY="$body" FAKE_COMMIT_MSG="$msg" \
CEREMONY_FORGE="$forge" \
CEREMONY_FORGE_API="https://forge.example/api/v1" GH_TOKEN=tok \
REPO="heavy-duty/ceremony" GITHUB_REPOSITORY="heavy-duty/ceremony" \
PR_NUMBER=268 \
GITHUB_ACTION_PATH="$ROOT/actions/refs-not-closing" \
bash "$ENTRYPOINT"
}
# THE PORTABILITY CLAIM, driven rather than asserted: one fixture, both
# backends, same verdict. This is the acceptance criterion #199 states.
check "a Refs promise contradicted by a commit is caught — github backend" 1 \
"#5" boundary github success
check "...and identically on the forgejo backend" 1 \
"#5" boundary forgejo success
# The half a body-only port would miss: the keyword is ONLY in the commit.
check "a closing keyword only in a commit message is detected — github" 1 \
"#5" boundary github success 'Refs #5' 'Closes #5'
check "...and on forgejo" 1 \
"#5" boundary forgejo success 'Refs #5' 'Closes #5'
# ...and the same PR passes when nothing closes it, so the case above is
# detecting the keyword rather than just failing on every input.
check "a body that only references, with no closing keyword anywhere, passes" 0 \
"" boundary forgejo success 'Refs #5' 'plain commit subject'
# A failed read must never reach the parser: an empty body parses to an empty
# closing set, which is a PASSING verdict the action never earned.
check "a failed body read refuses, non-zero — github" 1 \
"refusing a verdict" boundary github body-fails
check "...and on forgejo" 1 "refusing a verdict" boundary forgejo body-fails
check "a failed commit read refuses, non-zero" 1 \
"refusing a partial verdict" boundary github commits-fails
# The `hasNextPage` refusal, relocated (#199 spec 3): a short paginated gather
# is the REST equivalent, and the forgejo backend's x-total-count proof is
# what catches it. This is the case that would silently pass if the port had
# dropped --paginate.
check "an incomplete commit read refuses a partial verdict" 1 \
"refusing a partial verdict" boundary forgejo partial
# Strip comments first. The entrypoint's prose NAMES gh, GraphQL and
# CEREMONY_FORGE_CLIENT to explain what it replaced, so a raw grep asserts on
# the explanation rather than the code — it passes with the call still there.
entrypoint_code() { sed 's/#.*//' "$ENTRYPOINT"; }
invokes_gh() { entrypoint_code | grep -qE '(^|[^[:alnum:]_])gh[[:space:]]'; }
holds_graphql() { entrypoint_code | grep -qi 'graphql'; }
declares_client() { entrypoint_code | grep -q 'CEREMONY_FORGE_CLIENT'; }
check "the entrypoint invokes no gh (#199)" 1 "" invokes_gh
check "...and holds no GraphQL" 1 "" holds_graphql
check "...and declares no forge client, because it speaks the shim" 1 "" declares_client
# The caller must SCHEDULE it now. A portable action behind a github-only gate
# is a guard that passes by never running (@kimi-reviewer-andresmgsl, #198).
check "the caller no longer gates the job on the forge" 1 "" \
grep -F "github.server_url" "$ROOT/.github/workflows/refs-guard.yml"
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

View file

@ -29,11 +29,7 @@ trap 'rm -rf "$TMP"' EXIT
mkdir -p "$TMP/stub"
cat >"$TMP/stub/gh" <<'EOF'
#!/usr/bin/env bash
# The label read is now GET commits/{sha}/pulls, a JSON array (#191).
if [ "$1" = api ]; then
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
exit 0
fi
if [ "$1" = api ]; then echo true; exit 0; fi
echo "gh stub: unexpected call: gh $*" >&2
exit 97
EOF
@ -87,7 +83,6 @@ chain() {
(
cd "${3:-$TMP/repo}" || exit 1
facts_out="$(env PATH="${4:-$TMP/stub}:$PATH" GITHUB_REPOSITORY=fixture/fixture \
CEREMONY_FORGE=github \
GH_TOKEN=stub VERSION_SOURCE=file MERGE_SHA="$1" EVENT_BEFORE="$2" \
bash "$FACTS")" || exit 1
printf '%s\n' "$facts_out"

View file

@ -1,210 +0,0 @@
#!/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" ]
}
path_check_reports_only() {
local tree="$1" expected="$2" output rc
output="$(path_check "$tree" 2>&1)"
rc=$?
if [ "$rc" -ne 1 ] || [ "$output" != "$expected" ]; then
printf 'expected only: %s\ngot (exit %s): %s\n' \
"$expected" "$rc" "$output" >&2
return 1
fi
}
readme_has_no_path_enumeration() {
local token found=no
for token in \
bin/ lib/version.sh lib/decide.sh lib/facts.sh lib/changelog.sh lib/forge.sh
do
if grep -qF "$token" "$ROOT/drills/README.md"; then
printf 'drill doctrine enumerates release path: %s\n' "$token" >&2
found=yes
fi
done
[ "$found" = no ]
}
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"
printf '#!/usr/bin/env bash\n' >"$tree/lib/preflight.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/preflight.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"
check "drill doctrine does not duplicate the executable release path" 0 "" \
readme_has_no_path_enumeration
# 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"\nrun: . "%s"\n' \
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/preflight.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 only its missing path" 0 "" \
path_check_reports_only "$tree" \
"release-path: missing dependency: lib/ruling.sh"
# 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"\nrun: . "%s"\n' \
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/preflight.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 only its missing path" 0 "" \
path_check_reports_only "$tree" \
"release-path: missing dependency: lib/ruling.sh"
# A manifest may not rot into a safe-looking superset.
tree="$(fixture extra)"
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
>"$tree/.github/workflows/release.yml"
sed -i '$ s|$| \\|' \
"$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 with only its stale path" 0 "" \
path_check_reports_only "$tree" "release-path: stale path: lib/ruling.sh"
# Transitive sourcing is part of the derivation, not decoration.
tree="$(fixture transitive)"
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/preflight.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 with only its stale path" 0 "" \
path_check_reports_only "$tree" "release-path: stale path: lib/version.sh"
summary

View file

@ -115,71 +115,6 @@ 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

View file

@ -1,306 +0,0 @@
#!/usr/bin/env bash
# The delta-inventory guard (#200, enforcing #197 D3's standing constraint).
#
# Forge-specific behaviour lives in the files docs/UPSTREAM-SYNC.md names,
# and nowhere else.
#
# WHY. This tree carries upstream's content plus a forge delta, forever — the
# sync is recurring and upstream is read-only (#197 D3). What makes that
# tractable is not luck: the 0.6.0 sync cost 18 conflict hunks across 10 files
# because the delta sits in six files upstream never touches. Scatter
# `forge_detect` into a seventh and every future sync pays for it, in a place
# nobody chose.
#
# So this fails on the PR that scatters it, rather than on the sync after next
# — the same reason changelog-monotonic guards a property no single tree can
# be asked about.
#
# IT RUNS OFFLINE, AND IT DOES NOT PASS BY ABSENCE. Tests must not need the
# network, and a guard that goes green when it cannot see its input is the
# blind-sweep shape this repo keeps writing issues about. So the inventory is
# read from the document and the tree is scanned for forge markers — both local
# — and a missing or empty `.upstream-ref` is a REFUSAL, not a skip.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
DOC="$ROOT/docs/UPSTREAM-SYNC.md"
REF="$ROOT/.upstream-ref"
# --- the recorded upstream ref ------------------------------------------------
check "the sync document exists" 0 "" test -f "$DOC"
check "the upstream ref is recorded" 0 "" test -f "$REF"
# THE OBJECT IS MANDATORY. An earlier head accepted "UNVERIFIABLE-HERE" when
# upstream's commit was not in the local store, which contradicted this issue's
# own "must not pass by absence" and made the check decorative in exactly the
# environment it matters in (@codex-reviewer-andresmgsl, #200 review).
#
# "Runs offline" means the TEST reads local evidence, not that CI may omit the
# evidence and pass. ci.yml fetches the recorded object before running the
# suite; if it is missing here, that is a refusal.
recorded_ref() { grep -vE '^[[:space:]]*(#|$)' "$REF" | head -n1; }
ref_is_recorded() { [ -n "$(recorded_ref)" ]; }
check "...and is not blank — an unrecorded ref is a refusal, not a skip" 0 "" \
ref_is_recorded
# Full 40, not a short prefix: a short SHA is ambiguous across a growing repo
# and cannot be compared byte-wise between the runbook, the CHANGELOG and here.
ref_is_full_sha() { printf '%s' "$(recorded_ref)" | grep -qE '^[0-9a-f]{40}$'; }
check "...and is a FULL 40-character SHA" 0 "" ref_is_full_sha
ref_object_present() { git -C "$ROOT" cat-file -e "$(recorded_ref)^{commit}" 2>/dev/null; }
check "the recorded commit is present locally — absent is a refusal, not a skip" 0 "" \
ref_object_present
ref_is_ancestor() { git -C "$ROOT" merge-base --is-ancestor "$(recorded_ref)" HEAD 2>/dev/null; }
check "...and is an ancestor of HEAD, so main really carries what is recorded" 0 "" \
ref_is_ancestor
# --- the inventory ------------------------------------------------------------
# The document's inventory table: the first `code` span of each table row in
# the "Where the forge delta lives" section.
inventory() {
awk '/^## Where the forge delta lives/ { on = 1; next }
on && /^## / { on = 0 }
on && /^\| `/ { gsub(/^\| `/, ""); sub(/`.*$/, ""); print }' "$DOC"
}
inventory_size() { inventory | wc -l; }
inventory_is_populated() { [ "$(inventory_size)" -ge 6 ]; }
check "the inventory names at least the six known delta files" 0 "" \
inventory_is_populated
# Exact for a file entry, `dir/` + one path separator for a directory entry.
# Prefix matching accepted `drills-old/x` for `drills/` and `lib/forge.sh.backup`
# for `lib/forge.sh` (@codex-reviewer-andresmgsl, #200 review).
in_inventory() { # $1 = repo-relative path
local entry
while IFS= read -r entry; do
[ -n "$entry" ] || continue
case "$entry" in
*/) case "$1" in "$entry"*) return 0 ;; esac ;;
*) [ "$1" = "$entry" ] && return 0 ;;
esac
done < <(inventory)
return 1
}
check "a listed file is recognised" 0 "" in_inventory lib/forge.sh
check "...including one listed as a directory" 0 "" in_inventory drills/0.4.1.md
check "an unlisted file is not" 1 "" in_inventory lib/version.sh
# --- the scan -----------------------------------------------------------------
# What "forge-specific" means, mechanically: the selector's own verbs, the
# environment override that drives them, and — on a workflow, which has no
# shell to call the selector from — a forge decision written inline.
#
# The earlier version scanned only shell under lib/ actions/ bin/ .github/scripts.
# That missed three categories the inventory itself claims to govern: workflows,
# .github/labels.conf and drills/. Merged main already had blind spots there —
# labels.yml decides on GITHUB_SERVER_URL and declares a client, refs-guard.yml
# carries a positive GitHub-only scheduling decision
# (@codex-reviewer-andresmgsl, #200 review).
FORGE_MARKERS='forge_detect|forge_select|forge_preflight|forge_client|CEREMONY_FORGE\b|CEREMONY_FORGE_CLIENT|GITHUB_SERVER_URL|github\.server_url'
# SCAN_ROOT is a parameter so the teeth below can drive the REAL check against
# a constructed tree. The previous mutation proved the predicates and never
# invoked no_unlisted — the guard could have been `return 0` and still passed.
scan_root() { printf '%s' "${SCAN_ROOT:-$ROOT}"; }
# DISCOVERY IS DERIVED FROM THE TREE, not from a list of directories, depths
# and extensions. The earlier version hand-picked five globs and therefore
# could not see `actions/*/action.yml` — this repository's normal composite
# structure, where a client declaration is exactly a forge decision — or a
# workflow written `.yaml` rather than `.yml`. @codex-reviewer-andresmgsl
# constructed both and the guard stayed 21/21 green, which is the whole
# argument against maintaining a glob list.
#
# So: walk everything, then EXCLUDE by class, and let content classify the
# rest. Excluding is safer than including because a new file type arrives
# scanned rather than invisible.
#
# test/ the harness stubs and asserts these tokens by design
# changelog.d/ prose fragments
# *.md prose. `drills/` stays in the INVENTORY because its records
# are forge-specific by content, but a record mentioning a
# selector verb in prose is not a decision, and scanning prose
# for decisions is the mistake this guard's own comment
# handling exists to avoid.
#
# DISCOVERY IS GIT'S, NOT THE FILESYSTEM'S. An earlier head said "tracked" and
# used `find`, which walks the working directory and knows nothing about the
# index. That is not pedantry: `ci.yml` extracts shellcheck and actionlint
# tarballs INTO the checkout before the suite runs, and any developer cache
# sits there too. @codex-reviewer-andresmgsl reproduced a false red with one
# untracked file. `git ls-files -z` makes "tracked" executable rather than
# prose.
scanned_paths() {
local root; root="$(scan_root)"
git -C "$root" ls-files -z 2>/dev/null \
| tr '\0' '\n' \
| grep -vE '^(test/|changelog\.d/)' \
| grep -vE '\.md$' \
| sort
}
forge_specific_files() {
local root rel; root="$(scan_root)"
while IFS= read -r rel; do
[ -n "$rel" ] || continue
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$root/$rel" 2>/dev/null \
| grep -qE "$FORGE_MARKERS" && printf '%s\n' "$rel"
done < <(scanned_paths)
return 0
}
# A file that merely CALLS the shim is not where the forge is decided — every
# reconciler calls forge_preflight and that is the point of the shim. A file
# that DECLARES a client is a different thing and is NOT exempt: it is a
# deliberate forge-delta location and belongs in the inventory
# (@codex-reviewer-andresmgsl).
SHIM_CONSUMERS='actions/issueflow-reconcile/issueflow-reconcile.sh
actions/labels-reconcile/labels-reconcile.sh
actions/labels-scope/labels-scope.sh
lib/facts.sh
lib/ruling.sh
lib/attention.sh
.github/workflows/release.yml'
declares_a_client() {
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$(scan_root)/$1" \
| grep -qE 'CEREMONY_FORGE_CLIENT[=:]'
}
unlisted_deciders() {
local rel
while IFS= read -r rel; do
[ -n "$rel" ] || continue
if grep -qxF "$rel" <<<"$SHIM_CONSUMERS" && ! declares_a_client "$rel"; then
continue
fi
in_inventory "$rel" && continue
printf '%s\n' "$rel"
done < <(forge_specific_files)
}
no_unlisted() {
local found
found="$(unlisted_deciders)"
[ -z "$found" ] || {
printf 'forge-specific but not in docs/UPSTREAM-SYNC.md:\n' >&2
printf ' %s\n' "$found" >&2
return 1
}
}
check "every forge-deciding file is named in the inventory" 0 "" no_unlisted
# --- teeth --------------------------------------------------------------------
# These drive the REAL top-level check against a constructed tree, via
# SCAN_ROOT. The earlier version asserted the predicates separately and never
# invoked no_unlisted — so the guard could have been replaced with `return 0`
# and both "must-fail" rows would still have passed
# (@codex-reviewer-andresmgsl, #200 review). A mutation test that cannot fail
# when the thing it guards is deleted is the shape this repo keeps filing
# issues about.
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# A tiny GIT repository, because discovery is git's now: a fixture that is only
# a directory would be invisible to `ls-files` and every must-fail below would
# pass vacuously.
fixture_tree() { # -> a tracked tree with no forge-deciding files
local t="$TMP/tree"
rm -rf "$t"
mkdir -p "$t/lib" "$t/.github/workflows"
printf '#!/usr/bin/env bash\necho hello\n' >"$t/lib/plain.sh"
printf 'name: ci\non: [push]\n' >"$t/.github/workflows/plain.yml"
git -C "$t" init -q 2>/dev/null
git -C "$t" add -A 2>/dev/null
printf '%s' "$t"
}
track() { git -C "$root" add -A 2>/dev/null; }
root="$(fixture_tree)"
clean_tree_passes() { SCAN_ROOT="$root" no_unlisted; }
check "the guard is green on a tree with no forge decisions" 0 "" clean_tree_passes
# MUST FAIL: a shell file that decides the forge, in no inventory entry.
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
printf '%s\n' '#!/usr/bin/env bash' 'case "$(forge_detect)" in forgejo) : ;; esac' \
>"$root/lib/scattered.sh"
track; scattered_shell() { SCAN_ROOT="$root" no_unlisted; }
check "a new SHELL file deciding the forge fails the real guard" 1 "" scattered_shell
check "...and the failure names the file" 1 "lib/scattered.sh" scattered_shell
rm -f "$root/lib/scattered.sh"; track
# MUST FAIL on a NON-SHELL surface too, so coverage cannot regress to the old
# shell-only glob: a workflow deciding on the server URL.
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
printf '%s\n' 'name: x' 'on: [push]' 'jobs:' ' j:' ' steps:' \
' - run: [ "$GITHUB_SERVER_URL" = https://github.com ] || exit 0' \
>"$root/.github/workflows/scattered.yml"
track; scattered_workflow() { SCAN_ROOT="$root" no_unlisted; }
check "a new WORKFLOW deciding the forge fails it too" 1 "" scattered_workflow
check "...naming that file" 1 ".github/workflows/scattered.yml" scattered_workflow
rm -f "$root/.github/workflows/scattered.yml"; track
# @codex-reviewer-andresmgsl's two reproductions, verbatim as fixtures. Both
# passed the hand-picked-glob version 21/21, which is why discovery is derived
# from the tree now. Deleting a discovery class must make these red.
mkdir -p "$root/actions/unlisted-forge-decision"
printf '%s\n' 'name: x' 'runs:' ' using: composite' ' steps:' \
' - shell: bash' ' env:' ' CEREMONY_FORGE_CLIENT: gh' \
' run: true' >"$root/actions/unlisted-forge-decision/action.yml"
track; composite_action_seen() { SCAN_ROOT="$root" no_unlisted; }
check "a forge declaration in actions/*/action.yml fails the guard" 1 \
"actions/unlisted-forge-decision/action.yml" composite_action_seen
rm -rf "$root/actions/unlisted-forge-decision"; track
# ...and a workflow written .yaml rather than .yml — `*.yml` was never a
# complete workflow surface.
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
printf '%s\n' 'name: x' 'on: [push]' 'jobs:' ' j:' \
" if: github.server_url == 'https://github.com'" ' steps: []' \
>"$root/.github/workflows/unlisted-forge-decision.yaml"
track; yaml_workflow_seen() { SCAN_ROOT="$root" no_unlisted; }
check "...and one in a .yaml workflow does too" 1 \
".github/workflows/unlisted-forge-decision.yaml" yaml_workflow_seen
rm -f "$root/.github/workflows/unlisted-forge-decision.yaml"; track
check "...leaving the fixture tree green again" 0 "" clean_tree_passes
# A declaration is a delta location even in a file that would otherwise read as
# a shim consumer, so the consumer allow-list cannot hide one.
mkdir -p "$root/lib"
printf '%s\n' '#!/usr/bin/env bash' 'CEREMONY_FORGE_CLIENT=gh' >"$root/lib/facts.sh"
track; declared_not_exempt() { SCAN_ROOT="$root" no_unlisted; }
check "a shim consumer that DECLARES a client is not exempt" 1 "lib/facts.sh" \
declared_not_exempt
rm -f "$root/lib/facts.sh"; track
track
check "...and the tree is green again once it is gone" 0 "" clean_tree_passes
# UNTRACKED input is not source. ci.yml extracts shellcheck and actionlint
# tarballs into the checkout before the suite runs, and a developer cache sits
# there too; parsing either is a false red on something outside the repository
# property (@codex-reviewer-andresmgsl, #200 review — reproduced with one file).
printf 'CEREMONY_FORGE_CLIENT=gh\n' >"$root/local-tool-cache.txt"
check "an UNTRACKED marker-bearing file is ignored" 0 "" clean_tree_passes
check "...and is still ignored once it carries a decision" 0 "" clean_tree_passes
track
check "...but the moment it is TRACKED the guard sees it" 1 "local-tool-cache.txt" \
clean_tree_passes
git -C "$root" rm -q --cached local-tool-cache.txt 2>/dev/null
rm -f "$root/local-tool-cache.txt"
check "...and removing it restores green" 0 "" clean_tree_passes
# Path matching, both boundaries.
check "a directory entry does not match a sibling with the same prefix" 1 "" \
in_inventory drills-old/0.4.1.md
check "a file entry does not match a longer path" 1 "" in_inventory lib/forge.sh.backup
check "...while the real ones still match" 0 "" in_inventory drills/0.4.1.md
summary

View file

@ -1,243 +0,0 @@
#!/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