diff --git a/.github/labeler.yml b/.github/labeler.yml index 1859654..c7a60bc 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -7,6 +7,32 @@ # these globs implement. Scopes locate, they do not alert — a path that maps # to nothing is fine (the mapping is advisory), so these rows chase the big # surfaces, not every file. +# +# A row that matches everything is worse than a missing one: it costs the +# same silence and adds a wrong answer. `changelog.d/**` sat under +# scope:release-flow until #267 measured it — the last 20 PRs (#197–#263) +# all carried scope:release-flow and 3 of them touched a release surface, +# because BUILDER.md makes every behavior change write a fragment, so the +# glob was "any PR that changes behavior" by doctrine. CHANGELOG.md stays: +# the same doctrine forbids editing it for an entry, so only the release PR +# does. The other rows #267 added — the issueflow reconciler, the three +# unmapped guard actions, RELEASES.md, README (which this tree spells +# README.md, so the old glob could match nothing) — are the same read of the +# same file, gaps rather than wrong answers. +# +# #302 is the same read once more, from #300's review: lib/attention.sh had +# #267 D4's premise exactly (both reconcilers source it, nothing release-side +# does) and was not in the rows — a wrong answer, not a gap. The sweep +# workflow pair joins beside its trigger pair: the sweeps detached in #209 +# and took the reconcile jobs and the cron with them. Two asymmetries are +# deliberate, not drift: the TESTS of the shared lib/ files take +# scope:labels alone, because lib/ruling.sh and lib/read.sh wear +# scope:release-flow only through the lib/** glob being kept whole and a +# test file inherits no such glob; and there is still no test/** or +# .github/scripts/** catch-all, because both directories span all four +# scopes — a catch-all is the changelog.d/** defect again, 100% recall and +# no locating power. The enumeration is the price of a test locating its +# subject. scope:release-flow: - changed-files: - any-glob-to-any-file: @@ -18,7 +44,6 @@ scope:release-flow: - bin/** - VERSION - CHANGELOG.md - - changelog.d/** - drills/** - test/decide.test.sh - test/facts.test.sh @@ -26,35 +51,67 @@ scope:release-flow: - test/version.test.sh - test/changelog.test.sh - test/self-ref.test.sh + - test/changelog-assemble.test.sh + - .github/scripts/release-path.sh + - test/release-path.test.sh scope:guards: - changed-files: - any-glob-to-any-file: - actions/changelog-armed/** + - actions/changelog-assembled/** - actions/changelog-monotonic/** + - actions/docs-sync/** - actions/drill-recorded/** + - actions/refs-not-closing/** + - actions/runner-isolated/** + - .github/workflows/refs-guard.yml - test/changelog-armed.test.sh + - test/changelog-assembled.test.sh - test/changelog-monotonic.test.sh + - test/docs-sync.test.sh - test/drill-recorded.test.sh + - test/refs-not-closing.test.sh + - test/runner-isolated.test.sh + - .github/scripts/marker-check.sh + - test/marker-check.test.sh + - .github/scripts/vendored-check.sh + - test/vendored.test.sh scope:labels: - changed-files: - any-glob-to-any-file: - .github/workflows/labels.yml - .github/workflows/self-labels.yml + - .github/workflows/labels-sweep.yml + - .github/workflows/self-labels-sweep.yml - .github/labeler.yml - .github/labels.conf + - actions/issueflow-reconcile/** - actions/labels-reconcile/** - actions/labels-scope/** + # shared by both reconcilers; lib/** keeps scope:release-flow too, + # and a mixed file honestly wears both labels (#267 D4, #302 D1) + - lib/read.sh + - lib/ruling.sh + - lib/attention.sh - LABELS.md + - test/issueflow-reconcile.test.sh - test/labels.test.sh - test/labels-reconcile.test.sh - test/labels-scope.test.sh + # tests of the shared lib/ files: scope:labels ALONE — a test + # inherits no lib/** glob, so its row is the one scope its subject + # actually locates (#302 D3) + - test/attention.test.sh + - test/ruling.test.sh + - test/labels-triggers.test.sh scope:docs: - changed-files: - any-glob-to-any-file: - - README + - README.md - docs/** - AGENTS.md - BUILDER.md + - RELEASES.md - REVIEWER.md - TRIAGE.md - CONTRIBUTING.md diff --git a/.github/scripts/marker-check.sh b/.github/scripts/marker-check.sh new file mode 100755 index 0000000..851ed6a --- /dev/null +++ b/.github/scripts/marker-check.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Availability-marker guard (issue #238). Five of five markers found by #221 +# outlived the releases that shipped their machinery. A release candidate must +# therefore reject a marker its assembled changelog makes false, while every +# tree rejects an untraceable marker. Cross-repo citations are traceable but +# are not compared with this repository's changelog; a marker for this repo's +# own issue uses bare #N, never a self-qualified repository citation (#238 D8). +# CHANGELOG.md is the release oracle and immutable shipped prose, so it and the +# fragments that feed it are excluded from the documentation scan (#238 D5). +# A token inside inline code is a mention, not a marker; spans are stripped +# individually so unrelated backticks cannot hide a real marker (#238 D9). +# +# Usage: marker-check.sh [tree-dir] (default: the repository root) +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +tree="${1:-$ROOT}" + +fail() { + printf '%s\n' "$@" >&2 + exit 1 +} + +if ! git -C "$tree" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + fail "marker-check: $tree is not a Git work tree; tracked Markdown cannot be determined." +fi + +marker_records="$(mktemp)" +trap 'rm -f "$marker_records"' EXIT + +mapfile -d '' markdown_files < <(git -C "$tree" ls-files -z -- '*.md') +for relative in "${markdown_files[@]}"; do + case "$relative" in + CHANGELOG.md|changelog.d/*) continue ;; + esac + + if ! awk -v file="$relative" ' + function without_inline_code(text, before, after) { + while (match(text, /`[^`]*`/)) { + before = substr(text, 1, RSTART - 1) + after = substr(text, RSTART + RLENGTH) + text = before after + } + return text + } + { + lines[NR] = $0 + scan_lines[NR] = without_inline_code($0) + } + END { + token = "**unreleased**" + citation_re = "^[[:space:]]*\\((([[:alnum:]_.-]+/)?[[:alnum:]_.-]+)?#[0-9]+\\)" + bad = 0 + + for (line_no = 1; line_no <= NR; line_no++) { + remaining = scan_lines[line_no] + offset = 0 + while ((at = index(remaining, token)) != 0) { + rest = substr(remaining, at + length(token)) + candidate = rest + next_line = line_no + 1 + while (candidate ~ /^[[:space:]]*$/ && next_line <= NR) { + candidate = candidate " " scan_lines[next_line] + next_line++ + } + + if (match(candidate, citation_re)) { + citation = substr(candidate, RSTART, RLENGTH) + sub(/^[[:space:]]*\(/, "", citation) + sub(/\)$/, "", citation) + printf "%s\t%d\t%s\n", file, line_no, citation + } else { + printf "marker-check: %s:%d: %s\n", file, line_no, lines[line_no] > "/dev/stderr" + printf "marker-check: every **unreleased** marker must be immediately followed by an issue citation such as (#238), (crew#293), or (owner/repo#293).\n" > "/dev/stderr" + bad = 1 + } + + offset += at + length(token) - 1 + remaining = substr(scan_lines[line_no], offset + 1) + } + } + exit bad + } + ' "$tree/$relative" >>"$marker_records"; then + exit 1 + fi +done + +version="" +if [ -f "$tree/VERSION" ]; then + IFS= read -r version <"$tree/VERSION" || true +fi + +if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + [ -f "$tree/CHANGELOG.md" ] || \ + fail "marker-check: bare VERSION '$version' requires CHANGELOG.md for the release-marker check." + + shipped_issues="$(awk ' + $1 == "##" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+$/ { + if (in_section) exit + in_section = 1 + next + } + in_section && /^##[[:space:]]/ { exit } + in_section { + text = $0 + while (match(text, /(^|[^[:alnum:]_./-])#[0-9]+/)) { + issue = substr(text, RSTART, RLENGTH) + sub(/^.*#/, "", issue) + print issue + text = substr(text, RSTART + RLENGTH) + } + } + ' "$tree/CHANGELOG.md" | sort -u)" + + while IFS=$'\t' read -r file line citation; do + case "$citation" in + \#*) + issue="${citation#\#}" + if printf '%s\n' "$shipped_issues" | grep -qxF "$issue"; then + fail "marker-check: $file:$line: **unreleased** (#$issue) is false on release candidate $version; CHANGELOG.md's top release section cites #$issue, so clear the marker in this release PR." + fi + ;; + esac + done <"$marker_records" +fi + +echo "marker-check: availability markers agree with the tree." diff --git a/.github/scripts/release-path.sh b/.github/scripts/release-path.sh new file mode 100755 index 0000000..3bbe3db --- /dev/null +++ b/.github/scripts/release-path.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# The release doors' executable path (discussion #217; issue #237). The +# 0.5.0 record had to explain why lib/ruling.sh changed without changing a +# door. Keep this list executable so a doors-unchanged record measures the +# workflow and only the scripts it actually runs, while the test catches a +# new or removed dependency before a record can silently omit it. +# +# On this tree the doors also speak the forge shim: #191 ported lib/facts.sh +# and release.yml off `gh` so a Forgejo consumer can publish at all, which +# makes lib/forge.sh part of the doors' executable path here. The backends it +# loads (lib/forge-github.sh, lib/forge-forgejo.sh) are sourced through +# $FORGE_LIB_DIR at run time rather than by a literal `.` line, so the +# transitive scan cannot see them and listing them would read as a stale +# path; lib/forge.sh standing for the trio is the honest entry (#198). +set -euo pipefail + +printf '%s\n' \ + .github/workflows/release.yml \ + bin/ \ + lib/version.sh \ + lib/decide.sh \ + lib/facts.sh \ + lib/changelog.sh \ + lib/forge.sh diff --git a/.github/scripts/vendored-check.sh b/.github/scripts/vendored-check.sh new file mode 100755 index 0000000..929e4fe --- /dev/null +++ b/.github/scripts/vendored-check.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +# The vendored-manifest self-guard (issue #251; #248's near-miss). Consumers +# mirror the agent-facing doc set declared by docs/VENDORED.txt, and +# actions/docs-sync already enforces "manifest ∪ .ceremony/, nothing else" +# on the CONSUMER side. Nothing enforced the other end: a new doctrine file +# could land at ceremony's root and nobody add it to the manifest, and the +# miss is SILENT — docs-sync asserts byte-identity for the files the +# manifest names, so a doc it omits is never checked and every consumer +# drifts doctrine-blind with green guards. #248 (RELEASES.md) nearly shipped +# that way, caught only by a hand-written `grep -Fx RELEASES.md` row in +# test/docs-sync.test.sh — the hardcoded list this guard abolishes, one +# layer down. That row is deleted; this script carries its intent. +# +# Two directions, two mechanisms, because only one of them can be a scan +# (#251 D2): +# +# * MANIFEST → TREE is a scan: every entry resolves to a regular, +# non-empty, tracked file at the declared path — no symlink (PR #43: +# a symlink read as doctrine while staying invisible), no directory, +# no `../` escape. +# * TREE → MANIFEST cannot scan, because nothing in the tree answers +# "which files are vendorable" — the manifest is the only +# machine-readable notion of it. So it gets a CLOSED-WORLD RULE +# instead: every `*.md` at the repository ROOT is either in the +# manifest or in the exemption list below. Adding a root doc then +# forces a one-line decision — vendor it or exempt it — and the +# refusal names the file and both fixes. +# +# The rule is ROOT-LEVEL `*.md` ONLY. It does not walk docs/, actions/ or +# drills/: those hold no agent-facing doctrine, and a recursive version +# would grow the exemption list past the length at which a reviewer still +# reads it — which is the failure this rule is shaped against. +# +# The exemption list lives HERE, in the script. CONTRIBUTING.md's +# vendored-set sentence is documentation, never an input: two declarations +# of the same set is the drift the manifest exists to prevent. +# +# Usage: vendored-check.sh [tree-dir] (default: the repo root — the CI +# step; tests point it at fixture trees) +set -euo pipefail + +MANIFEST="docs/VENDORED.txt" + +tree="${1:-.}" + +# The root docs that are deliberately ceremony-only. Each carries the reason +# it is not vendored, because the reason is what lets the next reviewer +# judge the next addition. Prints the reason and returns 0 when exempt. +exempt_reason() { + case "$1" in + README.md) + echo "ceremony's own front page — a consumer's router is AGENTS.md, not this repo's README" + ;; + CONTRIBUTING.md) + echo "repo-specific facts (this repo's roster, scopes and conventions); every governed repo writes its own" + ;; + CHANGELOG.md) + echo "ceremony's own release history; a consumer keeps its own" + ;; + FLEET.md) + echo "the operator's fleet map — about running the fleet, not about how a governed repo works" + ;; + *) return 1 ;; + esac +} + +die() { + printf 'vendored-check: %s\n' "$@" >&2 + exit 1 +} + +manifest_file="$tree/$MANIFEST" +[ -f "$manifest_file" ] || die \ + "no $MANIFEST under $tree — the manifest is the sole declaration of the" \ + " vendored doc set, and this guard has nothing to guard without it." + +# Blank lines are skipped, exactly as actions/docs-sync reads it: the guard +# and the tool must accept the same file, or one of them is the bug. +mapfile -t manifest < <(grep -v '^[[:space:]]*$' "$manifest_file" || true) +[ "${#manifest[@]}" -gt 0 ] || die \ + "$MANIFEST is empty — an empty doctrine set is a ceremony bug, not a repo" \ + " with no rules." + +# Whether the tracked-file assertion can bind: only when the tree IS a git +# work tree root. Fixture trees are plain directories, and asserting +# tracked-ness against an enclosing repository would be asserting about the +# wrong tree. +# +# When it cannot bind, SAY SO. This guard's whole argument is that a silent +# miss is worse than a loud one, and a guard that quietly stops asserting one +# of its four properties is exactly that shape — so the skip is announced on +# every run, green or red, rather than inferred from the absence of a +# refusal (#251 round 1). +tracked_check=no +tracked_note="tracked-ness NOT asserted: $tree is not a git work tree root, so + 'is this file in the tag's tree' cannot be answered about THIS tree. The + other three manifest assertions (regular file, non-empty, no + symlink/dir/escape) still bind." +if command -v git >/dev/null 2>&1; then + toplevel="$(git -C "$tree" rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$toplevel" ] && [ "$toplevel" = "$(cd "$tree" && pwd -P)" ]; then + tracked_check=yes + tracked_note="" + fi +fi + +# Every refusal is collected and reported together, one multi-line string +# per offending file: a guard that stops at the first problem makes a +# builder pay one CI round per file. +problems=() + +# --- manifest → tree --------------------------------------------------------- + +for entry in "${manifest[@]}"; do + case "$entry" in + /* | *..*) + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry' — an absolute path or a '..' escape. The" \ + " mirror writes only inside a consumer's .ceremony/, so a path that" \ + " leaves it is never vendorable." \ + " Fix: name the path relative to the repository root, with no '..'." + )") + continue + ;; + esac + + path="$tree/$entry" + + # -L before -f: `[ -f ]` follows the link, so a symlink to a real file + # would otherwise pass as a regular one. + if [ -L "$path" ]; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry', which is a SYMLINK. A symlink vendors as" \ + " doctrine while its content lives somewhere the mirror never checks" \ + " (PR #43's round: it read as doctrine and stayed invisible)." \ + " Fix: make '$entry' a regular file, or drop the entry from $MANIFEST." + )") + continue + fi + if [ -d "$path" ]; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry', which is a DIRECTORY. The manifest declares" \ + " files, one per line — a directory entry vendors nothing." \ + " Fix: name each file under '$entry' on its own line, or drop the entry." + )") + continue + fi + if [ ! -f "$path" ]; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry' but the tree has no such file. Every consumer" \ + " mirroring this ref would fail on it." \ + " Fix: add '$entry' to the tree, or remove it from $MANIFEST." + )") + continue + fi + if [ ! -s "$path" ]; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry', which is EMPTY. An empty file vendors as" \ + " doctrine that says nothing, and is read as doctrine anyway." \ + " Fix: write '$entry', or remove it from $MANIFEST." + )") + continue + fi + if [ "$tracked_check" = yes ] && + ! git -C "$tree" ls-files --error-unmatch -- "$entry" >/dev/null 2>&1; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry', which is not TRACKED. A file absent from the" \ + " tag's tree cannot be fetched by a consumer syncing at that tag," \ + " however present it is on this machine." \ + " Fix: git add '$entry', or remove it from $MANIFEST." + )") + continue + fi +done + +# --- tree → manifest: the closed world over root `*.md` ---------------------- + +in_manifest() { + local p + for p in "${manifest[@]}"; do + [ "$p" = "$1" ] && return 0 + done + return 1 +} + +shopt -s nullglob +exempted=() +vendored=() +for path in "$tree"/*.md; do + doc="${path##*/}" + if in_manifest "$doc"; then + vendored+=("$doc") + continue + fi + if reason="$(exempt_reason "$doc")"; then + exempted+=("$doc — $reason") + continue + fi + problems+=("$( + printf '%s\n' \ + "'$doc' is a root doc in NEITHER list. Every root *.md is either vendored" \ + " doctrine — mirrored into every governed repo at .ceremony/ — or" \ + " deliberately ceremony-only, and nothing in the tree says which, so the" \ + " decision has to be written down. Fix, one of:" \ + " * add '$doc' to $MANIFEST, if it is agent-facing doctrine that every" \ + " governed repo must carry;" \ + " * add '$doc' to the exemption list in" \ + " .github/scripts/vendored-check.sh, with the reason it stays" \ + " ceremony-only." + )") +done +shopt -u nullglob + +if [ "${#problems[@]}" -gt 0 ]; then + { + printf 'vendored-check: %d problem(s) — docs/VENDORED.txt and the tree disagree.\n\n' \ + "${#problems[@]}" + printf '%s\n\n' "${problems[@]}" + [ -z "$tracked_note" ] || printf 'vendored-check: %s\n' "$tracked_note" + } >&2 + exit 1 +fi + +printf 'vendored-check: %d manifest entries resolve; %d root docs vendored, %d exempt.\n' \ + "${#manifest[@]}" "${#vendored[@]}" "${#exempted[@]}" +[ -z "$tracked_note" ] || printf 'vendored-check: %s\n' "$tracked_note" +[ "${#vendored[@]}" -eq 0 ] || printf ' vendored: %s\n' "${vendored[@]}" +[ "${#exempted[@]}" -eq 0 ] || printf ' exempt: %s\n' "${exempted[@]}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62827b9..2a05151 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,28 @@ 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 diff --git a/.github/workflows/labels-sweep.yml b/.github/workflows/labels-sweep.yml new file mode 100644 index 0000000..707b871 --- /dev/null +++ b/.github/workflows/labels-sweep.yml @@ -0,0 +1,125 @@ +name: labels-sweep +# Reusable sweep half of the labels automation — the reconcile + issueflow +# jobs that rode labels.yml until #209. Triggers and permissions live in the +# caller; docs/CONSUMERS.md carries the complete caller stub +# (workflow_dispatch plus the hourly cron, which relocated here with the +# sweep). Board events still yield a sweep within seconds: labels.yml's +# trigger job dispatches this workflow's caller on every event it used to +# run reconcile on. +# +# Detached on purpose (#209): every sweep covers every open PR and all +# sweeps serialize through ONE shared concurrency group, so GitHub's +# one-running-plus-one-pending queue records every extra run as CANCELLED. +# That displacement is semantically lossless — the surviving sweep does the +# displaced run's work — but while the sweep rode pull_request_target runs +# the ❌ landed on that PR's checks and read as red CI, with no manual +# escape hatch: GitHub refuses to rerun a queue-displaced run (crew#250). +# And displacement is the steady state of a working fleet, not a spike — +# one panel request emits one review_requested event per reviewer, so +# every review round over-fills the one-running-plus-one-pending queue. +# Here a displaced run attaches to no PR: the cancellations live on the +# Actions tab only. +# +# Bootstrap semantics: a manual dispatch of the caller bootstraps the +# taxonomy (its `bootstrap` input defaults to "yes"), exactly what +# dispatching the labels caller did before the split. The trigger job's +# dispatches carry bootstrap=no — ~20 label upserts per sweep is too chatty +# for every board event, the same reason cron runs never bootstrapped. +# +# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not +# create workflow runs from GITHUB_TOKEN-raised events (the trigger's +# workflow_dispatch is one of the two documented exemptions; this workflow +# dispatches nothing). Agent writes use a PAT and therefore do trigger — +# exactly the asymmetry wanted. +on: + workflow_call: + inputs: + pr_workflow_name: + description: >- + The `name:` of the consumer's PR-facing labels caller, exported + to the reconcile step as SELF_WORKFLOW so the sweep can leave + the label machinery's own check entries (scope, trigger) out of + its CI verdict: a red trigger means "fix the caller", which no + PR edit can do, so it must never count toward blocker:ci-red. + Read by the #208 reconciler; harmless to earlier ones. + type: string + required: false + default: labels + +env: + # A called workflow arrives without its repository. Keep this literal pin + # aligned with the ceremony release consumed by callers (issue #9 D3). + CEREMONY_SELF_REF: "0.6.0" + +jobs: + reconcile: + runs-on: ubuntu-latest + # ONE shared group: every reconcile sweeps every open PR, so cron and + # dispatched runs must serialize or two sweeps race the same PR's labels + # and both pass the request-the-human-once guard. + concurrency: + group: labels-reconcile + cancel-in-progress: false + steps: + # No PR code is ever checked out or executed: the sweep checks out + # the consumer's default branch and the pinned ceremony + # implementation only. Keep it that way. + - uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + ref: ${{ github.event.repository.default_branch }} + - uses: actions/checkout@v4 + # The self-consumption bypass — release.yml's twin, and load-bearing + # for the same reason (#11): ceremony's own labels bootstrap must + # run BEFORE any release tag exists for this checkout to fetch — the + # release label the merge door reads is created by that dispatch, so + # without the bypass the first release deadlocks on its own pin. The + # base-branch checkout above already IS ceremony on the dogfood + # path. + if: github.repository != 'heavy-duty/ceremony' + with: + repository: heavy-duty/ceremony + ref: ${{ env.CEREMONY_SELF_REF }} + path: .ceremony-src + # Two steps, mutually exclusive `if:`s, because a `uses:` path must be + # a literal — the same fork release.yml's CEREMONY_DIR env line + # papers over for `run:` steps, which composite `uses:` has no + # equivalent of. + # + # bootstrap: every trigger-driven wake arrives as workflow_dispatch + # too (that is how `gh workflow run` wakes the caller), so the event + # name alone no longer separates the operator's manual full-board + # bootstrap from an event-woken sweep — the caller's `bootstrap` + # dispatch input does: the trigger passes "no", a bare manual + # dispatch defaults to "yes". A caller reached on any other event + # (the cron) has no input and stays "no". + - name: reconcile state + stale + if: github.repository != 'heavy-duty/ceremony' + uses: ./.ceremony-src/actions/labels-reconcile + with: + bootstrap: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.bootstrap != 'no' && 'yes' || 'no' }} + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SELF_WORKFLOW: ${{ inputs.pr_workflow_name }} + - name: reconcile state + stale (dogfood — the workspace IS ceremony) + if: github.repository == 'heavy-duty/ceremony' + uses: ./actions/labels-reconcile + with: + bootstrap: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.bootstrap != 'no' && 'yes' || 'no' }} + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SELF_WORKFLOW: ${{ inputs.pr_workflow_name }} + - name: reconcile issue flow + if: github.repository != 'heavy-duty/ceremony' + uses: ./.ceremony-src/actions/issueflow-reconcile + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + - name: reconcile issue flow (dogfood — the workspace IS ceremony) + if: github.repository == 'heavy-duty/ceremony' + uses: ./actions/issueflow-reconcile + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 6e2b194..925ff2f 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -6,36 +6,56 @@ name: labels # family arrives from a fork, where pull_request runs with a READ-ONLY token # and cannot label anything. _target is safe in this workflow because no PR # code is ever checked out or executed — scope reads changed paths and the -# path mapping via the API and checks out only the ceremony implementation, -# and reconcile checks out the BASE branch only. Keep it that way. +# path mapping via the API and checks out only the ceremony implementation. +# Keep it that way. # -# There is no pull_request_review_target, so a review landing cannot wake this -# workflow directly — which is why the caller's cron is load-bearing, not a -# safety net (#199 relaxed it from */15 to hourly, but did NOT drop it). The -# cron is the sweep's only discovery path for every transition no subscribed -# event carries: a verdict landing, blocker:ci-red set/cleared, a -# blocker:conflict when another PR merges under this one, and the time-based -# stale / 48h claim-reclaim. Where an event IS subscribed the wake is direct — -# the handoff sets state:needs-human and the caller's `labeled` event confirms -# or corrects that optimistic write within seconds. +# The reconcile sweep lived here until #209. Riding the PR-triggered run +# meant every displacement in the sweep's shared concurrency queue recorded +# a CANCELLED `reconcile` check on some PR — read as red CI by every human +# and agent, though the surviving sweep does the displaced run's work. Two +# field facts made that untenable (crew#250): a displaced run cannot be +# rerun — `gh run rerun`, `--failed`, and `--job` all refuse — so a victim +# PR has no manual escape hatch; and the displacing burst is deterministic, +# one `review_requested` event per panelist per request, so every review +# round displaces runs and the rate scales with panel size. The +# sweep now lives in labels-sweep.yml behind its own caller, and the +# trigger job below is its wake: it fires on every event this caller +# subscribes — the exact surface that used to run reconcile directly — so +# the wake latency (#137) is unchanged, while a displaced sweep cancels on +# the Actions tab, attached to no PR. PR checks show scope + trigger only. # -# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not -# create workflow runs from GITHUB_TOKEN-triggered events. Agent writes use a -# PAT and therefore do trigger — exactly the asymmetry wanted. +# This cannot loop: the trigger's dispatch and the reconciler's label +# writes both use GITHUB_TOKEN. GitHub does not create workflow runs from +# GITHUB_TOKEN-raised events — workflow_dispatch and repository_dispatch +# are the two documented exemptions, which is exactly why the trigger can +# wake the sweep with no PAT anywhere in the path — and the sweep itself +# dispatches nothing. Agent writes use a PAT and therefore do trigger — +# exactly the asymmetry wanted. on: workflow_call: + inputs: + sweep_workflow: + description: >- + Filename of the consumer's sweep caller — the workflow that + calls labels-sweep.yml (docs/CONSUMERS.md carries the stub). + The trigger job dispatches it by this name. Override it only + when the caller file is not named labels-sweep.yml (ceremony's + own dogfood names it self-labels-sweep.yml). + type: string + required: false + default: labels-sweep.yml env: # A called workflow arrives without its repository. Keep this literal pin # aligned with the ceremony release consumed by callers (issue #9 D3). - CEREMONY_SELF_REF: "0.4.1" + CEREMONY_SELF_REF: "0.6.0" jobs: scope: # Not on labeled/unlabeled: those events change no paths, so scope has # nothing new to derive — and label churn is precisely what they are. # review_requested/review_request_removed likewise change no paths — they - # exist to wake reconcile (#137) — and running labeler on them widens + # exist to wake the sweep (#137) — and running labeler on them widens # exactly the window #130 documents, where a label written during a # scope run is clobbered. if: >- @@ -85,65 +105,54 @@ jobs: # the mapping it is judged by CONFIG_REF: ${{ github.sha }} - reconcile: + trigger: + # The sweep's wake (#209). No `if:`: reconcile carried none, so the + # trigger keeps the whole event surface the caller subscribes — + # workflow_dispatch of the labels caller itself included. That cannot + # double-fire bootstrap: this dispatch always carries bootstrap=no, so + # a dispatched labels caller yields one plain sweep, and the taxonomy + # bootstrap fires solely on a manual dispatch of the sweep caller + # (whose input defaults to "yes"). Excluding workflow_dispatch here + # would instead make a dispatched labels caller do nothing at all — + # a silent no-op run is worse than a redundant sweep. + # + # LOUD on failure — never `|| true`: a red trigger is the + # misconfiguration alarm. A consumer that bumps the pin without adding + # the sweep caller (workflow-not-found), without its declared + # `bootstrap` input (unexpected input), or without `actions: write` + # on this caller (permission denied) fails HERE, visibly on the PR, + # instead of silently never sweeping again. runs-on: ubuntu-latest - # ONE shared group: every reconcile sweeps every open PR, so cron and - # PR-event runs must serialize or two sweeps race the same PR's labels - # and both pass the request-the-human-once guard. - concurrency: - group: labels-reconcile - cancel-in-progress: false steps: - # pull_request_target is required for fork PR write permission. It is - # safe here because no PR code is ever checked out or executed: - # labels-scope reads the mapping and changed paths via the API, and - # reconcile checks out the BASE branch only. Keep it that way. - - uses: actions/checkout@v4 - with: - repository: ${{ github.repository }} - ref: ${{ github.event.repository.default_branch }} - - uses: actions/checkout@v4 - # The self-consumption bypass — release.yml's twin, and load-bearing - # for the same reason (#11): ceremony's own labels bootstrap must - # run BEFORE any release tag exists for this checkout to fetch — the - # release label the merge door reads is created by that dispatch, so - # without the bypass the first release deadlocks on its own pin. The - # base-branch checkout above already IS ceremony on the dogfood - # path. - if: github.repository != 'heavy-duty/ceremony' - with: - repository: heavy-duty/ceremony - ref: ${{ env.CEREMONY_SELF_REF }} - path: .ceremony-src - # Two steps, mutually exclusive `if:`s, because a `uses:` path must be - # a literal — the same fork release.yml's CEREMONY_DIR env line - # papers over for `run:` steps, which composite `uses:` has no - # equivalent of. - - name: reconcile state + stale - if: github.repository != 'heavy-duty/ceremony' - uses: ./.ceremony-src/actions/labels-reconcile - with: - bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }} + - name: dispatch the sweep env: GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - - name: reconcile state + stale (dogfood — the workspace IS ceremony) - if: github.repository == 'heavy-duty/ceremony' - uses: ./actions/labels-reconcile - with: - bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }} - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - - name: reconcile issue flow - if: github.repository != 'heavy-duty/ceremony' - uses: ./.ceremony-src/actions/issueflow-reconcile - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - - name: reconcile issue flow (dogfood — the workspace IS ceremony) - if: github.repository == 'heavy-duty/ceremony' - uses: ./actions/issueflow-reconcile - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} + SWEEP_WORKFLOW: ${{ inputs.sweep_workflow }} + # This step speaks gh and says so, the same declaration + # actions/refs-not-closing carries (#198 spec 4). A workflow has no + # shell to call forge_preflight from, so the refusal is inline + # below; #205 owns the REST port that removes both. + CEREMONY_FORGE_CLIENT: gh + run: | + # Two questions, not one. @codex-reviewer-andresmgsl: a guard that + # only asks `command -v gh` passes the moment a Forgejo runner image + # happens to ship gh — and then runs a GitHub dispatch against a + # forge that cannot serve it, which is the client/forge mismatch + # forge_preflight exists to prevent. So the FORGE is decided first, + # mirroring forge_detect positively (only github.com is accepted; + # anything else, known or not, is refused — "Never 'probably + # github'"), and the binary is checked second. + # + # A warning, not a failure: this trigger is the misconfiguration + # alarm for a CONSUMER's missing sweep caller, and reddening every + # sweep on a forge for a gap #205 already owns would drown that + # signal. #205 ports the dispatch to REST and removes all of this. + if [ "${GITHUB_SERVER_URL:-}" != "https://github.com" ]; then + echo "::warning::labels: the sweep was NOT woken from this trigger — it dispatches with \`gh\` against GitHub, and this is not a GitHub forge (GITHUB_SERVER_URL=${GITHUB_SERVER_URL:-unset}). #205 ports it to REST. The hourly SCHEDULED sweep still runs; every event-driven wake through this caller — issue events included — is unavailable until then." + exit 0 + fi + if ! command -v gh >/dev/null 2>&1; then + echo "::warning::labels: the sweep was NOT woken from this trigger — this runner does not carry \`gh\`. #205 ports the dispatch to REST. The hourly SCHEDULED sweep still runs; every event-driven wake through this caller is unavailable until then." + exit 0 + fi + gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no diff --git a/.github/workflows/refs-guard.yml b/.github/workflows/refs-guard.yml new file mode 100644 index 0000000..5b6abbe --- /dev/null +++ b/.github/workflows/refs-guard.yml @@ -0,0 +1,30 @@ +name: Refs guard + +on: + # Body edits are load-bearing: #200 gained its accidental closing keyword + # after the PR opened, with no new commit to wake ordinary CI (#218). + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + pull-requests: read + +jobs: + refs-not-closing: + # The action is gh-only until #199: its whole gather is a GraphQL query, + # and Forgejo serves no GraphQL at all. The ACTION refuses by name on a + # backend it cannot speak (that is its contract, and its contract test); + # scheduling it where it can only refuse is this workflow's decision, and + # a permanently red required check would block every merge on this forge + # for a gap #199 already owns. So the job does not run there — a skipped + # check is a green head, an invented verdict is not. + # + # The condition mirrors lib/forge.sh's forge_detect positively: only + # github.com is accepted, and anything else — Forgejo, or a host this + # file has not met — is not run. "Never 'probably github'." + if: ${{ github.server_url == 'https://github.com' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: ./actions/refs-not-closing diff --git a/.github/workflows/release-exercise.yml b/.github/workflows/release-exercise.yml index aa83b37..7194d81 100644 --- a/.github/workflows/release-exercise.yml +++ b/.github/workflows/release-exercise.yml @@ -132,7 +132,7 @@ jobs: EOF mkdir changelog.d printf '# changelog.d/ — assembled at release (heavy-duty/ceremony#112); the marker keeps the directory tracked.\n' > changelog.d/README.md - printf -- '- The entry this release ships.\n' > changelog.d/42.md + printf -- '- The entry this release ships (#42).\n' > changelog.d/42.md git add VERSION CHANGELOG.md changelog.d git commit -qm "base" printf '0.7.0\n' > VERSION diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba110cd..cdb1133 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,7 @@ env: # `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the # shared logic arrives as script files via checkout, not as inner `uses:` # references. - CEREMONY_SELF_REF: "0.4.1" + CEREMONY_SELF_REF: "0.6.0" VERSION_SOURCE: ${{ inputs.version-source }} jobs: diff --git a/.github/workflows/self-labels-sweep.yml b/.github/workflows/self-labels-sweep.yml new file mode 100644 index 0000000..94ae4a3 --- /dev/null +++ b/.github/workflows/self-labels-sweep.yml @@ -0,0 +1,46 @@ +name: labels-sweep +# Ceremony's own sweep caller (#209) — self-labels.yml's detached half, +# wearing the same local-`uses:` deviation and the same warning: consumers +# must NEVER copy the local form (it rides main, unpinned — correct only +# for the repo that IS the source). Consumers write: +# uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@ +on: + # The consumer owns this cadence (#203). Hourly is the recommended default + # when no other engine drives board state: the cron is then the sweep's ONLY + # wake for four transition classes — a review verdict landing (there is no + # pull_request_review trigger on the labels caller), blocker:ci-red set or + # cleared (no check_suite/check_run/workflow_run), a blocker:conflict when + # ANOTHER PR merges under this one, and the time-based stale / 48h + # claim-reclaim. The labels caller's events carry the rest in seconds, one + # trigger-job dispatch away. Hourly trades ≤1h of latency on those four + # while cutting nominal scheduled sweeps from four an hour to one at + # GitHub's 1-minute billing floor. Do not delete the cron: it is their + # discovery path. If another engine writes some of those transitions, only + # the classes with no other writer bound the cadence; relax it only as that + # list shrinks. + schedule: [{cron: "0 * * * *"}] + # A manual full-board sweep. A bare dispatch (input default "yes") also + # bootstraps the taxonomy on a fresh repo — what dispatching the labels + # caller did before #209. The reusable's trigger job wakes this workflow + # with bootstrap=no on every board event — an event-woken sweep must not + # re-upsert ~20 labels each time — so declaring this input is part of the + # caller contract: a dispatch naming an undeclared input is refused, and + # the trigger job goes loudly red. + workflow_dispatch: + inputs: + bootstrap: + description: Bootstrap the label taxonomy before sweeping + type: choice + options: ["yes", "no"] + default: "yes" +permissions: + contents: read + checks: read # mergeability/check-rollup read for PR state + statuses: read # commit-status rollup read for PR state + issues: write + pull-requests: write +jobs: + sweep: + # pr_workflow_name keeps its default: ceremony's PR-facing caller is + # named `labels` (self-labels.yml). + uses: ./.github/workflows/labels-sweep.yml diff --git a/.github/workflows/self-labels.yml b/.github/workflows/self-labels.yml index 272f429..715f1ee 100644 --- a/.github/workflows/self-labels.yml +++ b/.github/workflows/self-labels.yml @@ -4,22 +4,13 @@ name: labels # same warning: consumers must NEVER copy the local form (it rides main, # unpinned — correct only for the repo that IS the source). Consumers write: # uses: heavy-duty/ceremony/.github/workflows/labels.yml@ +# +# Since #209 this caller carries the PR/issue event surface only. The +# reconcile sweep no longer rides these runs — the reusable's trigger job +# dispatches the sweep caller (self-labels-sweep.yml here), which owns the +# hourly cron and the manual/bootstrap workflow_dispatch. A board event +# below still yields a sweep within seconds, one dispatch hop later. on: - # The consumer owns this cadence (#203). Hourly is the recommended default - # when no other engine drives board state: the cron is then the sweep's ONLY - # wake for four transition classes — a review verdict landing (there is no - # pull_request_review trigger here), blocker:ci-red set or cleared (no - # check_suite/check_run/workflow_run), a blocker:conflict when ANOTHER PR - # merges under this one, and the time-based stale / 48h claim-reclaim. The - # events below carry the rest in seconds. Hourly trades ≤1h of latency on - # those four while cutting nominal scheduled sweeps from four an hour to one - # at GitHub's 1-minute billing floor. Do not delete the cron: it is their - # discovery path. If another engine writes some of those transitions, only - # the classes with no other writer bound the cadence; relax it only as that - # list shrinks. - schedule: [{cron: "0 * * * *"}] - # A manual full-board sweep, including taxonomy bootstrap on a fresh repo. - workflow_dispatch: # Narrowed (#199) to the actions that carry a queue-state change the hourly # cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/ # unassigned, which feed validation and the 48h claim clock (caught within one @@ -48,8 +39,13 @@ permissions: contents: read checks: read # mergeability/check-rollup read for PR state statuses: read # commit-status rollup read for PR state + actions: write # the trigger job's `gh workflow run` dispatch of the sweep caller (#209) issues: write pull-requests: write jobs: labels: uses: ./.github/workflows/labels.yml + with: + # Dogfood filename deviation only — consumers keep the default, + # labels-sweep.yml, and pass nothing. + sweep_workflow: self-labels-sweep.yml diff --git a/.upstream-ref b/.upstream-ref new file mode 100644 index 0000000..2337fa2 --- /dev/null +++ b/.upstream-ref @@ -0,0 +1,7 @@ +# 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 diff --git a/BUILDER.md b/BUILDER.md index 13a395d..4429bbc 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -6,292 +6,232 @@ triage bug, and the move is to say so on the issue, not to guess. ## Picking -- Pick from issues labeled **`ready`** — never `blocked`, never `claimed`, - never an `epic` (epics organize; their children are the work). -- Respect dependency order: inside an epic, take the earliest unblocked - unclaimed child. Between epics and strays, prefer the issue that unblocks - the most other work. -- **Your own red head outranks a new claim.** A failing check at the head - of a PR you authored is picked up **before claiming another issue** — - repairing your own red PR comes ahead of new work, which is why the - engine's duty order evaluates ci-red between resume and build (crew#17: - ceremony#163 sat with full-panel approvals at its head, mergeable, and - stranded on an HTTP 429 in a job that never ran the PR's code, because no - wake covered a red head that owed no round and had no conflict). Red and - green here are the ruled terms of the review round below: a cancelled or - stale check is not a green head; a skipped or neutral one is. The - recovery path (crew#17): inspect the check at the head and record the - failing check and its failure class; rerun a clearly retryable - infrastructure failure without changing code; when the failure belongs to - the branch, return to the normal fix-round and worklog discipline; leave - visible evidence when a rerun cannot be started or the cause is - uncertain; never repeatedly rerun a deterministic branch failure without - a corrective commit; and proceed to handoff once the check is green and - current-head approvals stand. A PR of yours with a red head is **not - parked** — the next move is yours, whatever the round's verdict state - says (shape 2 below carves this out explicitly). How the engine detects a red - head — its ledger, its quiet rules, the rollup's node shapes — is crew's - to describe, not this file's. -- **One build at a time.** You hold at most one issue on which you are - writing or revising a deliverable — finish or release that work before - starting new work. The rule counts build work in flight, not claims: a - claim does not consume the slot while it is **parked**, meaning the next - move belongs to someone else. Exactly five shapes qualify: - 1. the issue carries `needs-ruling`, its escalation names a decider, and - its `Blocked:` line stops the remaining work; - 2. the deliverable is in a review round where every outstanding verdict - belongs to someone else — either the round is awaiting its first - verdicts, or it was answered whole and the owed re-requests posted — - by head, not by verdict: every panelist after a push, the - non-approvers alone at an unchanged head (the review round, steps - 1–2). This is the *live* round; shape 4 is - the *passed* one — they are sequential and do not overlap. A red - check at the current head takes the deliverable **out of this - shape**: mid-round CI going red is exactly the state that reads as - "waiting on the panel" and is not — the next move is yours (the - red-head rule above), and reading it as parked is what strands the - PR; - 3. every remaining acceptance criterion is operator-owned, stated as such - by triage on the issue; - 4. the deliverable is **handed off** — the round passed, no `blocker:*` - stands, and you set `state:needs-human` per Handoff (below). The - remaining move is the human's merge. - 5. the claim is **held by directive** — triage or the operator has told - you to stop, the direction names what the hold waits on, and that thing - is not yours to move. This is not "waiting for a good moment": somebody - else has decided the work must not proceed, and only they end it. - And it ends the same way it started: **on the labels.** When the queue - labels and any prose — an issue body header, a triage comment, an - operator's comment — disagree about whether a hold stands, the most - recent queue-label event by the hold's owner governs, and the prose is - stale until someone corrects it. So before standing down *or* standing - up on a hold, read the issue's **label events** - (`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not only its - comments: an operator may lift by label alone, and on 2026-07-24 did, - twice, on [#149](https://github.com/heavy-duty/ceremony/issues/149) - and [#151](https://github.com/heavy-duty/ceremony/issues/151). Acting - on the labels against stale prose, say so in the claim — name the - events you read, their timestamps and their actor, and invite the - correction if the read is wrong; - [the 14:11:45Z claim on #149](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070781295) - is the exemplar. Refusing is not a resting place either: - [*"I am not claiming through that contradiction"*](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070776624) - was a correct instinct and an incomplete move — the next step is to - read the events, state what they say, and then claim or stand down on - that, or, if the events genuinely do not resolve it, say so on the - issue and pick the next `ready` issue rather than idling on this one. - Not parked — these are what the rule defends against: waiting on - yourself, waiting on CI (a red head is your own work, above; a pending - one resolves without you), or waiting for a good moment. An issue you have - simply stopped working on is not parked either — that is abandonment, - and its move is unchanged: unassign and restore `ready` (Claiming, - below). - The 2026-07-23 board is why the rule counts work and not claims: one - builder correctly held - [#15](https://github.com/heavy-duty/ceremony/issues/15) (`offsite`, - round answered whole, one verdict outstanding) and - [#16](https://github.com/heavy-duty/ceremony/issues/16) (`needs-ruling` - hard block, triage said hold) parked beside the one active build, - [#73](https://github.com/heavy-duty/ceremony/issues/73). +- Pick from issues labeled **`ready`** — never `blocked`, `claimed`, or an + `epic` (epics organize; their children are the work). Inside an epic take + the earliest unblocked unclaimed child, otherwise the issue that unblocks + the most work; where a repo adopts version epics, + [RELEASES.md](RELEASES.md) governs among window members. +- **Your own red head outranks a new claim**: repair a failing check at your + PR's head before claiming another issue (#163). Red and green here are the + review round's ruled terms: cancelled, stale, or unreported — every entry + at the head cancelled — is not green; skipped or neutral is. Record the + check and its failure class; rerun a clearly retryable infrastructure + failure unchanged; treat a branch failure as an ordinary fix round, + worklog and all; leave evidence where a rerun cannot start or the cause is + unclear; never rerun a deterministic failure without a corrective commit; + hand off once green with current-head approvals. Such a PR is **never + parked**, whatever the verdict state says; how the engine detects a red + head is crew's to describe. +- **One build at a time**: one issue on which you are writing or revising a + deliverable, finished or released before you start more. The rule counts + work in flight, not claims — a **parked** claim, whose next move is + someone else's, does not hold the slot. Five shapes park: + 1. `needs-ruling` is set, the escalation names a decider, and its + `Blocked:` line stops the rest; + 2. a **live** review round holds it, every outstanding verdict someone + else's — awaiting first verdicts, or answered whole with the owed + re-requests posted, by head and not by verdict (steps 1–2). A red check + at the head takes it out of this shape: the next move is yours; + 3. every remaining acceptance criterion is operator-owned, stated so by + triage on the issue; + 4. it is **handed off** — round passed, no `blocker:*` standing, + `state:needs-human` set per Handoff, the merge the human's. Shapes 2 + and 4 are sequential and never overlap; + 5. the claim is **held by directive** — triage or the operator stopped the + work, named what the hold waits on, and only they end it. A hold ends + as it started, **on the labels**: where labels and prose disagree, the + most recent queue-label event by the hold's owner governs, and an + operator may lift by label alone (#149, #151). So read the label events + (`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just the + comments, before standing down *or* up, and say in the claim which you + read, their timestamps and their actor. Where they do not resolve the + contradiction, say so and take the next `ready` issue; refusing is no + resting place. + Not parked: waiting on yourself, on CI (a red head is yours; a pending one + resolves without you), or for a good moment. An issue you stopped working + on is abandoned — unassign and restore `ready`. Parked claims are held + beside the one active build (#15, #16, #73). ## Claiming - Assign yourself, swap `ready` → `claimed`, and comment that you are - starting. The claim is a promise of a draft PR soon — a claim with no PR - and no activity is what the staleness sweep reclaims unless `offsite` - records that its PR lives in another repository. -- **A park is declared, never inferred.** When your claim enters a parked - shape (Picking, above), say so in a comment on that issue, naming what it - waits on and who owns the next move. No new label: the comment is - activity, so it feeds the same reclaim clock the `needs-ruling` - ([#52](https://github.com/heavy-duty/ceremony/issues/52)) and `offsite` - ([#68](https://github.com/heavy-duty/ceremony/issues/68)) exemptions - already guard — a parked claim nobody can name is an abandoned one. - Shape 4 alone is exempt from the separate comment: the factual handoff - comment plus the `state:needs-human` write *is* its declaration — both - halves are already there, what the claim waits on (the merge) and who - owns the next move (the human), and both are visible to any scan as a - `labeled` event with the comment beside it. No second comment is owed on - the issue. Every other shape still declares as above. - Declared once, the declaration **stands** until the park's facts change: - a resumption that finds nothing changed posts nothing — the standing - declaration is the record, and silence while parked is compliant, not - abandonment-shaped. Re-declaring on every resume is the flood - [rig#145](https://github.com/heavy-duty/rig/pull/145) drowned in — 38 - near-identical audits in one night, each saying nothing changed - ([#177](https://github.com/heavy-duty/ceremony/discussions/177)). What - re-opens the duty to comment is the facts changing — the named wait - resolves or changes hands, the parked shape changes, or the claim - unparks — and each owes one new comment. The one place silence has a - cost: a parked claim with **no open PR** still feeds the 48-hour - reclaim clock, so there the builder refreshes the declaration before - the window closes. That refresh is the only repeat a park ever owes, - and its cadence is the reclaim window's, not any duty loop's. None of - this loosens the abandonment rule below: a claim that was never parked - and has simply stopped moving is abandoned, not silent. -- **Pick up `attention` before anything else.** On your claim, first post a - short pickup comment and remove `attention`; the removal is the ack. A - demand on a parked claim is usually its unpark, so take the slot back under - the existing rule below rather than leaving the demand parked. A demand - that *is* the park is different: the pickup comment is the declaration, - so one comment does both jobs, and the demand does not take the slot back. -- **A directed hold keeps its bookkeeping visible.** The PR carries `blocked` - with a comment naming what it waits on; the issue stays `claimed` and - carries `attention` until the builder acknowledges it. Nobody unassigns - the issue, and the 48-hour reclaim does not fire because the claim has an - open PR. Unparking follows the existing rule below. -- **Unparking is a claim like any other.** When the wait ends, the parked - issue is work again and takes the slot. If you are already active - elsewhere, finish or release that work first, and say which you did on - both issues — the slot is still one. Nothing counts claims per builder - and no reconciler path enforces any of this: `claim_decision()` sees one - issue at a time by construction, and no such machinery should be built - expecting it to have been specified here. The discipline is the - declaration, not a counter. -- **Abandoning is fine; ghosting is not.** If you stop, say where you got to, - push the branch if it holds anything useful, unassign, and restore - `ready`. + starting. The claim promises a draft PR soon: a claim with no PR and no + activity is what the staleness sweep reclaims, unless `offsite` records + that its PR lives in another repo. +- **A park is declared, never inferred.** Comment naming what the claim + waits on and who owns the next move — no new label; the comment is the + activity the reclaim clock reads, as for `needs-ruling` (#52) and + `offsite` (#68). Shape 4 is exempt: the handoff comment and + `state:needs-human` already say both. +- **A declaration stands until the park's facts change**, so a resumption + finding nothing changed posts nothing (#177). Each change owes one comment + — the wait resolves or changes hands, the shape changes, the claim + unparks. A parked claim with **no open PR** still feeds the 48-hour + reclaim clock, so refresh the declaration before it closes; that is a + park's only repeat. +- **Pick up `attention` before anything else**: post a short pickup comment + and remove the label, which is the ack. A demand on a parked claim is + usually its unpark, so take the slot back — unless the demand *is* the + park, the pickup comment then doubling as the declaration. +- **A directed hold keeps its bookkeeping visible.** The PR carries + `blocked` with a comment naming what it waits on; the issue stays + `claimed` and carries `attention` until the builder acks. Nobody unassigns + it, and the 48-hour reclaim does not fire while the claim has an open PR. +- **Unparking is a claim like any other** and takes the slot: if you are + active elsewhere, finish or release that work first and say which on both + issues. No machinery counts claims per builder, and none should be built + expecting this section to have specified one. +- **Abandoning is fine; ghosting is not.** Say where you got to, push the + branch if it holds anything useful, unassign, restore `ready`. ## Building - Branch per issue; open the PR **as a draft early**, `Closes #N` in the - body. `Closes #N` does not cross repos: when the PR is in a different repo - from its authorizing issue, use `Part of /#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/.md`, - named for the authorizing issue (`-.md` when the work is - cross-repo) — the exact prose that will be published, nothing else: `- ` - bullets, and in a grouped repo the `### Added` / `### Changed` / - `### Fixed` headings inside the fragment, creating a rarer kind only when - a change genuinely is one. An entry is at most 300 characters — the - fragment guard reds longer (#167) — so a genuinely long change ships - several short entries, never one long one; wrapping an entry over - continuation lines is fine and never counts against it. Never edit - `CHANGELOG.md` for an entry — the - release PR assembles the section from the fragments (#112); the monotonic - guard still refuses anything that deletes a shipped heading. + body. Drafts are invisible to the panel on purpose: that phase is yours. +- **`Closes #N` does not cross repos.** A PR in a different repo from its + issue says `Part of /#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/.md` + named for the authorizing issue (`-.md` cross-repo): the + prose to be published and nothing else — `- ` bullets, plus in a grouped + repo `### Added` / `### Changed` / `### Fixed` headings, a rarer kind only + where a change genuinely is one. An entry is at most 300 characters, so a + long change ships several short ones (wrapping over continuation lines is + free), and it **ends with its issue citation**: a parenthesised group of + `#N`, `repo#N` or `owner/repo#N` separated by `, `, then the final `.` and + nothing after — `(#262).`, `(#236, #250).` — which need not name the + fragment's own issue, the filename carrying it. The guard reds a long + entry (#167) and an uncited one (#262). Never edit `CHANGELOG.md`: the + release PR assembles the section from fragments (#112), and the monotonic + guard refuses anything deleting a shipped heading. - Follow the repo's conventions file and match the code you touch. Tests are not optional: the issue's test plan is the floor, not the ceiling. +- **A write-capable job gets a repo-owned script, not a third-party + action.** Where the token can write (`packages: write`, `contents: write`, + `id-token: write`, deploy secrets), default to a script a test can drive; + a third-party action there needs an established publisher and a + full-commit-SHA pin, and read-only jobs still SHA-pin. The full rule and + its red-flag profile are in REVIEWER.md §What you review against, item 2 + (#216). - **Scope discipline: the PR does the issue — whole, and nothing else.** - Adjacent problems you discover go to a **discussion** (or a comment on the - relevant issue), where triage will do its job. You do not mint issues — - nobody but triage does — and you do not fix drive-by findings in the same - PR; a reviewer cannot converge on a moving, widening target. + Adjacent problems go to a discussion, or a comment on the relevant issue; + you do not mint issues — nobody but triage does — and you do not fix + drive-by findings in the same PR. ## The review round -(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: -repo-specific facts such as the panel roster live in that repo's own -CONTRIBUTING; the shared flow lives here and is not restated there.) +(In a governed repo this file is `.ceremony/BUILDER.md`: repo-specific facts +such as the panel roster live in that repo's own CONTRIBUTING.) -1. Mark ready-for-review; request **the whole panel**. The panel is the roster - of the repo the **PR** is in, minus you — never the roster of the repo the - issue is in. The PR repo's `.github/labels.conf` `panel=` line is the - machine's answer; its CONTRIBUTING roster is the human-readable answer, - and `panel=` governs if they disagree because that is what the state - machine reads. If the PR repo names no roster, ask triage on the - authorizing issue before marking ready-for-review; do not guess. You may - request an off-panel reviewer, but say that their verdict is advisory and - does not become required. On rig#112 this distinction mattered: requesting - codex and grok was correct for rig's panel even though ceremony's bench was - larger, and the doctrine had not said which roster governed. - **A review request requires a green check at the head.** A red check is - the author's own signal, not the panel's work: if the check is red, that - is your next task, not the panel's — fix it and push, then request. This - binds *you*, whether or not any engine enforces it. "My local suite - passed" is evidence about your machine; the check at the head is the - shared artifact the panel actually reads, and a reviewer's first act is - to read it. The one exception is a failure genuinely outside the PR — a - runner outage, a flaky dependency, a failure already present on the - default branch — and it is an exception only if the request says so - explicitly and names the evidence (e.g. "the same job fails identically - on `origin/main` at ``"). Silence about a red check is what is - prohibited; an argued exception shifts the burden to the author. - *Green* is a ruled term (operator, 2026-07-27): a **cancelled or - stale** check is not a green head — the rollup is scoped to the current - head, so what survives there is same-head cancellation, not - supersession by a newer push — while a **skipped or neutral** one *is* - green: those are deliberate "passed / not applicable" conclusions, and - reddening them would red every conditional job the fleet skips on - purpose. The costs behind the line are asymmetric: a false green spends - a three-reviewer round; a false red spends one author session. +1. Mark ready-for-review; request **the whole panel**: the PR repo's + `panel[]=` 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 ``"); + silence about a red check is what is prohibited, and an argued exception + shifts the burden to the author. + + *Green* is a ruled term (operator, 2026-07-27), read in two steps. + **First take the check's word at this head**: its newest entry by start + time — not completion, a cancelled run outliving its replacement's start + — and never a `CANCELLED` entry while the same check has a non-cancelled + one there. A check whose entries at the head are all cancelled has not + reported at all and is not green — a collapse, not a new class, and the + gate partitions alike, dropping a cancelled entry only where a + non-cancelled survivor remains and leaving an all-cancelled context + blocking (#139, #276). **Then classify that entry by `conclusion`, never + `status`**, which can disagree with it (#259). No conclusion is not + green: a configured run in progress is waited on, and waiting is + compliance, not a stall. Cancelled or stale is not green, *stale* being a + superseded head's check, which a head-scoped rollup never shows. Skipped + or neutral is green, those being deliberate "passed / not applicable" + conclusions. No checks configured is green — the third ruled case, not an + argued exception, so the request goes out at once with no evidence owed; + that never covers nothing-answered-yet, and the machine partitions alike, + admitting the ask on `SUCCESS` and `NONE` (#236). The costs behind the + line are asymmetric: a false green spends a three-reviewer round, a false + red one author session. What the machine drops from the rollup before + grading is crew's to describe. 2. **Wait for every verdict, then answer the round whole** — one reply - covering every point and stating what changed and what was verified. - That reply is the written round record: the engine mirrors it under the - PR body's **Round log**, newest last, so the builder owes the reply and - no separate body edit. At re-request time the engine takes the author's - comments posted after the newest verdict in the round and appends them - with ``; an existing marker makes a retry a - no-op. If the builder posted no reply, the engine records that the round - passed without one and never blocks handoff on the omission. Then push - the fixes, then re-request **by head, not by verdict**: if answering the - round pushed any commit, every - panelist's approval is now stale — an approval is of a specific tree, - and the handoff predicate counts only approvals at the current head — - so **every panelist is re-requested, the approvers included**; a - panelist left un-re-requested after a push can never approve the tree - you shipped, and the PR sits looking finished with a full set of - verdicts and nothing owed by anyone, the same silent-stall shape as - [#26](https://github.com/heavy-duty/ceremony/issues/26)/[#39](https://github.com/heavy-duty/ceremony/issues/39). - Only when the head did not move — the round was answered with argument - or evidence and nothing was pushed — do you re-request just the - non-approvers: a standing approval already covers this exact head, and - the engine absorbs a re-request at an unchanged head (the re-request - rule, [#94](https://github.com/heavy-duty/ceremony/issues/94); its - mechanism is crew's to describe). **The re-request carries the same - green-check-at-head precondition as the first request**, argued - exception included. This is where the measured cost landed: crew#40 - burned two consecutive heads and four reviewer-rounds, every one - relaying a CI failure already visible in the job log (crew#45). A fix - push whose check comes up red is not ready to go back to the panel; it - is your next fix. Prefer verification over argument: when a - reviewer doubts behavior, add the test that settles it. + covering every point, stating what changed and what was verified. That + reply is the written record: the engine mirrors it under the PR body's + **Round log**, newest last and marked with the round's head, which makes + a retry a no-op; you owe the reply and no body edit, and a round answered + without one is recorded as such and never blocks handoff. Then push the + fixes and re-request **by head, not by verdict**. A push makes every + approval stale — an approval is of a specific tree, and the handoff + predicate counts only approvals at the current head — so **every panelist + is re-requested, approvers included**; one left un-re-requested can never + approve the tree you shipped (#26, #39). Only where the head did not move + — answered with argument or evidence, nothing pushed — do you re-request + just the non-approvers; the engine absorbs a re-request at an unchanged + head, and its mechanism is crew's to describe (#94). **The re-request + carries the same green-check-at-head precondition**, argued exception + included: a fix push whose check comes up red is your next fix, not the + panel's. Prefer verification over argument — add the test that settles + the doubt. 3. Never dismiss a review, never merge, never mark your own work as passed. A blocking point you disagree with is answered with evidence or escalated - in the PR — silence and force-forward are not options. A panel deadlock - is one kind of human-owned decision; use the ruling ask below - ([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). + in the PR; silence and force-forward are not options, and a panel + deadlock is one kind of human-owned decision (#50 D11). + +**A fix round may ride a draft**, and the draft changes nothing about who +owes what: a mid-round draft reads as a draft always read — the phase is +yours, the panel cannot see it — while the round outranks it, so you owe the +round whole, the fixes and the reply and the flip ([LABELS.md](LABELS.md)'s +`state:building` row, #205). **Ready-for-review is the act that ends the +round, and it is the builder's alone**: the flip asserts the round was +answered whole, the one judgement its author cannot delegate, so an engine +may draft a PR but only the builder undrafts it. **Where a draft suppressed +the checks, green is proven at the flip and the request still follows it** — +marking ready runs the checks the draft held back, so the order is flip, let +the head answer, then request, step 1's precondition and not a second one. +Waiting there is compliance, and `blocker:unrequested` does not fire while a +head's checks are pending or red (#236). ## The ruling ask Set `needs-ruling` whenever a decision belongs to a human: org policy, published artifacts, secrets, prod, or any choice whose cost lands outside -the PR. A panel deadlock is one instance, not the definition. The builder is -the accountable flag-setter on a PR and consolidates the decision into one -comment rather than forwarding several reviewers' phrasings -([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). +the PR — a panel deadlock is one instance, not the definition. The builder +is the PR's accountable flag-setter and consolidates the decision into one +comment rather than forwarding several reviewers' phrasings (#50 D11). -Keep at most these five lines above the fold and put all other analysis -inside the fold. The field labels are fixed because the ruling machinery -checks for them ([#50 D12](https://github.com/heavy-duty/ceremony/issues/50)): +Keep at most these five lines above the fold, all other analysis inside it. +The field labels are fixed because the ruling machinery checks for them (#50 +D12): ```text 🧭 needs-ruling — @@ -305,53 +245,48 @@ Default: | none — hard block The options must be exhaustive and mutually exclusive; more than three means the question is not ready. `Recommend:` is mandatory — omitting it hands the whole problem to the human. `Blocked:` names both what stops and what -continues. Write a timed `Default:` only when you are affirmatively confident -the decision is reversible inside the PR before merge. Unsure is not a tie: -it is a hard block. Published artifacts, secrets, prod, and org policy are -hard blocks by construction ([#50 D12–D13](https://github.com/heavy-duty/ceremony/issues/50)). +continues. Write a timed `Default:` only when affirmatively confident the +decision is reversible inside the PR before merge; unsure is not a tie but a +hard block, as published artifacts, secrets, prod and org policy are by +construction (#50 D12–D13). The ladder is anchored to the current episode's `needs-ruling` **`labeled` -event**, not its `Default:` deadline or the last activity -([#50 D13–D14](https://github.com/heavy-duty/ceremony/issues/50)): +event**, not its `Default:` deadline or the last activity (#50 D13–D14): -- **0–12h:** proceed when a still-clear, reversible default expires, and say - out loud that you did. A hard block waits. -- **at 12h:** do not fire a stale default. Re-read it against what has landed - and ask whether it still holds and whether reasonable doubt remains. If - doubt has appeared, make it a hard block. -- **at 24h:** proceed regardless, **as a PR**. Pick an option and state in the - PR body which way you went and what doubt remains. Nothing merges by this; +- **0–12h:** proceed when a still-clear, reversible default expires, saying + out loud that you did; a hard block waits. +- **at 12h:** do not fire a stale default — re-read it against what has + landed, and where doubt has appeared, make it a hard block. +- **at 24h:** proceed regardless, **as a PR**: pick an option and say in the + body which way you went and what doubt remains. Nothing merges by this; the human still gates the merge. -- **past 24h:** hand the choice to triage. Triage picks the option, records it - as a decision, and remains accountable; the operator can overturn it at +- **past 24h:** hand the choice to triage, which picks the option, records + it as a decision, and stays accountable; the operator can overturn it at merge. -A re-flag starts a fresh ladder. The ladder applies whatever `Default:` says, -including a hard block, and an active back-and-forth still climbs it. This is -different from the 7-day nudge, which resets on real activity. The machine -observes both clocks but never sets, clears, or decides `needs-ruling`. - -The label stays until agreement is *reached*, not until the maintainer -replies. The setter records the ruling, removes the label, and returns the -item to its flow in the same comment ([LABELS.md](LABELS.md)). +A re-flag starts a fresh ladder, which applies whatever `Default:` says, +hard block included, and an active back-and-forth still climbs it — unlike +the 7-day nudge, which resets on real activity. The machine observes both +clocks but never sets, clears, or decides `needs-ruling`. The label stays +until agreement is *reached*, not until the maintainer replies: the setter +records the ruling, removes the label, and returns the item to its flow in +the same comment ([LABELS.md](LABELS.md)). ## Handoff -When the round passes — every panel verdict approves the **current head**, -and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if -this is a release PR) — the engine performs these mechanical steps on the -builder's behalf, in order: +When the round passes — every panel verdict approving the **current head**, +no `blocker:*` standing (conflicts rebased, CI green, drill recorded if this +is a release PR) — the engine does these steps for the builder, in order: 1. request the human's review; 2. set `state:needs-human`; 3. post the engine-rendered handoff comment: approvals at the current head, the head SHA, and a pointer to the PR body's **Round log**. -The builder composes no new summary at handoff: the authored record already -lives in the Round log, mirrored mechanically from each whole-round reply as -specified above. The label write is optimistic — the reconciler validates -it, and takes it back if the PR is not actually mergeable-right-now. Then -stop: the PR is the human's. The claim is now parked as shape 4 (Picking, -above) — the handoff you just posted is its declaration, and your build slot -is free. Address what comes back (`state:addressing`) and re-hand-off the -same way. +The builder composes no new summary: the authored record already lives in +the Round log, mirrored from each whole-round reply. The label write is +optimistic — the reconciler validates it and takes it back if the PR is not +mergeable-right-now. Then stop: the PR is the human's, and the claim parks +as shape 4 (Picking, above), that comment its declaration and your slot +free. Address what comes back (`state:addressing`) and re-hand-off the same +way. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d3e8bc..3d9a9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,256 @@ published verbatim as that release's body (lib/changelog.sh extracts it), so entries say what changed, cite the issue, and stop — at most 300 characters each, guard-enforced on the PR that writes the fragment (#167); a genuinely long change ships several short entries, never one long one. +The citation is guard-enforced too, and it closes the entry: one `(#N)` +group, then the final `.` and nothing after it (#262). Sections published +before that rule keep their prose; the guard reads fragments only. Entries arrive as fragments — one `changelog.d/.md` per PR, never an edit to this file — and the release PR assembles them into the next section here (`bin/changelog-assemble`, #112). +This tree is `heavy-duty/ceremony` on +`forgejo.heavyduty.builders`, and it tracks the upstream tree's version +numbers (#197 D2). Two trees therefore answer to the same number, differing +by the forge-compatibility delta. **This tree carries upstream through +`8c3a4d1`** (upstream `0.6.0`, merged by #198); the `0.4.1` section below is +this forge's own release, not upstream's, and upstream's separate `0.4.1` +section is deliberately not carried — the tag published here is the one this +section is the body of. Each sync updates this line +(docs/UPSTREAM-SYNC.md, #200). + +## 0.6.0 — 2026-08-05 + +### Added + +- The issue-flow sweep's `claimed`-branch ruling pre-read is pinned: an + unassigned claim under `needs-ruling` must draw its board diagnostic and + its ruling nudge in one sweep, so a read that drifts below the diagnostic + reds instead of silently costing the escalation 7 days (#284, #307). +- The issue-flow sweep now flags a collision the board never declared: two + open, unblocked issues whose titles name one deliverable draw a comment + naming the newer's owed `Blocked by` edge. Keys normalize, so + `actions/x` and `x` are one deliverable (#288). +- The sweep now flags an unblocked non-member during a standing release + window, naming the window's invariant. `claimed` counts, PR in flight or + not. The gate is read from the release issue's own `Blocked by` + declarations, and an emptied gate leaves it dormant (#292). +- Both flags are advisory: comments only, no label write and no state + change, deduped against each family's last word on the thread so a + standing state re-sweeps silently (#293). +- The fragment guard now requires each entry to end with its issue + citation: one `(#N)` group — local, `repo#N` or `owner/repo#N` + references separated by `, ` — then the final `.` and nothing after it + (#262). +- The refusal distinguishes an entry carrying no reference at all from one + whose reference is present but not terminal, and names the shape to + write in both (#262). +- The 300-character bound still outranks the citation across the whole + fragment, and the outranked problem stays out of the message it lost + to: one fragment, one diagnosis, wherever in the file it sits (#262). +- BUILDER.md now describes a fix round that rides a draft: the draft phase + stays the builder's, ready-for-review is the builder's own act, and where a + draft suppressed the checks green is proven at the flip (#258). +- REVIEWER.md now reads a draft carrying `state:addressing` as a fix round in + progress rather than abandonment (#258). +- A `post-merge` item with no comment for 7 days now draws one nudge from the + issue sweep: the wake evidence is owed. A starving criterion used to be + found only when someone happened to run the right read (#254). +- Label churn does not reset that clock, and neither does an assignment: on + `post-merge` an assignee is an invalid composition, not activity, and it + must not buy the item another 7 days of silence (#254). +- The nudge names the triage actor from `triage-actors=`, not the human + reviewer: `post-merge` is triage's completion queue, so the starved wake + condition is triage's to answer (#254). +- It links the item and parses nothing from the body — which criterion + starved is prose, and the machine never judges prose (#254). +- Like the ruling nudge it carries no idempotency marker on purpose: the + comment is itself activity, so the rule self-rate-limits to one nudge per 7 + quiet days. Comment-only — no path here writes a label (#254). +- Release epics now announce release initialization when their declared dependency gates clear (#253). +- The issue sweep now echoes an issue's parsed `Blocked by` set as a comment + whenever that set changes, so a readable-but-wrong declaration is visible in + one sweep instead of days later, when a human happens to run the parser by + hand (#252). +- The echo's marker carries the parsed set itself: an unchanged parse never + re-posts on a 15-minute cron, and a changed one always speaks. Comment-only + — no path here writes a label (#252). +- CI now refuses a root `*.md` declared in neither `docs/VENDORED.txt` nor the + guard's short exemption list, so a new doctrine file can no longer reach a + tag undeclared and stay invisible to every consumer's `docs-sync` (#251). +- The same guard reads the manifest the other way: every entry must resolve to + a regular, non-empty, tracked file — no symlink, no directory, no `../` + escape (#251). +- Document the optional, operator-ruled release-epic flow for governed repositories. (#248). +- Guard documentation availability markers against missing issue citations + and release candidates that already ship the cited work (#238). +- The label and issue-flow sweeps now comment once per episode when + `attention` targets a pull request or an unassigned issue, without + retargeting the demand or changing labels or assignees (#232). +- Pull requests that promise `Refs #N` now fail a read-only, body-edit-aware + guard if GitHub would close N through a keyword or sidebar link (#218). + +### Changed + +- `README.md` is rewritten whole from the current tree: the front page names + the governance repo ceremony now is, routes to `docs/CONSUMERS.md`, + `AGENTS.md`, `LABELS.md` and `RELEASES.md` rather than restating them, and + keeps the operator's release runbook as its core, re-measured (#311). +- Standing release windows are dependency DAGs: every mint is placed in the window or behind it, and only current sources are `ready` (#292). +- TRIAGE.md now requires unconditional collision-edge chains when open issues + carry the same deliverable, keeping the ready queue concurrently claimable + (#288). +- TRIAGE.md now states its rules with bare record cites: the label-race and + lifted-hold incident narratives leave the normative text while their + operational rules remain complete (#282). +- `BUILDER.md` states its rules and cites their record bare: the incident + narratives, the links into issue comments and the cross-repo issue cites + leave the normative text, which no rule leaves with them (#281). +- CONTRIBUTING.md now keeps vendored doctrine self-contained: state the rule, + retain at most one sentence of why, cite the local record bare, and leave the + incident narrative in that record (#280). +- BUILDER.md's green ruled term now says which entry to read before it says + what an entry means: a check's word at a head is its newest entry by start + time, and a cancelled entry is not that word while the same check carries a + non-cancelled one at that head (#276). +- A check whose every entry at the head is cancelled is unchanged — nothing + survived to be its word, so it never reported and is not green — and the + collapse mirrors `checks_state`'s carve-out rather than adding a class + (#276). +- BUILDER.md's step 1 now rules the checkless head: no checks configured is + nothing to wait for, and the request goes out straight away — stated once, + in the ruled-term paragraph, with the draft-round restatement removed + (#272). +- `README.md` and `RELEASES.md` derive `scope:docs`, and the + `changelog-assembled`, `docs-sync` and `runner-isolated` actions and tests + derive `scope:guards`; all five were mapped nowhere. The docs block matched + a literal `README`, which this tree does not carry (#267). +- `lib/read.sh` and `lib/ruling.sh` derive `scope:labels` beside + `scope:release-flow`. Both reconcilers share them, and a mixed file wears + both labels rather than `lib/**` being re-carved into a row per file (#267). +- TRIAGE.md now tells every epic author to put its progress checklist under + the literal `## Task list` heading, because any other heading is silently + invisible to the completion sweep (#266). +- TRIAGE.md now scopes the no-assignee board bug to flagging an unassigned + issue, while still directing triage to repair ownership instead (#264). +- `BUILDER.md` and `CHANGELOG.md` state the citation as guard-enforced + rather than as house style, beside the 300-character bound it now sits + next to (#262). +- Four fragments in flight gained a terminal citation; published sections + are untouched, so no shipped prose is re-opened (#262). +- BUILDER.md's green ruled term now names its field: greenness is read from + each check's `conclusion`, never its `status`, and *stale* means a check + of a superseded head — not a same-head node whose `status` lags its own + conclusion (#260). +- Consumer guidance: re-vendor tooling reads the pin's `docs/VENDORED.txt`, + never a hardcoded list, so a new doctrine file propagates at the next + ordinary pin bump with zero list edits (#251). +- Define the doors-unchanged drill record and an executable release-path list, + so a release may reuse live evidence only when its door bytes are unchanged + since the last rehearsed tag (#237). + +### Fixed + +- A roster edit no longer reds the whole suite: the labels-reconcile + state-machine fixtures name their own panel instead of binding + `.github/labels.conf` by slot (#304). +- Shrinking `panel=` to three had left that binding's third slot unbound, and + `set -u` aborted the file before its first assertion — 217 assertions + became 0, on `main` and on every branch cut from it (#304). +- The one case still reading the shipped roster asserts a property, not a + size: it parses, and each member is recused from its own panel. Any + `panel=` of one or more members leaves `test/run.sh` green (#304). +- `lib/attention.sh` locates as label machinery beside its two shelf-mates — + `[scope:release-flow]` alone was a wrong answer of the class #267 measured + — and the map learns the sweep workflow pair, the shared-lib tests, and + seven enumerated test/guard surfaces (#302). +- Claiming a `needs-ruling` issue no longer buys its escalation another 7 + quiet days: the issue-side ruling clock reads comments alone — an + assignment is the claim clock's fact — and LABELS.md now names what each + surface's clock reads (#284). +- `scope:release-flow` no longer rides every pull request: `changelog.d/**` + is out of its path map. Doctrine makes every behavior change write a + fragment, so the glob labelled 20 of the last 20 PRs while 3 touched a + release surface. `CHANGELOG.md` stays, as only the release PR edits it + (#267). +- The issue-flow reconciler and its test now derive `scope:labels`, the scope + that already names the taxonomy they reconcile (#267). +- Abort issue-flow reconciliation when the board read fails instead of reporting a complete pass over an empty or partial result (#257). +- The issue sweep no longer derives label writes from a read that failed. An + HTTP 504 whose body is GitHub's JSON error object passed every guard and + emptied the label set, so a healthy epic was written `needs-triage` and the + pass reported success (#247). +- A failed comments read no longer reclaims a live claim. Swallowed, it dated + the issue by `created_at` and unassigned the builder under a comment + asserting 48 hours of silence about an issue commented on seconds earlier + (#247). +- A failed comments read no longer reads as "no marker", which re-posted the + comment the marker exists to suppress (#247). +- Every read inside the per-issue subshell is checked explicitly, on its + status and on its payload shape; the issue is left exactly as it is and the + sweep continues. A partial pass names its skipped issues after + `reconciled.` (#247). +- A per-issue pass is now atomic: its writes and its log lines commit only + once the pass completes. A skip could previously land after an earlier + mutation, reporting an issue as untouched when a label had already been + written or removed (#247). +- The issue-flow sweep now reads an issue's deliverable as the `Refs` PR that + merged last, not the one numbered highest — merge order is not number order, + and the old rule spent the transition marker on the wrong PR (#242). +- Preserve active claims when an open local pull request links them with `Refs #N`. (#241). +- `blocker:unrequested` no longer fires while a head's checks are pending or + red: the review round forbids requesting there, so the one blocker that + demanded an act flagged builders for complying. Pending is CI's move, red is + `blocker:ci-red`'s (#236). +- `blocker:unrequested` now waits for the round to settle — the head and the + newest verdict must have stood for `RECONCILE_UNREQUESTED_GRACE` (default + 300s) — so a sweep landing between a push and its re-request no longer flags + a round in motion (#236). +- LABELS.md no longer claims nothing in `actions/` clears or reads + `attention`: the reconciler has done both since the derived `claimed` → + `post-merge` transition shipped. The amended text keeps the hand-set rule + and admits the one clear and the diagnostic read (#231). +- Triage now puts `attention` on the assigned issue that owns a claim, never + on its pull request, and treats an unassigned issue as a board bug rather + than a demand (#230). + +## 0.5.0 — 2026-08-03 + +### Added + +- `labels.conf` accepts optional `panel[]=` 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf46b13..39d38ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,6 +94,28 @@ table repeats it (#104). - Whole-version matching everywhere: `0.7.0` never matches `0.7.0-rc1`. - Shellcheck- and actionlint-clean is a CI gate, not a suggestion. +## Doctrine conventions + +The vendored role files — `AGENTS.md`, `TRIAGE.md`, `BUILDER.md`, +`REVIEWER.md`, `LABELS.md`, and `RELEASES.md` — state each normative rule +completely, keep at most one sentence of why, and cite its record only with a +bare parenthetical such as `(#N)`, `(#N D3)`, or `(#N, #M)`. Incident +narrative — timestamps, actors, quoted comments, measured counts, and links to +specific comments — belongs in that record. If a rule cannot be followed +without chasing its cite, the rule is under-stated: fix the statement, not the +citation. (#280) + +Normative text in those files does not cite issues from other repositories. +Consumers read the vendored bytes outside this organization's context, and a +cited repository may not be public. A repo-boundary deferral remains allowed: +it names another component as the owner of a fact rather than citing one of +that component's issues. (#280) + +This is distinct from the code-comment convention above: a code comment is +read by a maintainer inside the organization while standing in the file, +whereas vendored doctrine is read by any agent in any governed repository on +every session. (#280) + ## How the other repos use this Two consumption modes, split by what has a runtime: @@ -105,7 +127,7 @@ Two consumption modes, split by what has a runtime: "runtime" is an agent reading the working tree of the repo it stands in — a doc that requires a cross-repo fetch before it governs is a doc that sometimes goes unread. So the agent-facing set — **AGENTS.md, TRIAGE.md, - BUILDER.md, REVIEWER.md, LABELS.md** — is vendored into each governed + BUILDER.md, REVIEWER.md, LABELS.md, RELEASES.md** — is vendored into each governed repo at **`.ceremony/`**, byte-identical to this repo at the pinned ref, by the sync tool (issue #19). A CI guard diffs the mirror against the pin on every PR: hand-editing a vendored file, or bumping the pin without diff --git a/LABELS.md b/LABELS.md index 8eba639..61ffcfc 100644 --- a/LABELS.md +++ b/LABELS.md @@ -17,7 +17,7 @@ and the reconciler recomputes it from GitHub's own facts. | Label | Color | Waiting on | |---|---|---| -| `state:building` | `#FBCA04` | the builder — PR is a draft | +| `state:building` | `#FBCA04` | the builder — pre-round: no verdict stands against the head. Draft is evidence for it, not the definition of it: a draft carrying a standing non-approving verdict is a fix round and reads `state:addressing` (#205) | | `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) | | `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up, or a ruling is pending | | `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head | @@ -68,7 +68,16 @@ assignee, and comments with the remaining criteria verbatim. The comment says that the claim is released and that triage owes a follow-up naming the owner and wake condition for completion. Triage writes that full transition comment in the same tick when it or the operator makes the move by hand. The sweep -never reclaims `post-merge`: weeks of quiet can be the state working. +never reclaims `post-merge`: weeks of quiet can be the state working. It does +make the quiet visible — after 7 days with no comment on the issue, the sweep +posts one nudge naming the triage actor, saying the wake evidence is owed and +linking the item. Only a comment resets that clock: label churn does not, and +neither does an assignment, which is the claim clock's fact and on this queue +state is the invalid composition flagged below. Which criterion starved is +prose the machine never judges; the link is the payload. Like the ruling nudge +it carries no idempotency marker on purpose — the comment is itself activity, +so the rule self-rate-limits to one nudge per 7 quiet days — and it writes no +label. `post-merge` never composes with `blocked`; the transition comment carries the wait. It never composes with `attention`, because releasing the claim clears @@ -145,8 +154,11 @@ label never removed — and a ruling with no real activity for 7 days draws a comment-only nudge addressed to the decider, linking the escalation. The nudge carries no marker on purpose: the comment is itself activity, so it resets its own window and never repeats within a quiet week. Label churn is -not activity — the clock reads comments, reviews and commits, or the sweep -would reset itself. +never activity, or the sweep would reset itself — and each surface's clock +reads what exists on it: on a pull request, comments, reviews and commits; +on an issue, comments alone. An assignment is the claim clock's fact, not +the ruling's — claiming a flagged issue does not answer it, and buys the +escalation no quiet (#284). `offsite` is issue-only and records that a claimed issue's deliverable lives in another repository, where a closing reference cannot make a local open PR @@ -171,8 +183,15 @@ The flag is additive: it composes with `ready`, `claimed`, or `blocked` and with `needs-ruling`, and never substitutes for queue state. It pauses no clock. Unlike `offsite` and `needs-ruling`, which make silence legitimate, unanswered `attention` is exactly the silence the 48-hour reclaim should -take. It is hand-set doctrine only: nothing in `actions/` sets, clears, -reads, or validates it, and no reconciler enforces the assignee requirement. +take. It is hand-set: the machine never sets `attention`, never assigns +anyone to receive one, and never decides that one has been answered — the +assignee's removal is the only ack. It writes the label in exactly one +place, the derived `claimed` → `post-merge` transition below, and nowhere +else; where it reads the flag it reads it to diagnose. The PR sweep comments +when `attention` is put on a pull request, and the issue sweep comments when +it is put on an issue with no assignee. Both diagnoses leave the label and +assignees alone; the machine never infers the claim issue, decides that the +demand was answered, or repairs either malformed shape. An `attention` issue without an assignee is therefore a board bug, not a demand; anyone may assign it or remove the flag. It never composes with `post-merge`, whose released claim has no assignee to answer the demand. The diff --git a/README.md b/README.md index 3ffaa7e..718eb69 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,66 @@ # ceremony -One release ceremony for the whole heavy-duty family — implemented once, -tested once, documented here, consumed everywhere else by reference. The -approach and its constraints live in -[#1](https://github.com/heavy-duty/ceremony/issues/1); this README is the -operator-facing doctrine that used to live, three times over, in the -consumers' CONTRIBUTINGs. +The heavy-duty family's **governance repo**: the machinery every repo in the +family runs, and the doctrine every agent in the family reads. Implemented +once here, tested once here, consumed everywhere else — the machinery never +copied at all, the doctrine only as a mirror a guard keeps byte-identical to +the pin. -- **Adopting or converting a repo** → [docs/CONSUMERS.md](docs/CONSUMERS.md). -- **Working in this repo as an agent** → [AGENTS.md](AGENTS.md) routes you; - [CONTRIBUTING.md](CONTRIBUTING.md) has the repo specifics. +Two kinds of thing live in this tree, and they are consumed in two different +ways because they have two different runtimes. + +**Machinery is consumed by reference, at a pin.** The reusable workflows in +[`.github/workflows/`](.github/workflows/) and the composite actions in +[`actions/`](actions/) are fetched by GitHub at run time from the ref the +caller pins; no copy exists in the consumer. That machinery is two systems. +The **release ceremony** — [`release.yml`](.github/workflows/release.yml), +the decision and fact libraries under [`lib/`](lib/), and the guard actions +that keep a release honest — is the operator-facing half, and the runbook +below is its documentation. The **label and issue-flow machine** — +[`labels.yml`](.github/workflows/labels.yml) and its detached sweep half +[`labels-sweep.yml`](.github/workflows/labels-sweep.yml) (split in #209), +driving [`labels-scope`](actions/labels-scope/), +[`labels-reconcile`](actions/labels-reconcile/) and +[`issueflow-reconcile`](actions/issueflow-reconcile/) — converges PR state +and the issue work queue. What its labels *mean* is +[LABELS.md](LABELS.md)'s contract, not this page's. + +**Doctrine is consumed as a machine-verified mirror.** A document's only +runtime is an agent reading the working tree it stands in, and a doc that +needs a cross-repo fetch before it governs is a doc that sometimes goes +unread. So the agent-facing set — the files named in +[`docs/VENDORED.txt`](docs/VENDORED.txt) — is vendored into each governed +repo at `.ceremony/`, byte-identical to this repo at the pinned ref, by +[`actions/docs-sync`](actions/docs-sync/). A CI guard diffs the mirror +against the pin on every PR: hand-editing a vendored file, or bumping the +pin without re-syncing, goes red. It is a copy that cannot drift, which is +the only kind of copy this org allows. This README is deliberately *not* in +that set — a consumer's router is its `AGENTS.md`, not this repo's front +page — and [`.github/scripts/vendored-check.sh`](.github/scripts/vendored-check.sh) +records that reason beside the three other ceremony-only root docs. + +**One pin governs both halves.** The ref a repo's workflow callers name is +the ref its `.ceremony/` mirror is verified against, so a process change +rolls out as one reviewed PR per repo: the pin line plus the re-synced +mirror, checked by the same guard. + +## Where to go + +- **Adopting ceremony, or converting a repo that carries its own copy** → + [docs/CONSUMERS.md](docs/CONSUMERS.md) — the bootstrap and conversion + checklists, the caller stubs, the pin-bump procedure. +- **Working in this repo as an agent** → [AGENTS.md](AGENTS.md) routes you + to your role file ([TRIAGE.md](TRIAGE.md), [BUILDER.md](BUILDER.md), + [REVIEWER.md](REVIEWER.md)); [CONTRIBUTING.md](CONTRIBUTING.md) carries + this repo's own specifics — the review panel roster, the `scope:*` set, + the code and doctrine conventions. +- **The board: what a label means, and who may set it** → + [LABELS.md](LABELS.md). It is the shared state machine; misusing one label + lies to every other agent on the board. +- **Family release windows — what ships together, and when** → + [RELEASES.md](RELEASES.md). +- **How the operator fleet is actually wired** → [FLEET.md](FLEET.md), a + descriptive snapshot rather than doctrine. - **Operating a release, or staring at a red run on main** → read on. ## What a release is @@ -23,13 +74,14 @@ stamps: ([lib/version.sh](lib/version.sh)). 2. **The changelog section is assembled — one edit, produced by the tool** (#112). Entries never accumulate in `CHANGELOG.md`: each PR wrote one - fragment file, `changelog.d/.md`, and the ceremony PR runs - [bin/changelog-assemble](bin/changelog-assemble) — by hand, on purpose, - so the section lands in the PR's diff where the panel reads it (#112 - D12; a consumer's exact invocation is in - [docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)). - The tool folds every fragment into a new `## X.Y.Z — DATE` section on - top and deletes the fragments it consumed; the + fragment file, `changelog.d/.md` + ([the directory's marker](changelog.d/README.md) names the doctrine), and + the ceremony PR runs [bin/changelog-assemble](bin/changelog-assemble) — + by hand, on purpose, so the section lands in the PR's diff where the + panel reads it (#112 D12; a consumer's exact invocation is in + [docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)). The + tool folds every fragment into a new `## X.Y.Z — DATE` section on top and + deletes the fragments it consumed; the [assembled guard](#changelog-assembled--the-stamp-is-exactly-the-fragments) replays that run and refuses a stamp that is not byte-for-byte the fragments' assembly. @@ -38,62 +90,79 @@ stamps: `## Unreleased` back on top — because every PR inserted at that one shared anchor, and between the stamp and the re-arm a PR authored *before* the release landed its entry under whatever now occupied the - position — **the section that just shipped** — cleanly, no conflict, - no signal (box#108; confirmed cross-repo as rig#66). Fragments make - that failure structurally impossible rather than guarded-against: a - fragment merged after the release simply sits in the directory and is - assembled into the *next* section. There is no anchor left to misplace, - and nothing to re-arm — the directory is always armed. + position — **the section that just shipped** — cleanly, no conflict, no + signal (box#108; confirmed cross-repo as rig#66). Fragments make that + failure structurally impossible rather than guarded-against: a fragment + merged after the release simply sits in the directory and is assembled + into the *next* section. There is no anchor left to misplace, and nothing + to re-arm — the directory is always armed. 3. **The drill record is present**: `drills/X.Y.Z.md`, non-blank — the - evidence the release rests on - ([the drill doctrine](#the-drill-doctrine)). + evidence the release rests on ([the drill doctrine](#the-drill-doctrine)). -(This repo's own ceremony adds a fourth stamp: `CEREMONY_SELF_REF` — the -ref consumers' runs fetch this repo at — moves to the version being -released, in [release.yml](.github/workflows/release.yml#L123-L132) and -every other workflow that carries it. -[self-ref-check.sh](.github/scripts/self-ref-check.sh) fails CI here, not -a consumer's release, when it is stale.) +(This repo's own ceremony adds a fourth stamp: `CEREMONY_SELF_REF` — the ref +consumers' runs fetch this repo at — moves to the version being released, in +[release.yml](.github/workflows/release.yml#L123-L132) and every other +workflow that carries it. +[self-ref-check.sh](.github/scripts/self-ref-check.sh) fails CI here, not a +consumer's release, when it is stale.) **The merge is the ship decision; the tag is transcription.** After the -merge, [release.yml](.github/workflows/release.yml#L136-L300) asserts its +merge, [release.yml](.github/workflows/release.yml#L136-L301) asserts its way to certainty, tags the merge commit, publishes the GitHub release with the version's own changelog section as the body — the curated prose, never -the generated PR list -([lib/changelog.sh](lib/changelog.sh) is the one canonical extractor) — -and re-arms main by bumping to `X.Y.(Z+1)-dev` -([release.yml](.github/workflows/release.yml#L266-L300)) — the version is -the only re-arm left; the changelog needs none (#112). The machine does -the transcription because humans err silently and machines fail loudly: +the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one +canonical extractor, and [bin/changelog-section](bin/changelog-section) is +its command-line face) — and, on the bare-`X.Y.Z` path, re-arms main by +bumping to `X.Y.(Z+1)-dev`; the version is the only re-arm left, the +changelog needs none (#112). An rc ships too, and its next version is a human +decision rather than arithmetic, so the re-arm stops for you to make it +([The re-arm refused](#the-re-arm-refused-releaseyml)). The machine does the +transcription because humans err silently and machines fail loudly: **everything asserts its way to certainty and fails loudly, creating -nothing** — a wrong release is worse than a missing one, so every failed -assert leaves zero artifacts: no tag, no release, no bump. +nothing** — a wrong release is worse than a missing one, so every assert in +this file fires *before its door creates anything*, and one that fails leaves +zero artifacts of the run's own: no tag it made, no release, no bump. Three +steps run past the tag, and what a failure at each leaves behind is what +sorts them. Two fail before the release exists: the consumer's +[artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and +the publish, so its non-zero exit aborts, and the publish itself +([`gh release create --verify-tag`](.github/workflows/release.yml#L246-L258)) +can fail on the API call or the assets. Either leaves the same state — a tag +standing and no release — which the +[nothing-exists assert](#the-merge-door-refused-releaseyml) names and the tag +door recovers. The third is the re-arm, which runs after the publish, and its +refusal is the single failure in this file that leaves a real release behind. ## The two doors -- **The merge door — the paved road.** A push to main - ([release.yml](.github/workflows/release.yml#L140)) runs the +- **The merge door — the paved road.** A push to main runs the [decide table](#what-happens-when-my-pr-lands-on-main); a merged, `release`-labeled PR whose version transitioned to bare is the ceremony, everything legitimate that isn't one is a green no-op, and every - half-ceremony dies loudly. Use it for every normal release. + half-ceremony dies loudly + ([release.yml](.github/workflows/release.yml#L136-L301)). Use it for every + normal release. -- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag - push — **no `v` prefix**, box's 0.6.0 set the scheme - ([release.yml](.github/workflows/release.yml#L302-L369)) — publishes the +- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag push + — **no `v` prefix**, box's 0.6.0 set the scheme + ([release.yml](.github/workflows/release.yml#L303-L371)) — publishes the same way. The tag is the operator's explicit act, so there is no decide - and no label check; the one assert is that **the tag names the tree's - own version**, and a mismatch refuses, creating nothing. No `-dev` bump - either — the fallback does not rewrite main (cast's precedent). Use it - when the merge path is red, for backfills, and for the + and no label check — what is left is two asserts: **the tag names the + tree's own version** + ([L328–L339](.github/workflows/release.yml#L328-L339)) and **the tagged + tree carries a publishable `## X.Y.Z` section** + ([L340–L352](.github/workflows/release.yml#L340-L352)); either failing + refuses, creating nothing. No `-dev` bump either + — the fallback does not rewrite main (cast's precedent). Use it when the + merge path is red, for backfills, and for the [first-release edge](#what-happens-when-my-pr-lands-on-main) (row 4). -Tag + publish (+ the consumer's artifact hook) happen **in the same job, -on purpose**: a `GITHUB_TOKEN`-created tag fires no workflows (GitHub's -anti-recursion), so the merge door's tag can never re-enter the tag door -and double-publish — and that job is the release's only chance to publish -([release.yml](.github/workflows/release.yml#L223-L234), #1 constraint 2). +Tag + publish (+ the consumer's artifact hook) happen **in the same job, on +purpose**: a `GITHUB_TOKEN`-created tag fires no workflows (GitHub's +anti-recursion), so the merge door's tag can never re-enter the tag door and +double-publish — and that job is the release's only chance to publish (#1 +constraint 2). ## What happens when my PR lands on main @@ -101,8 +170,8 @@ The merge door runs on **every** push to main, and the `release` label legitimately means two things (release ceremonies, and ordinary work *on* the release machinery), so the door's first act is a decision: the six-row table in [lib/decide.sh](lib/decide.sh#L29-L61) (issue #8 — the comment -block *is* the spec, and the table is contract-tested offline). Rendered -for operators: +block *is* the spec, and the table is contract-tested offline by +[test/decide.test.sh](test/decide.test.sh)). Rendered for operators: | # | the tree your merge produced | the run | what it means — and your move | |---|---|---|---| @@ -111,11 +180,11 @@ for operators: | 3 | version bare, unchanged, already released | green `NOTICE`, no-op | The post-release window: the ceremony landed, the `-dev` bump hasn't. Nothing to do. | | 4 | version bare, unchanged, **never released** | **red, nothing created** | The label says ship but this PR did not mint the version. Mislabeled → drop the label. Meant to release → it forgot the bump; re-do the ceremony PR. A repo whose first version never carried `-dev` ships its first release by the **tag door** — the known first-release edge (cast#111; [lib/decide.sh](lib/decide.sh#L70-L74)). | | 5 | version transitioned to bare, **no merged `release`-labeled PR** behind the commit | **red, nothing created** | A transition nobody declared — a release is a labeled ceremony PR, not a bare push. Label a proper ceremony PR and re-do it, or publish by the tag door if the tree is genuinely right. | -| 6 | version transitioned to bare, merged `release`-labeled PR behind the commit | **the ceremony** | Tag → notes → publish → `-dev` re-arm. Your move afterwards: verify the release exists and main reads `X.Y.(Z+1)-dev`. | +| 6 | version transitioned to bare, merged `release`-labeled PR behind the commit | **the ceremony** | Tag → notes → publish → `-dev` re-arm. Your move afterwards: verify the release exists and main reads `X.Y.(Z+1)-dev`. **Read *bare* as decide reads it** — anything not `-dev` ([lib/decide.sh](lib/decide.sh#L108-L110)) — so an rc transition is a shippable ceremony here too, and the release lands but the re-arm stops for you to pick the next version ([The re-arm refused](#the-re-arm-refused-releaseyml)). | -The green rows are the point as much as the red ones: the machinery must -be safe to work on, so every legitimate non-ceremony is a green `NOTICE` -no-op — never a red run on main per infra PR +The green rows are the point as much as the red ones: the machinery must be +safe to work on, so every legitimate non-ceremony is a green `NOTICE` no-op, +never a red run on main per infra PR ([lib/decide.sh](lib/decide.sh#L6-L12)). The label is hand-set intent and automation never guesses; the version transition is the interlock, and label-without-transition (row 4) and transition-without-label (row 5) both @@ -123,36 +192,54 @@ refuse (#1 constraint 8). ## The guards -Four composite actions run in every consumer's CI (and in this repo's -own). Shared shape: version-keyed where the tree's state matters, loud -where it fails, and **a file of its own so a test can drive it**. The full -war stories are in the scripts' header comments — authoritative and longer -than this; what follows is the operator's cut. +[`actions/`](actions/) holds ten composite actions. Three belong to the +label machine named above and are not the operator's business here. Of the +remaining seven, a consumer's own `ci.yml` carries **five** guard steps — +`changelog-armed`, `changelog-monotonic`, `changelog-assembled`, +`drill-recorded` and [`runner-isolated`](actions/runner-isolated/), the last +asserting that no `pull_request`-triggered workflow names a self-hosted +runner (#58) — plus [`refs-not-closing`](actions/refs-not-closing/) in its +own [`refs-guard.yml`](.github/workflows/refs-guard.yml) caller, because +body edits are load-bearing there (#200, #218), and +[`docs-sync`](actions/docs-sync/) once the repo adopts the agent team flow. +The exact steps and their pin-availability rules are in +[docs/CONSUMERS.md](docs/CONSUMERS.md). + +The four below are the release's own, and this is the operator's cut of +them. Shared shape: version-keyed where the tree's state matters, loud where +it fails, and **a file of its own so a test can drive it**. The full war +stories are in the scripts' header comments — authoritative and longer than +this. + +This repo eats what it serves: [`ci.yml`](.github/workflows/ci.yml) runs the +guard actions against its own real tree, and +[`release-exercise.yml`](.github/workflows/release-exercise.yml) replays the +merge door's step sequence on every PR. ### changelog-armed — main never sits disarmed **The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh)), keyed on the tree's shape, then its version. In **fragment mode** — -`changelog.d/` exists, the arming property moved onto the directory -(#112 D7): +`changelog.d/` exists, the arming property moved onto the directory (#112 +D7): - always → the marker `changelog.d/README.md` must exist (what keeps the - directory tracked when it holds no fragments), no `## Unreleased` - section may survive in `CHANGELOG.md` (a second anchor with no owner), - and every fragment must be publishable on its own — named `.md` - or `-.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 `.md` or + `-.md`, no `## ` heading, at least one bullet, no `### ` + heading without an entry. A malformed fragment fails the PR that wrote it, + not the release that consumes it (#112 D9). +- `-dev` tree → nothing more. The directory **is** the arming: the next PR's + entry is a new file, and a new file always has somewhere to land. - bare tree (the ceremony PR and its merge) → every fragment must be - consumed, and the top section must be the stamped, publishable section - for exactly that version. Fragment mode has no re-armed shape — there - is nothing left to re-arm. + consumed, and the top section must be the stamped, publishable section for + exactly that version. Fragment mode has no re-armed shape — there is + nothing left to re-arm. In **legacy mode** — no `changelog.d/` — the version-keyed rules stand -verbatim; both shapes stay supported so a consumer adopts fragments on a -pin bump, on its own schedule (#112 D8): +verbatim; both shapes stay supported so a consumer adopts fragments on a pin +bump, on its own schedule (#112 D8): - `-dev` tree → the top section **must** be `## Unreleased`. - bare tree (the ceremony PR and its merge) → the top section may be @@ -166,32 +253,30 @@ pin bump, on its own schedule (#112 D8): uses, so the two cannot disagree about what a section is). **The incident**: box#108 / rig#66 — the silent mislanding described -[above](#what-a-release-is). Fragment mode retires the incident's -mechanism outright; legacy mode guards it. **Red means** a PR entry has -nowhere safe to land — a missing marker, a surviving `## Unreleased`, a -malformed fragment — or a stamped version would publish no entries, a -dangling grouped heading, or a bare tree still carrying fragments the -stamp did not consume (`not consumed` — re-run the assembler); the -message names the fix in every case. What this guard cannot see is a -fragment that *was* consumed but whose entry the stamp omits — the -fragment is gone from HEAD, so only +[above](#what-a-release-is). Fragment mode retires the incident's mechanism +outright; legacy mode guards it. **Red means** a PR entry has nowhere safe +to land — a missing marker, a surviving `## Unreleased`, a malformed +fragment — or a stamped version would publish no entries, a dangling grouped +heading, or a bare tree still carrying fragments the stamp did not consume +(`not consumed` — re-run the assembler); the message names the fix in every +case. What this guard cannot see is a fragment that *was* consumed but whose +entry the stamp omits — the fragment is gone from HEAD, so only [changelog-assembled](#changelog-assembled--the-stamp-is-exactly-the-fragments)'s merge-base replay catches that loss. **Do not "simplify" this to "always require `## Unreleased`".** The -unconditional form is false by construction on the ceremony PR's own tree -— it makes every release unshippable — and rig#44 and cast#108 both had -to revert exactly that -([the script's header](actions/changelog-armed/changelog-armed.sh#L8-L16)). -The version-keyed form is what rig and cast get back by adopting this repo. +unconditional form is false by construction on the ceremony PR's own tree — +it makes every release unshippable — and rig#44 and cast#108 both had to +revert exactly that. The version-keyed form is what rig and cast get back by +adopting this repo. One consequence worth knowing before it happens, legacy mode only: a ceremony PR that stamps and forgets to re-arm still passes this guard — a bare tree is allowed to be stamped. It goes red **the moment the automatic `-dev` bump lands on main**. The guard does not block the release; it refuses to let main *sit* disarmed, which is the window a late PR falls -into. Fragment mode has no such window: with no re-arm step there is -nothing to forget. +into. Fragment mode has no such window: with no re-arm step there is nothing +to forget. ### changelog-assembled — the stamp is exactly the fragments @@ -199,132 +284,138 @@ nothing to forget. ([actions/changelog-assembled/changelog-assembled.sh](actions/changelog-assembled/changelog-assembled.sh)): on a release PR in fragment mode, the stamped `## X.Y.Z` section must be **byte-for-byte** what the fragments it consumed assemble to. The guard -reads the fragments as of the merge base (they are gone from HEAD — that -is the point of the ceremony), replays `changelog-assemble --check` over -that set, and diffs the result against HEAD's section body. Every tree it -does not apply to — a `-dev` tree, legacy mode, no consumed fragments — -passes with a green `NOTICE`, so a non-ceremony PR is never red here. +reads the fragments as of the merge base (they are gone from HEAD — that is +the point of the ceremony), replays `changelog-assemble --check` over that +set, and diffs the result against HEAD's section body. Every tree it does +not apply to — a `-dev` tree, legacy mode, no consumed fragments — passes +with a green `NOTICE`, so a non-ceremony PR is never red here. **The failure it catches** (#116): assembly is a hand-run step by design — -the section must land in the PR's diff where the panel reads it (#112 D12) -— and a mis-run hand step can leave no trace. The two failure shapes -differ, and the guards split them exactly as -[test/changelog-assembled.test.sh](test/changelog-assembled.test.sh)'s -trio rows record: leave a fragment **out of the deletion** and it survives -on HEAD, where -[changelog-armed](#changelog-armed--main-never-sits-disarmed) already -refuses the bare tree (`not consumed`) — this guard goes red too, naming -the entry the section lost. But **delete** a fragment while omitting its -entry from the stamp, or hand-edit one word of the assembled prose, and -nothing on HEAD is out of place: armed is green, monotonic is green, and -the publisher would happily publish history that is not what the authors -wrote. Only the merge-base replay catches those. The replay is what -makes a hand-run step safe. **This guard needs history** — same stance as -the monotonic guard: `fetch-depth: 0`, and in CI an unresolvable base is a -hard failure, not a skip. +the section must land in the PR's diff where the panel reads it (#112 D12) — +and a mis-run hand step can leave no trace. The two failure shapes differ, +and the guards split them exactly as +[test/changelog-assembled.test.sh](test/changelog-assembled.test.sh)'s trio +rows record: leave a fragment **out of the deletion** and it survives on +HEAD, where [changelog-armed](#changelog-armed--main-never-sits-disarmed) +already refuses the bare tree (`not consumed`) — this guard goes red too, +naming the entry the section lost. But **delete** a fragment while omitting +its entry from the stamp, or hand-edit one word of the assembled prose, and +nothing on HEAD is out of place: armed is green, monotonic is green, and the +publisher would happily publish history that is not what the authors wrote. +Only the merge-base replay catches those. The replay is what makes a +hand-run step safe. **This guard needs history** — same stance as the +monotonic guard: `fetch-depth: 0`, and in CI an unresolvable base is a hard +failure, not a skip. ### changelog-monotonic — shipped headings are append-only **The rule** -([actions/changelog-monotonic/changelog-monotonic.sh](actions/changelog-monotonic/changelog-monotonic.sh#L4-L7)): -the set of `## X.Y.Z` headings on your branch must be a **superset** of -the set at the merge base, and no heading may appear twice on HEAD. The -rule needs no tuning because release headings are append-only by doctrine: -the ceremony adds one and nothing ever legitimately removes one — so -superset has no exception to carve. The ceremony's own stamp passes by -construction: the assembler writes a new `## X.Y.Z — DATE` heading and -removes none. Fragment mode changes nothing here (#112 D10): fragments add -no `## ` heading, and `Unreleased` was never in the guard's set — it is -not a version heading; it is +([actions/changelog-monotonic/changelog-monotonic.sh](actions/changelog-monotonic/changelog-monotonic.sh)): +the set of `## X.Y.Z` headings on your branch must be a **superset** of the +set at the merge base, and no heading may appear twice on HEAD. The rule +needs no tuning because release headings are append-only by doctrine: the +ceremony adds one and nothing ever legitimately removes one — so superset +has no exception to carve. The ceremony's own stamp passes by construction: +the assembler writes a new `## X.Y.Z — DATE` heading and removes none. +Fragment mode changes nothing here (#112 D10): fragments add no `## ` +heading, and `Unreleased` was never in the guard's set — it is not a version +heading; it is [changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business — which is why a repo's adoption PR can delete it and stay green. -**The incidents**: box#122 (caught in review of box#118) — an author -adding an entry under `## Unreleased` **replaced** the heading below it -instead of inserting above it; git merges that cleanly, and the shipped -section's body is silently absorbed into `## Unreleased`. And box#118 -itself — a bad rebase *duplicated* a shipped heading, which containment is -blind to, which is why uniqueness-on-HEAD is a separate assert -([the script](actions/changelog-monotonic/changelog-monotonic.sh#L96-L116)). +**The incidents**: box#122 (caught in review of box#118) — an author adding +an entry under `## Unreleased` **replaced** the heading below it instead of +inserting above it; git merges that cleanly, and the shipped section's body +is silently absorbed into `## Unreleased`. And box#118 itself — a bad rebase +*duplicated* a shipped heading, which containment is blind to, which is why +uniqueness-on-HEAD is a separate assert. -**Red means** a shipped section was deleted (put the heading back and -insert **above** it) or duplicated (collapse to one heading; the failure -message walks through both fixes with the diff to run). **This guard needs +**Red means** a shipped section was deleted (put the heading back and insert +**above** it) or duplicated (collapse to one heading; the failure message +walks through both fixes with the diff to run). **This guard needs history**: the consumer's checkout must use `fetch-depth: 0`, and in CI an unresolvable base is a hard failure, not a skip — a guard that can quietly -stop guarding is the failure shape this family of checks exists to refuse -([strict mode](actions/changelog-monotonic/changelog-monotonic.sh#L60-L79)). +stop guarding is the failure shape this family of checks exists to refuse. ### drill-recorded — a release carries its evidence **The rule** -([actions/drill-recorded/drill-recorded.sh](actions/drill-recorded/drill-recorded.sh#L23-L48)), -keyed on the tree's version: a `-dev` tree passes with nothing to assert -(a development tree ships nothing); a bare tree — the ceremony PR and its -merge — must carry `drills/.md` with at least one -non-whitespace character. One file per version, so `0.9.0.md` and -`0.9.0-rc1.md` are simply different files and prefix confusion is -unrepresentable (#1 constraint 7). +([actions/drill-recorded/drill-recorded.sh](actions/drill-recorded/drill-recorded.sh)), +keyed on the tree's version: a `-dev` tree passes with nothing to assert (a +development tree ships nothing); a bare tree — the ceremony PR and its merge +— must carry `drills/.md` with at least one non-whitespace +character. One file per version, so `0.9.0.md` and `0.9.0-rc1.md` are simply +different files and prefix confusion is unrepresentable (#1 constraint 7). **The incident**: box's CONTRIBUTING said since box#96 that the release ritual must be run and recorded. No release ever did it — box#95, box#114 and box#148 all shipped as a version bump plus a changelog stamp, because -the gate was a sentence in a document and the only thing standing on it -was a reviewer remembering to ask. The rule moved into CI, where it fires +the gate was a sentence in a document and the only thing standing on it was +a reviewer remembering to ask. The rule moved into CI, where it fires whether or not anyone is paying attention. **Red means** the release is asserting a ritual it left no evidence of. -**The fix is to run the drill** and record it — or to waive it *in -writing* at the same path: the guard demands a **record, not a passing -result** ([below](#the-drill-doctrine)). +**The fix is to run the drill** and record it — or to waive it *in writing* +at the same path: the guard demands a **record, not a passing result** +([below](#the-drill-doctrine)). ## The drill doctrine **Evidence, not success.** The guard asserts a record exists — a failed drill honestly written down satisfies it, and so does a maintainer waiver -that says plainly the drill was waived and why. What it refuses is -silence: a skip must cost a deliberate, reviewable file in the diff, -which is precisely what box's three silent skips never produced. CI -cannot run a consumer's drill (box's wants real hardware and the better -part of an hour); it can only refuse a release that never ran one. +that says plainly the drill was waived and why. What it refuses is silence: +a skip must cost a deliberate, reviewable file in the diff, which is +precisely what box's three silent skips never produced. CI cannot run a +consumer's drill (box's wants real hardware and the better part of an hour); +it can only refuse a release that never ran one. **Each repo defines what its drill *means*** — the gate only reads the -record. box asserts the **isolation contract**; rig asserts -**convergence** (a machine reaches its role, idempotently); cast asserts -**promotion** (A→B reproduces, the diff is idempotent); ceremony's own -drill is a **door rehearsal** — both doors exercised end-to-end on a -disposable repo (#11 names the six probes); incubator's is TBD in -heavy-duty/incubator. Each repo states its meaning in its own -`drills/README.md`. Three different exercises sharing a substrate is why -the records are per-repo — they are not phases of one script. +record. box asserts the **isolation contract**; rig asserts **convergence** +(a machine reaches its role, idempotently); cast asserts **promotion** (A→B +reproduces, the diff is idempotent); ceremony's own drill is a **door +rehearsal** — both doors exercised end-to-end on a disposable repo, written +out step by step in [drills/README.md](drills/README.md), with the records +themselves in [drills/](drills/); incubator asserts the **staging verify** — +the canonical candidate deployed, its smoke probe run *inside* the staging +container on the deployed environment's credentials, the record pinning the +commit SHA and image digest that were exercised +([heavy-duty/incubator `drills/README.md`](https://github.com/heavy-duty/incubator/blob/main/drills/README.md)). +Each repo states its meaning in its own `drills/README.md`. Five different +exercises sharing a substrate is why the records are per-repo — they are not +phases of one script. **Drills exercise candidate refs, not released artifacts.** A ref is a -static identifier that exists as soon as the release branch does, so no -repo has to be released — or drilled — before another can be drilled: -what looks like a box↔rig recursion at runtime dissolves into two -independent tests against one fixed pair of refs. And drilling the -candidate *is* drilling the release: a ceremony PR's diff is the stamps -and nothing else, so no executable byte differs between the tree that was -drilled and the tree that ships. +static identifier that exists as soon as the release branch does, so no repo +has to be released — or drilled — before another can be drilled: what looks +like a box↔rig recursion at runtime dissolves into two independent tests +against one fixed pair of refs. And drilling the candidate *is* drilling the +release: a ceremony PR's diff is the stamps and nothing else, so no +executable byte differs between the tree that was drilled and the tree that +ships. **A cross-repo release set shares one run ID.** Each repo records its own -legs in its own `drills/X.Y.Z.md`, citing that run ID and the sibling -SHAs, so the records reconcile afterwards — but the guard only ever reads -the repo it runs in. If a defect shows up only in the combination: patch, -re-drill, re-record. The set converges; it is not required to be right in -one pass. +legs in its own `drills/X.Y.Z.md`, citing that run ID and the sibling SHAs, +so the records reconcile afterwards — but the guard only ever reads the repo +it runs in. If a defect shows up only in the combination: patch, re-drill, +re-record. The set converges; it is not required to be right in one pass. ## Troubleshooting red main Every refusal the release flow can emit, verbatim, with cause and remedy. -The catalog is generated from the sources, not paraphrased — regenerate -it with: +The catalog is generated from the sources, not paraphrased — regenerate it +with: ```sh -grep -n -A2 'refuse \|>&2' lib/decide.sh lib/facts.sh .github/workflows/release.yml +grep -n -A2 'refuse \|>&2' \ + lib/decide.sh lib/facts.sh lib/version.sh .github/workflows/release.yml ``` -`$VER`-style variables appear as the run interpolates them. +`$VER`-style variables appear as the run interpolates them. One refusal is +outside that command by construction: `version_read: $path: no version field` +is a `console.error` inside the node one-liner at +[lib/version.sh#L55](lib/version.sh#L55) — no `>&2`, no `refuse `, so the grep +cannot see it. It is quoted below as it reaches the log at run time, which is +the convention this catalog is written to. ### The decision refused ([lib/decide.sh](lib/decide.sh)) @@ -347,12 +438,11 @@ or — if the tree is genuinely the release — publish by the tag door. > the version '$VER' is bare and unchanged, but RELEASED is empty — this state is decided by whether '$VER' is already released, and the caller did not establish that fact. Refusing to guess — creating nothing. > the version transitioned ('$BASE_VER' -> '$VER') but LABELED is empty — a transition ships only behind a merged, release-labeled PR, and the caller did not establish that fact. Refusing to guess — creating nothing. -The fact-gathering guards -([L92–L105](lib/decide.sh#L92-L105), [L135](lib/decide.sh#L135), -[L151](lib/decide.sh#L151)): a missing fact must never fall through to -"no". These indicate a bug upstream in [lib/facts.sh](lib/facts.sh) or the -workflow plumbing, not an operator mistake — read the run's `facts:` -stderr line and file what you find. +The fact-gathering guards ([L92–L105](lib/decide.sh#L92-L105), +[L135](lib/decide.sh#L135), [L151](lib/decide.sh#L151)): a missing fact must +never fall through to "no". These indicate a bug upstream in +[lib/facts.sh](lib/facts.sh) or the workflow plumbing, not an operator +mistake — read the run's `facts:` stderr line and file what you find. ### The facts could not be established ([lib/facts.sh](lib/facts.sh), [lib/version.sh](lib/version.sh)) @@ -367,44 +457,53 @@ stderr line and file what you find. > version_read: node is required for version-source: package-json [lib/version.sh](lib/version.sh#L16-L66): the tree's version source is -missing, empty, or unreadable. A wrong release is worse than a missing -one, so an unreadable state is never an empty print — restore the -`VERSION` file (or `package.json` version field) on main. +missing, empty, or unreadable. A wrong release is worse than a missing one, +so an unreadable state is never an empty print — restore the `VERSION` file +(or `package.json` version field) on main. -### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L300)) +> version_read: unknown backend: $backend + +[L62](lib/version.sh#L62): not an operator mistake and not reachable through +the release flow — [lib/facts.sh](lib/facts.sh#L33-L40) rejects a bad +`VERSION_SOURCE` with the message above before `version_read` is ever called, +so this line can only appear when some *other* caller invokes `version_read` +directly with a backend that is neither `file` nor `package-json`. Fix that +caller. + +### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L301)) > CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release [L202–L205](.github/workflows/release.yml#L202-L205): the ceremony merged without its stamp (a state the -[armed guard](#changelog-armed--main-never-sits-disarmed) already refuses -on the PR — red main here means it was overridden). Stamp the section on -main, then publish by the tag door. +[armed guard](#changelog-armed--main-never-sits-disarmed) already refuses on +the PR — red main here means it was overridden). Stamp the section on main, +then publish by the tag door. > tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing. > release '$VER' already exists — refusing to re-release, creating nothing. -[L207–L222](.github/workflows/release.yml#L207-L222), the nothing-exists +[L208–L223](.github/workflows/release.yml#L208-L223), the nothing-exists assert — what makes a re-run of a completed ceremony refuse instead of clobber, and what catches a manual tag racing the merge. If the release -truly exists, there is nothing to do: this red is the system declining to -do the thing twice. If the tag exists but the release does not (a manual -tag won the race, or -[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook)), recover by -the tag door: delete and re-push the tag, or `gh release create` by hand -from a fixed tree. +truly exists, there is nothing to do: this red is the system declining to do +the thing twice. If the tag exists but the release does not (a manual tag +won the race, or +[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook), or the publish +step itself failing after the tag), recover by the tag door: delete and +re-push the tag, or `gh release create` by hand from a fixed tree. > direct push refused (branch protection?) — opening the bump PR instead -[L292–L300](.github/workflows/release.yml#L292-L300) — loud, but not a +[L293–L301](.github/workflows/release.yml#L293-L301) — loud, but not a refusal: the post-release `-dev` bump could not push directly, so the run opened a `release`-labeled bump PR itself. Your move: merge it promptly — -until it lands, main is sitting bare, where a dev install -[impersonates the release](.github/workflows/release.yml#L291) and the +until it lands, main is sitting bare, where a dev install impersonates the +release and the [armed guard's window](#changelog-armed--main-never-sits-disarmed) stays open. -### The tag door refused ([release.yml](.github/workflows/release.yml#L302-L369)) +### The tag door refused ([release.yml](.github/workflows/release.yml#L303-L371)) > tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing. > A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit. @@ -416,40 +515,99 @@ remedy. [L346–L349](.github/workflows/release.yml#L346-L349). The tagged tree was never stamped. Assemble the section -([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), -then delete and re-push the tag. +([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then +delete and re-push the tag. + +### The re-arm refused ([release.yml](.github/workflows/release.yml#L267-L301)) + +The bump belongs to the merge door alone — the tag door deliberately does not +rewrite main ([L303–L307](.github/workflows/release.yml#L303-L307)) — and it +runs *after* the tag, the notes and the publish. So a refusal here leaves a +real release standing behind a main that never re-armed — the release exists, +and main is left *armed to impersonate* it, still reading the version it just +shipped ([L266](.github/workflows/release.yml#L266)). That is the one failure +in this catalog whose remedy is a manual bump, not a re-run. + +> version_next_dev: refusing '$ver' — expected bare X.Y.Z + +[L86](lib/version.sh#L86): the version reaching the bump is not bare `X.Y.Z`. +Two senses of *bare* meet here, and the gap between them is the **rc release +path** — the way this refusal is actually reached, and designed behaviour +rather than a decide bug. decide calls a version bare when it is not `-dev` +([version_is_dev](lib/version.sh#L68-L76) matches that suffix and nothing +else), so row 6 admits a transition to `1.2.3-rc1`, and a labeled rc ceremony +is designed to ship ([lib/decide.sh](lib/decide.sh#L108-L110)). +`version_next_dev` means `^[0-9]+\.[0-9]+\.[0-9]+$`. An rc sits between the +two, and nothing filters it out on the way: the step's only gate is +`ceremony == 'yes'` and its `VER` is the tree's version verbatim. So an rc +ceremony tags, writes the notes, publishes — and *then* the re-arm refuses. +That is the machine correctly declining to guess rather than a bug: an rc's +next version "is a human decision, not arithmetic" +([L78–L82](lib/version.sh#L78-L82)), so make the decision and bump main by +hand to it. A `-dev` version reaching this line is the same refusal's other +half, and *that* half is unreachable as the doors stand — rows 1–2 send `-dev` +to a no-op, and the tag door never bumps. A malformed version is not: nothing +upstream checks the shape ([version_read](lib/version.sh#L22-L33) checks only +that a version is present and non-empty), so `banana` rides row 6 exactly as +an rc does, and the same manual bump is the remedy. One note on work that has +not landed: #317 would make rc cuts native and their re-arm deterministic, and +if it lands only the malformed half still reaches this refusal. + +> version_write: npm is required for version-source: package-json + +[L106](lib/version.sh#L106): the package-json backend needs npm to write — +`npm pkg set version=` plus a lockfile-only `npm install` +([L114–L115](lib/version.sh#L114-L115)), never `npm version`, which would tag +— and the runner has none. The read path fails the same way one step earlier +(`node is required…`, above), so a run reaching *this* message got past the +read — set up node/npm in the caller. + +> version_write: unknown backend: $backend + +[L118](lib/version.sh#L118): the write-side twin of `version_read: unknown +backend`, and unreachable for the same reason — `VERSION_SOURCE` was validated +before either was called. Fix the caller. + +In every case the remedy has the same shape — bump `VERSION` (or the +`package.json` version field) by hand and push: `X.Y.(Z+1)-dev` where the +shipped version was bare, and where it was an rc, whatever you have decided +comes next. Note that a *push* refusal is not one of these — branch +protection is expected, and the step opens the bump PR itself rather than +failing ([L293–L301](.github/workflows/release.yml#L293-L301)). ### Red main that is not the release workflow Consumer CI runs its guard steps on pushes to main too (this repo's [ci.yml](.github/workflows/ci.yml) does the same). The one guard red an -operator will actually meet on main is **changelog-armed after a re-arm -was forgotten — legacy mode only**: the ceremony stamped without putting -`## Unreleased` back, the release's own `-dev` bump landed, and the guard -now says (first line): +operator will actually meet on main is **changelog-armed after a re-arm was +forgotten — legacy mode only**: the ceremony stamped without putting +`## Unreleased` back, the release's own `-dev` bump landed, and the guard now +says (first line): > changelog-armed: the version is '$ver' (a development tree) but the top > section of $changelog is: … The fix is a one-line PR: add an empty `## Unreleased` above the stamped -section. The full message carries the same instruction. Fragment mode has -no re-arm to forget, so it has no equivalent red on main — its refusals -(a missing marker, a surviving `## Unreleased`, a malformed or unconsumed +section. The full message carries the same instruction. Fragment mode has no +re-arm to forget, so it has no equivalent red on main — its refusals (a +missing marker, a surviving `## Unreleased`, a malformed or unconsumed fragment) all fire on the PR that caused them, where the author is still holding it. ## Design lineage -The ceremony converged across box#83 → box#96, rig#32 → rig#47, and -cast#96 → cast#111; this repo is those three implementations folded into -one (the drift that motivated it is measured in -[#1](https://github.com/heavy-duty/ceremony/issues/1)). The load-bearing -constraints — each bought with an incident, none of them safe to -"simplify" away — are listed in -[#1](https://github.com/heavy-duty/ceremony/issues/1) and carried, with -their war stories, in the headers of the scripts they bind: -[release.yml](.github/workflows/release.yml#L1-L109), -[lib/decide.sh](lib/decide.sh#L1-L74), -[lib/facts.sh](lib/facts.sh#L1-L24), and the four -[guard scripts](actions/). The comments are the documentation of record; -this README is their operator-facing cut. +The ceremony converged across box#83 → box#96, rig#32 → rig#47 and cast#96 → +cast#111; this repo is those three implementations folded into one, and the +drift that motivated it is measured in +[#1](https://github.com/heavy-duty/ceremony/issues/1), which also lists the +load-bearing constraints — each bought with an incident, none of them safe +to "simplify" away. The label machine's own record is #10, #11 and #130; the +issue-flow queue's is #15, #16 and #73; the fragment changelog's is #112 and +#116; the sweep/trigger split is #209. + +The narrative lives in those issues, by design: the war stories are carried +in the headers of the scripts they bind — +[release.yml](.github/workflows/release.yml), +[lib/decide.sh](lib/decide.sh), [lib/facts.sh](lib/facts.sh) and the +[guard scripts](actions/) — and those comments are the documentation of +record. This README is their operator-facing cut. diff --git a/RELEASES.md b/RELEASES.md new file mode 100644 index 0000000..930463c --- /dev/null +++ b/RELEASES.md @@ -0,0 +1,125 @@ +# Release management + +This file describes the release-management pattern available to governed +repositories. Adoption is per repository and operator-ruled: a repository +without version epics is not out of compliance. A repo-local roadmap is the +map; each epic remains the source of truth for its own release. Where an older +repo-local description differs from this file, this file governs. + +## The ladder + +Represent each planned release with one version epic. The epic is the working +surface for that release: it states the goal, names the members, and records +the ordered waves as checklists. Keep the machine-readable progress checklist +under a heading matching `## Task list`, case-insensitively; the issue-flow +sweep reads task rows there until the next heading when it decides whether to +nudge triage about a completed epic. Other member or wave headings are not +completion inputs. + +Keep a short repo-local roadmap beside the epics. The roadmap shows the whole +ladder and points to each working surface; it does not duplicate the live +member lists or ordering. crew's roadmap discussion [heavy-duty/crew#338](https://github.com/heavy-duty/crew/discussions/338) +maps the ladder whose `0.1.2` working surface moved from the crufty ledger +[heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to +[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346). + +## Gates + +Each version epic declares `Blocked by `. Special ordering — a +double gate or an out-of-chain gate — is written explicitly on that epic; +there is no hidden global schedule. The epic carries `epic` and the +repository's release label, with no queue label. Its `Blocked by` line is a +declaration a human reads: shipping closes the predecessor, then triage opens +the next window by hand as the first step of release-init. The issue-flow +sweep does not promote version epics; automating that gate would require a +separately specified change to its queue-category model. + +The gate orders windows, not their contents. Members enter a release only by +decision during release-init. The double gate on +[heavy-duty/crew#163](https://github.com/heavy-duty/crew/issues/163) and the +out-of-chain track on [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348) +are worked examples of exceptions declared where they apply. + +## Release-init + +The predecessor closing and clearing the next epic's declared gate is the +trigger, and today triage must notice it and open that window by hand. +[heavy-duty/ceremony#253](https://github.com/heavy-duty/ceremony/issues/253) +tracks the not-yet-shipped sweep announcement of that duty; do not treat the +announcement as present until the consumer's pin carries it. Triage runs five +steps: + +1. Mint the epic's “to mint when this arc opens” list together with findings, + deferred work, and discussion outcomes accumulated since the epic was + written. Each member initially declares `Blocked by `. +2. Graph hard `Blocked by` edges and same-file clusters on the epic. +3. Write the waves into the epic body as checklists in claim order, with a + separate verification lane and the progress view under `## Task list`. +4. Ask the operator to bless the order, then have triage open the first wave + by applying the flip mechanics below. The operator's blessing is the one + step this chain never automates. +5. Ship through the repository's cut process, close the epic, and treat that + close as the trigger for the next window. + +heavy-duty/crew#346 is the worked wave plan; its graph made both hard edges +and shared-file contention visible before builders entered the queue. If init +finds no work worth minting, the operator either folds the empty window into a +later release or skips the version, recording that ruling on the epic before +closing it unshipped. + +## One primary window, declared parallel tracks + +Run one primary release window by default. A cut takes whatever has landed, so +interleaving unrelated windows blurs both the release story and the evidence +behind it. Gates open windows; they do not silently admit members, so builders +still see one deliberately ordered queue. + +While a window stands — an open release-labeled issue with a non-empty +enumerated gate — its members form a DAG whose sink is the release issue. +Every member reaches that sink. Members declare only their immediate +predecessors; ordering edges live on members, while the sink records membership +only; and the `ready` set is exactly the graph's current sources. Every close +releases exactly its declared successors, and that whole set is concurrently +claimable: a member may have multiple successors, while the collision rule +already orders any that share a deliverable. Insertion re-points downstream +edges rather than merely appending membership at the sink. It follows that +every `ready` issue is a gate member. `epic` and `post-merge` issues are exempt +because neither is claimable (#292). + +The operator may declare a parallel track at init when its footprint is +disjoint from the primary window: another repository, another artifact, or +provably non-overlapping clusters. The declaration names the boundary and any +bridge work that must rejoin the primary. [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348) +is the worked example: its app and artifact form a parallel track while its +small crew-side bridge remains in the primary window. + +## Flip mechanics + +To admit a member, delete or rewrite its literal, parseable +`Blocked by ` declaration and swap `blocked` to `ready` in the same +edit. Markdown or HTML strikethrough is insufficient: the blocker parser reads +the raw marker text and still returns the reference. Never preserve history by +negating the marker phrase — the parser unions declarations even when prose +says they no longer apply. Preserve the history only after rewriting the +marker into non-parseable prose, then verify that the parser returns an empty +set for the release gate. + +Release membership is a decision, never a sweep default. Triage performs each +flip only after the operator blesses the wave; the issue-flow sweep may resolve +ordinary issue dependencies, but it does not choose a release's contents. +heavy-duty/crew#346 records the member-by-member flip that opened its first +wave. + +## The ledger pattern + +When a release epic has become too crufty to remain a clear working surface, +create a replacement and treat the old epic as a ledger. Do not close the old +epic until every live member declaration points at the replacement and the +blocker parser verifies the new set. Closing early can release every member +that still names the old issue. + +The [heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to +[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346) +transition is the worked example: all member declarations were re-pointed and +parse-verified before #162 closed; #162 remains the historical record while +#346 is the release's working surface. diff --git a/REVIEWER.md b/REVIEWER.md index 22f102f..17e8b77 100644 --- a/REVIEWER.md +++ b/REVIEWER.md @@ -34,7 +34,12 @@ In order of authority: not a defect: the issue directs it, triage owns that close, and a request-changes on the "missing" keyword enforces the bug the shape exists to fix — `Closes #137` closed its issue with a post-merge - criterion unmet (#151). Check every + criterion unmet (#151). For a `Refs #N` body, also verify that no closing + keyword immediately precedes `#N` anywhere in the body, even in prose + explaining the hand close or inside a code span: GitHub used those exact + shapes to close #209, #212 and #199 (#200, #218). The safe forms put the + number first (`#N is closed by hand`) or omit it (`triage closes the issue + by hand`). Check every criterion; a PR that ships less than the issue says is a request-changes even if the code is beautiful. 2. **The repo's load-bearing constraints** — the rules bought with @@ -49,6 +54,19 @@ In order of authority: `0.1.0`'s `load_config` rejected `triage-actors=...` with `malformed label row` and `exit=1`. CI green on a conversion PR proves nothing about the new config: the base branch's workflow is what ran. + - **Third-party actions never hold a write-capable token by default.** In + any job whose token is write-capable (`packages: write`, + `contents: write`, `id-token: write`, or one carrying deploy secrets), + the default is a repo-owned script a test can drive. A third-party + action may hold that token only if it comes from an **established + publisher** — a real organization with maintenance history and more + than one maintainer, not a memberless shell or a lone account shipping + an unauditable `dist/` blob — and is **pinned by full commit SHA**. An + action matching the incubator red-flag profile never holds a write + token, however well it works. Read-only jobs: ordinary dependency + judgement, SHA-pinning still required. This is bot-run infrastructure — + no human watches runtime logs, so a compromised action's window is + unbounded (incubator#53/#54; #216). 3. **The code itself** — correctness first, then tests (does the test plan's floor exist? do the failure cases actually fail?), then conventions. Changelog line present for behavior changes; comments carry why, not @@ -65,7 +83,9 @@ saw Y" outranks one that says "this looks like it might". wait for the repo to appear on a list: review is reversible read-plus-comment work, and the requester already decided it should happen. - **A request is authorization, not panel membership.** Convergence is - measured against the target repo's `panel=` roster minus the author. If you + measured against the target repo's `panel[]=` line if its + `labels.conf` defines one for the PR author, else its `panel=` line; minus + the author in either case (#224). If you are requested off-panel, post the verdict anyway and say in its body that it is advisory; neither your silence nor your request-changes is a gate the reconciler enforces. The nine-hour wait for kimi's off-panel verdict on @@ -121,6 +141,11 @@ saw Y" outranks one that says "this looks like it might". - The builder answers rounds whole and re-requests you; until re-requested, the ball is not yours (`state:addressing` is the builder working — pile-on reviews mid-address just churn the target). +- A **draft carrying `state:addressing` is a fix round in progress**, not + abandonment: an engine may convert a PR back to draft at round close so the + builder's mid-round saves stop firing CI, and the flip back to ready is the + builder's own act announcing the round is answered + ([BUILDER.md](BUILDER.md#the-review-round)). - Convergence = every panel verdict approves the current head, no `blocker:*` standing. Then the builder hands off (`state:needs-human`) and the panel's job is done. diff --git a/TRIAGE.md b/TRIAGE.md index 840c09a..e05ccb4 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -1,32 +1,25 @@ # TRIAGE.md — the triage role -You are the only door issues come through. Humans and agents open -**discussions**; you decide what becomes work. The quality of every -downstream stage — a builder succeeding without asking, a reviewer having a -spec to review against — is set here, by you, and nowhere else. +You are the only door issues come through. Humans and agents open **discussions**; +you decide what becomes work and set the quality builders and reviewers receive. ## Why this door exists -Discussions are allowed to be ambiguous; issues are not. An issue is a work -order a builder must be able to execute **without asking anyone anything**. -Keeping one accountable role between the two is what keeps the bar from -eroding — the moment anyone can mint an issue, the backlog fills with -"improve X" entries nobody can build, and builders start guessing. Guessing -is the failure this whole flow exists to prevent. +Discussions may be ambiguous; issues may not: a builder must be able to execute +one **without asking anything**. One accountable role keeps builders from guessing. ## Your inputs - **Every open discussion** in the repo you serve. - **Stray issues** — anything filed directly, by anyone. Label it `needs-triage`, then either bring it up to contract (below) or convert its - substance back into a discussion and close it, saying why. Do not shame the - filer; do route the work correctly. + substance back into a discussion and close it, saying why. Route the work + without shaming the filer. ## For each discussion, converge on exactly one outcome 1. **Answer.** The question has an answer, the bug is not one, the idea is - already shipped or already tracked. Reply with the answer (link the code, - the doc, the existing issue), mark answered. + already shipped or tracked. Link the code, doc, or issue; mark answered. 2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the repo, its history, or its docs. Ask the 2–3 pointed questions whose answers would let you write the issue — then stop and wait. Do not mint an @@ -35,11 +28,10 @@ is the failure this whole flow exists to prevent. 3. **Escalate.** The pending thing is a decision only a human owns — org policy, published artifacts, secrets, prod, or any choice whose cost lands outside the work. A panel deadlock is one instance, not the definition - ([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). Say - precisely what the decision is, name the decider, and use + (#50 D11). Say precisely what the decision is, name the decider, and use [BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask), including its options, recommendation, blocked/continues statement, and - reversible-only default rules ([#50 D12–D13](https://github.com/heavy-duty/ceremony/issues/50)). + reversible-only default rules (#50 D12–D13). The discussion is where humans decide; wait there. When the decision blocks something already on the board — an existing issue, or minted work a discussion's ruling gates — set `needs-ruling` on it too, so the board @@ -53,22 +45,19 @@ is the failure this whole flow exists to prevent. `needs-ruling` ask — re-read that issue's **label events** (`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just its comments: the answer often arrives as a label with no comment, and a - write that re-read only the thread races it. Both 2026-07-24 failures — - [a header correction on #149](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070758613) - asserting a hold 58 seconds after its lift, and - [a `needs-ruling` ask on #151](https://github.com/heavy-duty/ceremony/issues/151#issuecomment-5070768876) - the operator's label events had answered 132 seconds earlier — are this - sentence's absence. + write that re-read only the thread races it (#149, #151). Past 24 hours from the current episode's `labeled` event, if the ruling still stands and doubt remains, it is triage's duty to pick the option the builder proceeds on, record that pick as a decision, and stay accountable - for it; the operator may overturn it at merge - ([#50 D13–D14](https://github.com/heavy-duty/ceremony/issues/50)). You set - the flag, so you also close it out ([LABELS.md](LABELS.md)): judge when + for it; the operator may overturn it at merge (#50 D13–D14). You set the + flag, so you also close it out ([LABELS.md](LABELS.md)): judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the issue to its flow in that same comment; when that ruling or any directive or answered builder question delivers - the assignee's next move in prose, set `attention` in the same comment. + the assignee's next move in prose, set `attention` in the same comment on + the assigned issue that owns the claim — never on the pull request, even + when the comment lives there. Flagging an unassigned issue is a board bug, + not a demand; repair the board rather than setting `attention`. This is not a substitute for minting work or for `needs-ruling`. 4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link where it belongs if anywhere, close. A refusal with reasons is a good @@ -93,20 +82,33 @@ Every issue you mint carries, in this order: A criterion that can only be checked after the merge must carry its own mechanism, in the criterion itself: that it is post-merge, that triage owns the close, and that the PR references the issue with `Refs #N` - rather than `Closes #N`. A criterion that survives the merge only if - someone remembers to reopen the issue is an incomplete criterion — #137's - amended body is the worked example, reopened by hand after `Closes #137` - closed it with the criterion unmet (#151). The merge moves the issue to - `post-merge` and releases the claim. The sweep writes the transition - comment when it derives the move; when triage or the operator moves it by - hand, triage writes the comment in the same tick. In either case triage - follows up with the remaining criteria, their owner, and the wake condition - for completion. + rather than `Closes #N`; relying on somebody to reopen the issue is an + incomplete criterion (#151). The merge moves the issue to `post-merge` and + releases the claim. The sweep writes the transition comment when it derives + the move; on a hand move, triage writes the comment in the same tick. In + either case triage follows up with the remaining criteria, their owner, and + the wake condition for completion. - **Test plan**: what proves it, including the cases that must fail. - **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an epic organizes it. Name a cross-repo dependency the same way with its repository qualified (`Blocked by repo#N` or `owner/repo#N`); the sweep cannot resolve it, so triage verifies it and flips the issue by hand. + When a deliverable is already carried by an open `ready`, `claimed`, or + `blocked` issue, the newer issue must declare an unconditional collision + edge with `Blocked by #N`, naming the newest open carrier; there is no + alternative for disjoint regions. This keeps every `ready` issue + concurrently claimable and makes each close release one successor (#288). + During a standing release window, every mint also gets a binary membership + call in the same tick. A non-member names the release issue as its blocker + in its own Dependencies. A member is placed with three writes: the new issue + names its immediate member predecessors; every member whose immediate + predecessor the new issue becomes adds or re-points its dependency to the + new issue, dropping any predecessor the new issue now reaches (inserting X + into A → B makes A → X → B, so B drops A); a member that must land after the + new issue but already reaches it through another member declares nothing + new; and the release issue adds the new issue to its gate, recording + membership only. Collision and window edges are independent, so write both + when both apply (#292). - **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)). @@ -118,10 +120,14 @@ expected. ## Multi-issue work When an acceptance produces more than one issue, mint an **epic** (`epic` -label): the approach, the decisions, the constraint list, and a -dependency-ordered task list of child issues. Children reference the epic; -the epic's checklist is the progress view. Builders never pick the epic +label) with the approach, decisions, constraints, and a dependency-ordered +child checklist. Children reference the epic; that checklist is the progress +view. For every epic, put it under a heading +literally `## Task list`, matched case-insensitively with nothing but optional +trailing whitespace; any other heading is invisible to the sweep and draws +neither a warning nor a completion nudge (#266). Builders never pick the epic itself. Keep the checklist current — a stale epic misleads every scan. +Repositories that adopt version epics follow [RELEASES.md](RELEASES.md). ## Backlog hygiene @@ -142,13 +148,8 @@ itself. Keep the checklist current — a stale epic misleads every scan. them. Every label on every open issue stays true; the board is only worth scanning if it does not lie. - **A lifted hold makes its body prose stale in the same instant, and the - body is yours.** The "stays true" bar above extends past the labels to - the prose that describes them: when a hold lifts, correcting the body - header that described it is your move in the same tick — not the - builder's, and not left for the next reader to diff. On - [#149](https://github.com/heavy-duty/ceremony/issues/149) the lift - arrived by label alone and the body said held for the next five and a - half minutes; two builders read that window to opposite conclusions. + body is yours.** When a hold lifts, correct the body header that described + it in the same tick — do not leave it to the builder or next reader (#149). ## What you never do diff --git a/VERSION b/VERSION index 7532512..2feed2f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.2-dev +0.6.1-dev diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 54c2c23..f5386b5 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -31,9 +31,86 @@ TRIAGE_ACTORS=() . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh" # shellcheck source=lib/closes_references.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh" +# The attention target invariants (#232) — diagnosis only, both surfaces. +# shellcheck source=lib/attention.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/attention.sh" +# The guarded read and its reason line (#101, #247) — one implementation for +# both surfaces. +# shellcheck source=lib/read.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/read.sh" -log() { printf 'issueflow: %s\n' "$*"; } -run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; } +# The status a per-issue subshell exits with when it walked away from an +# unreadable fact (#247 D4). Distinguished from every other non-zero status so +# a deliberate skip is counted rather than reported as a crash — and so the +# existing crash handler still names a genuine one. +ISSUEFLOW_SKIP=3 +# Set by reconcile_issue_pass, read once by main for the D6 tail. +SKIPPED_COUNT=0 +SKIPPED_ISSUES="" + +# A per-issue pass is ATOMIC: it commits its whole effect or none of it +# (#247 D1). Inside reconcile_issue_pass's subshell, `run` and `log` do not +# act — they append here, and commit_staged_effects replays them in order +# once the pass has completed. Everywhere else (the arrival path, the sweep's +# own lines) they act immediately, as they always did. +# +# This is the ordering invariant itself, not a fix for the two sites that +# happened to violate it: a mutation reached before a later guarded read is +# what let a pass remove `stale`, or mint `needs-triage`, and THEN report the +# issue as skipped — the sweep saying it touched nothing while a write had +# landed, which is the same false-report class #247 exists to close. Stated +# per site it would hold until the next composition; stated here it holds for +# compositions nobody has written yet, because reconcile_issue has no way to +# mutate directly. +# +# Reads are deliberately NOT staged. They may happen anywhere in the pass, +# because nothing lands until the end. +STAGING=false +STAGED_EFFECTS=() + +emit() { printf 'issueflow: %s\n' "$*"; } +apply() { if [ -n "${DRY_RUN:-}" ]; then emit "DRY_RUN: $*"; else "$@"; fi; } + +stage() { # $1 = LOG|WRITE, rest = the effect's argv, kept exact by the count + STAGED_EFFECTS+=("$#" "$@") +} + +log() { if [ "$STAGING" = true ]; then stage LOG "$@"; else emit "$@"; fi; } +run() { if [ "$STAGING" = true ]; then stage WRITE "$@"; else apply "$@"; fi; } + +commit_staged_effects() { + # In staging order, so a completed pass's log and writes read exactly as + # they did when each acted at its own call site. The `>/dev/null` is the one + # every `run` call site already applies: a redirection cannot travel with + # the argv, so it is applied here instead — uniformly, because on this + # surface every staged write has it. + local i=0 argc + STAGING=false + while [ "$i" -lt "${#STAGED_EFFECTS[@]}" ]; do + argc="${STAGED_EFFECTS[i]}" + if [ "${STAGED_EFFECTS[i + 1]}" = LOG ]; then + emit "${STAGED_EFFECTS[@]:i + 2:argc - 1}" + else + apply "${STAGED_EFFECTS[@]:i + 2:argc - 1}" >/dev/null + fi + i=$((i + 1 + argc)) + done + STAGED_EFFECTS=() +} + +skip_issue() { # $1 = issue, $2 = the whole reason clause — ends this issue's pass + # Leaves the issue exactly as it is: nothing is derived from a read that + # did not answer, and nothing this pass staged is ever committed — `exit` + # discards the subshell that holds the buffer. So a skip implies zero + # `gh issue edit`, zero `gh issue comment`, and no log line claiming an + # effect that never landed, wherever in the pass the failed read lives. + # Called from the read itself, so no call site can forget to check — which + # is why it exits rather than returns. The reason rides its own + # `#$n:`-prefixed line (#247 D5), emitted directly: the skip is a fact + # about the pass, not one of the effects the pass staged. + emit "#$1: skipped this pass — $2" + exit "$ISSUEFLOW_SKIP" +} load_issueflow_config() { # $1 = labels.conf local conf="$1" line seen=false @@ -143,6 +220,16 @@ refs_references() { # PR body on stdin -> local issue numbers named by Refs | awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu } +open_pr_issues() { # records on stdin: CLOSING|BODYvalue -> issue numbers + local kind value + while IFS=$'\t' read -r kind value; do + case "$kind" in + CLOSING) [ -n "$value" ] && printf '%s\n' "$value" ;; + BODY) refs_references <<<"$value" ;; + esac + done | sort -nu +} + unchecked_criteria() { # issue body on stdin -> unchecked task-list lines verbatim awk ' /^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]+\[[[:space:]]\]/ { @@ -161,9 +248,19 @@ post_merge_decision() { # $1 merged Refs PR, $2 linked open PR, $3 already handl fi } -post_merge_pr_for_issue() { # $1 issue; records are ISSUEPR - awk -F '\t' -v issue="$1" '$1 == issue { print $2 }' \ - <<<"${MERGED_REF_PR_RECORDS:-}" | sort -n | tail -n1 +post_merge_pr_for_issue() { # $1 issue; records are ISSUEPRMERGED_AT + # The deliverable is the PR that merged last, not the one numbered highest. + # Merge order is not number order in this family: crew#176's two Refs PRs + # merged #184 at 19:05:16Z and #182 at 19:05:18Z — the higher number two + # seconds earlier. Number order is also what spends a marker on the wrong + # PR: crew#321 carries `post-merge-transition-pr-326` while its real + # deliverable crew#322 — a lower number, merging later — is still open, so + # under the old rule the transition it owes could never fire (#242). + # mergedAt is ISO-8601 UTC, so it sorts as a string; ties break by highest + # PR number so the answer never depends on input order. + awk -F '\t' -v issue="$1" '$1 == issue { print $3 "\t" $2 }' \ + <<<"${MERGED_REF_PR_RECORDS:-}" \ + | sort -t $'\t' -k1,1 -k2,2n | tail -n1 | cut -f2 } post_merge_transition_marker() { # $1 merged PR number @@ -224,6 +321,80 @@ blocked_cross_references() { # body on stdin -> qualified refs, one per line blocked_reference_records | awk -F '\t' '$1 == "CROSS" { print $2 }' | sort -u } +blocked_parse_set() { # $1 local refs, $2 cross refs -> "{#7, #12}" | "{}" + # The parse, rendered once. The comment, the marker and the log line all + # read this one string, so the three can never disagree about what the + # machine read. Both classes are shown because both are parsed: the locals + # in the numeric order blocked_references answers, then the qualified + # references blocked_cross_references answers — a cross-repo clause is as + # capable of being readable-but-wrong as a local one. + local rendered + rendered="$( + { [ -z "$1" ] || awk '{ print "#" $0 }' <<<"$1" + [ -z "${2:-}" ] || printf '%s\n' "$2" + } | awk '{ printf "%s%s", (NR > 1 ? ", " : ""), $0 } END { printf "\n" }' + )" + printf '{%s}\n' "$rendered" +} + +blocked_parse_marker() { # $1 rendered set -> the echo's idempotency marker + # Scoped to the SET's value, not to the issue and not to the sweep: the + # marker names WHAT was echoed, and blocked_parse_echo_needed decides whether + # it is still what the thread is saying. + # + # The identity is the DIGEST, not the slug beside it. Slugging is many-to-one + # — `{acme/widgets#9}` and `{acme-widgets#9}` are both parses this reconciler + # accepts, and both slug to `acme-widgets-9` — so a slug-keyed marker lets a + # changed set find the old marker and say nothing, silence in precisely the + # case the echo exists to speak about. Distinguishing `/` would close that + # pair and leave the class: `-`, `_` and `.` are legal in a qualifier token + # and all collapse the same way. The slug stays in front so a human reading + # the raw comment can still see which set it belongs to; it decides nothing. + state_marker blockers-parsed "$1" +} + +state_marker() { # $1 = marker family, $2 = the state's rendered value + # The one spelling of a value-keyed marker. Three flags now key on a state + # that changes rather than on "have I ever said this" — the blocked-parse + # echo (#252) and the two board flags (#293) — and a second implementation + # of the slug-plus-digest rule is the drift a shared helper prevents. + local slug digest + slug="$(printf '%s' "$2" | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')" + digest="$(printf '%s' "$2" | sha256sum | cut -c1-12)" + printf '%s-%s-%s\n' "$1" "${slug:-none}" "$digest" +} + +blocked_parse_echo_needed() { # $1 issue, $2 this parse's marker → 0 echo, 1 quiet + # Idempotency for the parse echo is against the LAST parse echo on the + # thread, not against any historical one. ensure_comment's any-occurrence + # grep is right for a flag like `blocked-unparseable`, whose question is + # "have I ever said this"; it is wrong for a value that changes, whose + # question is "is this still what I am saying". The difference is A -> B -> A: + # under an any-occurrence search the return to A finds A's own first echo and + # stays silent, leaving the thread's most recent echo asserting B while the + # sweep gates on A. A stale parse presented as the current one is the exact + # failure #252 exists to kill, and the third edit changed the parsed set, so + # the criterion says it speaks. + # + # Comparing markers rather than re-rendering the last set keeps the digest as + # the only identity: two sets are the same here iff blocked_parse_marker says + # so, the same rule the marker itself is built on. + state_echo_needed "$1" blockers-parsed "$2" +} + +state_echo_needed() { # $1 issue, $2 family, $3 this state's marker → 0 echo, 1 quiet + # The value-keyed dedup itself, family-scoped so each flag compares against + # its OWN last word and never against another flag's (#293 D4 asks for the + # declaration echo's mechanism exactly, and three families now share it). + local bodies last + guarded_read bodies forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \ + || skip_issue "$1" "could not read its comments: $(read_failure_reason "$READ_FAILURE_STDERR")" + # The read fails closed above (#247 D1): an unreadable history skips the + # issue rather than answering "nothing echoed yet" and re-posting. + last="$(grep -o "" <<<"$bodies" | tail -n 1)" + [ "$last" != "" ] +} + blocked_decision() { # $1 local refs, $2 OPEN/CLOSED states, $3 cross-repo refs local refs="$1" states="$2" cross_refs="${3:-}" if [ -n "$cross_refs" ]; then echo FLAG_CROSS_REPO @@ -250,6 +421,166 @@ epic_decision() { # $1 refs, $2 states fi } +# ---- the two board flags (#293): collision (#288) and window (#292) -------- +# +# Both are ADVISORY and comment-only (#293 D1). The sweep never guesses +# intent, so neither writes a label, changes a state, or invents one: it +# states the board fact and triage resolves it. Both are also the first +# checks here whose input is the WHOLE board rather than one issue, so the +# facts are gathered once in main() and every decision below is pure over +# those records — one record per open issue, `numberlabelstitle`. +# +# Why they exist as guards at all: #288 and #292 are triage prose, and both +# failed silently on the same morning (2026-08-04) — #284 minted `ready` +# into a claimed file's function, and six `ready` non-members raced an +# emptying gate. #262 measured the pattern: the same class of rule, once in +# a guard, produced zero misses. + +DELIVERABLE_PATH_PREFIXES=(actions/ lib/ bin/ .github/) + +deliverable_key() { # $1 = one title segment -> its normalized key, or nothing + # Normalized, because the 2026-08-04 miss spelled one deliverable two ways + # — `actions/issueflow-reconcile` against plain `issueflow-reconcile` — so + # exact-prefix matching would have missed the pair it was written for + # (#293 D2). One leading path segment comes off, then every extension: + # `issueflow-reconcile.test.sh` and `issueflow-reconcile.sh` are the same + # spelling habit one more time. + local key="$1" prefix + key="${key#"${key%%[![:space:]]*}"}" + key="${key%"${key##*[![:space:]]}"}" + for prefix in "${DELIVERABLE_PATH_PREFIXES[@]}"; do + [ "${key#"$prefix"}" = "$key" ] || { key="${key#"$prefix"}"; break; } + done + while [[ "$key" =~ \.[[:alnum:]]+$ ]]; do key="${key%.*}"; done + # Case folds because the key is a spelling, not an identifier, and folding + # only ever widens the match — the same direction of error the blocked + # parse takes, and the cheap one: a false pair costs a comment a human + # dismisses, a missed pair costs two builders one deliverable. + printf '%s\n' "$key" | tr '[:upper:]' '[:lower:]' +} + +deliverable_keys() { # title on stdin -> its deliverable keys, one per line + local title prefix segment key + IFS= read -r title + # The issue contract forces every title to name its deliverable before the + # em dash, so the key exists on every well-formed title by construction + # (#288 D5). A title without one names no deliverable, and inventing a key + # out of prose is the guessing this sweep never does — the malformed title + # is triage's own contract to enforce, not this flag's to infer around. + prefix="${title%%—*}" + [ "$prefix" != "$title" ] || return 0 + # A multi-file deliverable joins its files with `+` and collides on any + # segment: `TRIAGE.md + RELEASES.md` carries both keys. + local segments=() + IFS='+' read -r -a segments <<<"$prefix" + # An issue answers a SET of keys, never a multiset. Normalization is + # many-to-one by design — `issueflow-reconcile.sh + issueflow-reconcile.test.sh` + # is one deliverable spelled twice, which is exactly the `+` shape D2 wrote + # the segment rule for — and a repeated key makes `collision_flags`' scan + # find the issue adjacent to itself, chaining it to its own number: the + # comment would ask #402 to declare `Blocked by #402`. It corrupts the chain + # between two such issues too, since each contributes two rows to one key. + # Deduping here rather than in the index keeps the set property with the + # function whose contract it is. + { for segment in "${segments[@]}"; do + key="$(deliverable_key "$segment")" + [ -z "$key" ] || printf '%s\n' "$key" + done + } | awk '!seen[$0]++' +} + +unblocked_claimable() { # $1 = comma-joined labels -> 0 when the issue is unblocked + # THE one definition of `unblocked`, because #293 gives both flags one word + # and one gloss on it: D2 as corrected reads "`unblocked` means open and not + # `blocked` — carrying `ready` or `claimed`, with or without an open PR", + # and D3b's first line says D3 uses D2's corrected `unblocked` and names the + # domain as the claimable set. Two spellings of one spec word is how the + # flags came to disagree about `needs-triage`, so there is one predicate and + # both flags call it. + # + # `blocked` is out: a chained issue is the GOAL state of #288's rule, and + # flagging it would report the fix as the defect. Anything else without + # `ready` or `claimed` is out because it is not claimable — `needs-triage` + # and a label-less issue are not states a builder can pick up, and an + # unlabeled one is getting `needs-triage` from this very pass. `epic` and + # `post-merge` are out by #288 D6 and #292 D1 alike — neither is picked by a + # builder — and they carry no queue label to admit them here anyway. + case ",$1," in *,blocked,*) return 1 ;; esac + case ",$1," in *,ready,*|*,claimed,*) return 0 ;; esac + return 1 +} + +collision_in_scope() { # $1 = comma-joined labels -> 0 in the collision set + unblocked_claimable "$1" +} + +window_in_scope() { # $1 = comma-joined labels -> 0 subject to the window rule + # The same `unblocked`, not a second reading of it. Excluding only + # `blocked`/`epic`/`post-merge` here admitted `needs-triage` and a + # label-less issue, which left the sweep adding `needs-triage` to an + # unlabeled issue and then, in the same pass, telling it about a membership + # call made at mint time. Neither is claimable; #292's invariant is stated + # over the claimable set (D3b), and its exemptions say why — `epic` and + # `post-merge` are exempt *because neither is claimable*. + unblocked_claimable "$1" +} + +collision_key_index() { # board records on stdin -> "keynumber" in scope + local n labels title key + while IFS=$'\t' read -r n labels title; do + [ -n "$n" ] || continue + collision_in_scope "$labels" || continue + while IFS= read -r key; do + [ -z "$key" ] || printf '%s\t%s\n' "$key" "$n" + done < <(deliverable_keys <<<"$title") + done +} + +collision_flags() { # key index on stdin -> "numberkey=carrier[,key=carrier]" + # A CHAIN, not a fan (#288 D3): within one key, each issue names the newest + # open carrier below it, so the declaration the flag asks for releases + # exactly one successor per close. Three issues on one deliverable draw two + # comments — #257 naming #253, #284 naming #257 — never three pairs, which + # is the fan the rule exists to forbid. + # + # One line per issue, its keys folded into one state: an issue carrying two + # colliding deliverables has ONE offending state and owes one comment (D4), + # the same shape the blocked-parse echo takes with its set. + sort -t $'\t' -k1,1 -k2,2n \ + | awk -F '\t' ' + $1 == key { print $2 "\t" $1 "=" carrier } + { key = $1; carrier = $2 } + ' \ + | sort -t $'\t' -k1,1n -k2,2 \ + | awk -F '\t' ' + $1 != n { if (n != "") print n "\t" state; n = $1; state = $2; next } + { state = state "," $2 } + END { if (n != "") print n "\t" state } + ' +} + +window_flags() { # $1 gate members, $2 window carriers; records on stdin -> numbers + local n labels title gate="$1" carriers="$2" + [ -n "$carriers" ] || return 0 + while IFS=$'\t' read -r n labels title; do + [ -n "$n" ] || continue + window_in_scope "$labels" || continue + grep -qxF "$n" <<<"$gate" && continue + # The release issue is the graph's SINK, never one of its own members + # (#292 D2), so it can never be its own non-member. + grep -qxF "$n" <<<"$carriers" && continue + printf '%s\n' "$n" + done +} + +window_state() { # $1 = window carriers -> the rendered state, "#249" | "#249, #250" + awk 'NF { printf "%s#%s", (shown++ ? ", " : ""), $1 } END { printf "\n" }' <<<"$1" +} + +flag_for_issue() { # $1 = issue, $2 = flag records "numberstate" + awk -F '\t' -v n="$1" '$1 == n { print $2 }' <<<"$2" +} + offsite_cross_referenced_prs() { # timeline JSON on stdin -> owner/repo#N jq -r ' .[] @@ -271,6 +602,44 @@ offsite_resolved_decision() { # PR states on stdin -> NUDGE | QUIET fi } +issue_payload_valid() { # $1 = the requested issue; payload on stdin + # The second of D3's two required guards, and neither subsumes the other. + # The status check catches the 504 whose body is GitHub's JSON error object + # — valid JSON that passes every jq guard and empties the label set. THIS + # one catches an HTTP 200 whose body is `null`, which exits 0 and empties it + # just the same. `.number` is checked against the issue asked for, so a + # payload about some other issue can never be reconciled as this one. + # THE EMPTINESS CHECK IS NOT REDUNDANT, and it is not stylistic. `jq -e` + # disagrees with itself across versions on empty input: jq 1.7 exits 4 (no + # valid result was ever produced), jq 1.6 exits **0**. This instance's + # runner image (ghcr.io/catthehacker/ubuntu:act-22.04) carries jq 1.6, so + # without this line an EMPTY payload reads as a valid issue payload here — + # the precise thing D3 added this guard to refuse — and the sweep would + # reconcile an issue from a body it never received. Measured both ways, + # 2026-08-05: `jq -e '' /dev/null 2>&1 +} + +skipped_tail() { # $1 = skip count, $2 = the issue numbers → the D6 line, or nothing + # `reconciled.` stays byte-identical when the pass was whole — tests pin that + # exact string, and #101 D1 is the precedent for not folding new text into a + # matched line. A partial pass says so on a line of its own, after it, so a + # consumer reading only the tail of a job log can see it. + [ "$1" -gt 0 ] || return 0 + if [ "$1" -eq 1 ]; then + printf '%s issue skipped this pass on an unreadable fact: %s\n' "$1" "$2" + else + printf '%s issues skipped this pass on unreadable facts: %s\n' "$1" "$2" + fi +} + # API edge. Marker comments make warnings and nudges idempotent across sweeps. ensure_comment() { # $1 issue, $2 marker, $3 message local n="$1" marker="$2" message="$3" @@ -279,9 +648,15 @@ ensure_comment() { # $1 issue, $2 marker, $3 message $message" >/dev/null } -issue_comment_has_marker() { # $1 issue, $2 marker - forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \ - | grep -qF "" +issue_comment_has_marker() { # $1 issue, $2 marker → 0 found, 1 genuinely absent + # A failed read used to answer "no marker", which re-posts the comment the + # marker exists to suppress — absence of evidence read as evidence of + # absence (#247 D1). It cannot be a return value: every caller treats + # non-zero as "absent", so the skip is taken here, at the read. + local bodies + guarded_read bodies forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \ + || skip_issue "$1" "could not read its comments: $(read_failure_reason "$READ_FAILURE_STDERR")" + grep -qF "" <<<"$bodies" } reference_states() { @@ -308,24 +683,142 @@ offsite_timeline() { # unreadable timelines are deliberately silent forge_api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1 } -last_issue_activity() { - local n="$1" created="$2" latest - latest="$({ - printf '%s\n' "$created" - forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' - # Assignment is the claim itself. Ignoring it would let an old issue be - # reclaimed in the seconds between assignment and its required draft PR. - forge_api --paginate "repos/$REPO/issues/$n/timeline" \ - --jq '.[] | select(.event == "assigned") | .created_at' - } \ - | sort | tail -n1)" +issue_activity_at() { # $1 issue, $2 created_at, $3 with-assignment|comments-only + # One body, two clocks over it — a second activity computation is the drift + # the reuse exists to prevent, and the two callers below are the whole + # difference between them. + # + # Both reads are checked, and a failure reports rather than answering an age + # (#247 D1). Swallowed, the comments read falls back to `created_at`, and a + # `claimed` issue created months ago but commented on seconds earlier is + # reclaimed — the live builder unassigned, under a comment asserting 48 + # hours of silence. `needs-triage` is cheap to remove; that is not. + # The backend's stderr is left to flow to this function's own, where the + # caller's guarded_read captures it for the reason line. + local n="$1" created="$2" mode="$3" comments timeline="" latest + comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at')" \ + || return 1 + if [ "$mode" = with-assignment ]; then + timeline="$(forge_api --paginate "repos/$REPO/issues/$n/timeline" \ + --jq '.[] | select(.event == "assigned") | .created_at')" || return 1 + fi + latest="$(printf '%s\n%s\n%s\n' "$created" "$comments" "$timeline" | sort | tail -n1)" date -d "$latest" +%s } +last_issue_activity() { # $1 issue, $2 created_at → epoch; non-zero if a read failed + # The claim clock, and only the claim clock (#284). Assignment is the claim + # itself. Ignoring it would let an old issue be reclaimed in the seconds + # between assignment and its required draft PR. The ruling clock no longer + # rides here: an assignment says nothing about whether the decider + # answered, and counting it let claiming a flagged issue buy its + # escalation another 7 quiet days. + issue_activity_at "$1" "$2" with-assignment +} + +last_issue_comment_activity() { # $1 issue, $2 created_at → epoch; non-zero on a failed read + # The evidence nudge's clock (#254), and the issue-side ruling clock with + # it (#284): on an issue, a comment is the only substantive activity + # toward a ruling. Same computation as the claim clock, one input fewer, + # and the input it drops is the one that would starve each criterion: on + # `post-merge` there is no claim for an assignment to protect, and an + # assignee there is the invalid composition the `post-merge-assigned` flag + # reports; under `needs-ruling` the assignment is the *claim's* fact, and + # counting it silenced the escalation at exactly the moment somebody + # started working through it. Either way, a wider clock would let board + # state buy the wait another 7 days of silence. + # + # A comment the sweep itself wrote is still activity here, deliberately: + # the nudge carries no marker, so its own comment is what rate-limits it, + # and no machine comment can be exempted without exempting that one too. + # Reading authorship back into the clock would mean a body read this issue + # forbids. + issue_activity_at "$1" "$2" comments-only +} + +reconcile_board_flags() { # $1 = issue — the collision and window flags (#293) + # Dedup is the declaration echo's, per family (#293 D4): the marker is + # keyed to the offending state's VALUE and compared against this family's + # last word on the thread, so a state that changes speaks and a state that + # stands is silent. What that buys over ensure_comment's any-occurrence + # grep is the A -> B -> A case — an issue that collides with #257, is + # re-declared against #284, and collides with #257 again is saying + # something new each time, and an any-occurrence marker would go quiet on + # the third. What it does not buy is the state that resolves and returns + # unchanged: nothing is posted at the resolution, so the thread's last word + # is still the state itself and the return is silent. That is the echo's + # own boundary, and it is the right one here — the flag speaks about a + # board fact that is true right now, and a board where the fact never + # changed has nothing new to say. + local n="$1" state marker rendered + state="$(flag_for_issue "$n" "${COLLISION_FLAGS:-}")" + if [ -n "$state" ]; then + marker="$(state_marker collision "$state")" + if state_echo_needed "$n" collision "$marker"; then + rendered="$(tr ',' '\n' <<<"$state" \ + | awk -F= '{ print "- `" $1 "` — also carried by #" $2 }')" + run forge_issue_comment "$n" " +This issue and the issue named beside each key below are both open and +unblocked, and their titles name the same deliverable: + +$rendered + +That owes a **collision edge**, and #288 makes it unconditional: a deliverable +already carried by an open \`ready\`, \`claimed\` or \`blocked\` issue owes +\`Blocked by #N\` on the newer issue, naming the newest open carrier, so each +close releases exactly one successor. Disjoint regions do not waive it — +\`ready\` must mean claimable concurrently with every other \`ready\` issue, +and an undeclared collision sends two builders at one deliverable. + +The key is the title's em-dash prefix, normalized: one leading \`actions/\`, +\`lib/\`, \`bin/\` or \`.github/\` segment comes off, then every extension, and +a \`+\`-joined title matches on any segment. That is what the machine read, +never a judgment about what the deliverable is — if two spellings normalized +to one deliverable that is really two, say so and no edge is owed. + +*Comment only: nothing on this path writes a label or changes a state. The +marker carries the collision itself, so an unchanged one never re-posts.*" >/dev/null + log "#$n: collision flag — $state" + fi + fi + + state="$(flag_for_issue "$n" "${WINDOW_FLAGS:-}")" + if [ -n "$state" ]; then + marker="$(state_marker window-nonmember "$state")" + if state_echo_needed "$n" window-nonmember "$marker"; then + run forge_issue_comment "$n" " +A release window is standing ($state) and this issue is neither one of its +gate members nor an \`epic\` or \`post-merge\` issue. + +#292's invariant: during a standing window — an open \`release\`-labeled issue +with a non-empty gate — the \`ready\` set is a subset of the gate, \`epic\` and +\`post-merge\` exempt. Every mint during a window is a membership call, binary, +made at mint time: **behind the gate**, this issue's own Dependencies declare +the release issue as a blocker and the sweep releases it when the release +closes; or **into the graph**, three writes in one tick — this issue declares +its immediate predecessors, every member whose immediate predecessor it +becomes re-points to it, and the release issue gains \`Blocked by #N\`, which +records membership and nothing else. Silence is not a state. + +The gate is read from the release issue's own \`Blocked by\` declarations — the +same parse every \`blocked\` issue is gated on, echoed on that issue. + +*Comment only: nothing on this path writes a label or changes a state. The +marker carries the window itself, so an unchanged one never re-posts.*" >/dev/null + # "unblocked", not "ready": the flag fires on `claimed` too, PR in + # flight or not, which is the one wording #293 D3b went out of its way + # to correct. The log line is read by a human deciding whether the + # sweep understood the board, so it says what the predicate says. + log "#$n: window flag — an unblocked non-member under $state" + fi + fi +} + reconcile_issue() { - local n="$1" decision refs cross_refs states age assignees open_pr=false label owners - local merged_ref_pr="" transition_marker="" transition_handled=false + local n="$1" decision refs cross_refs states age evidence_age ruling_age created assignees open_pr=false label owners + local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker="" local unchecked="" remove_claimed=claimed + local attention_active=true attention_suppression="" decision="$(queue_decision <<<"$ISSUE_LABELS")" case "$decision" in ADD_NEEDS_TRIAGE) @@ -341,6 +834,19 @@ reconcile_issue() { if has_issue_label claimed; then assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")" grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true + # Under a pending ruling, the ruling clock is read at the top of the + # branch, before anything either arm below can post — the derived + # transition comment, the reclaim notice and the claimed-unassigned flag + # are all comments, and a read taken after one would date the issue by + # this sweep's own writing (#284; the hazard #274 met from the other + # side). Two clocks on purpose: `age` below counts the assignment + # because the assignment IS the claim; the ruling waits on a human, and + # an assignment says nothing about whether the decider answered. + if has_issue_label needs-ruling; then + created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" + guarded_read ruling_age last_issue_comment_activity "$n" "$created" \ + || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" + fi merged_ref_pr="$(post_merge_pr_for_issue "$n")" if [ -n "$merged_ref_pr" ]; then transition_marker="$(post_merge_transition_marker "$merged_ref_pr")" @@ -369,8 +875,11 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in --remove-label "$remove_claimed" --add-label post-merge >/dev/null fi log "#$n: merged Refs PR -> post-merge; claim released" + attention_active=false else - age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")" + created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" + guarded_read age last_issue_activity "$n" "$created" \ + || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" if [ "$(claim_clock_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then # Legitimately quiet work does not run the reclaim clock. Only the # clock stops: an unassigned claim is still a repair the decision must @@ -398,6 +907,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in fi log "#$n: stale claim reclaimed -> ready" ;; esac + [ "$decision" != FLAG_UNASSIGNED ] || attention_suppression=claimed-unassigned if has_issue_label offsite; then local timeline if timeline="$(offsite_timeline "$n")"; then @@ -413,14 +923,98 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in fi elif has_issue_label post-merge; then assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")" + # The evidence nudge's clock is read BEFORE any comment this branch + # posts. `ensure_comment` below is itself activity, so reading after it + # would let the assigned-flag comment silence the nudge for another 7 + # days — the same self-silencing the ruling nudge avoids by taking its + # clock from this same read, below. + created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" + guarded_read evidence_age last_issue_comment_activity "$n" "$created" \ + || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" + # On this surface the ruling clock IS this read (#284 D6): both nudges + # wait on comments and nothing else, so the evidence clock is handed to + # the ruling block rather than read again — and handed HERE, before the + # assigned-flag comment and the evidence nudge below, so neither wait is + # ever answered by anything this pass writes. `post-merge` + + # `needs-ruling` now costs one comments read where it cost three. + ruling_age="$evidence_age" if [ "$assignees" -gt 0 ] || has_issue_label attention; then ensure_comment "$n" post-merge-assigned \ 'This `post-merge` issue has an assignee or `attention`. The sweep will not undo hand-set intent; triage must clear the invalid composition or move the issue back into buildable queue state.' log "#$n: assigned or attention-bearing post-merge issue flagged" fi + attention_suppression=post-merge-assigned + # ---- the post-merge evidence nudge (#254), the ruling nudge's twin ---- + # A `post-merge` item waits on named evidence with a named owner, and + # nothing nudged when the wait went quiet: crew#181's real-host criterion + # starved four separate times across two releases, crew#240/#264 sat + # until an operator happened to run the right read. Same 7-day rule, same + # constant, same no-marker property — `ruling_nudge_decision` is the one + # spelling of all three (lib/ruling.sh), and a second `7 * 24 * 3600` + # here is the drift that file exists to prevent. + # + # The addressee is the triage actor, not `HUMAN_REVIEWER`: `post-merge` + # is triage's completion queue by contract (TRIAGE.md), so a starving + # wake condition is triage's to answer, and routing it to the operator + # asks the wrong party for a move it does not owe. `triage-actors=` is + # mandatory config — `load_issueflow_config` refuses to run without it — + # so there is nothing to fall back to, and a silent fallback is exactly + # how the wrong addressee comes back. + if [ "$(ruling_nudge_decision "$NOW" "$evidence_age")" = NUDGE ]; then + local quiet_days=$(((NOW - evidence_age) / 86400)) + # Trailing slash stripped so a server URL that carries one does not + # render `//owner/repo` — `:-` first so an absent value is empty rather + # than fatal, `%/` second so a present one is normalized (#198). + local server="${GITHUB_SERVER_URL:-}" + server="${server%/}" + run forge_issue_comment "$n" "@${TRIAGE_ACTORS[0]} — this \`post-merge\` item has had no comment for ${quiet_days} days: ${server}/$REPO/issues/$n + +Its wake evidence is still owed. \`post-merge\` means the merge landed and +triage owns completion — judge the remaining criteria against the evidence +and close the issue, or say what is still outstanding and who owes it. The +sweep names no criterion: which one starved is prose, and the machine never +judges prose (the link is the payload). + +*This nudge is comment-only and carries no idempotency marker on purpose: the comment itself is activity, so posting it resets the 7-day window and the rule self-rate-limits to one nudge per 7 quiet days. Do not add a marker.*" >/dev/null + log "#$n: post-merge evidence nudge (${quiet_days}d quiet — triage owes the wake evidence)" + fi elif has_issue_label blocked; then refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" + # The parse is echoed before any verdict is derived from it (#252). The + # clause parse is exact and unforgiving, and its output was invisible: + # crew#308 silently parsed a negated "no longer blocked by #221" as a + # blocker, crew#71 spent five days as an unresolvable queue conflict, and + # crew#284's declaration had to be re-derived by hand-running the parser. + # Every one of those was found by a human running the parser, hours or + # days late. `blocked-unparseable` already catches the UNREADABLE + # declaration; this catches the readable-but-wrong one, which no flag can + # detect because the machine cannot judge what a human meant — only state + # what it read, and let the human see the divergence in one sweep. + # + # The illustrative `#9` in the body below is code-spanned for the same + # reason `blocked-unparseable` code-spans its `Blocked by #N`: an + # unbackticked `#N` in a comment this sweep posts on a cron linkifies, and + # writes a "mentioned in" event onto an unrelated issue once per echo. + parsed_set="$(blocked_parse_set "$refs" "$cross_refs")" + parse_marker="$(blocked_parse_marker "$parsed_set")" + if blocked_parse_echo_needed "$n" "$parse_marker"; then + run forge_issue_comment "$n" " +This issue's \`Blocked by\` declarations parse to: $parsed_set + +That is the exact set this sweep gates on — what the machine read, never a +judgment about whether it is what you meant. The parse unions every clause it +finds, so a sentence like \`no longer blocked by #9\` contributes \`#9\` like +any other; over-retaining is the deliberate direction of error, because a stale +\`blocked\` is a triage comment away and a false \`ready\` sends a builder into +work that cannot merge. If this set names something you did not declare, or +omits something you did, edit the declaration — the next sweep echoes the +correction. + +*Comment only: nothing on this path writes a label. The marker carries the set +itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null + fi + log "#$n: blocked declarations parse to $parsed_set" states="$(reference_states <<<"$refs")" decision="$(blocked_decision "$refs" "$states" "$cross_refs")" case "$decision" in @@ -437,6 +1031,27 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in log "#$n: blockers closed -> ready" ;; esac elif has_issue_label epic; then + if has_issue_label release && ! issue_comment_has_marker "$n" release-init-due; then + release_doctrine_path=.ceremony/RELEASES.md + # Ceremony dogfoods the action but owns doctrine at the repository root (#253). + [ "$REPO" != heavy-duty/ceremony ] || release_doctrine_path=RELEASES.md + refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" + cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" + states="$(reference_states <<<"$refs")" + if [ "$(blocked_decision "$refs" "$states" "$cross_refs")" = READY ]; then + ensure_comment "$n" release-init-due \ + "This release epic's declared gate is open. Release initialization is due: + +1. Mint the window's members. +2. Graph hard dependencies and same-file clusters. +3. Write ordered waves and the progress task list. +4. Ask the operator to bless the order, then open the first wave. +5. Ship the release, close this epic, and trigger the next window. + +See \`$release_doctrine_path\`. The operator blessing the order is the one step this chain never automates." + log "#$n: release-init due" + fi + fi refs="$(epic_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" states="$(reference_states <<<"$refs")" if [ "$(epic_decision "$refs" "$states")" = NUDGE ]; then @@ -446,6 +1061,21 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in fi fi + # The flag composes with every build queue state, but requires an assignee. + # Existing post-merge/claimed diagnostics take precedence so one board bug + # draws one comment (#232 D5); the shared helper still logs the suppression. + if [ "$attention_active" = true ] && has_issue_label attention; then + [ -n "${assignees:-}" ] || assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")" + reconcile_attention "$n" issue "$assignees" "$attention_suppression" + fi + + # ---- the two board flags (#293), on any queue state ---- + # After the queue branches for the same reason the ruling block is: both + # compose with every queue state, and FLAG_CONFLICT's early return still + # short-circuits them, because a board lying about its queue state is + # repaired before anything is derived from it. + reconcile_board_flags "$n" + # ---- the ruling invariants (#52), on any queue state ---- # The flag composes with the queue labels (#50 D8), so this runs after the # queue branches rather than inside one of them. The FLAG_CONFLICT return @@ -458,9 +1088,19 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in run forge_issue_edit "$n" --remove-label stale >/dev/null log "#$n: unstale (a ruling is pending)" fi - [ -n "${age:-}" ] \ - || age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")" - reconcile_ruling "$n" "$age" "$NOW" + # The ruling clock reads comments only (#284 D1): an `assigned` event is + # the claim clock's fact, and counting it here let claiming a flagged + # issue buy its escalation another 7 quiet days. The reclaim clock + # (`age`) must never reach this call — the branches that write comments + # before this block (`claimed`, `post-merge`) arrive holding + # `ruling_age` already, read before anything they post; the fresh read + # serves the paths that arrive empty-handed. + if [ -z "${ruling_age:-}" ]; then + created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" + guarded_read ruling_age last_issue_comment_activity "$n" "$created" \ + || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" + fi + reconcile_ruling "$n" "$ruling_age" "$NOW" fi } @@ -495,6 +1135,48 @@ reconcile_opened_issue() { log "#$n: needs-triage (opened by $author)" } +reconcile_issue_pass() { # $1 = issue — one issue's whole pass, in its own subshell + # The subshell is #91's resilience: one unreadable or broken issue must not + # take the sweep down. What it is NOT is an errexit boundary — a command + # whose status is tested by `||` runs with errexit suppressed, and the + # suppression extends through the whole subshell body, so the handler below + # is what disables the errexit that would have caught a failed read (#247 + # D2). Removing it would revive errexit and lose #91. Explicit per-read + # checks are the mechanism instead, and each one exits with ISSUEFLOW_SKIP. + # + # What the subshell IS, since #247's first round, is the atomicity + # boundary: the staged effects live in it, so ending it — by a skip, or by + # a crash — discards them, and no partial pass can ever reach the board. + local n="$1" status=0 + ( + # Everything below stages rather than acts, and commits at the bottom — + # so a skip taken at any read, and a crash at any statement, leaves the + # issue exactly as it was (D1). `|| exit $?` keeps a crash's status the + # subshell's own, as it was when reconcile_issue was the last command + # here: the commit must not overwrite it, and must not run under it. + STAGING=true + guarded_read ISSUE_JSON forge_api "repos/$REPO/issues/$n" \ + || skip_issue "$n" "could not read the issue: $(read_failure_reason "$READ_FAILURE_STDERR")" + issue_payload_valid "$n" <<<"$ISSUE_JSON" \ + || skip_issue "$n" "the issue read answered a payload that is not issue #$n carrying a label array" + # `.pull_request == null`, never `has("pull_request") | not` (#188, #210): + # every Forgejo entry CARRIES the key, valued null on an issue, so the + # has() form selects zero rows here — silently, forever. + jq -e '.pull_request == null' <<<"$ISSUE_JSON" >/dev/null || exit 0 + ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" + reconcile_issue "$n" || exit $? + commit_staged_effects + ) || status=$? + if [ "$status" -eq "$ISSUEFLOW_SKIP" ]; then + SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) + SKIPPED_ISSUES="${SKIPPED_ISSUES:+$SKIPPED_ISSUES }#$n" + elif [ "$status" -ne 0 ]; then + # Byte-identical, and still owed: a skip is deliberate, a crash is not, + # and folding the two together would hide one behind the other (D4). + log "#$n: reconcile failed — continuing with the remaining issues" + fi +} + main() { # See labels-reconcile's twin (#188). This one already failed loudly on # Forgejo — but with `line 408: gh: command not found`, which names the @@ -517,49 +1199,130 @@ main() { if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}" fi - # owner/name split out here until #188 — the GraphQL query took them as - # separate variables. REST takes the owner/name path whole, so it is gone. # Both gathers were `gh api graphql` until #188. Forgejo has NO GraphQL # API — a real forgejo-runner job even arrives with GITHUB_GRAPHQL_URL set # to the empty string (probe task 278) — so these could not be translated # to a Forgejo endpoint; there is none. They are REST + a parser this repo - # owns, over `number` and `body`, which /api/v3 and /api/v1 both return in - # the same shape (measured on both, 2026-08-02). + # owns, over `number`, `body` and `merged_at`, which /api/v3 and /api/v1 + # both return in the same shape (measured on both; `.merged_at` is + # Z-suffixed UTC on each, so it still sorts as a string). + # + # owner/name were separate GraphQL variables until #188. REST takes the + # owner/name path whole, so they are gone. + # + # crew#321 released a live claim because the open side read only closing + # links while the merged side parsed Refs bodies. One parser now supplies + # the local body references on both sides, so transition and reclaim agree + # — which is why this is `open_pr_issues` over Refs and no longer + # `closes_references`. # # Bodies travel base64 because they contain newlines: jq's @tsv escapes a # newline to a literal backslash-n, which a line-oriented parser reads as - # one line and silently loses every declaration after the first. The old - # GraphQL gather sidestepped that with `split("\n")[]`; base64 is the same - # protection without needing the split to be correct. + # one line and silently loses every declaration after the first. + # `open_pr_issues` IS line-oriented — upstream fed it `split("\n")[]` — so + # the decoded body is re-split into one BODY row per physical line here. + # Handing it the whole decoded body as ONE record loses every declaration + # including the first, silently, because the trailing lines arrive as + # records whose kind matches neither case arm (#198). + # + # BOTH record kinds are fed, not just BODY. Upstream's CLOSING rows came + # from GitHub's `closingIssuesReferences` — GitHub's own parse of the + # CLOSING KEYWORDS in the body — and `lib/closes_references.sh` is exactly + # the replacement #188 wrote for that field. Feeding BODY rows alone would + # drop every `Closes #N` link on the open side: `refs_references` matches + # `Refs` and deliberately not `Closes` (#151), so an open PR that says it + # CLOSES an issue would stop counting as that issue's open PR and the claim + # would be reclaimed under it. test/issueflow-reconcile.test.sh's + # base64-round-trip case is that regression, and it is red without this. OPEN_PR_ISSUES="$(forge_api --paginate "repos/$REPO/pulls?state=open" \ --jq '.[] | .body // "" | @base64' \ | while IFS= read -r b64; do - [ -n "$b64" ] && printf '%s' "$b64" | base64 -d | closes_references - done | sort -nu)" - # closes_references, not refs_references: GitHub's closingIssuesReferences - # meant the CLOSING relation specifically, and reading Refs as closing - # would make every referenced issue look closeable — the distinction #151 - # was reopened by hand over. + [ -n "$b64" ] || continue + body="$(printf '%s' "$b64" | base64 -d)" + printf '%s' "$body" | closes_references | awk '{ print "CLOSING\t" $0 }' + printf '%s' "$body" | awk '{ print "BODY\t" $0 }' + done | open_pr_issues)" + # Three columns: ISSUEPRMERGED_AT (#242). The third is not + # decoration — `post_merge_pr_for_issue` sorts on it to answer the PR that + # merged LAST rather than the one numbered highest, and with the column + # absent every sort key ties and the old number order comes back silently. + # base64 cannot contain a tab, so the three fields split cleanly. MERGED_REF_PR_RECORDS="$(forge_api --paginate "repos/$REPO/pulls?state=closed" \ - --jq '.[] | select(.merged_at != null) | "\(.number)\t\(.body // "" | @base64)"' \ - | while IFS=$'\t' read -r pr b64; do + --jq '.[] | select(.merged_at != null) + | "\(.number)\t\(.merged_at)\t\(.body // "" | @base64)"' \ + | while IFS=$'\t' read -r pr merged b64; do [ -n "$b64" ] || continue while IFS= read -r issue; do - [ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr" + [ -n "$issue" ] && printf '%s\t%s\t%s\n' "$issue" "$pr" "$merged" done < <(printf '%s' "$b64" | base64 -d | refs_references) done)" - local n - for n in $(forge_api --paginate "repos/$REPO/issues?state=open" \ - --jq '.[] | select(.pull_request == null) | .number'); do - ( - ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")" - jq -e '.pull_request == null' <<<"$ISSUE_JSON" >/dev/null || exit 0 - ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" - reconcile_issue "$n" - ) || log "#$n: reconcile failed — continuing with the remaining issues" - done + local n tail_line issue_numbers board_json release_bodies rn rbody gate body + local window_rendered="" + SKIPPED_COUNT=0 + SKIPPED_ISSUES="" + # A command substitution in a for list suppresses errexit. Capture and + # check the board read before entering the loop, or a 504 (including one + # after partial pagination) reports a full pass over a truncated board + # (#257). + # + # The read answers the whole payload rather than a projection of it because + # the two board flags (#293) are decided over the WHOLE board — every open + # issue's labels and title, and every open `release` issue's body. One read + # supplies all of it; a second pagination for the same rows would be a + # second board, free to disagree with this one mid-sweep. + if ! guarded_read board_json forge_api --paginate \ + "repos/$REPO/issues?state=open&per_page=100"; then + log "could not read the issue board: $(read_failure_reason "$READ_FAILURE_STDERR")" + return 1 + fi + # `.pull_request == null`, never `has("pull_request") | not` (#188, #210). + BOARD_RECORDS="$(jq -r '.[] | select(.pull_request == null) + | [(.number | tostring), ((.labels // []) | map(.name) | join(",")), (.title // "")] + | @tsv' \ + <<<"$board_json")" + issue_numbers="$(cut -f1 <<<"$BOARD_RECORDS")" + # A standing window is an open `release`-labeled issue whose gate still + # holds an OPEN member (#292 D1). The board read IS the open set, so + # membership decides openness with no extra call — and an all-closed gate + # is exactly the emptied gate the release's own `blocked` -> `ready` + # promotion answers, which is why a `ready` release leaves the flag + # dormant rather than flagging the whole board. + release_bodies="$(jq -r '.[] | select(.pull_request == null) + | select((.labels // []) | map(.name) | index("release")) + | [(.number | tostring), ((.body // "") | gsub("[\t\r\n]"; " "))] | @tsv' \ + <<<"$board_json")" + WINDOW_CARRIERS="" + WINDOW_GATE="" + if [ -n "$issue_numbers" ]; then + while IFS=$'\t' read -r rn rbody; do + [ -n "$rn" ] || continue + gate="$(blocked_references <<<"$rbody")" + [ -n "$gate" ] || continue + grep -qxF -f <(printf '%s\n' "$issue_numbers") <<<"$gate" || continue + WINDOW_CARRIERS="${WINDOW_CARRIERS}${rn}"$'\n' + WINDOW_GATE="${WINDOW_GATE}${gate}"$'\n' + done <<<"$release_bodies" + fi + [ -z "$WINDOW_CARRIERS" ] || window_rendered="$(window_state "$WINDOW_CARRIERS")" + COLLISION_FLAGS="$(collision_key_index <<<"$BOARD_RECORDS" | collision_flags)" + WINDOW_FLAGS="$(window_flags "$WINDOW_GATE" "$WINDOW_CARRIERS" <<<"$BOARD_RECORDS" \ + | awk -v state="$window_rendered" 'NF { print $1 "\t" state }')" + if [ -z "$issue_numbers" ]; then + log "no open issues." + else + while IFS= read -r n; do + [ -n "$n" ] && reconcile_issue_pass "$n" + done <<<"$issue_numbers" + fi log "reconciled." + # The job stays green (D7): an hourly sweep over a hundred-issue board meets + # transient 504s as a matter of course, and reddening the whole run for one + # skipped issue trains consumers to ignore red — the outcome #95 and #101 + # both steered away from on the PR surface. This line is what buys back the + # auditability that costs. + tail_line="$(skipped_tail "$SKIPPED_COUNT" "$SKIPPED_ISSUES")" + [ -z "$tail_line" ] || log "$tail_line" } if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@"; fi diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 04c84ef..449ab12 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -37,6 +37,12 @@ fi HUMAN="${HUMAN_REVIEWER:-danmt}" BOTS=() +# Per-author panels (#224): parallel arrays because the conf is tiny and an +# associative array buys nothing but a bash-4 dependency statement. One entry +# per panel[]= row — PANEL_AUTHORS holds the login, PANEL_ROWS the +# space-joined reviewer set at the same index. +PANEL_AUTHORS=() +PANEL_ROWS=() REQUIRED_BOTS=() STATES=(state:building state:bots-reviewing state:addressing state:needs-human) BLOCKERS=(blocker:conflict blocker:ci-red blocker:unrequested) @@ -49,12 +55,36 @@ LABELS="" # retirement heals the board instead of stranding a label nothing recomputes. RETIRED=(state:needs-rebase) STALE_AFTER=$((48 * 3600)) +# How long the facts behind blocker:unrequested must have stood still before it +# is written (#236 D2). The operator's "more than 5 minutes", measured off the +# inputs' own timestamps rather than off sweep memory — this script is +# stateless per pass and stays that way. Overridable the way this file's other +# constants are, for a caller whose round cadence is slower or faster. +RECONCILE_UNREQUESTED_GRACE="${RECONCILE_UNREQUESTED_GRACE:-300}" +# The workflow whose runs checks_state must never grade — its own (#208). +# GITHUB_WORKFLOW is ambient in every Actions step and names the CALLER (the +# consumer's PR-facing workflow, since consumers name the caller), so this +# self-serves with no workflow-file change. The explicit override exists for +# two readers: the fixtures, and #209's detached sweep caller, which will +# need to point this at the PR-facing caller's name once reconcile no longer +# runs inside it. Empty means "filter nothing" — a caller outside Actions +# (a local rehearsal, an older pin) must not silently start dropping entries. +SELF_WORKFLOW="${SELF_WORKFLOW:-${GITHUB_WORKFLOW:-}}" # The needs-ruling invariants (#52) — one implementation for both surfaces. # shellcheck source=lib/ruling.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh" # shellcheck source=lib/forge.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh" +# The attention target invariants (#232) — diagnosis only, both surfaces. +# shellcheck source=lib/attention.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/attention.sh" +# The guarded read and its reason line (#101) — one implementation for both +# surfaces. read_failure_reason lived here until the issue surface needed the +# identical rule (#247); a second copy of it is the failure lib/ruling.sh's +# own header was written to record. +# shellcheck source=lib/read.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/read.sh" log() { printf 'labels: %s\n' "$*"; } @@ -79,25 +109,6 @@ blind_sweep_warning() { # $1 = unreadable PRs, $2 = all open PRs, $3 = sampled r fi } -read_failure_reason() { # $1 = captured stderr → one bounded line; pure (#101) - # Verbatim, collapsed, bounded (D3): gh emits multi-line errors and GraphQL - # blobs. Collapsed so the reason is exactly one log line — a raw newline - # inside the captured per-PR output block could collide with a matched - # string — and truncated because an unbounded paste per PR per sweep is - # noise, and annotations are capped anyway. - local reason - reason="$(printf '%s' "${1-}" | tr '\n' ' ')" - if [ -z "$reason" ]; then - # Empty stderr is itself a fact (D4): a read that failed silently is a - # different observation from a denial, and must not read as one. - echo "no error output" - elif [ "${#reason}" -gt 300 ]; then - printf '%s…\n' "${reason:0:300}" - else - printf '%s\n' "$reason" - fi -} - missing_core_labels_warning() { # $1 = declared rows, $2 = repo label names local rows="$1" repo_labels="$2" row name missing="" [ -n "$repo_labels" ] || return 0 @@ -120,8 +131,16 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional return 1 } BOTS=() + PANEL_AUTHORS=() + PANEL_ROWS=() + # shellcheck disable=SC2094 # parse_panel_author_row takes $conf for its + # error messages only — nothing in this loop writes the file it reads while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] || continue + # The panel[ prefix is matched QUOTED (#224 D7): in a case pattern an + # unquoted panel[abc]=* is a bracket expression that matches panela=…, + # panelb=…, panelc=… — silently rerouting ordinary settings. The + # panela= tripwire in test/labels.test.sh goes red if this regresses. case "$line" in panel=*) [ "$panel_seen" = false ] || { @@ -135,6 +154,7 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional return 1 } ;; + "panel["*) parse_panel_author_row "$line" "$conf" || return ;; triage-actors=*) ;; *) parse_label_row "$line" >/dev/null || return ;; esac @@ -145,6 +165,55 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional } } +parse_panel_author_row() { # panel[]= (#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[]= row (expected panel[]=): $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[]= row (a login is [A-Za-z0-9-] only): $line in $conf" >&2 + return 1 + ;; + esac + for existing in ${PANEL_AUTHORS[@]+"${PANEL_AUTHORS[@]}"}; do + [ "$existing" != "$login" ] || { + echo "labels: duplicate panel[$login]= row in $conf: $line" >&2 + return 1 + } + done + local -a row=() + rest="${line#*]=}" + read -r -a row <<<"$rest" + [ "${#row[@]}" -gt 0 ] || { + echo "labels: panel[$login]= must name at least one reviewer in $conf: $line" >&2 + return 1 + } + PANEL_AUTHORS+=("$login") + PANEL_ROWS+=("${row[*]}") +} + parse_label_row() { # exact name|color|description; pipes in descriptions are refused local line="$1" name color desc extra IFS='|' read -r name color desc extra <<<"$line" @@ -160,15 +229,38 @@ configured_label_rows() { # validated scope rows, excluding the panel setting [ -f "$conf" ] || return 0 while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] || continue - case "$line" in panel=* | triage-actors=*) continue ;; esac + # "panel["* quoted for the same D7 reason as load_config's case; skipping + # the bracketed rows (D5) keeps a dispatch bootstrap from trying to + # create a label named panel[]. + case "$line" in panel=* | "panel["* | triage-actors=*) continue ;; esac parse_label_row "$line" || return done <"$conf" } +panel_for_author() { # $1 = author → the effective panel, space-joined (#224 D2) + # THE resolution point: the author's panel[]= row when the conf + # defines one, the base panel= otherwise. Everything that computes a + # required set goes through here, because two places computing the panel + # is how the engine and the reconciler came to disagree in the first place. + local author="$1" i + for i in ${PANEL_AUTHORS[@]+"${!PANEL_AUTHORS[@]}"}; do + if [ "${PANEL_AUTHORS[i]}" = "$author" ]; then + printf '%s\n' "${PANEL_ROWS[i]}" + return + fi + done + printf '%s\n' "${BOTS[*]}" +} + set_required_bots() { # the PR author is recused by construction + # Minus-the-author applies to WHICHEVER set panel_for_author returns (#224 + # D2's safety net): an author who mistakenly appears inside its own + # bracketed row is still recused. local author="$1" bot + local -a effective=() + read -r -a effective <<<"$(panel_for_author "$author")" REQUIRED_BOTS=() - for bot in "${BOTS[@]}"; do + for bot in ${effective[@]+"${effective[@]}"}; do [ "$bot" = "$author" ] || REQUIRED_BOTS+=("$bot") done } @@ -183,6 +275,8 @@ set_required_bots() { # the PR author is recused by construction # MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict) # CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup) # LABELS newline-separated labels currently on the PR +# HEAD_COMMIT_AT the head commit's own date, ISO-8601; empty when unread +# NOW this sweep's epoch seconds (main sets it once per run) # --------------------------------------------------------------------------- requested() { grep -qxF "$1" <<<"$REQUESTED"; } @@ -252,7 +346,27 @@ checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | # a context whose entries are ALL cancelled never reported at all (a killed # or timed-out required job), so it keeps CANCELLED and still blocks — # discard needs a surviving verdict, never an empty context. - jq -r ' + # + # And one exclusion that comes before every rule above: the label machine + # never grades its own runs (#208). Every reconcile sweep serializes + # through one shared concurrency group, and GitHub records a displaced + # queued run as CANCELLED — there is no "superseded" conclusion for queue + # displacement. When the displaced run was born from a pull_request_target + # event, that cancelled entry attaches to the victim PR while its + # SUCCESSOR — triggered by a different PR or an issues event — attaches + # elsewhere, so the #139 carve-out's premise (a surviving sibling on the + # same PR) fails structurally: on the victim the newest self entry stays + # CANCELLED, the deny-list scores it FAILURE, and the sweep sets + # blocker:ci-red off its own corpse — then re-affirms it every cadence. + # Proven on crew#227: every real check green, the only red rollup entry + # the sweep's own displaced run. So drop every entry belonging to + # $SELF_WORKFLOW before the newest-per-context collapse. Accepted + # consequences: a rollup of ONLY self entries scores NONE (honestly: no + # checks — never SUCCESS), and a genuine reconcile failure surfaces on the + # Actions tab instead of as blocker:ci-red, which is right because no PR + # edit can fix the label machinery. An empty $self filters nothing — the + # exclusion must never widen into dropping entries on a guess. + jq -r --arg self "$SELF_WORKFLOW" ' if (has("statusCheckRollup") | not) then "UNREADABLE" else # NEUTRAL and SKIPPED satisfy branch protection — a skipped required check @@ -293,7 +407,11 @@ checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | # and treating it as newest keeps an undateable in-flight run from being # discarded in favour of a stale success. Every ambiguity resolves toward # "not settled". + # The #208 exclusion (header above): self entries leave the rollup here, + # BEFORE the group_by — a self-only context must vanish entirely, never + # survive as an all-cancelled context that still classifies FAILURE. | [ (.statusCheckRollup // [])[] + | select($self == "" or (.workflowName // "") != $self) | { ctx: [.workflowName // "", .name // .context // ""], at: ([.startedAt, .createdAt, .completedAt] | map(select(type == "string" and . != "" @@ -340,6 +458,44 @@ bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK esac } +iso_epoch() { # $1 = ISO-8601 timestamp → epoch seconds; nothing, rc 1, when unreadable + # An absent field reaches this as the empty string or as jq's literal "null"; + # both are "we did not read a time", and neither may be graded as one. + local at="${1-}" epoch + case "$at" in "" | null) return 1 ;; esac + epoch="$(date -d "$at" +%s 2>/dev/null)" || return 1 + [ -n "$epoch" ] || return 1 + printf '%s\n' "$epoch" +} + +unrequested_quiescent() { # 0 when the unrequested facts have stood for the grace (#236 D2) + # The stall blocker's supporting facts are the head and the round's newest + # submitted review: the ask it demands is owed only once both have stopped + # moving. Measured off those timestamps, not off sweep memory — ceremony#235 + # was flagged inside the ~90 seconds between a round-answer push and the + # author's re-request, because a sweep read the facts before the request + # landed and wrote after it. That is a round in motion, not a dropped ball. + # + # "Newest submitted review" is any submitted review, COMMENTED included: a + # non-verdict is still evidence the round is live, and counting it can only + # delay a flag, never invent one. + # + # A timestamp we could not read refuses the blocker (the standing rule: an + # unreadable fact never invents a verdict). This direction is deliberate and + # asymmetric — a missed flag costs one sweep of the 15-minute cadence, a + # false one flags a builder for doing exactly what BUILDER.md requires. + local newest verdict_at verdict_epoch + newest="$(iso_epoch "${HEAD_COMMIT_AT:-}")" || return 1 + verdict_at="$(jq -r '[.[].submitted_at] | max // empty' <<<"${REVIEWS_JSON:-[]}")" + if [ -n "$verdict_at" ]; then + # A round WITH verdicts whose newest one cannot be dated is unreadable, not + # quiescent; a round with no verdicts at all is simply the head's clock. + verdict_epoch="$(iso_epoch "$verdict_at")" || return 1 + [ "$verdict_epoch" -gt "$newest" ] && newest="$verdict_epoch" + fi + [ $((${NOW:-0} - newest)) -ge "$RECONCILE_UNREQUESTED_GRACE" ] +} + human_request_needed() { # 0 when needs-human requires a FRESH human request # already requested → the handoff is live; head-current human approval → # nothing left to ask. Anything else (never reviewed, an old comment, an @@ -375,7 +531,27 @@ blockers() { # → the blocker:* labels this PR should carry, one per line # A draft is exempt (the bots ignore drafts by design), and so is an # explicit human request — a maintainer claiming a PR early is deliberate, # not a dropped ball. - if [ "$DRAFT" != true ] && ! requested "$HUMAN"; then + # + # And so is a head whose checks have not answered yet (#236 D1). This is the + # one blocker that names an act the author must PERFORM, so it is the one + # that has to know when performing it is permitted: BUILDER.md's review round + # requires a green check at the head before requesting, so a builder waiting + # out a pending run is complying, and flagging compliance teaches its readers + # to ignore the label. Both 2026-08-03 instances were exactly that — + # crew#318 at ~12:44Z carried state:addressing + blocker:unrequested while + # the head's run was IN_PROGRESS, and ceremony#235 at 12:30Z caught the + # ~90-second gap between a round-answer push and the re-request. + # + # PENDING and FAILURE each already have an owner, which is why gating loses + # no coverage: on PENDING the next move is CI's and state:addressing / + # state:bots-reviewing already say what the PR is doing; on FAILURE + # blocker:ci-red owns that head, and stacking a second blocker on it + # double-flags one stall. NONE joins SUCCESS because no checks configured is + # nothing to wait for — the same reading the request rule gives the builder. + # UNREADABLE never arrives here: the caller skips the PR before deciding. + local checks_permit_the_ask=false + case "${CHECKS:-NONE}" in SUCCESS | NONE) checks_permit_the_ask=true ;; esac + if [ "$DRAFT" != true ] && [ "$checks_permit_the_ask" = true ] && ! requested "$HUMAN"; then local b v owed=false any_requested=false for b in "${REQUIRED_BOTS[@]}"; do requested "$b" && any_requested=true @@ -386,18 +562,64 @@ blockers() { # → the blocker:* labels this PR should carry, one per line v="$(bot_verdict "$b")" case "$v" in MISSING | STALE) owed=true ;; esac done - if [ "$owed" = true ] && [ "$any_requested" = false ]; then + # The quiescence grace (#236 D2) is the last question, after the debt is + # established: it asks whether the debt has stood long enough to be a + # dropped ball rather than a round still in motion. + if [ "$owed" = true ] && [ "$any_requested" = false ] && unrequested_quiescent; then echo blocker:unrequested fi fi } +round_outranks_draft() { # 0 when the round's standing word survives a re-draft (#205) + # A standing non-approving verdict outranks draft: a PR that took a round, + # carries CHANGES_REQUESTED (or a comment owed a reply, or approvals a push + # staled), and is then converted back to draft is a fix round in progress, + # not a build — and hiding it behind state:building is a dropped ball the + # staleness sweep reads as work in progress. Approvals do NOT outrank + # draft: a re-draft after a passed round is deliberately building again, + # and a draft must never read state:needs-human. + # + # A LIVE panel request on a draft also falls through — deliberately + # surfaced, not absorbed (#205's must-not-paper-over): the bots ignore + # drafts by design, so a draft wearing state:bots-reviewing on the board + # is the visible symptom of a real defect (a request nobody cleared at + # round close, or a hand-requested draft), and reading it as building + # would hide exactly that. + local b + for b in "${REQUIRED_BOTS[@]}"; do + requested "$b" && return 0 + case "$(bot_verdict "$b")" in BLOCK | FEEDBACK | STALE) return 0 ;; esac + done + [ "$(bot_verdict "$HUMAN")" = BLOCK ] +} + decide_state() { # → the one state:* label this PR should carry - if [ "$DRAFT" = true ]; then echo state:building; return; fi + # Draft decides the state only when the round implies nothing else (#205): + # a draft with no round history reads state:building exactly as it always + # has, and round_outranks_draft is what "nothing else" means. + if [ "$DRAFT" = true ] && ! round_outranks_draft; then + echo state:building + return + fi local s s="$(round_state)" + # A draft disqualifies needs-human unconditionally (#205, round 1): with + # the short-circuit above now conditional, a draft carrying a live human + # request plus a standing bot block or comment fell through to + # round_state, whose explicit-human-request precedence sits above the + # BLOCK/FEEDBACK cases — and GitHub cannot merge a draft at all, so + # "a human could merge this right now" would lie no matter what the + # round says. state:addressing is the same honest landing the blocker/ + # needs-ruling/blocked clauses below use: the round's word stands, only + # the mergeable-now claim is off the table while the PR is a draft. + if [ "$s" = state:needs-human ] && [ "$DRAFT" = true ]; then + echo state:addressing + return + fi + # The one rule joining the two axes: state:needs-human means a human could # merge this RIGHT NOW, so it requires a clear branch. Any blocker at all # means the work is the agent's — whatever the review round says — and the @@ -500,7 +722,7 @@ round_state() { # → the state the REVIEW ROUND alone implies; knows no branch core_label_rows() { cat <<'EOF' -state:building|FBCA04|PR is a draft — the coding agent is still building +state:building|FBCA04|Pre-round: the builder is still building — draft is evidence for it, not the definition state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes state:needs-human|8250DF|No blockers, all bots approve — waiting on the human reviewer @@ -598,6 +820,27 @@ tree_version() { # $1 = ref → that tree's version via the API, or nothing return 0 } +# label_write — 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 @@ -674,11 +917,21 @@ 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 run forge_issue_edit "$n" "${args[@]}" >/dev/null; then + if label_write "$n" "${args[@]}"; then log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}" else - # 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)" + # 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 fi fi @@ -697,7 +950,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 - run forge_issue_edit "$n" --remove-label merge-next >/dev/null + label_write "$n" --remove-label merge-next || return 1 log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)" fi @@ -723,11 +976,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 - run forge_issue_edit "$n" --remove-label stale >/dev/null + label_write "$n" --remove-label stale || return 1 log "#$n: unstale" fi elif ! has_label stale; then - run forge_issue_edit "$n" --add-label stale >/dev/null + label_write "$n" --add-label stale || return 1 log "#$n: stale ($((age / 3600))h quiet)" fi @@ -739,6 +992,12 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch if has_label needs-ruling; then reconcile_ruling "$n" "$last_activity_epoch" "$NOW" fi + + # `attention` belongs on the assigned issue that owns the claim, never on + # a pull request (#232). Behind the label gate so ordinary PRs pay no read. + if has_label attention; then + reconcile_attention "$n" pr "$(jq '.assignees | length' <<<"$PR_JSON")" "" + fi } main() { @@ -773,7 +1032,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 sampled_reason="" + local n output status total=0 unreadable=0 write_failures=0 sampled_reason="" while IFS= read -r n; do [ -n "$n" ] || continue total=$((total + 1)) @@ -825,6 +1084,29 @@ main() { log "#$n: read failed: $(read_failure_reason "$GH_VIEW_ERR")" exit 0 fi + # The head's own clock, for the blocker:unrequested grace (#236 D2). One + # read, pinned to the head SHA — not `gh pr view --json commits`, which + # asks for the FIRST hundred commits and would date a longer PR by a + # commit that is not its head. Last of the fetches on purpose: a PR the + # skip above walked away from must not pay for it, and neither do drafts, + # which never reach that blocker. Empty (a failed read, or a body without + # the field) leaves the blocker unjudged, by unrequested_quiescent. + HEAD_COMMIT_AT="" + if [ "$DRAFT" != true ]; then + HEAD_COMMIT_ERR_FILE="$(mktemp)" + HEAD_COMMIT_AT="$(forge_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=$? @@ -836,10 +1118,33 @@ main() { sampled_reason="$(sed -n "s/^labels: #$n: read failed: //p" <<<"$output" | head -n1)" fi elif [ "$status" -ne 0 ]; then - log "#$n: reconcile failed — continuing with the remaining PRs" + # 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." } diff --git a/actions/refs-not-closing/action.yml b/actions/refs-not-closing/action.yml new file mode 100644 index 0000000..10ffeae --- /dev/null +++ b/actions/refs-not-closing/action.yml @@ -0,0 +1,16 @@ +name: Refs not closing +description: >- + Refuse a pull request whose `Refs #N` promise contradicts GitHub's + closing-issue graph (#218). GitHub recognizes closing keywords anywhere + in a PR body, including ordinary prose and code spans; the action reads + the graph once and lets a pure script decide whether any Refs target is + already scheduled to close. +runs: + using: composite + steps: + - name: refs targets are not closing + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: bash "$GITHUB_ACTION_PATH/run.sh" diff --git a/actions/refs-not-closing/refs-not-closing.sh b/actions/refs-not-closing/refs-not-closing.sh new file mode 100755 index 0000000..0f2af98 --- /dev/null +++ b/actions/refs-not-closing/refs-not-closing.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +# refs-not-closing.sh [ ...] — 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:-}" >&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 diff --git a/actions/refs-not-closing/run.sh b/actions/refs-not-closing/run.sh new file mode 100755 index 0000000..d0dbbf4 --- /dev/null +++ b/actions/refs-not-closing/run.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +# The composite action's executable boundary (#218). Keeping the GraphQL +# gather here lets the offline contract test replace `gh` and prove that +# failed and partial reads cannot accidentally produce a green verdict. + +# THIS ACTION IS STILL gh-ONLY, AND SAYS SO (#198 spec 4, #199 ports it). +# Its entire gather is a single GraphQL query issued through `gh`, and +# Forgejo serves no +# GraphQL surface at all — `/api/graphql` 404s on this instance, and a real +# forgejo-runner job arrives with GITHUB_GRAPHQL_URL set to the empty string +# (lib/forge.sh's header). There is no endpoint to translate this to, so +# unlike every other call site the merge touched it cannot be ported here; +# it has to be re-expressed over REST, which is #199. +# +# Until then the declaration is the honest move: CEREMONY_FORGE_CLIENT names +# the client this file actually speaks, and forge_preflight refuses loudly on +# a forge that cannot serve it — rather than reading nothing and reporting a +# verdict. That is lib/forge.sh's own rule, "Never 'probably github'", +# applied to the one action that has not caught up yet. +# shellcheck source=lib/forge.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh" +export CEREMONY_FORGE_CLIENT=gh +# Fail CLOSED, at the action boundary. An earlier head here exited 0 with a +# notice so the PR check would not be red; @codex-reviewer-andresmgsl was +# right that this conflates two different questions. "This action cannot +# produce a verdict" is the ACTION's contract and must stay a refusal; "this +# check should not block the board" is the CALLER's decision, and it belongs +# in .github/workflows/refs-guard.yml, which skips on a backend this action +# cannot speak until #199 ports it. +forge_preflight || exit 1 + +owner="${GITHUB_REPOSITORY%%/*}" +name="${GITHUB_REPOSITORY#*/}" +[ -n "${PR_NUMBER:-}" ] || { + echo "refs-not-closing: pull request number is unavailable" >&2 + exit 1 +} + +# GraphQL variables are literal API syntax; the shell must not expand them. +# shellcheck disable=SC2016 +facts="$(gh api graphql \ + -f query='query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + body + closingIssuesReferences(first: 100) { + nodes { number } + pageInfo { hasNextPage } + } + } + } + }' \ + -F owner="$owner" -F name="$name" -F number="$PR_NUMBER")" + +body_file="$(mktemp)" +closing_file="$(mktemp)" +trap 'rm -f "$body_file" "$closing_file"' EXIT +jq -er ' + .data.repository.pullRequest + | if . == null then error("pull request was not returned") else .body // "" end +' <<<"$facts" >"$body_file" +jq -r ' + .data.repository.pullRequest.closingIssuesReferences + | if . == null then + error("closing issue references were not returned") + elif .pageInfo.hasNextPage then + error("more than 100 closing issue references; refusing a partial verdict") + else + .nodes[].number + end +' <<<"$facts" >"$closing_file" + +mapfile -t closing_issues <"$closing_file" +bash "$GITHUB_ACTION_PATH/refs-not-closing.sh" \ + "$body_file" "${closing_issues[@]}" diff --git a/changelog.d/192.md b/changelog.d/192.md new file mode 100644 index 0000000..8851646 --- /dev/null +++ b/changelog.d/192.md @@ -0,0 +1,47 @@ +### Fixed + +- 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). + +### Added + +- `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). diff --git a/changelog.d/198.md b/changelog.d/198.md new file mode 100644 index 0000000..4fa510f --- /dev/null +++ b/changelog.d/198.md @@ -0,0 +1,58 @@ +### Added + +- This tree carries upstream ceremony through `8c3a4d1` (upstream `0.6.0`): + `lib/attention.sh`, `lib/read.sh`, `actions/refs-not-closing`, the guarded + reads, and the ruling and window rules (#198). + +- `test/no-runtime-gh.test.sh` — the forge-portability guard: no runtime `gh` + outside `lib/forge-github.sh` unless the file declares + `CEREMONY_FORGE_CLIENT=gh` (#198). + +- `CHANGELOG.md` names the upstream commit this tree carries, so a drill + record can say which `0.6.0` it exercised (#197, #198). + +### Fixed + +- Eight runtime `gh` call sites arrived with the merge outside every conflict + hunk, in functions upstream added to files this tree already owned. Seven + are ported onto the shim; the eighth is named with its reason (#198). + +- The open-PR gather reads `Refs`, not only closing keywords. Reading one side + for closing links and the other for `Refs` is what released a live claim in + crew#321, and this tree carried that shape (#198). + +- The merged record gains `merged_at`, so `post_merge_pr_for_issue` answers + the PR that merged last rather than the highest-numbered one. Without the + column every sort key ties and the old order returns silently (#198). + +- The open gather feeds `open_pr_issues` one record per physical body line. A + whole decoded body as one record loses every declaration including the + first, and reclaims a claim a live PR was holding (#198). + +- The post-merge nudge links the issue on the forge in play rather than a + hard-coded `github.com` (#198). + +- `actions/refs-not-closing` reports and skips on a forge it cannot speak, + naming the client and #199, instead of standing red on every PR. It reaches + the forge zero times, so no verdict is produced either way (#198). + +- `.github/workflows/labels.yml`'s sweep dispatch declares the client it + speaks and decides the FORGE before the binary, so a Forgejo runner that + happens to ship `gh` cannot dispatch against a forge that cannot serve it. + #205 ports it to REST (#198). + +- `actions/refs-not-closing` fails closed on a forge it cannot speak, and + `.github/workflows/refs-guard.yml` carries the scheduling decision — the + action never reports a success it did not earn (#198). + +- `issue_payload_valid` refuses an empty payload on jq 1.6 as well as 1.7. + `jq -e` exits 4 on empty input under 1.7 and **0** under 1.6, and this + instance's runner carries 1.6 — so the guard #247 D3 added to refuse an + unreadable read was accepting one here (#198). + +- The post-merge nudge strips a trailing slash from the server URL, so a forge + URL carrying one does not render `//owner/repo` (#198). + +- `.github/scripts/release-path.sh` names `lib/forge.sh`: #191 put the shim on + the release doors' executable path here, so a doors-unchanged record that + omitted it was measuring the wrong set (#198). diff --git a/changelog.d/200.md b/changelog.d/200.md new file mode 100644 index 0000000..acfada8 --- /dev/null +++ b/changelog.d/200.md @@ -0,0 +1,48 @@ +### Added + +- `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). diff --git a/changelog.d/209.md b/changelog.d/209.md new file mode 100644 index 0000000..320b984 --- /dev/null +++ b/changelog.d/209.md @@ -0,0 +1,16 @@ +### Fixed + +- `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). + +### Added + +- `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). diff --git a/changelog.d/210.md b/changelog.d/210.md new file mode 100644 index 0000000..ed46cf2 --- /dev/null +++ b/changelog.d/210.md @@ -0,0 +1,27 @@ +### Fixed + +- `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). + +### Added + +- 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). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index fb244c2..0454f32 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -27,7 +27,7 @@ edits to this guide (#12). - **The `release` label must exist** before the first ceremony PR — it is the merge door's declared-intent read ([lib/facts.sh](../lib/facts.sh#L88-L101)). Bootstrap it via the labels - workflow's `workflow_dispatch` + sweep caller's `workflow_dispatch` ([Labels automation](#labels-automation)), or create it by hand, matching the core table ([actions/labels-reconcile/labels-reconcile.sh](../actions/labels-reconcile/labels-reconcile.sh#L369)): @@ -60,10 +60,10 @@ the machinery at all: the release PR assembles the section ([Assembling a release section](#assembling-a-release-section)). - Fragment mode is **unreleased** and not in `0.1.0`. A consumer pinned - to `0.1.0` bootstraps the legacy shape instead — the preamble plus an - empty `## Unreleased` section for entries to land under — and converts - on the pin bump to the first tag carrying fragment mode; never mix + Fragment mode is available at `0.2.0` and later, and not in `0.1.0`. + A consumer pinned to `0.1.0` bootstraps the legacy shape instead — the + preamble plus an empty `## Unreleased` section for entries to land + under — and converts on the pin bump to `0.2.0` or later; never mix refs to adopt it early. 3. **`drills/README.md`** defining what a drill *means* in this repo — each repo names its own @@ -85,14 +85,16 @@ the machinery at all: fetch-depth: 0 - uses: heavy-duty/ceremony/actions/changelog-armed@ - uses: heavy-duty/ceremony/actions/changelog-monotonic@ - # Unreleased: changelog-assembled is not in 0.1.0. Adopt this step - # with the pin bump to the first tag that carries it; never mix - # refs. Green NOTICE on every non-release PR; on a release PR it - # asserts the stamped section is exactly the fragments it consumed. + # changelog-assembled is available at 0.2.0 and later, not in + # 0.1.0. Adopt this step with the pin bump to 0.2.0 or later; + # never mix refs. Green NOTICE on every non-release PR; on a + # release PR it asserts the stamped section is exactly the + # fragments it consumed. - uses: heavy-duty/ceremony/actions/changelog-assembled@ - uses: heavy-duty/ceremony/actions/drill-recorded@ - # Unreleased: runner-isolated is not in 0.1.0. Adopt this step with - # the pin bump to the first tag that carries it; never mix refs. + # runner-isolated is available at 0.2.0 and later, not in 0.1.0. + # Adopt this step with the pin bump to 0.2.0 or later; never mix + # refs. - uses: heavy-duty/ceremony/actions/runner-isolated@ ``` @@ -111,20 +113,59 @@ the machinery at all: self-hosted runner still wants it: the guard's value is the day somebody adds one. - This guide documents `main`. New machinery is marked **unreleased** - here until a release tag ships it. If an action does not exist at the + This guide documents `main`. A marker is the literal token + `**unreleased**` immediately followed by its issue citation (for example, + `(#238)`); whitespace between them may include a line break. A citation is + mandatory, because a marker the guard cannot trace is a marker it cannot + prove false. A token inside an inline-code span is a mention, not a marker; + spans are ignored individually, so unrelated inline code cannot hide one. + A marker for this repository's own issue uses bare `#N`. Cross-repo + citations such as `(crew#293)` satisfy the traceability rule but are not + compared with this repository's release section. The ceremony-only + `marker-check.sh` guard enforces these rules. The release PR that ships the machinery clears, in that same PR, + every marker its own assembled section makes false: the section cites its + issues, each marker cites the same issue, and the release PR's diff is the + one place both halves are visible at once (#221). If an action does not exist at the consumer's pinned tag, adopt it with the pin bump to the first tag that carries it; never mix a moving or newer ref into an otherwise exact-pin consumer. In particular, `0.1.0` carries `changelog-armed`, `changelog-monotonic` and `drill-recorded` plus `docs-sync`, but not `changelog-assembled` or `runner-isolated`. -6. **Labels automation** (optional but recommended): the caller from - [Labels automation](#labels-automation), plus `.github/labels.conf` +6. **`.github/workflows/refs-guard.yml`** — the body-aware guard is its own + caller because `edited` is load-bearing: #200 gained its accidental + closing keyword after the PR opened, with no push to wake ordinary CI. + It costs the consumer one read-only workflow file and no other machinery: + + ```yaml + name: Refs guard + + on: + pull_request: + types: [opened, edited, reopened, synchronize] + + permissions: + contents: read + pull-requests: read + + jobs: + refs-not-closing: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: heavy-duty/ceremony/actions/refs-not-closing@ + ``` + + `refs-not-closing` is available at `0.6.0` and later (#218). Adopt this + caller with that ordinary pin bump; never point only this file at a + moving or newer ref. +7. **Labels automation** (optional but recommended): the two callers from + [Labels automation](#labels-automation) — the event-facing labels + caller and the sweep caller (#209) — plus `.github/labels.conf` (panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the - path→scope globs). Run `workflow_dispatch` once — **this bootstraps - the taxonomy, `release` label included** — and use it again whenever an - operator needs a full-board sweep immediately. -7. **The artifact hook** (optional): `.github/actions/release-artifact/` + path→scope globs). Run the sweep caller's `workflow_dispatch` once — + **this bootstraps the taxonomy, `release` label included** — and use it + again whenever an operator needs a full-board sweep immediately. +8. **The artifact hook** (optional): `.github/actions/release-artifact/` per [The artifact hook](#the-artifact-hook). No hook → the source tarball is the package. @@ -149,8 +190,11 @@ precisely so the machinery is safe to work on sibling `push:` silently kills a door (rig's review catch). - [ ] Swap the guard *script* steps in `ci.yml` for the `uses:` steps in the bootstrap list above (with `fetch-depth: 0` on the checkout). +- [ ] Add `refs-guard.yml` from the bootstrap list with the same ceremony + pin as the release caller and CI guard steps. - [ ] Replace `labels.yml` with the caller from - [Labels automation](#labels-automation); extract + [Labels automation](#labels-automation) and add the sweep caller + `labels-sweep.yml` beside it (#209); extract `.github/labels.conf` from the old reconciler's embedded config — the `panel=` roster line and the repo's `scope:*` rows ([the format](#labels-automation)). `.github/labeler.yml` stays as @@ -289,15 +333,35 @@ build (#15) and incubator's GHCR image push (#16). ## Labels automation -The reusable labels workflow owns two independent jobs: additive path-based -`scope:*` labels and reconciliation of PR state, blockers, handoff, stale -status, and the `needs-ruling` invariants on both surfaces — the bare-flag -check and the 7-day comment-only nudge (#52; the sweep reads that flag and -never writes it). The consumer keeps its path mapping in -`.github/labeler.yml` and its review panel plus scope taxonomy in -`.github/labels.conf`. +The labels automation is two reusable workflows since #209, adopted +together at the same pin: -**Additive means additive** (unreleased — #130): the scope job's only label +- **`labels.yml`** — the event-facing half, called on PR and issue events. + Two jobs: additive path-based `scope:*` labels, and a few-seconds + `trigger` job that wakes the sweep by dispatching the consumer's sweep + caller (`gh workflow run`, plain `GITHUB_TOKEN` — `workflow_dispatch` is + one of the two documented exemptions from the token's no-retrigger rule, + so no PAT anywhere in the path and no loop: the sweep dispatches + nothing). +- **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers, + handoff, stale status, the issue work queue, and the `needs-ruling` + invariants on both surfaces — the bare-flag check and the 7-day + comment-only nudge (#52; the sweep reads that flag and never writes it). + Detached from PR-triggered runs on purpose: all sweeps serialize through + one shared concurrency group, and GitHub records every queue-displaced + run as CANCELLED — harmless (the surviving sweep does its work) until it + rode a `pull_request_target` run and the ❌ landed on that PR's checks + as fake red CI that GitHub refuses to rerun (crew#250: `gh run rerun` + and its `--failed`/`--job` forms all decline a queue-displaced run). + Behind its own caller, a displaced sweep cancels on the + Actions tab, attached to no PR; PR checks show `scope` and the green + `trigger` only. + +The consumer keeps its path mapping in `.github/labeler.yml` and its +review panel plus scope taxonomy in `.github/labels.conf`. + +**Additive means additive** (available at `0.3.0` and later — #130): the +scope job's only label write is `POST /issues/{n}/labels`, which adds the derived scopes and removes nothing, so a label applied while the job runs survives it. Earlier tags used `actions/labeler@v5`, which — even under `sync-labels: false` — replaces the @@ -312,25 +376,11 @@ half-honoured. The reconcile sweep also warns (never sets) when a non-draft PR carries a bare `X.Y.Z` version differing from its base but no `release` label — the merge door would refuse that merge, and the sweep says so first. -The complete caller is: +The complete event-facing caller is: ```yaml name: labels on: - # The consumer owns this cadence (#203). Hourly is the recommended default - # when no other engine drives board state: the cron is then the sweep's only - # wake for four transition classes — a review verdict landing (no - # pull_request_review trigger), blocker:ci-red set/cleared, blocker:conflict - # when another PR merges under this one, and time-based stale / 48h - # claim-reclaim. Events below carry the rest in seconds. Hourly trades ≤1h of - # latency on those four while cutting nominal scheduled sweeps from four an - # hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their - # discovery path. If another engine writes some of those transitions, only - # the classes with no other writer bound the cadence; relax it only as that - # list shrinks. - schedule: [{cron: "0 * * * *"}] - # A manual full-board sweep, including taxonomy bootstrap on a fresh repo. - workflow_dispatch: pull_request_target: # Fork PRs; these carry the head/draft/review facts state:* derives from. # labeled/unlabeled are the handoff wake (state:needs-human confirmed here); @@ -354,18 +404,81 @@ permissions: contents: read checks: read # mergeability/check-rollup read for PR state statuses: read # commit-status rollup read for PR state - actions: read # workflow-run nodes inside the check rollup — private repos do not imply it (incubator#60) + actions: write # the trigger job's `gh workflow run` dispatch of the sweep caller (#209) issues: write pull-requests: write jobs: labels: uses: heavy-duty/ceremony/.github/workflows/labels.yml@ + # If the sweep caller below is named anything but labels-sweep.yml, + # say so: `with: { sweep_workflow: }`. Ceremony's own + # dogfood does (self-labels-sweep.yml). +``` + +And the complete sweep caller, `labels-sweep.yml` beside it — the hourly +cron lives HERE since #209, not on the labels caller: + +```yaml +name: labels-sweep +on: + # The consumer owns this cadence (#203). Hourly is the recommended default + # when no other engine drives board state: the cron is then the sweep's only + # wake for four transition classes — a review verdict landing (no + # pull_request_review trigger on the labels caller), blocker:ci-red + # set/cleared, blocker:conflict when another PR merges under this one, and + # time-based stale / 48h claim-reclaim. The labels caller's events carry the + # rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of + # latency on those four while cutting nominal scheduled sweeps from four an + # hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their + # discovery path. If another engine writes some of those transitions, only + # the classes with no other writer bound the cadence; relax it only as that + # list shrinks. + schedule: [{cron: "0 * * * *"}] + # A manual full-board sweep. A bare dispatch (input default "yes") also + # bootstraps the taxonomy on a fresh repo. The labels caller's trigger job + # wakes this workflow with bootstrap=no on every board event, so the + # declared input is part of the contract: a dispatch naming an undeclared + # input is refused, and the trigger job goes loudly red. + workflow_dispatch: + inputs: + bootstrap: + description: Bootstrap the label taxonomy before sweeping + type: choice + options: ["yes", "no"] + default: "yes" +permissions: + contents: read + checks: read # mergeability/check-rollup read for PR state + statuses: read # commit-status rollup read for PR state + actions: read # workflow-run nodes inside the check rollup — private repos do not imply it (incubator#60) + issues: write + pull-requests: write +jobs: + sweep: + uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@ + # If this repo's PR-facing labels caller is named anything but `labels`, + # pass that name: `with: { pr_workflow_name: }`. The sweep exports + # it as SELF_WORKFLOW so the label machinery's own check entries (scope, + # trigger) never count toward blocker:ci-red — a red trigger means "fix + # the caller", which no PR edit can do (#208 reads it). ``` Naming any permission sets every unnamed permission to `none`. Public repositories allow check data to be read regardless, but a private consumer -needs all three explicit reads above; without them the failure appears as an empty -`state:*` axis on the board rather than a red workflow run. +needs the explicit reads above; without them the failure appears as an empty +`state:*` axis on the board rather than a red workflow run. The labels +caller's `actions: write` is different — it is required everywhere, public +repos included: the trigger job's `gh workflow run` is a write, and without +it every event run goes red at the trigger. + +**The failure mode to know before bumping**: a consumer that bumps its pin +to a #209-carrying tag without adding the sweep caller keeps green-looking +silence nowhere — the trigger job goes **red on every PR and issue event** +(workflow-not-found; likewise on a sweep caller missing its `bootstrap` +input, or a labels caller missing `actions: write`), and event-woken sweeps +stop until the caller lands. That loudness is deliberate: never read +silence, or a green `scope` alone, as health. Make the adoption one atomic +PR — pin bump, sweep caller file, `actions: write` line together. The `issues:` trigger is available at `0.2.0` and later — `0.2.0` is the first tag carrying ceremony#32. A consumer pinned to `0.1.0` omits it. Adopt @@ -386,8 +499,42 @@ mint→`needs-triage` check and `closed` the blocker-closes→`ready` self-heal; the stub and ceremony's own caller stay byte-for-byte identical, the parity #144 established. +The two-caller split (ceremony#209) is available at `0.4.1` and later. A +consumer pinned to `0.4.0` or earlier keeps the previous single-caller +shape — the labels caller carrying the cron, `workflow_dispatch`, and +`actions: read` — and adopts the split at the pin bump to `0.4.1` or +later. Never mix refs to adopt it early. + +The migration is **one atomic PR** with exactly four edits — crew, the +consumer whose displaced-check evidence drove #209 (crew#227, crew#250), +is the worked example; written here against `0.4.1`, the first tag +carrying the split: + +1. **Pin bump, every reference together** ([Version pinning](#version-pinning)): + `0.4.0` → `0.4.1` in the labels caller's `uses:` line **and in every + other ceremony `uses:` in the repo** — crew also pins in + `release.yml` and its `ci.yml` guard steps. A repo on the doctrine + mirror re-runs `docs-sync --fix` in the same PR. +2. **New file `.github/workflows/labels-sweep.yml`** — the sweep caller + stub above, verbatim, `bootstrap` input included (the trigger's + `-f bootstrap=no` dispatch is refused if the input is undeclared). +3. **The hourly cron RELOCATES — it is moved, never copied.** Delete the + `schedule:` block (and the bare `workflow_dispatch:`) from the labels + caller in the same edit that adds the sweep caller. + **Warning**: a consumer that copies the sweep caller and leaves the + old schedule on the labels caller gets DOUBLE sweeps — every cron tick + fires both callers into the one shared `labels-reconcile` group — so + displacement goes **up**, and the fix reads as the bug getting worse. +4. **`actions: write` on the labels caller** — consumers carry + `actions: read` today (crew does); the trigger job's `gh workflow run` + is a write. The sweep caller keeps `actions: read`. + +Bump without the sweep caller and the trigger job goes red on every PR +and issue event — the loud failure mode above — so never split these +four edits across PRs. + `pull_request_target` is intentional: fork PRs need the base repository's -token to write labels. The reusable workflow executes no PR code. It checks +token to write labels. The reusable workflows execute no PR code. They check out only the consumer's base branch and the pinned ceremony implementation. The #52 ruling invariants ride exactly these triggers — but the caller above is no longer the #18 shape, so adopting current triggers is a stub edit, not @@ -398,10 +545,12 @@ quiet repo wears that flag until the backstop cron; a consumer picks them up by pinning `0.3.0` or later, never through mixed refs. `.github/labels.conf` has one mandatory panel setting, one mandatory -`triage-actors` setting, and then zero or more scope rows: +`triage-actors` setting, zero or more optional per-author panel rows, and +then zero or more scope rows: ```text panel=claude-bot example-codex-bot example-grok-bot +panel[example-builder]=example-codex-bot example-grok-bot triage-actors=example-triage-bot scope:cli|C5DEF5|The command-line surface scope:docs|C5DEF5|Documentation @@ -413,6 +562,18 @@ rows only; adding `triage-actors=` is a parse failure, not an ignored setting. Add it at the same pin bump as the `issues:` trigger — `0.2.0` or later — never before it and never through mixed refs. +The optional `panel[]=` rows are available at `0.5.0` and later (#224). A row names +the effective panel for PRs authored by exactly that login — the reconciler +computes that PR's required set from the row, minus the author as always — +and every other author keeps the base `panel=`, which stays mandatory. The +panel is configured or it is the base one: ceremony never infers a reviewer +set from the model behind a login. On any earlier pin a bracketed row is a +**parse failure, not an ignored setting** — the same shape `triage-actors=` +bought at `0.2.0`, but harsher in practice: the reconcile job dies on every +PR event and every sweep until the row is removed, so the whole label board +goes down. Add the row only at or after the pin bump that carries it, never +before it and never through mixed refs. + Both actor lists are whitespace-separated. `triage-actors` names the identities allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly `name|color|description`; blank lines are ignored and extra pipes are refused. @@ -424,41 +585,77 @@ dropped — on Forgejo with `422 Reviewer can't read`, naming the account it is a real failure mode when a panel member is not on the collaborator list, and the sweep will report it rather than sweep blind. There are no comment lines: every non-blank line must be the `panel=` -setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose -is a parse failure, not a comment (rig #13's conversion found this the hard -way — keep the file data only). +setting, a `panel[]=` row, the `triage-actors=` setting, or a label +row, so `#`-prefixed prose is a parse failure, not a comment (rig #13's +conversion found this the hard way — keep the file data only). Core state, blocker, work-queue, and release labels come from ceremony. Scope rows remain consumer-owned because paths and surfaces differ by repository. -After adding the caller and configuration, run `workflow_dispatch` once to -bootstrap labels on a fresh repository. It is also the operator's general -manual full-board sweep — the answer when the board looks wrong now rather -than after the next scheduled cadence: +After adding the callers and configuration, dispatch the sweep caller once +to bootstrap labels on a fresh repository. A bare dispatch is also the +operator's general manual full-board sweep — the answer when the board +looks wrong now rather than after the next scheduled cadence: ```sh -gh workflow run labels.yml -R / +gh workflow run labels-sweep.yml -R / ``` -Ceremony dogfoods the caller under the filename `self-labels.yml`, so the -equivalent command in this repository substitutes that filename. Scheduled -and PR-triggered runs only reconcile; they do not repeatedly upsert the -taxonomy. When a ceremony pin bump adds a core label, bump the pin first and -then re-dispatch `workflow_dispatch`; the scheduled sweep warns when the -pinned taxonomy declares a core label the repository lacks. +Ceremony dogfoods the callers under the filenames `self-labels.yml` and +`self-labels-sweep.yml`, so the equivalent command in this repository +substitutes that filename. Scheduled and trigger-driven runs only +reconcile; they do not repeatedly upsert the taxonomy (the trigger's +dispatch carries `bootstrap=no`). When a ceremony pin bump adds a core +label, bump the pin first and then re-dispatch; the scheduled sweep warns +when the pinned taxonomy declares a core label the repository lacks. ## Doctrine mirror Machinery is consumed by reference — GitHub fetches the workflows and actions above from the pin at run time — but documents have no runtime: an agent reads the working tree it stands in. So the agent-facing doc set -(ceremony's `docs/VENDORED.txt`: AGENTS.md, TRIAGE.md, BUILDER.md, -REVIEWER.md, LABELS.md) is vendored into each consumer at **`.ceremony/`**, +declared by ceremony's `docs/VENDORED.txt` is vendored into each consumer at **`.ceremony/`**, byte-identical to ceremony at the pin, plus a generated `.ceremony/README.md` marking the directory machine-managed. `actions/docs-sync` owns the copy: `--fix` writes it (and deletes what the manifest dropped — mirror means mirror), `--check` re-diffs it in CI on every PR, so a hand edit or a stale pin goes red instead of quietly governing. +`RELEASES.md` joins that mirror with the first tag carrying ceremony#248, +and is available at `0.6.0` and later: consumers add `.ceremony/RELEASES.md` +only with the ordinary pin bump and re-sync, never by copying it ahead of +their pinned doctrine set. + +### Read the manifest, never a copy of it + +Anything on the consumer's side that needs to know *which* documents are +vendored — a re-vendor script, a `docs-sync` equivalent, the task list of a +conversion issue — reads **the pin's `docs/VENDORED.txt`** and never names +the files itself. The manifest is available at the pinned ref from `0.1.0` +and later — it shipped with `actions/docs-sync` itself (ceremony#19), in the +same commit, and that tool has read it rather than a list since — and it is +one path per line, relative to ceremony's root, blank lines ignored: + +```sh +# the vendored doc set at the ref this repo is pinned to +curl -fsSL "https://raw.githubusercontent.com/heavy-duty/ceremony//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 @@ -490,6 +687,26 @@ 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)). diff --git a/docs/UPSTREAM-SYNC.md b/docs/UPSTREAM-SYNC.md new file mode 100644 index 0000000..84bbd8b --- /dev/null +++ b/docs/UPSTREAM-SYNC.md @@ -0,0 +1,267 @@ +# 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. + +## 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. diff --git a/docs/VENDORED.txt b/docs/VENDORED.txt index 10c20a3..ff41f35 100644 --- a/docs/VENDORED.txt +++ b/docs/VENDORED.txt @@ -3,3 +3,4 @@ TRIAGE.md BUILDER.md REVIEWER.md LABELS.md +RELEASES.md diff --git a/drills/0.5.0.md b/drills/0.5.0.md new file mode 100644 index 0000000..6a29c1f --- /dev/null +++ b/drills/0.5.0.md @@ -0,0 +1,58 @@ +# 0.5.0 — drill record + +Run 2026-08-03 by `dan-claude-bot` against the release PR (Refs #233), +candidate branch `build/233-release-0-5-0` on `heavy-duty/ceremony` main at +`0ac3a6f`. + +## Scope ruling — doors unchanged, no disposable-repo rehearsal + +The 0.4.0 drill (drills/0.4.0.md) probed both release doors end-to-end in a +disposable repo; the live 0.4.0 and 0.4.1 releases then exercised the merge +door for real, twice, within the last three days. Measured at `0ac3a6f`, +stated as they are rather than as #233's D3 predicted them at `b18c0bc`: + +- `git diff 0.4.1..main -- .github/workflows/release.yml` — **empty**. No + door logic, no decide table, no publish step moved. +- `git diff 0.4.1..main -- bin` — **empty**. No release tooling moved. +- `git diff 0.4.1..main -- lib` — **not empty**: `lib/ruling.sh` +50/−13, + #226's best-shaped escalation selection, merged this morning as PR #234. + That is the labels-sweep library the reconcilers source; nothing in the + release path reads it. The doors-unchanged ruling rests on the two empty + surfaces above, not on a lib claim this tag can no longer make. + +Re-running a full disposable-repo drill would rehearse machinery this repo +proved in rehearsal at 0.4.0 and in production at both subsequent tags. This +record rests on the standing evidence below and says so honestly. The panel +reviews this claim like any other; if any reviewer rules a full drill owed, +that verdict wins (#233 D3). + +## Standing evidence at the candidate head + +| # | probe | where | result | +|---|---|---|---| +| 1 | decide + merge-door step-replay (dogfood) | `release-exercise / step-replay (dogfood)` on this PR | CI-gating; green required to merge | +| 2 | decide + merge-door step-replay (consumer) | `release-exercise / step-replay (consumer)` on this PR | CI-gating; green required to merge | +| 3 | fragment chain: armed → assemble → monotonic | `release-exercise / fixture-chain` on this PR | CI-gating; green required to merge | +| 4 | the real tree's own guards (armed, monotonic, drill-recorded, self-ref) | `self-guards` on this PR | CI-gating; green required to merge | +| 5 | the 0.5.0 payload's one lib delta: #226's best-shaped selection | `test/ruling.test.sh` in the `test` job at this head, plus claude-bot's independent reproduction in the #234 review round | green — suite coverage, **not** live dogfood; see below | + +Probe 5 makes no live-exercise claim, and the reason is stated so the next +reader does not restore one: the post-merge sweeps (`30811983604`, +`30812172095`, both at `0ac3a6f`) do run the candidate's `lib/ruling.sh` on +the dogfood path, but every #226 call site sits inside `reconcile_ruling`, +whose single caller is behind the `needs-ruling` flag check +(`labels-reconcile.sh:865–866`), and this board has no `needs-ruling` item, +standing or historical — so not one line of the delta this tag carries has +executed live here. Its coverage at this candidate is `test/ruling.test.sh` +(107 checks, including the crew#293 replay and the six regression proofs +named in PR #234) and the #234 round's independent re-run of that proof. +Per drills/README.md — the record is the evidence — and #135's defect +class, a smaller true probe stands where a larger unobserved one would not. + +## Deviations + +No candidate-ref deviation arises: this record stages no scratch caller, so +nothing needs to resolve `CEREMONY_SELF_REF: "0.5.0"` before the tag exists. +The sibling adoption proof is post-tag and crew's: crew#298's pin bump, then +its `panel[]=` rows, then crew's first clean sweep — owned by that +issue's criteria, reported back on #233 per its post-merge criterion. diff --git a/drills/0.6.0.md b/drills/0.6.0.md new file mode 100644 index 0000000..3ba6704 --- /dev/null +++ b/drills/0.6.0.md @@ -0,0 +1,272 @@ +# 0.6.0 — drill record + +Run 2026-08-05 by `cndgrr` against the 0.6.0 release PR (Refs #249), +candidate branch `build/249-release-0-6-0`, canonical candidate SHA +`fb8f8282a9e7b317d4d028f8e8da50501a882d14`. All six probes ran; every row in +the table below was written from its own run. + +## Scope ruling — a full rehearsal is owed, and doors-unchanged is refused + +This record's shape was measured, not chosen. `drills/README.md` allows the +doors-unchanged shape only when all three of its conditions hold at the +candidate head; the first one does not. + +The baseline is the last **rehearsed** tag, never the previous tag: +`drills/0.4.1.md` and `drills/0.5.0.md` are both doors-unchanged records, so +the anchor is **`0.4.0`**, whose record is a full disposable-repo rehearsal, +whose release is published, and after which `main` was re-armed to +`0.4.1-dev` (`84bb1a4`). Condition 3 holds. + +The release path is exactly the output of `.github/scripts/release-path.sh` +at this head — `.github/workflows/release.yml`, `bin/`, `lib/version.sh`, +`lib/decide.sh`, `lib/facts.sh`, `lib/changelog.sh`. Condition 2 holds. + +Condition 1 fails. Measured at this candidate: + +```console +$ git diff 0.4.0..HEAD -- $(.github/scripts/release-path.sh) + .github/workflows/release.yml | 2 +- + lib/changelog.sh | 83 ++++++++++++++++++++++++++++++++++++++--- +``` + +`release.yml`'s two lines are the `CEREMONY_SELF_REF` pin, which the +condition exempts. **`lib/changelog.sh` is not exempt and is not empty**: it +carries `72fa3e0` (the terminal issue-citation rule joining the fragment +guard, #262) and `75a5b68` (one fragment, one diagnosis, #262). That file is +on the release path because the merge door sources it to assemble and read +the release section — this is a door byte, not a neighbouring library, and +the last-rehearsed anchor exists precisely so an accumulated change like +this forces a new rehearsal rather than chaining a third doors-unchanged +assertion off the second. + +So this release owes the disposable-repo rehearsal, and this record is it. + +## Where + +Disposable **private** repo `cndgrr/ceremony-drill-0.6.0`, created +2026-08-05T00:02:58Z. It carries the `docs/CONSUMERS.md` release caller +verbatim (`version-source: file`) over a fragment-mode fixture armed at +`0.6.0-dev`: a preamble-only `CHANGELOG.md`, `changelog.d/README.md` plus +one fragment, and a non-blank `drills/0.6.0.md`. The `release` label was +created there before the first ceremony PR, per the guide's prerequisite. + +**Disposal, as this record's author observed it**: the repository is +**archived** — `PATCH /repos/cndgrr/ceremony-drill-0.6.0` with +`archived: true` returned `true`, and a fresh read afterwards reported +`archived=true private=true`. It is **pending the operator's delete**, which +this builder cannot perform: `delete_repo` is absent from fleet tokens by +doctrine (#135). No delete was attempted and none is claimed. Cleanup gates +nothing — not this PR's ready-for-review, not the panel, not the merge. + +## Candidate-ref deviation + +The pure consumer path cannot resolve this candidate's +`CEREMONY_SELF_REF: "0.6.0"`: that tag is the one this release has not +created yet. No `0.6.0` branch was created on `heavy-duty/ceremony`. + +The scratch caller instead pins `cndgrr/ceremony/.github/workflows/release.yml@drill/0.6.0`. +That fork ref's parent is the canonical candidate SHA +`fb8f8282a9e7b317d4d028f8e8da50501a882d14`, and its one additional commit +(`775b4d1f6485ebdde924979ac2dce536643c6071`) rewrites all three +`CEREMONY_SELF_REF` carriers — `release.yml`, `labels.yml`, +`labels-sweep.yml` — to that same SHA. All runtime machinery in every probe +below was therefore fetched from the 0.6.0 candidate tree. + +Commits pushed to the candidate after `fb8f828` are this record only; the +release path (`.github/scripts/release-path.sh`) is byte-identical at the +canonical SHA and at the final head. + +## Probes + +One row per probe, written from its run. Runs are in +`cndgrr/ceremony-drill-0.6.0`. + +| # | probe | run | result | +|---|---|---|---| +| 1 | merge-door ceremony | 30992108742 (attempt 1) | ✅ exactly one `0.6.0` release; tag equals the merge commit; main re-armed to `0.6.1-dev` | +| 2 | mislabeled ordinary PR | 30991634654 | ✅ green NOTICE no-op; no tag, no release | +| 3 | bare-version PR without `release` | 30991832001 | ✅ refused at decide; no tag, no release | +| 4 | re-run completed ceremony | 30992108742 (attempt 2) | ✅ refused at the nothing-exists assert; the release count stayed one | +| 5 | manual matching tag | 30992258952 | ✅ `0.6.1` published from its own changelog section; main untouched | +| 6 | mismatched tag | 30992310031 | ✅ refused before publication; no `9.9.9` release, and the probe tag was removed afterwards | + +### Probe 5 — a manual tag matching its tree + +Branch `probe5-tag` carried `VERSION` at `0.6.1` and a +`## 0.6.1 — 2026-08-05` section; tag `0.6.1` was pushed at that commit +(`dfd0cfeaca772cf45bcb63a1a639829185510c60`) with a personal token, so it +fired the door — the anti-recursion property probe 1 relies on is exactly +what makes a hand-pushed tag the only way to reach this door. The +`release-on-merge` job skipped and `release-on-tag` ran: the version assert +passed, notes were extracted, the release published. + +The branch, not main, carried the tagged tree on purpose — the tag door +takes no bump step, and pointing it at a side branch proves that without a +bare version ever sitting on main. Observed afterwards: `0.6.1` published +with exactly its own section's bullet, and main still reading `0.6.1-dev`, +untouched by the publish. Two releases now exist, `0.6.0` and `0.6.1`, +neither a draft, neither carrying assets. + +### Probe 6 — a mismatched tag + +Tag `9.9.9` was pushed at the same `0.6.1` commit. The door refused at its +first assert, before notes and before publication: + +```text +tag '9.9.9' does not match the tree's version '0.6.1' — creating nothing. +``` + +Notes, the artifact hook and publish all skipped. `GET /releases` still +returned exactly `0.6.1` and `0.6.0`. The `9.9.9` ref was deleted afterwards +(`DELETE /git/refs/tags/9.9.9`); `GET /git/refs/tags` then listed `0.6.0` +and `0.6.1` only. The probe tag was the operator's artefact, never the +workflow's — the door created nothing, which is the whole assertion. + +### Probe 1 — the merge-door ceremony + +PR #4 (`probe1-ceremony`) bumped `0.6.0-dev` to bare `0.6.0` and stamped +`## 0.6.0 — 2026-08-05`, assembled from the three fixture fragments by the +candidate's own `bin/changelog-assemble` and committed with the deletions +in one commit. The `release` label was applied and confirmed before the +merge. Facts and verdict: + +```text + VER: 0.6.0 + BASE_VER: 0.6.0-dev + RELEASED: + LABELED: yes +ceremony=yes +``` + +Observed after the run: + +- **Exactly one** release: `GET /releases` returned `0.6.0` alone, not a + draft, not a pre-release, zero assets (no artifact hook in the fixture — + the hook step skipped). +- `GET /tags` returned `0.6.0` alone, pointing at + `64d02539f4a20286afc08b9997f0f8a7d1dbfccd`, which is PR #4's merge commit + — the tag names the tree that was reviewed. +- The release body was byte-for-byte the assembled section's bullets: + + ```text + - A second ordinary fragment, written by probe 2 of the 0.6.0 drill (#249). + - An ordinary behavior change, landing under the release label (#249). + - Fragment mode is exercised by the ceremony 0.6.0 drill (#249). + ``` + +- Main re-armed itself: commit `2d0e19a` ("bump main to 0.6.1-dev — a dev + install must not impersonate 0.6.0"), pushed by the job's own token. Main + reads `0.6.1-dev` and `changelog.d/` holds only `README.md`. +- **The anti-recursion property held.** Neither the tag create nor the bump + push started a workflow run — the run list after the ceremony ends at + 30992108742. That is what makes the merge door the release's only chance + to publish, and it is the reason probe 4 below is the door's own guard + rather than a second run's. + +### Probe 4 — a re-run of the completed ceremony + +Re-running 30992108742 as attempt 2 re-decided `ceremony=yes` — the facts +at that merge commit have not changed — and then died at the assert: + +```text +tag '0.6.0' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing. +``` + +Tag, publish and bump all skipped. `GET /releases` still returned exactly +one `0.6.0`. The refusal is loud (the job is red) and creates nothing, which +is the required shape: the assert is what covers a manual tag racing the +merge, not only an operator's stray re-run. + +### Probe 3 — a bare-version PR without the `release` label + +PR #3 (`probe3-bare`) bumped `VERSION` to bare `0.6.0` and carried no label; +the label list was read as empty before merging. The merge run refused at +decide, row 5 of the table: + +```text + VER: 0.6.0 + BASE_VER: 0.6.0-dev + RELEASED: + LABELED: no +the version transitioned ('0.6.0-dev' -> '0.6.0') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR, not a bare push — creating nothing. +``` + +Notes, the assert, tag, hook, publish and bump all skipped; tags and +releases were both still empty afterwards. The merge was then undone and +main re-armed to `0.6.0-dev` before the ceremony probe ran (see Setup). + +### Probe 2 — a mislabeled ordinary PR + +PR #2 (`probe2b-mislabeled`) added one changelog fragment and touched no +version. The `release` label was applied through +`POST /repos/{owner}/{repo}/issues/2/labels` and confirmed present before +the merge. The merge run decided row 1 of the table and published nothing: + +```text + VER: 0.6.0-dev + BASE_VER: 0.6.0-dev + RELEASED: + LABELED: +NOTICE: the version '0.6.0-dev' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish. +ceremony=no +``` + +`RELEASED` and `LABELED` are empty on purpose — the `-dev` rows never +consult them, which is precisely why the label alone cannot ship anything. +Notes, the nothing-exists assert, tag, artifact hook, publish and bump all +skipped; `GET /tags` and `GET /releases` were both empty afterwards. + +An earlier merge (PR #1, run 30991571096) was intended as this probe but +landed **unlabeled**: `gh pr edit --add-label` failed against this repo's +projects-classic GraphQL surface, and the merge went ahead before the +failure was read. That run is a green no-op too, but it is not evidence for +this probe — an unlabeled ordinary merge proves less than a labeled one — +so the probe was re-run as PR #2 with the label applied through the REST +endpoint and verified before merging. Recorded here because the run exists +in the repo's history and a reader will find it. + +## Setup, and the runs that are not probes + +The armed fixture was committed before the caller, so the first door run had +a real parent version to inspect: run **30962040469** is that green baseline +no-op. The probes then ran in the order 2, 3, 1, 4, 5, 6 — the refusals +first, against an armed tree, so the ceremony itself ran last against a +fixture the refusals had already proven intact. + +Three non-probe runs are on the board and are accounted for here rather than +left for a reader to guess at: + +- **30991571096** (green) — PR #1, the unlabeled first attempt at probe 2, + described above. +- **30991892212** (green) — restoring `VERSION` to `0.6.0-dev` after probe + 3's refusal, so the ceremony probe met an armed tree. Row 2 of the table: + the version changed and still ends `-dev`. +- **30991958967** (red) — **a builder error, not a door finding.** An + uncommitted `VERSION` bump left over from staging the ceremony branch rode + along into a setup commit that was meant to touch only the fragments, and + pushed bare `0.6.0` straight to main. The door refused it exactly as it + refused probe 3, by the same row-5 path, and created nothing: tags and + releases were both still empty when the failure was read. Main was re-armed + to `0.6.0-dev` (green run **30992046247**) before the ceremony probe. It is + written down because a red run on a drill repo that the record does not + explain is indistinguishable from a door that failed. + +The fixture's three fragments were also rewritten mid-setup to carry +terminal issue citations. The candidate's own `bin/changelog-assemble` +refused them without one — `fragment 'changelog.d/1.md' has an entry with no +issue citation` — which is #262's rule, one of the two commits on +`lib/changelog.sh` that make this release owe a rehearsal at all. The +fixture had been written before that rule existed. The refusal is the guard +working; the correction is recorded because the fragments the ceremony +consumed are not the fragments the repo was created with. + +## What the rehearsal establishes + +Both doors ran live against the 0.6.0 candidate's own machinery. The merge +door published exactly one release from a labeled ceremony PR, tagged the +reviewed merge commit, and re-armed main itself; it refused a bare push +without a label, refused a re-run of its own completed ceremony, and stayed +a green no-op under a label carried by ordinary work. The tag door published +from a matching manual tag without touching main, and refused a mismatched +one before creating anything. Every refusal created nothing — no tag, no +release, on any of the four refusal paths. diff --git a/drills/README.md b/drills/README.md index f59e961..e25f97f 100644 --- a/drills/README.md +++ b/drills/README.md @@ -66,6 +66,46 @@ record is the only thing that survives the drill, and 0.2.0's record shipped its first draft asserting a cleanup that had not happened (#135) — false evidence in the one file whose job is to be evidence. +A record has one of three shapes. A **rehearsal** records the disposable-repo +run above. **Doors unchanged** records the mechanically checked claim below +when a new rehearsal would execute the same bytes as the last one. **WAIVED** +records a maintainer's judgement under the standing paragraph below. If the +doors-unchanged conditions do not all hold, the release owes a rehearsal or a +waiver; the narrower shape is never a substitute for either. + +## Doors unchanged + +The builder may assert that no disposable-repo rehearsal is owed only when +all three conditions below hold at the candidate head. The release PR's panel +verifies the claim like any other evidence, and if any reviewer rules a full +drill owed, that verdict wins. + +1. `git diff ..HEAD -- ` contains no change + except the `CEREMONY_SELF_REF` pin line in + `.github/workflows/release.yml`. +2. The release path is exactly the output of + `.github/scripts/release-path.sh`: `.github/workflows/release.yml`, `bin/`, + `lib/version.sh`, `lib/decide.sh`, `lib/facts.sh`, and + `lib/changelog.sh`. The script is the record author's copy-paste source; + its contract test keeps this inline list and the workflow's direct and + transitive dependencies in agreement. +3. The last rehearsed tag's own record is a full rehearsal, its release is + published, and `main` was re-armed to `-dev` after it. + +The baseline is the last **rehearsed** tag, never merely the previous tag. A +previous-tag baseline could chain one doors-unchanged assertion from another +while the doors drift a small diff at a time; the last-rehearsed anchor makes +any accumulated release-path change force a new rehearsal. + +The record carries all three measurements as observed at its candidate head, +never copied from an earlier record. `drills/0.4.1.md` and +`drills/0.5.0.md` are the worked examples; the latter's amendment from a +predicted empty `lib/` diff to the observed `lib/ruling.sh` delta is why each +candidate is measured afresh (#233). Re-running its stricter baseline now is +also the path-enumeration proof: `git diff 0.4.0 0.5.0 -- ` 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, diff --git a/lib/attention.sh b/lib/attention.sh new file mode 100644 index 0000000..75124ea --- /dev/null +++ b/lib/attention.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# lib/attention.sh — the `attention` target invariants (#232, epic #229). +# +# Both reconcilers source this file. Pure decisions sit above the divider; +# the impure orchestrator below reads the current label episode and comments +# through the sourcing script's run()/log(). The machine diagnoses only: it +# never sets, clears, retargets or assigns anything on the strength of these +# checks (#229 D2). + +ATTENTION_MARKER_PREFIX='\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" +} diff --git a/lib/changelog.sh b/lib/changelog.sh index 974b4a8..fb229a1 100644 --- a/lib/changelog.sh +++ b/lib/changelog.sh @@ -115,8 +115,22 @@ changelog_fragments() { # splits the measured history: every healthy entry passes untouched, # the drift cluster does not. mawk's length() counts bytes; prose here # is ASCII and the fuzz is acceptable. +# - every entry ends with its issue citation (#262): one '(' group of +# '#N', 'repo#N' or 'owner/repo#N' references separated by ', ', then +# ')', then the final '.' and nothing after it. Stated as style and +# enforced by nobody, this rule cost #255 a full four-bot round on a +# missing '(#248)'; the fragment rules that live in this guard drew no +# review comment at all across the same fifteen PRs. Measured on the +# same normalized entry as the bound above, so a citation that wraps +# onto a continuation line still counts. The repo token is the one the +# filename rule already admits, so '-.md' and its cite +# cannot drift apart; the two halves of one convention. A single group +# is what makes 'terminal' checkable — '(#236, #250).' lands two issues +# in one entry, '(#236) and (#250).' does not. The citation need not +# name the file's own issue: the filename already carries the +# authorizing one, so a fragment may cite the incident beside it. changelog_fragment_problem() { - local file="$1" base problem + local file="$1" base problem kind detail rest base="${file##*/}" if ! printf '%s\n' "$base" | grep -qE '^([a-z][a-z0-9-]*-)?[0-9]+\.md$'; then @@ -157,9 +171,35 @@ changelog_fragment_problem() { return 1 fi + # One walk of the entries, two rules, and the order between them is + # deliberate: an over-long entry anywhere outranks a citation problem + # anywhere, so the length diagnosis a fragment already draws is the same + # one it drew before the citation rule existed. Both read the entry the + # same normalizer produces, which is the whole reason they share a pass. problem="$( awk -v max=300 ' - function flush( len, e) { + # cite_problem — "", "uncited" or "misplaced". Counting the + # groups is what distinguishes the two admitted shapes: one group + # closing the entry passes however many references it carries, and a + # second group anywhere means no single group is terminal. + function cite_problem(e, rest, groups, consumed, group_end) { + rest = e + groups = 0 + consumed = 0 + while (match(rest, /\((([A-Za-z0-9._-]+\/)?[a-z][a-z0-9-]*)?#[0-9]+(, (([A-Za-z0-9._-]+\/)?[a-z][a-z0-9-]*)?#[0-9]+)*\)/)) { + groups++ + group_end = consumed + RSTART + RLENGTH - 1 + consumed = group_end + rest = substr(rest, RSTART + RLENGTH) + } + if (groups == 0) return e ~ /#[0-9]/ ? "misplaced" : "uncited" + if (groups > 1) return "misplaced" + return (group_end == length(e) - 1 && substr(e, group_end + 1) == ".") ? "" : "misplaced" + } + function excerpt(e) { + return length(e) > 60 ? substr(e, 1, 60) "…" : e + } + function flush( len, e, kind) { if (entry == "") return 0 e = entry entry = "" @@ -168,9 +208,15 @@ changelog_fragment_problem() { sub(/ $/, "", e) len = length(e) if (len > max) { - printf "%d\t%s\n", len, substr(e, 1, 60) + reported = 1 + printf "long\t%d\t%s\n", len, excerpt(e) return 1 } + kind = cite_problem(e) + if (kind != "" && cite_kind == "") { + cite_kind = kind + cite_excerpt = excerpt(e) + } return 0 } /^### / { if (flush()) exit; next } @@ -182,12 +228,37 @@ changelog_fragment_problem() { } /^[[:space:]]*$/ { next } entry != "" { entry = entry " " $0 } - END { flush() } + # An exit from a main rule still runs END, so a length row printed + # mid-file would be followed by the citation row it outranks — two + # lines spliced into one diagnosis, the internal protocol row landing + # inside the human-facing excerpt (#262 round 1). The reported flag is + # the same guard the empty-heading walk above uses, for the same + # reason: one diagnosis per fragment is the contract. + END { + if (flush()) exit + if (!reported && cite_kind != "") printf "%s\t\t%s\n", cite_kind, cite_excerpt + } ' "$file" )" if [ -n "$problem" ]; then - printf "fragment '%s' has a %s-character entry — '%s…' — the bound is 300: split it into multiple '- ' entries in this same fragment\n" \ - "$file" "${problem%%$'\t'*}" "${problem#*$'\t'}" + kind="${problem%%$'\t'*}" + rest="${problem#*$'\t'}" + detail="${rest%%$'\t'*}" + rest="${rest#*$'\t'}" + case "$kind" in + long) + printf "fragment '%s' has a %s-character entry — '%s' — the bound is 300: split it into multiple '- ' entries in this same fragment\n" \ + "$file" "$detail" "$rest" + ;; + uncited) + printf "fragment '%s' has an entry with no issue citation — '%s' — end it with the issue it comes from: '(#N).'\n" \ + "$file" "$rest" + ;; + *) + printf "fragment '%s' has an entry whose issue citation is not terminal — '%s' — exactly one '(#N)' group ends the entry, the final '.' after it\n" \ + "$file" "$rest" + ;; + esac return 1 fi } diff --git a/lib/forge-forgejo.sh b/lib/forge-forgejo.sh index 426776a..4331cc6 100644 --- a/lib/forge-forgejo.sh +++ b/lib/forge-forgejo.sh @@ -123,7 +123,7 @@ forge_api() { echo "forge_api: request failed: $endpoint" >&2 return 1 fi - forgejo_http_ok "$hdr" "$endpoint" || return 1 + forgejo_http_ok "$hdr" "GET $endpoint" || return 1 if [ "$have_jq" = true ]; then jq -r "$jqexpr" <"$body"; else cat "$body"; fi return 0 fi @@ -140,7 +140,7 @@ forge_api() { echo "forge_api: request failed: $endpoint (page $page)" >&2 return 1 fi - forgejo_http_ok "$hdr" "$endpoint" || return 1 + forgejo_http_ok "$hdr" "GET $endpoint" || return 1 # 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 @@ -223,9 +223,14 @@ EOF printf '%s\n' "$total" } -# forgejo_http_ok — a non-2xx is named, not +# forgejo_http_ok — 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 }')" @@ -298,25 +303,94 @@ forge_issue_edit() { shift done - if [ "${#add_labels[@]}" -gt 0 ]; then + # 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":[]} -> 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=() + # nameid 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 nameid, 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 @@ -568,6 +642,27 @@ forge_release_exists() { # forge_commit_pulls — the pull requests whose merge produced , 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 — 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)" diff --git a/lib/forge-github.sh b/lib/forge-github.sh index 36307e1..c2e268e 100644 --- a/lib/forge-github.sh +++ b/lib/forge-github.sh @@ -186,6 +186,18 @@ forge_release_exists() { # forge_commit_pulls — the pull requests whose merge produced , 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 — 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)" diff --git a/lib/read.sh b/lib/read.sh new file mode 100644 index 0000000..b3a0a7a --- /dev/null +++ b/lib/read.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# lib/read.sh — the guarded read: an unreadable fact never invents a verdict. +# +# Both reconcilers source this file. The rule is the family's oldest one +# (#101, #95) and it has now been bought twice: the PR surface learned it +# when a permissions denial and a network hiccup left byte-identical +# evidence, and the ISSUE surface learned it when an HTTP 504 whose body is +# GitHub's JSON error object flowed straight into a decision function — +# `gh api` prints that body to stdout *and* exits non-zero, so the payload +# reaching the guards was valid JSON, `.labels[]` came back empty, and the +# sweep wrote `needs-triage` onto a healthy epic and called the pass a +# success (crew#329, #247). +# +# Two helpers: +# - guarded_read — run a read, keep its stderr, report its status +# - read_failure_reason — render that stderr into one bounded log line +# +# `read_failure_reason` is called from both surfaces. `guarded_read` is +# called from the issue surface only, and that is deliberate rather than +# unfinished: labels-reconcile's two capture sites are byte-identical to each +# other and predate this file, and converting them is a cleanup #247 does not +# own. Do not go looking for a labels-side caller — there is none yet. +# +# What the CALLER does with a failed read is the caller's: labels-reconcile +# leaves the PR alone for the pass, issueflow-reconcile skips the issue. The +# one thing neither may do is carry a degraded value into a decision. + +guarded_read() { # $1 = variable to fill, rest = the read; sets READ_FAILURE_STDERR + # The status check and the captured stderr are one operation on purpose: a + # read whose failure is noticed but whose reason is thrown away is what + # #95 had to infer a cause from a control case for — wrongly, it turned + # out (#101 D2). Captured into a file rather than merged into stdout, so + # an unlucky error line can never be read back as the read's own payload. + local __var="$1" __err __out __rc=0 + shift + __err="$(mktemp)" || return 1 + __out="$("$@" 2>"$__err")" || __rc=$? + # shellcheck disable=SC2034 # the out-parameter: every caller reads it beside the status + READ_FAILURE_STDERR="$(cat "$__err")" + rm -f "$__err" + printf -v "$__var" '%s' "$__out" + return "$__rc" +} + +read_failure_reason() { # $1 = captured stderr → one bounded line; pure (#101) + # Verbatim, collapsed, bounded (D3): gh emits multi-line errors and GraphQL + # blobs. Collapsed so the reason is exactly one log line — a raw newline + # inside the captured per-item output block could collide with a matched + # string — and truncated because an unbounded paste per item per sweep is + # noise, and annotations are capped anyway. + local reason + reason="$(printf '%s' "${1-}" | tr '\n' ' ')" + if [ -z "$reason" ]; then + # Empty stderr is itself a fact (D4): a read that failed silently is a + # different observation from a denial, and must not read as one. + echo "no error output" + elif [ "${#reason}" -gt 300 ]; then + printf '%s…\n' "${reason:0:300}" + else + printf '%s\n' "$reason" + fi +} diff --git a/lib/ruling.sh b/lib/ruling.sh index c88b1ab..b527956 100644 --- a/lib/ruling.sh +++ b/lib/ruling.sh @@ -99,18 +99,40 @@ ruling_bare_comment_needed() { # $1 labeled epoch, $2 newest marked-comment epoc fi } +ruling_shape_field_present() { # $1 field label; escalation body on stdin → 0 iff present + # THE one spelling of the field-presence test — the escalation selector + # scores by it and the shape check grades by it, on purpose in one place: + # a selector scoring by one grep while the check grades by another is how + # the crew#293 misgrade would come back from the other side (#226). + # Line-anchored, allowing leading whitespace and Markdown bold + # (`**Options:**` is how the live escalations write them): the labels + # appearing only mid-sentence is not the template. + local field="$1" + grep -Eq "^[[:space:]]*(\*\*)?$field" +} + +ruling_shape_score() { # escalation body on stdin → 0–4, one point per field present + # The selection rule's metric (#226). An empty body scores 0 through the + # same loop — no special case, and never an error. + local body field score=0 + body="$(cat)" + for field in "${RULING_SHAPE_FIELDS[@]}"; do + if ruling_shape_field_present "$field" <<<"$body"; then score=$((score + 1)); fi + done + echo "$score" +} + ruling_shape_decision() { # escalation body on stdin → SHAPED | MALFORMED # Presence only (#50 D4): that `Recommend:` exists is checkable, that the # recommendation is any good is not — no counting options, no parsing the - # prose. Line-anchored, allowing leading whitespace and Markdown bold - # (`**Options:**` is how the live escalations write them): the labels - # appearing only mid-sentence is not the template. The `🧭 needs-ruling` - # header line is deliberately unchecked — it is prose, and an emoji grep - # on an LC_ALL=C runner is a portability trap for zero enforcement value. + # prose. The per-field test is ruling_shape_field_present, shared with the + # selector (#226). The `🧭 needs-ruling` header line is deliberately + # unchecked — it is prose, and an emoji grep on an LC_ALL=C runner is a + # portability trap for zero enforcement value. local body field missing="" body="$(cat)" for field in "${RULING_SHAPE_FIELDS[@]}"; do - grep -Eq "^[[:space:]]*(\*\*)?$field" <<<"$body" || missing="$missing $field" + ruling_shape_field_present "$field" <<<"$body" || missing="$missing $field" done if [ -z "$missing" ]; then echo SHAPED; else echo "MALFORMED$missing"; fi } @@ -153,8 +175,11 @@ ruling_default_decision() { # escalation body on stdin → DEADLINE | HARDB } ruling_nudge_decision() { # $1 now, $2 last real-activity epoch → NUDGE | KEEP - # Real activity only — comments, reviews, commits, never label churn, or - # the sweep would reset its own clock. The nudge needs NO marker: the + # Real activity only, as the caller's surface defines it: the PR sweep + # supplies comments, reviews and commits; the issue sweeps supply comments + # alone — an `assigned` event is the claim clock's fact, and counting it + # let a claim silence a pending ruling (#284). Never label churn, or the + # sweep would reset its own clock. The nudge needs NO marker: the # nudge comment is itself activity, so posting it resets this window and # the rule self-rate-limits to at most one nudge per 7 quiet days. That is # deliberate — a later refactor that "fixes" it by adding a marker breaks @@ -170,17 +195,32 @@ ruling_newest_flag() { # "loginiso8601" lines on stdin → the newest line } ruling_escalation_row() { # $1 setter, $2 labeled epoch; "login epoch url [b64]" lines on stdin - # → "url b64" of the EARLIEST in-window comment by the setter, or nothing. - # Earliest, because the natural shape is escalation-then-flag: the first - # qualifying comment is the escalation itself, later ones are follow-ups. - # The body rides along base64-encoded (#73's shape check reads it); rows - # without the column still resolve, with an empty body. - local setter="$1" labeled="$2" login epoch url b64 best_epoch="" best="" + # → "url b64" of the BEST-SHAPED in-window comment by the setter, or + # nothing: highest ruling_shape_score wins, equal scores break to the + # earliest epoch. Earliest-wins outright was the rule until crew#293 + # (2026-08-02): a builder answered its round whole and escalated 33 + # seconds later — both in one window, the reply earlier — and the sweep + # graded the round reply, told a correct escalation it was malformed, and + # the setter re-posted a shape it had already met. Score resolves both + # orderings; the earliest tiebreak keeps escalation-then-follow-ups + # wherever the scores cannot tell candidates apart, including all-zero. + # An undecodable or absent body scores 0 and stays a legal candidate — + # an unreadable fact never invents a verdict, and never errors the sweep. + # The window and the setter gate candidacy before any score is taken. + local setter="$1" labeled="$2" login epoch url b64 body score + local best_score=-1 best_epoch="" best="" while read -r login epoch url b64; do [ -n "$login" ] || continue [ "$login" = "$setter" ] || continue ruling_accompanies "$epoch" "$labeled" || continue - if [ -z "$best_epoch" ] || [ "$epoch" -lt "$best_epoch" ]; then + if body="$(base64 -d <<<"${b64:-}" 2>/dev/null)"; then + score="$(ruling_shape_score <<<"$body")" + else + score=0 + fi + if [ "$score" -gt "$best_score" ] \ + || { [ "$score" -eq "$best_score" ] && [ "$epoch" -lt "$best_epoch" ]; }; then + best_score="$score" best_epoch="$epoch" best="$url ${b64:-}" fi diff --git a/test/attention.test.sh b/test/attention.test.sh new file mode 100644 index 0000000..410c572 --- /dev/null +++ b/test/attention.test.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +source "$ROOT/test/harness.sh" +# shellcheck source=lib/attention.sh +source "$ROOT/lib/attention.sh" + +check "attention on an unassigned PR is malformed" 0 "MALFORMED_PR" \ + attention_target_decision pr 0 +check "attention on an assigned PR is still malformed" 0 "MALFORMED_PR" \ + attention_target_decision pr 1 +check "attention on an unassigned issue is malformed" 0 "MALFORMED_UNASSIGNED" \ + attention_target_decision issue 0 +check "attention on an assigned issue is healthy" 0 "KEEP" \ + attention_target_decision issue 1 +check "an unknown surface is rejected" 2 "" attention_target_decision discussion 0 + +check "a malformed PR target is commented" 0 "POST" \ + attention_comment_decision MALFORMED_PR "" +check "an unassigned issue target is commented without precedence" 0 "POST" \ + attention_comment_decision MALFORMED_UNASSIGNED "" +check "claimed-unassigned precedence suppresses the second comment" 0 "SUPPRESS" \ + attention_comment_decision MALFORMED_UNASSIGNED claimed-unassigned +check "post-merge-assigned precedence suppresses the second comment" 0 "SUPPRESS" \ + attention_comment_decision MALFORMED_UNASSIGNED post-merge-assigned +check "a healthy target stays silent" 0 "KEEP" \ + attention_comment_decision KEEP "" + +check "the newest labeled event defines the episode" 0 "2026-08-03T12:00:00Z" \ + attention_newest_flag <<'EOF' +2026-08-03T10:00:00Z +2026-08-03T12:00:00Z +2026-08-03T11:00:00Z +EOF +check "the marker names the label episode" 0 \ + '' \ + 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 diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh index 66c0f88..f9bb57f 100644 --- a/test/changelog-armed.test.sh +++ b/test/changelog-armed.test.sh @@ -275,7 +275,7 @@ fragment_tree fragments-dev-flat 1.2.4-dev <<'EOF' - The shipped entry. EOF -printf '%s\n' "- Added fragment mode." >"$TMP/fragments-dev-flat/changelog.d/115.md" +printf '%s\n' "- Added fragment mode (#115)." >"$TMP/fragments-dev-flat/changelog.d/115.md" check "fragment -dev + well-formed flat fragment passes" 0 "fragment mode" \ in_tree fragments-dev-flat @@ -298,6 +298,38 @@ check "fragment mode over-bound refusal names the bound and the split fix" 1 \ "the bound is 300: split it into multiple '- ' entries in this same fragment" \ in_tree fragments-dev-over-bound +# The terminal cite (#262) reds the PR that writes the fragment, through the +# same shared predicate — which is the whole point of the rule living there +# rather than in prose a reviewer has to remember. +fragment_tree fragments-dev-uncited 1.2.4-dev <<'EOF' +# Changelog + +## 1.2.3 — 2026-07-20 + +- The shipped entry. +EOF +printf '%s\n' "- An entry that never learned to cite its issue." \ + >"$TMP/fragments-dev-uncited/changelog.d/115.md" +check "fragment mode refuses an uncited entry, fragment named" 1 \ + "115.md' has an entry with no issue citation" \ + in_tree fragments-dev-uncited +check "fragment mode uncited refusal names the shape to write" 1 \ + "end it with the issue it comes from: '(#N).'" \ + in_tree fragments-dev-uncited + +fragment_tree fragments-dev-misplaced-cite 1.2.4-dev <<'EOF' +# Changelog + +## 1.2.3 — 2026-07-20 + +- The shipped entry. +EOF +printf '%s\n' "- The citation trails the period. (#115)" \ + >"$TMP/fragments-dev-misplaced-cite/changelog.d/115.md" +check "fragment mode refuses a non-terminal citation, fragment named" 1 \ + "115.md' has an entry whose issue citation is not terminal" \ + in_tree fragments-dev-misplaced-cite + fragment_tree fragments-dev-grouped 1.2.4-dev <<'EOF' # Changelog @@ -310,7 +342,7 @@ EOF cat >"$TMP/fragments-dev-grouped/changelog.d/115.md" <<'EOF' ### Changed -- Added fragment mode. +- Added fragment mode (#115). EOF check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \ in_tree fragments-dev-grouped @@ -322,11 +354,11 @@ fragment_tree fragments-dev-mixed 1.2.4-dev <<'EOF' - The shipped entry. EOF -printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-mixed/changelog.d/114.md" +printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-mixed/changelog.d/114.md" cat >"$TMP/fragments-dev-mixed/changelog.d/115.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#115). EOF check "fragment mode refuses mixed shapes with the shared assembler diagnosis" 1 \ "fragment 'changelog.d/115.md' is grouped but fragment 'changelog.d/114.md' is not" \ @@ -342,7 +374,7 @@ EOF cat >"$TMP/fragments-dev-all-grouped-over-flat/changelog.d/115.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#115). EOF check "fragment mode refuses an all-grouped set over a flat published section" 1 \ "changelog.d/115.md' is grouped but newest published section '1.2.3'" \ @@ -357,7 +389,7 @@ fragment_tree fragments-dev-flat-over-grouped 1.2.4-dev <<'EOF' - The shipped entry. EOF -printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md" +printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md" check "fragment mode refuses a flat set over a grouped published section" 1 \ "changelog.d/115.md' is flat but newest published section '1.2.3'" \ in_tree fragments-dev-flat-over-grouped @@ -376,14 +408,14 @@ printf '%s\n' "grouped" >"$TMP/fragments-dev-flip/changelog.d/shape" cat >"$TMP/fragments-dev-flip/changelog.d/115.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#115). EOF check "fragment mode: 'grouped' sentinel admits the flip tree over a flat published section" 0 \ "fragment mode" in_tree fragments-dev-flip # Post-flip drift is refused on its own PR: a flat probe fragment atop the # flip tree goes red — beside grouped fragments the mix rule names it first. -printf '%s\n' "- Flat probe." >"$TMP/fragments-dev-flip/changelog.d/116.md" +printf '%s\n' "- Flat probe (#116)." >"$TMP/fragments-dev-flip/changelog.d/116.md" check "fragment mode: a flat probe atop the flip tree is refused" 1 \ "changelog.d/115.md' is grouped but fragment 'changelog.d/116.md' is not" \ in_tree fragments-dev-flip @@ -393,7 +425,7 @@ rm "$TMP/fragments-dev-flip/changelog.d/116.md" # holds the shape: an all-flat set under 'grouped' is refused, sentinel # named — the published-section inference never gets a say. rm "$TMP/fragments-dev-flip/changelog.d/115.md" -printf '%s\n' "- Flat probe." >"$TMP/fragments-dev-flip/changelog.d/116.md" +printf '%s\n' "- Flat probe (#116)." >"$TMP/fragments-dev-flip/changelog.d/116.md" check "fragment mode: a flat set under the 'grouped' sentinel refused, sentinel named" 1 \ "changelog.d/116.md' is flat but 'changelog.d/shape' declares grouped" \ in_tree fragments-dev-flip @@ -417,7 +449,7 @@ EOF cat >"$TMP/fragments-dev-no-published/changelog.d/115.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#115). EOF check "fragment mode accepts a consistent set with no published section" 0 \ "fragment mode" in_tree fragments-dev-no-published @@ -484,7 +516,7 @@ check "fragment bare + stamped section + consumed directory passes" 0 \ "fragment mode" in_tree fragments-bare-stamped cp -R "$TMP/fragments-bare-stamped" "$TMP/fragments-bare-survivor" -printf '%s\n' "- This entry was not consumed." \ +printf '%s\n' "- This entry was not consumed (#115)." \ >"$TMP/fragments-bare-survivor/changelog.d/115.md" check "fragment bare refuses and lists surviving fragments" 1 \ "these fragments were not consumed: changelog.d/115.md" \ diff --git a/test/changelog-assemble.test.sh b/test/changelog-assemble.test.sh index de008b9..f87a00f 100644 --- a/test/changelog-assemble.test.sh +++ b/test/changelog-assemble.test.sh @@ -55,13 +55,13 @@ tree flat-one <"$TMP/flip/changelog.d/shape" frag flip 40.md <<'EOF' ### Added -- Forty landed. +- Forty landed (#40). EOF check "sentinel: the flip release assembles grouped over a flat published section" 0 \ "consumed 1 fragment" in_tree flip 0.2.0 2026-07-24 check "sentinel: the written flip section is exact" 0 "" \ assert_file "$TMP/flip/CHANGELOG.md" \ - $'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n### Added\n\n- Forty landed.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.' + $'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n### Added\n\n- Forty landed (#40).\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.' check "sentinel: changelog.d/shape survives consumption" 0 "" \ test -e "$TMP/flip/changelog.d/shape" @@ -171,7 +171,7 @@ $BASE_CHANGELOG EOF printf 'grouped\n' >"$TMP/flip-flat-frag/changelog.d/shape" frag flip-flat-frag 41.md <<'EOF' -- Flat forty-one. +- Flat forty-one (#41). EOF check "sentinel: a flat fragment under 'grouped' refuses, sentinel named" 1 \ "changelog.d/shape' declares grouped" in_tree flip-flat-frag 0.2.0 2026-07-24 @@ -183,7 +183,7 @@ printf 'Grouped\n' >"$TMP/flip-malformed/changelog.d/shape" frag flip-malformed 42.md <<'EOF' ### Added -- Forty-two. +- Forty-two (#42). EOF check "sentinel: a malformed sentinel refuses, file named" 1 \ "changelog.d/shape' declares neither shape" in_tree flip-malformed 0.2.0 2026-07-24 @@ -196,13 +196,45 @@ tree preamble-only <<'EOF' Only preamble so far. EOF frag preamble-only 1.md <<'EOF' -- The first entry ever. +- The first entry ever (#1). EOF check "a changelog with no section yet gets the section after the preamble" 0 "" \ in_tree preamble-only 0.1.0 2026-07-24 check "preamble-only write is exact" 0 "" \ assert_file "$TMP/preamble-only/CHANGELOG.md" \ - $'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever.' + $'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever (#1).' + +# --- the fragment predicate at release time (#262) --------------------------- + +# The cite rule joins changelog_fragment_problem, so it binds both callers: +# the arming guard at PR time and this assembler at release time. Asserted +# rather than assumed — a release that publishes an uncited entry is the +# failure the PR-time guard exists to have already caught. + +tree uncited-release <"$TMP/flagged/NOTES.md" -printf -- '- Flagged entry.\n' >"$TMP/flagged/frags/2.md" +printf -- '- Flagged entry (#2).\n' >"$TMP/flagged/frags/2.md" check "--changelog and --dir override the defaults" 0 "" \ "$TOOL" 0.2.0 2026-07-24 --changelog "$TMP/flagged/NOTES.md" --dir "$TMP/flagged/frags" check "the flag-driven write landed in the named changelog" 0 "" \ - grep -qF -- "- Flagged entry." "$TMP/flagged/NOTES.md" + grep -qF -- "- Flagged entry (#2)." "$TMP/flagged/NOTES.md" # --- refusals: each names the file responsible ------------------------------- @@ -292,7 +324,7 @@ tree stray-txt <"$TMP/no-changelog/changelog.d/2.md" +printf -- '- Entry (#2).\n' >"$TMP/no-changelog/changelog.d/2.md" check "a missing changelog refuses" 1 "no such file" \ in_tree no-changelog 0.2.0 @@ -404,12 +436,12 @@ frag round-trip 30.md <<'EOF' ### Added - Thirty — wraps onto a - continuation line with a naïve café. + continuation line with a naïve café (#30). EOF frag round-trip 29.md <<'EOF' ### Fixed -- Fixed twenty-nine. +- Fixed twenty-nine (#29). EOF CHECKED="$(in_tree round-trip 0.2.0 2026-07-24 --check)" check "round trip: write mode succeeds after --check" 0 "" \ diff --git a/test/changelog-assembled.test.sh b/test/changelog-assembled.test.sh index d43e18b..79a69f1 100644 --- a/test/changelog-assembled.test.sh +++ b/test/changelog-assembled.test.sh @@ -58,8 +58,8 @@ Preamble prose belongs to no section. - The shipped entry. EOF printf '0.1.1-dev\n' >"$dir/VERSION" - printf -- '- Twelve landed.\n' >"$dir/changelog.d/12.md" - printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$dir/changelog.d/9.md" + printf -- '- Twelve landed (#12).\n' >"$dir/changelog.d/12.md" + printf -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\n' >"$dir/changelog.d/9.md" commit_base "$name" } @@ -86,8 +86,8 @@ check "faithful flat ceremony: the section is byte-for-byte the assembly" 0 \ seed_flat faithful-grouped sed -i '/^- The shipped entry/i ### Fixed\\\n' "$TMP/faithful-grouped/CHANGELOG.md" -printf -- '### Fixed\n\n- Fixed twenty-one.\n' >"$TMP/faithful-grouped/changelog.d/21.md" -printf -- '### Added\n\n- Added twenty.\n\n### Docs\n\n- Docs twenty.\n' >"$TMP/faithful-grouped/changelog.d/20.md" +printf -- '### Fixed\n\n- Fixed twenty-one (#21).\n' >"$TMP/faithful-grouped/changelog.d/21.md" +printf -- '### Added\n\n- Added twenty (#20).\n\n### Docs\n\n- Docs twenty (#20).\n' >"$TMP/faithful-grouped/changelog.d/20.md" rm "$TMP/faithful-grouped/changelog.d/12.md" "$TMP/faithful-grouped/changelog.d/9.md" git -C "$TMP/faithful-grouped" add -A git -C "$TMP/faithful-grouped" commit -qm regroup @@ -108,7 +108,7 @@ check "the stamp's date never enters the comparison" 0 "byte-for-byte" \ # --- inapplicable trees: green NOTICE, never a silent skip ------------------- seed_flat ordinary-add -printf -- '- Thirteen incoming.\n' >"$TMP/ordinary-add/changelog.d/13.md" +printf -- '- Thirteen incoming (#13).\n' >"$TMP/ordinary-add/changelog.d/13.md" commit_head ordinary-add check "-dev PR adding a fragment: green NOTICE" 0 "NOTICE" run ordinary-add base @@ -195,8 +195,8 @@ Preamble prose belongs to no section. ## 0.2.0 — 2026-07-24 - Nine landed, and its prose wraps onto a - continuation line. -- Twelve landed. + continuation line (#9). +- Twelve landed (#12). ## 0.1.0 — 2026-07-01 @@ -210,7 +210,7 @@ check "re-ordered entries fail" 1 "NOT what the fragments" run reordered base # directory is not — only the survivor refusal fires. seed_flat survivor ceremony survivor 0.2.0 2026-07-24 -printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$TMP/survivor/changelog.d/9.md" +printf -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\n' >"$TMP/survivor/changelog.d/9.md" commit_head survivor check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \ run survivor base @@ -318,7 +318,7 @@ init_repo env-tree mkdir -p "$TMP/env-tree/frags" printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/env-tree/NOTES.md" printf '0.1.1-dev\n' >"$TMP/env-tree/VERSION" -printf -- '- Flagged entry.\n' >"$TMP/env-tree/frags/2.md" +printf -- '- Flagged entry (#2).\n' >"$TMP/env-tree/frags/2.md" git -C "$TMP/env-tree" add -A git -C "$TMP/env-tree" commit -qm base git -C "$TMP/env-tree" branch fixture-base diff --git a/test/changelog.test.sh b/test/changelog.test.sh index 0c9979d..47e9ac9 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -182,11 +182,11 @@ printf 'marker\n' >"$FRAG/README.md" check "fragments: README.md is the directory marker, never a fragment" 0 "" \ changelog_fragments "$FRAG" -printf -- '- Two.\n' >"$FRAG/2.md" -printf -- '- Nine.\n' >"$FRAG/9.md" -printf -- '- Ten.\n' >"$FRAG/10.md" -printf -- '- Cross.\n' >"$FRAG/ceremony-14.md" -printf -- '- Local fourteen.\n' >"$FRAG/14.md" +printf -- '- Two (#2).\n' >"$FRAG/2.md" +printf -- '- Nine (#9).\n' >"$FRAG/9.md" +printf -- '- Ten (#10).\n' >"$FRAG/10.md" +printf -- '- Cross (#14).\n' >"$FRAG/ceremony-14.md" +printf -- '- Local fourteen (#14).\n' >"$FRAG/14.md" assert_fragments_order() { local expected="$1" actual @@ -205,19 +205,19 @@ check "fragments: issue number descending (numeric, 10 before 9), filename tie-b PF="$TMP/frag-problems" mkdir -p "$PF" -printf -- '- Fine.\n' >"$PF/7.md" +printf -- '- Fine (#7).\n' >"$PF/7.md" check "fragment predicate: a flat fragment passes" 0 "" \ changelog_fragment_problem "$PF/7.md" cat >"$PF/8.md" <<'EOF' ### Added -- Grouped fine. +- Grouped fine (#8). EOF check "fragment predicate: a grouped fragment passes" 0 "" \ changelog_fragment_problem "$PF/8.md" -printf -- '- Cross-repo.\n' >"$PF/ceremony-14.md" +printf -- '- Cross-repo (#14).\n' >"$PF/ceremony-14.md" check "fragment predicate: a cross-repo name passes" 0 "" \ changelog_fragment_problem "$PF/ceremony-14.md" @@ -274,14 +274,14 @@ check "length bound: the refusal names the bound and the split fix" 1 \ "the bound is 300: split it into multiple '- ' entries in this same fragment" \ changelog_fragment_problem "$PF/30.md" -printf -- '- %s\n' "$(mkchars 300)" >"$PF/31.md" +printf -- '- %s (#31).\n' "$(mkchars 293)" >"$PF/31.md" check "length bound: an entry of exactly 300 passes" 0 "" \ changelog_fragment_problem "$PF/31.md" { - printf -- '- %s\n' "$(mkchars 150)" - printf -- '- %s\n' "$(mkchars 150)" - printf -- '- %s\n' "$(mkchars 150)" + printf -- '- %s (#32).\n' "$(mkchars 143)" + printf -- '- %s (#32).\n' "$(mkchars 143)" + printf -- '- %s (#32).\n' "$(mkchars 143)" } >"$PF/32.md" check "length bound: several within-bound entries pass though the file totals over 300" 0 "" \ changelog_fragment_problem "$PF/32.md" @@ -291,14 +291,14 @@ check "length bound: several within-bound entries pass though the file totals ov printf ' %s\n' "$(mkchars 50)" printf ' %s\n' "$(mkchars 50)" printf ' %s\n' "$(mkchars 50)" - printf ' %s\n' "$(mkchars 50)" + printf ' %s (#33).\n' "$(mkchars 50)" } >"$PF/33.md" check "length bound: a ~250-character entry wrapped over four continuation lines passes" 0 "" \ changelog_fragment_problem "$PF/33.md" { printf '### Added\n\n' - printf -- '- %s\n' "$(mkchars 300)" + printf -- '- %s (#34).\n' "$(mkchars 293)" } >"$PF/34.md" check "length bound: a '### ' heading counts toward no entry — 300 under it still passes" 0 "" \ changelog_fragment_problem "$PF/34.md" @@ -331,6 +331,151 @@ check "length bound: published sections stay unvalidated — 0.3.0's over-bound check "length bound: published sections stay unvalidated — 0.2.0 reds nothing either" 0 "" \ changelog_section_problem "$ROOT/CHANGELOG.md" 0.2.0 +# --- the terminal issue cite (#262) ------------------------------------------ + +# cite_case — a fragment holding exactly the given +# lines, so a case reads as the entry it is about. +cite_case() { + local num="$1" + shift + printf '%s\n' "$@" >"$PF/$num.md" +} + +cite_case 40 '- Local (#262).' +check "cite: the canonical '(#N).' passes" 0 "" \ + changelog_fragment_problem "$PF/40.md" +cite_case 41 '- Sibling repo (crew#309).' +check "cite: a sibling-repo reference passes" 0 "" \ + changelog_fragment_problem "$PF/41.md" +cite_case 42 '- Fully qualified (heavy-duty/crew#309).' +check "cite: an owner/repo reference passes" 0 "" \ + changelog_fragment_problem "$PF/42.md" +cite_case 43 '- Two issues, one entry (#236, #250).' +check "cite: one group carrying two references passes" 0 "" \ + changelog_fragment_problem "$PF/43.md" + +# The cite is measured on the normalized entry, so a citation that lands on +# a continuation line still closes the entry — the #167 lesson, repeated: +# wrapping alone must never red a compliant entry. +cite_case 44 '- An entry whose prose wraps onto a' ' continuation line, cite and all (#262).' +check "cite: a citation on a continuation line passes — the entry is normalized first" 0 "" \ + changelog_fragment_problem "$PF/44.md" + +cite_case 45 '### Added' '' '- Added one (#101).' '- Added two (#102).' '' \ + '### Changed' '' '- Changed one (#103).' '' '### Fixed' '' '- Fixed one (#104).' +check "cite: a grouped fragment, three headings, every entry compliant, passes" 0 "" \ + changelog_fragment_problem "$PF/45.md" + +# The two diagnoses are distinct by construction (D5): a builder who reads +# one must not be told the other's fix. +cite_case 50 '- No cite here.' +check "cite: an entry with no reference at all is refused" 1 \ + "50.md' has an entry with no issue citation" \ + changelog_fragment_problem "$PF/50.md" +check "cite: the uncited refusal names the shape to write" 1 \ + "end it with the issue it comes from: '(#N).'" \ + changelog_fragment_problem "$PF/50.md" + +cite_case 51 '- Cite before the period. (#262)' +check "cite: a citation trailing the period is refused — the 248.md shape" 1 \ + "51.md' has an entry whose issue citation is not terminal" \ + changelog_fragment_problem "$PF/51.md" +check "cite: the misplaced refusal names the shape to write" 1 \ + "exactly one '(#N)' group ends the entry, the final '.' after it" \ + changelog_fragment_problem "$PF/51.md" + +cite_case 52 '- Trailing prose (#262) and then more.' +check "cite: a citation with prose after it is refused" 1 \ + "has an entry whose issue citation is not terminal" \ + changelog_fragment_problem "$PF/52.md" + +cite_case 53 '- Two groups (#236) and (#250).' +check "cite: two citation groups are refused — one terminal group, or none (D2)" 1 \ + "has an entry whose issue citation is not terminal" \ + changelog_fragment_problem "$PF/53.md" + +cite_case 54 '- Bad token (#abc).' +check "cite: a reference with no digits is no reference" 1 \ + "has an entry with no issue citation" \ + changelog_fragment_problem "$PF/54.md" +cite_case 55 '- Bad token (#).' +check "cite: an empty reference is no reference" 1 \ + "has an entry with no issue citation" \ + changelog_fragment_problem "$PF/55.md" + +# The citation need not name the file's own issue (D3): the filename already +# carries the authorizing one, so an entry may cite the incident beside it. +cite_case 56 '- Cites another issue entirely (#101).' +check "cite: the reference need not match the filename" 0 "" \ + changelog_fragment_problem "$PF/56.md" + +# Ordering: the bound outranks the cite across the whole fragment, so a +# fragment that reds today draws the diagnosis it drew before this rule +# existed. The uncited entry comes FIRST here on purpose — the other order +# would pass whatever the precedence is. +{ + printf -- '- Uncited, and it comes first.\n' + printf -- '- %s\n' "$(mkchars 301)" +} >"$PF/57.md" +check "cite: an over-bound entry outranks an earlier uncited one" 1 \ + "57.md' has a 301-character entry" \ + changelog_fragment_problem "$PF/57.md" +assert_one_diagnosis() { + local count + count="$(changelog_fragment_problem "$PF/$1.md" | wc -l)" + [ "$count" = 1 ] || { + printf 'wanted one diagnosis, got %s\n' "$count" + return 1 + } +} +check "cite: the outranked citation problem is not reported beside it" 0 "" \ + assert_one_diagnosis 57 + +# The axis 57.md cannot test: its over-bound entry is LAST, so the only +# flush that can print is END's, which exits immediately. A flush from a +# main rule exits too — but awk runs END on the way out, so the citation +# row a mid-file length row outranks would print after it unless END is +# guarded. Both ways out of the walk, a bullet and a heading. +{ + printf -- '- Uncited, and it comes first.\n' + printf -- '- %s\n' "$(mkchars 301)" + printf -- '- A later entry the walk never reaches (#57).\n' +} >"$PF/58.md" +check "cite: an over-bound entry that is not the last one still reports the bound" 1 \ + "58.md' has a 301-character entry" \ + changelog_fragment_problem "$PF/58.md" +check "cite: and it is still one diagnosis, not the protocol row spliced into it" 0 "" \ + assert_one_diagnosis 58 +{ + printf '### Fixed\n' + printf -- '- Misplaced, and it comes first. (#59)\n' + printf -- '- %s\n' "$(mkchars 301)" + printf '### Changed\n' + printf -- '- The heading is the other way out of the walk (#59).\n' +} >"$PF/59.md" +check "cite: a heading after the over-bound entry is the same one diagnosis" 1 \ + "59.md' has a 301-character entry" \ + changelog_fragment_problem "$PF/59.md" +check "cite: the misplaced row does not ride along with it either" 0 "" \ + assert_one_diagnosis 59 + +# Published sections keep their pre-rule prose (D4): reddening history is a +# wall, not a guard. Every shipped section predates the cite. +check "cite: a published section with uncited entries still reds nothing" 0 "" \ + changelog_section_problem "$ROOT/CHANGELOG.md" 0.3.0 + +# The fragments this repo carries right now are the rule's own first +# constituency — the guard is worth nothing if the tree it ships in fails it. +assert_tree_fragments() { + local f + while IFS= read -r f; do + [ -n "$f" ] || continue + changelog_fragment_problem "$f" || return 1 + done <<<"$(changelog_fragments "$ROOT/changelog.d")" +} +check "cite: every fragment in this tree passes the rule it ships" 0 "" \ + assert_tree_fragments + # --- the assembler (#114) ---------------------------------------------------- assert_assemble() { @@ -347,11 +492,11 @@ mkdir -p "$AF" printf 'marker\n' >"$AF/README.md" cat >"$AF/3.md" <<'EOF' - Three — an em dash, and prose that - wraps onto a continuation line. + wraps onto a continuation line (#3). EOF -printf -- '- Ten.\n- Ten again.\n' >"$AF/10.md" +printf -- '- Ten (#10).\n- Ten again (#10).\n' >"$AF/10.md" check "assemble: flat fragments, newest issue first, prose verbatim" 0 "" \ - assert_assemble "$AF" $'- Ten.\n- Ten again.\n- Three — an em dash, and prose that\n wraps onto a continuation line.' + assert_assemble "$AF" $'- Ten (#10).\n- Ten again (#10).\n- Three — an em dash, and prose that\n wraps onto a continuation line (#3).' check "assemble: an empty directory is empty output — refusing is the caller's stance" 0 "" \ changelog_assemble "$TMP/no-such-dir" @@ -361,36 +506,36 @@ mkdir -p "$AG" cat >"$AG/21.md" <<'EOF' ### Fixed -- Fixed twenty-one. +- Fixed twenty-one (#21). EOF cat >"$AG/20.md" <<'EOF' ### Added -- Added twenty. +- Added twenty (#20). ### Docs -- Docs twenty. +- Docs twenty (#20). EOF cat >"$AG/19.md" <<'EOF' ### Security -- Security nineteen. +- Security nineteen (#19). ### Added -- Added nineteen. +- Added nineteen (#19). EOF check "assemble: canonical group order, unnamed group appended, fragment order inside a group" 0 "" \ - assert_assemble "$AG" $'### Added\n\n- Added twenty.\n- Added nineteen.\n\n### Fixed\n\n- Fixed twenty-one.\n\n### Security\n\n- Security nineteen.\n\n### Docs\n\n- Docs twenty.' + assert_assemble "$AG" $'### Added\n\n- Added twenty (#20).\n- Added nineteen (#19).\n\n### Fixed\n\n- Fixed twenty-one (#21).\n\n### Security\n\n- Security nineteen (#19).\n\n### Docs\n\n- Docs twenty (#20).' AM="$TMP/assemble-mixed" mkdir -p "$AM" -printf -- '- Flat five.\n' >"$AM/5.md" +printf -- '- Flat five (#5).\n' >"$AM/5.md" cat >"$AM/6.md" <<'EOF' ### Added -- Grouped six. +- Grouped six (#6). EOF check "assemble: mixed shapes refused, grouped side named" 1 "6.md" \ changelog_assemble "$AM" @@ -400,11 +545,11 @@ check "assemble: mixed shapes refused, flat side named too" 1 "5.md" \ AX="$TMP/assemble-selfmixed" mkdir -p "$AX" cat >"$AX/7.md" <<'EOF' -- Ungrouped lead. +- Ungrouped lead (#7). ### Added -- Grouped follow. +- Grouped follow (#7). EOF check "assemble: one fragment mixing both shapes is refused, file named" 1 \ "'$AX/7.md' mixes grouped headings and ungrouped bullets" \ @@ -429,14 +574,14 @@ cat >"$SHAPE_CHANGELOG" <<'EOF' - Older section is grouped. EOF -printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md" +printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md" check "shape: flat set matches newest flat published section" 0 "" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF check "shape: grouped set names its conflict with newest flat published section" 1 \ "fragment '$SHAPE_DIR/1.md' is grouped but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is flat" \ @@ -451,7 +596,7 @@ cat >"$SHAPE_CHANGELOG" <<'EOF' - Newest section is grouped. EOF -printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md" +printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md" check "shape: flat set names its conflict with newest grouped published section" 1 \ "fragment '$SHAPE_DIR/1.md' is flat but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is grouped" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" @@ -459,7 +604,7 @@ check "shape: flat set names its conflict with newest grouped published section" cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF check "shape: grouped set matches newest grouped published section" 0 "" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" @@ -484,7 +629,7 @@ EOF cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF printf 'grouped\n' >"$SHAPE_DIR/shape" check "shape: 'grouped' sentinel admits a grouped set over a flat published section" 0 "" \ @@ -492,7 +637,7 @@ check "shape: 'grouped' sentinel admits a grouped set over a flat published sect check "shape: the sentinel binds with no changelog at all — the assembler's call" 0 "" \ changelog_shape_problem "" "$SHAPE_DIR" -printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md" +printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md" check "shape: flat fragment under a 'grouped' sentinel refused, fragment and sentinel named" 1 \ "fragment '$SHAPE_DIR/1.md' is flat but '$SHAPE_DIR/shape' declares grouped" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" @@ -512,14 +657,14 @@ check "shape: 'flat' sentinel admits a flat set over a grouped published section cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF check "shape: grouped fragment under a 'flat' sentinel refused, fragment and sentinel named" 1 \ "fragment '$SHAPE_DIR/1.md' is grouped but '$SHAPE_DIR/shape' declares flat" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" printf 'grouped\n' >"$SHAPE_DIR/shape" -printf -- '- Flat two.\n' >"$SHAPE_DIR/2.md" +printf -- '- Flat two (#2).\n' >"$SHAPE_DIR/2.md" check "shape: a mixed set is refused regardless of the sentinel" 1 \ "fragment '$SHAPE_DIR/1.md' is grouped but fragment '$SHAPE_DIR/2.md' is not" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" @@ -558,7 +703,7 @@ printf 'grouped\n' >"$SHAPE_DIR/shape" cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF assert_fragments_exclude_sentinel() { local out @@ -579,17 +724,17 @@ printf 'grouped\n' >"$AS/shape" cat >"$AS/30.md" <<'EOF' ### Fixed -- Fixed thirty. +- Fixed thirty (#30). EOF cat >"$AS/31.md" <<'EOF' ### Added -- Added thirty-one. +- Added thirty-one (#31). EOF check "assemble: the sentinel never assembles, and canonical order holds under it" 0 "" \ - assert_assemble "$AS" $'### Added\n\n- Added thirty-one.\n\n### Fixed\n\n- Fixed thirty.' + assert_assemble "$AS" $'### Added\n\n- Added thirty-one (#31).\n\n### Fixed\n\n- Fixed thirty (#30).' rm "$AS/30.md" "$AS/31.md" -printf -- '- Flat probe.\n' >"$AS/29.md" +printf -- '- Flat probe (#29).\n' >"$AS/29.md" check "assemble: a flat set under a 'grouped' sentinel refuses to assemble" 1 \ "declares grouped" \ changelog_assemble "$AS" diff --git a/test/docs-sync.test.sh b/test/docs-sync.test.sh index 129f663..abec030 100644 --- a/test/docs-sync.test.sh +++ b/test/docs-sync.test.sh @@ -19,6 +19,14 @@ SCRIPT="$ROOT/actions/docs-sync/docs-sync.sh" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT +# The real manifest is asserted by test/vendored.test.sh, not here (#251 D4). +# A `grep -Fx RELEASES.md` row lived at this spot from #248's review round, +# binding the promise to the one file that had nearly been missed. It was the +# hardcoded list the manifest exists to abolish, one layer down: two spellings +# of "the manifest is right" is exactly the drift it prevents. Its intent — +# every root doctrine file is declared, RELEASES.md included — is now a +# closed-world guard case, which the next file inherits for free. + # --- fixture builders -------------------------------------------------------- # The main fake ceremony tree: three manifest entries, one in a subdirectory diff --git a/test/forge-backends.test.sh b/test/forge-backends.test.sh index d59f39e..d304218 100644 --- a/test/forge-backends.test.sh +++ b/test/forge-backends.test.sh @@ -257,9 +257,20 @@ 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" @@ -285,19 +296,111 @@ 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 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" +# 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" -# 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" +# 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" # Adding takes names directly — no lookup, one request. FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes @@ -672,6 +775,21 @@ 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 @@ -754,5 +872,14 @@ 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 diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 858d1cd..918b635 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -28,6 +28,16 @@ printf '%s\n' 'panel=one' >"$TMP/missing.conf" check "missing triage actors fails loudly" 1 "missing triage-actors=" load_issueflow_config "$TMP/missing.conf" printf '%s\n' 'triage-actors=one' 'triage-actors=two' >"$TMP/duplicate.conf" check "duplicate triage actors fails loudly" 1 "duplicate triage-actors" load_issueflow_config "$TMP/duplicate.conf" +# A panel[]= row (#224 D8) must not take the issue board down: this +# loader ignores every line that is not triage-actors=. That tolerance was +# incidental; this row makes it deliberate, so a future tightening cannot +# break the sweep as a side effect. +printf '%s\n' \ + 'panel=one two' \ + 'panel[builder-z]=two' \ + 'triage-actors=triage-one' >"$TMP/bracketed.conf" +check "a per-author panel row is tolerated by the issue-flow loader" 0 "" \ + load_issueflow_config "$TMP/bracketed.conf" # The dogfood caller and reusable workflow must expose the same runtime facts # as the documented consumer stub. Static pins catch YAML blocks drifting to @@ -36,7 +46,7 @@ check "dogfood caller wakes on issue events" 0 " issues:" \ grep -F " issues:" "$ROOT/.github/workflows/self-labels.yml" dogfood_pr_step="$(sed -n \ '/name: reconcile state + stale (dogfood/,/name: reconcile issue flow/p' \ - "$ROOT/.github/workflows/labels.yml")" + "$ROOT/.github/workflows/labels-sweep.yml")" # shellcheck disable=SC2016 # GitHub expressions are asserted as literals check "dogfood PR reconcile receives repository" 0 ' REPO: ${{ github.repository }}' \ grep -F ' REPO: ${{ github.repository }}' <<<"$dogfood_pr_step" @@ -91,6 +101,13 @@ check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt refs_body=$'Refs #12\nAlso refs: #8 and heavy-duty/rig#4.\nCloses #99\nNot refs-ish #7\nfix refs parsing from #200\nCloses #40; refs: none\nRefs #175 (split from #150)' check "Refs parser returns only references owned by a valid Refs marker" 0 "" \ test "$(refs_references <<<"$refs_body")" = $'8\n12\n175' +open_records=$'BODY\tRefs #5\nCLOSING\t9\nBODY\tRefs heavy-duty/rig#112\nBODY\tRefs #5\nCLOSING\t5' +check "open PR linkage unions closing and local Refs body references" 0 $'5\n9' \ + open_pr_issues <<<"$open_records" +check "cross-repo Refs never enter the local open PR set" 0 "" \ + open_pr_issues <<< $'BODY\tRefs heavy-duty/rig#112' +check "an issue named by both linkage paths appears exactly once" 0 "1" \ + grep -cxF 5 <<<"$(open_pr_issues <<<"$open_records")" check "unchecked criteria preserve their source lines verbatim" 0 \ $'- [ ] first criterion\n * [ ] indented criterion\n1. [ ] numbered criterion' \ unchecked_criteria <<< $'- [x] done\n- [ ] first criterion\r\n * [ ] indented criterion\n1. [ ] numbered criterion' @@ -102,6 +119,54 @@ check "a handled merged Refs episode does not transition again" 0 "KEEP" \ post_merge_decision 12 false true <<<"- [ ] verify after merge" check "merged Refs with all criteria checked does not transition" 0 "KEEP" \ post_merge_decision 12 false false >"$TMP/api-calls" - [ ! -f "$file.error" ] || return 1 - [ -f "$file" ] || { printf '[]\n'; return 0; } - if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi + # A `.http-error` sentinel is the real 5xx (#247): `gh api` prints the + # response body — GitHub's JSON error object — to STDOUT, says why on + # stderr, and exits non-zero. The `.error` sentinel models a failure with + # no payload, which is the *safe* path (an empty label set is empty either + # way), and is why this class was never caught. Both now speak on stderr, + # because the real gh always does and the reason line renders it. + if [ -f "$file.http-error" ]; then + # A --jq call gets the filter applied to the error body, as gh does. + # That is what "yields no timestamps" looks like — the shape that let + # last_issue_activity fall back to created_at and reclaim a live claim. + if [ -n "$jqexpr" ]; then + jq -r "$jqexpr" "$file.http-error" 2>/dev/null || true + else + cat "$file.http-error" + fi + printf '%s\n' "$GH_STUB_STDERR" >&2 + return 1 + fi + [ ! -f "$file.error" ] || { printf '%s\n' "$GH_STUB_STDERR" >&2; return 1; } + # An absent fixture answers an empty list, and a --jq call gets the filter + # applied to it — the arrival stub's shape, and gh's. Returning the raw + # `[]` to a --jq caller made every missing fixture answer a literal `[]` + # where the real API answers nothing, and `[]` outsorts an ISO-8601 + # timestamp in the C locale but not in a UTF-8 one, so last_issue_activity + # dated an issue by a stub artifact on the runner and by its created_at + # here. The old code swallowed the resulting date failure; #247's guards + # turn it into a skip, which is what made the lie visible. + local payload='[]' + [ ! -f "$file" ] || payload="$(cat "$file")" + if [ -n "$jqexpr" ]; then jq -r "$jqexpr" <<<"$payload"; else printf '%s\n' "$payload"; fi elif [ "$1" = issue ] && [ "$2" = comment ]; then local n="$3" body="" file shift 3 @@ -267,7 +434,7 @@ issue_stub_gh() { shift done printf '%s\n----\n' "$body" >>"$TMP/posted-$n" - file="$TMP/repos_owner_repo_issues_${n}_comments.json" + file="$TMP/$(printf 'repos/%s/issues/%s/comments' "$REPO" "$n" | tr / _).json" [ -f "$file" ] || printf '[]\n' >"$file" jq --arg b "$body" --arg at "$(iso_at "$INOW")" \ '. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/posted","body":$b}]' \ @@ -277,22 +444,41 @@ issue_stub_gh() { fi } -issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 open PR, $5 merged PR, $6 body +issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 false|closing|refs, $5 merged PR specs, $6 body ( - local assignees="${3:-1}" open_pr="${4:-false}" merged_ref_pr="${5:-}" - local body="${6:-}" assignee_json='[]' + local assignees="${3:-1}" open_pr="${4:-false}" merged_ref_prs="${5:-}" + local body="${6:-}" assignee_json='[]' open_pr_records="" spec pr merged_at [ "$assignees" -eq 0 ] || assignee_json='[{"login":"owner-bot"}]' - REPO=owner/repo NOW="$INOW" + # `PROBE_NOW` moves the sweep's clock without moving the fixtures — the + # only way to prove a rule that self-rate-limits on its own comment's + # timestamp (#254): sweep, then sweep again a day later and watch the + # nudge stay silent because the comment it posted is now the activity. + REPO="${PROBE_REPO:-owner/repo}" NOW="${PROBE_NOW:-$INOW}" + # The nudge links the issue on the forge the job is running on, so the + # probe stands on one. PROBE_SERVER_URL lets a case assert the link moves + # with the forge instead of being written into the source (#198). + GITHUB_SERVER_URL="${PROBE_SERVER_URL:-https://github.com}" ISSUE_LABELS="$2" ISSUE_JSON="$(jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \ --argjson assignees "$assignee_json" --arg body "$body" \ '{created_at: $at, assignees: $assignees, body: $body}')" - if [ "$open_pr" = true ]; then OPEN_PR_ISSUES="$1"; else OPEN_PR_ISSUES=""; fi - if [ -n "$merged_ref_pr" ]; then - MERGED_REF_PR_RECORDS="$(printf '%s\t%s\n' "$1" "$merged_ref_pr")" - else - MERGED_REF_PR_RECORDS="" - fi + case "$open_pr" in + true|closing) open_pr_records="$(printf 'CLOSING\t%s\n' "$1")" ;; + refs|draft-refs) open_pr_records="$(printf 'BODY\tRefs #%s\n' "$1")" ;; + esac + OPEN_PR_ISSUES="$(open_pr_issues <<<"$open_pr_records")" + # Records are ISSUEPRMERGED_AT (#242). A spec is `PR` or + # `PR@`; the bare form takes a fixed hour-old merge, which is every + # probe that does not care about merge order. An empty list is no record + # at all, so the no-merged-PR probes read exactly as they did. + MERGED_REF_PR_RECORDS="$( + # shellcheck disable=SC2086 # the spec list is deliberately word-split + for spec in $merged_ref_prs; do + pr="${spec%%@*}" + merged_at="${spec#*@}" + [ "$merged_at" != "$spec" ] || merged_at="$(iso_at $((INOW - 3600)))" + printf '%s\t%s\t%s\n' "$1" "$pr" "$merged_at" + done)" run() { "$@"; } # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) gh() { issue_stub_gh "$@"; } @@ -303,6 +489,191 @@ issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 open PR, $5 merged PR, $ tfix() { printf '%s/repos_owner_repo_issues_%s_timeline.json' "$TMP" "$1"; } cfix() { printf '%s/repos_owner_repo_issues_%s_comments.json' "$TMP" "$1"; } +# -- release epics announce an opened declared gate, comment-only (#253) ----- +printf '{"state":"closed"}\n' >"$TMP/repos_owner_repo_issues_201.json" +printf '{"state":"closed"}\n' >"$TMP/repos_owner_repo_issues_202.json" +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_203.json" +printf '[]\n' >"$(cfix 53)" +: >"$TMP/issue-edits" +release_init_edits_before="$(wc -l <"$TMP/issue-edits")" +release_init_body=$'Blocked by #201, #202.\n\n## Task list\n- [ ] #203 later work' +release_init="$(issue_probe 53 $'epic\nrelease' 0 false "" "$release_init_body")" +check "a release epic with every declared blocker closed announces init" 0 "" \ + grep -qF '' "$TMP/posted-53" +check "the init announce names all five steps" 0 "5" \ + grep -cE '^[1-5]\. ' "$TMP/posted-53" +# shellcheck disable=SC2016 # backticks are the literal portable doctrine citation +check "the init announce cites the portable vendored doctrine path" 0 "" \ + grep -qF 'See `.ceremony/RELEASES.md`.' "$TMP/posted-53" +check "the init announce names the never-automated operator blessing" 0 "" \ + grep -qF 'operator blessing the order is the one step this chain never automates' \ + "$TMP/posted-53" +check "the opened release gate is logged" 0 "" \ + grep -qF '#53: release-init due' <<<"$release_init" +release_init_gate_reads_before_repeat="$( + grep -cE 'repos/owner/repo/issues/(201|202)$' "$TMP/api-calls" +)" +issue_probe 53 $'epic\nrelease' 0 false "" "$release_init_body" >/dev/null +check "an unchanged opened gate announces only once" 0 "1" \ + grep -cF '' "$TMP/posted-53" +check "an announced gate does not re-read its durable blockers" 0 \ + "$release_init_gate_reads_before_repeat" \ + grep -cE 'repos/owner/repo/issues/(201|202)$' "$TMP/api-calls" + +printf '{"state":"closed"}\n' >"$TMP/repos_heavy-duty_ceremony_issues_201.json" +printf '{"state":"closed"}\n' >"$TMP/repos_heavy-duty_ceremony_issues_202.json" +printf '[]\n' >"$TMP/repos_heavy-duty_ceremony_issues_53_comments.json" +PROBE_REPO=heavy-duty/ceremony \ + issue_probe 53 $'epic\nrelease' 0 false "" "$release_init_body" >/dev/null +# shellcheck disable=SC2016 # backticks are the literal dogfood doctrine citation +check "the ceremony dogfood announce cites its root doctrine path" 0 "" \ + grep -qF 'See `RELEASES.md`.' "$TMP/posted-53" + +printf '[]\n' >"$(cfix 54)" +issue_probe 54 $'epic\nrelease' 0 false "" \ + $'Blocked by #203.\n\n## Task list\n- [x] #201 complete' >/dev/null +check "an open declared blocker suppresses init despite a complete task list" 1 "" \ + grep -qF '' "$TMP/posted-54" +check "the independent epic-complete nudge still fires" 0 "" \ + grep -qF '' "$TMP/posted-54" + +printf '[]\n' >"$(cfix 55)" +issue_probe 55 $'epic\nrelease' 0 false "" \ + $'Blocked by #201.\n\n## Task list\n- [x] #202 complete' >/dev/null +check "release-init and epic-complete can coexist" 0 "2" \ + grep -c -- '^----$' "$TMP/posted-55" +# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell +check "the coexisting comments keep distinct markers" 0 "" \ + bash -c 'grep -qF "" "$1" && grep -qF "" "$1"' \ + _ "$TMP/posted-55" + +for n in 56 57 58 59; do printf '[]\n' >"$(cfix "$n")"; done +issue_probe 56 $'epic\nrelease' 0 false "" 'No dependency declaration.' >/dev/null +check "a release epic without a Blocked by declaration stays silent" 1 "" \ + test -f "$TMP/posted-56" +issue_probe 57 $'epic\nrelease' 0 false "" 'Blocked by heavy-duty/rig#9.' >/dev/null +check "a cross-repo release gate stays silent" 1 "" test -f "$TMP/posted-57" +issue_probe 58 $'epic\nrelease' 0 false "" 'Blocked by #204.' >/dev/null +check "an unreadable release gate stays silent" 1 "" test -f "$TMP/posted-58" +: >"$TMP/api-calls" +issue_probe 59 epic 0 false "" 'Blocked by #201.' >/dev/null +check "a plain epic does not parse or announce a release gate" 1 "" \ + test -f "$TMP/posted-59" +check "a plain epic pays no Blocked by reference read" 1 "" \ + grep -qF 'repos/owner/repo/issues/201' "$TMP/api-calls" + +printf '[]\n' >"$(cfix 60)" +issue_probe 60 $'ready\nrelease' 0 false "" 'Blocked by #201.' >/dev/null +check "a non-epic release issue does not announce init" 1 "" \ + test -f "$TMP/posted-60" +# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell +check "every release-init probe is comment-only" 0 "$release_init_edits_before" \ + bash -c 'wc -l <"$1"' _ "$TMP/issue-edits" + +# -- malformed attention targets are diagnosed, never repaired (#232) ------- +attention_episode() { # $1 issue, $2 labeled timestamp + jq -n --arg at "$2" \ + '[{"event":"labeled","label":{"name":"attention"},"actor":{"login":"setter"},"created_at":$at}]' \ + >"$(tfix "$1")" + printf '[]\n' >"$(cfix "$1")" +} + +: >"$TMP/issue-edits" +attention_edits_before="$(wc -l <"$TMP/issue-edits")" +for n in 61 62 63; do + attention_episode "$n" "$(iso_at $((INOW - 60)))" +done +# The assignment event is the claim clock's own activity fact. The live issue +# has since lost its assignee, which is exactly the claimed-unassigned shape. +jq --arg at "$(iso_at $((INOW - 60)))" \ + '. + [{"event":"assigned","created_at":$at}]' \ + "$(tfix 63)" >"$(tfix 63).tmp" && mv "$(tfix 63).tmp" "$(tfix 63)" +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_999.json" + +issue_probe 61 $'ready\nattention' 0 >/dev/null +check "unassigned attention under ready is diagnosed once" 0 "1" \ + grep -cF '' "$TMP/posted-63" +check "the suppressed attention detection remains in the log" 0 "1" \ + grep -cF 'comment suppressed by claimed-unassigned precedence' <<<"$claimed_attention" + +attention_episode 64 "$(iso_at $((INOW - 60)))" +issue_probe 64 $'ready\nattention' 1 >/dev/null +check "assigned attention under ready is healthy" 1 "" test -f "$TMP/posted-64" +attention_episode 65 "$(iso_at $((INOW - 60)))" +issue_probe 65 $'claimed\nattention' 1 true >/dev/null +check "assigned attention under claimed is healthy" 1 "" test -f "$TMP/posted-65" +attention_episode 66 "$(iso_at $((INOW - 60)))" +issue_probe 66 $'blocked\nattention' 1 false "" 'Blocked by #999' >/dev/null +# A `blocked` issue now always carries one comment — the parse echo (#252) — +# so "healthy" can no longer be spelled "no comment file at all". It is spelled +# the way this section's other cases already spell it: no attention diagnostic. +# Both halves are pinned, so the case still fails if an attention comment +# appears beside the echo, or if a second comment of any kind does. +check "assigned attention under blocked is healthy" 1 "" \ + grep -qF '' "$TMP/posted-67" +check "the post-merge suppression remains in the log" 0 "1" \ + grep -cF 'comment suppressed by post-merge-assigned precedence' <<<"$post_merge_attention" + +attention_episode 68 "$(iso_at $((INOW - 120)))" +issue_probe 68 $'ready\nattention' 0 >/dev/null +issue_probe 68 $'ready\nattention' 0 >/dev/null +check "two sweeps in one malformed episode post once" 0 "1" \ + grep -cF '' "$TMP/posted-37" check "...and the hand-assignment is not repaired" 1 "" \ grep -qF -- 'issue edit 37' "$TMP/issue-edits" +# The flag and the nudge answer different questions — a board bug and a +# starved wake condition — so neither suppresses the other (#254). +check "...and the evidence nudge rides beside it, neither suppressed" 0 "2" \ + grep -c -- '^----$' "$TMP/posted-37" + +# -- the post-merge evidence nudge (#254), the ruling nudge's twin ---------- +# The ruling nudge solved "a wait goes quiet and nobody is told" for +# `needs-ruling`; `post-merge` had no equivalent, and crew#181's real-host +# criterion starved four times across two releases for want of one. Same +# 7-day constant (`ruling_nudge_decision`, reused not mirrored), same +# deliberate absence of an idempotency marker, and — unlike the ruling +# nudge — addressed to the triage actor, because `post-merge` is triage's +# completion queue and the operator owes nothing here (#254 D1). +nudge_edits_before="$(wc -l <"$TMP/issue-edits")" + +quiet_comment() { # $1 issue, $2 seconds of quiet — one ordinary comment, then silence + jq -n --arg at "$(iso_at $((INOW - $2)))" \ + '[{"user":{"login":"triage-one"},"created_at":$at,"html_url":"https://x/c","body":"evidence pending"}]' \ + >"$(cfix "$1")" + printf '[]\n' >"$(tfix "$1")" +} + +quiet_comment 80 $((8 * 86400)) +nudged="$(issue_probe 80 post-merge 0)" +check "8 quiet days on a post-merge item draws the evidence nudge" 0 "" \ + grep -q 'post-merge evidence nudge' <<<"$nudged" +# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process +check "...addressed to the triage actor, never the human reviewer" 0 "" \ + bash -c 'grep -qF "@triage-one" "$1" && ! grep -qF "@danmt" "$1"' _ "$TMP/posted-80" +check "...with the issue link as the payload" 0 "" \ + grep -qF 'https://github.com/owner/repo/issues/80' "$TMP/posted-80" +# The host comes from the environment, and a server URL that carries a +# trailing slash must not render `//owner/repo` (@codex-reviewer-andresmgsl, +# #198). Same forge, same issue, one character of difference in the input. +quiet_comment 80 $((8 * 86400)) +PROBE_SERVER_URL=https://forgejo.example.test/ issue_probe 80 post-merge 0 >/dev/null +check "a trailing slash on the server URL does not double the separator" 0 "" \ + grep -qF 'https://forgejo.example.test/owner/repo/issues/80' "$TMP/posted-80" +check "...and no doubled separator appears at all" 1 "" \ + grep -qF 'forgejo.example.test//owner' "$TMP/posted-80" +quiet_comment 80 $((8 * 86400)) +: >"$TMP/posted-80" +issue_probe 80 post-merge 0 >/dev/null +check "...carrying the do-not-add-a-marker warning in the comment" 0 "" \ + grep -qF 'Do not add a marker.' "$TMP/posted-80" +# Asserted directly, not merely omitted: a marker would turn "once per 7 +# quiet days" into "once per issue, forever" — the exact "fix" lib/ruling.sh's +# header records as the thing that breaks this rule. +check "...and no idempotency marker on the path" 1 "" \ + grep -qF '\nrung"}, + {"user":{"login":"sweep-bot"},"created_at":$r24,"html_url":"https://x/r24","body":"\nrung"}]' \ + >"$(cfix 84)" +both="$(issue_probe 84 $'post-merge\nneeds-ruling' 0)" +check "a quiet post-merge item under a pending ruling nudges both waits" 0 "" \ + grep -q 'post-merge evidence nudge' <<<"$both" +check "...and the ruling nudge is not suppressed by it" 0 "" \ + grep -q 'ruling nudge' <<<"$both" +# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process +check "...each addressing its own party" 0 "" \ + bash -c 'grep -qF "@triage-one" "$1" && grep -qF "@danmt" "$1"' _ "$TMP/posted-84" + +# Every other queue state: the nudge is `post-merge`'s alone. Each is equally +# quiet, and `claimed` carries an open PR so its own reclaim clock — the one +# other 10-day rule on this path — stays out of the way. +non_post_merge=(85:ready:0:false 86:claimed:1:true 87:blocked:0:false 88:epic:0:false 89:needs-triage:0:false) +for spec in "${non_post_merge[@]}"; do + IFS=: read -r n state probe_assignees probe_pr <<<"$spec" + printf '[]\n' >"$(cfix "$n")" + printf '[]\n' >"$(tfix "$n")" + check "a 10-day-quiet $state issue draws no evidence nudge" 1 "" \ + grep -q 'post-merge evidence nudge' \ + <<<"$(issue_probe "$n" "$state" "$probe_assignees" "$probe_pr")" +done + +# The machine never judges prose: which criterion starved is not a fact the +# sweep reads, so a body it could not parse if it tried still nudges. +quiet_comment 90 $((8 * 86400)) +unparseable_body="$(issue_probe 90 post-merge 0 false "" '¯\_(ツ)_/¯ wake: ask danmt sometime')" +check "an unparseable body still nudges — the link is the payload" 0 "" \ + grep -q 'post-merge evidence nudge' <<<"$unparseable_body" +check "...and the nudge quotes none of it" 1 "" \ + grep -qF 'ask danmt sometime' "$TMP/posted-90" + +# One spelling of the 7-day rule. `lib/ruling.sh` exists because this family +# already paid for two copies of a constant; a second one here is the drift, +# and it is cheap to pin at the grep level. +# Code lines only: the branch's comment names the constant it must not +# respell, which is the sentence a future reader needs and not a second copy. +check "the 7-day rule is not respelled in the sweep" 1 "" \ + grep -nE '^[^#]*(7 \* 24 \* 3600|604800)' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" +# shellcheck disable=SC2016 # the call site is asserted as a literal +check "...it is reused from lib/ruling.sh" 0 "" \ + grep -qF 'ruling_nudge_decision "$NOW" "$evidence_age"' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" + +# `post-merge` is the one queue state whose whole meaning is that the machine +# owes nothing (LABELS.md: the sweep never reclaims it). This issue makes the +# quiet visible; it must never make it actionable. +# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell +check "the evidence-nudge probes perform no issue edits" 0 "$nudge_edits_before" \ + bash -c 'wc -l <"$1"' _ "$TMP/issue-edits" + +# -- the issue-side ruling clock is comments-only (#284) --------------------- +# #52 D10's "reuse the activity computation" made the issue-side ruling nudge +# ride the claim-reclamation clock, `assigned` events included — so claiming +# a flagged issue dated it, and the escalation the flag exists to keep +# visible went quiet for another 7 days at exactly the moment somebody +# started working through it. The ruling clock is now the comments-only one +# (D1); the reclaim clock keeps the assignment (D2), because there the +# assignment IS the claim. Every must-nudge probe below was run against the +# pre-#284 sweep and went red — the #274 round-1 discipline: the fixture +# proves the defect, not merely the fix. +ruling_clock_edits_before="$(wc -l <"$TMP/issue-edits")" +ruling_quiet() { # $1 issue — conforming escalation, both rungs fired, ~9d of comment quiet + jq -n --arg l "$(iso_at $((INOW - 10 * 86400)))" \ + '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}]' \ + >"$(tfix "$1")" + jq -n --arg at "$(iso_at $((INOW - 10 * 86400 - 60)))" \ + --arg b $'Options: A — x B — y\nRecommend: A, because x.\nBlocked: z\nDefault: none — hard block' \ + --arg r12 "$(iso_at $((INOW - 10 * 86400 + 13 * 3600)))" \ + --arg r24 "$(iso_at $((INOW - 10 * 86400 + 25 * 3600)))" \ + '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc","body":$b}, + {"user":{"login":"sweep-bot"},"created_at":$r12,"html_url":"https://x/r12","body":"\nrung"}, + {"user":{"login":"sweep-bot"},"created_at":$r24,"html_url":"https://x/r24","body":"\nrung"}]' \ + >"$(cfix "$1")" +} +timeline_add() { # $1 issue, $2 event, $3 seconds ago + jq --arg e "$2" --arg at "$(iso_at $((INOW - $3)))" \ + '. + [{"event":$e,"created_at":$at}]' \ + "$(tfix "$1")" >"$(tfix "$1").tmp" && mv "$(tfix "$1").tmp" "$(tfix "$1")" +} +comment_add() { # $1 issue, $2 seconds ago + jq --arg at "$(iso_at $((INOW - $2)))" \ + '. + [{"user":{"login":"decider"},"created_at":$at,"html_url":"https://x/d","body":"still thinking"}]' \ + "$(cfix "$1")" >"$(cfix "$1").tmp" && mv "$(cfix "$1").tmp" "$(cfix "$1")" +} + +# The live shape, and the defect: a builder claims the flagged issue, the +# assignment is an hour old, the decider has been silent ~9 days. +ruling_quiet 101 +timeline_add 101 assigned 3600 +claimed_fresh="$(issue_probe 101 $'claimed\nneeds-ruling' 1 true)" +check "an hour-old claim does not silence a ruling 9 days quiet" 0 "" \ + grep -q 'ruling nudge' <<<"$claimed_fresh" +check "...and the fresh assignment is not reclaim bait either" 1 "" \ + grep -q 'reclaimed' <<<"$claimed_fresh" + +# The claimed branch's top read, pinned. `claimed` + `needs-ruling` with no +# assignee is the one composition where this branch posts BEFORE the ruling +# block: `claim_decision` returns FLAG_UNASSIGNED and the +# `claimed-unassigned` comment goes out, so a ruling clock read down there +# would date the issue by the sweep's own writing and buy the escalation +# another 7 quiet days — the #274 hazard, on the very branch #284 D6 exists +# to hold. Probe 101 does not reach it: it carries an assignee and an open +# PR. One sweep, both outputs — the board diagnostic and the nudge it must +# not silence — because asserting only the nudge would pass with the +# diagnostic silently gone. Reds the moment the top read drifts below the +# post, and the deletion of that read is what proved it (#284 D6, #307). +ruling_quiet 110 +timeline_add 110 assigned 3600 +unassigned_flag="$(issue_probe 110 $'claimed\nneeds-ruling' 0 false)" +check "an unassigned claim under a ruling still draws its board flag" 0 "1" \ + grep -cF '' "$TMP/posted-110" +check "...and the ruling nudge fires beside it, undated by it" 0 "" \ + grep -q 'ruling nudge' <<<"$unassigned_flag" + +# The clock rule alone, no assignee in the way: an assigned/unassigned pair +# in the timeline is the claim's history, not activity toward the ruling. +ruling_quiet 102 +timeline_add 102 assigned 3600 +timeline_add 102 unassigned 3500 +ready_pair="$(issue_probe 102 $'ready\nneeds-ruling' 0)" +check "a ready issue nudges through an hour-old assignment pair" 0 "" \ + grep -q 'ruling nudge' <<<"$ready_pair" + +# post-merge + needs-ruling fires BOTH nudges in one sweep, from one read +# taken before either write. This probe is also the read-order pin: the +# evidence nudge posts first and the stub stamps it as fresh activity, so +# restoring a ruling-clock read below `ensure_comment` turns the second +# check red — the hazard #274 met and killed inside one round. +ruling_quiet 103 +timeline_add 103 assigned 3600 +timeline_add 103 unassigned 3500 +both_fresh="$(issue_probe 103 $'post-merge\nneeds-ruling' 0)" +check "a fresh assignment starves neither post-merge wait" 0 "" \ + grep -q 'post-merge evidence nudge' <<<"$both_fresh" +check "...the ruling nudge fires beside it, not behind it" 0 "" \ + grep -q 'ruling nudge' <<<"$both_fresh" + +# blocked composes the same way. The #252 parse echo is pre-seeded old so +# the probe isolates the clock rule — steady state, where the echo for this +# parse set already exists and the branch posts nothing before the tail. +ruling_quiet 104 +timeline_add 104 assigned 3600 +refs_104="$(blocked_references <<<'Blocked by #999.')" +cross_104="$(blocked_cross_references <<<'Blocked by #999.')" +marker_104="$(blocked_parse_marker "$(blocked_parse_set "$refs_104" "$cross_104")")" +jq --arg m "$marker_104" --arg at "$(iso_at $((INOW - 9 * 86400)))" \ + '. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/echo","body":("\necho")}]' \ + "$(cfix 104)" >"$(cfix 104).tmp" && mv "$(cfix 104).tmp" "$(cfix 104)" +blocked_fresh="$(issue_probe 104 $'blocked\nneeds-ruling' 0 false "" 'Blocked by #999.')" +check "a blocked issue nudges through an hour-old assignment" 0 "" \ + grep -q 'ruling nudge' <<<"$blocked_fresh" + +# 6 days of comment quiet is 6, with or without an assignment inside it. +ruling_quiet 105 +timeline_add 105 assigned 3600 +comment_add 105 $((6 * 86400)) +six_days="$(issue_probe 105 $'claimed\nneeds-ruling' 1 true)" +check "6 days of comment quiet draws no nudge" 1 "" \ + grep -q 'ruling nudge' <<<"$six_days" + +# Label churn is not activity on this clock either — it reads no timeline +# at all, which closes the class rather than the spelling. +ruling_quiet 106 +timeline_add 106 labeled 1800 +timeline_add 106 unlabeled 1700 +churn="$(issue_probe 106 $'ready\nneeds-ruling' 0)" +check "hour-old label churn does not hold the nudge back" 0 "" \ + grep -q 'ruling nudge' <<<"$churn" + +# Self-rate-limiting, asserted as the property (#254's discipline): sweep +# again a day after probe 101's nudge and the nudge it posted is the +# activity that keeps it silent — no marker involved. +day_after="$(PROBE_NOW=$((INOW + 86400)) issue_probe 101 $'claimed\nneeds-ruling' 1 true)" +check "the sweep a day after its nudge holds its silence" 1 "" \ + grep -q 'ruling nudge' <<<"$day_after" + +# No flag, no nudge, whatever the clock says. +quiet_comment 107 $((60 * 86400)) +noflag="$(issue_probe 107 ready 0)" +check "an unflagged issue draws no ruling nudge at any age" 1 "" \ + grep -q 'ruling nudge' <<<"$noflag" + +# D2's input doing its job — the one thing a "the clocks are the same now, +# merge them" refactor would break. Red the instant `last_issue_activity` +# loses `assigned`. +printf '[]\n' >"$(cfix 108)" +jq -n --arg at "$(iso_at $((INOW - 600)))" \ + '[{"event":"assigned","created_at":$at}]' >"$(tfix 108)" +not_reclaimed="$(issue_probe 108 claimed 1 false)" +check "a 10-minute-old claim on a silent issue is not reclaimed" 1 "" \ + grep -q 'reclaimed' <<<"$not_reclaimed" + +# One fixture, two clocks, asserted directly and not by inspection: the +# newest event is the assignment; the reclaim clock returns it and the +# ruling clock returns the older comment. +jq -n --arg at "$(iso_at $((INOW - 8 * 86400)))" \ + '[{"user":{"login":"decider"},"created_at":$at,"html_url":"https://x/c9","body":"x"}]' >"$(cfix 109)" +jq -n --arg at "$(iso_at $((INOW - 3600)))" \ + '[{"event":"assigned","created_at":$at}]' >"$(tfix 109)" +clock_read() { # $1 clock fn — both against fixture 109 + ( REPO=owner/repo + # shellcheck disable=SC2317 # reached indirectly, through the clock under test + gh() { issue_stub_gh "$@"; } + "$1" 109 "$(iso_at $((INOW - 10 * 86400)))" ) +} +check "one fixture, two clocks: the reclaim clock returns the assignment" \ + 0 "$((INOW - 3600))" clock_read last_issue_activity +check "...and the ruling clock returns the older comment" \ + 0 "$((INOW - 8 * 86400))" clock_read last_issue_comment_activity + +# The mechanical call-site pins: the reclaim clock can never reach +# reconcile_ruling, on any path, asserted against the source and not the +# diff. Beside them, the one-spelling pins this issue inherits stay green. +# shellcheck disable=SC2016 # the call sites are asserted as literals +check "reconcile_ruling is never handed the reclaim clock" 1 "" \ + grep -E '^[^#]*reconcile_ruling.*\$age' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" +# shellcheck disable=SC2016 # the call site is asserted as a literal +check "...its one call site is fed the comments-only clock" 0 "1" \ + grep -cF 'reconcile_ruling "$n" "$ruling_age" "$NOW"' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" +# shellcheck disable=SC2016 # the guarded_read is asserted as a literal +check "...and ruling_age is never fed by the reclaim clock" 1 "" \ + grep -E 'ruling_age.*last_issue_activity ' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" + +# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell +check "the #284 probes perform no issue edits" 0 "$ruling_clock_edits_before" \ + bash -c 'wc -l <"$1"' _ "$TMP/issue-edits" # -- non-triggers stay byte-for-byte outside the transition ------------------ recent_timeline() { @@ -378,14 +1124,19 @@ recent_timeline() { } edit_count_before="$(wc -l <"$TMP/issue-edits")" recent_timeline 38 -open_refs="$(issue_probe 38 claimed 1 true 380 '- [ ] verify after merge')" -check "open Refs PR leaves the issue exactly as found" 0 "" \ +open_refs="$(issue_probe 38 claimed 1 refs 380 '- [ ] verify after merge')" +check "issue_probe: open Refs PR leaves the issue exactly as found" 0 "" \ test -z "$open_refs" # shellcheck disable=SC2016 # positional parameters belong to bash -c check "...with no edit or comment" 0 "" \ bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \ "$edit_count_before" "$TMP/issue-edits" "$TMP/posted-38" +recent_timeline 46 +open_closing="$(issue_probe 46 claimed 1 closing 460 '- [ ] verify after merge')" +check "issue_probe: closing-linked open PR remains the unchanged control" 0 "" \ + test -z "$open_closing" + recent_timeline 39 merged_closes="$(issue_probe 39 claimed 1 false "" '- [ ] verify after merge')" check "merged Closes PR leaves a recent claim exactly as found" 0 "" \ @@ -431,6 +1182,25 @@ check "a later merged Refs PR gets an episode-specific transition comment" 0 "" check "...and the later episode still transitions" 0 "" \ grep -qF 'merged Refs PR -> post-merge; claim released' <<<"$second_transition" +# End to end on the crew#321 shape: the later merge is the *lower*-numbered +# PR, and its marker is already on the issue. Selecting by number would find +# no marker for #461, fire the transition a second time, and release a claim +# the board already released (#242). +recent_timeline 46 +jq -n --arg b '' \ + --arg at "$(iso_at $((INOW - 60)))" \ + '[{"body":$b,"created_at":$at}]' >"$(cfix 46)" +spent_edit_count="$(wc -l <"$TMP/issue-edits")" +spent="$(issue_probe 46 claimed 1 false \ + "461@$(iso_at $((INOW - 7200))) 460@$(iso_at $((INOW - 3600)))" \ + '- [ ] verify after merge')" +check "the marker of the later-merged lower-numbered PR is the one read" 0 "" \ + test -z "$spent" +# shellcheck disable=SC2016 # positional parameters belong to bash -c +check "...so the spent transition is not fired a second time" 0 "" \ + bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \ + "$spent_edit_count" "$TMP/issue-edits" "$TMP/posted-46" + printf '[]\n' >"$(cfix 45)" issue_probe 45 $'claimed\npost-merge' >/dev/null # shellcheck disable=SC2016 # Markdown backticks are literal evidence @@ -495,6 +1265,17 @@ unreadable="$(issue_probe 32 $'claimed\noffsite')" check "an unreadable timeline stays silent" 1 "" test -f "$TMP/posted-32" check "...and leaves the sweep running without an alarming log" 1 "" \ grep -qiE 'error|failed' <<<"$unreadable" +# Both checks above still hold, and #247 D1 changed what reaches them: +# last_issue_activity reads the same timeline endpoint, so the issue is now +# skipped before the offsite verification runs. The skip is why nothing is +# posted, and its reason line is a deliberate report rather than an alarm +# (D4). D8 leaves offsite_timeline's own silence alone, so it is pinned here +# directly rather than through a probe that can no longer reach it. +# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) +offsite_timeline_probe() { ( REPO=owner/repo; gh() { issue_stub_gh "$@"; }; offsite_timeline "$1" ); } +check "an unreadable offsite timeline yields nothing and still fails closed" 1 "" \ + offsite_timeline_probe 32 +check "...while a readable one answers its payload" 0 "[]" offsite_timeline_probe 31 : >"$TMP/api-calls" printf '[]\n' >"$(tfix 33)" @@ -516,6 +1297,134 @@ check "no reconciler mutation names offsite (#68 D4)" 1 "" \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" \ "$ROOT/actions/labels-reconcile/labels-reconcile.sh" +# -- the parse echo: one comment per changed set, none per sweep (#252) ------ +# The whole point is a sweep-visible statement of what was read, so it is +# probed through the sweep and not only as a rendering: the marker has to +# survive the comment body, the second pass has to find it, and the third has +# to miss it because the declaration changed. +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_90.json" +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_91.json" +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_92.json" +printf '[]\n' >"$(cfix 35)" +echo_edits_before="$(wc -l <"$TMP/issue-edits")" +first_echo="$(issue_probe 35 blocked 1 false "" "Part of #1. Blocked by #90, #91.")" +check "a first parse is echoed, naming the set" 0 "" \ + grep -qF 'parse to: {#90, #91}' "$TMP/posted-35" +check "...and the sweep log carries the same set" 0 \ + "issueflow: #35: blocked declarations parse to {#90, #91}" \ + printf '%s\n' "$first_echo" +issue_probe 35 blocked 1 false "" "Part of #1. Blocked by #90, #91." >/dev/null +check "an unchanged parse draws nothing on the next sweep" 0 "1" \ + grep -cF "" "$TMP/posted-35" +# AC-1's other input, and it is not the sweep above. A re-sweep of a +# BYTE-IDENTICAL body is quiet under both spellings of the decision — the one +# that keys on the parse and the one that keys on the body — so it cannot tell +# them apart. Only an edit that changes the prose and preserves the parse can: +# the refs are reordered and sentences are added on either side, and the set is +# still {#90, #91}. What this pins is that the marker is a function of the +# PARSE and not of the prose around it, which is the property the echo's whole +# idempotency rests on and which nothing else in the suite states. +preserved_edits_before="$(wc -l <"$TMP/issue-edits")" +issue_probe 35 blocked 1 false "" \ + "Some new prose here. Blocked by #91, #90. And more text." >/dev/null +check "a body edit that preserves the parse draws nothing" 0 "1" \ + grep -cF "" "$TMP/posted-35" +check "...and adds no echo under any other marker either" 0 "1" \ + grep -cF '" "$TMP/posted-35" +check "...naming the new set" 0 "" \ + grep -qF 'parse to: {#90, #91, #92}' "$TMP/posted-35" +check "...and saying so in the sweep log" 0 \ + "issueflow: #35: blocked declarations parse to {#90, #91, #92}" \ + printf '%s\n' "$changed_echo" +check "...and leaving the first echo alone" 0 "1" \ + grep -cF "" "$TMP/posted-35" +# shellcheck disable=SC2016 # positional parameters belong to bash -c +check "no label write comes from the echo path" 0 "" \ + bash -c 'test "$1" -eq "$(wc -l <"$2")"' _ "$echo_edits_before" "$TMP/issue-edits" + +# crew#308, replayed through the sweep: the declaration denies #221 and the +# parse unions it anyway. Nobody saw that set for as long as it stayed inside +# the machine; the echo puts it in the thread that contains the declaration. +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_162.json" +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_221.json" +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_265.json" +printf '[]\n' >"$(cfix 36)" +issue_probe 36 blocked 1 false "" \ + 'Blocked by #162, #265. It is no longer blocked by #221.' >/dev/null +check "the #308 misparse is echoed verbatim, denial and all" 0 "" \ + grep -qF 'parse to: {#162, #221, #265}' "$TMP/posted-36" + +# The empty parse says so, and the flag that catches the UNREADABLE +# declaration is untouched beside it: one comment states what was read, the +# other states that nothing was. +printf '[]\n' >"$(cfix 37)" +issue_probe 37 blocked 1 false "" 'No declaration anywhere in this body.' >/dev/null +check "an empty parse is echoed as the empty set" 0 "" \ + grep -qF 'parse to: {}' "$TMP/posted-37" +check "...and blocked-unparseable still fires beside it" 0 "" \ + grep -qF '' "$TMP/posted-37" + +# The collision the round found, replayed through the sweep: two declarations +# whose parsed sets differ but whose slugs do not. Keyed on the slug, the +# second edit found the first echo's marker and posted nothing — the machine +# silently gating on `acme-widgets#9` while the thread said `acme/widgets#9`, +# which is the readable-but-wrong shape this whole change exists to surface. +# Asserted end-to-end, so it is the second echo landing that is observed. +printf '[]\n' >"$(cfix 38)" +issue_probe 38 blocked 1 false "" 'Blocked by acme/widgets#9.' >/dev/null +check "a qualified cross-repo declaration is echoed" 0 "1" \ + grep -cF 'parse to: {acme/widgets#9}' "$TMP/posted-38" +collision_edits_before="$(wc -l <"$TMP/issue-edits")" +issue_probe 38 blocked 1 false "" 'Blocked by acme-widgets#9.' >/dev/null +check "a slug-colliding edit still draws its own echo" 0 "1" \ + grep -cF 'parse to: {acme-widgets#9}' "$TMP/posted-38" +check "...under a marker of its own" 0 "1" \ + grep -cF "" "$TMP/posted-38" +check "...leaving the colliding first echo alone" 0 "1" \ + grep -cF "" "$TMP/posted-38" +# The cross-repo flag is marker-constant across both parses, so it stays at one +# while the echo moves: what spoke on the second sweep was the changed set. +check "...and not re-flagging cross-repo, which did not change" 0 "1" \ + grep -cF '' "$TMP/posted-38" +# shellcheck disable=SC2016 # positional parameters belong to bash -c +check "no label write comes from the colliding-edit path either" 0 "" \ + bash -c 'test "$1" -eq "$(wc -l <"$2")"' _ "$collision_edits_before" "$TMP/issue-edits" + +# A -> B -> A. The marker names the SET, so the return to A is a marker this +# thread has carried before; the question the echo has to answer is not "have I +# ever said this" but "is this still what I am saying". Searching the whole +# history answers the first, and the return went silent while the thread's +# newest echo asserted B and the sweep gated on A — a stale parse presented as +# the current one, which is the readable-but-wrong shape #252 exists to kill. +# All four sweeps are driven, because the bug is only visible as a sequence. +printf '[]\n' >"$(cfix 52)" +issue_probe 52 blocked 1 false "" 'Blocked by #90, #91.' >/dev/null +issue_probe 52 blocked 1 false "" 'Blocked by #90, #91, #92.' >/dev/null +return_edits_before="$(wc -l <"$TMP/issue-edits")" +issue_probe 52 blocked 1 false "" 'Blocked by #90, #91.' >/dev/null +check "a set edited back to a previously echoed one speaks again" 0 "3" \ + grep -cF '" "$TMP/posted-52" +# The assertion the silence used to fail: it is the NEWEST echo that has to +# name what the sweep gates on, not merely some echo somewhere in the thread. +# shellcheck disable=SC2016 # positional parameters belong to bash -c +check "...leaving the newest echo naming the set the sweep now gates on" 0 \ + "parse to: {#90, #91}" \ + bash -c 'grep -o "parse to: {[^}]*}" "$1" | tail -n 1' _ "$TMP/posted-52" +issue_probe 52 blocked 1 false "" 'Blocked by #90, #91.' >/dev/null +check "an unchanged sweep after the return still draws nothing" 0 "3" \ + grep -cF '' \ + --arg at "$(iso_at $((INOW - 3600)))" \ + '[{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/m","body":$b}]' \ + >"$(cfix 51)" +printf '%s\n' "$GH_STUB_ERROR_BODY" >"$(cfix 51).http-error" +check "a 504 on the marker read skips rather than reading it as no marker" \ + 3 "#51: skipped this pass — could not read its comments: $GH_STUB_STDERR" \ + issue_probe 51 blocked 1 false "" "no parseable declaration here" +check "...so no duplicate comment is posted" 1 "" test -f "$TMP/posted-51" + +# -- a deliberate skip is counted; a genuine crash is still named (D4) ------- +printf '%s\n' '{"number":60,"labels":[{"name":"ready"}],"assignees":[]}' \ + >"$TMP/repos_owner_repo_issues_60.json" +printf '%s\n' '{"number":61,"labels":[{"name":"ready"}],"assignees":[]}' \ + >"$TMP/repos_owner_repo_issues_61.json" +printf '%s\n' "$GH_STUB_ERROR_BODY" >"$TMP/repos_owner_repo_issues_61.json.http-error" +pass_probe() { # $1 issue; $2 non-empty makes reconcile_issue crash + ( + REPO=owner/repo + # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) + gh() { issue_stub_gh "$@"; } + [ -z "${2:-}" ] || reconcile_issue() { return 9; } + SKIPPED_COUNT=0 + SKIPPED_ISSUES="" + reconcile_issue_pass "$1" + printf 'rc=%s count=%s issues=%s\n' "$?" "$SKIPPED_COUNT" "$SKIPPED_ISSUES" + ) +} +check "a genuine non-read crash still names the failure byte-identically" 0 \ + "issueflow: #60: reconcile failed — continuing with the remaining issues" \ + pass_probe 60 crash +check "...and the pass still returns 0, so the loop reaches the next issue" 0 \ + "rc=0" pass_probe 60 crash +check "...and a crash is not counted as a skip" 0 "count=0" pass_probe 60 crash +check "a skipped issue is counted and named" 0 "count=1 issues=#61" pass_probe 61 +check "...and is not also reported as a crash" 1 "" \ + grep -q 'reconcile failed' <<<"$(pass_probe 61)" +check "...leaving the loop free to continue" 0 "rc=0" pass_probe 61 + # --------------------------------------------------------------------------- # The arrival path, executed the way the action executes it (#91): four # triage-authored mints died silently because the stand-down `return`s in @@ -566,11 +1611,13 @@ cat >"$ARRIVAL/stub/gh" <<'EOF' # answers an empty list, a .error sentinel fails the call like a dead API. if [ "$1" = api ]; then shift - endpoint="" jqexpr="" + endpoint="" jqexpr="" query="" while [ $# -gt 0 ]; do case "$1" in --jq) jqexpr="$2"; shift ;; - -f|-F) shift ;; + -f|-F) + case "$2" in query=*) query="${2#query=}" ;; esac + shift ;; -*) ;; *) [ -n "$endpoint" ] || endpoint="$1" ;; esac @@ -582,7 +1629,23 @@ if [ "$1" = api ]; then # endpoint (#188). endpoint="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')" file="$GH_FIXTURES/$(printf '%s' "$endpoint" | tr '/?&=' '____').json" - [ ! -f "$file.error" ] || exit 1 + if [ "$endpoint" = graphql ]; then + case "$query" in + esac + fi + # `.http-error` is the real 5xx (#247): the response body — GitHub's JSON + # error object — goes to STDOUT, the reason to stderr, and the status is + # non-zero. `.error` is the payload-free failure, which is the safe path. + if [ -f "$file.http-error" ]; then + if [ -n "$jqexpr" ]; then + jq -r "$jqexpr" "$file.http-error" 2>/dev/null || true + else + cat "$file.http-error" + fi + printf '%s\n' "${GH_STUB_STDERR:-}" >&2 + exit 1 + fi + [ ! -f "$file.error" ] || { printf '%s\n' "${GH_STUB_STDERR:-}" >&2; exit 1; } if [ -f "$file" ]; then payload="$(cat "$file")"; else payload='[]'; fi if [ -n "$jqexpr" ]; then jq -r "$jqexpr" <<<"$payload"; else printf '%s\n' "$payload"; fi exit 0 @@ -606,7 +1669,7 @@ arrival_fixture() { printf '%s\n' "$1" >"$ARRIVAL/fixtures/repos_owner_repo_issu # boundary this issue moved. arrival_run() { : >"$ARRIVAL/fixtures/edits" - env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ + env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \ CEREMONY_FORGE=github \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ EVENT_NAME=issues EVENT_ACTION=opened EVENT_ISSUE=91 \ @@ -659,18 +1722,36 @@ jq -n --arg at "$(iso_at "$INOW")" \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_40.json" printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_40_comments.json" : >"$ARRIVAL/fixtures/edits" +transition_out="$( + env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \ + REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +)" +transition_rc=$? +check "an executable sweep with no linked open PR exits 0" 0 "" \ + test "$transition_rc" -eq 0 +check "...reaches the transition through the REST gather and the issue loop" 0 "" \ + grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$transition_out" +check "...and performs the release edit from the executable path" 0 "" \ + grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \ + "$ARRIVAL/fixtures/edits" + +printf '%s\n' \ + '[{"number":401,"body":"Refs #40","draft":false,"merged_at":null}]' \ + >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json" +: >"$ARRIVAL/fixtures/edits" subprocess_out="$( - env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ + env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \ CEREMONY_FORGE=github \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 )" subprocess_rc=$? -check "executable sweep transitions merged Refs work" 0 "" \ +check "an open Refs-bodied PR suppresses the post-merge transition" 0 "" \ test "$subprocess_rc" -eq 0 -check "...reaches the transition through the REST gather and the issue loop" 0 "" \ +check "...leaves the live claim assigned" 1 "" \ grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$subprocess_out" -check "...and performs the release edit from the executable path" 0 "" \ +check "...performs no release edit" 1 "" \ grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \ "$ARRIVAL/fixtures/edits" @@ -698,6 +1779,151 @@ check "a PR reads as a PR on either shape" 0 "pr" \ check "the old has() test misreads a forgejo issue as a PR" 0 "pr" \ bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e "has(\"pull_request\") | not" >/dev/null && echo issue || echo pr' +# ...AND THE GATHER, because the rows above assert jq expressions in isolation +# and passed all the way through #210 — a sweep that saw zero issues on every +# pass and printed `reconciled.` The 0.6.0 merge reintroduced the has() form in +# the board gather; these cases could not see it because they never ran it. +# +# The fixture is FORGEJO-SHAPED: every entry carries `pull_request`, valued +# null on an issue and an object on a PR. On a GitHub-shaped board (key absent +# on issues) both discriminators agree, which is why this needs its own board. +FORGEJO_BOARD="$TMP/forgejo-board" +mkdir -p "$FORGEJO_BOARD" +cp "$ARRIVAL/labels.conf" "$FORGEJO_BOARD/labels.conf" 2>/dev/null || true +printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_pulls_state_open.json" +printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_pulls_state_closed.json" +printf '%s\n' \ + '[{"number":60,"pull_request":null,"labels":[],"title":"an issue with no queue state"}, + {"number":61,"pull_request":{"merged":false},"labels":[],"title":"a pull request"}]' \ + >"$FORGEJO_BOARD/repos_owner_repo_issues_state_open.json" +jq -n --arg at "$(iso_at "$INOW")" \ + '{number:60,user:{login:"triage-one"},created_at:$at,body:"",pull_request:null, + labels:[],assignees:[]}' \ + >"$FORGEJO_BOARD/repos_owner_repo_issues_60.json" +printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_60_comments.json" +forgejo_board_run() { + : >"$FORGEJO_BOARD/edits" + env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$FORGEJO_BOARD" \ + ISSUEFLOW_NOW="$INOW" REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +} +fjb_out="$(forgejo_board_run)" +check "a forgejo-shaped board is NOT read as empty" 1 "" \ + grep -qF 'issueflow: no open issues.' <<<"$fjb_out" +check "...the sweep completes over it" 0 "" \ + grep -qF 'issueflow: reconciled.' <<<"$fjb_out" +# TRAVERSAL, not merely a non-empty gather: the null-valued row has a +# deterministic outcome — no queue state means needs-triage is minted — so this +# proves reconcile_issue_pass actually ran over it, which "the board is not +# empty" does not (@codex-reviewer-andresmgsl, #210 review). +check "...the null-valued row is TRAVERSED, with an observable outcome" 0 "" \ + grep -qE '^issueflow: #60: needs-triage' <<<"$fjb_out" +check "...and the object-valued PR row is not reconciled as an issue" 1 "" \ + grep -qE '^issueflow: #61' <<<"$fjb_out" + +# release_bodies is the THIRD producer and has its own has() site. A `release` +# issue on a forgejo-shaped board must reach the window gather, or the #292 +# flags are decided over an empty set (@codex-reviewer-andresmgsl, #210). +printf '%s\n' \ + '[{"number":60,"pull_request":null,"labels":[{"name":"ready"}],"title":"an issue"}, + {"number":62,"pull_request":null,"labels":[{"name":"release"}],"title":"Release 9.9.9","body":"Blocked by #60."}, + {"number":63,"pull_request":null,"labels":[{"name":"ready"}],"title":"a claimable non-member"}, + {"number":61,"pull_request":{"merged":false},"labels":[],"title":"a pull request"}]' \ + >"$FORGEJO_BOARD/repos_owner_repo_issues_state_open.json" +jq -n --arg at "$(iso_at "$INOW")" \ + '{number:63,user:{login:"triage-one"},created_at:$at,body:"",pull_request:null, + labels:[{name:"ready"}],assignees:[]}' \ + >"$FORGEJO_BOARD/repos_owner_repo_issues_63.json" +printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_63_comments.json" +jq -n --arg at "$(iso_at "$INOW")" \ + '{number:62,user:{login:"triage-one"},created_at:$at,body:"Blocked by #60.",pull_request:null, + labels:[{name:"release"}],assignees:[]}' \ + >"$FORGEJO_BOARD/repos_owner_repo_issues_62.json" +printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_62_comments.json" +fjb2_out="$(forgejo_board_run)" +# The observable effect of release_bodies being NON-empty: an open `release` +# issue whose gate still holds an open member makes every claimable non-member +# draw a window flag. With that gather empty there are no carriers and no flag, +# so this row discriminates the site rather than merely reaching it. +check "a release issue on a forgejo-shaped board reaches the window gather" 0 "" \ + grep -qE '#63: window flag' <<<"$fjb2_out" +check "...and the sweep still completes" 0 "" \ + grep -qF 'issueflow: reconciled.' <<<"$fjb2_out" + +# The THIRD site is reconcile_issue_pass's per-issue payload check. Same key, +# scalar rather than a list: null means issue, an object means PR. +# THE SCALAR SITE, ISOLATED. `BOARD_RECORDS` filters an object-valued row out +# of the LIST before the per-issue guard ever sees it, so a board fixture alone +# cannot prove the scalar stand-down (@codex-reviewer-andresmgsl, #210 review). +# +# The fixture that isolates it: the LIST row is null-valued, so the board +# gather admits it — and the INDIVIDUAL payload the sweep then fetches is +# object-valued. Only reconcile_issue_pass's own guard can stand that down. +printf '%s\n' \ + '[{"number":65,"pull_request":null,"labels":[],"title":"list says issue, payload says PR"}]' \ + >"$FORGEJO_BOARD/repos_owner_repo_issues_state_open.json" +jq -n --arg at "$(iso_at "$INOW")" \ + '{number:65,user:{login:"triage-one"},created_at:$at,body:"", + pull_request:{merged:false},labels:[],assignees:[]}' \ + >"$FORGEJO_BOARD/repos_owner_repo_issues_65.json" +printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_65_comments.json" +fjb3_out="$(forgejo_board_run)" +check "the per-issue guard stands down an object-valued payload" 1 "" \ + grep -qE '^issueflow: #65: needs-triage' <<<"$fjb3_out" +check "...and the sweep still completes" 0 "" \ + grep -qF 'issueflow: reconciled.' <<<"$fjb3_out" + +# The same fixture with a null-valued payload MUST reconcile — otherwise the row +# above would pass on a guard that stands everything down. +jq -n --arg at "$(iso_at "$INOW")" \ + '{number:65,user:{login:"triage-one"},created_at:$at,body:"", + pull_request:null,labels:[],assignees:[]}' \ + >"$FORGEJO_BOARD/repos_owner_repo_issues_65.json" +fjb4_out="$(forgejo_board_run)" +check "...while a null-valued payload at the same site reconciles" 0 "" \ + grep -qE '^issueflow: #65: needs-triage' <<<"$fjb4_out" + +# And the GitHub shape — key absent entirely — is still an issue. +jq -n --arg at "$(iso_at "$INOW")" \ + '{number:65,user:{login:"triage-one"},created_at:$at,body:"",labels:[],assignees:[]}' \ + >"$FORGEJO_BOARD/repos_owner_repo_issues_65.json" +fjb5_out="$(forgejo_board_run)" +check "...and a github-shaped payload (key absent) reconciles too" 0 "" \ + grep -qE '^issueflow: #65: needs-triage' <<<"$fjb5_out" + +# -- the rule is pinned at the source, because a comment did not hold -------- +# `.pull_request == null` is stated in this file's own header AND at +# issueflow-reconcile.sh:1113 — and the merge put `has("pull_request")` back 40 +# lines below that comment, in three places. Prose is not a guard (#210). +# In-process, not `bash -c`: a subshell cannot see this file's functions, and a +# pin that silently inspected nothing would be the same defect one level up. +no_has_pull_request() { + local hits + hits="$(sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" \ + | grep -n 'has("pull_request")')" + [ -z "$hits" ] || { printf 'executable has("pull_request") at:\n%s\n' "$hits" >&2; return 1; } +} +check "no executable has(\"pull_request\") survives on this surface" 0 "" \ + no_has_pull_request +# The controls, because this guard MUST tolerate the #188 comment that explains +# why the form is wrong — a raw grep would either fail forever or pressure a +# builder into deleting the very warning that prevents recurrence +# (@codex-reviewer-andresmgsl, #210 review). +DTMP="$(mktemp -d)"; trap 'rm -rf "$DTMP"' EXIT +strip_and_find() { # $1 = file -> 0 when an EXECUTABLE use survives + sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$1" | grep -q 'has("pull_request")' +} +# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold +printf '%s\n' '#!/usr/bin/env bash' \ + '# `.pull_request == null`, NOT has("pull_request") | not (#188)' \ + 'jq -e ".pull_request == null" <<<"$J"' >"$DTMP/prose.sh" +check "the explanatory #188 comment is allowed" 1 "" strip_and_find "$DTMP/prose.sh" +# single-quoted so the fixture holds the LITERAL form the guard looks for +printf '%s\n' '#!/usr/bin/env bash' \ + "jq -r '.[] | select(has(\"pull_request\") | not)' <<<\"\$J\"" >"$DTMP/exec.sh" +check "...while an executable jq filter is rejected" 0 "" strip_and_find "$DTMP/exec.sh" + # -- the OPEN-pull gather, at main() granularity ---------------------------- # The closed/merged half above proves one REST path; this proves the other, # which is a DIFFERENT pipeline: `.body | @base64` -> base64 -d -> @@ -737,7 +1963,7 @@ for n in 50 51; do done : >"$ARRIVAL/fixtures/edits" open_pr_out="$( - env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ + env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \ CEREMONY_FORGE=github ISSUEFLOW_NOW="$INOW" ISSUEFLOW_STALE_HOURS=1 \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 @@ -760,4 +1986,848 @@ check "a dead API on the arrival path still fails the run (D2)" 0 "" \ check "...and the sweep does not run over a lying arrival" 1 "" \ grep -qF 'issueflow: reconciled.' <<<"$err_out" +# --------------------------------------------------------------------------- +# The whole sweep over an unreadable board (#247), executed. The sourced +# probes above drive one issue's pass; only this path exercises the loop, the +# counting and the tail — and only this path reproduces crew#329's log, which +# ended `issueflow: reconciled.` with rc=0 over a label it should never have +# written. Its own fixture directory: the arrival fixtures above are stateful +# across their cases. +# --------------------------------------------------------------------------- +SWEEP="$TMP/sweep" +mkdir -p "$SWEEP" +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$SWEEP/repos_owner_repo_pulls_state_open.json" +cp "$SWEEP/repos_owner_repo_pulls_state_open.json" "$SWEEP/repos_owner_repo_pulls_state_closed.json" +# 70: the 504 with a JSON error body on the per-issue read. +printf '%s\n' "$GH_STUB_ERROR_BODY" >"$SWEEP/repos_owner_repo_issues_70.json.http-error" +# 71: healthy, and carrying no queue label — so if the sweep reaches it, it +# writes needs-triage. That write is the evidence the loop continued. +printf '%s\n' \ + '{"number":71,"user":{"login":"triage-one"},"labels":[{"name":"enhancement"}],"assignees":[]}' \ + >"$SWEEP/repos_owner_repo_issues_71.json" +# 72: HTTP 200 whose body is `null` — exit 0, and the label set empties just +# as it does on the 504. The shape check is the only thing that catches it. +printf 'null\n' >"$SWEEP/repos_owner_repo_issues_72.json" + +sweep_board() { printf '%s\n' "$1" >"$SWEEP/repos_owner_repo_issues_state_open.json"; } +sweep_run() { + : >"$SWEEP/edits" + env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$SWEEP" ISSUEFLOW_NOW="$INOW" \ + REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +} + +# The board read is a precondition for the whole pass (#257). A failed read +# cannot be inferred from an empty result: its status is the only fact that +# separates an unreadable board from a clean one. Drive both through main(), +# including gh's partial-pagination shape where stdout is non-empty on error. +board_fixture="$SWEEP/repos_owner_repo_issues_state_open.json" +printf '%s\n' "$GH_STUB_ERROR_BODY" >"$board_fixture.http-error" +board_504_out="$(sweep_run)" +board_504_rc=$? +check "an issue-list 504 aborts the whole pass" 0 "" test "$board_504_rc" -eq 1 +check "...names the board read's stderr" 0 \ + "issueflow: could not read the issue board: $GH_STUB_STDERR" \ + printf '%s\n' "$board_504_out" +check "...writes no issue edit or comment" 1 "" test -s "$SWEEP/edits" +check "...never reports the pass reconciled" 1 "" \ + grep -qF 'issueflow: reconciled.' <<<"$board_504_out" + +board_silent_out="$(GH_STUB_STDERR="" sweep_run)" +board_silent_rc=$? +check "a silent issue-list failure still aborts" 0 "" test "$board_silent_rc" -eq 1 +check "...renders the empty stderr as a fact" 0 \ + 'issueflow: could not read the issue board: no error output' \ + printf '%s\n' "$board_silent_out" +check "...also writes nothing" 1 "" test -s "$SWEEP/edits" + +printf '[{"number":71}]\n' >"$board_fixture.http-error" +partial_board_out="$(sweep_run)" +partial_board_rc=$? +check "partial pagination aborts the whole pass" 0 "" \ + test "$partial_board_rc" -eq 1 +check "...does not reconcile the returned first page" 1 "" \ + grep -qF 'issue edit 71' "$SWEEP/edits" +check "...does not report the truncated pass reconciled" 1 "" \ + grep -qF 'issueflow: reconciled.' <<<"$partial_board_out" + +rm -f "$board_fixture.http-error" +sweep_board '[]' +empty_board_out="$(sweep_run)" +empty_board_rc=$? +check "a successful empty board stays green" 0 "" test "$empty_board_rc" -eq 0 +check "...writes nothing" 1 "" test -s "$SWEEP/edits" +check "...names the empty-board outcome" 0 'issueflow: no open issues.' \ + printf '%s\n' "$empty_board_out" +check "...still ends with byte-identical reconciled." 0 \ + 'issueflow: reconciled.' printf '%s\n' "$(tail -n1 <<<"$empty_board_out")" + +sweep_board '[{"number":70},{"number":71}]' +sweep_out="$(sweep_run)" +sweep_rc=$? +check "an unreadable issue does not red the sweep (D7)" 0 "" test "$sweep_rc" -eq 0 +check "the 504's JSON error body is skipped, with the reason named" 0 \ + "issueflow: #70: skipped this pass — could not read the issue: $GH_STUB_STDERR" \ + printf '%s\n' "$sweep_out" +check "...and crew#329's label is never written" 1 "" \ + grep -qF '#70: needs-triage (no queue state)' <<<"$sweep_out" +check "...nor any edit at all on the unreadable issue" 1 "" \ + grep -qF 'issue edit 70' "$SWEEP/edits" +check "...while the readable issue beside it is reconciled as before" 0 "" \ + grep -qxF 'issue edit 71 -R owner/repo --add-label needs-triage' "$SWEEP/edits" +check "...and the partial pass names its count and its issue" 0 \ + 'issueflow: 1 issue skipped this pass on an unreadable fact: #70' \ + printf '%s\n' "$sweep_out" +check "...after a byte-identical reconciled. line" 0 "" \ + grep -qxF 'issueflow: reconciled.' <<<"$sweep_out" + +sweep_board '[{"number":72}]' +null_out="$(sweep_run)" +null_rc=$? +check "an HTTP 200 whose body is null exits 0 and writes nothing" 0 "" \ + test "$null_rc" -eq 0 +check "...because the shape check refuses it, on its own line" 0 \ + 'issueflow: #72: skipped this pass — the issue read answered a payload that is not issue #72 carrying a label array' \ + printf '%s\n' "$null_out" +check "...so no label is derived from an empty label set" 1 "" \ + grep -qF 'issue edit 72' "$SWEEP/edits" +check "...and the tail names it too" 0 \ + 'issueflow: 1 issue skipped this pass on an unreadable fact: #72' \ + printf '%s\n' "$null_out" + +sweep_board '[{"number":70},{"number":72}]' +both_out="$(sweep_run)" +check "two skipped issues are both named, in the plural" 0 \ + 'issueflow: 2 issues skipped this pass on unreadable facts: #70 #72' \ + printf '%s\n' "$both_out" + +sweep_board '[{"number":71}]' +whole_out="$(sweep_run)" +whole_rc=$? +check "a whole pass still exits 0" 0 "" test "$whole_rc" -eq 0 +check "...ends on the byte-identical reconciled. line, with no tail after it" 0 \ + "issueflow: reconciled." printf '%s\n' "$(tail -n1 <<<"$whole_out")" +check "...and says nothing about skipping" 1 "" \ + grep -q 'skipped this pass' <<<"$whole_out" + +# --------------------------------------------------------------------------- +# The ordering invariant (#247 D1): a skip implies ZERO writes, wherever in +# the pass the failed read lives. Round 1 measured what the per-read guards +# alone left standing — a pass could remove `stale`, or mint `needs-triage`, +# and only then reach a guarded read, fail it, and report the issue as +# skipped. The sweep said it had touched nothing while a write had landed: +# the same false report #247 exists to close, one layer along. +# +# Every composition is driven TWICE against identical fixtures, differing +# only in whether the late read answers. The healthy run is the control — it +# proves the mutation is genuinely on this path, so the failing run's "no +# edit" is a fact about the guard and not about a branch that never fired. +# Executed through the sweep, because staging is a property of the pass. +# --------------------------------------------------------------------------- +ORDER="$TMP/order" +mkdir -p "$ORDER" +cp "$SWEEP/repos_owner_repo_pulls_state_open.json" "$SWEEP/repos_owner_repo_pulls_state_closed.json" "$ORDER/" +order_board() { printf '%s\n' "$1" >"$ORDER/repos_owner_repo_issues_state_open.json"; } +order_fixture() { # $1 issue, $2 labels JSON, $3 body + jq -n --argjson n "$1" --argjson labels "$2" --arg body "${3:-}" \ + --arg at "$(iso_at $((INOW - 10 * 86400)))" \ + '{number: $n, created_at: $at, user: {login: "triage-one"}, + labels: $labels, assignees: [], body: $body}' \ + >"$ORDER/repos_owner_repo_issues_$1.json" +} +order_run() { + : >"$ORDER/edits" + env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ORDER" ISSUEFLOW_NOW="$INOW" \ + REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +} +# The late read fails, or answers. `guarded_read` is what turns either into a +# skip, so which endpoint carries the sentinel is what picks the composition. +order_breaks() { printf '%s\n' "$GH_STUB_ERROR_BODY" >"$ORDER/repos_owner_repo_issues_$1_$2.json.http-error"; } +order_heals() { rm -f "$ORDER/repos_owner_repo_issues_$1_$2.json.http-error"; } +# A skip must leave no trace of the staged effect: not the write, and not the +# log line that would have announced it. Both halves, because a landed write +# under a "skipped" line and a "reconciled" line over no write are the same +# lie told from opposite ends. +order_wrote() { grep -qF "issue $2 $1" "$ORDER/edits"; } + +# -- 1. unstale, then a failed activity read (the round's first composition) - +# `needs-ruling` heals an applied `stale` off before the tail reads the +# issue's activity. The read is two statements later; the write is already +# gone. +order_fixture 80 '[{"name":"ready"},{"name":"needs-ruling"},{"name":"stale"}]' +order_board '[{"number":80}]' +order_heals 80 comments +healthy_unstale="$(order_run)" +check "the control: a healthy pass really does unstale a pending ruling" 0 "" \ + order_wrote 80 edit +check "...and says so" 0 "issueflow: #80: unstale (a ruling is pending)" \ + printf '%s\n' "$healthy_unstale" +order_breaks 80 comments +broken_unstale="$(order_run)" +check "a failed activity read skips the unstale composition" 0 \ + "issueflow: #80: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ + printf '%s\n' "$broken_unstale" +check "...and the stale label is still on the issue" 1 "" order_wrote 80 edit +check "...and nothing claims it came off" 1 "" \ + grep -qF 'unstale (a ruling is pending)' <<<"$broken_unstale" + +# -- 2. ADD_NEEDS_TRIAGE, then a failed activity read (the second) ----------- +# The mint falls through — unlike FLAG_CONFLICT, which returns — into the +# same tail. crew#329's own label, written and then disowned by the log. +order_fixture 81 '[{"name":"enhancement"},{"name":"needs-ruling"}]' +order_board '[{"number":81}]' +order_heals 81 comments +healthy_mint="$(order_run)" +check "the control: a healthy pass really does mint needs-triage here" 0 "" \ + order_wrote 81 edit +check "...and says so" 0 "issueflow: #81: needs-triage (no queue state)" \ + printf '%s\n' "$healthy_mint" +order_breaks 81 comments +broken_mint="$(order_run)" +check "a failed activity read skips the needs-triage composition" 0 \ + "issueflow: #81: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ + printf '%s\n' "$broken_mint" +check "...and crew#329's label is not written on the way out" 1 "" \ + order_wrote 81 edit +check "...and nothing claims it was" 1 "" \ + grep -qF '#81: needs-triage (no queue state)' <<<"$broken_mint" + +# -- 3. the blockers->ready flip, then a failed COMMENTS read ---------------- +# The read that guards this branch is the marker check ahead of the parse +# echo: a broken comments endpoint skips there, before the echo or the flip +# is staged. The timeline is no longer an input on this path at all (#284 +# D1 — the ruling clock reads comments alone), so the unreadable-timeline +# case moved from "skips everything" to its own pin below. +printf '%s\n' '{"number":82,"state":"closed"}' \ + >"$ORDER/repos_owner_repo_issues_82.json" +order_fixture 83 '[{"name":"blocked"},{"name":"needs-ruling"}]' 'Blocked by #82.' +order_board '[{"number":83}]' +order_heals 83 comments +order_heals 83 timeline +healthy_flip="$(order_run)" +check "the control: a healthy pass really does flip cleared blockers to ready" 0 \ + "issueflow: #83: blockers closed -> ready" printf '%s\n' "$healthy_flip" +check "...writing the label edit" 0 "" order_wrote 83 edit +check "...and posting the blockers-cleared comment" 0 "" order_wrote 83 comment +order_breaks 83 comments +broken_flip="$(order_run)" +check "a failed comments read skips the blockers->ready composition" 0 \ + "issueflow: #83: skipped this pass — could not read its comments: $GH_STUB_STDERR" \ + printf '%s\n' "$broken_flip" +check "...leaving the issue blocked" 1 "" order_wrote 83 edit +check "...with no comment posted about it" 1 "" order_wrote 83 comment +check "...and nothing claiming the flip happened" 1 "" \ + grep -qF 'blockers closed -> ready' <<<"$broken_flip" +# The read this path no longer takes cannot skip it (#284): with comments +# healthy and the timeline broken, the flip commits, and only the ruling +# ladder's own soft-failing read goes without — no verdict is invented, and +# no unrelated write is held hostage by an input the clocks stopped reading. +order_heals 83 comments +order_breaks 83 timeline +narrowed_flip="$(order_run)" +check "a failed timeline read no longer skips the flip" 1 "" \ + grep -qF 'skipped this pass' <<<"$narrowed_flip" +check "...the flip commits" 0 "" order_wrote 83 edit +check "...and the ruling ladder says what it could not read" 0 \ + "issueflow: #83: ruling timeline unreadable — no verdict invented this pass" \ + printf '%s\n' "$narrowed_flip" + +# -- 4. a posted nudge, then a failed COMMENTS read ------------------------- +# The comment-only half of the class: the epic nudge's own marker check is +# the read that fails, so the nudge is never staged and the skip reports the +# truth. A comment is as much a mutation as a label — it is the thing +# markers exist to make idempotent. +order_fixture 84 '[{"name":"epic"},{"name":"needs-ruling"}]' \ + '## Task list + +- [x] #82' +order_board '[{"number":84}]' +order_heals 84 comments +order_heals 84 timeline +healthy_nudge="$(order_run)" +check "the control: a healthy pass really does nudge a completed epic" 0 \ + "issueflow: #84: completed epic nudged" printf '%s\n' "$healthy_nudge" +check "...by posting a comment" 0 "" order_wrote 84 comment +order_breaks 84 comments +broken_nudge="$(order_run)" +check "a failed comments read skips the epic-nudge composition" 0 \ + "issueflow: #84: skipped this pass — could not read its comments: $GH_STUB_STDERR" \ + printf '%s\n' "$broken_nudge" +check "...and the nudge comment is never posted" 1 "" order_wrote 84 comment +check "...and nothing claims it was" 1 "" \ + grep -qF 'completed epic nudged' <<<"$broken_nudge" +# The narrowed surface again (#284): a broken timeline neither skips nor +# suppresses the nudge; the ruling ladder alone goes without a verdict. +order_heals 84 comments +order_breaks 84 timeline +narrowed_nudge="$(order_run)" +check "a failed timeline read no longer skips the epic nudge" 1 "" \ + grep -qF 'skipped this pass' <<<"$narrowed_nudge" +check "...the nudge commits" 0 "" order_wrote 84 comment + +# -- the skip is still just a skip: counted, tailed, and green (D4, D6, D7) -- +check "a mutation-bearing composition that skips is still not a crash" 1 "" \ + grep -qF 'reconcile failed' <<<"$broken_flip" +check "...is still counted in the D6 tail" 0 \ + 'issueflow: 1 issue skipped this pass on an unreadable fact: #83' \ + printf '%s\n' "$broken_flip" +order_board '[{"number":83}]' +order_run >/dev/null +check "...and still leaves the job green (D7)" 0 "" test $? -eq 0 + +# -- the two board flags (#293): the deliverable key, normalized ------------ +# The 2026-08-04 miss spelled one deliverable two ways, so exact-prefix +# matching is specified away (D2). These pin the normalization itself. +count_lines() { deliverable_keys | grep -c .; } +keys_of() { # title on stdin -> its keys, each bracketed so `check` matches exactly + # ANCHORED, because `check` compares its expectation as a substring: a bare + # `issueflow-reconcile` expectation is satisfied by `issueflow-reconcile.test` + # too, so the multi-extension row below stayed green under a normalization + # stripping only the last extension — it asserted nothing it was named for. + # Bracketing each key makes every row here fail for its own reason. + deliverable_keys | sed 's/.*/[&]/' +} +check "the em-dash prefix is the key" 0 "[issueflow-reconcile]" \ + keys_of <<<"issueflow-reconcile — the ruling clock counts assigned" +check "a leading actions/ segment comes off" 0 "[issueflow-reconcile]" \ + keys_of <<<"actions/issueflow-reconcile — a failed board read" +check "...and so does .github/" 0 "[labeler]" \ + keys_of <<<".github/labeler.yml — one wrong answer left by D4" +check "...and lib/" 0 "[attention]" keys_of <<<"lib/attention.sh — the target" +check "...and bin/" 0 "[decide]" keys_of <<<"bin/decide.sh — the door" +check "every extension comes off, not just the last" 0 "[issueflow-reconcile]" \ + keys_of <<<"issueflow-reconcile.test.sh — the pre-read is unpinned" +check "the key folds case" 0 "[triage]" keys_of <<<"TRIAGE.md — the bullet" +check "a + title carries both segments" 0 $'[triage]\n[releases]' \ + keys_of <<<"TRIAGE.md + RELEASES.md — a standing window is a graph" +# A path segment the rule does not name stays part of the key: the strip list +# is closed on purpose (D2), so `test/issueflow-reconcile.test.sh` is its own +# deliverable and not the action it exercises. +check "an unlisted path segment stays in the key" 0 "[test/issueflow-reconcile]" \ + keys_of <<<"test/issueflow-reconcile.test.sh — the pre-read" +# One issue answers a SET. Normalization is many-to-one by design, so a `+` +# title can spell one deliverable twice — a deliverable and its test named +# together is the ordinary shape here, not an exotic one — and a repeated key +# makes the chain scan find the issue adjacent to ITSELF. +check "a + title whose segments normalize to one key answers that key once" 0 \ + "[issueflow-reconcile]" \ + keys_of <<<"issueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable" +check "...and answers it exactly once, not twice" 0 "1" \ + count_lines <<<"issueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable" +check "...and the path prefix folds onto the bare spelling the same way" 0 "1" \ + count_lines <<<"actions/issueflow-reconcile + issueflow-reconcile.sh — still one" +# No em dash, no key. Inventing one out of prose is the guessing this sweep +# never does; the malformed title is triage's own contract to enforce. The +# emptiness is asserted through grep's exit, since `check` cannot assert an +# empty expectation. +check "a title with no em dash names no deliverable" 1 "" \ + grep -q . < <(deliverable_keys <<<"a title that names nothing") + +# -- the collision decision: a chain, never a fan (#288 D3) ------------------ +# Sourced helpers, not `bash -c`: a subshell started with -c has none of these +# functions, and a pipeline ending in grep would then answer "no match" from a +# command-not-found and pass a negative case for the wrong reason. +collision_chain() { collision_key_index | collision_flags; } +collision_flags_issue() { collision_chain | grep -q "^$1"; } +window_flags_issue() { # $1 issue, $2 gate, $3 carriers; records on stdin + window_flags "$2" "$3" | grep -qx "$1" +} +collision_board=$'253\tclaimed\tissueflow-reconcile — release-init\n257\tclaimed\tactions/issueflow-reconcile — a failed board read\n284\tready\tissueflow-reconcile — the ruling clock' +check "three issues on one deliverable chain, each naming the newest below it" 0 \ + $'257\tissueflow-reconcile=253\n284\tissueflow-reconcile=257' \ + collision_chain <<<"$collision_board" +check "...so the oldest carrier is never itself flagged" 1 "" \ + collision_flags_issue 253 <<<"$collision_board" +check "a lone carrier draws nothing" 0 "" \ + collision_chain <<<$'284\tready\tissueflow-reconcile — alone' +# `blocked` is the GOAL state of #288's rule; flagging it reports the fix as +# the defect. Both legs of the test plan, on one board. +check "two blocked twins are the declared chain, not a collision" 0 "" \ + collision_chain \ + <<<$'264\tblocked\tTRIAGE.md — one\n266\tblocked\tTRIAGE.md — two' +check "a blocked twin does not carry a ready one's edge either" 0 "" \ + collision_chain \ + <<<$'264\tblocked\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two' +check "an epic carrying the key is outside the claimable set (#288 D6)" 0 "" \ + collision_chain \ + <<<$'264\tepic\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two' +check "a post-merge carrier is outside it too" 0 "" \ + collision_chain \ + <<<$'264\tpost-merge\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two' +# The #284 shape, stated as its own case (test plan): a `claimed` issue whose +# PR is already in flight is the STRONGEST collision on the board, not a +# weaker one, and the flag reads the queue label rather than the PR link. +check "a claimed carrier with a PR in flight still carries the collision" 0 \ + $'284\tissueflow-reconcile=253' \ + collision_chain \ + <<<$'253\tclaimed,scope:labels\tissueflow-reconcile — release-init\n284\tready\tissueflow-reconcile — the ruling clock' +# One issue, two colliding deliverables: ONE offending state, one comment (D4). +check "a multi-file title folds its collisions into one state" 0 \ + $'295\treleases=292,triage=264' \ + collision_chain \ + <<<$'264\tready\tTRIAGE.md — one\n292\tready\tRELEASES.md — two\n295\tready\tTRIAGE.md + RELEASES.md — three' +# ...and an issue can never be its own carrier. A `+` title whose segments +# normalize to one key contributed that key twice, and the chain scan, which +# reads adjacent rows within a key, then found the issue beside itself: the +# comment asked #402 to declare `Blocked by #402`. +check "a self-folding + title never chains an issue to its own number" 0 "" \ + collision_chain \ + <<<$'402\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable' +check "...and two such carriers chain once, to each other" 0 \ + $'284\tissueflow-reconcile=257' \ + collision_chain \ + <<<$'257\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one\n284\tready\tactions/issueflow-reconcile — two' +check "...with the older carrier still asked for nothing" 1 "" \ + collision_flags_issue 257 \ + <<<$'257\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one\n284\tready\tactions/issueflow-reconcile — two' + +# -- the window decision (#292 D1) ------------------------------------------ +window_board=$'249\tblocked,release\tRelease 0.6.0 — the board empties\n253\tclaimed\tissueflow-reconcile — a member\n264\tready\tTRIAGE.md — a non-member\n270\tepic\tsome epic — exempt\n271\tpost-merge\tsome item — exempt\n272\tblocked\tsome issue — already placed' +check "a ready non-member is flagged during a standing window" 0 "264" \ + window_flags "253" "249" <<<"$window_board" +check "...and a gate member is not" 1 "" \ + window_flags_issue 264 $'253\n264' 249 <<<"$window_board" +check "...nor an epic (#292 D1 exempts it by name)" 1 "" \ + window_flags_issue 270 253 249 <<<"$window_board" +check "...nor a post-merge issue" 1 "" \ + window_flags_issue 271 253 249 <<<"$window_board" +check "...nor a blocked issue, which is already placed behind something" 1 "" \ + window_flags_issue 272 253 249 <<<"$window_board" +# The release issue is the graph's SINK (#292 D2), so it can never be its own +# non-member — even when its own labels would otherwise admit it. +check "the window carrier is never flagged as its own non-member" 1 "" \ + window_flags_issue 249 253 249 \ + <<<$'249\tready,release\tRelease 0.6.0 — the board empties' +check "no standing window means no flag at all" 0 "" \ + window_flags "" "" <<<"$window_board" +check "two standing windows render as one state" 0 "#249, #250" window_state $'249\n250\n' +# ONE reading of `unblocked` across both flags. D2 as corrected glosses the +# word as "carrying `ready` or `claimed`" and D3b says D3 uses that gloss and +# names the domain as the claimable set, so an issue that is `needs-triage` or +# carries no queue label at all is outside BOTH flags. Excluding only +# `blocked`/`epic`/`post-merge` admitted them, and the second case is the one +# that showed: the same pass adds `needs-triage` to an unlabeled issue and +# then tells it about a membership call made at mint time. +scope_board=$'249\tblocked,release\tRelease 0.6.0 — the board empties\n253\tclaimed\tissueflow-reconcile — a member\n400\tneeds-triage\tTRIAGE.md — not through the door yet\n401\t\tTRIAGE.md — no queue label at all\n402\tclaimed\tREVIEWER.md — claimable, and a non-member' +check "a needs-triage issue is not in the window flag's domain" 1 "" \ + window_flags_issue 400 253 249 <<<"$scope_board" +check "...nor is an issue carrying no queue label at all" 1 "" \ + window_flags_issue 401 253 249 <<<"$scope_board" +check "...while the claimable non-member beside them still flags" 0 "402" \ + window_flags "253" "249" <<<"$scope_board" +# The same word, asserted through the other flag, so the two can never drift +# apart again without a red. +check "the collision flag reads that word identically" 0 "" \ + collision_chain <<<$'400\tneeds-triage\tTRIAGE.md — one\n401\t\tTRIAGE.md — two' +check "...and both flags answer one shared predicate" 1 "" \ + unblocked_claimable "needs-triage" +check "...which admits ready and claimed, and nothing else" 0 "" \ + unblocked_claimable "claimed,scope:labels" + +# -- the 2026-08-04 board, replayed whole (D5) ------------------------------ +# The corpus the operator ruled on. Both flags are decided over the WHOLE +# board, so a sourced decision probe cannot exercise the gather — these run +# the script as a subprocess behind the PATH-stubbed gh, #91's lesson applied +# to a board-wide check. +BOARD="$TMP/board" +mkdir -p "$BOARD" +cp "$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json" "$BOARD/repos_owner_repo_pulls_state_open.json" +cp "$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json" "$BOARD/repos_owner_repo_pulls_state_closed.json" + +board_issue() { # $1 number, $2 labels(csv), $3 title, $4 body, $5 assignee count + local labels_json + labels_json="$(printf '%s' "$2" | tr ',' '\n' \ + | jq -R . | jq -sc 'map(select(. != "") | {name: .})')" + jq -n --argjson n "$1" --argjson labels "$labels_json" --arg t "$3" \ + --arg b "${4:-}" --argjson a "${5:-0}" --arg at "$(iso_at "$INOW")" \ + '{number: $n, state: "open", title: $t, body: $b, labels: $labels, + created_at: $at, user: {login: "triage-one"}, + assignees: (if $a > 0 then [{login: "builder-bot"}] else [] end)}' \ + >"$BOARD/repos_owner_repo_issues_$1.json" +} + +board_assemble() { # numbers… -> the open-issue list, with fresh comment threads + local n + for n in "$@"; do printf '[]\n' >"$BOARD/repos_owner_repo_issues_${n}_comments.json"; done + # shellcheck disable=SC2016 # the filename expansion belongs to the loop below + for n in "$@"; do cat "$BOARD/repos_owner_repo_issues_$n.json"; done \ + | jq -sc . >"$BOARD/repos_owner_repo_issues_state_open.json" +} + +flag_count() { # $1 = collision|window, $2 = a sweep's output + grep -c ": $1 flag — " <<<"$2" +} + +board_run() { + : >"$BOARD/edits" + env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$BOARD" ISSUEFLOW_NOW="$INOW" \ + REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +} + +# The morning shape, as the board actually stood at the 10:28:54Z mint: +# #253 `claimed` with no open PR (#285 was not created until 10:49:16Z), +# #257 `ready` since the evening before, #284 minted `ready` into both of +# them — six `ready` non-members against a standing gate, and one deliverable +# carried three times in two spellings. +board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #253.' +board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 +board_issue 257 ready 'actions/issueflow-reconcile — a failed board read sweeps an empty board' +board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`' +board_issue 276 ready 'REVIEWER.md — the green-check precondition' +board_issue 281 ready 'LABELS.md — the attention row' +# The blocked twin on the same key, on the board rather than in a decision +# probe: it is neither a collision flag nor one of the six. +board_issue 282 blocked 'TRIAGE.md — the two comment links come out' 'Blocked by #266.' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 284 ready 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' +board_assemble 249 253 257 264 266 276 281 282 284 +morning_out="$(board_run)" +morning_rc=$? + +check "the morning board replays green" 0 "" test "$morning_rc" -eq 0 +# D5's named pair, and the whole reason the key is normalized: #257 spells the +# deliverable `actions/issueflow-reconcile`, #284 spells it bare. +check "#284 draws the collision flag, naming #257 across the spelling variance" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=257' \ + printf '%s\n' "$morning_out" +check "...and #257 names #253, so the flag asks for a chain and not a fan" 0 \ + 'issueflow: #257: collision flag — issueflow-reconcile=253' \ + printf '%s\n' "$morning_out" +check "...while #253, the oldest carrier, is asked for nothing" 1 "" \ + grep -qF 'issueflow: #253: collision flag' <<<"$morning_out" +check "the TRIAGE.md pair chains the same way" 0 \ + 'issueflow: #266: collision flag — triage=264' printf '%s\n' "$morning_out" +check "...while the blocked twin beside them is the goal state, not a flag" 1 "" \ + grep -qF 'issueflow: #282: collision flag' <<<"$morning_out" +check "the morning board draws exactly three collision flags" 0 "3" \ + flag_count collision "$morning_out" +# D3's corpus: the six `ready` non-members that raced the emptying gate. +for nonmember in 257 264 266 276 281 284; do + check "#$nonmember is flagged as an unblocked non-member under #249" 0 \ + "issueflow: #$nonmember: window flag — an unblocked non-member under #249" \ + printf '%s\n' "$morning_out" +done +check "the morning board draws exactly six window flags" 0 "6" \ + flag_count window "$morning_out" +check "...and never flags the gate member holding the window open" 1 "" \ + grep -qF 'issueflow: #253: window flag' <<<"$morning_out" +check "...nor the blocked issue already placed behind something" 1 "" \ + grep -qF 'issueflow: #282: window flag' <<<"$morning_out" +check "...nor the release issue that carries the window" 1 "" \ + grep -qF 'issueflow: #249: window flag' <<<"$morning_out" +# D1: comments only. Not "no unexpected edit" — no edit at all. +check "the whole replay writes no label and no state (D1)" 1 "" \ + grep -qF 'issue edit' "$BOARD/edits" +check "...and no new label is ever proposed" 1 "" \ + grep -qE 'add-label (collision|window)' "$BOARD/edits" +check "the collision comment cites the rule it is asking for" 0 "" \ + grep -qF 'collision edge' "$BOARD/edits" +check "...and names #288 as its authority" 0 "" grep -qF '#288 makes it unconditional' "$BOARD/edits" +check "the window comment names #292's invariant" 0 "" \ + grep -qF "#292's invariant" "$BOARD/edits" +# shellcheck disable=SC2016 # backticks are the comment body's own Markdown +check "...and states the subset rule with its exemptions" 0 "" \ + grep -qF 'the `ready` set is a subset of the gate' "$BOARD/edits" +check "both comments carry idempotency markers (D4)" 0 "" \ + grep -qF ' +said already" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +jq -n --arg b " +said already" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_276_comments.json" +resweep_out="$(board_run)" +check "a standing collision is silent on the next sweep (D4)" 1 "" \ + grep -qF 'issueflow: #284: collision flag' <<<"$resweep_out" +check "a standing window non-membership is silent too" 1 "" \ + grep -qF 'issueflow: #276: window flag' <<<"$resweep_out" +check "...while every other flag on the board still speaks" 0 "2" \ + flag_count collision "$resweep_out" +check "...and the window flags with it" 0 "5" \ + flag_count window "$resweep_out" +# The value-keyed marker's whole point: a state that CHANGED speaks, even +# though this family has already had its say on the thread (#252's A -> B -> A). +jq -n --arg b " +an older, different state" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +changed_out="$(board_run)" +check "a changed collision state speaks over this family's last word" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=257' \ + printf '%s\n' "$changed_out" +# And a family only ever silences itself: the blocked-parse echo's marker +# lives on many of these threads and must not read as either flag's. +jq -n --arg b " +a different family entirely" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +foreign_out="$(board_run)" +check "another family's marker never silences the collision flag" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=257' \ + printf '%s\n' "$foreign_out" +# The direction that is actually load-bearing, and that the case above cannot +# reach: a foreign family's marker landing AFTER this flag's own must not make +# the flag speak again. Family-blind, "the last marker on the thread" is the +# blocked-parse echo's, which is not this state's marker, and the flag +# re-posts a comment that already stands — the noise D4's dedup exists to +# stop, on the one thread where three families all have something to say. +jq -n --arg b " +this flag's own last word" \ + --arg c " +a different family, later on the thread" \ + '[{"user": {"login": "sweep-bot"}, "body": $b}, + {"user": {"login": "sweep-bot"}, "body": $c}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +later_foreign_out="$(board_run)" +check "a foreign family's LATER marker never makes the flag re-post" 1 "" \ + grep -qF 'issueflow: #284: collision flag' <<<"$later_foreign_out" +check "...while every other collision on the board still speaks" 0 "2" \ + flag_count collision "$later_foreign_out" + +# -- the post-ruling board draws nothing (D5's must-not-flag leg) ----------- +# The same issues after triage placed them: the TRIAGE.md triple chained +# oldest-first, the reconciler chain chained, and every one of them a gate +# member. Every flag above must go quiet, or the flag is reporting the fix. +board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #253, #257, #264, #266, #276, #281, #282, #284.' +board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 +board_issue 257 blocked 'actions/issueflow-reconcile — a failed board read sweeps an empty board' \ + 'Blocked by #253.' +board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 266 blocked 'TRIAGE.md — the epic task-list heading is literally `## Task list`' \ + 'Blocked by #264.' +board_issue 276 ready 'REVIEWER.md — the green-check precondition' +board_issue 281 ready 'LABELS.md — the attention row' +board_issue 282 blocked 'TRIAGE.md — the two comment links come out' 'Blocked by #266.' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 284 blocked 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' \ + 'Blocked by #257.' +board_assemble 249 253 257 264 266 276 281 282 284 +ruled_out="$(board_run)" +check "the post-ruling board replays green" 0 "" test $? -eq 0 +check "...and draws no collision flag at all" 1 "" \ + grep -qF ': collision flag' <<<"$ruled_out" +check "...and no window flag either" 1 "" grep -qF ': window flag' <<<"$ruled_out" +check "...and still reports a whole pass" 0 'issueflow: reconciled.' \ + printf '%s\n' "$ruled_out" + +# -- an emptied gate leaves the window flag dormant (test plan) ------------- +# A gate DECLARATION never empties: #249 names fifteen members and still names +# fifteen after all fifteen close. So the precondition is the gate's OPEN +# members, not its parse — read straight off the board, which already is the +# open set. Under the declaration reading the release issue, now `ready`, is +# itself an open unblocked non-`epic` non-member, and D3 would flag the sink +# at the exact moment the window ends. +board_issue 249 ready,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #218, #230, #232, #236, #237, #238, #241, #242, #247, #248, #251, #252, #253, #254, #257.' +board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`' +board_assemble 249 264 266 +empty_gate_out="$(board_run)" +check "a fifteen-member declaration with every member closed leaves D3 dormant" 1 "" \ + grep -qF ': window flag' <<<"$empty_gate_out" +check "...and the release issue is never flagged as its own non-member" 1 "" \ + grep -qF 'issueflow: #249' <<<"$empty_gate_out" +check "...while the collision flag beside it is unaffected" 0 \ + 'issueflow: #266: collision flag — triage=264' printf '%s\n' "$empty_gate_out" + +# -- both carriers claimed, both with their own PRs open (test plan) -------- +# The ninety-three minutes from #285's creation to its merge: under D2's +# struck parenthetical the live collision went silent for all of them, so +# whether the flag ever fired depended on where the sweep tick fell relative +# to a builder opening a PR. It fires on the board, and only on the board. +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[{"number":285,"body":"","closingIssuesReferences":{"nodes":[{"number":253}]}},{"number":286,"body":"","closingIssuesReferences":{"nodes":[{"number":284}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$BOARD/repos_owner_repo_pulls_state_open.json" +board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 284 claimed 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' '' 1 +board_assemble 253 284 +both_claimed_out="$(board_run)" +check "two claimed carriers, both with PRs in flight, still draw the newer's flag" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=253' \ + printf '%s\n' "$both_claimed_out" +check "...and both live claims are left exactly as they were" 1 "" \ + grep -qF 'issue edit' "$BOARD/edits" +# The same board with the newer side `ready`, so the queue label is visibly +# the only input the flag has. +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 284 ready 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' +board_assemble 253 284 +in_flight_out="$(board_run)" +check "a claimed carrier with an open PR draws the ready issue's flag too" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=253' \ + printf '%s\n' "$in_flight_out" + +# -- D3b's headline case, on the WINDOW side (acceptance criterion) ---------- +# The criterion says a `claimed` non-member is flagged whether or not it has +# an open PR, and it is the line the 18:11Z ruling turned on — triage had +# excluded the open-PR case at 18:06Z and corrected it five minutes later. +# The collision fixtures above cover the PR-liveness question for their flag; +# this covers it for the other one. #292's charge against the third state is +# that a non-member competes with gate members for builders, and a non-member +# holding a builder AND a review round is that competition realized. +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[{"number":403,"body":"","closingIssuesReferences":{"nodes":[{"number":402}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$BOARD/repos_owner_repo_pulls_state_open.json" +board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #253.' +board_issue 253 claimed 'issueflow-reconcile — a member holding the window open' '' 1 +board_issue 402 claimed 'REVIEWER.md — a non-member with a builder and a round' '' 1 +board_assemble 249 253 402 +nonmember_pr_out="$(board_run)" +check "a claimed non-member with an open PR still draws the window flag" 0 \ + 'issueflow: #402: window flag — an unblocked non-member under #249' \ + printf '%s\n' "$nonmember_pr_out" +check "...and the gate member beside it, also claimed with a PR, is not" 1 "" \ + grep -qF 'issueflow: #253: window flag' <<<"$nonmember_pr_out" +check "...and the live claim is left exactly as it was" 1 "" \ + grep -qF 'issue edit' "$BOARD/edits" +check "...one window flag on the board, and only one" 0 "1" \ + flag_count window "$nonmember_pr_out" + +# -- flagged, resolved, recreated unchanged: silent, and specified ---------- +# D4's boundary, asserted rather than left accidental. Nothing is posted at +# the resolution — D1 admits no comment there — so the thread's last word is +# still the state itself and an identical return says nothing new. #292 D2b +# owns the recurrence: a board state violating the window invariants is +# triage's to repair in the tick it is seen, and triage has already been told +# about this one. +jq -n --arg b " +flagged once" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +board_assemble_keep() { # board_assemble without wiping the seeded threads + local n + for n in "$@"; do cat "$BOARD/repos_owner_repo_issues_$n.json"; done \ + | jq -sc . >"$BOARD/repos_owner_repo_issues_state_open.json" +} +board_assemble_keep 284 +resolved_out="$(board_run)" +check "the collision resolves when its carrier leaves the board" 1 "" \ + grep -qF ': collision flag' <<<"$resolved_out" +check "...and the resolution itself writes nothing at all" 1 "" test -s "$BOARD/edits" +board_assemble_keep 253 284 +recreated_out="$(board_run)" +check "an unchanged state recreated is silent — D4's stated boundary" 1 "" \ + grep -qF 'issueflow: #284: collision flag' <<<"$recreated_out" + +# -- today's board draws nothing (the post-ruling shape, live) -------------- +# #249 the `blocked` sink, this issue `claimed` with no open PR and a gate +# member, #307 and #311 `blocked`. The `claimed` member is the case D3b would +# flag if membership were read wrong, which is why it is here. +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$BOARD/repos_owner_repo_pulls_state_open.json" +board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #293, #307.' +board_issue 293 claimed 'issueflow-reconcile — the sweep flags what the window and collision rules forbid' '' 1 +board_issue 307 blocked 'test/issueflow-reconcile.test.sh — the ruling pre-read is unpinned' \ + 'Blocked by #293.' +board_issue 311 blocked 'docs/CONSUMERS.md — a deliberate non-member' 'Blocked by #249.' +board_assemble 249 293 307 311 +today_out="$(board_run)" +check "today's board draws no collision flag" 1 "" grep -qF ': collision flag' <<<"$today_out" +check "...and no window flag: the claimed member is a member" 1 "" \ + grep -qF ': window flag' <<<"$today_out" +check "...and still reports a whole pass" 0 'issueflow: reconciled.' \ + printf '%s\n' "$today_out" + +# -- the invariant is enforced at the source, not remembered ---------------- +# Staging only holds while every mutation goes through run(). A future call +# site reaching gh directly would reopen this hole silently, so it is pinned +# here rather than left to review — the shape lib/ruling.sh already uses for +# #50 D9. reconcile_opened_issue is deliberately exempt: it runs outside the +# per-issue subshell, under live errexit, and stages nothing (#247 D8). +# The verbs are the shim's now, not gh's (#188, and every remaining call site +# ported by #198) — pinning `gh issue` here would pin a string this surface no +# longer contains and pass vacuously forever. +mutation_calls() { + grep -nE '(^|[^_[:alnum:]])forge_issue_(edit|comment)' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" "$ROOT/lib/ruling.sh" \ + | grep -vE '^\S+:[0-9]+: *#' || true +} +# shellcheck disable=SC2016 # positional parameters belong to bash -c +check "every issue mutation on this surface goes through run()" 0 "" \ + bash -c 'while IFS= read -r line; do + [ -n "$line" ] || continue + case "$line" in *"run forge_issue_"*) ;; *) printf "unstaged mutation: %s\n" "$line"; exit 1 ;; esac + done <<<"$1"' _ "$(mutation_calls)" +check "...and the pin sees the call sites it is guarding" 0 "" \ + test "$(mutation_calls | wc -l)" -ge 8 + +# -- the gather->consumer seam, at main() granularity (#198) ----------------- +# Every MERGED_REF_PR_RECORDS fixture above assigns the variable DIRECTLY, so +# they prove post_merge_pr_for_issue given three columns and pass unchanged if +# the gather emits two. The seam the 0.6.0 resolution actually decides is the +# one none of them span, so it is driven here through the real REST gather. + +# 1. The out-of-order pair. crew#176's shape: the HIGHER PR number merged +# EARLIER, so number order and merge order disagree. A two-column emit +# leaves $3 empty, every sort key ties, and the tie-break silently answers +# the highest number — the #242 fix undone with no error and no red. +printf '%s\n' \ + '[{"number":184,"body":"Refs #42","merged_at":"2026-07-30T19:05:16Z"},{"number":182,"body":"Refs #42","merged_at":"2026-07-30T19:05:18Z"}]' \ + >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json" +printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json" +printf '[{"number":42}]\n' \ + >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json" +jq -n --arg at "$(iso_at "$INOW")" \ + '{number:42,user:{login:"triage-one"},created_at:$at,body:"- [ ] verify after merge",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \ + >"$ARRIVAL/fixtures/repos_owner_repo_issues_42.json" +printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_42_comments.json" +: >"$ARRIVAL/fixtures/edits" +order_out="$( + env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \ + ISSUEFLOW_NOW="$INOW" REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +)" +check "the out-of-order gather still transitions the issue" 0 "" \ + grep -qF '#42: merged Refs PR -> post-merge; claim released' <<<"$order_out" +# The marker carries the deliverable's number, so it is where the answer is +# observable from outside. 182 merged LAST; 184 is the higher number. +check "...naming the PR that merged last, not the highest-numbered one" 0 "" \ + grep -qF 'post-merge-transition-pr-182' "$ARRIVAL/fixtures/edits" +check "...and never the higher number that merged earlier" 1 "" \ + grep -qF 'post-merge-transition-pr-184' "$ARRIVAL/fixtures/edits" + +# 2. The multi-line open body. open_pr_issues is line-oriented, so the decoded +# body must arrive one BODY row per PHYSICAL line. Handed over as a single +# record it loses every declaration including the first, and the claim is +# reclaimed under a live PR. `Refs #43` sits on line 3 for that reason: on +# line 1 the case would pass either way, which is a vacuous test. +printf '%s\n' \ + '[{"number":430,"body":"## Summary\nSome prose about the work.\nRefs #43\nMore prose after it.","merged_at":null}]' \ + >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json" +printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json" +printf '[{"number":43}]\n' \ + >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json" +jq -n --arg at "$(iso_at $((INOW - 7200)))" \ + '{number:43,user:{login:"triage-one"},created_at:$at,body:"- [ ] build",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \ + >"$ARRIVAL/fixtures/repos_owner_repo_issues_43.json" +printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_43_comments.json" +printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_43_timeline.json" +: >"$ARRIVAL/fixtures/edits" +multiline_out="$( + env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \ + ISSUEFLOW_NOW="$INOW" ISSUEFLOW_STALE_HOURS=1 \ + REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +)" +check "the multi-line gather completes" 0 "" \ + grep -qF 'issueflow: reconciled.' <<<"$multiline_out" +check "a Refs off the first line of an open body still rescues the claim" 1 "" \ + grep -qE 'issue edit 43 .*--remove-label claimed' "$ARRIVAL/fixtures/edits" + summary diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index 87f4107..d9e9a9a 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -26,13 +26,29 @@ forge_select github forge_stub_path() { printf '%s' "$1" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g' } -load_config .github/labels.conf -set_required_bots codex-bot-andresmgsl +RTMP="$(mktemp -d)" +trap 'rm -rf "$RTMP"' EXIT + +# The fixture roster is the test's own, and deliberately not the shipped one +# (#304). The state machine is roster-agnostic — it needs three distinct +# required logins, not THESE three — so binding the fixtures to +# .github/labels.conf by slot bought nothing and cost the file: when the +# operator shrank panel= from four members to three, the recused author left +# two, the third slot came up unbound, and set -u aborted this file before +# its first assertion. 217 assertions became 0, on main and on every branch cut +# from it, and no fixture here was about the panel's size. The shape below is +# test/labels.test.sh's, which has always written its own conf. +FIXTURE_CONF="$RTMP/fixture-labels.conf" +FIXTURE_AUTHOR=fixture-builder # The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state # machine's inputs, consumed inside the sourced decide_state — not unused. # shellcheck disable=SC2034 -BOT1="${REQUIRED_BOTS[0]}" BOT2="${REQUIRED_BOTS[1]}" BOT3="${REQUIRED_BOTS[2]}" +BOT1=fixture-bot-one BOT2=fixture-bot-two BOT3=fixture-bot-three +printf 'panel=%s %s %s %s\n' "$BOT1" "$BOT2" "$BOT3" "$FIXTURE_AUTHOR" \ + >"$FIXTURE_CONF" +load_config "$FIXTURE_CONF" +set_required_bots "$FIXTURE_AUTHOR" pass=0 fail=0 expect() { # $1 = description, $2 = want, $3 = got @@ -51,6 +67,22 @@ rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review obj reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array +# The blocker:unrequested quiescence inputs (#236 D2). Every fixture below +# inherits a readable, settled world — a head commit an hour before this +# sweep's clock — so the cases written before #236 assert exactly what they +# always asserted. The #236 block sets both per case. +# +# One consequence a new fixture has to know: a case that means to raise +# blocker:unrequested needs a REAL submitted_at on its reviews, because the +# grace dates the round's newest review. The symbolic stamps this file uses +# elsewhere (`t1`, `t2`, …) are not unreadable — GNU date reads `t1` as 01:00 +# in military timezone T, i.e. a time on WHATEVER day the suite runs — which is +# worse: the verdict would flip with the calendar, the hazard the LC_ALL pin at +# the top of this file guards on the other axis. Hence a fixed NOW here and +# real timestamps on the three stall fixtures below. +NOW="$(date -d 2026-08-03T12:00:00Z +%s)" +HEAD_COMMIT_AT=2026-08-03T11:00:00Z + # -- a sweep-wide read failure is visible without changing any PR ------------ warning="$(blind_sweep_warning 3 3 "HTTP 403: Resource not accessible by integration")" expect "a wholly blind sweep warns, leading with the observed reason" \ @@ -144,8 +176,8 @@ expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state # With a live request that is the bots' ball; with NO request outstanding it # is the agent's, because nothing is coming until somebody asks. REQUESTED="$BOT3" REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)")" + "$(rev "$BOT1" APPROVED head1 "" 2026-08-03T10:00:00Z)" \ + "$(rev "$BOT2" APPROVED head1 "" 2026-08-03T10:01:00Z)")" expect "a missing bot WITH a live request is bots-reviewing" state:bots-reviewing "$(decide_state)" REQUESTED="" expect "...but with nobody asked it is the agent's ball" state:addressing "$(decide_state)" @@ -296,15 +328,15 @@ expect "...and raises no blocker" "" "$(blockers)" MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" REVIEWS_JSON='[]' expect "ready, nobody asked, nothing reviewed raises unrequested" blocker:unrequested "$(blockers)" # ...the partial case is equally stalled: one verdict in, nobody asked for the rest -REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")" +REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" 2026-08-03T10:00:00Z)")" expect "one bot in, none requested is still unrequested" blocker:unrequested "$(blockers)" # ...a STALE round with nobody asked is the same debt, and arguably worse: the # page carries approvals that no longer describe the tree. Guarding on # MISSING alone let this one through with no blocker at all. REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED oldhead "" t1)" \ - "$(rev "$BOT2" APPROVED oldhead "" t2)" \ - "$(rev "$BOT3" APPROVED oldhead "" t3)")" + "$(rev "$BOT1" APPROVED oldhead "" 2026-08-03T10:00:00Z)" \ + "$(rev "$BOT2" APPROVED oldhead "" 2026-08-03T10:01:00Z)" \ + "$(rev "$BOT3" APPROVED oldhead "" 2026-08-03T10:02:00Z)")" expect "a stale round with nobody asked is unrequested too" blocker:unrequested "$(blockers)" expect "...and is still the agent's ball" state:addressing "$(decide_state)" # ...but a live request means an answer IS coming @@ -364,6 +396,13 @@ run_() { jq -n --arg n "$1" --arg o "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \ ctx_() { jq -n --arg n "$1" --arg s "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \ '{__typename:"StatusContext", context:$n, state:$s, createdAt:$t}'; } +# Pinned empty for every fixture below except the #208 block, which sets its +# own. The script defaults SELF_WORKFLOW from the ambient GITHUB_WORKFLOW — +# present in any CI run of this suite — and an inherited name that happened +# to match a fixture's workflowName ("ci", "labels") would silently drop +# entries these fixtures rely on. The verdicts must not flip with the runner. +SELF_WORKFLOW="" + expect "no checks at all is NONE" NONE "$(rollup '[]' | checks_state)" # A failed fetch leaves no rollup KEY; a PR with no checks leaves an empty # ARRAY. Collapsing the two let an API hiccup read as "nothing is failing" — @@ -442,6 +481,39 @@ expect "a cancelled newest over an earlier FAILURE is still that failure" FAILUR "$(rollup "[$(rec_ FAILURE 2026-07-24T12:16:17Z 2026-07-24T12:17:06Z),\ $(rec_ CANCELLED 2026-07-24T12:16:41Z 2026-07-24T12:16:41Z)]" | checks_state)" +# -- the #208 exclusion: the label machine never grades its own runs. The +# shared concurrency group displaces queued sweeps as CANCELLED, and the +# displaced run's successor was triggered by a DIFFERENT PR or an issues +# event — so on the victim PR the #139 carve-out's premise (a surviving +# sibling on the same head) fails structurally: the newest self entry +# stays CANCELLED, and the sweep set blocker:ci-red off its own corpse, +# re-affirming it every cadence. Proven on crew#227: every real check +# green, the only red rollup entry the sweep's own displaced run. rec_ +# already builds entries under workflowName "labels"; naming that as +# self must drop them whole, before the newest-per-context collapse. +SELF_WORKFLOW="labels" +expect "a displaced self CANCELLED beside green others is no verdict (crew#227)" SUCCESS \ + "$(rollup "[$(run_ a SUCCESS),$(run_ b SUCCESS),\ + $(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)" +expect "a FAILED self run surfaces on the Actions tab, not as the PR's red" SUCCESS \ + "$(rollup "[$(run_ a SUCCESS),$(run_ b SUCCESS),\ + $(rec_ FAILURE 2026-08-01T15:00:00Z 2026-08-01T15:01:00Z)]" | checks_state)" +expect "a rollup of ONLY self entries is honestly NONE, never SUCCESS" NONE \ + "$(rollup "[$(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)" +# must-fail: the filter keys on the self workflow ALONE. Widening it — any +# cancelled entry, any labels-shaped name — certifies a genuine foreign +# failure green, which is #136's unknown-as-green shape all over again. +expect "a genuine foreign FAILURE still blocks beside a cancelled self entry" FAILURE \ + "$(rollup "[$(run_ a FAILURE),\ + $(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)" +# ...and an empty self filters NOTHING: outside Actions no workflow name is +# ambient, and the exclusion must never drop entries on a guess — the same +# displaced-self rollup keeps blocking there, all-cancelled context intact. +SELF_WORKFLOW="" +expect "an empty SELF_WORKFLOW filters nothing — the same rollup still blocks" FAILURE \ + "$(rollup "[$(run_ a SUCCESS),$(run_ b SUCCESS),\ + $(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)" + # -- a run still IN FLIGHT. `run_()` cannot express this: it always carries a # real completedAt, which is exactly why the supersede rule shipped dating # runs by completion and nothing caught it. Both spellings of "no @@ -655,24 +727,27 @@ expect "...and an already-applied stale comes off" \ # posted comments appended back into the fixture so a second sweep sees the # first one's writes, and every label edit recorded. # --------------------------------------------------------------------------- -RTMP="$(mktemp -d)" -trap 'rm -rf "$RTMP"' EXIT iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } RNOW=2000000000 -ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines +ruling_sweep_probe() { # $1 labels, $2 PR, $3 assignees, $4 requested, $5 activity age days ( + local n="${2:-77}" assignees="${3:-0}" requested="${4:-}" + local activity_days="${5:-8}" + local assignee_json='[]' + [ "$assignees" -eq 0 ] || assignee_json='[{"login":"owner-bot"}]' REPO_LABELS="$(printf 'state:addressing\nstate:needs-human\nmerge-next\nstale\nneeds-ruling')" REPO=owner/repo NOW="$RNOW" LABELS="$1" - DRAFT=false HEAD_SHA=head1 REQUESTED="" + DRAFT=false HEAD_SHA=head1 REQUESTED="$requested" # Approvals submitted 8 days ago — the newest real activity anywhere. REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED head1 "" "$(iso_at $((RNOW - 8 * 86400)))")" \ - "$(rev "$BOT2" APPROVED head1 "" "$(iso_at $((RNOW - 8 * 86400)))")" \ - "$(rev "$BOT3" APPROVED head1 "" "$(iso_at $((RNOW - 8 * 86400)))")")" + "$(rev "$BOT1" APPROVED head1 "" "$(iso_at $((RNOW - activity_days * 86400)))")" \ + "$(rev "$BOT2" APPROVED head1 "" "$(iso_at $((RNOW - activity_days * 86400)))")" \ + "$(rev "$BOT3" APPROVED head1 "" "$(iso_at $((RNOW - activity_days * 86400)))")")" MERGEABLE=MERGEABLE CHECKS=SUCCESS - PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" '{created_at: $at}')" + PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" \ + --argjson assignees "$assignee_json" '{created_at: $at, assignees: $assignees}')" run() { "$@"; } # mutations reach the stub and are recorded, not swallowed # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) gh() { @@ -689,6 +764,8 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines done endpoint="$(forge_stub_path "$endpoint")" file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json" + printf '%s\n' "$endpoint" >>"$RTMP/api-calls" + [ ! -f "$file.error" ] || return 1 # A missing fixture is an empty collection — projected through the # caller's --jq exactly like real gh, so '.[].foo' yields no lines. [ -f "$file" ] || { printf '[]\n' | jq -r "${jqexpr:-.}"; return 0; } @@ -710,7 +787,7 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines printf '%s\n' "$*" >>"$RTMP/edits" fi } - reconcile_pr 77 2>&1 + reconcile_pr "$n" 2>&1 ) } @@ -749,6 +826,60 @@ expect "exactly one nudge across both sweeps" \ expect "no label edit across both sweeps names the ruling flag" \ no "$(grep -q 'needs-ruling' "$RTMP/edits" 2>/dev/null && echo yes || echo no)" +# --------------------------------------------------------------------------- +# The attention pass on the PR surface (#232): every PR target is malformed, +# assigned or not. The episode marker makes the comment once-per-labeling; +# every other board mutation remains the ordinary state machine's concern. +# --------------------------------------------------------------------------- +attention_pr_fixture() { # $1 PR, $2 labeled timestamp + jq -n --arg at "$2" \ + '[{"event":"labeled","label":{"name":"attention"},"actor":{"login":"setter"},"created_at":$at}]' \ + >"$RTMP/repos_owner_repo_issues_${1}_timeline.json" + printf '[]\n' >"$RTMP/repos_owner_repo_issues_${1}_comments.json" +} + +attention_pr_fixture 78 "$(iso_at $((RNOW - 120)))" +attention_mutations_before="$(wc -l <"$RTMP/edits")" +attention_pr="$(ruling_sweep_probe $'attention\nstate:needs-human' 78 0 danmt 1)" +expect "attention on an unassigned PR is diagnosed" yes \ + "$(grep -q 'malformed attention (pr)' <<<"$attention_pr" && echo yes || echo no)" +expect "the PR comment points to the assigned claim issue" yes \ + "$(grep -qF 'assigned issue that owns the claim' "$RTMP/posted-78" && echo yes || echo no)" +expect "the PR comment does not guess a target issue number" no \ + "$(grep -Eq '#[0-9]+' "$RTMP/posted-78" && echo yes || echo no)" +ruling_sweep_probe $'attention\nstate:needs-human' 78 0 danmt 1 >/dev/null +expect "two PR sweeps in one attention episode post once" 1 \ + "$(grep -cF '