2026-07-22 18:18:18 +00:00
#!/usr/bin/env bash
if [ " ${ BASH_SOURCE [0] } " = " $0 " ] ; then
set -euo pipefail
else
# Fixture tests source the pure functions and deliberately inspect failures.
set -u
fi
# labels-reconcile.sh — the automation LABELS.md promises: state labels are
# written by machinery, never by hand. Every run derives each open PR's
# state:* from GitHub's own facts (draft flag, requested reviewers, submitted
# reviews) and converges the labels to it, so a killed run or a hand-moved
# label heals on the next pass. Stale is judged from real activity — commits,
# comments, reviews — never from label churn, or the sweep would un-stale its
# own mark every tick.
#
# The verdict contract (CONTRIBUTING.md): reviews end in approve or
# request-changes. Some live bots are comment-only and post agreement as a
# COMMENTED review — a non-verdict this machine refuses to guess about (body
# parsing is a heuristic, and a wrong guess promotes an unapproved PR). The
# judgment call belongs to the PR AUTHOR, who reads the round and escalates
# by requesting the human's review — an explicit request is a fact, and it is
# the one this machine trusts (see decide_state's top precedence). The
# machine auto-requests the human only in the no-judgment-needed case: every
2026-07-22 18:21:11 +00:00
# required verdict is a formal head-current approval. Any approval that counts
# must be bound to
2026-07-22 18:18:18 +00:00
# the CURRENT head SHA: GitHub keeps approvals alive across pushes, and a
# stale approval must never promote unreviewed code to the human.
#
# DRY_RUN=1 narrates every mutation instead of performing it (how this script
# is rehearsed against the live repo). A workflow_dispatch run also bootstraps
# the taxonomy (label create --force) — that heal is dispatch-only; the cron
# sweep tolerates a missing label rather than recreating it.
#
# The state machine below is pure (globals in, state out) and covered by
2026-07-22 18:21:11 +00:00
# fixture tests in test/labels-reconcile.test.sh.
2026-07-22 18:18:18 +00:00
HUMAN = " ${ HUMAN_REVIEWER :- danmt } "
BOTS = ( )
2026-08-02 13:23:41 +00:00
# Per-author panels (#224): parallel arrays because the conf is tiny and an
# associative array buys nothing but a bash-4 dependency statement. One entry
# per panel[<login>]= row — PANEL_AUTHORS holds the login, PANEL_ROWS the
# space-joined reviewer set at the same index.
PANEL_AUTHORS = ( )
PANEL_ROWS = ( )
2026-07-22 18:18:18 +00:00
REQUIRED_BOTS = ( )
STATES = ( state:building state:bots-reviewing state:addressing state:needs-human)
BLOCKERS = ( blocker:conflict blocker:ci-red blocker:unrequested)
2026-07-23 00:44:16 +00:00
# The PR's current labels, set per PR by the sweep. Initialized here because
# the script runs under `set -u` even when sourced, and the pure-function
# fixtures call decide_state — which now reads has_label — without ever
# setting it (#51). An empty default keeps has_label honest for every caller.
LABELS = ""
2026-07-22 18:18:18 +00:00
# Labels this machine used to own and no longer does. Cleared on sight so a
# retirement heals the board instead of stranding a label nothing recomputes.
RETIRED = ( state:needs-rebase)
STALE_AFTER = $(( 48 * 3600 ))
2026-08-03 17:12:38 +00:00
# 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 } "
2026-08-01 16:12:17 +00:00
# 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 :- } } "
2026-07-22 18:18:18 +00:00
2026-07-23 11:52:57 +00:00
# The needs-ruling invariants (#52) — one implementation for both surfaces.
# shellcheck source=lib/ruling.sh
. " $( cd " $( dirname " ${ BASH_SOURCE [0] } " ) " && pwd ) /../../lib/ruling.sh "
feat(forge): refuse loudly when the client cannot speak the forge
The preflight half of #188, landed first so it stands alone: the forge is
decided once, before any sweep, and a client that cannot speak it exits
non-zero with a named reason.
Measured against forgejo.heavyduty.builders at 84bb1a4 — two of the three
actions reported SUCCESS having read nothing:
labels-scope exit 0 "no .github/labeler.yml" (the file is HTTP 200)
labels-reconcile exit 0 "reconciled." (zero PRs enumerated)
issueflow-reconcile exit 1 "unexpected end of JSON input"
labels-reconcile's blind-sweep warning (#96) could not fire: it counts
unreadable PRs against a list `gh pr list` never produced, and a process
substitution's failure does not trip set -e, so total stayed 0. Installing
gh makes it worse, silencing the one loud failure.
Detection is measured, not inferred from docs: a real forgejo-runner v6.3.1
job (probe task 278) shows Forgejo populating the whole GITHUB_* namespace,
so GITHUB_ACTIONS proves nothing. GITHUB_API_URL's shape, GITEA_ACTIONS and
GITHUB_SERVER_URL do. The same probe shows the runner image carries neither
gh nor stoke, which is what makes the forgejo backend REST.
Tests declare CEREMONY_FORGE at the forge boundary rather than stubbing gh
and staying silent about the forge — the boundary move term 5 asks for.
Refs #188
2026-08-02 18:29:08 +00:00
# shellcheck source=lib/forge.sh
. " $( cd " $( dirname " ${ BASH_SOURCE [0] } " ) " && pwd ) /../../lib/forge.sh "
2026-08-03 19:48:15 +00:00
# The attention target invariants (#232) — diagnosis only, both surfaces.
# shellcheck source=lib/attention.sh
. " $( cd " $( dirname " ${ BASH_SOURCE [0] } " ) " && pwd ) /../../lib/attention.sh "
fix(issueflow): a failed read never reaches a decision function
`gh api` prints a 5xx response body to stdout AND exits non-zero, and
GitHub's 5xx body is a JSON object. Inside the per-issue subshell that
payload passed `has("pull_request") | not`, emptied `.labels[]`, and
`queue_decision` — correct on the input it was handed — wrote
`needs-triage` onto a healthy epic. The run then logged `reconciled.`
and exited 0 (crew#329, #247).
errexit could not have caught it: a command whose status is tested by
`||` runs with errexit suppressed, and the suppression extends through
the whole subshell body, so the `|| log` handler is what disables the
errexit that would have aborted at the failed read. Removing the handler
revives errexit and loses #91's resilience, and an inline `set -e` does
not re-arm it. Explicit per-read checks are the mechanism.
Every read inside that subshell is now checked — the issue read on its
status AND on its payload shape (an HTTP 200 whose body is `null` exits
0 and empties the label set just the same), both reads in
`last_issue_activity`, and the comments read in
`issue_comment_has_marker`. On failure the issue is left exactly as it
is, the reason rides its own `#$n:` line, and the subshell exits with a
distinguished status the sweep counts, so a deliberate skip is not
reported as a crash and a genuine crash is still named byte-identically.
`read_failure_reason` moves to lib/read.sh beside a new `guarded_read`,
sourced by both reconcilers: labels-reconcile's copy was the only one,
and the issue surface needs the identical rule.
Refs #247
2026-08-03 18:01:58 +00:00
# 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 "
2026-07-23 11:52:57 +00:00
2026-07-22 18:18:18 +00:00
log( ) { printf 'labels: %s\n' " $* " ; }
run( ) { # every mutation goes through here — DRY_RUN=1 logs instead of doing
if [ -n " ${ DRY_RUN :- } " ] ; then log " DRY_RUN: $* " ; else " $@ " ; fi
}
2026-07-24 00:47:37 +00:00
blind_sweep_warning( ) { # $1 = unreadable PRs, $2 = all open PRs, $3 = sampled read-failure reason
# Report, do not diagnose (#101 D5). The old text asserted the caller's
# checks:/statuses: grants as THE cause — an inference #95 made from a
# control case, and the merged consumer-side fix (incubator#48/PR #49)
# left the symptom standing while the run emitting this warning held the
# evidence that would have said so. Lead with what gh actually said this
# sweep; the permissions hint stays, demoted to one named candidate.
2026-07-23 23:12:30 +00:00
if [ " $2 " -gt 0 ] && [ " $1 " -eq " $2 " ] ; then
2026-07-24 00:47:37 +00:00
local reason = " ${ 3 :- } "
if [ -n " $reason " ] ; then
2026-07-24 22:57:22 +00:00
echo " ::warning::labels: every open PR was unreadable; sampled reason: $reason — one candidate is missing checks: read, statuses: read and actions: read in the caller (private repos do not imply them) "
2026-07-24 00:47:37 +00:00
else
2026-07-24 22:57:22 +00:00
echo "::warning::labels: every open PR was unreadable; no reason was captured — one candidate is missing checks: read, statuses: read and actions: read in the caller (private repos do not imply them)"
2026-07-24 00:47:37 +00:00
fi
fi
}
2026-07-24 00:50:20 +00:00
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
while IFS = read -r row; do
[ -n " $row " ] || continue
name = " ${ row %%|* } "
if ! grep -qxF " $name " <<< " $repo_labels " ; then
if [ -n " $missing " ] ; then missing = " $missing , $name " ; else missing = " $name " ; fi
fi
done <<< " $rows "
if [ -n " $missing " ] ; then
echo " ::warning::labels: missing core label(s): $missing ; bump the ceremony pin, then re-dispatch workflow_dispatch to bootstrap the taxonomy "
fi
}
2026-07-22 18:18:18 +00:00
load_config( ) { # $1 = consumer labels.conf; panel is mandatory, scopes optional
local conf = " $1 " line panel_seen = false
[ -f " $conf " ] || {
echo " labels: missing config: $conf (a panel= line is required) " >& 2
return 1
}
BOTS = ( )
2026-08-02 13:23:41 +00:00
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
2026-07-22 18:18:18 +00:00
while IFS = read -r line || [ -n " $line " ] ; do
[ -n " $line " ] || continue
2026-08-02 13:23:41 +00:00
# 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.
2026-07-22 18:18:18 +00:00
case " $line " in
panel = *)
[ " $panel_seen " = false ] || {
echo " labels: duplicate panel line in $conf " >& 2
return 1
}
panel_seen = true
read -r -a BOTS <<< " ${ line #panel= } "
[ " ${# BOTS [@] } " -gt 0 ] || {
echo " labels: panel must name at least one reviewer in $conf " >& 2
return 1
}
; ;
2026-08-02 13:23:41 +00:00
"panel[" *) parse_panel_author_row " $line " " $conf " || return ; ;
2026-07-22 19:21:01 +00:00
triage-actors= *) ; ;
2026-07-22 18:18:18 +00:00
*) parse_label_row " $line " >/dev/null || return ; ;
esac
done <" $conf "
[ " $panel_seen " = true ] || {
echo " labels: missing panel= line in $conf " >& 2
return 1
}
}
2026-08-02 13:23:41 +00:00
parse_panel_author_row( ) { # panel[<login>]=<space-separated logins> (#224)
# Every failure here is a hard one that names the offending line (D3): a
# conf error takes the whole board down, and the run log is the only place
# the operator can read why. A malformed bracket is refused AS a bracket
# (D4) — falling through to parse_label_row would report it as a
# "malformed label row", the misleading diagnostic #224 was filed over.
local line = " $1 " conf = " $2 " login rest existing
case " $line " in
"panel[" *"]=" *) ; ;
*)
echo " labels: malformed panel[<login>]= row (expected panel[<login>]=<reviewers>): $line in $conf " >& 2
return 1
; ;
esac
login = " ${ line #panel[ } "
login = " ${ login %%]=* } "
[ -n " $login " ] || {
echo " labels: empty login in panel row: $line in $conf " >& 2
return 1
}
2026-08-02 14:13:25 +00:00
# The login must be exactly one well-formed bracket pair of login
# characters. Without this, panel[z]]=b parses: the case above only
# establishes that SOME ]= occurs, ${login%%]=*} keeps the stray ] inside
# the login (z]), and set_required_bots for the real z then silently falls
# back to the base panel — the misroute D4 exists to refuse. GitHub logins
# are [A-Za-z0-9-], per the #285 spec.
case " $login " in
*[ !A-Za-z0-9-] *)
echo " labels: malformed panel[<login>]= row (a login is [A-Za-z0-9-] only): $line in $conf " >& 2
return 1
; ;
esac
2026-08-02 13:23:41 +00:00
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 [*] } " )
}
2026-07-22 18:18:18 +00:00
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 "
if [ -z " $name " ] || [ -z " $color " ] || [ -z " $desc " ] || [ -n " ${ extra :- } " ] ; then
echo " labels: malformed label row: $line " >& 2
return 1
fi
printf '%s|%s|%s\n' " $name " " $color " " $desc "
}
configured_label_rows( ) { # validated scope rows, excluding the panel setting
local conf = " $1 " line
[ -f " $conf " ] || return 0
while IFS = read -r line || [ -n " $line " ] ; do
[ -n " $line " ] || continue
2026-08-02 13:23:41 +00:00
# "panel["* quoted for the same D7 reason as load_config's case; skipping
# the bracketed rows (D5) keeps a dispatch bootstrap from trying to
# create a label named panel[<login>].
case " $line " in panel = * | "panel[" * | triage-actors= *) continue ; ; esac
2026-07-22 18:18:18 +00:00
parse_label_row " $line " || return
done <" $conf "
}
2026-08-02 13:23:41 +00:00
panel_for_author( ) { # $1 = author → the effective panel, space-joined (#224 D2)
# THE resolution point: the author's panel[<login>]= row when the conf
# defines one, the base panel= otherwise. Everything that computes a
# required set goes through here, because two places computing the panel
# is how the engine and the reconciler came to disagree in the first place.
local author = " $1 " i
for i in ${ PANEL_AUTHORS [@]+ " ${ !PANEL_AUTHORS[@] } " } ; do
if [ " ${ PANEL_AUTHORS [i] } " = " $author " ] ; then
printf '%s\n' " ${ PANEL_ROWS [i] } "
return
fi
done
printf '%s\n' " ${ BOTS [*] } "
}
2026-07-22 18:18:18 +00:00
set_required_bots( ) { # the PR author is recused by construction
2026-08-02 13:23:41 +00:00
# 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.
2026-07-22 18:18:18 +00:00
local author = " $1 " bot
2026-08-02 13:23:41 +00:00
local -a effective = ( )
read -r -a effective <<< " $( panel_for_author " $author " ) "
2026-07-22 18:18:18 +00:00
REQUIRED_BOTS = ( )
2026-08-02 13:23:41 +00:00
for bot in ${ effective [@]+ " ${ effective [@] } " } ; do
2026-07-22 18:18:18 +00:00
[ " $bot " = " $author " ] || REQUIRED_BOTS += ( " $bot " )
done
}
# ---------------------------------------------------------------------------
2026-07-23 00:44:16 +00:00
# The state machine. Pure functions over these globals, set per PR:
2026-07-22 18:18:18 +00:00
# DRAFT true|false
# HEAD_SHA the PR's current head commit
2026-07-24 11:54:49 +00:00
# BASE_SHA the PR's base branch head (the release-shape guard's ref)
2026-07-22 18:18:18 +00:00
# REQUESTED newline-separated logins with a review currently requested
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews
# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict)
# CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup)
2026-07-23 00:44:16 +00:00
# LABELS newline-separated labels currently on the PR
2026-08-03 17:12:38 +00:00
# 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)
2026-07-22 18:18:18 +00:00
# ---------------------------------------------------------------------------
requested( ) { grep -qxF " $1 " <<< " $REQUESTED " ; }
feat(forge): derive outstanding review requests from the head, not the field
Term 4. GitHub clears requested_reviewers when a verdict lands, so the field
answers "who still owes a verdict" by itself. Forgejo never clears it —
measured: rig!140 listed all three panelists with all three verdicts in, and
rig!146 still lists three while MERGED, so the field is stale even on a
closed PR.
Read raw on Forgejo that is not a cosmetic over-count. `requested` drives
three decisions, and a permanently-true field pins a PR at
state:bots-reviewing for life and stops blocker:unrequested from ever being
true: the sweep believes a round is live forever and no staleness can
correct it.
So the requested set is intersected with who has NOT submitted a verdict for
the current head, derived from /pulls/{n}/reviews — the read that is true on
both forges. On GitHub the filter removes nothing, because the field is
already accurate; term 5 holds by construction rather than by care.
A STALE approval — an approval of an older head — still owes a verdict. That
is the case that matters: treating it as answered would let a stale round
read as complete, which is the shape #136 exists to prevent.
Mutation-verified both ways: reading the field raw again reds three cases,
and treating STALE as answered reds two.
Also documents @grok-reviewer-andresmgsl's ask (#4763): every panel= account
must be able to read the repo, or the forge refuses the review request —
422 naming the account on Forgejo. A real failure mode for private
consumers, and it fails loudly rather than sweeping blind.
Refs #188
2026-08-02 19:48:09 +00:00
# outstanding_requests <requested-logins> — the portable "who still owes a
# verdict on THIS head" (issue #188, term 4).
#
# GitHub clears requested_reviewers when a verdict lands, so on that forge the
# field already answers this question and the filter below removes nothing.
# **Forgejo does not clear it.** Measured 2026-08-02: rig!140 listed all three
# panelists with all three verdicts in, and rig!146 still lists three while
# MERGED — the field is stale even on a closed PR, so it over-counts forever.
#
# Reading it raw on Forgejo pins a PR at state:bots-reviewing for life and
# stops blocker:unrequested from ever being true: the sweep believes a round
# is permanently live. So the requested set is intersected with "has not
# submitted a verdict for the current head", which is derived from
# /pulls/{n}/reviews — the read that is true on both forges.
#
# Pure over REVIEWS_JSON/HEAD_SHA so the fixtures can drive it; a reviewer
# whose only verdict is STALE still owes one, which is why this asks
# bot_verdict rather than merely "has any review".
outstanding_requests( ) {
local login
while IFS = read -r login; do
[ -n " $login " ] || continue
case " $( bot_verdict " $login " ) " in
APPROVE | BLOCK | FEEDBACK) continue ; ;
esac
printf '%s\n' " $login "
done <<< " ${ 1 - } "
}
2026-07-22 18:18:18 +00:00
checks_state( ) { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE
# UNREADABLE is the absence of the key itself, which is what a failed fetch
# leaves behind — distinct from a present-but-empty rollup, which honestly
# means this PR has no checks. Collapsing the two let an API hiccup present
# as "nothing is failing", i.e. as mergeable-by-a-human: the same
# unknown-certified-as-green shape as the bug this machine exists to stop.
# The caller skips the PR entirely rather than labelling on facts it did not
# read; blocking on it instead would flap the whole board on one bad call.
# The rollup mixes two node types with two different closed enums: CheckRun
# carries `conclusion` (CheckConclusionState), StatusContext carries `state`
# (StatusState). Rather than list the outcomes that block — the version that
# shipped in this PR's first round listed four, and ERROR, CANCELLED and
# STALE fell through its `else` into SUCCESS — this lists the outcomes that
# DON'T, and treats everything else as blocking.
#
# That direction is the point. An outcome we do not recognise is one we
# cannot certify as mergeable, and certifying the unrecognised as green is
# the exact shape of #136. The cost of being wrong is symmetric in form and
# not in consequence: a false FAILURE parks the PR on the agent, who looks;
# a false SUCCESS invites a human to merge a tree that will not merge.
2026-07-24 12:34:51 +00:00
#
# The list-what-passes rule has exactly one carve-out, and it is narrower
# than an outcome: a CANCELLED entry is discarded when its context holds at
# least one non-cancelled sibling (#139). The reconcile job queues in one
# repo-global concurrency group, so any repo event — a sibling PR's push,
# triage labelling an issue — evicts the queued duplicate AFTER it has
# attached a check run to this PR's head, and that cancelled entry became
# the context's newest word: blocker:ci-red on a PR whose real checks were
# all green (#133/#136, evictable only by an empty commit). A cancelled run
# said nothing about this head; a non-cancelled sibling is a real verdict
# about exactly these bytes, whatever order the two arrived in — and for
# this workflow the evictor performs the duplicate's work anyway, since
# every sweep covers every open PR. This does not widen unknown-into-green:
# 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.
2026-08-01 16:12:17 +00:00
#
# 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 " '
2026-07-22 18:18:18 +00:00
if ( has( "statusCheckRollup" ) | not) then "UNREADABLE" else
# NEUTRAL and SKIPPED satisfy branch protection — a skipped required check
# is not a failed one, and path-filtered jobs skip constantly here.
[ "SUCCESS" , "NEUTRAL" , "SKIPPED" ] as $passing
# "" covers a StatusContext still reported with no state at all.
| [ "" , "PENDING" , "IN_PROGRESS" , "QUEUED" , "WAITING" , "REQUESTED" , "EXPECTED" ] as $waiting
# A re-run does not evict the run it superseded — the rollup keeps both.
# This PR proved it: its own tip carried a CANCELLED `scope` (15:19:39)
# beside the SUCCESS `scope` (15:19:45) that replaced it, same workflow.
# Once CANCELLED blocks, judging every entry would strand this very PR in
# needs-rebase forever, so collapse each context to its newest entry first.
# Key on workflow + name because a bare job name is only unique within its
# workflow.
#
# Dating a run is the subtle part, and getting it wrong restores the bug.
# A run still in flight has no completion, but `gh` does not omit the
# field: its Go struct marshals the zero time as "0001-01-01T00:00:00Z",
# which is a string, so `//` will not fall through it. Ordering on
# completion therefore sorted the LIVE re-run to the bottom and let `last`
# pick the very run it superseded — reporting the old SUCCESS while a
# replacement was still running, which is #136 again.
#
# So: date a run by when it BEGAN, discarding both spellings of absent
# (null, and the zero sentinel) and falling back only if it never recorded
# a beginning. NOT by the newest stamp of any kind: `max` compares the
# completion of a finished run against the start of a live one, which are
# different quantities and not an ordering on runs. A run cancelled by the
# concurrency group does not stop the instant its replacement starts — the
# runner has to wind down — so predecessor.completedAt > successor.startedAt
# is the ordinary case, and `max` dated the dead predecessor newer than the
# live run that replaced it, narrowing both failures above without closing
# them. The list is already in preference order, so `first` IS that rule.
#
# An entry that carries no usable timestamp at all sorts LAST rather than
# first — something we cannot date is most likely the thing just created,
# 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".
2026-08-01 16:12:17 +00:00
# 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.
2026-07-22 18:18:18 +00:00
| [ ( .statusCheckRollup // [ ] ) [ ]
2026-08-01 16:12:17 +00:00
| select ( $self = = "" or ( .workflowName // "" ) != $self )
2026-07-22 18:18:18 +00:00
| { ctx: [ .workflowName // "" , .name // .context // "" ] ,
at: ( [ .startedAt, .createdAt, .completedAt]
| map( select ( type = = "string" and . != ""
and ( startswith( "0001-01-01" ) | not) ) )
| first // "" ) ,
outcome: ( ( .conclusion // .state // "" ) | ascii_upcase) } ]
2026-07-24 12:34:51 +00:00
# The #139 carve-out (header above): drop CANCELLED entries only when the
# context keeps a non-cancelled survivor — BEFORE the sort, so a cancelled
# entry that arrived newest cannot outvote the real verdict it displaced.
# An all-cancelled context is left intact and still classifies FAILURE.
2026-07-22 18:18:18 +00:00
| group_by( .ctx)
2026-07-24 12:34:51 +00:00
| map( map( select ( .outcome != "CANCELLED" ) ) as $live
| ( if ( $live | length) > 0 then $live else . end)
| sort_by( [ ( .at = = "" ) , .at] ) | last | .outcome ) as $latest
2026-07-22 18:18:18 +00:00
| if ( $latest | length) = = 0 then "NONE"
elif ( ( $latest - $passing - $waiting ) | length) > 0 then "FAILURE"
elif ( ( $latest - $passing ) | length) > 0 then "PENDING"
else "SUCCESS" end
end'
}
bot_verdict( ) { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
local review state commit
review = " $( jq -c --arg u " $1 " \
'[.[] | select(.user.login == $u)] | sort_by(.submitted_at) | last // empty' \
<<< " $REVIEWS_JSON " ) "
if [ -z " $review " ] ; then echo MISSING; return ; fi
state = " $( jq -r '.state' <<< " $review " ) "
commit = " $( jq -r '.commit_id' <<< " $review " ) "
case " $state " in
CHANGES_REQUESTED)
# blocks at ANY head — GitHub's own semantic: only a newer review
# from the same reviewer clears it
echo BLOCK ; ;
APPROVED)
if [ " $commit " = " $HEAD_SHA " ] ; then echo APPROVE; else echo STALE; fi ; ;
*)
# COMMENTED and anything else: a non-verdict. The machine does not
# read bodies — if the comment is really an agreement, the AUTHOR
# says so by requesting the human's review.
echo FEEDBACK ; ;
esac
}
2026-08-03 17:12:38 +00:00
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 " ]
}
2026-07-22 18:18:18 +00:00
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
# approval of an older head) stalls the handoff unless we request —
# guarding on "has the human ever reviewed" wedged exactly that way.
if requested " $HUMAN " ; then return 1; fi
if [ " $( bot_verdict " $HUMAN " ) " = APPROVE ] ; then return 1; fi
return 0
}
blockers( ) { # → the blocker:* labels this PR should carry, one per line
# The second axis. These are FACTS ABOUT THE BRANCH, and they are mutually
# independent — a PR can be conflicted and red and unasked at once — so they
# are a set, not an ordering. That is the whole point of splitting them out
# of state:*: every precedence bug this machine has had (needs-human
# surviving a conflict, MISSING swallowing STALE) came from projecting
# independent facts onto one totally-ordered label. A set has no precedence
# to get wrong.
#
# UNKNOWN mergeability is deliberately NOT a conflict: GitHub reports it for
# about a minute after every merge while it recomputes, and flapping every
# open PR on each merge would be worse than the bug. Same for a failed read
# of either fact — both default to the "do not know" value, which blocks
# nothing. An unset global (an older fixture, a failed fetch) must never
# invent a verdict it did not read.
case " ${ MERGEABLE :- UNKNOWN } " in CONFLICTING) echo blocker:conflict ; ; esac
case " ${ CHECKS :- NONE } " in FAILURE) echo blocker:ci-red ; ; esac
# Nobody is on the hook for a verdict somebody still owes. Distinct from
# bots-reviewing, which says a request is live and an answer is coming:
# here the round is stalled because no one was ever asked, and the board
# said "waiting on the bots" for the 48h it took `stale` to notice.
# 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.
2026-08-03 17:12:38 +00:00
#
# 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
2026-07-22 18:18:18 +00:00
local b v owed = false any_requested = false
for b in " ${ REQUIRED_BOTS [@] } " ; do
requested " $b " && any_requested = true
# MISSING and STALE are both verdicts this head does not have: nobody
# reviewed it, or everybody reviewed something else. The agent owes an
# ask either way — the stale round is if anything the worse of the two,
# since it has approvals on the page that no longer describe the tree.
v = " $( bot_verdict " $b " ) "
case " $v " in MISSING | STALE) owed = true ; ; esac
done
2026-08-03 17:12:38 +00:00
# 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
2026-07-22 18:18:18 +00:00
echo blocker:unrequested
fi
fi
}
2026-08-02 13:25:59 +00:00
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 ]
}
2026-07-22 18:18:18 +00:00
decide_state( ) { # → the one state:* label this PR should carry
2026-08-02 13:25:59 +00:00
# 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
2026-07-22 18:18:18 +00:00
local s
s = " $( round_state) "
2026-08-02 14:13:25 +00:00
# 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
2026-07-22 18:18:18 +00:00
# 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
# blocker label says which work it is. Nothing else in this function reads
# the branch, which is what keeps the ordering below purely about reviews.
if [ " $s " = state:needs-human ] && [ -n " $( blockers) " ] ; then
echo state:addressing; return
fi
2026-07-23 00:44:16 +00:00
# A pending ruling disqualifies needs-human the same way (#51): while
# `needs-ruling` is up, the human's turn lives in the THREAD — the flag
# marks it — and "mergeable right now" must not read true beside an open
# decision. state:addressing is the honest landing because the ball ON THE
# PR is the builder's: the flag-setter judges when agreement is reached and
# carries the ruling in (#50 D6). The label is deliberately NOT in BLOCKERS:
# that array is machine-owned, and the converge loop strips every entry the
# current facts do not re-derive — `needs-ruling` is hand-set intent the
# machine reads and never writes (#50 D9), so parking it there would strip
# a live escalation on the next 15-minute tick.
if [ " $s " = state:needs-human ] && has_label needs-ruling; then
echo state:addressing; return
fi
2026-07-25 09:08:54 +00:00
# A directed hold disqualifies it the same way (#180): `blocked` is hand-set
# intent — triage sets it, anyone may correct it — and during the #111
# freeze rig#126/#128 carried it beside state:needs-human, so the board said
# "mergeable right now" about PRs a hold said must not merge (rig#126 was
# merged seven minutes later). Not a BLOCKERS entry, deliberately: that
# array is machine-owned and the converge loop strips whatever the facts do
# not re-derive, so emitting the label there would strip a live hold on the
# next 15-minute tick — the same trap #51 names for `needs-ruling`.
# state:addressing is the accepted imprecision: under a hold the builder
# owes nothing, but "a human could merge this now" must not lie.
if [ " $s " = state:needs-human ] && has_label blocked; then
echo state:addressing; return
fi
2026-07-22 18:18:18 +00:00
echo " $s "
}
round_state( ) { # → the state the REVIEW ROUND alone implies; knows no branch facts
local b verdicts = ""
for b in " ${ REQUIRED_BOTS [@] } " ; do
if requested " $b " ; then echo state:bots-reviewing; return ; fi
done
# Collect the WHOLE round before applying any precedence. Deciding inside
# the loop let BOTS order pick the winner: a MISSING returned immediately,
# so a STALE belonging to a later bot was never even read, and the mixed
# round (one approval staled by a push, another bot yet to review) came out
# needs-human — the #136 headline shape, with zero reviews bound to the head.
for b in " ${ REQUIRED_BOTS [@] } " ; do
verdicts = " $verdicts $( bot_verdict " $b " ) "
done
case " $verdicts " in
# STALE = a verdict for an older head. Unlike MISSING, this outranks the
# human request: every approval it covers was invalidated by a push, so
# NOBODY has reviewed this tree. Handing that to the human is the #136 case
# where everything reads green — mergeable, CI passing, "waiting on the
# human" — over code no reviewer has seen. The agent owes a re-request.
# Checked before MISSING because "unfinished" must not swallow "and also
# stale": a round that is both is a push that outran the re-requests, not
# a maintainer deliberately claiming the PR early.
*STALE*) echo state:addressing; return ; ;
esac
case " $verdicts " in
# No verdict at all from some bot, and nothing staled. An explicit human
# request still outranks an unfinished round — a maintainer pulling a PR
# to themselves early is a deliberate act, and the original precedence.
#
# Otherwise it is the AGENT's ball, not the bots'. The loop above already
# returned for every live bot request, so reaching here with a MISSING
# means somebody owes a verdict and nobody was asked for one — the round
# is not running. Calling that bots-reviewing was the lie that let a
# forgotten PR read "waiting on the reviewers" for the 48h it took the
# stale sweep to notice. blocker:unrequested says why.
*MISSING*)
if requested " $HUMAN " ; then echo state:needs-human; return ; fi
echo state:addressing; return ; ;
esac
# an explicit human request outranks the remaining bot outcomes — it is the
# final gate, and a maintainer pulling a PR to themselves early counts too
if requested " $HUMAN " ; then echo state:needs-human; return ; fi
case " $verdicts " in
# FEEDBACK = a comment with no verdict → the agent owes the round-reply.
*BLOCK* | *FEEDBACK*) echo state:addressing; return ; ;
esac
# the bots all approve — but if the human's standing word is
# changes-requested (and nobody re-requested them yet), the agent owes
# fixes, not the human a nag
if [ " $( bot_verdict " $HUMAN " ) " = BLOCK ] ; then
echo state:addressing
else
echo state:needs-human
fi
}
# ---------------------------------------------------------------------------
# The sweep: fetch facts, decide, converge. One PR's failure never aborts the
# others — each PR reconciles in a subshell and a failure just logs.
# ---------------------------------------------------------------------------
core_label_rows( ) {
cat <<'EOF'
2026-08-02 14:13:25 +00:00
state:building| FBCA04| Pre-round: the builder is still building — draft is evidence for it, not the definition
2026-07-22 18:18:18 +00:00
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
blocker:conflict| B60205| Does not merge — the branch conflicts and the agent owes a rebase
blocker:ci-red| B60205| A check is failing — the agent owes a fix ( not a rebase)
blocker:unrequested| E99695| Somebody still owes a verdict and nobody was asked for one
merge-next| 0E8A16| Head of the merge queue — merge this one next ( set by hand/agent, cleared here)
stale| B60205| No activity for 48h — needs a poke ( sweep-managed)
blocked| 6A737D| Waiting on another PR or issue to land first
2026-07-23 12:48:04 +00:00
offsite| CFD3D7| Issue deliverable is a PR in another repository — claim clock paused
2026-07-23 00:44:16 +00:00
needs-ruling| D4C5F9| A human decision is pending — question, options and a recommendation are in the comment
2026-07-23 17:27:24 +00:00
attention| D93F0B| A demand is parked here for the assignee: pick up the thread, ack by removing this label
2026-07-22 18:18:18 +00:00
release| 0E8A16| Release flow and version/packaging work
needs-triage| FBCA04| Did not come through triage — owes normalization or conversion to a discussion
ready| 0E8A16| Triaged, spec complete, unblocked — a builder can start now and succeed
claimed| 1D76DB| A builder owns it: assignee set, draft PR expected shortly
2026-07-25 00:09:14 +00:00
post-merge| 006B75| Refs-linked PR merged; post-merge criteria remain and triage owns completion
2026-07-22 18:18:18 +00:00
epic| 5319E7| Organizes other issues via a dependency-ordered task list — builders never pick it
EOF
}
2026-07-23 21:09:54 +00:00
retired_label_names( ) { # the GitHub defaults LABELS.md retires — a `question` is a discussion
# One registry, kept beside core_label_rows() for the same reason those rows
# are not in labels.conf: a rule that must hold in every governed repo
# cannot live in a per-repo file. The six names match LABELS.md exactly.
cat <<'EOF'
duplicate
invalid
question
wontfix
help wanted
good first issue
EOF
}
2026-07-22 18:18:18 +00:00
bootstrap_labels( ) { # dispatch-only: ~20 upserts is too chatty for every cron tick
2026-07-23 21:09:54 +00:00
local rows name
2026-07-22 18:18:18 +00:00
rows = " $( core_label_rows) "
if [ -f " $LABELS_CONF " ] ; then
rows = " $rows
$( configured_label_rows " $LABELS_CONF " ) "
fi
while IFS = '|' read -r name color desc; do
[ -n " $name " ] || continue
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
run forge_label_create " $name " " $color " " $desc "
2026-07-22 18:18:18 +00:00
done <<< " $rows "
2026-07-23 21:09:54 +00:00
# LABELS.md publishes the defaults as deleted at bootstrap; until #93
# nothing deleted them — incubator's first dispatch ran green and left
# `good first issue` standing. Deletion is dispatch-only like the upserts,
# and never fatal: `gh label delete` exits non-zero on a label that is
# already gone, the NORMAL case from the second dispatch on, and under
# set -e an unguarded call aborts the whole run (#91's shape). A 403
# refusal gets the same tolerance — the bot bootstrap already 403s on
# blocker:drill-pending, and a token that cannot delete must still get
# the taxonomy it can create. Either way: log the name, keep going.
while IFS = read -r name; do
[ -n " $name " ] || continue
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
run forge_label_delete " $name " \
2026-07-23 21:09:54 +00:00
|| log " retire: ' $name ' not deleted (already absent, or refused) — continuing "
done <<< " $( retired_label_names) "
2026-07-22 18:18:18 +00:00
}
has_label( ) { grep -qxF " $1 " <<< " $LABELS " ; }
2026-07-24 11:54:49 +00:00
release_shape_warning( ) { # $1 = PR, $2 = head version, $3 = base version
# The #128 incident's guard (#130): a release-shaped PR — bare X.Y.Z at
# its head where the base says something else — reaching the board with
# no `release` label is exactly the state whose merge would publish
# nothing, so the sweep says so instead of letting the merge door
# discover it. A WARNING, never a write: `release` is declared intent,
# and the reconciler does not guess intent (LABELS.md's rule for
# `blocked`/`release`). An unreadable version blocks nothing — the
# sweep must not nag on facts it did not read.
local n = " $1 " head_ver = " $2 " base_ver = " $3 "
[ -n " $head_ver " ] || return 0
grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' <<< " $head_ver " || return 0
[ " $head_ver " != " $base_ver " ] || return 0
2026-07-24 12:07:57 +00:00
echo " ::warning::labels: # $n is release-shaped (version ${ base_ver :- unreadable } -> $head_ver at its head) but carries no release label — the merge door reads that label as declared intent and will refuse without it; if this is the ceremony PR, apply release (#130; the #128 incident) "
2026-07-24 11:54:49 +00:00
}
tree_version( ) { # $1 = ref → that tree's version via the API, or nothing
# Both backends, no checkout: a VERSION file first, package.json's
# version field second (jq, not node — a read needs no npm machinery).
# Every failure path prints nothing: the caller treats "could not read"
# as "not release-shaped" rather than warning on a guess.
local ref = " $1 " ver
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
ver = " $( forge_api " repos/ $REPO /contents/VERSION?ref= $ref " --jq '.content' 2>/dev/null \
2026-07-24 11:54:49 +00:00
| base64 -d 2>/dev/null | tr -d '[:space:]' ) "
if [ -z " $ver " ] ; then
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
ver = " $( forge_api " repos/ $REPO /contents/package.json?ref= $ref " --jq '.content' 2>/dev/null \
2026-07-24 11:54:49 +00:00
| base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null) "
fi
[ -z " $ver " ] || printf '%s\n' " $ver "
return 0
}
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
# label_write <n> <args…> — every label mutation on this surface goes through
# here (#192). A write that did not happen must reach main's exit code, and the
# first version of this fix marked only the primary state edit: clearing
# `merge-next` and the two `stale` edits could still fail into the generic
# per-PR branch and finish with `reconciled.` and exit 0
# (@codex-reviewer-andresmgsl). One helper means a future call site cannot
# reopen that by forgetting to mark itself.
#
# The marker is a log line rather than a return code because reconcile_pr runs
# in a subshell whose STDOUT main reads — the same channel the degraded-read
# warning already travels on.
label_write( ) {
local n = " $1 "
shift
if run forge_issue_edit " $n " " $@ " >/dev/null; then
return 0
fi
log " # $n : label edit FAILED — attempted: forge_issue_edit $n $* ; the write did not happen (reason on stderr above) "
return 1
}
2026-07-22 18:18:18 +00:00
reconcile_pr( ) { # $1 = PR number; relies on the globals set from its fetch
2026-07-23 11:52:57 +00:00
local n = " $1 " desired remove s args last_activity last_activity_epoch age
2026-07-22 18:18:18 +00:00
desired = " $( decide_state) "
# encode the runbook's last step for the no-judgment case: every required
2026-07-22 18:21:11 +00:00
# verdict is a head-current approval → the human is asked, once. The guard
# asks whether
2026-07-22 18:18:18 +00:00
# a FRESH human review is needed for THIS head — never "has the human ever
# reviewed", which wedged the handoff after any earlier human comment.
# Idempotent (a live request suppresses it); race-free via the shared
# concurrency group in labels.yml. With a comment-only bot on the panel
# this path stays cold and the AUTHOR requests the human.
if [ " $desired " = state:needs-human ] && human_request_needed; then
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
run forge_request_reviewer " $n " " $HUMAN "
2026-07-22 18:18:18 +00:00
log " # $n : requested $HUMAN (round passed) "
fi
# ---- converge both axes ----
# state:* is exclusive (everything but $desired comes off); blocker:* is a
# set (each one on or off on its own); RETIRED always comes off. One edit
# call for all of it, so a PR never flickers through a half-applied board.
local want_blockers add = ""
want_blockers = " $( blockers) "
remove = ""
for s in " ${ STATES [@] } " ; do
if [ " $s " != " $desired " ] && has_label " $s " ; then remove = " $remove , $s " ; fi
done
for s in " ${ RETIRED [@] } " ; do
if has_label " $s " ; then remove = " $remove , $s " ; fi
done
for s in " ${ BLOCKERS [@] } " ; do
if grep -qxF " $s " <<< " $want_blockers " ; then
has_label " $s " || add = " $add , $s "
else
has_label " $s " && remove = " $remove , $s "
fi
done
add = " ${ add #, } "
remove = " ${ remove #, } "
# Never NAME a label the repo does not have. `gh issue edit --add-label`
# rejects the WHOLE call on one unknown name — nothing is applied — so a
# single missing blocker would take the state convergence down with it, on
# exactly the PRs this change exists to fix, surfacing only as a log line.
# Batching state and blockers into one edit for anti-flicker is what widened
# that blast radius; filtering the add side is what closes it again.
# Removals need no filter: they are built from has_label, so the label
# provably exists. REPO_LABELS unreadable means no filtering rather than
# filtering everything out — a failed read must not silently strip the board.
local skip_edit = false
if [ -n " ${ REPO_LABELS :- } " ] ; then
local kept = "" missing = "" want
for want in ${ add //,/ } ; do
if grep -qxF " $want " <<< " $REPO_LABELS " ; then kept = " $kept , $want "
else missing = " $missing $want " ; fi
done
add = " ${ kept #, } "
# A missing STATE label skips only the EDIT — never the rest of this
# function. Everything below is independent of the state:* taxonomy, and
# returning here stranded it: `merge-next` kept claiming "merge this one
# next" on a PR the board had moved to the agent, and the stale sweep
# stopped running. That is the original false-invitation bug, reintroduced
# in the very fix meant to survive a cold-start repo — and a regression
# against the old behaviour, which failed the edit and fell through.
if ! grep -qxF " $desired " <<< " $REPO_LABELS " ; then
log " # $n : WARNING: state label ' $desired ' does not exist — skipping the label edit; dispatch the workflow to bootstrap "
skip_edit = true
elif [ -n " $missing " ] ; then
log " # $n : WARNING: missing label(s) $missing — state still converged; dispatch the workflow to bootstrap "
fi
fi
if [ " $skip_edit " = false ] && { ! has_label " $desired " || [ -n " $remove " ] || [ -n " $add " ] ; } ; then
args = ( --add-label " $desired ${ add : +, $add } " )
[ -n " $remove " ] && args += ( --remove-label " $remove " )
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
if label_write " $n " " ${ args [@] } " ; then
2026-07-22 18:18:18 +00:00
log " # $n : state -> $desired ${ add : + + $add } ${ remove : + (cleared $remove ) } "
else
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was 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 inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: 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. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
# 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
2026-07-22 18:18:18 +00:00
fi
fi
2026-07-24 11:54:49 +00:00
# ---- the release-shape guard (#130): a warning, never a write --------
# Drafts are exempt (the build phase is the builder's); the version
# reads cost two API calls and only on PRs missing the label.
if [ " $DRAFT " != true ] && ! has_label release; then
release_shape_warning " $n " " $( tree_version " $HEAD_SHA " ) " " $( tree_version " $BASE_SHA " ) "
fi
2026-07-22 18:18:18 +00:00
# ---- merge-next: cleared, never set ----------------------------------
# Queue order is INTENT — which PR should land first is a judgement about
# conflicts and dependencies that GitHub knows nothing about, so the
# reconciler must not guess it (LABELS.md's rule for `blocked`/`release`).
# What it CAN do is stop the label going stale the way needs-human did:
# 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
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
label_write " $n " --remove-label merge-next || return 1
2026-07-22 18:18:18 +00:00
log " # $n : cleared merge-next (state is $desired , not mergeable-by-a-human) "
fi
# ---- stale: real activity only, and blocked is legitimately quiet ----
2026-08-03 15:13:30 +00:00
# forge_pr_activity owns the portable half: issue comments + commits +
# inline review comments. The flat /pulls/{n}/comments endpoint 404s on
# Forgejo; the forgejo backend re-derives it from reviews with
# comments_count > 0 (#188 / #4844). PR created_at and review submitted_at
# stay here — they are already in hand and need no second fetch.
2026-07-22 18:18:18 +00:00
last_activity = " $(
{
jq -r '.created_at' <<< " $PR_JSON "
2026-08-03 15:13:30 +00:00
jq -r '.[].submitted_at // empty' <<< " $REVIEWS_JSON "
2026-08-03 15:26:08 +00:00
# Non-fatal degrade (pre-#188 same edge), but do NOT swallow stderr —
# forge_api names failures loudly, and hiding them re-opens this issue's
# silent-green class (#4879 / #101 D5).
forge_pr_activity " $n " || true
2026-07-22 18:18:18 +00:00
} | sort | tail -n1
) "
2026-07-23 11:52:57 +00:00
last_activity_epoch = " $( date -d " $last_activity " +%s) "
age = $(( NOW - last_activity_epoch))
2026-07-23 00:44:16 +00:00
# needs-ruling joins blocked here: waiting on a human is legitimately quiet
# (#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
2026-07-22 18:18:18 +00:00
if has_label stale; then
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
label_write " $n " --remove-label stale || return 1
2026-07-22 18:18:18 +00:00
log " # $n : unstale "
fi
elif ! has_label stale; then
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
label_write " $n " --add-label stale || return 1
2026-07-22 18:18:18 +00:00
log " # $n : stale ( $(( age / 3600 )) h quiet) "
fi
2026-07-23 11:52:57 +00:00
# ---- the ruling invariants (#52): the bare-flag check + the 7-day nudge --
# The stale EXEMPTION above is #51's; these are the sweep halves that ride
# the same real-activity computation (lib/ruling.sh, shared with the issue
# side). Behind the flag check so flag-free PRs — all of them, almost
# always — cost no extra API reads.
if has_label needs-ruling; then
reconcile_ruling " $n " " $last_activity_epoch " " $NOW "
fi
2026-08-03 19:48:15 +00:00
# `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
2026-07-22 18:18:18 +00:00
}
main( ) {
feat(forge): refuse loudly when the client cannot speak the forge
The preflight half of #188, landed first so it stands alone: the forge is
decided once, before any sweep, and a client that cannot speak it exits
non-zero with a named reason.
Measured against forgejo.heavyduty.builders at 84bb1a4 — two of the three
actions reported SUCCESS having read nothing:
labels-scope exit 0 "no .github/labeler.yml" (the file is HTTP 200)
labels-reconcile exit 0 "reconciled." (zero PRs enumerated)
issueflow-reconcile exit 1 "unexpected end of JSON input"
labels-reconcile's blind-sweep warning (#96) could not fire: it counts
unreadable PRs against a list `gh pr list` never produced, and a process
substitution's failure does not trip set -e, so total stayed 0. Installing
gh makes it worse, silencing the one loud failure.
Detection is measured, not inferred from docs: a real forgejo-runner v6.3.1
job (probe task 278) shows Forgejo populating the whole GITHUB_* namespace,
so GITHUB_ACTIONS proves nothing. GITHUB_API_URL's shape, GITEA_ACTIONS and
GITHUB_SERVER_URL do. The same probe shows the runner image carries neither
gh nor stoke, which is what makes the forgejo backend REST.
Tests declare CEREMONY_FORGE at the forge boundary rather than stubbing gh
and staying silent about the forge — the boundary move term 5 asks for.
Refs #188
2026-08-02 18:29:08 +00:00
# BEFORE anything reads the board (#188). Every call site below is still
# `gh`, so that is what this declares — honestly, which is the point: on
# a Forgejo consumer the preflight refuses here instead of letting the
# sweep run blind and print "reconciled." over zero PRs (rig run 979).
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
# The forge is decided once, here, before anything reads the board, and
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
# wrapper that stood here died with the call-site port: it declared "this
# code uses gh", which stopped being true the moment every site went
# through the shim, and leaving it would have defaulted the forgejo path
# into the very client its own preflight refuses.
forge_preflight || return 1
# "" means decide from the environment; forge_select takes an explicit
# forge only in tests.
forge_select "" || return 1
feat(forge): refuse loudly when the client cannot speak the forge
The preflight half of #188, landed first so it stands alone: the forge is
decided once, before any sweep, and a client that cannot speak it exits
non-zero with a named reason.
Measured against forgejo.heavyduty.builders at 84bb1a4 — two of the three
actions reported SUCCESS having read nothing:
labels-scope exit 0 "no .github/labeler.yml" (the file is HTTP 200)
labels-reconcile exit 0 "reconciled." (zero PRs enumerated)
issueflow-reconcile exit 1 "unexpected end of JSON input"
labels-reconcile's blind-sweep warning (#96) could not fire: it counts
unreadable PRs against a list `gh pr list` never produced, and a process
substitution's failure does not trip set -e, so total stayed 0. Installing
gh makes it worse, silencing the one loud failure.
Detection is measured, not inferred from docs: a real forgejo-runner v6.3.1
job (probe task 278) shows Forgejo populating the whole GITHUB_* namespace,
so GITHUB_ACTIONS proves nothing. GITHUB_API_URL's shape, GITEA_ACTIONS and
GITHUB_SERVER_URL do. The same probe shows the runner image carries neither
gh nor stoke, which is what makes the forgejo backend REST.
Tests declare CEREMONY_FORGE at the forge boundary rather than stubbing gh
and staying silent about the forge — the boundary move term 5 asks for.
Refs #188
2026-08-02 18:29:08 +00:00
2026-07-22 18:18:18 +00:00
REPO = " ${ REPO : ?set REPO to owner/name } "
LABELS_CONF = " ${ LABELS_CONF :- .github/labels.conf } "
load_config " $LABELS_CONF "
NOW = " $( date +%s) "
if [ " ${ GITHUB_EVENT_NAME :- } " = workflow_dispatch ] ; then
log "workflow_dispatch: bootstrapping the taxonomy"
bootstrap_labels
fi
# The repo's label set, read ONCE per sweep — reconcile_pr filters every
# add against it, because one unknown name fails the whole edit call.
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
REPO_LABELS = " $( forge_label_list 2>/dev/null || echo "" ) "
2026-07-22 18:18:18 +00:00
[ -z " $REPO_LABELS " ] && log "WARNING: could not read the label set — applying labels unfiltered"
2026-07-24 00:50:20 +00:00
missing_core_labels_warning " $( core_label_rows) " " $REPO_LABELS "
2026-07-22 18:18:18 +00:00
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was 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 inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: 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. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
local n output status total = 0 unreadable = 0 write_failures = 0 sampled_reason = ""
2026-07-23 23:12:30 +00:00
while IFS = read -r n; do
[ -n " $n " ] || continue
total = $(( total + 1 ))
status = 0
output = " $(
(
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
PR_JSON = " $( forge_api " repos/ $REPO /pulls/ $n " ) "
2026-07-22 18:18:18 +00:00
DRAFT = " $( jq -r '.draft' <<< " $PR_JSON " ) "
AUTHOR = " $( jq -r '.user.login' <<< " $PR_JSON " ) "
set_required_bots " $AUTHOR "
HEAD_SHA = " $( jq -r '.head.sha' <<< " $PR_JSON " ) "
2026-07-24 11:54:49 +00:00
BASE_SHA = " $( jq -r '.base.sha' <<< " $PR_JSON " ) "
2026-07-22 18:18:18 +00:00
LABELS = " $( jq -r '.labels[].name' <<< " $PR_JSON " ) "
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
REVIEWS_JSON = " $( forge_api --paginate " repos/ $REPO /pulls/ $n /reviews " --jq '.[]' \
2026-07-22 18:18:18 +00:00
| jq -s '[.[] | select(.state != "PENDING")]' ) "
feat(forge): derive outstanding review requests from the head, not the field
Term 4. GitHub clears requested_reviewers when a verdict lands, so the field
answers "who still owes a verdict" by itself. Forgejo never clears it —
measured: rig!140 listed all three panelists with all three verdicts in, and
rig!146 still lists three while MERGED, so the field is stale even on a
closed PR.
Read raw on Forgejo that is not a cosmetic over-count. `requested` drives
three decisions, and a permanently-true field pins a PR at
state:bots-reviewing for life and stops blocker:unrequested from ever being
true: the sweep believes a round is live forever and no staleness can
correct it.
So the requested set is intersected with who has NOT submitted a verdict for
the current head, derived from /pulls/{n}/reviews — the read that is true on
both forges. On GitHub the filter removes nothing, because the field is
already accurate; term 5 holds by construction rather than by care.
A STALE approval — an approval of an older head — still owes a verdict. That
is the case that matters: treating it as answered would let a stale round
read as complete, which is the shape #136 exists to prevent.
Mutation-verified both ways: reading the field raw again reds three cases,
and treating STALE as answered reds two.
Also documents @grok-reviewer-andresmgsl's ask (#4763): every panel= account
must be able to read the repo, or the forge refuses the review request —
422 naming the account on Forgejo. A real failure mode for private
consumers, and it fails loudly rather than sweeping blind.
Refs #188
2026-08-02 19:48:09 +00:00
# Read AFTER the reviews, because the raw field is not portable: Forgejo
# never clears it, so it is intersected with who still owes a verdict on
# this head (#188 term 4). A no-op on GitHub, which clears it itself.
REQUESTED = " $( outstanding_requests " $( jq -r '.requested_reviewers[].login' <<< " $PR_JSON " ) " ) "
2026-07-22 18:18:18 +00:00
# mergeability + the check rollup, the two facts the state machine was
# blind to (#136). `gh pr view` rather than the REST PR object: the API's
# `mergeable` is a tri-state boolean that GitHub computes lazily, while
# this returns the same MERGEABLE/CONFLICTING/UNKNOWN string the UI shows.
# Failure to read them is NOT fatal and NOT treated as broken — an API
# hiccup must never flap every PR into needs-rebase, so both degrade to
# the "do not know" value that triggers nothing.
2026-07-24 00:47:37 +00:00
# The WHY goes to gh's stderr, and 2>/dev/null threw it away — a
# permanent denial and a network hiccup left byte-identical evidence,
# and #95 had to infer a cause from a control case instead of reading
# it off a run (wrongly, it turned out). Captured into a file (#101
# D2), never left to interleave raw into the per-PR output block,
# where an unlucky line could collide with a matched string.
GH_VIEW_ERR_FILE = " $( mktemp) "
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
GH_VIEW = " $( forge_pr_view " $n " 2>" $GH_VIEW_ERR_FILE " || echo '{}' ) "
2026-07-24 00:47:37 +00:00
GH_VIEW_ERR = " $( cat " $GH_VIEW_ERR_FILE " ) "
rm -f " $GH_VIEW_ERR_FILE "
2026-07-22 18:18:18 +00:00
MERGEABLE = " $( jq -r '.mergeable // "UNKNOWN"' <<< " $GH_VIEW " ) "
CHECKS = " $( checks_state <<< " $GH_VIEW " ) "
# Read failed: leave this PR exactly as it is. Recomputing on facts we
# did not read is how an API hiccup turns into a false "merge me" —
# and the next tick is 15 minutes away, not 15 hours.
if [ " $CHECKS " = UNREADABLE ] ; then
2026-07-24 00:47:37 +00:00
# Two lines on purpose (#101 D1): the sweep detects a wholly blind
# pass by whole-line-matching the counted line below, so the reason
# rides its OWN line — folding it in would silently break the
# `unreadable` counter and the wholly-blind warning #96 landed.
2026-07-22 18:18:18 +00:00
log " # $n : could not read mergeability/checks — left alone this pass "
2026-07-24 00:47:37 +00:00
log " # $n : read failed: $( read_failure_reason " $GH_VIEW_ERR " ) "
2026-07-22 18:18:18 +00:00
exit 0
fi
2026-08-03 17:23:11 +00:00
# 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) "
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
HEAD_COMMIT_AT = " $( forge_api " repos/ $REPO /commits/ $HEAD_SHA " \
2026-08-03 17:23:11 +00:00
--jq '.commit.committer.date' 2>" $HEAD_COMMIT_ERR_FILE " || echo "" ) "
HEAD_COMMIT_ERR = " $( cat " $HEAD_COMMIT_ERR_FILE " ) "
rm -f " $HEAD_COMMIT_ERR_FILE "
case " $HEAD_COMMIT_AT " in
"" | null)
# Say why it degraded (#101 D2/D4), on its own line: this one
# narrows a blocker rather than skipping the PR, so it must not
# read as the wholly-blind shape the counted line above matches.
HEAD_COMMIT_AT = ""
log " # $n : could not read the head commit's date: $( read_failure_reason " $HEAD_COMMIT_ERR " ) — blocker:unrequested not judged this pass " ; ;
esac
fi
2026-07-22 18:18:18 +00:00
reconcile_pr " $n "
2026-07-23 23:12:30 +00:00
) 2>& 1
) " || status= $?
[ -n " $output " ] && printf '%s\n' " $output "
if grep -qxF " labels: # $n : could not read mergeability/checks — left alone this pass " <<< " $output " ; then
unreadable = $(( unreadable + 1 ))
2026-07-24 00:47:37 +00:00
# the first observed reason stands in for the sweep in the blind warning
if [ -z " $sampled_reason " ] ; then
sampled_reason = " $( sed -n " s/^labels: # $n : read failed: //p " <<< " $output " | head -n1) "
fi
2026-07-23 23:12:30 +00:00
elif [ " $status " -ne 0 ] ; then
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was 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 inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: 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. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
# 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
2026-07-23 23:12:30 +00:00
fi
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
done < <( forge_pr_list)
2026-07-24 00:47:37 +00:00
blind_sweep_warning " $unreadable " " $total " " $sampled_reason "
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was 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 inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: 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. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
if [ " $write_failures " -gt 0 ] ; then
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
# 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 "
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was 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 inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: 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. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
return 1
fi
2026-07-22 18:18:18 +00:00
log "reconciled."
}
# sourced by test/labels-reconcile.sh for the fixture tests; executed in CI
if [ " ${ BASH_SOURCE [0] } " = " $0 " ] ; then
main " $@ "
fi