`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
The state-machine fixtures bound BOT1/BOT2/BOT3 to REQUIRED_BOTS by index off
the shipped .github/labels.conf, so three fixtures silently required a
four-member panel=. Shrinking it to three left the third slot unbound and
set -u aborted the file before assertion 1: 217 assertions became 0, on main
and on every branch cut from it.
The fixtures now write their own conf, in test/labels.test.sh's shape, at all
three load sites (top of file, the #205 re-drafted-round block, and the
mutant_blockers subshell). One live-file case survives as a property — the
shipped conf parses and recuses each member from its own panel — with no
index and no expected size, and a copy whose panel= names nobody proves it
still has teeth.
Refs #304
The predicate's fixtures cannot see the read that sets HEAD_COMMIT_AT, so a
sweep probe drives it both ways: read, and the blocker is written off a dated
head; denied, and the denial is named on its own line while the state still
converges — this read narrows one blocker, it does not skip the PR the way an
unreadable rollup does. Renaming the assignment reds the probe.
The read also moves after the mergeability/checks skip: a PR the sweep walks
away from must not pay for a call whose only consumer is a blocker that pass
will never decide.
Refs #236
The six cases the issue names, plus the boundary (the grace is inclusive), a
verdict inside the window against an old head, both unreadable timestamps, and
the configured-grace override.
Two proofs run rather than asserted in prose: a copy of the script with the
gate removed must flag the PENDING fixture, and a copy with the grace removed
must flag the inside-the-window one. The harness checks itself against the
unmutated copy first, or a flip would prove nothing.
The pre-#236 stall fixtures gain real timestamps. Their symbolic stamps are
not unreadable — GNU date reads `t1` as 01:00 in military timezone T, a time on
whatever day the suite runs — so a grace measured against a fixed NOW would
flip with the calendar. Every assertion is byte-identical.
Refs #236
@codex-reviewer-andresmgsl #4780, concurred by @grok-reviewer-andresmgsl
#4785. All four real.
1. Three issueflow call sites still named per_page=100. The backend
sanitized it so it worked, but the frozen term and the changelog both say
no call site names a page size — and a contract that holds only because
something downstream cleans up is not the contract. Endpoints now carry
their logical query alone.
2. The suite's summary and `[ "$fail" -eq 0 ]` gate sat in the MIDDLE of
test/labels-reconcile.test.sh, and the eight outstanding_requests expects
were appended after them. Proven before fixing: a deliberately broken
term-4 assertion printed FAIL, was excluded from the totals, and the
suite still exited 0. Those assertions were decorative. The gate moves to
the true end, with a note that nothing goes below it; the reported count
goes 157 -> 164, which is the eight that were never being counted.
3. forge_labels_add and forge_request_reviewer arrived with the port and had
no boundary pins. Both backends now have them, and the labels_add cases
pin the property ceremony#128 turns on: an additive POST, never a PUT of
the whole set, exactly one write so nothing is read-modify-written.
Mutation-verified — making it RMW/PUT, or routing github through
`issue edit --add-label`, each red their own cases.
4. The historical comment said the old gathers were `forge_api graphql`. My
own mechanical port rewrote it; before #188 they were `gh api graphql`
and the abstraction did not exist.
Refs #188
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
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
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
The reorder let a draft with a live human request plus a standing block
or comment fall through to round_state, whose human-request precedence
sits above BLOCK/FEEDBACK — 224 of claude's 1500 fixture cases read
needs-human on a PR GitHub cannot merge. decide_state now disqualifies
needs-human unconditionally under DRAFT=true, landing on
state:addressing like the blocker/needs-ruling/blocked clauses. The two
new rows assert the criterion where it can actually fail: human
requested x {CHANGES_REQUESTED, COMMENTED}. Also grok's nit: the
bootstrap row for state:building now matches LABELS.md (draft is
evidence, not the definition), and the CONSUMERS.md reflow nits are in.
Refs #205
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
round_outranks_draft consults the round before draft short-circuits: a
re-drafted PR carrying CHANGES_REQUESTED, an owed round-reply, or
push-staled approvals reads state:addressing; a live panel request on a
draft surfaces as state:bots-reviewing rather than being absorbed
(the must-not-paper-over combination, decided as: visible). Approvals do
not outrank draft, so a draft never reads needs-human, and a virgin
draft is byte-identical to before. LABELS.md's state:building row makes
draft evidence, not the definition.
Refs #205
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One resolution point (panel_for_author) feeds set_required_bots; the
author's row when the conf defines one, the base panel= otherwise, minus
the author in either case. Bracket prefixes are matched quoted so the
case patterns cannot glob (D7, panela= tripwire). configured_label_rows
skips the rows so a dispatch bootstrap cannot mint a label named after
one. BUILDER.md/REVIEWER.md carry the one D9 wording; CONSUMERS.md
publishes the row as unreleased with the parse-failure warning.
Refs #224
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shared reconcile concurrency group displaces queued sweeps as
CANCELLED, and the displaced run's successor attaches to a different PR —
so on the victim the newest self entry stayed CANCELLED, scored FAILURE,
and the sweep set blocker:ci-red off its own corpse every cadence
(crew#227). Drop rollup entries whose workflowName matches SELF_WORKFLOW
(defaulting to the ambient GITHUB_WORKFLOW — the caller's name, so no
workflow edit and no hardcoded consumer name) before the newest-per-context
collapse; an empty name filters nothing. A self-only rollup now honestly
scores NONE, and a genuine foreign failure still blocks beside a cancelled
self entry — the must-fail guard against re-opening #136.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
During the ceremony#111 freeze, rig#126/#128 carried blocked beside
state:needs-human — the round had finished, but the hold said the merge
must not happen, and rig#126 was merged seven minutes after the
reconciler wrote the green label. decide_state() only joined the two
axes through blockers(), which emits branch facts; the hand-set blocked
label was never consulted.
blocked becomes the second exclusion on state:needs-human, exactly
parallel to needs-ruling: round says needs-human + has_label blocked ->
state:addressing. Deliberately not a blockers() emission — BLOCKERS is
machine-owned and the converge loop would strip the live hold on the
next tick, the same trap #51 names for needs-ruling.
Ruling record: discussion 122, armed default A fired 2026-07-25T09:00Z.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
checks_state discards a CANCELLED entry only when its context group holds
at least one non-cancelled sibling — before the sort, so the duplicate the
repo-global reconcile queue evicted (after it had already attached a check
to the head) cannot outvote the success that did its work (#136a17e497,
#1334002924). An all-cancelled context never reported at all and still
classifies FAILURE; {FAILURE older, CANCELLED newest} keeps its red.
The fixture that pinned the opposite rule imagined a cancelled run
replacing a success; it never saw one that replaced nothing. Rewritten
with its reason, plus the recorded a17e497 shape, the all-cancelled
groups, and the cancelled-over-FAILURE case.
Closes#139
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bare X.Y.Z at the head where the base says otherwise, no release label,
not a draft: the sweep emits one :⚠️: per pass naming both
versions. A warning only — release is declared intent and the reconciler
never guesses intent (LABELS.md). Version read via the API, both
backends, jq not node; unreadable reads nag nobody. Plus the yq test
contract in CI and fixture tests for the guard matrix.
Part of #130.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unit block now feeds blind_sweep_warning a sampled reason and asserts
the new lead plus two must-fail guards: the disproven 'grant checks: read
and statuses: read' diagnosis stated as fact goes red, and so does any
drift in the counted line's whole-line shape (exactly the blind PRs match,
no more, no less — a reason line that matched would double-count, a folded
reason would undercount). read_failure_reason is covered pure: D4 wording
for empty stderr, multi-line collapse to one line, 400 chars truncated to
300 plus ellipsis within the 304-byte bound, 300 passing through whole.
blind_main_probe's gh pr view stub now fails with a denial on stderr, the
way real gh fails.
Part of #101.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI's shellcheck gate treats info findings as red. The gh() stubs paired
with bootstrap_labels are reached only through run's "$@", which
shellcheck cannot trace (the older probes stub reconcile_pr, which calls
gh directly), so they carry reasoned SC2317 directives; the LABELS.md
doctrine parse carries SC2016 for its literal backticks. Probes now live
in named functions, matching the house *_probe() pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Registry-vs-LABELS.md identity, happy path, missing label, one-name 403,
DRY_RUN narration, and the executed-subprocess dispatch (#91's lesson: a
sourced probe cannot see set -e). Cron and pull_request_target delete
nothing. Verified red without the guard: the unguarded dispatch dies on
the FIRST absent label, one delete of six attempted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pure decisions (shape presence, rung boundaries, Default: parse for wording
only), sweep probes for every AC path (malformed-once, conforming silence,
rungs despite activity, cron progression, missed-moment skip, re-flag
episode, unreadable comment list, malformed+rung same pass), and the
existing nudge fixtures updated to conforming escalations with pre-seeded
rung markers so each probe observes one behavior alone.
Part of #73.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real 'gh api --jq .[].created_at' on an empty collection emits no lines;
the stub printed a literal '[]', which under byte-wise collation sorts
after ISO-8601 timestamps and poisoned the PR-surface probe's
last_activity. Route the synthesized empty array through the same jq
projection as a present fixture, and pin the test's collation so the
verdict cannot flip with the runner's ambient locale.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Issue side: invariant-1 composition, the reclaim clock stopping under a
pending ruling (with a flag-free reclaim control), the stale heal, label
churn invisible to the activity clock, the surface-level nudge reset, and
no edit anywhere naming the flag. PR side: the wired nudge riding the
stale sweep's activity computation, one nudge across two sweeps, #51's
stale skip intact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>