Compare commits

..

No commits in common. "e035130f6564589e8a116a8d713a9c33744a0106" and "a663c631bf9050019d81bb3442298fd313a541a1" have entirely different histories.

75 changed files with 633 additions and 4085 deletions

4
.github/labels.conf vendored
View file

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

View file

@ -4,14 +4,6 @@
# door. Keep this list executable so a doors-unchanged record measures the
# workflow and only the scripts it actually runs, while the test catches a
# new or removed dependency before a record can silently omit it.
#
# On this tree the doors also speak the forge shim: #191 ported lib/facts.sh
# and release.yml off `gh` so a Forgejo consumer can publish at all, which
# makes lib/forge.sh part of the doors' executable path here. The backends it
# loads (lib/forge-github.sh, lib/forge-forgejo.sh) are sourced through
# $FORGE_LIB_DIR at run time rather than by a literal `.` line, so the
# transitive scan cannot see them and listing them would read as a stale
# path; lib/forge.sh standing for the trio is the honest entry (#198).
set -euo pipefail
printf '%s\n' \
@ -20,5 +12,4 @@ printf '%s\n' \
lib/version.sh \
lib/decide.sh \
lib/facts.sh \
lib/changelog.sh \
lib/forge.sh
lib/changelog.sh

View file

@ -15,18 +15,6 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# GitHub-hosted ubuntu-latest ships shellcheck; the Forgejo runner image
# this instance uses (ghcr.io/catthehacker/ubuntu:act-22.04) does not.
# actionlint already self-installs below — the same for shellcheck, so a
# green head is reachable once a ceremony runner is online (#188).
- name: Install shellcheck
env:
SHELLCHECK_VERSION: 0.10.0
run: |
curl -fsSLo shellcheck.tar.xz \
"https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
tar -xJf shellcheck.tar.xz "shellcheck-v${SHELLCHECK_VERSION}/shellcheck"
sudo install "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck
- name: Shellcheck
run: bash .github/scripts/shellcheck-all.sh
- name: Install actionlint

View file

@ -49,7 +49,7 @@ on:
env:
# A called workflow arrives without its repository. Keep this literal pin
# aligned with the ceremony release consumed by callers (issue #9 D3).
CEREMONY_SELF_REF: "0.6.0"
CEREMONY_SELF_REF: "0.5.0"
jobs:
reconcile:

View file

@ -48,7 +48,7 @@ on:
env:
# A called workflow arrives without its repository. Keep this literal pin
# aligned with the ceremony release consumed by callers (issue #9 D3).
CEREMONY_SELF_REF: "0.6.0"
CEREMONY_SELF_REF: "0.5.0"
jobs:
scope:

View file

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

View file

@ -54,7 +54,7 @@ name: release
# branches: [main]
# permissions:
# contents: write # tag ref create + release create + the bump push
# pull-requests: write # the label read; the bump-fallback PR
# pull-requests: write # the label read; the bump-fallback `gh pr create`
# issues: write # --label on that fallback PR rides the issues API
# jobs:
# release:
@ -87,7 +87,7 @@ name: release
# ## The artifact hook (#1 D4)
#
# If the consumer carries .github/actions/release-artifact/action.yml, both
# doors invoke it — after the tag exists, before the publish — with
# doors invoke it — after the tag exists, before `gh release create` — with
# `version` as input and RELEASE_ASSETS_DIR exported; every file the hook
# drops there is uploaded as a release asset. Exit non-zero to abort the
# release. No hook → no assets.
@ -129,7 +129,7 @@ env:
# `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the
# shared logic arrives as script files via checkout, not as inner `uses:`
# references.
CEREMONY_SELF_REF: "0.6.0"
CEREMONY_SELF_REF: "0.5.0"
VERSION_SOURCE: ${{ inputs.version-source }}
jobs:
@ -217,14 +217,7 @@ jobs:
echo "tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing." >&2
exit 1
fi
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
if ! exists="$(forge_release_exists "$VER")"; then
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
exit 1
fi
if [ "$exists" = yes ]; then
if gh release view "$VER" -R "$GITHUB_REPOSITORY" --json name >/dev/null 2>&1; then
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
exit 1
fi
@ -238,10 +231,8 @@ jobs:
# the tag door cannot double-fire off this tag — and this job is
# the only chance to publish (the sources' central comment).
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
forge_tag_create "$VER" "$MERGE_SHA"
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
-f "ref=refs/tags/$VER" -f "sha=$MERGE_SHA"
- name: artifact hook — the consumer's own release-artifact action
# Runs after the tag exists, before the publish (#1 D4). The local
# path resolves in the consumer checkout at the workspace root —
@ -262,10 +253,9 @@ jobs:
for f in "$RELEASE_ASSETS_DIR"/*; do
if [ -e "$f" ]; then assets+=("$f"); fi
done
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}"
gh release create "$VER" --verify-tag --title "$VER" \
--notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \
"${assets[@]}"
# The post-release bump, folded into the release act (the sources'
# operator decision: a mechanical one-liner deserves no PR of its
# own). X.Y.(Z+1)-dev is arithmetic, not judgment (version_next_dev
@ -304,13 +294,10 @@ jobs:
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
git checkout -b "chore/bump-$next"
git push origin "chore/bump-$next"
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
forge_pr_create "chore/bump-$next" main \
"chore: bump main to $next" \
"The post-release re-arm, opened by release.yml because the direct push was refused. One version bump, nothing else — never leave main armed to impersonate $VER." \
release
gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \
--title "chore: bump main to $next" \
--body "The post-release re-arm, opened by release.yml because the direct push was refused. One version bump, nothing else — never leave main armed to impersonate $VER." \
--label release
fi
release-on-tag:
@ -379,7 +366,6 @@ jobs:
for f in "$RELEASE_ASSETS_DIR"/*; do
if [ -e "$f" ]; then assets+=("$f"); fi
done
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}"
gh release create "$VER" --verify-tag --title "$VER" \
--notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \
"${assets[@]}"

View file

@ -12,211 +12,6 @@ Entries arrive as fragments — one `changelog.d/<issue>.md` per PR, never
an edit to this file — and the release PR assembles them into the next
section here (`bin/changelog-assemble`, #112).
This tree is `heavy-duty/ceremony` on
`forgejo.heavyduty.builders`, and it tracks the upstream tree's version
numbers (#197 D2). Two trees therefore answer to the same number, differing
by the forge-compatibility delta. **This tree carries upstream through
`8c3a4d1`** (upstream `0.6.0`, merged by #198); the `0.4.1` section below is
this forge's own release, not upstream's, and upstream's separate `0.4.1`
section is deliberately not carried — the tag published here is the one this
section is the body of. Each sync updates this line
(docs/UPSTREAM-SYNC.md, #200).
## 0.6.0 — 2026-08-05
### Added
- The issue-flow sweep's `claimed`-branch ruling pre-read is pinned: an
unassigned claim under `needs-ruling` must draw its board diagnostic and
its ruling nudge in one sweep, so a read that drifts below the diagnostic
reds instead of silently costing the escalation 7 days (#284, #307).
- The issue-flow sweep now flags a collision the board never declared: two
open, unblocked issues whose titles name one deliverable draw a comment
naming the newer's owed `Blocked by` edge. Keys normalize, so
`actions/x` and `x` are one deliverable (#288).
- The sweep now flags an unblocked non-member during a standing release
window, naming the window's invariant. `claimed` counts, PR in flight or
not. The gate is read from the release issue's own `Blocked by`
declarations, and an emptied gate leaves it dormant (#292).
- Both flags are advisory: comments only, no label write and no state
change, deduped against each family's last word on the thread so a
standing state re-sweeps silently (#293).
- The fragment guard now requires each entry to end with its issue
citation: one `(#N)` group — local, `repo#N` or `owner/repo#N`
references separated by `, ` — then the final `.` and nothing after it
(#262).
- The refusal distinguishes an entry carrying no reference at all from one
whose reference is present but not terminal, and names the shape to
write in both (#262).
- The 300-character bound still outranks the citation across the whole
fragment, and the outranked problem stays out of the message it lost
to: one fragment, one diagnosis, wherever in the file it sits (#262).
- BUILDER.md now describes a fix round that rides a draft: the draft phase
stays the builder's, ready-for-review is the builder's own act, and where a
draft suppressed the checks green is proven at the flip (#258).
- REVIEWER.md now reads a draft carrying `state:addressing` as a fix round in
progress rather than abandonment (#258).
- A `post-merge` item with no comment for 7 days now draws one nudge from the
issue sweep: the wake evidence is owed. A starving criterion used to be
found only when someone happened to run the right read (#254).
- Label churn does not reset that clock, and neither does an assignment: on
`post-merge` an assignee is an invalid composition, not activity, and it
must not buy the item another 7 days of silence (#254).
- The nudge names the triage actor from `triage-actors=`, not the human
reviewer: `post-merge` is triage's completion queue, so the starved wake
condition is triage's to answer (#254).
- It links the item and parses nothing from the body — which criterion
starved is prose, and the machine never judges prose (#254).
- Like the ruling nudge it carries no idempotency marker on purpose: the
comment is itself activity, so the rule self-rate-limits to one nudge per 7
quiet days. Comment-only — no path here writes a label (#254).
- Release epics now announce release initialization when their declared dependency gates clear (#253).
- The issue sweep now echoes an issue's parsed `Blocked by` set as a comment
whenever that set changes, so a readable-but-wrong declaration is visible in
one sweep instead of days later, when a human happens to run the parser by
hand (#252).
- The echo's marker carries the parsed set itself: an unchanged parse never
re-posts on a 15-minute cron, and a changed one always speaks. Comment-only
— no path here writes a label (#252).
- CI now refuses a root `*.md` declared in neither `docs/VENDORED.txt` nor the
guard's short exemption list, so a new doctrine file can no longer reach a
tag undeclared and stay invisible to every consumer's `docs-sync` (#251).
- The same guard reads the manifest the other way: every entry must resolve to
a regular, non-empty, tracked file — no symlink, no directory, no `../`
escape (#251).
- Document the optional, operator-ruled release-epic flow for governed repositories. (#248).
- Guard documentation availability markers against missing issue citations
and release candidates that already ship the cited work (#238).
- The label and issue-flow sweeps now comment once per episode when
`attention` targets a pull request or an unassigned issue, without
retargeting the demand or changing labels or assignees (#232).
- Pull requests that promise `Refs #N` now fail a read-only, body-edit-aware
guard if GitHub would close N through a keyword or sidebar link (#218).
### Changed
- `README.md` is rewritten whole from the current tree: the front page names
the governance repo ceremony now is, routes to `docs/CONSUMERS.md`,
`AGENTS.md`, `LABELS.md` and `RELEASES.md` rather than restating them, and
keeps the operator's release runbook as its core, re-measured (#311).
- Standing release windows are dependency DAGs: every mint is placed in the window or behind it, and only current sources are `ready` (#292).
- TRIAGE.md now requires unconditional collision-edge chains when open issues
carry the same deliverable, keeping the ready queue concurrently claimable
(#288).
- TRIAGE.md now states its rules with bare record cites: the label-race and
lifted-hold incident narratives leave the normative text while their
operational rules remain complete (#282).
- `BUILDER.md` states its rules and cites their record bare: the incident
narratives, the links into issue comments and the cross-repo issue cites
leave the normative text, which no rule leaves with them (#281).
- CONTRIBUTING.md now keeps vendored doctrine self-contained: state the rule,
retain at most one sentence of why, cite the local record bare, and leave the
incident narrative in that record (#280).
- BUILDER.md's green ruled term now says which entry to read before it says
what an entry means: a check's word at a head is its newest entry by start
time, and a cancelled entry is not that word while the same check carries a
non-cancelled one at that head (#276).
- A check whose every entry at the head is cancelled is unchanged — nothing
survived to be its word, so it never reported and is not green — and the
collapse mirrors `checks_state`'s carve-out rather than adding a class
(#276).
- BUILDER.md's step 1 now rules the checkless head: no checks configured is
nothing to wait for, and the request goes out straight away — stated once,
in the ruled-term paragraph, with the draft-round restatement removed
(#272).
- `README.md` and `RELEASES.md` derive `scope:docs`, and the
`changelog-assembled`, `docs-sync` and `runner-isolated` actions and tests
derive `scope:guards`; all five were mapped nowhere. The docs block matched
a literal `README`, which this tree does not carry (#267).
- `lib/read.sh` and `lib/ruling.sh` derive `scope:labels` beside
`scope:release-flow`. Both reconcilers share them, and a mixed file wears
both labels rather than `lib/**` being re-carved into a row per file (#267).
- TRIAGE.md now tells every epic author to put its progress checklist under
the literal `## Task list` heading, because any other heading is silently
invisible to the completion sweep (#266).
- TRIAGE.md now scopes the no-assignee board bug to flagging an unassigned
issue, while still directing triage to repair ownership instead (#264).
- `BUILDER.md` and `CHANGELOG.md` state the citation as guard-enforced
rather than as house style, beside the 300-character bound it now sits
next to (#262).
- Four fragments in flight gained a terminal citation; published sections
are untouched, so no shipped prose is re-opened (#262).
- BUILDER.md's green ruled term now names its field: greenness is read from
each check's `conclusion`, never its `status`, and *stale* means a check
of a superseded head — not a same-head node whose `status` lags its own
conclusion (#260).
- Consumer guidance: re-vendor tooling reads the pin's `docs/VENDORED.txt`,
never a hardcoded list, so a new doctrine file propagates at the next
ordinary pin bump with zero list edits (#251).
- Define the doors-unchanged drill record and an executable release-path list,
so a release may reuse live evidence only when its door bytes are unchanged
since the last rehearsed tag (#237).
### Fixed
- A roster edit no longer reds the whole suite: the labels-reconcile
state-machine fixtures name their own panel instead of binding
`.github/labels.conf` by slot (#304).
- Shrinking `panel=` to three had left that binding's third slot unbound, and
`set -u` aborted the file before its first assertion — 217 assertions
became 0, on `main` and on every branch cut from it (#304).
- The one case still reading the shipped roster asserts a property, not a
size: it parses, and each member is recused from its own panel. Any
`panel=` of one or more members leaves `test/run.sh` green (#304).
- `lib/attention.sh` locates as label machinery beside its two shelf-mates —
`[scope:release-flow]` alone was a wrong answer of the class #267 measured
— and the map learns the sweep workflow pair, the shared-lib tests, and
seven enumerated test/guard surfaces (#302).
- Claiming a `needs-ruling` issue no longer buys its escalation another 7
quiet days: the issue-side ruling clock reads comments alone — an
assignment is the claim clock's fact — and LABELS.md now names what each
surface's clock reads (#284).
- `scope:release-flow` no longer rides every pull request: `changelog.d/**`
is out of its path map. Doctrine makes every behavior change write a
fragment, so the glob labelled 20 of the last 20 PRs while 3 touched a
release surface. `CHANGELOG.md` stays, as only the release PR edits it
(#267).
- The issue-flow reconciler and its test now derive `scope:labels`, the scope
that already names the taxonomy they reconcile (#267).
- Abort issue-flow reconciliation when the board read fails instead of reporting a complete pass over an empty or partial result (#257).
- The issue sweep no longer derives label writes from a read that failed. An
HTTP 504 whose body is GitHub's JSON error object passed every guard and
emptied the label set, so a healthy epic was written `needs-triage` and the
pass reported success (#247).
- A failed comments read no longer reclaims a live claim. Swallowed, it dated
the issue by `created_at` and unassigned the builder under a comment
asserting 48 hours of silence about an issue commented on seconds earlier
(#247).
- A failed comments read no longer reads as "no marker", which re-posted the
comment the marker exists to suppress (#247).
- Every read inside the per-issue subshell is checked explicitly, on its
status and on its payload shape; the issue is left exactly as it is and the
sweep continues. A partial pass names its skipped issues after
`reconciled.` (#247).
- A per-issue pass is now atomic: its writes and its log lines commit only
once the pass completes. A skip could previously land after an earlier
mutation, reporting an issue as untouched when a label had already been
written or removed (#247).
- The issue-flow sweep now reads an issue's deliverable as the `Refs` PR that
merged last, not the one numbered highest — merge order is not number order,
and the old rule spent the transition marker on the wrong PR (#242).
- Preserve active claims when an open local pull request links them with `Refs #N`. (#241).
- `blocker:unrequested` no longer fires while a head's checks are pending or
red: the review round forbids requesting there, so the one blocker that
demanded an act flagged builders for complying. Pending is CI's move, red is
`blocker:ci-red`'s (#236).
- `blocker:unrequested` now waits for the round to settle — the head and the
newest verdict must have stood for `RECONCILE_UNREQUESTED_GRACE` (default
300s) — so a sweep landing between a push and its re-request no longer flags
a round in motion (#236).
- LABELS.md no longer claims nothing in `actions/` clears or reads
`attention`: the reconciler has done both since the derived `claimed`
`post-merge` transition shipped. The amended text keeps the hand-set rule
and admits the one clear and the diagnostic read (#231).
- Triage now puts `attention` on the assigned issue that owns a claim, never
on its pull request, and treats an unassigned issue as a board bug rather
than a demand (#230).
## 0.5.0 — 2026-08-03
### Added
@ -255,95 +50,30 @@ section is the body of. Each sync updates this line
draft surfaces as `state:bots-reviewing`, and a draft with no round history
still reads `state:building` (#205).
## 0.4.1 — 2026-08-04
### Added
- `forge_release_exists`, `forge_commit_pulls`, `forge_tag_create`,
`forge_release_create` and `forge_pr_create` on both backends, so the
release path names no client (#191).
- The forgejo backend serves one PR object at `/commits/{sha}/pull` where
GitHub serves an array at `/pulls`; both verbs emit the array shape, so
the call site carries one expression (#191).
- Forgejo creates tags at `POST /tags` — it serves `/git/refs` GET-only,
so GitHub's ref-POST would have 404'd there forever (#191).
- `forgejo_api_base` refuses when `REPO` is empty. Every verb interpolates
it and every call reaches the network through there, so `repos//…`
whose 404 reads as "no release" and "no PRs" — is now impossible (#191).
- Release asset names are percent-encoded. The hook contract permits any
filename, and the name travels as a query value: a space made curl reject
the URL and `&`/`#`/`+`/`%` silently renamed the asset (#191).
- `lib/forge.sh` — the forge selector: `forge_detect` names the forge from
the runner's own environment, `forge_client` names the client it needs, and
`forge_preflight` refuses loudly before any sweep when the two disagree
(#188).
- The reconcilers and `labels-scope` run that preflight first, so a
GitHub-shaped client on a Forgejo instance is a named refusal instead of a
sweep that reads nothing and reports success (#188).
- `lib/closes_references.sh` — the closing-keyword parser, sibling of
`refs_references`, so "which issues does this PR close" is answered from a
PR body rather than from GitHub's GraphQL API (#188).
- `lib/forge-github.sh` and `lib/forge-forgejo.sh` — one call surface, two
backends, selected by `forge_select`; no forge branching at the call sites
(#188).
- The forgejo backend proves each paginated gather complete against the
server's `x-total-count` and refuses loudly when it cannot — a missing
header is a refusal, not a pass (#188).
## 0.4.1 — 2026-08-01
### Changed
- `docs/CONSUMERS.md`'s artifact-hook recovery no longer tells operators to
run `gh release create` by hand — on a Forgejo runner there is no `gh`.
It names the forge-neutral tag-door path first, with both clients shown
(#191).
- `issueflow-reconcile` gathers open and merged PRs over REST instead of
`gh api graphql`. Forgejo serves no GraphQL at all, so the two queries were
replaced rather than translated; both forges return `number` and `body`
from `/pulls` in the same shape (#188).
- `forge_api` owns the page size, because each forge silently ignores the
other's parameter: `per_page=100` reads 30 items on Forgejo and `limit=100`
reads 30 on GitHub, both HTTP 200. No call site names one (#188).
- Outstanding review requests are derived from the reviews on the current head
rather than from `requested_reviewers`, which Forgejo never clears — read
raw there, a PR would sit at `state:bots-reviewing` forever (#188).
- The reconcile sweep is detached from PR-triggered runs: a new reusable
`labels-sweep.yml` carries it, woken by `labels.yml`'s new `trigger` job, so
a queue-displaced sweep cancels on the Actions tab instead of landing a
cancelled `reconcile` check on a PR (#209).
- Labels consumers add a sweep caller (`labels-sweep.yml`, stub in
docs/CONSUMERS.md), relocate the hourly cron and manual bootstrap dispatch
to it, and grant the labels caller `actions: write`; a pin bump without the
sweep caller goes loudly red at the trigger job (#209).
### Fixed
- The release doors run on a Forgejo consumer. `lib/facts.sh` and
`release.yml` gathered and published through `gh`, which the runner image
does not ship, so the merge door read `labeled=no` for a correctly
labeled ceremony PR and the tag door died at the publish (#191).
- A release fact that could not be read is no longer reported as a definite
`no`. A completed read finding no label is still `no` and still
fail-closed; a read that did not complete refuses and emits no fact
(#191).
- `labels-scope` no longer fails to compile its jq program on jq 1.6, which
the Forgejo runner image ships: `label` is a reserved word in jq's grammar,
so `$label` is a syntax error there and every scope derivation died before
reading the config (#188).
- `labels-reconcile` and `labels-scope` no longer exit 0 on a Forgejo
consumer having read zero facts — measured on `heavy-duty/rig`, where the
sweep printed `reconciled.` over an empty PR list and scope reported "no
labeler.yml" for a file that exists (#188).
- `forge_timeline` projects Forgejo's label events (`.type` / `.body` /
`.user.login`) into the GitHub shape (`.event` / `.actor.login`) so the
ruling ladder reads the same board on both forges (#188).
- `forge_pr_activity` no longer calls `/pulls/{n}/comments` on Forgejo
(HTTP 404); inline review comments come from reviews with
`comments_count > 0` (#188).
- CI installs shellcheck before linting, matching actionlint — the Forgejo
runner image does not ship it (#188).
- `checks_state` drops rollup entries belonging to the workflow it runs
inside — `SELF_WORKFLOW`, defaulting to the ambient `GITHUB_WORKFLOW`
before the newest-per-context collapse: the label machine never grades
its own runs, and an empty name filters nothing (#208).
- A sweep displaced from the shared concurrency queue attaches CANCELLED to
its PR while its successor attaches elsewhere, so the sweep set
`blocker:ci-red` off its own displaced run and re-affirmed it every
cadence (crew#227). A rollup of only self entries now honestly scores
NONE (#208).
## 0.4.0 — 2026-07-29

View file

@ -41,37 +41,28 @@ only ceremony-specific facts such as the roster and code conventions.
### Roster
Three identities share the work (org team `agents`), each living in its own
Five identities share the work (org team `agents`), each living in its own
[box](https://github.com/heavy-duty/box) — one box per credential, because
the box is the blast-radius boundary; roles are what a session is told, and
[AGENTS.md](AGENTS.md) routes from there:
| identity | box (rig tenant) | standing work |
|---|---|---|
| `cluade-reviewer-andresmgsl` | `triage` (claude-box) | **triage** — the only door issues come through; this identity mints issues and nothing else writes them (#18's `triage-actors`) — and review. It does not build. |
| `codex-reviewer-andresmgsl` | codex-box | build + review |
| `kimi-reviewer-andresmgsl` | kimi-box | review |
| `dan-claude-bot` | `triage` (claude-box) | **triage** — the only door issues come through; this identity mints issues and nothing else writes them (#18's `triage-actors`) |
| `claude-bot-andresmgsl` | claude-box | build (release-flow and guards machinery) + review |
| `codex-bot-andresmgsl` | codex-box | build (scaffolding, conversions) + review |
| `grok-bot-andresmgsl` | grok-box | review |
| `kimi-bot-andresmgsl` | kimi-box | review — builder trial on a small mechanical issue once its verdicts have a track record |
**The review panel for any PR is every bench identity except its author** —
recusal by construction, enforced by the reconciler (#10): the required
verdicts are the panel minus the PR's author. On this roster that resolves
to **two** cross-vendor approvals of the current head, because the only
builder is itself on the panel and recuses from its own PRs; the rule is
panel-minus-author, and two is what it currently comes to, not a second
rule. Builders and triage default to different models so the issue contract
is honestly exercised — a spec gap should surface as a question on the
issue, not be silently filled by shared priors. Humans (`andres`) decide in
discussions and merge; the roster is config, not doctrine — swapping a
vendor is an edit to this table (and to `panel=` in
`.github/labels.conf` once #10 lands), nothing more.
The identities named here must be the identities `.github/labels.conf`
names, and both must exist on the forge the repo lives on. A roster that
agrees with itself and disagrees with the instance is the failure #195
records: `panel=` naming absent users cannot converge and
`triage-actors=` naming an absent user makes every issue a stray mint that
nobody can normalize. `test/labels.test.sh` holds this table and the conf
to the same set, in both directions.
verdicts are the panel minus the PR's author, so convergence always means
three cross-vendor approvals of the current head. Builders and triage
default to different models so the issue contract is honestly exercised —
a spec gap should surface as a question on the issue, not be silently filled
by shared priors. Humans (`danmt`) decide in discussions and merge; the
roster is config, not doctrine — swapping a vendor is an edit to this table
(and to `panel=` in `.github/labels.conf` once #10 lands), nothing more.
Each governed repo names its own roster in its CONTRIBUTING; this one is
ceremony's. Its `scope:*` set is the same kind of repo-specific fact:

View file

@ -122,16 +122,12 @@ transcription because humans err silently and machines fail loudly:
**everything asserts its way to certainty and fails loudly, creating
nothing** — a wrong release is worse than a missing one, so every assert in
this file fires *before its door creates anything*, and one that fails leaves
zero artifacts of the run's own: no tag it made, no release, no bump. Three
steps run past the tag, and what a failure at each leaves behind is what
sorts them. Two fail before the release exists: the consumer's
zero artifacts of the run's own: no tag it made, no release, no bump. Only two
steps run past the tag. The consumer's
[artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and
the publish, so its non-zero exit aborts, and the publish itself
([`gh release create --verify-tag`](.github/workflows/release.yml#L246-L258))
can fail on the API call or the assets. Either leaves the same state — a tag
standing and no release — which the
[nothing-exists assert](#the-merge-door-refused-releaseyml) names and the tag
door recovers. The third is the re-arm, which runs after the publish, and its
the publish, so its non-zero exit aborts with a tag standing and no release —
a state the [nothing-exists assert](#the-merge-door-refused-releaseyml) names,
and recovers by the tag door. The re-arm runs after the publish, and its
refusal is the single failure in this file that leaves a real release behind.
## The two doors
@ -489,9 +485,9 @@ clobber, and what catches a manual tag racing the merge. If the release
truly exists, there is nothing to do: this red is the system declining to do
the thing twice. If the tag exists but the release does not (a manual tag
won the race, or
[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook), or the publish
step itself failing after the tag), recover by the tag door: delete and
re-push the tag, or `gh release create` by hand from a fixed tree.
[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook)), recover by
the tag door: delete and re-push the tag, or `gh release create` by hand
from a fixed tree.
> direct push refused (branch protection?) — opening the bump PR instead
@ -549,9 +545,10 @@ half, and *that* half is unreachable as the doors stand — rows 12 send `-de
to a no-op, and the tag door never bumps. A malformed version is not: nothing
upstream checks the shape ([version_read](lib/version.sh#L22-L33) checks only
that a version is present and non-empty), so `banana` rides row 6 exactly as
an rc does, and the same manual bump is the remedy. One note on work that has
not landed: #317 would make rc cuts native and their re-arm deterministic, and
if it lands only the malformed half still reaches this refusal.
an rc does, and the same manual bump is the remedy. The rc half has a shelf
life: the 0.7.0 window makes rc cuts native and their re-arm deterministic
(#317), after which no rc reaches this refusal — the malformed half still
does.
> version_write: npm is required for version-source: package-json

View file

@ -1 +1 @@
0.6.1-dev
0.5.1-dev

View file

@ -27,10 +27,6 @@ TRIAGE_ACTORS=()
# The needs-ruling invariants (#52) — one implementation for both surfaces.
# shellcheck source=lib/ruling.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
# shellcheck source=lib/closes_references.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh"
# The attention target invariants (#232) — diagnosis only, both surfaces.
# shellcheck source=lib/attention.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/attention.sh"
@ -387,7 +383,7 @@ state_echo_needed() { # $1 issue, $2 family, $3 this state's marker → 0 echo,
# its OWN last word and never against another flag's (#293 D4 asks for the
# declaration echo's mechanism exactly, and three families now share it).
local bodies last
guarded_read bodies forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
guarded_read bodies gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
|| skip_issue "$1" "could not read its comments: $(read_failure_reason "$READ_FAILURE_STDERR")"
# The read fails closed above (#247 D1): an unreadable history skips the
# issue rather than answering "nothing echoed yet" and re-posting.
@ -631,7 +627,7 @@ skipped_tail() { # $1 = skip count, $2 = the issue numbers → the D6 line, or n
ensure_comment() { # $1 issue, $2 marker, $3 message
local n="$1" marker="$2" message="$3"
if issue_comment_has_marker "$n" "$marker"; then return; fi
run forge_issue_comment "$n" "<!-- issueflow:$marker -->
run gh issue comment "$n" -R "$REPO" --body "<!-- issueflow:$marker -->
$message" >/dev/null
}
@ -641,7 +637,7 @@ issue_comment_has_marker() { # $1 issue, $2 marker → 0 found, 1 genuinely abse
# absence (#247 D1). It cannot be a return value: every caller treats
# non-zero as "absent", so the skip is taken here, at the read.
local bodies
guarded_read bodies forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
guarded_read bodies gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
|| skip_issue "$1" "could not read its comments: $(read_failure_reason "$READ_FAILURE_STDERR")"
grep -qF "<!-- issueflow:$2 -->" <<<"$bodies"
}
@ -650,7 +646,7 @@ reference_states() {
local ref state
while IFS= read -r ref; do
[ -n "$ref" ] || continue
state="$(forge_api "repos/$REPO/issues/$ref" --jq '.state' 2>/dev/null || echo UNKNOWN)"
state="$(gh api "repos/$REPO/issues/$ref" --jq '.state' 2>/dev/null || echo UNKNOWN)"
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
done
}
@ -661,13 +657,13 @@ offsite_pr_states() {
[ -n "$ref" ] || continue
repo="${ref%#*}"
number="${ref##*#}"
state="$(forge_api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo UNKNOWN)"
state="$(gh api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo UNKNOWN)"
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
done
}
offsite_timeline() { # unreadable timelines are deliberately silent
forge_api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1
gh api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1
}
issue_activity_at() { # $1 issue, $2 created_at, $3 with-assignment|comments-only
@ -680,13 +676,13 @@ issue_activity_at() { # $1 issue, $2 created_at, $3 with-assignment|comments-onl
# `claimed` issue created months ago but commented on seconds earlier is
# reclaimed — the live builder unassigned, under a comment asserting 48
# hours of silence. `needs-triage` is cheap to remove; that is not.
# The backend's stderr is left to flow to this function's own, where the
# caller's guarded_read captures it for the reason line.
# gh's stderr is left to flow to this function's own, where the caller's
# guarded_read captures it for the reason line.
local n="$1" created="$2" mode="$3" comments timeline="" latest
comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at')" \
comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at')" \
|| return 1
if [ "$mode" = with-assignment ]; then
timeline="$(forge_api --paginate "repos/$REPO/issues/$n/timeline" \
timeline="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \
--jq '.[] | select(.event == "assigned") | .created_at')" || return 1
fi
latest="$(printf '%s\n%s\n%s\n' "$created" "$comments" "$timeline" | sort | tail -n1)"
@ -744,7 +740,7 @@ reconcile_board_flags() { # $1 = issue — the collision and window flags (#293)
if state_echo_needed "$n" collision "$marker"; then
rendered="$(tr ',' '\n' <<<"$state" \
| awk -F= '{ print "- `" $1 "` — also carried by #" $2 }')"
run forge_issue_comment "$n" "<!-- issueflow:$marker -->
run gh issue comment "$n" -R "$REPO" --body "<!-- issueflow:$marker -->
This issue and the issue named beside each key below are both open and
unblocked, and their titles name the same deliverable:
@ -773,7 +769,7 @@ marker carries the collision itself, so an unchanged one never re-posts.*" >/dev
if [ -n "$state" ]; then
marker="$(state_marker window-nonmember "$state")"
if state_echo_needed "$n" window-nonmember "$marker"; then
run forge_issue_comment "$n" "<!-- issueflow:$marker -->
run gh issue comment "$n" -R "$REPO" --body "<!-- issueflow:$marker -->
A release window is standing ($state) and this issue is neither one of its
gate members nor an \`epic\` or \`post-merge\` issue.
@ -809,7 +805,7 @@ reconcile_issue() {
decision="$(queue_decision <<<"$ISSUE_LABELS")"
case "$decision" in
ADD_NEEDS_TRIAGE)
run forge_issue_edit "$n" --add-label needs-triage >/dev/null
run gh issue edit "$n" -R "$REPO" --add-label needs-triage >/dev/null
log "#$n: needs-triage (no queue state)" ;;
FLAG_CONFLICT)
ensure_comment "$n" queue-conflict \
@ -855,10 +851,10 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
# would create an impossible parked-for state (#175 D4).
has_issue_label attention && remove_claimed=claimed,attention
if [ -n "$owners" ]; then
run forge_issue_edit "$n" --remove-assignee "$owners" \
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
else
run forge_issue_edit "$n" \
run gh issue edit "$n" -R "$REPO" \
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
fi
log "#$n: merged Refs PR -> post-merge; claim released"
@ -887,10 +883,10 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.'
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
if [ -n "$owners" ]; then
run forge_issue_edit "$n" --remove-assignee "$owners" \
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
--remove-label claimed --add-label ready >/dev/null
else
run forge_issue_edit "$n" --remove-label claimed --add-label ready >/dev/null
run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null
fi
log "#$n: stale claim reclaimed -> ready" ;;
esac
@ -949,7 +945,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
# how the wrong addressee comes back.
if [ "$(ruling_nudge_decision "$NOW" "$evidence_age")" = NUDGE ]; then
local quiet_days=$(((NOW - evidence_age) / 86400))
run forge_issue_comment "$n" "@${TRIAGE_ACTORS[0]} — this \`post-merge\` item has had no comment for ${quiet_days} days: ${GITHUB_SERVER_URL:-}/$REPO/issues/$n
run gh issue comment "$n" -R "$REPO" --body "@${TRIAGE_ACTORS[0]} — this \`post-merge\` item has had no comment for ${quiet_days} days: https://github.com/$REPO/issues/$n
Its wake evidence is still owed. \`post-merge\` means the merge landed and
triage owns completion — judge the remaining criteria against the evidence
@ -981,7 +977,7 @@ judges prose (the link is the payload).
parsed_set="$(blocked_parse_set "$refs" "$cross_refs")"
parse_marker="$(blocked_parse_marker "$parsed_set")"
if blocked_parse_echo_needed "$n" "$parse_marker"; then
run forge_issue_comment "$n" "<!-- issueflow:$parse_marker -->
run gh issue comment "$n" -R "$REPO" --body "<!-- issueflow:$parse_marker -->
This issue's \`Blocked by\` declarations parse to: $parsed_set
That is the exact set this sweep gates on — what the machine read, never a
@ -1009,7 +1005,7 @@ itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null
READY)
ensure_comment "$n" blockers-cleared \
'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.'
run forge_issue_edit "$n" --remove-label blocked --add-label ready >/dev/null
run gh issue edit "$n" -R "$REPO" --remove-label blocked --add-label ready >/dev/null
log "#$n: blockers closed -> ready" ;;
esac
elif has_issue_label epic; then
@ -1067,7 +1063,7 @@ See \`$release_doctrine_path\`. The operator blessing the order is the one step
# An already-applied stale comes off: waiting on a human is legitimately
# quiet (#50 D10), and nothing on the issue side ever puts stale back.
if has_issue_label stale; then
run forge_issue_edit "$n" --remove-label stale >/dev/null
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
log "#$n: unstale (a ruling is pending)"
fi
# The ruling clock reads comments only (#284 D1): an `assigned` event is
@ -1088,19 +1084,11 @@ See \`$release_doctrine_path\`. The operator blessing the order is the one step
reconcile_opened_issue() {
local n="$1" author triage=false labels remove="" label
ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")"
ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")"
# The stand-downs return 0 explicitly: a bare return carries the failed
# test's status, which under execution is live `set -e` — and it killed the
# run on every triage-authored mint, before one issue was reconciled (#91).
# `.pull_request == null`, NOT `has("pull_request") | not` (#188). The key's
# PRESENCE is a GitHub-only discriminator: GitHub omits it on a plain issue,
# Forgejo emits it on every entry and sets it to null. Measured on the list
# endpoints, 2026-08-02 — GitHub 0 of 9 issues carried the key, Forgejo 10 of
# 10 did. So the old test selected ZERO issues here and the sweep printed
# "reconciled." over an untouched board: the blind sweep again, one layer in.
# The null test is true for an issue on both forges (absent key reads as
# null) and false for a PR on both.
jq -e '.pull_request == null' <<<"$ISSUE_JSON" >/dev/null || return 0
jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || return 0
author="$(jq -r '.user.login' <<<"$ISSUE_JSON")"
is_triage_actor "$author" && triage=true
labels="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
@ -1110,9 +1098,9 @@ reconcile_opened_issue() {
done
remove="${remove#,}"
if [ -n "$remove" ]; then
run forge_issue_edit "$n" --add-label needs-triage --remove-label "$remove" >/dev/null
run gh issue edit "$n" -R "$REPO" --add-label needs-triage --remove-label "$remove" >/dev/null
else
run forge_issue_edit "$n" --add-label needs-triage >/dev/null
run gh issue edit "$n" -R "$REPO" --add-label needs-triage >/dev/null
fi
log "#$n: needs-triage (opened by $author)"
}
@ -1137,7 +1125,7 @@ reconcile_issue_pass() { # $1 = issue — one issue's whole pass, in its own sub
# subshell's own, as it was when reconcile_issue was the last command
# here: the commit must not overwrite it, and must not run under it.
STAGING=true
guarded_read ISSUE_JSON forge_api "repos/$REPO/issues/$n" \
guarded_read ISSUE_JSON gh api "repos/$REPO/issues/$n" \
|| skip_issue "$n" "could not read the issue: $(read_failure_reason "$READ_FAILURE_STDERR")"
issue_payload_valid "$n" <<<"$ISSUE_JSON" \
|| skip_issue "$n" "the issue read answered a payload that is not issue #$n carrying a label array"
@ -1157,86 +1145,57 @@ reconcile_issue_pass() { # $1 = issue — one issue's whole pass, in its own sub
}
main() {
# See labels-reconcile's twin (#188). This one already failed loudly on
# Forgejo — but with `line 408: gh: command not found`, which names the
# symptom and not the cause, and only after the sibling step had already
# reported a green blind sweep.
# 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
local owner name
REPO="${REPO:?set REPO to owner/name}"
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
load_issueflow_config "$LABELS_CONF"
if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then
reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}"
fi
# Both gathers were `gh api graphql` until #188. Forgejo has NO GraphQL
# API — a real forgejo-runner job even arrives with GITHUB_GRAPHQL_URL set
# to the empty string (probe task 278) — so these could not be translated
# to a Forgejo endpoint; there is none. They are REST + a parser this repo
# owns, over `number`, `body` and `merged_at`, which /api/v3 and /api/v1
# both return in the same shape (measured on both; `.merged_at` is
# Z-suffixed UTC on each, so it still sorts as a string).
#
# owner/name were separate GraphQL variables until #188. REST takes the
# owner/name path whole, so they are gone.
#
owner="${REPO%%/*}"
name="${REPO#*/}"
# crew#321 released a live claim because the open side read only closing
# links while the merged side parsed Refs bodies. One parser now supplies
# the local body references on both sides, so transition and reclaim agree
# — which is why this is `open_pr_issues` over Refs and no longer
# `closes_references`.
#
# Bodies travel base64 because they contain newlines: jq's @tsv escapes a
# newline to a literal backslash-n, which a line-oriented parser reads as
# one line and silently loses every declaration after the first.
# `open_pr_issues` IS line-oriented — upstream fed it `split("\n")[]` — so
# the decoded body is re-split into one BODY row per physical line here.
# Handing it the whole decoded body as ONE record loses every declaration
# including the first, silently, because the trailing lines arrive as
# records whose kind matches neither case arm (#198).
#
# BOTH record kinds are fed, not just BODY. Upstream's CLOSING rows came
# from GitHub's `closingIssuesReferences` — GitHub's own parse of the
# CLOSING KEYWORDS in the body — and `lib/closes_references.sh` is exactly
# the replacement #188 wrote for that field. Feeding BODY rows alone would
# drop every `Closes #N` link on the open side: `refs_references` matches
# `Refs` and deliberately not `Closes` (#151), so an open PR that says it
# CLOSES an issue would stop counting as that issue's open PR and the claim
# would be reclaimed under it. test/issueflow-reconcile.test.sh's
# base64-round-trip case is that regression, and it is red without this.
OPEN_PR_ISSUES="$(forge_api --paginate "repos/$REPO/pulls?state=open" \
--jq '.[] | .body // "" | @base64' \
| while IFS= read -r b64; do
[ -n "$b64" ] || continue
body="$(printf '%s' "$b64" | base64 -d)"
printf '%s' "$body" | closes_references | awk '{ print "CLOSING\t" $0 }'
printf '%s' "$body" | awk '{ print "BODY\t" $0 }'
done | open_pr_issues)"
# Three columns: ISSUE<TAB>PR<TAB>MERGED_AT (#242). The third is not
# decoration — `post_merge_pr_for_issue` sorts on it to answer the PR that
# merged LAST rather than the one numbered highest, and with the column
# absent every sort key ties and the old number order comes back silently.
# base64 cannot contain a tab, so the three fields split cleanly.
MERGED_REF_PR_RECORDS="$(forge_api --paginate "repos/$REPO/pulls?state=closed" \
--jq '.[] | select(.merged_at != null)
| "\(.number)\t\(.merged_at)\t\(.body // "" | @base64)"' \
| while IFS=$'\t' read -r pr merged b64; do
[ -n "$b64" ] || continue
# the local body references on both sides, so transition and reclaim agree.
OPEN_PR_ISSUES="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query='
query($owner: String!, $name: String!, $endCursor: String) {
repository(owner: $owner, name: $name) {
pullRequests(first: 100, states: OPEN, after: $endCursor) {
nodes { body closingIssuesReferences(first: 100) { nodes { number } } }
pageInfo { hasNextPage endCursor }
}
}
}' --jq '.data.repository.pullRequests.nodes[]
| (.closingIssuesReferences.nodes[].number
| ["CLOSING", tostring] | @tsv),
((.body // "") | split("\n")[] | ["BODY", .] | @tsv)' \
| open_pr_issues)"
MERGED_REF_PR_RECORDS="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query='
query($owner: String!, $name: String!, $endCursor: String) {
repository(owner: $owner, name: $name) {
pullRequests(first: 100, states: MERGED, after: $endCursor) {
nodes { number mergedAt body }
pageInfo { hasNextPage endCursor }
}
}
}' --jq '.data.repository.pullRequests.nodes[]
| .number as $pr | .mergedAt as $merged | .body | split("\n")[]
| [$pr, $merged, .] | @tsv' \
| while IFS= read -r record; do
# Split on exact tabs rather than IFS: tab is IFS whitespace, so bash
# collapses a run of them, and a middle column that ever came back
# empty would silently shift the body one field left. The body is
# arbitrary text and stays last, where the remainder belongs.
pr="${record%%$'\t'*}"
rest="${record#*$'\t'}"
merged="${rest%%$'\t'*}"
body="${rest#*$'\t'}"
while IFS= read -r issue; do
[ -n "$issue" ] && printf '%s\t%s\t%s\n' "$issue" "$pr" "$merged"
done < <(printf '%s' "$b64" | base64 -d | refs_references)
done < <(refs_references <<<"$body")
done)"
local n tail_line issue_numbers board_json release_bodies rn rbody gate body
local n tail_line issue_numbers board_json release_bodies rn rbody gate
local window_rendered=""
SKIPPED_COUNT=0
SKIPPED_ISSUES=""
@ -1250,7 +1209,7 @@ main() {
# issue's labels and title, and every open `release` issue's body. One read
# supplies all of it; a second pagination for the same rows would be a
# second board, free to disagree with this one mid-sweep.
if ! guarded_read board_json forge_api --paginate \
if ! guarded_read board_json gh api --paginate \
"repos/$REPO/issues?state=open&per_page=100"; then
log "could not read the issue board: $(read_failure_reason "$READ_FAILURE_STDERR")"
return 1

View file

@ -74,8 +74,6 @@ SELF_WORKFLOW="${SELF_WORKFLOW:-${GITHUB_WORKFLOW:-}}"
# The needs-ruling invariants (#52) — one implementation for both surfaces.
# shellcheck source=lib/ruling.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
# The attention target invariants (#232) — diagnosis only, both surfaces.
# shellcheck source=lib/attention.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/attention.sh"
@ -281,35 +279,6 @@ set_required_bots() { # the PR author is recused by construction
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
# outstanding_requests <requested-logins> — the portable "who still owes a
# verdict on THIS head" (issue #188, term 4).
#
# GitHub clears requested_reviewers when a verdict lands, so on that forge the
# field already answers this question and the filter below removes nothing.
# **Forgejo does not clear it.** Measured 2026-08-02: rig!140 listed all three
# panelists with all three verdicts in, and rig!146 still lists three while
# MERGED — the field is stale even on a closed PR, so it over-counts forever.
#
# Reading it raw on Forgejo pins a PR at state:bots-reviewing for life and
# stops blocker:unrequested from ever being true: the sweep believes a round
# is permanently live. So the requested set is intersected with "has not
# submitted a verdict for the current head", which is derived from
# /pulls/{n}/reviews — the read that is true on both forges.
#
# Pure over REVIEWS_JSON/HEAD_SHA so the fixtures can drive it; a reviewer
# whose only verdict is STALE still owes one, which is why this asks
# bot_verdict rather than merely "has any review".
outstanding_requests() {
local login
while IFS= read -r login; do
[ -n "$login" ] || continue
case "$(bot_verdict "$login")" in
APPROVE | BLOCK | FEEDBACK) continue ;;
esac
printf '%s\n' "$login"
done <<<"${1-}"
}
checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE
# UNREADABLE is the absence of the key itself, which is what a failed fetch
# leaves behind — distinct from a present-but-empty rollup, which honestly
@ -767,7 +736,7 @@ $(configured_label_rows "$LABELS_CONF")"
fi
while IFS='|' read -r name color desc; do
[ -n "$name" ] || continue
run forge_label_create "$name" "$color" "$desc"
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
done <<<"$rows"
# LABELS.md publishes the defaults as deleted at bootstrap; until #93
@ -781,7 +750,7 @@ $(configured_label_rows "$LABELS_CONF")"
# the taxonomy it can create. Either way: log the name, keep going.
while IFS= read -r name; do
[ -n "$name" ] || continue
run forge_label_delete "$name" \
run gh label delete "$name" -R "$REPO" --yes \
|| log "retire: '$name' not deleted (already absent, or refused) — continuing"
done <<<"$(retired_label_names)"
}
@ -810,10 +779,10 @@ tree_version() { # $1 = ref → that tree's version via the API, or nothing
# 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
ver="$(forge_api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \
ver="$(gh api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \
| base64 -d 2>/dev/null | tr -d '[:space:]')"
if [ -z "$ver" ]; then
ver="$(forge_api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \
ver="$(gh api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \
| base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null)"
fi
[ -z "$ver" ] || printf '%s\n' "$ver"
@ -834,7 +803,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
# 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
run forge_request_reviewer "$n" "$HUMAN"
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent
log "#$n: requested $HUMAN (round passed)"
fi
@ -896,7 +865,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then
args=(--add-label "$desired${add:+,$add}")
[ -n "$remove" ] && args+=(--remove-label "$remove")
if run forge_issue_edit "$n" "${args[@]}" >/dev/null; then
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then
log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}"
else
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
@ -919,24 +888,18 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
# the moment the PR is no longer the thing a human should merge next, the
# claim is removed. Setting it stays with whoever owns the queue.
if has_label merge-next && [ "$desired" != state:needs-human ]; then
run forge_issue_edit "$n" --remove-label merge-next >/dev/null
run gh issue edit "$n" -R "$REPO" --remove-label merge-next >/dev/null
log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)"
fi
# ---- stale: real activity only, and blocked is legitimately quiet ----
# 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.
last_activity="$(
{
jq -r '.created_at' <<<"$PR_JSON"
jq -r '.[].submitted_at // empty' <<<"$REVIEWS_JSON"
# 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
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
} | sort | tail -n1
)"
last_activity_epoch="$(date -d "$last_activity" +%s)"
@ -945,11 +908,11 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
# (#50 D10). The 7-day nudge is #52's, once for both surfaces.
if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then
if has_label stale; then
run forge_issue_edit "$n" --remove-label stale >/dev/null
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
log "#$n: unstale"
fi
elif ! has_label stale; then
run forge_issue_edit "$n" --add-label stale >/dev/null
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null
log "#$n: stale ($((age / 3600))h quiet)"
fi
@ -970,21 +933,6 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
}
main() {
# 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).
# 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
REPO="${REPO:?set REPO to owner/name}"
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
load_config "$LABELS_CONF"
@ -997,7 +945,7 @@ main() {
# 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.
REPO_LABELS="$(forge_label_list 2>/dev/null || echo "")"
REPO_LABELS="$(gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' 2>/dev/null || echo "")"
[ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered"
missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS"
@ -1008,20 +956,17 @@ main() {
status=0
output="$(
(
PR_JSON="$(forge_api "repos/$REPO/pulls/$n")"
PR_JSON="$(gh api "repos/$REPO/pulls/$n")"
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")"
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
REVIEWS_JSON="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
| jq -s '[.[] | select(.state != "PENDING")]')"
# Read AFTER the reviews, because the raw field is not portable: Forgejo
# never clears it, so it is intersected with who still owes a verdict on
# this head (#188 term 4). A no-op on GitHub, which clears it itself.
REQUESTED="$(outstanding_requests "$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")")"
# mergeability + the check rollup, the two facts the state machine was
# blind to (#136). `gh pr view` rather than the REST PR object: the API's
# `mergeable` is a tri-state boolean that GitHub computes lazily, while
@ -1036,7 +981,7 @@ main() {
# 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)"
GH_VIEW="$(forge_pr_view "$n" 2>"$GH_VIEW_ERR_FILE" || echo '{}')"
GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>"$GH_VIEW_ERR_FILE" || echo '{}')"
GH_VIEW_ERR="$(cat "$GH_VIEW_ERR_FILE")"
rm -f "$GH_VIEW_ERR_FILE"
MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")"
@ -1063,7 +1008,7 @@ main() {
HEAD_COMMIT_AT=""
if [ "$DRAFT" != true ]; then
HEAD_COMMIT_ERR_FILE="$(mktemp)"
HEAD_COMMIT_AT="$(forge_api "repos/$REPO/commits/$HEAD_SHA" \
HEAD_COMMIT_AT="$(gh api "repos/$REPO/commits/$HEAD_SHA" \
--jq '.commit.committer.date' 2>"$HEAD_COMMIT_ERR_FILE" || echo "")"
HEAD_COMMIT_ERR="$(cat "$HEAD_COMMIT_ERR_FILE")"
rm -f "$HEAD_COMMIT_ERR_FILE"
@ -1089,7 +1034,7 @@ main() {
elif [ "$status" -ne 0 ]; then
log "#$n: reconcile failed — continuing with the remaining PRs"
fi
done < <(forge_pr_list)
done < <(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number')
blind_sweep_warning "$unreadable" "$total" "$sampled_reason"
log "reconciled."
}

View file

@ -6,9 +6,6 @@ else
set -u
fi
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
# labels-scope.sh — the additive half of the labels automation: derive
# scope:* labels from a PR's changed paths and ADD them, touching nothing
# else. This seat belonged to actions/labeler@v5 until #130: even under
@ -71,14 +68,6 @@ glob_to_regex() { # $1 = glob (the subset above) → anchored ERE, one line
}
parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines
# The jq variable is $lbl, not $label: **`label` is a reserved keyword in
# jq's grammar** (`label $out | ... | break $out`), and jq 1.6 refuses
# `$label` outright — "syntax error, unexpected label, expecting IDENT".
# jq 1.7 parses it, which is why this survived: GitHub's hosted
# ubuntu-latest ships 1.7, and the Forgejo runner image
# (ghcr.io/catthehacker/ubuntu:act-22.04) ships **1.6**. Measured on both,
# 2026-08-02 (#188). Every scope-label derivation on this forge failed on a
# jq compile error before the config was even read.
# yq only normalizes YAML to JSON; the shape contract is enforced in jq,
# where an unsupported key is a loud error naming the label it sits under.
yq -o=json '.' - | jq -r '
@ -86,38 +75,38 @@ parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines
error("labeler config: top level must be a map of label -> rules")
else . end
| to_entries[]
| .key as $lbl
| .key as $label
| (if (.value | type) != "array" then
error("labeler config: \($lbl): rules must be a list")
error("labeler config: \($label): rules must be a list")
else .value end)[]
| (if type != "object" then
error("labeler config: \($lbl): each rule must be a map")
error("labeler config: \($label): each rule must be a map")
else . end)
| ((keys - ["changed-files"]) as $extra
| if ($extra | length) > 0 then
error("labeler config: \($lbl): unsupported key(s) \($extra | join(", ")) — the scope job accepts changed-files/any-glob-to-any-file only (#130)")
error("labeler config: \($label): unsupported key(s) \($extra | join(", ")) — the scope job accepts changed-files/any-glob-to-any-file only (#130)")
else . end)
| .["changed-files"]
| (if type == "object" then [.]
elif type == "array" then .
else error("labeler config: \($lbl): changed-files must be a list") end)[]
else error("labeler config: \($label): changed-files must be a list") end)[]
| (if type != "object" then
error("labeler config: \($lbl): each changed-files entry must be a map")
error("labeler config: \($label): each changed-files entry must be a map")
else . end)
| ((keys - ["any-glob-to-any-file"]) as $extra
| if ($extra | length) > 0 then
error("labeler config: \($lbl): unsupported matcher(s) \($extra | join(", ")) — the scope job accepts any-glob-to-any-file only (#130)")
error("labeler config: \($label): unsupported matcher(s) \($extra | join(", ")) — the scope job accepts any-glob-to-any-file only (#130)")
else . end)
| .["any-glob-to-any-file"]
| (if type == "string" then [.]
elif type == "array" then .
else error("labeler config: \($lbl): any-glob-to-any-file must be a glob or a list of globs") end)[]
else error("labeler config: \($label): any-glob-to-any-file must be a glob or a list of globs") end)[]
| (if type != "string" then
error("labeler config: \($lbl): globs must be strings")
error("labeler config: \($label): globs must be strings")
elif contains("\\") then
error("labeler config: \($lbl): backslash in glob \(.) — escapes are not supported (#130)")
error("labeler config: \($label): backslash in glob \(.) — escapes are not supported (#130)")
else . end)
| [$lbl, .] | @tsv
| [$label, .] | @tsv
'
}
@ -136,21 +125,6 @@ derive_labels() { # $1 = "label<TAB>glob" lines, $2 = changed files (one per
}
main() {
# See labels-reconcile's twin (#188). This action's degraded read was the
# quietest of the three: an unreadable mapping and an absent one produced
# the same "nothing to derive" no-op, so on Forgejo a PR simply got no
# scope labels and nothing said why.
# 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
REPO="${REPO:?set REPO to owner/name}"
PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}"
CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}"
@ -160,13 +134,13 @@ main() {
# No mapping is a consumer that has not adopted scope labels — an
# advisory no-op, not a red run (scopes locate, they do not alert). A
# mapping that EXISTS but does not parse still fails loudly below.
if ! config="$(forge_api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \
if ! config="$(gh api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \
--jq '.content' 2>/dev/null | base64 -d)" || [ -z "$config" ]; then
log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive"
return 0
fi
tsv="$(parse_labeler_config <<<"$config")"
files="$(forge_api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"
files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"
labels="$(derive_labels "$tsv" "$files")"
if [ -z "$labels" ]; then
@ -174,8 +148,8 @@ main() {
return 0
fi
local args=()
while IFS= read -r label; do args+=("$label"); done <<<"$labels"
run forge_labels_add "$PR_NUMBER" "${args[@]}"
while IFS= read -r label; do args+=(-f "labels[]=$label"); done <<<"$labels"
run gh api "repos/$REPO/issues/$PR_NUMBER/labels" "${args[@]}" --silent
log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)"
}

View file

@ -5,25 +5,6 @@ set -euo pipefail
# gather here lets the offline contract test replace `gh` and prove that
# failed and partial reads cannot accidentally produce a green verdict.
# THIS ACTION IS STILL gh-ONLY, AND SAYS SO (#198 spec 4, #199 ports it).
# Its entire gather is a single GraphQL query issued through `gh`, and
# Forgejo serves no
# GraphQL surface at all — `/api/graphql` 404s on this instance, and a real
# forgejo-runner job arrives with GITHUB_GRAPHQL_URL set to the empty string
# (lib/forge.sh's header). There is no endpoint to translate this to, so
# unlike every other call site the merge touched it cannot be ported here;
# it has to be re-expressed over REST, which is #199.
#
# Until then the declaration is the honest move: CEREMONY_FORGE_CLIENT names
# the client this file actually speaks, and forge_preflight refuses loudly on
# a forge that cannot serve it — rather than reading nothing and reporting a
# verdict. That is lib/forge.sh's own rule, "Never 'probably github'",
# applied to the one action that has not caught up yet.
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
export CEREMONY_FORGE_CLIENT=gh
forge_preflight || exit 1
owner="${GITHUB_REPOSITORY%%/*}"
name="${GITHUB_REPOSITORY#*/}"
[ -n "${PR_NUMBER:-}" ] || {

View file

@ -1,16 +0,0 @@
### Fixed
- `.github/labels.conf` names identities that exist on the forge this repo
lives on. All five it named before were absent, so `panel=` could never
converge a review round and `triage-actors=` made every issue a stray mint
nobody could normalize (#195).
- `CONTRIBUTING.md`'s roster table matches the conf: three identities, the
human row is `andres`, and the approval count states what panel-minus-author
actually resolves to on this roster rather than a stale three (#195).
### Added
- `test/labels.test.sh` holds the conf's roster and `CONTRIBUTING.md`'s roster
table to the same set, in both directions, so a roster edit that touches one
file and not the other goes red instead of drifting quietly (#195).

View file

@ -1,37 +0,0 @@
### Added
- This tree carries upstream ceremony through `8c3a4d1` (upstream `0.6.0`):
`lib/attention.sh`, `lib/read.sh`, `actions/refs-not-closing`, the guarded
reads, and the ruling and window rules (#198).
- `test/no-runtime-gh.test.sh` — the forge-portability guard: no runtime `gh`
outside `lib/forge-github.sh` unless the file declares
`CEREMONY_FORGE_CLIENT=gh` (#198).
- `CHANGELOG.md` names the upstream commit this tree carries, so a drill
record can say which `0.6.0` it exercised (#197, #198).
### Fixed
- Eight runtime `gh` call sites arrived with the merge outside every conflict
hunk, in functions upstream added to files this tree already owned. Seven
are ported onto the shim; the eighth is named with its reason (#198).
- The open-PR gather reads `Refs`, not only closing keywords. Reading one side
for closing links and the other for `Refs` is what released a live claim in
crew#321, and this tree carried that shape (#198).
- The merged record gains `merged_at`, so `post_merge_pr_for_issue` answers
the PR that merged last rather than the highest-numbered one. Without the
column every sort key ties and the old order returns silently (#198).
- The open gather feeds `open_pr_issues` one record per physical body line. A
whole decoded body as one record loses every declaration including the
first, and reclaims a claim a live PR was holding (#198).
- The post-merge nudge links the issue on the forge in play rather than a
hard-coded `github.com` (#198).
- `.github/scripts/release-path.sh` names `lib/forge.sh`: #191 put the shim on
the release doors' executable path here, so a doors-unchanged record that
omitted it was measuring the wrong set (#198).

4
changelog.d/218.md Normal file
View file

@ -0,0 +1,4 @@
### Added
- Pull requests that promise `Refs #N` now fail a read-only, body-edit-aware
guard if GitHub would close N through a keyword or sidebar link (#218).

5
changelog.d/230.md Normal file
View file

@ -0,0 +1,5 @@
### Fixed
- Triage now puts `attention` on the assigned issue that owns a claim, never
on its pull request, and treats an unassigned issue as a board bug rather
than a demand (#230).

6
changelog.d/231.md Normal file
View file

@ -0,0 +1,6 @@
### Fixed
- LABELS.md no longer claims nothing in `actions/` clears or reads
`attention`: the reconciler has done both since the derived `claimed`
`post-merge` transition shipped. The amended text keeps the hand-set rule
and admits the one clear and the diagnostic read (#231).

5
changelog.d/232.md Normal file
View file

@ -0,0 +1,5 @@
### Added
- The label and issue-flow sweeps now comment once per episode when
`attention` targets a pull request or an unassigned issue, without
retargeting the demand or changing labels or assignees (#232).

10
changelog.d/236.md Normal file
View file

@ -0,0 +1,10 @@
### Fixed
- `blocker:unrequested` no longer fires while a head's checks are pending or
red: the review round forbids requesting there, so the one blocker that
demanded an act flagged builders for complying. Pending is CI's move, red is
`blocker:ci-red`'s (#236).
- `blocker:unrequested` now waits for the round to settle — the head and the
newest verdict must have stood for `RECONCILE_UNREQUESTED_GRACE` (default
300s) — so a sweep landing between a push and its re-request no longer flags
a round in motion (#236).

5
changelog.d/237.md Normal file
View file

@ -0,0 +1,5 @@
### Changed
- Define the doors-unchanged drill record and an executable release-path list,
so a release may reuse live evidence only when its door bytes are unchanged
since the last rehearsed tag (#237).

4
changelog.d/238.md Normal file
View file

@ -0,0 +1,4 @@
### Added
- Guard documentation availability markers against missing issue citations
and release candidates that already ship the cited work (#238).

3
changelog.d/241.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- Preserve active claims when an open local pull request links them with `Refs #N`. (#241).

5
changelog.d/242.md Normal file
View file

@ -0,0 +1,5 @@
### Fixed
- The issue-flow sweep now reads an issue's deliverable as the `Refs` PR that
merged last, not the one numbered highest — merge order is not number order,
and the old rule spent the transition marker on the wrong PR (#242).

20
changelog.d/247.md Normal file
View file

@ -0,0 +1,20 @@
### Fixed
- The issue sweep no longer derives label writes from a read that failed. An
HTTP 504 whose body is GitHub's JSON error object passed every guard and
emptied the label set, so a healthy epic was written `needs-triage` and the
pass reported success (#247).
- A failed comments read no longer reclaims a live claim. Swallowed, it dated
the issue by `created_at` and unassigned the builder under a comment
asserting 48 hours of silence about an issue commented on seconds earlier
(#247).
- A failed comments read no longer reads as "no marker", which re-posted the
comment the marker exists to suppress (#247).
- Every read inside the per-issue subshell is checked explicitly, on its
status and on its payload shape; the issue is left exactly as it is and the
sweep continues. A partial pass names its skipped issues after
`reconciled.` (#247).
- A per-issue pass is now atomic: its writes and its log lines commit only
once the pass completes. A skip could previously land after an earlier
mutation, reporting an issue as untouched when a label had already been
written or removed (#247).

3
changelog.d/248.md Normal file
View file

@ -0,0 +1,3 @@
### Added
- Document the optional, operator-ruled release-epic flow for governed repositories. (#248).

14
changelog.d/251.md Normal file
View file

@ -0,0 +1,14 @@
### Added
- CI now refuses a root `*.md` declared in neither `docs/VENDORED.txt` nor the
guard's short exemption list, so a new doctrine file can no longer reach a
tag undeclared and stay invisible to every consumer's `docs-sync` (#251).
- The same guard reads the manifest the other way: every entry must resolve to
a regular, non-empty, tracked file — no symlink, no directory, no `../`
escape (#251).
### Changed
- Consumer guidance: re-vendor tooling reads the pin's `docs/VENDORED.txt`,
never a hardcoded list, so a new doctrine file propagates at the next
ordinary pin bump with zero list edits (#251).

9
changelog.d/252.md Normal file
View file

@ -0,0 +1,9 @@
### Added
- The issue sweep now echoes an issue's parsed `Blocked by` set as a comment
whenever that set changes, so a readable-but-wrong declaration is visible in
one sweep instead of days later, when a human happens to run the parser by
hand (#252).
- The echo's marker carries the parsed set itself: an unchanged parse never
re-posts on a 15-minute cron, and a changed one always speaks. Comment-only
— no path here writes a label (#252).

3
changelog.d/253.md Normal file
View file

@ -0,0 +1,3 @@
### Added
- Release epics now announce release initialization when their declared dependency gates clear (#253).

16
changelog.d/254.md Normal file
View file

@ -0,0 +1,16 @@
### Added
- A `post-merge` item with no comment for 7 days now draws one nudge from the
issue sweep: the wake evidence is owed. A starving criterion used to be
found only when someone happened to run the right read (#254).
- Label churn does not reset that clock, and neither does an assignment: on
`post-merge` an assignee is an invalid composition, not activity, and it
must not buy the item another 7 days of silence (#254).
- The nudge names the triage actor from `triage-actors=`, not the human
reviewer: `post-merge` is triage's completion queue, so the starved wake
condition is triage's to answer (#254).
- It links the item and parses nothing from the body — which criterion
starved is prose, and the machine never judges prose (#254).
- Like the ruling nudge it carries no idempotency marker on purpose: the
comment is itself activity, so the rule self-rate-limits to one nudge per 7
quiet days. Comment-only — no path here writes a label (#254).

3
changelog.d/257.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- Abort issue-flow reconciliation when the board read fails instead of reporting a complete pass over an empty or partial result (#257).

7
changelog.d/258.md Normal file
View file

@ -0,0 +1,7 @@
### Added
- BUILDER.md now describes a fix round that rides a draft: the draft phase
stays the builder's, ready-for-review is the builder's own act, and where a
draft suppressed the checks green is proven at the flip (#258).
- REVIEWER.md now reads a draft carrying `state:addressing` as a fix round in
progress rather than abandonment (#258).

6
changelog.d/260.md Normal file
View file

@ -0,0 +1,6 @@
### Changed
- BUILDER.md's green ruled term now names its field: greenness is read from
each check's `conclusion`, never its `status`, and *stale* means a check
of a superseded head — not a same-head node whose `status` lags its own
conclusion (#260).

20
changelog.d/262.md Normal file
View file

@ -0,0 +1,20 @@
### Added
- The fragment guard now requires each entry to end with its issue
citation: one `(#N)` group — local, `repo#N` or `owner/repo#N`
references separated by `, ` — then the final `.` and nothing after it
(#262).
- The refusal distinguishes an entry carrying no reference at all from one
whose reference is present but not terminal, and names the shape to
write in both (#262).
- The 300-character bound still outranks the citation across the whole
fragment, and the outranked problem stays out of the message it lost
to: one fragment, one diagnosis, wherever in the file it sits (#262).
### Changed
- `BUILDER.md` and `CHANGELOG.md` state the citation as guard-enforced
rather than as house style, beside the 300-character bound it now sits
next to (#262).
- Four fragments in flight gained a terminal citation; published sections
are untouched, so no shipped prose is re-opened (#262).

4
changelog.d/264.md Normal file
View file

@ -0,0 +1,4 @@
### Changed
- TRIAGE.md now scopes the no-assignee board bug to flagging an unassigned
issue, while still directing triage to repair ownership instead (#264).

5
changelog.d/266.md Normal file
View file

@ -0,0 +1,5 @@
### Changed
- TRIAGE.md now tells every epic author to put its progress checklist under
the literal `## Task list` heading, because any other heading is silently
invisible to the completion sweep (#266).

19
changelog.d/267.md Normal file
View file

@ -0,0 +1,19 @@
### Fixed
- `scope:release-flow` no longer rides every pull request: `changelog.d/**`
is out of its path map. Doctrine makes every behavior change write a
fragment, so the glob labelled 20 of the last 20 PRs while 3 touched a
release surface. `CHANGELOG.md` stays, as only the release PR edits it
(#267).
- The issue-flow reconciler and its test now derive `scope:labels`, the scope
that already names the taxonomy they reconcile (#267).
### Changed
- `README.md` and `RELEASES.md` derive `scope:docs`, and the
`changelog-assembled`, `docs-sync` and `runner-isolated` actions and tests
derive `scope:guards`; all five were mapped nowhere. The docs block matched
a literal `README`, which this tree does not carry (#267).
- `lib/read.sh` and `lib/ruling.sh` derive `scope:labels` beside
`scope:release-flow`. Both reconcilers share them, and a mixed file wears
both labels rather than `lib/**` being re-carved into a row per file (#267).

6
changelog.d/272.md Normal file
View file

@ -0,0 +1,6 @@
### Changed
- BUILDER.md's step 1 now rules the checkless head: no checks configured is
nothing to wait for, and the request goes out straight away — stated once,
in the ruled-term paragraph, with the draft-round restatement removed
(#272).

10
changelog.d/276.md Normal file
View file

@ -0,0 +1,10 @@
### Changed
- BUILDER.md's green ruled term now says which entry to read before it says
what an entry means: a check's word at a head is its newest entry by start
time, and a cancelled entry is not that word while the same check carries a
non-cancelled one at that head (#276).
- A check whose every entry at the head is cancelled is unchanged — nothing
survived to be its word, so it never reported and is not green — and the
collapse mirrors `checks_state`'s carve-out rather than adding a class
(#276).

5
changelog.d/280.md Normal file
View file

@ -0,0 +1,5 @@
### Changed
- CONTRIBUTING.md now keeps vendored doctrine self-contained: state the rule,
retain at most one sentence of why, cite the local record bare, and leave the
incident narrative in that record (#280).

5
changelog.d/281.md Normal file
View file

@ -0,0 +1,5 @@
### Changed
- `BUILDER.md` states its rules and cites their record bare: the incident
narratives, the links into issue comments and the cross-repo issue cites
leave the normative text, which no rule leaves with them (#281).

5
changelog.d/282.md Normal file
View file

@ -0,0 +1,5 @@
### Changed
- TRIAGE.md now states its rules with bare record cites: the label-race and
lifted-hold incident narratives leave the normative text while their
operational rules remain complete (#282).

6
changelog.d/284.md Normal file
View file

@ -0,0 +1,6 @@
### Fixed
- Claiming a `needs-ruling` issue no longer buys its escalation another 7
quiet days: the issue-side ruling clock reads comments alone — an
assignment is the claim clock's fact — and LABELS.md now names what each
surface's clock reads (#284).

5
changelog.d/288.md Normal file
View file

@ -0,0 +1,5 @@
### Changed
- TRIAGE.md now requires unconditional collision-edge chains when open issues
carry the same deliverable, keeping the ready queue concurrently claimable
(#288).

3
changelog.d/292.md Normal file
View file

@ -0,0 +1,3 @@
### Changed
- Standing release windows are dependency DAGs: every mint is placed in the window or behind it, and only current sources are `ready` (#292).

13
changelog.d/293.md Normal file
View file

@ -0,0 +1,13 @@
### Added
- The issue-flow sweep now flags a collision the board never declared: two
open, unblocked issues whose titles name one deliverable draw a comment
naming the newer's owed `Blocked by` edge. Keys normalize, so
`actions/x` and `x` are one deliverable (#288).
- The sweep now flags an unblocked non-member during a standing release
window, naming the window's invariant. `claimed` counts, PR in flight or
not. The gate is read from the release issue's own `Blocked by`
declarations, and an emptied gate leaves it dormant (#292).
- Both flags are advisory: comments only, no label write and no state
change, deduped against each family's last word on the thread so a
standing state re-sweeps silently (#293).

6
changelog.d/302.md Normal file
View file

@ -0,0 +1,6 @@
### Fixed
- `lib/attention.sh` locates as label machinery beside its two shelf-mates —
`[scope:release-flow]` alone was a wrong answer of the class #267 measured
— and the map learns the sweep workflow pair, the shared-lib tests, and
seven enumerated test/guard surfaces (#302).

11
changelog.d/304.md Normal file
View file

@ -0,0 +1,11 @@
### Fixed
- A roster edit no longer reds the whole suite: the labels-reconcile
state-machine fixtures name their own panel instead of binding
`.github/labels.conf` by slot (#304).
- Shrinking `panel=` to three had left that binding's third slot unbound, and
`set -u` aborted the file before its first assertion — 217 assertions
became 0, on `main` and on every branch cut from it (#304).
- The one case still reading the shipped roster asserts a property, not a
size: it parses, and each member is recused from its own panel. Any
`panel=` of one or more members leaves `test/run.sh` green (#304).

6
changelog.d/307.md Normal file
View file

@ -0,0 +1,6 @@
### Added
- The issue-flow sweep's `claimed`-branch ruling pre-read is pinned: an
unassigned claim under `needs-ruling` must draw its board diagnostic and
its ruling nudge in one sweep, so a read that drifts below the diagnostic
reds instead of silently costing the escalation 7 days (#284, #307).

6
changelog.d/311.md Normal file
View file

@ -0,0 +1,6 @@
### Changed
- `README.md` is rewritten whole from the current tree: the front page names
the governance repo ceremony now is, routes to `docs/CONSUMERS.md`,
`AGENTS.md`, `LABELS.md` and `RELEASES.md` rather than restating them, and
keeps the operator's release runbook as its core, re-measured (#311).

View file

@ -155,9 +155,9 @@ the machinery at all:
- uses: heavy-duty/ceremony/actions/refs-not-closing@<pinned-tag>
```
`refs-not-closing` is available at `0.6.0` and later (#218). Adopt this
caller with that ordinary pin bump; never point only this file at a
moving or newer ref.
`refs-not-closing` is **unreleased** (#218) until the first tag carrying it.
Adopt this caller with that ordinary pin bump; never point only this file
at a moving or newer ref.
7. **Labels automation** (optional but recommended): the two callers from
[Labels automation](#labels-automation) — the event-facing labels
caller and the sweep caller (#209) — plus `.github/labels.conf`
@ -291,9 +291,8 @@ tag door instead (the known first-release edge, cast#111).
### The artifact hook
If the repository contains `.github/actions/release-artifact/action.yml`,
both doors invoke it — after the tag exists, before the release is
published — with the release `version` as input and `RELEASE_ASSETS_DIR`
exported.
both doors invoke it — after the tag exists, before `gh release create`
with the release `version` as input and `RELEASE_ASSETS_DIR` exported.
Contract for hook authors:
- Drop finished files into `$RELEASE_ASSETS_DIR`; every file there is
@ -304,28 +303,9 @@ Contract for hook authors:
A failed hook leaves the tag created but no release published. Recovery is
the tag door's semantics: fix the cause, then delete and re-push the same
tag — the tag door publishes for it. That path is forge-neutral and is the
one to prefer.
If you must publish by hand instead, use whatever your forge provides;
ceremony itself no longer names a client here, because on a Forgejo runner
there is no `gh` to name (#191):
```sh
# GitHub
gh release create "$VER" --verify-tag --title "$VER" \
--notes-file notes.md -R "$OWNER/$REPO"
# Forgejo / Gitea — POST /repos/{owner}/{repo}/releases
curl -sS -X POST -H "Authorization: token $TOKEN" \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg t "$VER" --rawfile b notes.md \
'{tag_name:$t,name:$t,body:$b}')" \
"$FORGE/api/v1/repos/$OWNER/$REPO/releases"
```
The merge door's nothing-exists assert will refuse a re-run of the
completed merge, by design.
tag (the tag door publishes for it), or run `gh release create` by hand from
a fixed tree. The merge door's nothing-exists assert will refuse a re-run of
the completed merge, by design.
No hook → no assets: for a pure-bash tree, GitHub's source tarball for the
tag IS the package. Worked examples land with the conversions: cast's tgz
@ -577,13 +557,6 @@ before it and never through mixed refs.
Both actor lists are whitespace-separated. `triage-actors` names the identities
allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly
`name|color|description`; blank lines are ignored and extra pipes are refused.
**Every account in `panel=` must be able to read the repository.** Requesting a
review from someone without read access is refused by the forge, not silently
dropped — on Forgejo with `422 Reviewer can't read`, naming the account
(#188). On a public repo this is satisfied already; on a **private** consumer
it is a real failure mode when a panel member is not on the collaborator
list, and the sweep will report it rather than sweep blind.
There are no comment lines: every non-blank line must be the `panel=`
setting, a `panel[<login>]=` row, the `triage-actors=` setting, or a label
row, so `#`-prefixed prose is a parse failure, not a comment (rig #13's
@ -620,10 +593,10 @@ marking the directory machine-managed. `actions/docs-sync` owns the copy:
mirror), `--check` re-diffs it in CI on every PR, so a hand edit or a stale
pin goes red instead of quietly governing.
`RELEASES.md` joins that mirror with the first tag carrying ceremony#248,
and is available at `0.6.0` and later: consumers add `.ceremony/RELEASES.md`
only with the ordinary pin bump and re-sync, never by copying it ahead of
their pinned doctrine set.
`RELEASES.md` joins that mirror with the first tag carrying ceremony#248.
It is **unreleased** (#248) until that tag exists: consumers add
`.ceremony/RELEASES.md` only with the ordinary pin bump and re-sync, never by
copying it ahead of their pinned doctrine set.
### Read the manifest, never a copy of it
@ -652,9 +625,10 @@ What makes reading the manifest *sufficient* — rather than merely better
than a copy — is that ceremony's CI now refuses a root doctrine file that is
declared in neither the manifest nor a short in-script exemption list
(`.github/scripts/vendored-check.sh`), so the manifest at a tag is the
complete set as of that tag. That guarantee holds at `0.6.0` and later
(#251); the manifest is worth reading at every earlier pin regardless, since
it is what `actions/docs-sync` has always mirrored.
complete set as of that tag. That guarantee is **unreleased** (#251) until
the first tag carrying it exists; the manifest is worth reading at every
earlier pin regardless, since it is what `actions/docs-sync` has always
mirrored.
The consumer's ci.yml gains the guard alongside the others:

View file

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

View file

@ -1,272 +0,0 @@
# 0.6.0 — drill record
Run 2026-08-05 by `cndgrr` against the 0.6.0 release PR (Refs #249),
candidate branch `build/249-release-0-6-0`, canonical candidate SHA
`fb8f8282a9e7b317d4d028f8e8da50501a882d14`. All six probes ran; every row in
the table below was written from its own run.
## Scope ruling — a full rehearsal is owed, and doors-unchanged is refused
This record's shape was measured, not chosen. `drills/README.md` allows the
doors-unchanged shape only when all three of its conditions hold at the
candidate head; the first one does not.
The baseline is the last **rehearsed** tag, never the previous tag:
`drills/0.4.1.md` and `drills/0.5.0.md` are both doors-unchanged records, so
the anchor is **`0.4.0`**, whose record is a full disposable-repo rehearsal,
whose release is published, and after which `main` was re-armed to
`0.4.1-dev` (`84bb1a4`). Condition 3 holds.
The release path is exactly the output of `.github/scripts/release-path.sh`
at this head — `.github/workflows/release.yml`, `bin/`, `lib/version.sh`,
`lib/decide.sh`, `lib/facts.sh`, `lib/changelog.sh`. Condition 2 holds.
Condition 1 fails. Measured at this candidate:
```console
$ git diff 0.4.0..HEAD -- $(.github/scripts/release-path.sh)
.github/workflows/release.yml | 2 +-
lib/changelog.sh | 83 ++++++++++++++++++++++++++++++++++++++---
```
`release.yml`'s two lines are the `CEREMONY_SELF_REF` pin, which the
condition exempts. **`lib/changelog.sh` is not exempt and is not empty**: it
carries `72fa3e0` (the terminal issue-citation rule joining the fragment
guard, #262) and `75a5b68` (one fragment, one diagnosis, #262). That file is
on the release path because the merge door sources it to assemble and read
the release section — this is a door byte, not a neighbouring library, and
the last-rehearsed anchor exists precisely so an accumulated change like
this forces a new rehearsal rather than chaining a third doors-unchanged
assertion off the second.
So this release owes the disposable-repo rehearsal, and this record is it.
## Where
Disposable **private** repo `cndgrr/ceremony-drill-0.6.0`, created
2026-08-05T00:02:58Z. It carries the `docs/CONSUMERS.md` release caller
verbatim (`version-source: file`) over a fragment-mode fixture armed at
`0.6.0-dev`: a preamble-only `CHANGELOG.md`, `changelog.d/README.md` plus
one fragment, and a non-blank `drills/0.6.0.md`. The `release` label was
created there before the first ceremony PR, per the guide's prerequisite.
**Disposal, as this record's author observed it**: the repository is
**archived** — `PATCH /repos/cndgrr/ceremony-drill-0.6.0` with
`archived: true` returned `true`, and a fresh read afterwards reported
`archived=true private=true`. It is **pending the operator's delete**, which
this builder cannot perform: `delete_repo` is absent from fleet tokens by
doctrine (#135). No delete was attempted and none is claimed. Cleanup gates
nothing — not this PR's ready-for-review, not the panel, not the merge.
## Candidate-ref deviation
The pure consumer path cannot resolve this candidate's
`CEREMONY_SELF_REF: "0.6.0"`: that tag is the one this release has not
created yet. No `0.6.0` branch was created on `heavy-duty/ceremony`.
The scratch caller instead pins `cndgrr/ceremony/.github/workflows/release.yml@drill/0.6.0`.
That fork ref's parent is the canonical candidate SHA
`fb8f8282a9e7b317d4d028f8e8da50501a882d14`, and its one additional commit
(`775b4d1f6485ebdde924979ac2dce536643c6071`) rewrites all three
`CEREMONY_SELF_REF` carriers — `release.yml`, `labels.yml`,
`labels-sweep.yml` — to that same SHA. All runtime machinery in every probe
below was therefore fetched from the 0.6.0 candidate tree.
Commits pushed to the candidate after `fb8f828` are this record only; the
release path (`.github/scripts/release-path.sh`) is byte-identical at the
canonical SHA and at the final head.
## Probes
One row per probe, written from its run. Runs are in
`cndgrr/ceremony-drill-0.6.0`.
| # | probe | run | result |
|---|---|---|---|
| 1 | merge-door ceremony | 30992108742 (attempt 1) | ✅ exactly one `0.6.0` release; tag equals the merge commit; main re-armed to `0.6.1-dev` |
| 2 | mislabeled ordinary PR | 30991634654 | ✅ green NOTICE no-op; no tag, no release |
| 3 | bare-version PR without `release` | 30991832001 | ✅ refused at decide; no tag, no release |
| 4 | re-run completed ceremony | 30992108742 (attempt 2) | ✅ refused at the nothing-exists assert; the release count stayed one |
| 5 | manual matching tag | 30992258952 | ✅ `0.6.1` published from its own changelog section; main untouched |
| 6 | mismatched tag | 30992310031 | ✅ refused before publication; no `9.9.9` release, and the probe tag was removed afterwards |
### Probe 5 — a manual tag matching its tree
Branch `probe5-tag` carried `VERSION` at `0.6.1` and a
`## 0.6.1 — 2026-08-05` section; tag `0.6.1` was pushed at that commit
(`dfd0cfeaca772cf45bcb63a1a639829185510c60`) with a personal token, so it
fired the door — the anti-recursion property probe 1 relies on is exactly
what makes a hand-pushed tag the only way to reach this door. The
`release-on-merge` job skipped and `release-on-tag` ran: the version assert
passed, notes were extracted, the release published.
The branch, not main, carried the tagged tree on purpose — the tag door
takes no bump step, and pointing it at a side branch proves that without a
bare version ever sitting on main. Observed afterwards: `0.6.1` published
with exactly its own section's bullet, and main still reading `0.6.1-dev`,
untouched by the publish. Two releases now exist, `0.6.0` and `0.6.1`,
neither a draft, neither carrying assets.
### Probe 6 — a mismatched tag
Tag `9.9.9` was pushed at the same `0.6.1` commit. The door refused at its
first assert, before notes and before publication:
```text
tag '9.9.9' does not match the tree's version '0.6.1' — creating nothing.
```
Notes, the artifact hook and publish all skipped. `GET /releases` still
returned exactly `0.6.1` and `0.6.0`. The `9.9.9` ref was deleted afterwards
(`DELETE /git/refs/tags/9.9.9`); `GET /git/refs/tags` then listed `0.6.0`
and `0.6.1` only. The probe tag was the operator's artefact, never the
workflow's — the door created nothing, which is the whole assertion.
### Probe 1 — the merge-door ceremony
PR #4 (`probe1-ceremony`) bumped `0.6.0-dev` to bare `0.6.0` and stamped
`## 0.6.0 — 2026-08-05`, assembled from the three fixture fragments by the
candidate's own `bin/changelog-assemble` and committed with the deletions
in one commit. The `release` label was applied and confirmed before the
merge. Facts and verdict:
```text
VER: 0.6.0
BASE_VER: 0.6.0-dev
RELEASED:
LABELED: yes
ceremony=yes
```
Observed after the run:
- **Exactly one** release: `GET /releases` returned `0.6.0` alone, not a
draft, not a pre-release, zero assets (no artifact hook in the fixture —
the hook step skipped).
- `GET /tags` returned `0.6.0` alone, pointing at
`64d02539f4a20286afc08b9997f0f8a7d1dbfccd`, which is PR #4's merge commit
— the tag names the tree that was reviewed.
- The release body was byte-for-byte the assembled section's bullets:
```text
- A second ordinary fragment, written by probe 2 of the 0.6.0 drill (#249).
- An ordinary behavior change, landing under the release label (#249).
- Fragment mode is exercised by the ceremony 0.6.0 drill (#249).
```
- Main re-armed itself: commit `2d0e19a` ("bump main to 0.6.1-dev — a dev
install must not impersonate 0.6.0"), pushed by the job's own token. Main
reads `0.6.1-dev` and `changelog.d/` holds only `README.md`.
- **The anti-recursion property held.** Neither the tag create nor the bump
push started a workflow run — the run list after the ceremony ends at
30992108742. That is what makes the merge door the release's only chance
to publish, and it is the reason probe 4 below is the door's own guard
rather than a second run's.
### Probe 4 — a re-run of the completed ceremony
Re-running 30992108742 as attempt 2 re-decided `ceremony=yes` — the facts
at that merge commit have not changed — and then died at the assert:
```text
tag '0.6.0' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing.
```
Tag, publish and bump all skipped. `GET /releases` still returned exactly
one `0.6.0`. The refusal is loud (the job is red) and creates nothing, which
is the required shape: the assert is what covers a manual tag racing the
merge, not only an operator's stray re-run.
### Probe 3 — a bare-version PR without the `release` label
PR #3 (`probe3-bare`) bumped `VERSION` to bare `0.6.0` and carried no label;
the label list was read as empty before merging. The merge run refused at
decide, row 5 of the table:
```text
VER: 0.6.0
BASE_VER: 0.6.0-dev
RELEASED:
LABELED: no
the version transitioned ('0.6.0-dev' -> '0.6.0') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR, not a bare push — creating nothing.
```
Notes, the assert, tag, hook, publish and bump all skipped; tags and
releases were both still empty afterwards. The merge was then undone and
main re-armed to `0.6.0-dev` before the ceremony probe ran (see Setup).
### Probe 2 — a mislabeled ordinary PR
PR #2 (`probe2b-mislabeled`) added one changelog fragment and touched no
version. The `release` label was applied through
`POST /repos/{owner}/{repo}/issues/2/labels` and confirmed present before
the merge. The merge run decided row 1 of the table and published nothing:
```text
VER: 0.6.0-dev
BASE_VER: 0.6.0-dev
RELEASED:
LABELED:
NOTICE: the version '0.6.0-dev' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish.
ceremony=no
```
`RELEASED` and `LABELED` are empty on purpose — the `-dev` rows never
consult them, which is precisely why the label alone cannot ship anything.
Notes, the nothing-exists assert, tag, artifact hook, publish and bump all
skipped; `GET /tags` and `GET /releases` were both empty afterwards.
An earlier merge (PR #1, run 30991571096) was intended as this probe but
landed **unlabeled**: `gh pr edit --add-label` failed against this repo's
projects-classic GraphQL surface, and the merge went ahead before the
failure was read. That run is a green no-op too, but it is not evidence for
this probe — an unlabeled ordinary merge proves less than a labeled one —
so the probe was re-run as PR #2 with the label applied through the REST
endpoint and verified before merging. Recorded here because the run exists
in the repo's history and a reader will find it.
## Setup, and the runs that are not probes
The armed fixture was committed before the caller, so the first door run had
a real parent version to inspect: run **30962040469** is that green baseline
no-op. The probes then ran in the order 2, 3, 1, 4, 5, 6 — the refusals
first, against an armed tree, so the ceremony itself ran last against a
fixture the refusals had already proven intact.
Three non-probe runs are on the board and are accounted for here rather than
left for a reader to guess at:
- **30991571096** (green) — PR #1, the unlabeled first attempt at probe 2,
described above.
- **30991892212** (green) — restoring `VERSION` to `0.6.0-dev` after probe
3's refusal, so the ceremony probe met an armed tree. Row 2 of the table:
the version changed and still ends `-dev`.
- **30991958967** (red) — **a builder error, not a door finding.** An
uncommitted `VERSION` bump left over from staging the ceremony branch rode
along into a setup commit that was meant to touch only the fragments, and
pushed bare `0.6.0` straight to main. The door refused it exactly as it
refused probe 3, by the same row-5 path, and created nothing: tags and
releases were both still empty when the failure was read. Main was re-armed
to `0.6.0-dev` (green run **30992046247**) before the ceremony probe. It is
written down because a red run on a drill repo that the record does not
explain is indistinguishable from a door that failed.
The fixture's three fragments were also rewritten mid-setup to carry
terminal issue citations. The candidate's own `bin/changelog-assemble`
refused them without one — `fragment 'changelog.d/1.md' has an entry with no
issue citation` — which is #262's rule, one of the two commits on
`lib/changelog.sh` that make this release owe a rehearsal at all. The
fixture had been written before that rule existed. The refusal is the guard
working; the correction is recorded because the fragments the ceremony
consumed are not the fragments the repo was created with.
## What the rehearsal establishes
Both doors ran live against the 0.6.0 candidate's own machinery. The merge
door published exactly one release from a labeled ceremony PR, tagged the
reviewed merge commit, and re-armed main itself; it refused a bare push
without a label, refused a re-run of its own completed ceremony, and stayed
a green no-op under a label carried by ordinary work. The tag door published
from a matching manual tag without touching main, and refused a mismatched
one before creating anything. Every refusal created nothing — no tag, no
release, on any of the four refusal paths.

View file

@ -47,7 +47,7 @@ attention_episode_marker() { # $1 current episode's labeled timestamp
reconcile_attention() { # $1 item, $2 pr|issue, $3 assignees, $4 suppression
local n="$1" surface="$2" assignees="$3" suppression="${4:-}"
local target comment labeled_events labeled_at marker comments body timeline
local target comment labeled_events labeled_at marker comments body
: "${REPO:?reconcile_attention: REPO is required}"
target="$(attention_target_decision "$surface" "$assignees")"
@ -59,19 +59,12 @@ reconcile_attention() { # $1 item, $2 pr|issue, $3 assignees, $4 suppression
return 0
fi
# forge_timeline projects both forges into the GitHub event shape
# (.event / .label.name) — Forgejo's raw timeline carries neither. Capture
# its status BEFORE jq: a pipeline's status is the last command's, so
# `forge_timeline | jq` would collapse an unreadable timeline into an empty
# one, and those are the two states this function exists to tell apart
# (#188, and lib/ruling.sh does the identical dance).
if ! timeline="$(forge_timeline "$n" 2>/dev/null)"; then
if ! labeled_events="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \
--jq '.[] | select(.event == "labeled" and .label.name == "attention")
| .created_at' 2>/dev/null)"; then
log "#$n: attention timeline unreadable — no verdict invented this pass"
return 0
fi
labeled_events="$(jq -r '.[]
| select(.event == "labeled" and .label.name == "attention")
| .created_at' <<<"$timeline")"
if [ -z "$labeled_events" ]; then
log "#$n: attention flag has no visible labeled event — no verdict invented this pass"
return 0
@ -79,7 +72,7 @@ reconcile_attention() { # $1 item, $2 pr|issue, $3 assignees, $4 suppression
labeled_at="$(attention_newest_flag <<<"$labeled_events")"
marker="$(attention_episode_marker "$labeled_at")"
if ! comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" \
if ! comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" \
--jq '.[].body // ""' 2>/dev/null)"; then
log "#$n: attention comments unreadable — no verdict invented this pass"
return 0
@ -99,6 +92,6 @@ This issue carries \`attention\` but has no assignee to receive the demand.
Assign the intended builder or remove the flag. The sweep reports the board
bug without assigning anyone or changing the label." ;;
esac
run forge_issue_comment "$n" "$body" >/dev/null
run gh issue comment "$n" -R "$REPO" --body "$body" >/dev/null
log "#$n: malformed attention ($surface) — commented; no label or assignee changed"
}

View file

@ -1,79 +0,0 @@
#!/usr/bin/env bash
# lib/closes_references.sh — "which issues does this PR body close?", parsed
# here rather than asked of a forge (issue #188, term 3).
#
# Sourced, never executed: no set -e/-u — the sourcing script owns its shell
# options, as lib/version.sh and lib/forge.sh do.
#
# WHY THIS EXISTS. issueflow-reconcile asked GitHub's GraphQL API for
# `closingIssuesReferences` — GitHub's own parse of the closing keywords in
# a PR body. **Forgejo has no GraphQL API at all**, and the runner confirms
# it from the other side: a real forgejo-runner job arrives with
# GITHUB_GRAPHQL_URL set to the empty string (probe task 278, 2026-08-02).
# So that call site could not be translated to a Forgejo endpoint — there is
# nothing to translate it to. It had to be replaced by a parse this repo
# owns, over a field both forges already return:
# `GET /repos/{owner}/{repo}/pulls` carries `number` and `body` on
# /api/v3 and /api/v1 alike (measured on both).
#
# That the replacement is honest is the point. The sibling half of the same
# GraphQL query, MERGED_REF_PR_RECORDS, was ALREADY a body parse — it pulled
# `number` and `body` and ran them through refs_references. GraphQL was
# buying pagination convenience there, nothing semantic. This file makes the
# other half symmetric: one parser this repo controls and can test, for both
# link kinds, on both forges.
#
# THE ACCEPTED DELTA, stated so it is not rediscovered as a bug: GitHub also
# records closing links attached through the pull request's development
# sidebar, which live in no body and which no body parse can see. This
# family declares its links in the body — that is what BUILDER.md's PR
# template asks for — so the delta is zero in practice here. A consumer that
# links through the sidebar would see those issues go unclosed by the sweep;
# they would need to say so in the body instead.
#
# DEPENDENCY: issue_references, from issueflow-reconcile.sh — the LOCAL /
# CROSS classifier that keeps rig#112 from ever being read as local #112
# (#61). Bash resolves function calls at call time, so the order of sourcing
# does not matter; both must simply be defined before closes_references runs.
# refs_references depends on it exactly the same way.
# closes_references — PR body on stdin -> local issue numbers this body
# declares it CLOSES, sorted, unique.
#
# The keyword set is GitHub's documented one, all three verbs in all three
# tenses. Matching is case-insensitive because bodies are written by humans
# and agents both ("Closes", "closes", "CLOSES").
#
# Deliberately NOT matched: "Refs #N". That is the other relation entirely —
# refs_references owns it, and conflating them would make every referenced
# issue look closeable, which is the post-merge transition #151 had to be
# reopened by hand over.
closes_references() {
awk '
{
line = $0
lower = tolower(line)
# Every occurrence contributes, not just the first: a body that says
# "Closes #1. Closes #2." declares two, and binding to the first
# occurrence dropped the later ones — the same defect #184 fixed in
# blocked_reference_records, kept fixed here by construction.
while (match(lower, /(^|[^[:alnum:]_-])(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]:]+/)) {
# BOTH cursors advance together. Advancing only `lower` left the
# next match offset indexing the ORIGINAL line, so the second
# declaration on a line came back as garbage — caught by the
# "two closes on one line" case, which is why it is a case.
rest = substr(line, RSTART + RLENGTH)
line = rest
lower = tolower(rest)
if (rest ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
token = rest
# Stop at the first thing that cannot be part of a reference, so
# "Closes #12, and more prose" yields #12 and not the sentence.
sub(/[^[:alnum:]_.\/#-].*/, "", token)
print token
}
}
}
' | issue_references \
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
}

View file

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

View file

@ -1,674 +0,0 @@
#!/usr/bin/env bash
# lib/forge-forgejo.sh — the Forgejo backend: /api/v1 over curl + jq
# (issue #188, term 1). Sourced by lib/forge.sh when forge_detect says
# forgejo; never sourced directly, and never at the same time as the github
# backend — they define the same verbs on purpose.
#
# curl+jq rather than a CLI because that is what the runner has. The image
# this instance runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04, probe
# task 278) carries curl, jq and node, and has neither `gh` nor `stoke`.
# forgejo_api_base — the /api/v1 root, from the runner's own environment.
# GITHUB_API_URL already IS the /api/v1 root on a Forgejo runner (measured:
# https://forgejo.heavyduty.builders/api/v1). CEREMONY_FORGE_API overrides
# it for tests and for anyone driving this outside Actions.
forgejo_api_base() {
local base="${CEREMONY_FORGE_API:-${GITHUB_API_URL:-}}"
if [ -z "$base" ]; then
echo "forgejo_api_base: no GITHUB_API_URL or CEREMONY_FORGE_API — cannot reach the forge (#188)" >&2
return 1
fi
# Every verb in this backend interpolates $REPO into its path, and every
# one of them reaches the network through here — so this is the one place
# that can make `repos//…` impossible.
#
# THE TRAP, measured on this instance with REPO unset (#191, caught by
# @kimi on !193 before it shipped):
#
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
# 404s; a repo-less path read
# as "the release does not
# exist" — and the
# nothing-exists assert would
# then proceed to CREATE)
# forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged
# PR behind it, read as none)
#
# A workflow `run:` shell carries no `set -u`, so an unset REPO expands
# empty and 404s into a fabricated fact instead of crashing. That is the
# exact failure #191 exists to remove, so it refuses here rather than
# anywhere later.
if [ -z "${REPO:-}" ]; then
echo "forgejo_api_base: REPO is empty — refusing to address 'repos//…', whose 404 would read as a fact (#191)" >&2
return 1
fi
printf '%s\n' "${base%/}"
}
# forgejo_page_url <endpoint> <page> — pure, so the page-size contract is
# testable without a network. Returns the endpoint with this backend's OWN
# paging parameters applied.
#
# THE TRAP THIS EXISTS TO REMOVE, measured 2026-08-02 against
# heavy-duty/rig (137 issues and PRs) and heavy-duty/ceremony on GitHub:
#
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
#
# Each forge silently ignores the other's page-size parameter, answers
# HTTP 200 with valid JSON, and says nothing. Every call site in this repo
# was written GitHub-shaped, so a verbatim port would have swept 30 of
# rig's 137 and printed "reconciled." — acceptance criterion 2 failing
# green, and the same "degraded read that does not report it degraded"
# failure class this whole issue exists to kill.
#
# So NO CALL SITE NAMES A PAGE SIZE. The backend owns it. Fixing the
# boundary once beats fixing nine call sites and trusting the tenth — the
# same argument that chose shape C over B, one level down.
#
# 50 is not a preference: Forgejo caps a page at MAX_RESPONSE_ITEMS (50 on
# this instance) whatever you ask for, so asking for more cannot help and
# pagination is mandatory rather than an optimisation.
forgejo_page_url() {
local endpoint="${1:?forgejo_page_url: endpoint required}" page="${2:?forgejo_page_url: page required}"
# Strip any page-size parameter a caller left behind, in either dialect,
# rather than trusting that none did: this function is the one place that
# decides paging, and a stray per_page= would be exactly the silent
# truncation above.
local clean="$endpoint"
clean="$(printf '%s' "$clean" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
case "$clean" in
*\?) printf '%slimit=50&page=%s\n' "$clean" "$page" ;;
*\?*) printf '%s&limit=50&page=%s\n' "$clean" "$page" ;;
*) printf '%s?limit=50&page=%s\n' "$clean" "$page" ;;
esac
}
# forge_api [--paginate] <endpoint> [--jq <expr>]
#
# --paginate walks page= until a short page, then PROVES the walk was
# complete by comparing what it collected against the server's declared
# x-total-count. @kimi-reviewer-andresmgsl's hardening (#4699): a MISSING
# header is a loud refusal, not a pass. Header exposure is a server setting
# (access-control-expose-headers), and an instance that withholds it would
# make the completeness check compare null to a number — the guard itself
# degrading silently, which is the failure class re-entering through the
# door built to stop it.
forge_api() {
local paginate=false endpoint="" jqexpr="" have_jq=false
while [ $# -gt 0 ]; do
case "$1" in
--paginate) paginate=true ;;
--jq) jqexpr="$2"; have_jq=true; shift ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
local base token
base="$(forgejo_api_base)" || return 1
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
local hdr body
hdr="$(mktemp)"; body="$(mktemp)"
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
trap "rm -f '$hdr' '$body'" RETURN
if [ "$paginate" = false ]; then
if ! curl -sS -D "$hdr" -o "$body" \
-H "Authorization: token $token" -H 'Accept: application/json' \
"$base/$endpoint"; then
echo "forge_api: request failed: $endpoint" >&2
return 1
fi
forgejo_http_ok "$hdr" "$endpoint" || return 1
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <"$body"; else cat "$body"; fi
return 0
fi
# Paginated: accumulate into ONE array and apply --jq once at the end.
# gh --paginate applies --jq per page and concatenates; for the `.[] | …`
# shapes every call site here uses, the two are identical, and merging
# first is what makes the completeness assert possible at all.
local page=1 total="" got=0 n all="[]" pagejson
while :; do
if ! curl -sS -D "$hdr" -o "$body" \
-H "Authorization: token $token" -H 'Accept: application/json' \
"$base/$(forgejo_page_url "$endpoint" "$page")"; then
echo "forge_api: request failed: $endpoint (page $page)" >&2
return 1
fi
forgejo_http_ok "$hdr" "$endpoint" || return 1
# Re-read on EVERY page, not once (#4712). A board that changes size
# under the walk was invisible: page 1 declaring 4 and page 2 declaring
# 9 stopped at 4 believing itself whole. A moving total means the read
# cannot have been atomic, so it is refused rather than reconciled.
local page_total
page_total="$(forgejo_total_count "$hdr")" || return 1
if [ -z "$total" ]; then
total="$page_total"
elif [ "$page_total" != "$total" ]; then
cat >&2 <<EOF
forge_api: the declared total for '$endpoint' changed between pages — $total then $page_total (#188).
The collection moved under the walk, so no page set can be proven whole.
Refusing rather than reconciling a board that is already out of date.
EOF
return 1
fi
pagejson="$(cat "$body")"
# A 200 whose body is not a collection counted as zero items (#4712),
# so an error object or a scalar arriving where a list belongs read as
# a complete EMPTY collection whenever the declared total was 0.
if [ "$(jq -r 'type' <<<"$pagejson" 2>/dev/null)" != array ]; then
cat >&2 <<EOF
forge_api: '$endpoint' did not return a collection (#188).
Expected a JSON array; got: $(head -c 200 <<<"$pagejson")
Refusing: a body this shim cannot count must not be counted as empty.
EOF
return 1
fi
n="$(jq 'length' <<<"$pagejson")"
[ "$n" -gt 0 ] || break
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
got=$((got + n))
[ "$got" -lt "$total" ] || break
page=$((page + 1))
done
# The assert. A short read here is the silent-truncation bug arriving by
# another route, so it is fatal rather than a warning.
if [ "$got" -ne "$total" ]; then
cat >&2 <<EOF
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188).
Refusing rather than reconciling a partial board: a sweep over part of the
queue that reports success is the failure this shim exists to prevent.
EOF
return 1
fi
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <<<"$all"; else printf '%s\n' "$all"; fi
}
# forgejo_total_count <header-file> — the declared size of the collection.
# Absent is fatal (#4699): without it the completeness assert cannot run,
# and an assert that cannot run must not silently pass.
forgejo_total_count() {
local hdr="$1" total
total="$(tr -d '\r' <"$hdr" | awk 'tolower($1) == "x-total-count:" { print $2 }' | tail -n1)"
if [ -z "$total" ]; then
cat >&2 <<EOF
forge_api: this forge did not send x-total-count — cannot prove the gather is complete (#188).
The header is exposed by a server setting (access-control-expose-headers).
Refusing: an unprovable read must not be reported as a whole one.
EOF
return 1
fi
# Validate before it reaches arithmetic (#4712). `X-Total-Count:
# not-a-number` used to sail through and become the bound the walk was
# compared against — a guard whose own input was never checked.
case "$total" in
'' | *[!0-9]*)
cat >&2 <<EOF
forge_api: x-total-count is not a non-negative integer: '$total' (#188).
Refusing: the completeness bound must be a number, or the assert that
uses it proves nothing.
EOF
return 1
;;
esac
printf '%s\n' "$total"
}
# forgejo_http_ok <header-file> <endpoint> — a non-2xx is named, not
# swallowed. gh exits non-zero on HTTP failure; curl does not without -f,
# and -f would throw away the body that says why.
forgejo_http_ok() {
local hdr="$1" endpoint="$2" code
code="$(tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }')"
case "$code" in
2*) return 0 ;;
*)
echo "forge_api: HTTP $code from '$endpoint'" >&2
return 1
;;
esac
}
# --- the verbs the reconcilers use, over /api/v1 --------------------------
# Three asymmetries with gh, all measured against this instance on
# 2026-08-02 using a scratch repo (never a live board):
#
# 1. Adding labels takes NAMES POST /issues/{n}/labels {"labels":["x"]} -> 200
# Removing one takes a numeric ID DELETE /issues/{n}/labels/x -> 422
# DELETE /issues/{n}/labels/149 -> 204
# So a removal must resolve name -> id first. gh hides this; the shim
# cannot.
#
# 2. Assignees are SET, not added and removed. PATCH /issues/{n} takes the
# whole list ({"assignees":[]} clears it, 201), so --remove-assignee is
# a read-modify-write rather than a delete.
#
# 3. There is no statusCheckRollup. The portable equivalent is the
# combined commit status, GET /commits/{sha}/status, which returns
# {state, statuses[]}.
# forgejo_label_ids — name<TAB>id for every label in the repo, read once per
# call site that needs it. Paginated through forge_api, so a repo with more
# than one page of labels cannot silently lose the tail (#188).
forgejo_label_ids() {
forge_api --paginate "repos/$REPO/labels" --jq '.[] | "\(.name)\t\(.id)"'
}
# forge_issue_edit <n> [--add-label X]… [--remove-label X]… [--add-assignee U]… [--remove-assignee U]…
# gh's flag surface, translated. Accepts comma-separated values, as gh does.
forge_issue_edit() {
local n="${1:?forge_issue_edit: number required}"
shift
local add_labels=() rm_labels=() add_assignees=() rm_assignees=() v
# Unknown flags REFUSE (#4743). The github backend forwards whatever it is
# given to `gh`, which fails on a flag it does not know; dropping it here
# instead would turn a port typo into a green no-op — a mutation that
# silently did not happen, which is precisely this issue's failure class
# arriving inside the fix for it.
while [ $# -gt 0 ]; do
case "$1" in
--add-label | --remove-label | --add-assignee | --remove-assignee)
if [ "$#" -lt 2 ]; then
echo "forge_issue_edit: $1 requires a value (#188)" >&2
return 1
fi
IFS=, read -ra v <<<"$2"
case "$1" in
--add-label) add_labels+=("${v[@]}") ;;
--remove-label) rm_labels+=("${v[@]}") ;;
--add-assignee) add_assignees+=("${v[@]}") ;;
--remove-assignee) rm_assignees+=("${v[@]}") ;;
esac
shift
;;
*)
echo "forge_issue_edit: unknown flag '$1' — refusing rather than silently skipping the edit (#188)" >&2
return 1
;;
esac
shift
done
if [ "${#add_labels[@]}" -gt 0 ]; then
local payload
payload="$(printf '%s\n' "${add_labels[@]}" | jq -R . | jq -sc '{labels: .}')"
forgejo_write POST "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1
fi
if [ "${#rm_labels[@]}" -gt 0 ]; then
local ids id name
ids="$(forgejo_label_ids)" || return 1
for name in "${rm_labels[@]}"; do
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
# A label the repo does not have is not an error: the reconcilers call
# --remove-label unconditionally to converge state, and gh's own
# behaviour there is a no-op.
[ -n "$id" ] || continue
forgejo_write DELETE "repos/$REPO/issues/$n/labels/$id" '' >/dev/null || return 1
done
fi
if [ "${#add_assignees[@]}" -gt 0 ] || [ "${#rm_assignees[@]}" -gt 0 ]; then
local current want payload
current="$(forge_api "repos/$REPO/issues/$n" --jq '[.assignees[]?.login] | join("\n")')" || return 1
want="$(
{
printf '%s\n' "$current"
[ "${#add_assignees[@]}" -gt 0 ] && printf '%s\n' "${add_assignees[@]}"
} | grep -v '^$' | sort -u
)"
if [ "${#rm_assignees[@]}" -gt 0 ]; then
want="$(grep -vxF -f <(printf '%s\n' "${rm_assignees[@]}") <<<"$want" || true)"
fi
payload="$(printf '%s' "$want" | jq -R . | jq -sc '{assignees: [.[] | select(. != "")]}')"
forgejo_write PATCH "repos/$REPO/issues/$n" "$payload" >/dev/null || return 1
fi
}
forge_issue_comment() {
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
forgejo_write POST "repos/$REPO/issues/$n/comments" "$(jq -nc --arg b "$body" '{body: $b}')" >/dev/null
}
forge_pr_list() {
forge_api --paginate "repos/$REPO/pulls?state=open" --jq '.[].number'
}
# forge_pr_view <n> — the {mergeable, statusCheckRollup} shape the state
# machine reads, assembled from the two places Forgejo keeps it. The rollup
# is mapped into the node shape checks_state already parses, so the decision
# code is untouched.
forge_pr_view() {
local n="${1:?forge_pr_view: number required}" pr sha status
pr="$(forge_api "repos/$REPO/pulls/$n")" || return 1
sha="$(jq -r '.head.sha // ""' <<<"$pr")"
[ -n "$sha" ] || { echo "forge_pr_view: PR $n has no head sha" >&2; return 1; }
status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1
jq -n --argjson pr "$pr" --argjson st "$status" '
{
mergeable: (if $pr.mergeable == true then "MERGEABLE"
elif $pr.mergeable == false then "CONFLICTING"
else "UNKNOWN" end),
statusCheckRollup: [
$st.statuses[]? | {
__typename: "StatusContext",
context: .context,
state: (.status | ascii_upcase),
# checks_state groups repeated contexts and takes the NEWEST by
# [.startedAt, .createdAt, .completedAt]. Without a timestamp the
# winner would be decided by incidental array order, so a stale
# re-run could outrank the live verdict (#4743). The combined
# status carries both fields; measured on this instance.
createdAt: .created_at,
completedAt: .updated_at
}
]
}'
}
forge_label_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; }
# forge_label_create — an UPSERT, matching `gh label create --force` (#4743).
# bootstrap_labels creates every declared label on every workflow_dispatch, so
# the second dispatch must update rather than conflict; a plain POST onto an
# existing name aborts the bootstrap under set -e.
forge_label_create() {
local name="${1:?}" color="${2:?}" desc="${3:-}" ids id payload
payload="$(jq -nc --arg n "$name" --arg c "$color" --arg d "$desc" '{name:$n,color:$c,description:$d}')"
ids="$(forgejo_label_ids)" || return 1
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
if [ -n "$id" ]; then
forgejo_write PATCH "repos/$REPO/labels/$id" "$payload" >/dev/null
else
forgejo_write POST "repos/$REPO/labels" "$payload" >/dev/null
fi
}
forge_label_delete() {
local name="${1:?}" ids id
ids="$(forgejo_label_ids)" || return 1
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
[ -n "$id" ] || return 0
forgejo_write DELETE "repos/$REPO/labels/$id" '' >/dev/null
}
# forgejo_write <method> <endpoint> <json-body> — every mutation goes through
# here so a non-2xx is named rather than swallowed, the same contract
# forgejo_http_ok gives reads.
forgejo_write() {
local method="$1" endpoint="$2" payload="$3" base token hdr body rc
base="$(forgejo_api_base)" || return 1
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
hdr="$(mktemp)"; body="$(mktemp)"
if [ -n "$payload" ]; then
curl -sS -X "$method" -D "$hdr" -o "$body" \
-H "Authorization: token $token" -H 'Content-Type: application/json' \
-d "$payload" "$base/$endpoint"
else
curl -sS -X "$method" -D "$hdr" -o "$body" \
-H "Authorization: token $token" "$base/$endpoint"
fi
rc=$?
if [ "$rc" -ne 0 ]; then
rm -f "$hdr" "$body"
echo "forge: $method $endpoint failed to send" >&2
return 1
fi
if ! forgejo_http_ok "$hdr" "$method $endpoint"; then
head -c 300 "$body" >&2; echo >&2
rm -f "$hdr" "$body"
return 1
fi
cat "$body"
rm -f "$hdr" "$body"
}
# forge_labels_add <n> <label…> — the additive label write (ceremony#128; see
# the github twin). POST /issues/{n}/labels adds the named labels and removes
# nothing, and it takes NAMES — measured, unlike the removal path, which
# needs ids.
forge_labels_add() {
local n="${1:?forge_labels_add: number required}"
shift
[ "$#" -gt 0 ] || return 0
forgejo_write POST "repos/$REPO/issues/$n/labels" \
"$(printf '%s\n' "$@" | jq -R . | jq -sc '{labels: .}')" >/dev/null
}
# forge_request_reviewer <n> <user> — ask <user> for a verdict.
#
# This endpoint DOES exist here, contrary to an earlier reading of mine
# (#4698) which recorded requested_reviewers as having no sub-resource at
# all. What is true is narrower: Forgejo serves POST and DELETE on it and no
# GET, so a GET probe answers 404 — and a POST naming a user who does not
# exist answers 404 as well, for a different reason. Measured on a scratch
# repo: POST with a real user who lacks read access is 422 ("Reviewer can't
# read"), and 201 once they have it.
#
# The READ stays retired regardless (term 4): the field is stale here even on
# merged PRs, so outstanding verdicts come from /pulls/{n}/reviews at the
# current head SHA. It is the write that has an answer.
forge_request_reviewer() {
local n="${1:?}" user="${2:?}"
forgejo_write POST "repos/$REPO/pulls/$n/requested_reviewers" \
"$(jq -nc --arg u "$user" '{reviewers: [$u]}')" >/dev/null
}
# forge_timeline <n> — JSON array of timeline events projected into the
# GitHub shape the reconcilers already select on. Measured mapping (#4849):
#
# | | GitHub | Forgejo |
# | event kind | .event == "labeled"/"unlabeled"| .type == "label" |
# | add vs remove | the two event names | .body "1" / "" |
# | actor | .actor.login (no .user) | .user.login (no .actor) |
#
# Status is captured BEFORE jq so an unreadable read cannot report as an
# empty timeline — the two states the ruling ladder must tell apart (#4853).
forge_timeline() {
local n="${1:?forge_timeline: number required}" raw
raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1
jq '
[.[]
| select(.type == "label")
| {
event: (if .body == "1" then "labeled" else "unlabeled" end),
actor: {login: (.user.login // "")},
label: {name: (.label.name // "")},
created_at: .created_at
}
]
' <<<"$raw"
}
# forge_pr_activity <n> — one ISO timestamp per line of real PR activity.
# Forgejo has no flat /pulls/{n}/comments (HTTP 404, measured #4844); inline
# review comments live under /pulls/{n}/reviews/{id}/comments. Only reviews
# with comments_count > 0 are fetched, so a board with none costs zero
# extra requests.
forge_pr_activity() {
local n="${1:?forge_pr_activity: number required}" reviews rid
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
reviews="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews")" || return 1
while IFS= read -r rid; do
[ -n "$rid" ] || continue
forge_api --paginate "repos/$REPO/pulls/$n/reviews/$rid/comments" \
--jq '.[].created_at' || return 1
done < <(jq -r '.[] | select((.comments_count // 0) > 0) | .id' <<<"$reviews")
}
# --- the release door's facts (#191) --------------------------------------
# Two reads the merge and tag doors depend on. Both answer a QUESTION, and
# both distinguish "the read completed and the answer is no" from "the read
# did not complete" — the distinction lib/facts.sh got wrong before #191,
# where any failure became a definite `no` and a release ceremony was
# silently demoted to a bare push.
#
# Measured on forgejo.heavyduty.builders (8.0.3+gitea-1.22.0), 2026-08-04:
#
# GET /repos/{o}/{r}/releases/tags/0.4.0 -> 200 (present)
# GET /repos/{o}/{r}/releases/tags/9.9.9 -> 404 (absent — a real answer)
#
# GET /repos/{o}/{r}/commits/{sha}/pull -> 200, a SINGLE PR object
# GET /repos/{o}/{r}/commits/{sha}/pulls -> 404 page not found
# ...on a commit with no PR -> 404 {"message":"pull request
# does not exist …"}
#
# The singular/plural split is the asymmetry: GitHub serves an ARRAY at
# /pulls, Forgejo serves one OBJECT at /pull. Both verbs below emit the
# GitHub shape — a JSON array — so lib/facts.sh carries one jq expression
# for both forges, which is the whole point of the shim.
# forgejo_read_code <endpoint> <body-file> — the raw GET, printing the HTTP
# status on stdout. Separate from forge_api because these two call sites
# must SEE a 404 rather than have it collapsed into a failure.
forgejo_read_code() {
local endpoint="$1" body="$2" base token hdr rc
base="$(forgejo_api_base)" || return 1
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
hdr="$(mktemp)"
curl -sS -D "$hdr" -o "$body" -H "Authorization: token $token" "$base/$endpoint"
rc=$?
if [ "$rc" -ne 0 ]; then
rm -f "$hdr"
echo "forge: GET $endpoint failed to send" >&2
return 1
fi
tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }'
rm -f "$hdr"
}
# forge_release_exists <tag> — prints `yes` or `no`. A non-zero exit means
# the read did not complete and the answer is UNKNOWN; the caller must not
# treat that as `no` (#191).
forge_release_exists() {
local tag="${1:?forge_release_exists: tag required}" body code
body="$(mktemp)"
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
rm -f "$body"
case "$code" in
2*) echo yes ;;
404) echo no ;;
*)
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
return 1
;;
esac
}
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
# a JSON ARRAY in GitHub's shape. An empty array is a completed read that
# found nothing; a non-zero exit is a read that did not complete.
forge_commit_pulls() {
local sha="${1:?forge_commit_pulls: sha required}" body code out
body="$(mktemp)"
code="$(forgejo_read_code "repos/$REPO/commits/$sha/pull" "$body")" || { rm -f "$body"; return 1; }
case "$code" in
2*)
# One object -> a one-element array, so the call site's jq is the
# same expression it runs against GitHub.
if ! out="$(jq -c '[.]' <"$body" 2>/dev/null)"; then
rm -f "$body"
echo "forge_commit_pulls: unreadable JSON for '$sha'" >&2
return 1
fi
printf '%s\n' "$out"
;;
404) printf '[]\n' ;;
*)
rm -f "$body"
echo "forge_commit_pulls: HTTP $code reading the PR for '$sha' — the answer is unknown, not 'none'" >&2
return 1
;;
esac
rm -f "$body"
}
# --- the release door's writes (#191) -------------------------------------
# Confirmed against this instance's own swagger, 2026-08-04:
#
# POST /repos/{o}/{r}/tags -> exists (tag creation)
# GET /repos/{o}/{r}/git/refs -> GET ONLY (no POST)
# POST /repos/{o}/{r}/releases -> exists
# POST /repos/{o}/{r}/releases/{id}/assets -> exists
#
# The asymmetry worth naming: GitHub creates a tag by POSTing a ref to
# /git/refs; Forgejo does not serve POST there at all and creates tags at
# /tags instead. A 1:1 port of the gh call would 404 forever.
# forge_tag_create <tag> <sha>
forge_tag_create() {
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
forgejo_write POST "repos/$REPO/tags" \
"$(jq -nc --arg t "$tag" --arg s "$sha" '{tag_name:$t,target:$s}')" >/dev/null
}
# forgejo_urlencode <string> — percent-encode one query VALUE. jq is already
# a hard dependency of this backend, and @uri is its one correct answer; a
# hand-rolled sed class is how the next unescaped character gets through.
forgejo_urlencode() {
jq -rn --arg s "${1-}" '$s|@uri'
}
# forge_release_create <tag> <title> <notes-file> [asset…] — publishes, then
# uploads each asset to the created release. The release id comes back from
# the create, so no second lookup is needed.
forge_release_create() {
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
local notes="${3:?forge_release_create: notes file required}" out id base token
shift 3
out="$(forgejo_write POST "repos/$REPO/releases" \
"$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \
'{tag_name:$t,name:$n,body:$b,draft:false,prerelease:false}')")" || return 1
id="$(printf '%s' "$out" | jq -r '.id // empty')"
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
[ "$#" -gt 0 ] || return 0
base="$(forgejo_api_base)" || return 1
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
local f name
for f in "$@"; do
[ -e "$f" ] || continue
# The asset name is a QUERY VALUE, and the hook contract permits any
# file the consumer drops in RELEASE_ASSETS_DIR. Raw interpolation broke
# on a space (curl exits 3 on the malformed URL) and silently changed
# the name on '&', '#', '+' and '%' — `gh release create` handled those,
# so a 1:1 port had to as well (#191, found by @codex on !193).
name="$(forgejo_urlencode "$(basename "$f")")"
curl -sS -f -X POST -H "Authorization: token $token" \
-F "attachment=@$f" \
"$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \
|| { echo "forge_release_create: asset upload failed for '$f'" >&2; return 1; }
done
}
# forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes
# label IDs, not names (the same asymmetry the issue-label writes carry), so
# the names are resolved first through forgejo_label_ids.
forge_pr_create() {
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
shift 4
local ids='[]' map name id
if [ "$#" -gt 0 ]; then
map="$(forgejo_label_ids)" || return 1
ids='['
for name in "$@"; do
id="$(printf '%s\n' "$map" | awk -F'\t' -v n="$name" '$1 == n { print $2; exit }')"
[ -n "$id" ] || { echo "forge_pr_create: no label '$name' in this repo" >&2; return 1; }
ids="$ids$id,"
done
ids="${ids%,}]"
fi
forgejo_write POST "repos/$REPO/pulls" \
"$(jq -nc --arg h "$head" --arg b "$base" --arg t "$title" --arg d "$body" \
--argjson l "$ids" '{head:$h,base:$b,title:$t,body:$d,labels:$l}')" >/dev/null
}

View file

@ -1,235 +0,0 @@
#!/usr/bin/env bash
# lib/forge-github.sh — the GitHub backend (issue #188, term 1). Sourced by
# lib/forge.sh when forge_detect says github; never at the same time as the
# forgejo backend — they define the same verbs on purpose.
#
# This file is the CURRENT call set, extracted 1:1 and nothing more. Term 5
# of the frozen Spec is "GitHub consumers are unchanged", and the cheapest
# way to keep that true is for every verb here to be a thin pass-through to
# the `gh` invocation the call site used before the port. No behaviour is
# added, fixed or tidied on this path; anything that looks like an
# improvement here is a regression risk against a forge nobody is currently
# reporting bugs on.
# forge_api [--paginate] <endpoint> [--jq <expr>]
#
# The one deliberate difference from a pure pass-through: the caller no
# longer names a page size, because the page-size parameter is not portable
# and is therefore the backend's to own (#188).
#
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
#
# Both answer HTTP 200 either way, so a call site that names one is a silent
# truncation waiting for the other forge. per_page=100 is injected here —
# exactly what the call sites said before — so the GitHub path is unchanged
# in behaviour while the parameter stops being a call-site concern.
forge_api() {
local paginate=false endpoint="" jqexpr="" have_jq=false
while [ $# -gt 0 ]; do
case "$1" in
--paginate) paginate=true ;;
--jq) jqexpr="$2"; have_jq=true; shift ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
if [ "$paginate" = true ]; then
endpoint="$(github_page_url "$endpoint")"
if [ "$have_jq" = true ]; then
gh api --paginate "$endpoint" --jq "$jqexpr"
else
gh api --paginate "$endpoint"
fi
else
if [ "$have_jq" = true ]; then
gh api "$endpoint" --jq "$jqexpr"
else
gh api "$endpoint"
fi
fi
}
# github_page_url <endpoint> — pure, so the page-size contract is testable
# without a network. Strips any page-size parameter a caller left behind in
# either dialect, then applies GitHub's own.
github_page_url() {
local endpoint="${1:?github_page_url: endpoint required}" clean
clean="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
case "$clean" in
*\?) printf '%sper_page=100\n' "$clean" ;;
*\?*) printf '%s&per_page=100\n' "$clean" ;;
*) printf '%s?per_page=100\n' "$clean" ;;
esac
}
# --- the verbs the reconcilers use, extracted 1:1 -------------------------
# Every one of these is the exact `gh` invocation the call site carried
# before the port. Term 5 is kept by making this file boring.
# forge_issue_edit <n> <gh-style flags…> — labels and assignees on an issue
# or a PR (gh treats them interchangeably, and so do the call sites).
forge_issue_edit() {
local n="${1:?forge_issue_edit: number required}"
shift
gh issue edit "$n" -R "$REPO" "$@"
}
# forge_issue_comment <n> <body>
forge_issue_comment() {
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
gh issue comment "$n" -R "$REPO" --body "$body"
}
# forge_pr_list — open PR numbers, one per line. Note this used
# `gh pr list --limit 100`: a page size in gh's OWN flag namespace, which no
# URL-parameter strip could have caught, so it moves behind the shim with
# the rest (#188).
forge_pr_list() {
gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'
}
# forge_pr_view <n> — {mergeable, statusCheckRollup} as JSON, or non-zero
# with the reason on stderr. `gh pr view` rather than the REST PR object:
# the API's `mergeable` is a tri-state boolean GitHub computes lazily, while
# this returns the MERGEABLE/CONFLICTING/UNKNOWN string the UI shows.
forge_pr_view() {
local n="${1:?forge_pr_view: number required}"
gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup
}
# forge_label_list — every label name in the repo.
forge_label_list() {
gh label list -R "$REPO" --limit 200 --json name --jq '.[].name'
}
forge_label_create() {
local name="${1:?}" color="${2:?}" desc="${3:-}"
gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
}
forge_label_delete() {
local name="${1:?}"
gh label delete "$name" -R "$REPO" --yes
}
# forge_labels_add <n> <label…> — an ADDITIVE label write, and deliberately
# not forge_issue_edit --add-label. The distinction is ceremony#128: the
# labeler action computed (labels-at-job-start derived) and PUT the whole
# set, so a label applied while the job ran was silently removed. This is the
# raw POST, which adds the named labels, ignores ones already present, and
# removes nothing — a concurrent label survives by construction.
forge_labels_add() {
local n="${1:?forge_labels_add: number required}" args=() label
shift
for label in "$@"; do args+=(-f "labels[]=$label"); done
gh api "repos/$REPO/issues/$n/labels" "${args[@]}" --silent
}
# forge_request_reviewer <n> <user> — ask <user> for a verdict.
forge_request_reviewer() {
local n="${1:?}" user="${2:?}"
gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$user" --silent
}
# forge_timeline <n> — JSON array of timeline events in the GitHub shape
# (.event, .actor.login, .label.name, .created_at). The GitHub path is a
# pass-through: that shape is what the forge already returns (#188 batch).
# Callers must capture the status of THIS function before piping into jq —
# a pipeline's status is the last command's, so `forge_timeline | jq`
# collapses an unreadable timeline into an empty one (#4853).
forge_timeline() {
local n="${1:?forge_timeline: number required}"
forge_api --paginate "repos/$REPO/issues/$n/timeline"
}
# forge_pr_activity <n> — one ISO timestamp per line of real PR activity
# (issue comments, inline review comments, commits). GitHub serves the
# flat /pulls/{n}/comments collection; the forgejo twin re-derives it from
# reviews with comments_count > 0 because that endpoint 404s there (#4844).
forge_pr_activity() {
local n="${1:?forge_pr_activity: number required}"
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1
forge_api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' || return 1
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
}
# --- the release door's facts (#191) --------------------------------------
# The github twins of the forgejo backend's two release-door reads. Term 5
# discipline applies: these are the `gh` calls lib/facts.sh carried before
# the port, with one behaviour added — a read that did not complete is
# reported as such instead of collapsing into a definite `no`.
# forge_release_exists <tag> — prints `yes` or `no`; non-zero exit means the
# read did not complete and the answer is UNKNOWN (#191).
forge_release_exists() {
local tag="${1:?forge_release_exists: tag required}" errf err rc
errf="$(mktemp)"
if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq .tag_name >/dev/null 2>"$errf"; then
rm -f "$errf"
echo yes
return 0
fi
rc=$?
err="$(cat "$errf")"; rm -f "$errf"
# gh's 404 text is stable and is the only failure that is an ANSWER.
case "$err" in
*"HTTP 404"*) echo no; return 0 ;;
esac
echo "forge_release_exists: gh exited $rc reading release '$tag' — the answer is unknown, not 'no': $err" >&2
return 1
}
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
# a JSON array. GitHub serves the array directly; the forgejo twin builds
# one from its single-object endpoint so this call site is identical.
forge_commit_pulls() {
local sha="${1:?forge_commit_pulls: sha required}" errf out rc err
errf="$(mktemp)"
if out="$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha/pulls" 2>"$errf")"; then
rm -f "$errf"
printf '%s\n' "$out"
return 0
fi
rc=$?
err="$(cat "$errf")"; rm -f "$errf"
case "$err" in
*"HTTP 404"*) printf '[]\n'; return 0 ;;
esac
echo "forge_commit_pulls: gh exited $rc reading the PRs for '$sha' — the answer is unknown, not 'none': $err" >&2
return 1
}
# --- the release door's writes (#191) -------------------------------------
# The gh calls the workflow carried before the port, moved behind the shim
# so the call sites stop naming a client. Term 5: same flags, same order.
# forge_tag_create <tag> <sha>
forge_tag_create() {
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$tag" -f "sha=$sha" >/dev/null
}
# forge_release_create <tag> <title> <notes-file> [asset…]
forge_release_create() {
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
local notes="${3:?forge_release_create: notes file required}"
shift 3
gh release create "$tag" --verify-tag --title "$title" \
--notes-file "$notes" -R "$GITHUB_REPOSITORY" "$@"
}
# forge_pr_create <head> <base> <title> <body> <label…> — the release's
# bump-fallback PR (#191). gh takes repeated --label flags.
forge_pr_create() {
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
shift 4
local args=() l
for l in "$@"; do args+=(--label "$l"); done
gh pr create -R "$GITHUB_REPOSITORY" --head "$head" --base "$base" \
--title "$title" --body "$body" "${args[@]}"
}

View file

@ -1,224 +0,0 @@
#!/usr/bin/env bash
# lib/forge.sh — one forge abstraction, two backends (issue #188).
#
# Sourced, never executed: no set -e/-u here — the sourcing script owns its
# own shell options, exactly as lib/version.sh does. This file is the
# selector only; the backends live beside it in lib/forge-github.sh and
# lib/forge-forgejo.sh, and nothing here talks to a network.
#
# WHY THIS FILE EXISTS, stated once. Until #188 the reconcilers were `gh`
# all the way down — 61 runtime call sites, no indirection, no forge check.
# Pointed at a Forgejo instance (heavy-duty/rig, which moved here and runs
# its CI on a Forgejo Actions runner) they did not fail usefully. Measured
# against forgejo.heavyduty.builders on 2026-08-02, at ceremony 84bb1a4:
#
# labels-scope exit 0 "no .github/labeler.yml at main — nothing
# to derive" — the file exists (HTTP 200)
# labels-reconcile exit 0 "reconciled." — having enumerated ZERO PRs
# issueflow-reconcile exit 1 "unexpected end of JSON input"
#
# Two of the three reported SUCCESS having read nothing. labels-reconcile's
# own blind-sweep warning (#96) could not fire, because 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 and the
# sweep called itself reconciled. rig run 979 is the log.
#
# The tempting fix — install gh on the runner — makes it WORSE. gh speaks
# GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1 and no
# GraphQL at all. With gh present and GH_HOST set to the Forgejo host, the
# one loud failure goes quiet (`gh pr list` hits /api/graphql -> HTTP 405,
# prints nothing, exits into the same empty loop) and all three actions go
# green while reading nothing. That is this repo's own doctrine — an
# unreadable rollup reads as "nothing is failing" — being violated by the
# repo that wrote it.
#
# So: the forge is decided ONCE, before any sweep, and a client that cannot
# speak it refuses loudly. Never "probably github".
# forge_detect — print "github" or "forgejo"; exit 1 loudly when it cannot
# tell. Order matters and every signal below was measured, not read from
# docs: a real forgejo-runner v6.3.1 job on forgejo.heavyduty.builders
# (probe task 278, 2026-08-02) dumped its environment, and a GitHub-hosted
# runner's is the control.
#
# The trap that makes this non-obvious: **the Forgejo runner populates the
# whole GITHUB_* namespace.** GITHUB_ACTIONS=true, GITHUB_REPOSITORY,
# GITHUB_SHA, GITHUB_TOKEN — all set, all correct-looking. Detecting on
# "GITHUB_ACTIONS is set" would answer "github" on both forges, which is
# precisely the bug. What actually differs:
#
# signal GitHub Forgejo (measured)
# GITHUB_API_URL https://api.github.com https://<host>/api/v1
# GITHUB_GRAPHQL_URL https://api.github.com/… (empty)
# GITEA_ACTIONS (unset) true
#
# GITHUB_GRAPHQL_URL being empty on Forgejo is not a curiosity — it is the
# forge telling us the two `gh api graphql` sites #188 retired can never
# work here. It is deliberately NOT a detection signal, though: an empty
# variable is also what a hand-rolled harness leaves behind, and a signal
# that fires on absence is a signal that fires by accident.
forge_detect() {
# 1. The explicit override outranks every probe — the escape hatch for a
# forge this file has not met, and the handle the tests drive. A typo
# in it is fatal on purpose: the operator said something and it was
# wrong, and falling through to a probe that guesses right by accident
# would hide that until the guess was wrong too.
if [ -n "${CEREMONY_FORGE:-}" ]; then
case "$CEREMONY_FORGE" in
github | forgejo) printf '%s\n' "$CEREMONY_FORGE"; return 0 ;;
*)
echo "forge_detect: unknown forge: CEREMONY_FORGE=$CEREMONY_FORGE (expected github or forgejo)" >&2
return 1
;;
esac
fi
# 2. Forgejo's and Gitea's own positive marker. Unambiguous where a
# hand-set GITHUB_API_URL might not be, so it is read first.
if [ "${GITEA_ACTIONS:-}" = true ] || [ "${FORGEJO_ACTIONS:-}" = true ]; then
printf 'forgejo\n'
return 0
fi
# 3. The API URL's shape. /api/v3 is GitHub's (github.com and GitHub
# Enterprise Server alike — GHES is a github backend on a non-github.com
# host, and routing it to the forgejo backend would regress term 5's
# "GitHub consumers are unchanged"). /api/v1 is the Gitea shape Forgejo
# serves.
case "${GITHUB_API_URL:-}" in
https://api.github.com | https://api.github.com/*) printf 'github\n'; return 0 ;;
*/api/v3 | */api/v3/*) printf 'github\n'; return 0 ;;
*/api/v1 | */api/v1/*) printf 'forgejo\n'; return 0 ;;
esac
# 4. Last resort, the server host. Only github.com itself is conclusive
# here: a bare hostname says nothing about which API it serves.
case "${GITHUB_SERVER_URL:-}" in
https://github.com | https://github.com/*) printf 'github\n'; return 0 ;;
esac
# 5. Refuse. "Nothing to read" is not "probably github" — guessing here
# reinstates the exact blind sweep this file exists to end. Name what
# was inspected and the escape hatch, so the log answers "why" without
# a second run (#101 D5, one layer up: report, do not diagnose).
cat >&2 <<EOF
forge_detect: cannot determine which forge this is — refusing to guess (#188).
GITHUB_API_URL='${GITHUB_API_URL:-}'
GITHUB_SERVER_URL='${GITHUB_SERVER_URL:-}'
GITEA_ACTIONS='${GITEA_ACTIONS:-}'
Set CEREMONY_FORGE=github or CEREMONY_FORGE=forgejo to say so explicitly.
EOF
return 1
}
# Where the backends live. Captured at source time, not call time: a
# function that resolves BASH_SOURCE later would resolve its own file, not
# this one.
FORGE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# forge_select [forge] — source the backend for this forge, so the forge_*
# verbs exist. Exactly one backend is ever loaded; both define the same
# names, which is what keeps the branching out of the 61 call sites (term 1)
# and the single-forge assumption from growing back.
#
# Idempotent, because the actions call it once and the tests call it per
# case. Pass a forge explicitly to load a specific backend; omit it and the
# environment decides via forge_detect.
forge_select() {
local forge="${1:-}"
# One default for every consumer: the forgejo backend addresses the repo
# through REPO, the github backend reads GITHUB_REPOSITORY. Defaulting
# here means no call site — workflow step or script — can forget it and
# get a repo-less path (#191). The reconcilers still assert their own.
REPO="${REPO:-${GITHUB_REPOSITORY:-}}"
export REPO
if [ -z "$forge" ]; then
forge="$(forge_detect)" || return 1
fi
case "$forge" in
github | forgejo) ;;
*)
echo "forge_select: unknown forge: $forge (expected github or forgejo)" >&2
return 1
;;
esac
# shellcheck source=/dev/null
. "$FORGE_LIB_DIR/forge-$forge.sh" || return 1
# Read by callers and tests to assert which backend is loaded, so the
# choice is inspectable rather than implied by which functions exist.
# shellcheck disable=SC2034 # consumed by sourcing scripts, not this file
FORGE="$forge"
}
# forge_client <forge> — print the client that backend requires.
#
# github -> gh the current call set, extracted 1:1 (term 5)
# forgejo -> rest /api/v1 over curl+jq
#
# forgejo is "rest" by MEASUREMENT, not preference. The image the Forgejo
# instance actually runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04,
# probe task 278) carries curl, jq and node — and has neither `gh` NOR
# `stoke` on PATH. That second absence is what retired option A from the
# ruling: porting the call sites to the stoke CLI would have put a binary
# on the critical path that the runner does not have and that would need
# installing before every job.
forge_client() {
case "${1:?forge_client: forge required}" in
github) printf 'gh\n' ;;
forgejo) printf 'rest\n' ;;
*)
echo "forge_client: unknown forge: $1 (expected github or forgejo)" >&2
return 1
;;
esac
}
# forge_preflight — the gate. Run it BEFORE any sweep: it decides the forge
# and proves the client can speak it, or exits non-zero with a named reason.
#
# CEREMONY_FORGE_CLIENT declares the client the caller will actually use —
# how a call site that still hard-codes `gh` announces itself honestly while
# the backends are being ported. Two checks run, in order:
#
# 1. the declaration, when made, must match what this forge needs;
# 2. that client's binaries must actually be on PATH — checked whether or
# not a declaration was made, because a call site that declares the
# right client on a runner that lacks it is still a blind sweep waiting
# to happen.
forge_preflight() {
local forge want
forge="$(forge_detect)" || return 1
want="$(forge_client "$forge")" || return 1
if [ -n "${CEREMONY_FORGE_CLIENT:-}" ] && [ "$CEREMONY_FORGE_CLIENT" != "$want" ]; then
cat >&2 <<EOF
forge_preflight: this is a '$forge' forge and the '$CEREMONY_FORGE_CLIENT' client cannot speak it (#188).
gh speaks GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1
and has no GraphQL surface at all. Pointing one at the other does not
fail usefully — it reads nothing and reports success.
This forge needs the '$want' client.
EOF
return 1
fi
# Then prove the tools are actually here — declared or not. A missing
# binary is the rig failure verbatim, "line 692: gh: command not found",
# and it must be a refusal before the sweep, not a 127 halfway through
# one. Checked on BOTH paths deliberately: a call site that declares the
# right client on a runner that lacks it is still a blind sweep waiting
# to happen.
local missing_bins=() bin
case "$want" in
gh) command -v gh >/dev/null 2>&1 || missing_bins+=(gh) ;;
rest) for bin in curl jq; do command -v "$bin" >/dev/null 2>&1 || missing_bins+=("$bin"); done ;;
esac
if [ "${#missing_bins[@]}" -gt 0 ]; then
cat >&2 <<EOF
forge_preflight: this is a '$forge' forge, which needs the '$want' client, and ${missing_bins[*]} is not installed (#188).
Refusing before the sweep: a reconciler that cannot read the board must
not report that it reconciled one.
EOF
return 1
fi
return 0
}

View file

@ -248,20 +248,13 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
# skips BOTH checks — the nudge's specified content links the escalation
# comment, which only these facts identify, and half-verdicts on half-read
# facts is the exact shape the reconciler's standing rule forbids.
# forge_timeline projects both forges into the GitHub event shape
# (.event / .actor.login). Capture its status BEFORE jq: a pipeline's
# status is the last command's, so `forge_timeline | jq` would collapse
# an unreadable timeline into an empty one — the two states this function
# exists to tell apart (#188 / #4853).
local flags newest setter labeled_at labeled_epoch timeline
if ! timeline="$(forge_timeline "$n" 2>/dev/null)"; then
local flags newest setter labeled_at labeled_epoch
if ! flags="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \
--jq '.[] | select(.event == "labeled" and .label.name == "needs-ruling")
| [.actor.login, .created_at] | @tsv' 2>/dev/null)"; then
log "#$n: ruling timeline unreadable — no verdict invented this pass"
return 0
fi
flags="$(jq -r '
.[] | select(.event == "labeled" and .label.name == "needs-ruling")
| [.actor.login, .created_at] | @tsv
' <<<"$timeline")"
if [ -z "$flags" ]; then
# The label is on the item but no labeled event is visible (a timeline
# hiccup, or an import). Same treatment as unreadable: do nothing.
@ -277,7 +270,7 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
# the whole file is line-oriented, so the row format stays TSV and the
# body is decoded at its points of use (#73). Do not switch rows to JSON.
local comments
if ! comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" \
if ! comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" \
--jq '.[] | [.user.login, .created_at, .html_url,
((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then
log "#$n: ruling comments unreadable — no verdict invented this pass"
@ -315,7 +308,7 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
# ---- the bare-flag check (#50 D4, mechanical proxy) ----
if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; then
if [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_bare")" = POST ]; then
run forge_issue_comment "$n" "$RULING_BARE_MARKER
run gh issue comment "$n" -R "$REPO" --body "$RULING_BARE_MARKER
The ruling flag on this item was set by @$setter with no accompanying
escalation comment. Setting it requires the escalation contract — the
**question**, the **options**, and a **recommendation** — posted by the
@ -346,7 +339,7 @@ still owed." >/dev/null
if [ "$shape" != SHAPED ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then
local missing="${shape#MALFORMED }"
run forge_issue_comment "$n" "$RULING_SHAPE_MARKER
run gh issue comment "$n" -R "$REPO" --body "$RULING_SHAPE_MARKER
@$setter — the [escalation comment]($esc_url) accompanying this ruling flag
is missing required field labels: **$missing**. The contract's shape is
fixed because this machinery checks for it (heavy-duty/ceremony#50 D12):
@ -374,7 +367,7 @@ enforced." >/dev/null
esac
if [ "$rung" = RUNG12 ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung12")" = POST ]; then
run forge_issue_comment "$n" "$RULING_RUNG12_MARKER
run gh issue comment "$n" -R "$REPO" --body "$RULING_RUNG12_MARKER
@$setter — this ruling is 12 hours past its \`labeled\` event: the ladder's
12h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
@ -389,7 +382,7 @@ reset on activity; this comment fires once per flag episode." >/dev/null
fi
if [ "$rung" = RUNG24 ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung24")" = POST ]; then
run forge_issue_comment "$n" "$RULING_RUNG24_MARKER
run gh issue comment "$n" -R "$REPO" --body "$RULING_RUNG24_MARKER
@$setter — this ruling is 24 hours past its \`labeled\` event: the ladder's
24h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
@ -422,7 +415,7 @@ timer." >/dev/null
else
esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter."
fi
run forge_issue_comment "$n" "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line
run gh issue comment "$n" -R "$REPO" --body "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line
Per heavy-duty/ceremony#50 D6/D7 the flag-setter ($setter) owns closing this out: judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the item to its flow in that same comment.

View file

@ -1,87 +0,0 @@
#!/usr/bin/env bash
# Contract tests for lib/closes_references.sh (issue #188, term 3).
# set -u, not -e: failing commands are behavior for the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# issue_references (the LOCAL/CROSS classifier) lives here; closes_references
# calls it, exactly as refs_references does.
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
. "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
# shellcheck source=lib/closes_references.sh
. "$ROOT/lib/closes_references.sh"
# closes <want-newline-separated> <body> — the parse of <body> is exactly
# <want>. Exact, not substring: "12" is contained in "123".
closes() {
local want="$1" body="$2" got
got="$(printf '%s' "$body" | closes_references)"
[ "$got" = "$want" ]
}
# --- the three verbs, the three tenses ----------------------------------
# GitHub's documented keyword set. All of them, because a body that says
# "Fixed #4" and goes unclosed is a silent failure of the post-merge
# transition, not a loud one.
check "closes" 0 "" closes 1 'Closes #1'
check "close" 0 "" closes 1 'Close #1'
check "closed" 0 "" closes 1 'Closed #1'
check "fixes" 0 "" closes 2 'Fixes #2'
check "fix" 0 "" closes 2 'Fix #2'
check "fixed" 0 "" closes 2 'Fixed #2'
check "resolves" 0 "" closes 3 'Resolves #3'
check "resolve" 0 "" closes 3 'Resolve #3'
check "resolved" 0 "" closes 3 'Resolved #3'
check "case-insensitive" 0 "" closes 4 'CLOSES #4'
check "lowercase" 0 "" closes 4 'closes #4'
check "colon form" 0 "" closes 5 'Closes: #5'
# --- Refs is NOT a closing link -----------------------------------------
# The relation this file must not swallow. refs_references owns Refs, and
# conflating them makes every referenced issue look closeable — the
# post-merge transition #151 was reopened by hand over exactly that
# distinction.
check "Refs is not a close" 0 "" closes '' 'Refs #7'
check "Refs and Closes in one body keeps only the close" 0 "" \
closes 8 $'Refs #7\nCloses #8'
# --- cross-repo references stay out (#61) -------------------------------
# rig#112 must never be read as local #112. The classifier is shared with
# refs_references precisely so this rule has one implementation.
check "qualified reference is not local" 0 "" closes '' 'Closes rig#112'
check "owner-qualified reference is not local" 0 "" \
closes '' 'Closes heavy-duty/rig#112'
check "a local and a cross reference keep only the local" 0 "" \
closes 9 $'Closes rig#112\nCloses #9'
# --- every occurrence contributes ---------------------------------------
# Binding to the first occurrence is the defect #184 fixed in
# blocked_reference_records; this parser must not reintroduce it.
check "two closes on one line" 0 "" closes $'1\n2' 'Closes #1. Closes #2.'
check "two closes on two lines" 0 "" closes $'1\n2' $'Closes #1\nCloses #2'
check "sorted and deduplicated" 0 "" closes $'2\n10' $'Closes #10\nCloses #2\nCloses #10'
# --- prose must not be swallowed ----------------------------------------
check "trailing prose is not part of the reference" 0 "" \
closes 12 'Closes #12, and adds the guard'
check "a sentence terminator ends the reference" 0 "" closes 13 'Closes #13.'
check "no reference means no output" 0 "" closes '' 'Closes the door behind it'
check "a bare issue mention is not a close" 0 "" closes '' 'See #14 for context'
# "unclosed" contains "close" — a naive word match would fire on it.
check "a word merely containing a verb does not fire" 0 "" \
closes '' 'This left #15 unclosed'
# --- the shapes a real PR body carries ----------------------------------
check "the template's leading declaration" 0 "" \
closes 188 $'Closes #188\n\n## Acceptance criteria\n\n- [ ] a thing'
check "an empty body yields nothing" 0 "" closes '' ''
summary

View file

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

View file

@ -1,758 +0,0 @@
#!/usr/bin/env bash
# Contract tests for lib/forge-github.sh and lib/forge-forgejo.sh
# (issue #188, term 1). set -u, not -e.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
eq() {
local want="$1" got
shift
got="$("$@")" || return 1
[ "$got" = "$want" ]
}
# --- forge_select: exactly one backend, chosen deliberately -------------
check "select github loads the github backend" 0 "" \
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select github; declare -f github_page_url >/dev/null'
check "select forgejo loads the forgejo backend" 0 "" \
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select forgejo; declare -f forgejo_page_url >/dev/null'
check "select refuses an unknown forge" 1 "unknown forge" \
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select gitlab'
# shellcheck disable=SC2016 # $FORGE expands in the isolated bash -c process
check "select with no argument reads the environment" 0 "" \
bash -c 'CEREMONY_FORGE=forgejo; . '"$ROOT"'/lib/forge.sh; forge_select; [ "$FORGE" = forgejo ]'
# --- the page-size contract, both dialects ------------------------------
# The trap, measured 2026-08-02: each forge silently ignores the OTHER's
# page-size parameter and answers HTTP 200 with fewer items.
#
# ?per_page=100 GitHub 100 Forgejo 30 (ignored)
# ?limit=100 GitHub 30 Forgejo 50 (capped)
#
# So no call site names one, and these two functions are the only places
# that decide. Pure on purpose: the contract is testable without a network.
. "$ROOT/lib/forge-github.sh"
. "$ROOT/lib/forge-forgejo.sh"
# Every /api/v1 call ceremony makes is repo-scoped, and since #191 the
# backend refuses to build `repos//…` — so the suite names a repo up front,
# the way every real caller does.
REPO=o/r
export REPO
check "github: a bare path gets a query" 0 "" \
eq 'repos/o/r/issues?per_page=100' github_page_url 'repos/o/r/issues'
check "github: an existing query is preserved" 0 "" \
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open'
check "forgejo: a bare path gets a query" 0 "" \
eq 'repos/o/r/issues?limit=50&page=1' forgejo_page_url 'repos/o/r/issues' 1
check "forgejo: an existing query is preserved" 0 "" \
eq 'repos/o/r/issues?state=open&limit=50&page=2' forgejo_page_url 'repos/o/r/issues?state=open' 2
# A caller that names a page size anyway must not be able to reintroduce the
# truncation — the parameter is stripped in BOTH dialects, on both backends,
# because the whole point is that the boundary decides and the call site
# cannot override it by accident.
check "github strips a stray per_page" 0 "" \
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open&per_page=30'
check "github strips a stray limit" 0 "" \
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open&limit=100'
check "forgejo strips a stray per_page" 0 "" \
eq 'repos/o/r/issues?state=open&limit=50&page=1' forgejo_page_url 'repos/o/r/issues?state=open&per_page=100' 1
check "forgejo strips a stray limit" 0 "" \
eq 'repos/o/r/issues?state=open&limit=50&page=1' forgejo_page_url 'repos/o/r/issues?state=open&limit=100' 1
check "stripping the only parameter leaves a clean query" 0 "" \
eq 'repos/o/r/issues?limit=50&page=1' forgejo_page_url 'repos/o/r/issues?per_page=100' 1
# --- the forgejo gather: complete, or loudly refused --------------------
# curl is stubbed as a function so these are hermetic. Each case writes the
# headers and body a real Forgejo would.
# fake_forge <total-spec> <pages…> — install a curl stub serving <pages> as
# successive page bodies, declaring <total-spec> in x-total-count. An empty
# string omits the header entirely (@kimi's #4699 case). A comma-separated
# spec declares a DIFFERENT total per page ("4,9"), which is
# @codex-reviewer-andresmgsl's changing-between-pages case (#4700 / #4712):
# a server whose count moves under the walk cannot have been read whole.
fake_forge() {
FAKE_TOTAL="$1"; shift
FAKE_PAGES=("$@")
FAKE_CALLS=0
# shellcheck disable=SC2317 # the stub is invoked indirectly, by forge_api
curl() {
local hdr="" out="" url=""
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift ;;
-o) out="$2"; shift ;;
-H) shift ;;
-*) ;;
*) url="$1" ;;
esac
shift
done
local page=1
case "$url" in *page=*) page="${url##*page=}"; page="${page%%&*}" ;; esac
local total="$FAKE_TOTAL"
case "$FAKE_TOTAL" in
*,*)
total="$(printf '%s' "$FAKE_TOTAL" | cut -d, -f"$page")"
[ -n "$total" ] || total="$(printf '%s' "$FAKE_TOTAL" | cut -d, -f1)"
;;
esac
{
printf 'HTTP/1.1 200 OK\r\n'
[ -n "$total" ] && printf 'X-Total-Count: %s\r\n' "$total"
printf '\r\n'
} >"$hdr"
if [ "$page" -le "${#FAKE_PAGES[@]}" ]; then
printf '%s' "${FAKE_PAGES[$((page - 1))]}" >"$out"
else
printf '[]' >"$out"
fi
FAKE_CALLS=$((FAKE_CALLS + 1))
return 0
}
}
export CEREMONY_FORGE_API=https://forge.example/api/v1
# One page, and the count agrees with the declared total.
fake_forge 2 '[{"number":1},{"number":2}]'
check "a complete single-page gather returns its items" 0 "" \
eq $'1\n2' forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# Two pages that add up. The walk must not stop at the first page merely
# because it came back non-empty — rig has 137 issues across 3 pages, which
# is the case this models.
fake_forge 4 '[{"number":1},{"number":2}]' '[{"number":3},{"number":4}]'
check "a multi-page gather walks every page" 0 "" \
eq $'1\n2\n3\n4' forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# The whole reason the assert exists: a server that declares more than it
# hands over must not produce a "successful" partial sweep.
fake_forge 137 '[{"number":1},{"number":2}]'
check "a short gather is refused, not reconciled" 1 "incomplete gather" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
check "...and the refusal names both counts" 1 "collected 2 of 137" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# @kimi-reviewer-andresmgsl's hardening (#4699): the guard must not be able
# to degrade silently either. A Forgejo that does not expose x-total-count
# leaves the assert with nothing to compare, and an assert that cannot run
# must refuse rather than pass.
fake_forge '' '[{"number":1},{"number":2}]'
check "a missing x-total-count refuses" 1 "did not send x-total-count" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
check "...and says why it cannot prove completeness" 1 "cannot prove the gather is complete" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# @codex-reviewer-andresmgsl's #4712 findings. Each one is a route by which
# an unprovable read could still have been reported as a whole one — the
# guard leaking the failure class it was built to stop, which is why they
# are refusals rather than warnings.
# A total that is not a number went straight into arithmetic. Reproduced on
# ab23a3b: `X-Total-Count: not-a-number` returned rc=0 with that string as
# the total.
fake_forge 'not-a-number' '[{"number":1}]'
check "a non-numeric total is refused" 1 "not a non-negative integer" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
check "...and the refusal quotes what arrived" 1 "not-a-number" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
fake_forge '12x' '[{"number":1}]'
check "a partly-numeric total is refused" 1 "not a non-negative integer" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
fake_forge '-3' '[{"number":1}]'
check "a negative total is refused" 1 "not a non-negative integer" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# A total that MOVES under the walk. The loop read it once, so a board
# changing size mid-gather was invisible: page 1 said 4, page 2 said 9, and
# the walk stopped at 4 believing itself complete.
fake_forge '4,9' '[{"number":1},{"number":2}]' '[{"number":3},{"number":4}]'
check "a total that changes between pages is refused" 1 "changed between pages" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# The distinguishing text, not a substring that survives losing half the
# message: "4" alone stayed green if the later total vanished, which is what
# @codex-reviewer-andresmgsl (#4727) and @grok-reviewer-andresmgsl (#4734)
# both caught. A test named "names BOTH totals" must fail when one goes.
check "...and the refusal names both totals" 1 "4 then 9" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# A 200 whose body is not a collection. `length` on a non-array counted 0,
# so an object or a scalar arriving where a list belongs read as a complete
# EMPTY collection when the declared total was 0 — silence dressed as a
# clean sweep.
fake_forge 0 '{"message":"Not found"}'
check "a non-array body is refused" 1 "did not return a collection" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
fake_forge 0 '"a string"'
check "a scalar body is refused" 1 "did not return a collection" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# A genuinely empty collection is still fine — the refusal must not fire on
# a repo that legitimately has nothing.
fake_forge 0 '[]'
check "an empty collection is not an error" 0 "" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# --- HTTP failures are named, not swallowed -----------------------------
# gh exits non-zero on an HTTP error; curl does not without -f, and -f
# discards the body that explains why. So the status is read explicitly.
fake_forge 1 '[{"number":1}]'
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
curl() {
local hdr="" out=""
while [ $# -gt 0 ]; do
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; esac
shift
done
printf 'HTTP/1.1 404 Not Found\r\n\r\n' >"$hdr"
printf '{"message":"Not found"}' >"$out"
return 0
}
check "a 404 is a named failure" 1 "HTTP 404" forge_api 'repos/o/r/issues/9999'
check "a 404 names the endpoint" 1 "repos/o/r/issues/9999" forge_api 'repos/o/r/issues/9999'
# --- forge_issue_edit: a typo must not become a green no-op --------------
# @codex-reviewer-andresmgsl (#4743). The github backend hands whatever it is
# given to `gh`, which fails on a flag it does not know. Dropping it here
# instead turned a mis-typed port site into a mutation that silently did not
# happen — this issue's own failure class, arriving inside the fix for it.
check "an unknown edit flag refuses" 1 "unknown flag" forge_issue_edit 1 --typo value
check "...and names the flag it refused" 1 "--typo" forge_issue_edit 1 --typo value
check "a flag with no value refuses" 1 "requires a value" forge_issue_edit 1 --add-label
# --- forge_label_create: an upsert, like gh's --force --------------------
# bootstrap_labels creates every declared label on EVERY workflow_dispatch,
# so a plain POST onto an existing name aborts the bootstrap under set -e
# from the second dispatch onward (#4743).
WRITES="$TMP/writes"
stub_writes() {
: >"$WRITES"
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
curl() {
local hdr="" out="" method=GET url="" payload=""
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift ;;
-o) out="$2"; shift ;;
-X) method="$2"; shift ;;
-d) payload="$2"; shift ;;
-H) shift ;;
-*) ;;
*) url="$1" ;;
esac
shift
done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_LABEL_N:-1}" >"$hdr"
case "$url" in
*"/labels?"* | */labels) printf '%s' "${FAKE_LABELS:-[]}" >"$out" ;;
*) printf '{}' >"$out" ;;
esac
[ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
return 0
}
}
# The label does not exist yet -> POST (create).
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_label_create ready 0e8a16 'in the queue'
check "creating a new label POSTs" 0 "" grep -q '^POST repos/o/r/labels ' "$WRITES"
# The label already exists -> PATCH (update), which is what --force does.
FAKE_LABELS='[{"name":"ready","id":7}]' FAKE_LABEL_N=1 stub_writes
FAKE_LABELS='[{"name":"ready","id":7}]' FAKE_LABEL_N=1 REPO=o/r forge_label_create ready 0e8a16 'new text'
check "recreating an existing label PATCHes it" 0 "" \
grep -q '^PATCH repos/o/r/labels/7 ' "$WRITES"
check "...and does not POST a duplicate" 1 "" grep -q '^POST repos/o/r/labels ' "$WRITES"
check "...carrying the updated description" 0 "" grep -q 'new text' "$WRITES"
# --- forge_issue_edit on forgejo: the two asymmetries, hermetically ------
# Promised with the call-site port (@grok-reviewer-andresmgsl #4741 note 2,
# #4751 item 2). Live scratch-repo evidence proved these work; these prove
# they keep working, and pin the SHAPE of the requests.
# Removal resolves name -> id, because Forgejo takes names on add and only a
# numeric id on remove. Measured: DELETE .../labels/probe:one -> 422,
# DELETE .../labels/149 -> 204.
FAKE_LABELS='[{"name":"stale","id":11},{"name":"ready","id":12}]' FAKE_LABEL_N=2 stub_writes
FAKE_LABELS='[{"name":"stale","id":11},{"name":"ready","id":12}]' FAKE_LABEL_N=2 REPO=o/r forge_issue_edit 5 --remove-label stale
check "removing a label resolves its numeric id" 0 "" grep -q '^DELETE repos/o/r/issues/5/labels/11 ' "$WRITES"
check "...and never sends the name as the path segment" 1 "" grep -q 'labels/stale' "$WRITES"
# A label the repo does not have is a no-op, matching gh: the reconcilers
# call --remove-label unconditionally to converge state.
FAKE_LABELS='[{"name":"ready","id":12}]' FAKE_LABEL_N=1 stub_writes
FAKE_LABELS='[{"name":"ready","id":12}]' FAKE_LABEL_N=1 REPO=o/r forge_issue_edit 5 --remove-label nonexistent
check "removing an absent label writes nothing" 0 "" test ! -s "$WRITES"
# Adding takes names directly — no lookup, one request.
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_issue_edit 5 --add-label "ready,stale"
check "adding labels posts them by name" 0 "" grep -q '^POST repos/o/r/issues/5/labels .*"ready"' "$WRITES"
check "...comma-separated values are split, as gh splits them" 0 "" grep -q '"stale"' "$WRITES"
# Assignees are SET, not added/removed: PATCH takes the whole list. So a
# removal is a read-modify-write, and a naive translation would have cleared
# every OTHER assignee as a side effect of removing one.
assignee_stub() {
: >"$WRITES"
# shellcheck disable=SC2317 # invoked indirectly, by forge_issue_edit
curl() {
local hdr="" out="" method=GET url="" payload=""
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift ;; -o) out="$2"; shift ;;
-X) method="$2"; shift ;; -d) payload="$2"; shift ;;
-H) shift ;; -*) ;; *) url="$1" ;;
esac
shift
done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 0\r\n\r\n' >"$hdr"
printf '{"assignees":[{"login":"alice"},{"login":"bob"}]}' >"$out"
[ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
return 0
}
}
assignee_stub
REPO=o/r forge_issue_edit 5 --remove-assignee alice
check "removing one assignee PATCHes the surviving list" 0 "" grep -q '^PATCH repos/o/r/issues/5 .*"bob"' "$WRITES"
check "...and the removed one is gone from it" 1 "" grep -q '"alice"' "$WRITES"
assignee_stub
REPO=o/r forge_issue_edit 5 --add-assignee carol
check "adding an assignee keeps the existing ones" 0 "" grep -qE '^PATCH repos/o/r/issues/5 .*"alice".*"bob".*"carol"|^PATCH repos/o/r/issues/5 .*"alice".*"carol".*"bob"' "$WRITES"
# --- forge_labels_add / forge_request_reviewer, both backends ------------
# @codex-reviewer-andresmgsl #4780 item 3. These two writes came in with the
# call-site port and had no boundary pins of their own.
# ceremony#128 is the whole reason forge_labels_add exists as its own verb.
# The labeler action computed (labels-at-job-start union derived) and PUT the
# whole set, so a label applied while the job ran was silently removed —
# ceremony#128 lost its `release` label, the merge door's declared-intent
# read, two seconds after the builder set it. This write must therefore be an
# ADDITIVE POST and must never read-modify-write.
FAKE_LABELS='[{"name":"scope:docs","id":21}]' FAKE_LABEL_N=1 stub_writes
FAKE_LABELS='[{"name":"scope:docs","id":21}]' FAKE_LABEL_N=1 \
REPO=o/r forge_labels_add 7 scope:docs scope:cli
check "labels_add POSTs to the issue labels collection" 0 "" \
grep -q '^POST repos/o/r/issues/7/labels ' "$WRITES"
check "...carrying every name in one request" 0 "" \
grep -q '"scope:docs","scope:cli"' "$WRITES"
# The regression that would reopen ceremony#128: any PUT, or a GET-then-write.
check "...and never PUTs the whole set (ceremony#128)" 1 "" grep -q '^PUT ' "$WRITES"
check "...exactly one write, so nothing is read-modify-written" 0 "" \
test "$(wc -l <"$WRITES")" -eq 1
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_labels_add 7
check "labels_add with no labels writes nothing" 0 "" test ! -s "$WRITES"
# The reviewer payload shape. Measured against this instance: the endpoint
# serves post and delete only, and takes {"reviewers":[...]}.
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_request_reviewer 9 danmt
check "request_reviewer POSTs to requested_reviewers" 0 "" \
grep -q '^POST repos/o/r/pulls/9/requested_reviewers ' "$WRITES"
check "...with the reviewers array payload" 0 "" \
grep -q '{"reviewers":\["danmt"\]}' "$WRITES"
# The github twin is a 1:1 gh pass-through (term 5), so its parity is pinned
# by the command it builds rather than by an HTTP shape.
gh_calls="$TMP/ghcalls"
: >"$gh_calls"
# shellcheck disable=SC2317 # invoked indirectly, by the github verbs
gh() { printf '%s\n' "$*" >>"$gh_calls"; }
# A subshell so the github backend does not stay loaded over the forgejo
# cases below; REPO is deliberately scoped to it for the same reason.
(
forge_select github
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
REPO=o/r
forge_labels_add 7 scope:docs scope:cli
forge_request_reviewer 9 danmt
)
check "github labels_add uses the additive api POST, not issue edit" 0 "" \
grep -q 'api repos/o/r/issues/7/labels -f labels\[\]=scope:docs -f labels\[\]=scope:cli' "$gh_calls"
check "...and never routes through issue edit --add-label" 1 "" \
grep -q 'issue edit' "$gh_calls"
check "github request_reviewer posts the reviewer" 0 "" \
grep -q 'api repos/o/r/pulls/9/requested_reviewers -f reviewers\[\]=danmt' "$gh_calls"
# --- term-5 pins for the batch verbs (codex 1566) -------------------------
# The forgejo twins have hermetic coverage below; these pin that the github
# twins stay 1:1 extractions of the pre-port endpoints, not silent rewrites.
: >"$gh_calls"
# shellcheck disable=SC2317 # invoked indirectly, by the github verbs
gh() {
printf '%s\n' "$*" >>"$gh_calls"
if [ "$1" = api ]; then
shift
local jqexpr="" endpoint=""
while [ $# -gt 0 ]; do
case "$1" in
--jq) jqexpr="$2"; shift ;;
--paginate) ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
local body='[]'
case "$endpoint" in
*'/issues/'*'/timeline'*)
body='[{"event":"labeled","actor":{"login":"alice"},"label":{"name":"ready"},"created_at":"2026-08-01T09:00:00Z"}]'
;;
*'/issues/'*'/comments'*)
body='[{"created_at":"2026-08-01T10:00:00Z"}]'
;;
*'/pulls/'*'/comments'*)
body='[{"created_at":"2026-08-01T10:30:00Z"}]'
;;
*'/pulls/'*'/commits'*)
body='[{"commit":{"committer":{"date":"2026-08-01T11:00:00Z"}}}]'
;;
esac
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" <<<"$body"; else printf '%s\n' "$body"; fi
return 0
fi
return 0
}
gh_tl="$(
forge_select github
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
REPO=o/r
forge_timeline 42
)"
check "github forge_timeline paginates the issue timeline endpoint" 0 "" \
grep -qE 'api --paginate repos/o/r/issues/42/timeline|api repos/o/r/issues/42/timeline' "$gh_calls"
check "github forge_timeline is a pass-through of the GitHub event shape" 0 "" \
jq -e '.[0].event == "labeled" and .[0].actor.login == "alice"' <<<"$gh_tl" >/dev/null
# The NEGATIVE half of "pass-through". A positive pin still passes if the
# github path GAINS forgejo behaviour, and term 5 is a statement about what
# must NOT change — so the drift these two catch is the only kind a
# 1:1-extraction path realistically suffers.
#
# The GitHub timeline already IS the shape ruling.sh selects on, so a
# projection here would be a second, divergent normalizer maintained by
# nobody.
check "...and never reshapes it, as the forgejo backend must" 1 "" \
grep -q 'issues/42/timeline.*--jq' "$gh_calls"
: >"$gh_calls"
gh_act="$(
forge_select github
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
REPO=o/r
forge_pr_activity 9 | sort
)"
check "github forge_pr_activity hits issue comments" 0 "" \
grep -q 'repos/o/r/issues/9/comments' "$gh_calls"
check "github forge_pr_activity hits the flat /pulls/{n}/comments endpoint" 0 "" \
grep -q 'repos/o/r/pulls/9/comments' "$gh_calls"
check "github forge_pr_activity hits commits" 0 "" \
grep -q 'repos/o/r/pulls/9/commits' "$gh_calls"
check "github forge_pr_activity emits all three timestamp sources" 0 "" \
test "$(printf '%s\n' "$gh_act")" = "$(printf '%s\n' '2026-08-01T10:00:00Z' '2026-08-01T10:30:00Z' '2026-08-01T11:00:00Z')"
# The forgejo backend derives inline comments from reviews with
# comments_count > 0, because the flat endpoint 404s there. That derivation
# must never appear on this path: GitHub serves the flat endpoint, and a
# github twin quietly adopting the workaround is the exact "both backends
# drift together" failure term 5 forbids.
check "...never deriving them from reviews, as forgejo must" 1 "" \
grep -q 'pulls/[0-9]*/reviews' "$gh_calls"
unset -f gh
. "$ROOT/lib/forge-forgejo.sh"
# --- forge_pr_view: newest verdict per context must win ------------------
# checks_state groups repeated contexts and selects the newest by
# [.startedAt, .createdAt, .completedAt]. Mapping only {context,state} left
# the winner to incidental array order, so a stale re-run could outrank the
# live one (#4743). Forgejo's combined status carries created_at/updated_at
# — measured on this instance, where Actions DO land as commit statuses
# (rig main: "ci / check (push)" success, with created_at).
pr_view_stub() {
# shellcheck disable=SC2317 # invoked indirectly, by forge_pr_view
curl() {
local hdr="" out="" url=""
while [ $# -gt 0 ]; do
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift
done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 1\r\n\r\n' >"$hdr"
case "$url" in
*/status) printf '%s' "$FAKE_STATUS" >"$out" ;;
*) printf '{"head":{"sha":"abc"},"mergeable":true}' >"$out" ;;
esac
return 0
}
}
# The FAILURE is older but listed second — array order would pick it.
FAKE_STATUS='{"state":"failure","statuses":[
{"context":"ci / check","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
{"context":"ci / check","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"}]}'
pr_view_stub
view_json="$(REPO=o/r forge_pr_view 5)"
check "pr_view maps createdAt" 0 "" \
grep -q '"createdAt": "2026-08-02T10:00:00Z"' <<<"$view_json"
check "pr_view maps completedAt" 0 "" \
grep -q '"completedAt":' <<<"$view_json"
check "pr_view maps mergeable to the UI string" 0 "" \
grep -q '"mergeable": "MERGEABLE"' <<<"$view_json"
# The real proof: feed it to the production classifier and confirm the newer
# SUCCESS wins over the older FAILURE regardless of array order.
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh
. "$ROOT/actions/labels-reconcile/labels-reconcile.sh"
classified="$(checks_state <<<"$view_json")"
check "the newest verdict per context wins, not the array order" 0 "" \
test "$classified" = SUCCESS
# --- the api base must be known -----------------------------------------
check "no api base refuses" 1 "cannot reach the forge" \
bash -c 'unset CEREMONY_FORGE_API GITHUB_API_URL; . '"$ROOT"'/lib/forge-forgejo.sh; forgejo_api_base'
# --- forge_timeline: project Forgejo labels into the GitHub event shape -
# Mapping measured #4849: .type=="label", .body "1"/"" -> labeled/unlabeled,
# .user.login -> .actor.login. Mutation-verified: collapsing add/remove or
# emitting .user instead of .actor each reds its own case (#4853).
timeline_stub() {
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
curl() {
local hdr="" out="" url=""
while [ $# -gt 0 ]; do
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift
done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_TL_N:-2}" >"$hdr"
case "$url" in
*timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;;
*) printf '[]' >"$out" ;;
esac
return 0
}
}
FAKE_TIMELINE='[
{"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"},
{"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"},
{"type":"comment","body":"noise","user":{"login":"other"},"created_at":"2026-08-02T15:00:00Z"}
]'
FAKE_TL_N=3
timeline_stub
tl="$(REPO=o/r forge_timeline 188)"
check "forge_timeline projects body=1 to labeled" 0 "" \
jq -e '.[] | select(.event == "labeled" and .label.name == "needs-ruling" and .actor.login == "setter")' <<<"$tl" >/dev/null
check "forge_timeline projects body=\"\" to unlabeled" 0 "" \
jq -e '.[] | select(.event == "unlabeled" and .label.name == "needs-ruling")' <<<"$tl" >/dev/null
check "forge_timeline drops non-label events" 0 "" \
test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0
check "forge_timeline uses .actor.login, not a bare .user" 0 "" \
jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null
# Unreadable: curl fails. Status must surface through forge_timeline itself
# (not a later jq), or the ruling ladder invents a verdict on a half-read.
# shellcheck disable=SC2317
curl() { return 22; }
tl_unreadable() { REPO=o/r forge_timeline 188; }
check "forge_timeline fails when the gather fails" 1 "" tl_unreadable
# --- forge_pr_activity: no flat /pulls/{n}/comments on Forgejo -----------
# Only reviews with comments_count > 0 are fetched (#4844).
activity_calls="$TMP/activity_calls"
: >"$activity_calls"
activity_stub() {
# shellcheck disable=SC2317
curl() {
local hdr="" out="" url="" total=1 body='[]'
while [ $# -gt 0 ]; do
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift
done
printf '%s\n' "$url" >>"$activity_calls"
case "$url" in
*'/issues/'*'/comments'*)
total=1
body='[{"created_at":"2026-08-01T10:00:00Z"}]'
;;
*'/pulls/'*'/commits'*)
total=1
body='[{"commit":{"committer":{"date":"2026-08-01T11:00:00Z"}}}]'
;;
*'/reviews/'*'/comments'*)
total=1
body='[{"created_at":"2026-08-01T12:00:00Z"}]'
;;
*'/pulls/'*'/reviews'*)
total=2
body="$FAKE_REVIEWS"
;;
*) total=0; body='[]' ;;
esac
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "$total" >"$hdr"
printf '%s' "$body" >"$out"
return 0
}
}
FAKE_REVIEWS='[{"id":7,"comments_count":1},{"id":8,"comments_count":0}]'
activity_stub
: >"$activity_calls"
act="$(REPO=o/r forge_pr_activity 9 | sort)"
check "forge_pr_activity emits issue-comment timestamps" 0 "" \
grep -qx '2026-08-01T10:00:00Z' <<<"$act"
check "forge_pr_activity emits commit timestamps" 0 "" \
grep -qx '2026-08-01T11:00:00Z' <<<"$act"
check "forge_pr_activity emits inline review-comment timestamps" 0 "" \
grep -qx '2026-08-01T12:00:00Z' <<<"$act"
check "forge_pr_activity fetches only reviews with comments_count>0" 0 "" \
grep -q '/reviews/7/comments' "$activity_calls"
check "...and never fetches a zero-comment review" 1 "" \
grep -q '/reviews/8/comments' "$activity_calls"
check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \
grep -E '/pulls/[0-9]+/comments(\?|$)' "$activity_calls"
# --- the release door's verbs, both backends (#191) -----------------------
# The five verbs the release path now goes through. These carry two measured
# asymmetries that would 404 forever if wrong, and neither is visible to a
# github-only suite:
#
# PRs behind a commit GitHub GET /commits/{sha}/pulls -> ARRAY
# Forgejo GET /commits/{sha}/pull -> ONE OBJECT
# (the plural 404s)
# tag creation GitHub POST /git/refs
# Forgejo POST /tags (/git/refs is GET-only)
# release_stub <code> <body> — a curl stub answering one canned response and
# recording the method+path it was asked for.
release_stub() {
# Globals, not locals: the curl closure below runs long after this
# function returns, exactly as stub_writes does above.
STUB_CODE="$1" STUB_BODY="$2"
: >"$WRITES"
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
curl() {
local hdr="" out="" method=GET url="" payload=""
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift ;;
-o) out="$2"; shift ;;
-X) method="$2"; shift ;;
-d) payload="$2"; shift ;;
-F) payload="$payload -F $2"; shift ;;
-H) shift ;;
-*) ;;
*) url="$1" ;;
esac
shift
done
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$STUB_CODE" >"$hdr"
[ -n "$out" ] && printf '%s' "$STUB_BODY" >"$out"
printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
return 0
}
}
GITHUB_API_URL=https://forge.example/api/v1
export GITHUB_API_URL
# Helpers so the assertions run in THIS shell, where the verbs are defined.
pulls_is_array() { forge_commit_pulls "$1" | jq -e 'type == "array" and length == 1' >/dev/null && echo array-of-1; }
writes_after() { "$@" >/dev/null 2>&1; cat "$WRITES"; }
repo_empty_release() { REPO='' forge_release_exists 1.2.3; }
repo_empty_pulls() { REPO='' forge_commit_pulls deadbeef; }
release_stub 200 '{"number":7,"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}'
check "forgejo: one PR object becomes a one-element array" 0 '"number":7' \
forge_commit_pulls deadbeef
check "forgejo: the array is what the call site's jq expects" 0 "array-of-1" \
pulls_is_array deadbeef
check "forgejo: it reads the SINGULAR path" 0 "commits/deadbeef/pull " \
writes_after forge_commit_pulls deadbeef
release_stub 404 '{"message":"pull request does not exist"}'
check "forgejo: 404 is an empty array, not a failure" 0 "[]" forge_commit_pulls deadbeef
release_stub 500 '{}'
check "forgejo: a 500 refuses rather than saying 'none'" 1 "the answer is unknown, not 'none'" \
forge_commit_pulls deadbeef
release_stub 200 '{"tag_name":"1.2.3"}'
check "forgejo: a present release is yes" 0 "yes" forge_release_exists 1.2.3
release_stub 404 '{}'
check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3
release_stub 503 '{}'
check "forgejo: an unreadable release refuses, not 'no'" 1 "the answer is unknown, not 'no'" \
forge_release_exists 1.2.3
# THE MUST-FAIL (#191, found by @kimi on !193 before it shipped): with REPO
# empty every path becomes repos//… , whose 404 would read as a fact — "no"
# and "[]" with rc 0. That is the bug this issue exists to remove.
release_stub 404 '{}'
check "REPO empty refuses instead of fabricating 'no'" 1 "refusing to address 'repos//" \
repo_empty_release
check "REPO empty refuses instead of fabricating '[]'" 1 "refusing to address 'repos//" \
repo_empty_pulls
release_stub 201 '{"id":42}'
check "forgejo: a tag is created at /tags, not /git/refs" 0 "POST repos/o/r/tags" \
writes_after forge_tag_create 1.2.3 cafebabe
release_stub 201 '{"id":42}'
check "forgejo: the tag body names the target sha" 0 '"target":"cafebabe"' \
writes_after forge_tag_create 1.2.3 cafebabe
printf 'notes body\n' >"$TMP/notes.md"
release_stub 201 '{"id":42}'
check "forgejo: the publish POSTs to /releases with the notes as body" 0 '"body":"notes body' \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
# Assets: the hook contract permits any filename the consumer drops in
# RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw
# interpolation exits 3 on a space and silently renames on '&' / '#' / '+' /
# '%' — `gh release create` handled those, so the forgejo twin must too
# (#191, @codex on !193).
check "the encoder escapes a space" 0 "release%20asset.tgz" \
forgejo_urlencode 'release asset.tgz'
check "the encoder escapes the query delimiters" 0 "a%26b%23c%2Bd%25e.tgz" \
forgejo_urlencode 'a&b#c+d%e.tgz'
printf 'x\n' >"$TMP/release asset.tgz"
printf 'y\n' >"$TMP/a&b.tgz"
release_stub 201 '{"id":42}'
check "an asset with a space uploads under the encoded name" 0 "assets?name=release%20asset.tgz" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz"
release_stub 201 '{"id":42}'
check "an asset with '&' does not become two parameters" 0 "assets?name=a%26b.tgz" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}'
check "the upload targets the created release id" 0 "releases/42/assets" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}'
check "the asset rides as a multipart attachment" 0 "attachment=@" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
# --- the github twins address their own paths ----------------------------
. "$ROOT/lib/forge-github.sh"
GITHUB_REPOSITORY=o/r
export GITHUB_REPOSITORY
GH_CALLS="$TMP/ghcalls"
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
gh() { printf '%s\n' "$*" >>"$GH_CALLS"; case "$*" in *commits/*) echo '[]' ;; esac; return 0; }
gh_after() { : >"$GH_CALLS"; "$@" >/dev/null 2>&1; cat "$GH_CALLS"; }
check "github: the tag goes to /git/refs" 0 "git/refs" \
gh_after forge_tag_create 1.2.3 cafebabe
check "github: PRs behind a commit use the PLURAL path" 0 "commits/deadbeef/pulls" \
gh_after forge_commit_pulls deadbeef
summary

View file

@ -1,175 +0,0 @@
#!/usr/bin/env bash
# Contract tests for lib/forge.sh (issue #188). set -u, not -e: failing
# commands are behavior for the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# A PATH with the shell and the text tools lib/forge.sh itself uses, but with
# NO forge clients on it — that is the condition under test. It cannot be a
# genuinely empty directory: `env -i PATH=…` would then fail to find `bash`,
# and the heredoc refusals use `cat`.
mkdir -p "$TMP/empty"
for _t in bash cat sed awk tr printf; do
_p="$(command -v "$_t" 2>/dev/null)" && ln -sf "$_p" "$TMP/empty/$_t"
done
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
# eq <want> <cmd...> — succeeds AND prints exactly <want>. check()'s
# substring match cannot prove "forgejo" was not printed as "forgejox".
eq() {
local want="$1" got
shift
got="$("$@")" || return 1
[ "$got" = "$want" ]
}
# detect_in <env-assignments…> — run forge_detect in a clean environment
# carrying only the named vars, so a leaked GITHUB_* from the CI running
# THIS suite cannot decide the answer. Every case below is hermetic.
detect_in() {
env -i PATH="$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_detect'
}
# A PATH carrying stub binaries for every client the preflight can require.
# Without this the "passes" cases depend on whatever the HOST happens to have,
# which is not hermetic and is wrong in the only place it matters: the Forgejo
# runner image (ghcr.io/catthehacker/ubuntu:act-22.04) has **no gh**, so
# "github + gh passes" failed there while passing on a developer box. Measured
# 2026-08-02 (#188) — the same class of hosted-image assumption this issue
# exists to find.
STUBBIN="$TMP/bin"
mkdir -p "$STUBBIN"
for _b in gh curl jq; do printf '#!/bin/sh\nexit 0\n' >"$STUBBIN/$_b"; chmod +x "$STUBBIN/$_b"; done
preflight_in() {
env -i PATH="$STUBBIN:$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_preflight'
}
# ...and one with NO clients at all, for the missing-binary refusal.
preflight_bare() {
env -i PATH="$TMP/empty" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_preflight'
}
# --- forge_detect: the explicit override --------------------------------
# CEREMONY_FORGE outranks every probe. It is the escape hatch for a forge
# whose env this file has not met yet, and the handle the tests below use
# to drive the backends without a live instance.
check "override: github" 0 "" eq github detect_in CEREMONY_FORGE=github
check "override: forgejo" 0 "" eq forgejo detect_in CEREMONY_FORGE=forgejo
check "override refuses an unknown forge" 1 "unknown forge" \
detect_in CEREMONY_FORGE=gitlab
# A typo must not silently fall through to a probe that guesses right by
# accident: the operator said something, and it was wrong.
check "override outranks the env" 1 "unknown forge" \
detect_in CEREMONY_FORGE=gitlab GITHUB_API_URL=https://api.github.com
# --- forge_detect: GITHUB_API_URL, the load-bearing signal ---------------
# Measured on forgejo.heavyduty.builders 2026-08-02 with a real
# forgejo-runner v6.3.1 job (probe run, task 278). The Forgejo runner
# populates the GITHUB_* namespace — GITHUB_ACTIONS=true and all — so
# "GITHUB_ACTIONS is set" proves nothing at all. What differs is where
# those URLs point:
#
# GitHub GITHUB_API_URL=https://api.github.com
# Forgejo GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1
#
# That is the whole bug this issue exists for, in one variable: gh speaks
# /api/v3 against api.github.com, and neither half is true here.
check "api url: api.github.com is github" 0 "" \
eq github detect_in GITHUB_API_URL=https://api.github.com
check "api url: /api/v1 is forgejo" 0 "" \
eq forgejo detect_in GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1
# GitHub Enterprise Server: a self-hosted GitHub still speaks /api/v3, and
# it is a github backend on a non-github.com host. Getting this wrong would
# route a GHES consumer to the forgejo backend and break term 5.
check "api url: GHES /api/v3 is github" 0 "" \
eq github detect_in GITHUB_API_URL=https://ghe.example.com/api/v3
# --- forge_detect: GITEA_ACTIONS, the positive marker --------------------
# The Forgejo runner also exports GITEA_ACTIONS=true (measured, task 278),
# which GitHub never sets. It is checked BEFORE the URL shape because it is
# unambiguous where a hand-set GITHUB_API_URL might not be.
check "gitea marker alone is enough" 0 "" eq forgejo detect_in GITEA_ACTIONS=true
check "gitea marker outranks a github-shaped api url" 0 "" \
eq forgejo detect_in GITEA_ACTIONS=true GITHUB_API_URL=https://api.github.com
# --- forge_detect: refusing to guess ------------------------------------
# Nothing to read is NOT "probably github". A wrong guess here is exactly
# the silent blind sweep #188 measured; the whole point of this file is
# that an unknown forge is loud.
check "bare environment refuses" 1 "cannot determine which forge" detect_in
check "refusal names what it looked at" 1 "GITHUB_API_URL" detect_in
check "refusal names the escape hatch" 1 "CEREMONY_FORGE" detect_in
# --- forge_preflight: the must-fail case --------------------------------
# The Test plan's named must-fail: "point it at a Forgejo instance with a
# GitHub-shaped client and assert it refuses loudly rather than sweeping
# blind."
#
# Measured before this guard existed, against this instance:
# labels-scope exit 0 "no .github/labeler.yml — nothing to derive" (it exists)
# labels-reconcile exit 0 "reconciled." (zero PRs read)
# issueflow-reconcile exit 1 "unexpected end of JSON input"
# Two of three swept blind and reported success. gh present made it WORSE:
# it silenced the one loud failure. Hence: refuse before the sweep, not
# after — and say which forge and which client, so the log answers "why"
# without a second run (#101 D5's report-do-not-diagnose, one layer up).
check "forgejo + gh-only client refuses" 1 "cannot speak" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
check "the refusal names the forge" 1 "forgejo" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
# The interpolated client, not the bare string "gh" — which also appears in
# the explanatory prose ("gh speaks GitHub's /api/v3…"), so the old assertion
# stayed green even if the client name never reached the message. Same class
# as the "names both totals" weakness the panel caught in the backend suite
# (#4727 / #4734); found by auditing this file for the same shape.
check "the refusal names the client" 1 "the 'gh' client cannot speak it" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
# The refusal must be actionable, not merely loud: #188's whole cost was a
# red check that told nobody what to do.
check "the refusal names the issue" 1 "#188" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
# --- forge_preflight: the passing pairs ---------------------------------
check "github + gh passes" 0 "" preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=gh
check "forgejo + rest passes" 0 "" preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=rest
# The mirror of the must-fail: a Forgejo client against GitHub is just as
# wrong, and symmetric refusal is cheaper than explaining why only one
# direction is checked.
check "github + rest refuses" 1 "cannot speak" \
preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=rest
# --- forge_preflight: it refuses when the forge itself is unknown --------
# Detection failure must not be swallowed into a pass — that would restore
# the blind sweep through the back door.
check "unknown forge fails the preflight" 1 "cannot determine which forge" preflight_in
# --- forge_client: what each backend actually needs ----------------------
# Measured in the runner image the Forgejo instance actually uses
# (ghcr.io/catthehacker/ubuntu:act-22.04, task 278): gh ABSENT, stoke
# ABSENT, curl and jq present. So the forgejo backend is REST-over-curl by
# necessity, not preference — this is the measurement that retired option
# A (port to stoke) as well: the CLI is not on the runner either.
check "github backend wants gh" 0 "" eq gh forge_client github
check "forgejo backend wants rest" 0 "" eq rest forge_client forgejo
check "forge_client refuses an unknown backend" 1 "unknown forge" forge_client gitlab
# The missing-binary arm, hermetically: an empty PATH has no client at all.
check "a forge whose client is not installed refuses" 1 "is not installed" \
preflight_bare CEREMONY_FORGE=github
check "...and names the missing binary" 1 "gh" preflight_bare CEREMONY_FORGE=github
check "...the forgejo arm names its own tools" 1 "curl" preflight_bare CEREMONY_FORGE=forgejo
summary

View file

@ -34,13 +34,3 @@ summary() {
[ "$FAIL" -eq 0 ]
}
# forge_stub_path <endpoint> — strip the paging parameters the forge shim
# injects (#188) so a fixture keyed on the logical endpoint still matches.
# The page size moved OUT of the call sites and into the backend, which means
# every stub now sees "?per_page=100" appended to a paginated read; without
# this, a fixture lookup misses and the stub answers "unreadable", which the
# production code correctly reports as a degraded read.
forge_stub_path() {
printf '%s' "$1" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g'
}

View file

@ -4,12 +4,6 @@ set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
source "$ROOT/test/harness.sh"
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
# stubs already intercept (#188). Without this the verbs are undefined.
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
forge_select github
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
source "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
@ -149,23 +143,13 @@ check "...so its post-merge decision is KEEP" 0 "KEEP" \
post_merge_decision "$(post_merge_pr_for_issue 999)" false false \
<<<"- [ ] verify after merge"
MERGED_REF_PR_RECORDS=""
# merged_at must stay a field on the PR list already fetched: the record shape
# gets richer, the request count does not (#242). The gathers are two REST
# list reads since #188 — Forgejo serves no GraphQL — so the pin counts those.
# shellcheck disable=SC2016 # the pin is the literal source text, not an expansion
check "the sweep still issues exactly two PR-list gathers" 0 "2" \
grep -c 'forge_api --paginate "repos/$REPO/pulls?state=' \
# mergedAt must stay a field on the merged-PR node set already fetched: the
# record shape gets richer, the request count does not (#242).
check "the sweep still issues exactly two GraphQL queries" 0 "2" \
grep -c 'gh api graphql' "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
check "...with mergedAt selected on the merged-PR node it already fetched" 0 "" \
grep -qF 'nodes { number mergedAt body }' \
"$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
check "...with merged_at selected on the PR list it already fetched" 0 "" \
grep -qF 'select(.merged_at != null)' \
"$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
# Comment lines are excluded on purpose: this file's own header explains at
# length what GraphQL used to do here, and a pin that counted prose would go
# red on its own documentation (@kimi-reviewer-andresmgsl, #198).
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "...and no GraphQL survives in executable code on this surface" 0 "0" \
bash -c 'grep -n graphql "$1" | grep -vcE "^[0-9]+: *#" || true' \
_ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
check "one closed offsite PR nudges" 0 "NUDGE" offsite_resolved_decision <<<"CLOSED"
check "two closed offsite PRs nudge" 0 "NUDGE" offsite_resolved_decision <<< $'CLOSED\nCLOSED'
@ -322,7 +306,7 @@ chmod +x "$TMP/gh"
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
check "cross-repo warning is idempotent across two sweeps" 0 "" \
env PATH="$TMP:$PATH" GH_COMMENTS="$TMP/comments" bash -c \
'source "$1"; forge_select github; REPO=heavy-duty/ceremony
'source "$1"; REPO=heavy-duty/ceremony
ensure_comment 99 blocked-cross-repo "cross-repo warning"
ensure_comment 99 blocked-cross-repo "cross-repo warning"
test "$(grep -cF "<!-- issueflow:blocked-cross-repo -->" "$GH_COMMENTS")" -eq 1' \
@ -380,7 +364,6 @@ GH_STUB_STDERR="gh: We couldn't respond to your request in time. (HTTP 504)"
GH_STUB_ERROR_BODY='{"message":"We could not respond to your request in time.","documentation_url":"https://docs.github.com/rest"}'
export GH_STUB_STDERR # the PATH-stubbed gh of the executable runs reads it too
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
issue_stub_gh() {
if [ "$1" = api ]; then
shift
@ -393,7 +376,6 @@ issue_stub_gh() {
esac
shift
done
endpoint="$(forge_stub_path "$endpoint")"
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
printf '%s\n' "$endpoint" >>"$TMP/api-calls"
# A `.http-error` sentinel is the real 5xx (#247): `gh api` prints the
@ -454,10 +436,6 @@ issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 false|closing|refs, $5 m
# timestamp (#254): sweep, then sweep again a day later and watch the
# nudge stay silent because the comment it posted is now the activity.
REPO="${PROBE_REPO:-owner/repo}" NOW="${PROBE_NOW:-$INOW}"
# The nudge links the issue on the forge the job is running on, so the
# probe stands on one. PROBE_SERVER_URL lets a case assert the link moves
# with the forge instead of being written into the source (#198).
GITHUB_SERVER_URL="${PROBE_SERVER_URL:-https://github.com}"
ISSUE_LABELS="$2"
ISSUE_JSON="$(jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \
--argjson assignees "$assignee_json" --arg body "$body" \
@ -480,7 +458,6 @@ issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 false|closing|refs, $5 m
printf '%s\t%s\t%s\n' "$1" "$pr" "$merged_at"
done)"
run() { "$@"; }
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { issue_stub_gh "$@"; }
reconcile_issue "$1" 2>&1
)
@ -845,7 +822,6 @@ jq -n --arg at "$(iso_at $((INOW - 5 * 86400)))" \
>"$(cfix 95)"
jq -n --arg at "$(iso_at $((INOW - 3600)))" \
'[{"event":"assigned","created_at":$at}]' >"$(tfix 95)"
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
two_clocks="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; }
printf '%s %s\n' \
"$(last_issue_activity 95 "$(iso_at $((INOW - 10 * 86400)))")" \
@ -1259,7 +1235,6 @@ check "...and leaves the sweep running without an alarming log" 1 "" \
# posted, and its reason line is a deliberate report rather than an alarm
# (D4). D8 leaves offsite_timeline's own silence alone, so it is pinned here
# directly rather than through a probe that can no longer reach it.
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
offsite_timeline_probe() { ( REPO=owner/repo; gh() { issue_stub_gh "$@"; }; offsite_timeline "$1" ); }
check "an unreadable offsite timeline yields nothing and still fails closed" 1 "" \
offsite_timeline_probe 32
@ -1437,7 +1412,6 @@ jq -n --arg flag "$(iso_at $((INOW - 8 * 86400)))" \
jq -n --arg at "$(iso_at $((INOW - 8 * 86400 - 60)))" \
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc24","body":"question, options, recommendation"}]' \
>"$(cfix 24)"
# shellcheck disable=SC2317 # reached through the forge backend (#188)
churn_last="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; }
last_issue_activity 24 "$(iso_at $((INOW - 10 * 86400)))") )"
check "last activity ignores the 2-day-old label churn" 0 "" \
@ -1562,7 +1536,6 @@ printf '%s\n' "$GH_STUB_ERROR_BODY" >"$TMP/repos_owner_repo_issues_61.json.http-
pass_probe() { # $1 issue; $2 non-empty makes reconcile_issue crash
(
REPO=owner/repo
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { issue_stub_gh "$@"; }
[ -z "${2:-}" ] || reconcile_issue() { return 9; }
SKIPPED_COUNT=0
@ -1611,14 +1584,11 @@ if [ "$1" = api ]; then
esac
shift
done
# Inlined, not the suite's helper: this stub is a standalone executable on
# PATH and cannot see a shell function from the test process. Strips the
# paging the forge shim injects so fixtures stay keyed on the logical
# endpoint (#188).
endpoint="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
file="$GH_FIXTURES/$(printf '%s' "$endpoint" | tr '/?&=' '____').json"
if [ "$endpoint" = graphql ]; then
case "$query" in
*'states: OPEN'*) file="$GH_FIXTURES/graphql-open.json" ;;
*'states: MERGED'*) file="$GH_FIXTURES/graphql-merged.json" ;;
esac
fi
# `.http-error` is the real 5xx (#247): the response body — GitHub's JSON
@ -1643,22 +1613,14 @@ echo "gh stub: unexpected call: gh $*" >&2
exit 97
EOF
chmod +x "$ARRIVAL/stub/gh"
# The two PR gathers were GraphQL until #188; they are REST now, so the
# fixtures are the /pulls list both forges return. Empty by default — the
# merged-Refs case below fills the closed one.
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$ARRIVAL/fixtures/graphql-open.json"
cp "$ARRIVAL/fixtures/graphql-open.json" "$ARRIVAL/fixtures/graphql-merged.json"
arrival_fixture() { printf '%s\n' "$1" >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json"; }
# CEREMONY_FORGE=github below, and at the executable-sweep driver further
# down: these fixtures ARE a GitHub board (a gh stub on PATH answering
# /api/v3 shapes), so the suite says so at the forge boundary rather than
# letting main()'s preflight infer a forge from whatever env the CI job
# leaked (#188). Stubbing `gh` and staying silent about the forge is the
# boundary this issue moved.
arrival_run() {
: >"$ARRIVAL/fixtures/edits"
env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \
CEREMONY_FORGE=github \
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
EVENT_NAME=issues EVENT_ACTION=opened EVENT_ISSUE=91 \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
@ -1693,44 +1655,43 @@ check "...stands down without minting" 1 "" test -s "$ARRIVAL/fixtures/edits"
check "...and the sweep still runs" 0 "" \
grep -qF 'issueflow: reconciled.' <<<"$pr_out"
# The merged-Refs transition must survive the executable's set -e path too.
# Keep this at main() granularity: the PR gather and loop are the code a
# sourced decision probe cannot exercise (#91's lesson).
#
# merged_at is what makes this PR merged rather than merely closed — the
# REST replacement for GraphQL's states: MERGED filter (#188). Both forges
# return the field, and both return null on a closed-unmerged PR.
# Exercise both directions through main(): a merged-Refs transition still
# fires without a linked open PR, then the open-body gather suppresses it.
# A sourced decision probe cannot exercise the GraphQL gather and loop
# (#91's lesson).
printf '%s\n' \
'[{"number":400,"body":"Refs #40","merged_at":"2026-07-30T00:00:00Z"},{"number":401,"body":"Refs #40","merged_at":null}]' \
>"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
'{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$ARRIVAL/fixtures/graphql-open.json"
printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":400,"mergedAt":"2026-07-30T19:05:16Z","body":"Refs #40","closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$ARRIVAL/fixtures/graphql-merged.json"
printf '[{"number":40}]\n' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json"
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open_per_page_100.json"
jq -n --arg at "$(iso_at "$INOW")" \
'{number:40,user:{login:"triage-one"},created_at:$at,body:"- [x] built\n- [ ] verify live label",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_40.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_40_comments.json"
: >"$ARRIVAL/fixtures/edits"
transition_out="$(
env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
)"
transition_rc=$?
check "an executable sweep with no linked open PR exits 0" 0 "" \
test "$transition_rc" -eq 0
check "...reaches the transition through the REST gather and the issue loop" 0 "" \
check "...reaches the transition through GraphQL and the issue loop" 0 "" \
grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$transition_out"
check "...and performs the release edit from the executable path" 0 "" \
grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \
"$ARRIVAL/fixtures/edits"
printf '%s\n' \
'[{"number":401,"body":"Refs #40","draft":false,"merged_at":null}]' \
>"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json"
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":401,"body":"Refs #40","isDraft":false,"closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$ARRIVAL/fixtures/graphql-open.json"
: >"$ARRIVAL/fixtures/edits"
subprocess_out="$(
env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \
CEREMONY_FORGE=github \
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
)"
@ -1743,81 +1704,62 @@ check "...performs no release edit" 1 "" \
grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \
"$ARRIVAL/fixtures/edits"
# -- the issue/PR discriminator is not `has("pull_request")` ---------------
# Measured on the two list endpoints, 2026-08-02 (#188):
#
# GitHub plain issues OMIT the key -> 0 of 9 carried it
# Forgejo every entry HAS the key -> 10 of 10, valued null on issues
#
# So `select(has("pull_request") | not)` selected ZERO issues on Forgejo and
# the sweep printed "reconciled." over an untouched board — the blind sweep
# again, one layer in, and invisible because the log looks identical to a
# legitimately empty queue. Caught by rehearsing DRY_RUN against rig's live
# board, not by any unit test. `.pull_request == null` is true for an issue on
# both forges (an absent key reads as null) and false for a PR on both.
disc() { jq -e "$1" >/dev/null 2>&1 && echo issue || echo pr; }
check "github-shaped issue (key absent) reads as an issue" 0 "issue" \
bash -c 'echo "{\"number\":1}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
check "forgejo-shaped issue (key present, null) reads as an issue" 0 "issue" \
bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
check "a PR reads as a PR on either shape" 0 "pr" \
bash -c 'echo "{\"number\":1,\"pull_request\":{\"url\":\"x\"}}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
# The old test, kept as the must-fail: it disagrees with the new one on the
# forgejo shape, which is exactly the bug.
check "the old has() test misreads a forgejo issue as a PR" 0 "pr" \
bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e "has(\"pull_request\") | not" >/dev/null && echo issue || echo pr'
# -- the OPEN-pull gather, at main() granularity ----------------------------
# The closed/merged half above proves one REST path; this proves the other,
# which is a DIFFERENT pipeline: `.body | @base64` -> base64 -d ->
# closes_references -> OPEN_PR_ISSUES. The 27 parser cases in
# test/closes_references.test.sh cannot reach it — they test the parser, not
# the encoding and wiring around it (#188).
#
# Both directions in ONE sweep, so neither assertion can pass vacuously:
# #50 IS closed by an open PR -> the claim is KEPT, no reclaim edit
# #51 is closed by nothing -> the claim is RECLAIMED
# A break anywhere in the pipeline reclaims #50 too, and the first check
# fails. A break that reclaims nothing fails the second.
#
# `Closes #50` sits on the THIRD line of the body on purpose. jq's @tsv
# escapes a newline to a literal backslash-n, so a line-oriented parser
# reading an @tsv-encoded body sees one line and drops everything after the
# first — with the declaration on line 3, that defect reclaims #50 and this
# case goes red. On line 1 it would pass either way, which is the definition
# of a vacuous test.
printf '%s\n' \
'[{"number":500,"body":"## Summary\nSome prose about the work.\nCloses #50\n","merged_at":null}]' \
>"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
printf '[{"number":50},{"number":51}]\n' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json"
# Both claims are two hours quiet against a ONE-hour stale bound, so the
# reclaim clock has genuinely expired for whichever of them no open PR
# rescues. The clock is injected rather than real: INOW is a fixed epoch in
# 2033, so without ISSUEFLOW_NOW the subprocess reads its own wall clock,
# dates these claims in the future, and both survive on a negative age —
# which is a green test proving nothing.
for n in 50 51; do
jq -n --arg at "$(iso_at $((INOW - 7200)))" --argjson n "$n" \
'{number:$n,user:{login:"triage-one"},created_at:$at,body:"- [x] built",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_$n.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_${n}_comments.json"
done
# The query selects every OPEN PR and deliberately does not select isDraft;
# this fixture-only flip documents that draft identity cannot narrow the set.
sed 's/"isDraft":false/"isDraft":true/' "$ARRIVAL/fixtures/graphql-open.json" \
>"$ARRIVAL/fixtures/graphql-open.json.tmp"
mv "$ARRIVAL/fixtures/graphql-open.json.tmp" "$ARRIVAL/fixtures/graphql-open.json"
: >"$ARRIVAL/fixtures/edits"
open_pr_out="$(
env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \
CEREMONY_FORGE=github ISSUEFLOW_NOW="$INOW" ISSUEFLOW_STALE_HOURS=1 \
draft_transition_out="$(
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
)"
check "the open-pull gather completes" 0 "" \
grep -qF 'issueflow: reconciled.' <<<"$open_pr_out"
check "a claim closed by an open PR survives the base64 round trip" 1 "" \
grep -qE 'issue edit 50 .*--remove-label claimed' "$ARRIVAL/fixtures/edits"
check "...while the claim no open PR closes is reclaimed in the same sweep" 0 "" \
grep -qE 'issue edit 51 .*--remove-label claimed --add-label ready' \
"$ARRIVAL/fixtures/edits"
check "a draft Refs-bodied PR suppresses post-merge transition identically" 1 "" \
grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$draft_transition_out"
# The same body linkage protects the reclaim clock even when no Refs-linked
# PR has merged. This is the derived half of crew#321's destructive shape.
printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":411,"body":"Refs #41","isDraft":false,"closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$ARRIVAL/fixtures/graphql-open.json"
printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$ARRIVAL/fixtures/graphql-merged.json"
printf '[{"number":41}]\n' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open_per_page_100.json"
jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \
'{number:41,user:{login:"triage-one"},created_at:$at,body:"- [ ] build",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_41.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_41_comments.json"
jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \
'[{"event":"assigned","created_at":$at}]' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_41_timeline.json"
: >"$ARRIVAL/fixtures/edits"
reclaim_out="$(
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
ISSUEFLOW_NOW="$INOW" REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
)"
reclaim_rc=$?
check "an open Refs-bodied PR suppresses stale reclaim" 0 "" test "$reclaim_rc" -eq 0
check "...keeps the quiet live claim" 1 "" \
grep -qF '#41: stale claim reclaimed -> ready' <<<"$reclaim_out"
# Drafts are live claim evidence by the same OPEN query (D4). The query does
# not select isDraft, so this fixture-only flip deliberately leaves production
# input byte-identical and guards the absence of a draft/readiness predicate.
sed 's/"isDraft":false/"isDraft":true/' "$ARRIVAL/fixtures/graphql-open.json" \
>"$ARRIVAL/fixtures/graphql-open.json.tmp"
mv "$ARRIVAL/fixtures/graphql-open.json.tmp" "$ARRIVAL/fixtures/graphql-open.json"
: >"$ARRIVAL/fixtures/edits"
draft_out="$(
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
ISSUEFLOW_NOW="$INOW" REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
)"
check "a draft Refs-bodied PR suppresses stale reclaim identically" 1 "" \
grep -qF '#41: stale claim reclaimed -> ready' <<<"$draft_out"
# D2 preserved: only the deliberate stand-downs changed; a genuine failure on
# the arrival path still kills the run loudly.
@ -1841,8 +1783,8 @@ SWEEP="$TMP/sweep"
mkdir -p "$SWEEP"
printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$SWEEP/repos_owner_repo_pulls_state_open.json"
cp "$SWEEP/repos_owner_repo_pulls_state_open.json" "$SWEEP/repos_owner_repo_pulls_state_closed.json"
>"$SWEEP/graphql-open.json"
cp "$SWEEP/graphql-open.json" "$SWEEP/graphql-merged.json"
# 70: the 504 with a JSON error body on the per-issue read.
printf '%s\n' "$GH_STUB_ERROR_BODY" >"$SWEEP/repos_owner_repo_issues_70.json.http-error"
# 71: healthy, and carrying no queue label — so if the sweep reaches it, it
@ -1854,10 +1796,10 @@ printf '%s\n' \
# as it does on the 504. The shape check is the only thing that catches it.
printf 'null\n' >"$SWEEP/repos_owner_repo_issues_72.json"
sweep_board() { printf '%s\n' "$1" >"$SWEEP/repos_owner_repo_issues_state_open.json"; }
sweep_board() { printf '%s\n' "$1" >"$SWEEP/repos_owner_repo_issues_state_open_per_page_100.json"; }
sweep_run() {
: >"$SWEEP/edits"
env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$SWEEP" ISSUEFLOW_NOW="$INOW" \
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$SWEEP" ISSUEFLOW_NOW="$INOW" \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
}
@ -1866,7 +1808,7 @@ sweep_run() {
# cannot be inferred from an empty result: its status is the only fact that
# separates an unreadable board from a clean one. Drive both through main(),
# including gh's partial-pagination shape where stdout is non-empty on error.
board_fixture="$SWEEP/repos_owner_repo_issues_state_open.json"
board_fixture="$SWEEP/repos_owner_repo_issues_state_open_per_page_100.json"
printf '%s\n' "$GH_STUB_ERROR_BODY" >"$board_fixture.http-error"
board_504_out="$(sweep_run)"
board_504_rc=$?
@ -1971,8 +1913,8 @@ check "...and says nothing about skipping" 1 "" \
# ---------------------------------------------------------------------------
ORDER="$TMP/order"
mkdir -p "$ORDER"
cp "$SWEEP/repos_owner_repo_pulls_state_open.json" "$SWEEP/repos_owner_repo_pulls_state_closed.json" "$ORDER/"
order_board() { printf '%s\n' "$1" >"$ORDER/repos_owner_repo_issues_state_open.json"; }
cp "$SWEEP/graphql-open.json" "$SWEEP/graphql-merged.json" "$ORDER/"
order_board() { printf '%s\n' "$1" >"$ORDER/repos_owner_repo_issues_state_open_per_page_100.json"; }
order_fixture() { # $1 issue, $2 labels JSON, $3 body
jq -n --argjson n "$1" --argjson labels "$2" --arg body "${3:-}" \
--arg at "$(iso_at $((INOW - 10 * 86400)))" \
@ -1982,7 +1924,7 @@ order_fixture() { # $1 issue, $2 labels JSON, $3 body
}
order_run() {
: >"$ORDER/edits"
env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ORDER" ISSUEFLOW_NOW="$INOW" \
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ORDER" ISSUEFLOW_NOW="$INOW" \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
}
@ -2277,8 +2219,8 @@ check "...which admits ready and claimed, and nothing else" 0 "" \
# to a board-wide check.
BOARD="$TMP/board"
mkdir -p "$BOARD"
cp "$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json" "$BOARD/repos_owner_repo_pulls_state_open.json"
cp "$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json" "$BOARD/repos_owner_repo_pulls_state_closed.json"
cp "$ARRIVAL/fixtures/graphql-open.json" "$BOARD/graphql-open.json"
cp "$ARRIVAL/fixtures/graphql-merged.json" "$BOARD/graphql-merged.json"
board_issue() { # $1 number, $2 labels(csv), $3 title, $4 body, $5 assignee count
local labels_json
@ -2297,7 +2239,7 @@ board_assemble() { # numbers… -> the open-issue list, with fresh comment threa
for n in "$@"; do printf '[]\n' >"$BOARD/repos_owner_repo_issues_${n}_comments.json"; done
# shellcheck disable=SC2016 # the filename expansion belongs to the loop below
for n in "$@"; do cat "$BOARD/repos_owner_repo_issues_$n.json"; done \
| jq -sc . >"$BOARD/repos_owner_repo_issues_state_open.json"
| jq -sc . >"$BOARD/repos_owner_repo_issues_state_open_per_page_100.json"
}
flag_count() { # $1 = collision|window, $2 = a sweep's output
@ -2306,7 +2248,7 @@ flag_count() { # $1 = collision|window, $2 = a sweep's output
board_run() {
: >"$BOARD/edits"
env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$BOARD" ISSUEFLOW_NOW="$INOW" \
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$BOARD" ISSUEFLOW_NOW="$INOW" \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
}
@ -2493,7 +2435,7 @@ check "...while the collision flag beside it is unaffected" 0 \
# to a builder opening a PR. It fires on the board, and only on the board.
printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":285,"body":"","closingIssuesReferences":{"nodes":[{"number":253}]}},{"number":286,"body":"","closingIssuesReferences":{"nodes":[{"number":284}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$BOARD/repos_owner_repo_pulls_state_open.json"
>"$BOARD/graphql-open.json"
board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1
# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown
board_issue 284 claimed 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' '' 1
@ -2524,7 +2466,7 @@ check "a claimed carrier with an open PR draws the ready issue's flag too" 0 \
# holding a builder AND a review round is that competition realized.
printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":403,"body":"","closingIssuesReferences":{"nodes":[{"number":402}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$BOARD/repos_owner_repo_pulls_state_open.json"
>"$BOARD/graphql-open.json"
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
'Blocked by #253.'
board_issue 253 claimed 'issueflow-reconcile — a member holding the window open' '' 1
@ -2554,7 +2496,7 @@ flagged once" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \
board_assemble_keep() { # board_assemble without wiping the seeded threads
local n
for n in "$@"; do cat "$BOARD/repos_owner_repo_issues_$n.json"; done \
| jq -sc . >"$BOARD/repos_owner_repo_issues_state_open.json"
| jq -sc . >"$BOARD/repos_owner_repo_issues_state_open_per_page_100.json"
}
board_assemble_keep 284
resolved_out="$(board_run)"
@ -2572,7 +2514,7 @@ check "an unchanged state recreated is silent — D4's stated boundary" 1 "" \
# flag if membership were read wrong, which is why it is here.
printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$BOARD/repos_owner_repo_pulls_state_open.json"
>"$BOARD/graphql-open.json"
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
'Blocked by #293, #307.'
board_issue 293 claimed 'issueflow-reconcile — the sweep flags what the window and collision rules forbid' '' 1
@ -2593,11 +2535,8 @@ check "...and still reports a whole pass" 0 'issueflow: reconciled.' \
# here rather than left to review — the shape lib/ruling.sh already uses for
# #50 D9. reconcile_opened_issue is deliberately exempt: it runs outside the
# per-issue subshell, under live errexit, and stages nothing (#247 D8).
# The verbs are the shim's now, not gh's (#188, and every remaining call site
# ported by #198) — pinning `gh issue` here would pin a string this surface no
# longer contains and pass vacuously forever.
mutation_calls() {
grep -nE '(^|[^_[:alnum:]])forge_issue_(edit|comment)' \
grep -nE '(^|[^_[:alnum:]])gh issue (edit|comment)' \
"$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" "$ROOT/lib/ruling.sh" \
| grep -vE '^\S+:[0-9]+: *#' || true
}
@ -2605,72 +2544,9 @@ mutation_calls() {
check "every issue mutation on this surface goes through run()" 0 "" \
bash -c 'while IFS= read -r line; do
[ -n "$line" ] || continue
case "$line" in *"run forge_issue_"*) ;; *) printf "unstaged mutation: %s\n" "$line"; exit 1 ;; esac
case "$line" in *"run gh issue "*) ;; *) printf "unstaged mutation: %s\n" "$line"; exit 1 ;; esac
done <<<"$1"' _ "$(mutation_calls)"
check "...and the pin sees the call sites it is guarding" 0 "" \
test "$(mutation_calls | wc -l)" -ge 8
# -- the gather->consumer seam, at main() granularity (#198) -----------------
# Every MERGED_REF_PR_RECORDS fixture above assigns the variable DIRECTLY, so
# they prove post_merge_pr_for_issue given three columns and pass unchanged if
# the gather emits two. The seam the 0.6.0 resolution actually decides is the
# one none of them span, so it is driven here through the real REST gather.
# 1. The out-of-order pair. crew#176's shape: the HIGHER PR number merged
# EARLIER, so number order and merge order disagree. A two-column emit
# leaves $3 empty, every sort key ties, and the tie-break silently answers
# the highest number — the #242 fix undone with no error and no red.
printf '%s\n' \
'[{"number":184,"body":"Refs #42","merged_at":"2026-07-30T19:05:16Z"},{"number":182,"body":"Refs #42","merged_at":"2026-07-30T19:05:18Z"}]' \
>"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json"
printf '[{"number":42}]\n' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json"
jq -n --arg at "$(iso_at "$INOW")" \
'{number:42,user:{login:"triage-one"},created_at:$at,body:"- [ ] verify after merge",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_42.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_42_comments.json"
: >"$ARRIVAL/fixtures/edits"
order_out="$(
env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \
ISSUEFLOW_NOW="$INOW" REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
)"
check "the out-of-order gather still transitions the issue" 0 "" \
grep -qF '#42: merged Refs PR -> post-merge; claim released' <<<"$order_out"
# The marker carries the deliverable's number, so it is where the answer is
# observable from outside. 182 merged LAST; 184 is the higher number.
check "...naming the PR that merged last, not the highest-numbered one" 0 "" \
grep -qF 'post-merge-transition-pr-182' "$ARRIVAL/fixtures/edits"
check "...and never the higher number that merged earlier" 1 "" \
grep -qF 'post-merge-transition-pr-184' "$ARRIVAL/fixtures/edits"
# 2. The multi-line open body. open_pr_issues is line-oriented, so the decoded
# body must arrive one BODY row per PHYSICAL line. Handed over as a single
# record it loses every declaration including the first, and the claim is
# reclaimed under a live PR. `Refs #43` sits on line 3 for that reason: on
# line 1 the case would pass either way, which is a vacuous test.
printf '%s\n' \
'[{"number":430,"body":"## Summary\nSome prose about the work.\nRefs #43\nMore prose after it.","merged_at":null}]' \
>"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
printf '[{"number":43}]\n' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json"
jq -n --arg at "$(iso_at $((INOW - 7200)))" \
'{number:43,user:{login:"triage-one"},created_at:$at,body:"- [ ] build",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_43.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_43_comments.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_43_timeline.json"
: >"$ARRIVAL/fixtures/edits"
multiline_out="$(
env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \
ISSUEFLOW_NOW="$INOW" ISSUEFLOW_STALE_HOURS=1 \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
)"
check "the multi-line gather completes" 0 "" \
grep -qF 'issueflow: reconciled.' <<<"$multiline_out"
check "a Refs off the first line of an open body still rescues the claim" 1 "" \
grep -qE 'issue edit 43 .*--remove-label claimed' "$ARRIVAL/fixtures/edits"
summary

View file

@ -13,19 +13,6 @@ export LC_ALL=C
cd "$(dirname "$0")/.."
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh
. actions/labels-reconcile/labels-reconcile.sh
# This suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
# now, and forge_label_delete/forge_issue_comment/... resolve to the gh
# invocations those stubs already intercept (#188). main() selects a backend
# itself, but these probes call the pure functions directly, so the suite has
# to say which forge it is standing in.
forge_select github
# This suite predates test/harness.sh and carries its own expect(), so it does
# not get harness.sh's helper — define it here rather than pulling in a second
# assertion vocabulary. Strips the paging the shim injects so a fixture keyed
# on the logical endpoint still matches (#188).
forge_stub_path() {
printf '%s' "$1" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g'
}
RTMP="$(mktemp -d)"
trap 'rm -rf "$RTMP"' EXIT
@ -597,7 +584,6 @@ reconcile_probe() { # $1 = REPO_LABELS content → the log lines reconcile_pr em
MERGEABLE=MERGEABLE CHECKS=SUCCESS
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
run() { :; } # swallow mutations
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { :; } # no network
reconcile_pr 777 2>&1
)
@ -696,7 +682,6 @@ ruling_probe() { # $1 = the PR's labels → the log lines reconcile_pr emits
MERGEABLE=MERGEABLE CHECKS=SUCCESS
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
run() { :; } # swallow mutations
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { :; } # no network
reconcile_pr 888 2>&1
)
@ -749,7 +734,6 @@ ruling_sweep_probe() { # $1 labels, $2 PR, $3 assignees, $4 requested, $5 activi
PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" \
--argjson assignees "$assignee_json" '{created_at: $at, assignees: $assignees}')"
run() { "$@"; } # mutations reach the stub and are recorded, not swallowed
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() {
if [ "$1" = api ]; then
shift
@ -762,7 +746,6 @@ ruling_sweep_probe() { # $1 labels, $2 PR, $3 assignees, $4 requested, $5 activi
esac
shift
done
endpoint="$(forge_stub_path "$endpoint")"
file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
printf '%s\n' "$endpoint" >>"$RTMP/api-calls"
[ ! -f "$file.error" ] || return 1
@ -886,10 +869,6 @@ blind_main_probe() {
GITHUB_EVENT_NAME=schedule
REPO=owner/repo
LABELS_CONF=.github/labels.conf
# This probe IS a GitHub board — say so at the forge boundary rather
# than leaving main()'s preflight to infer one (#188).
CEREMONY_FORGE=github
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() {
if [ "$1" = label ] && [ "$2" = list ]; then
core_label_rows | cut -d'|' -f1
@ -945,13 +924,7 @@ unrequested_main_probe() { # $1 = read | denied, the head-commit read's outcome
GITHUB_EVENT_NAME=schedule
REPO=owner/repo
LABELS_CONF=.github/labels.conf
# main() preflights the forge before it reads anything, so a probe that
# drives main() has to say which forge it is standing in rather than
# leaving the preflight to infer one from an empty environment (#188).
# The gh() stub below IS the github backend's boundary.
CEREMONY_FORGE=github
UMODE="$1"
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() {
if [ "$1" = label ] && [ "$2" = list ]; then core_label_rows | cut -d'|' -f1; return 0; fi
if [ "$1" = pr ] && [ "$2" = list ]; then printf '303\n'; return 0; fi
@ -1050,10 +1023,10 @@ expected_upserts="$({ core_label_rows; configured_label_rows .github/labels.conf
)
expect "a dispatch deletes the six in the same run as the upserts" \
"$RETIRED_WANT" \
"$(sed -n 's/^forge_label_delete \(.*\)$/\1/p' "$BOOT/happy")"
"$(sed -n 's/^gh label delete \(.*\) -R owner\/repo --yes$/\1/p' "$BOOT/happy")"
expect "...and the recorded upsert set is unchanged from today's" \
"$expected_upserts" \
"$(sed -n 's/^forge_label_create \([^ ]*\) .*/\1/p' "$BOOT/happy")"
"$(sed -n 's/^gh label create \([^ ]*\) .*/\1/p' "$BOOT/happy")"
# -- a missing label is success: gh exits non-zero with not-found, and the
# guard keeps that from aborting the dispatch. Red without the guard.
@ -1124,7 +1097,7 @@ boot_dry_probe() {
}
dry_out="$(boot_dry_probe)"
expect "DRY_RUN narrates each deletion" \
6 "$(grep -c '^labels: DRY_RUN: forge_label_delete' <<<"$dry_out")"
6 "$(grep -c '^labels: DRY_RUN: gh label delete' <<<"$dry_out")"
expect "...and performs none" \
no "$(test -f "$BOOT/dry-real" && echo yes || echo no)"
@ -1151,7 +1124,6 @@ printf 'panel=bot-a bot-b bot-c\n' >"$EXEC/labels.conf"
exec_env() { # $1 = event name → the real script, executed under the PATH stub
: >"$EXEC/record"
env PATH="$EXEC/stub:$PATH" GH_RECORD="$EXEC/record" \
CEREMONY_FORGE=github \
REPO=owner/repo LABELS_CONF="$EXEC/labels.conf" GITHUB_EVENT_NAME="$1" \
bash actions/labels-reconcile/labels-reconcile.sh
}
@ -1454,52 +1426,5 @@ sed 's/^panel=.*/panel=/' .github/labels.conf >"$BROKEN_CONF"
expect "...and a malformed panel= line in that same file is refused, not passed" \
PARSE:1 "$(live_panel_probe "$BROKEN_CONF")"
# ---------------------------------------------------------------------------
# outstanding_requests — the portable "who still owes a verdict" (#188 term 4)
#
# GitHub clears requested_reviewers when a verdict lands; Forgejo never does.
# Measured 2026-08-02: rig!140 listed all three panelists with all three
# verdicts in, and rig!146 still lists three while MERGED. Read raw on
# Forgejo, that pins a PR at state:bots-reviewing for life and stops
# blocker:unrequested from ever being true.
# ---------------------------------------------------------------------------
HEAD_SHA=head1
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" 2026-08-01T00:00:00Z)" \
"$(rev "$BOT2" CHANGES_REQUESTED head1 "" 2026-08-01T00:00:00Z)" \
"$(rev "$BOT3" APPROVED head0 "" 2026-07-01T00:00:00Z)")"
expect "a head-current approval is no longer outstanding" "" \
"$(outstanding_requests "$BOT1")"
expect "a blocking verdict is not outstanding either — it is answered" "" \
"$(outstanding_requests "$BOT2")"
# The one that matters: an approval of an OLDER head is not a verdict on this
# head, so that reviewer still owes one. Treating STALE as answered would let
# a stale round read as complete.
expect "a stale approval still owes a verdict" "$BOT3" \
"$(outstanding_requests "$BOT3")"
expect "a reviewer who never reviewed still owes one" "nobody" \
"$(outstanding_requests "nobody")"
# The Forgejo shape, end to end: the field lists all three long after every
# verdict landed. Only the stale one may survive the filter.
expect "the never-cleared forgejo field collapses to who actually owes" \
"$BOT3" "$(outstanding_requests "$BOT1
$BOT2
$BOT3")"
# The GitHub shape: the field is already accurate, so the filter is a no-op
# on the set GitHub would have produced (term 5 — behaviour unchanged).
expect "on a github-shaped field the filter removes nothing" "nobody" \
"$(outstanding_requests "nobody")"
expect "an empty request list stays empty" "" "$(outstanding_requests "")"
# The summary and the gate belong at the TRUE end of the file. They sat in the
# middle until #188: eight outstanding_requests expects were appended after
# them, so a failure there printed FAIL, was left out of the totals, and the
# suite still exited 0 (@codex-reviewer-andresmgsl #4780 item 2). Anything
# appended below this line is ungated — so nothing goes below it.
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]

View file

@ -202,52 +202,4 @@ sed 's/opened, closed/closed, opened/' "$STUB" >"$mut_stub"
check "an issue-list reorder in one file only goes red" 1 "" \
types_in_sync issues "$CALLER" "$mut_stub"
# --- #195: the conf's roster and CONTRIBUTING's roster table are one set ----
# The rot this catches: labels.conf named five identities, CONTRIBUTING named
# the same five, and none of the five existed on the forge — two files in
# perfect agreement with each other and none with reality. No offline check
# can reach the second half; what it can hold is that a roster edit touching
# one file and not the other goes red, which is the drift that turns a
# deliberate swap into a silent one.
# roster_from_conf <conf> — every identity panel= and triage-actors= name.
roster_from_conf() {
sed -nE 's/^(panel|triage-actors)=//p' "$1" | tr ' ' '\n' | sed '/^$/d' | sort -u
}
# roster_from_doc <contributing> — the identities the "### Roster" table's
# first column names. Anchored to the section rather than to the table's
# shape: another table elsewhere in the file must not be able to join the
# roster by looking like one.
# shellcheck disable=SC2016 # the backticks below are the table's, not a subshell
roster_from_doc() {
awk '/^### Roster$/ { inside = 1; next }
inside && /^#+ / { exit }
inside' "$1" |
sed -nE 's/^\| `([^`]+)`.*/\1/p' | sort -u
}
roster_in_sync() { # <conf> <contributing>
local conf="$1" doc="$2" drift
drift="$(diff <(roster_from_conf "$conf") <(roster_from_doc "$doc"))" && return 0
echo "roster drift ('<' conf only, '>' table only):" >&2
printf '%s\n' "$drift" >&2
return 1
}
CONF="$ROOT/.github/labels.conf"
CONTRIB="$ROOT/CONTRIBUTING.md"
check "the real conf and the real roster table name the same identities" 0 "" \
roster_in_sync "$CONF" "$CONTRIB"
# the failing cases, in both directions — a one-way check would have passed
# all week on the rot that produced #195
mut_conf="$TMP/mut-labels.conf" mut_contrib="$TMP/mut-contributing.md"
sed 's/^panel=/panel=ghost-bot /' "$CONF" >"$mut_conf"
check "an identity in the conf but not the table goes red" 1 "ghost-bot" \
roster_in_sync "$mut_conf" "$CONTRIB"
# shellcheck disable=SC2016 # the backticks are the table's, not a subshell
sed 's/^| `kimi-reviewer-andresmgsl`/| `ghost-bot`/' "$CONTRIB" >"$mut_contrib"
check "an identity in the table but not the conf goes red" 1 "ghost-bot" \
roster_in_sync "$CONF" "$mut_contrib"
summary

View file

@ -1,169 +0,0 @@
#!/usr/bin/env bash
# The forge-portability guard (#198, enforcing #197's acceptance bar):
#
# No runtime `gh` invocation survives outside lib/forge-github.sh, except
# in a file that declares CEREMONY_FORGE_CLIENT=gh and therefore refuses
# loudly on a forge that cannot serve it.
#
# WHY THIS FILE EXISTS, rather than the rule living in review. #188 ported
# every `gh` call site onto the shim. The 0.6.0 upstream merge put SEVEN of
# them back — not in the eighteen conflict hunks, where a resolver would have
# been forced to look, but in whole functions upstream added to files this
# tree already owned. `git merge` takes upstream's side wherever only upstream
# moved a region, so it raised no conflict and asked no question. Reviewing
# the hunks could not have caught them; four reviewers reading the same diff
# each found a different subset.
#
# The sweep runs on a Forgejo instance whose runner image carries curl, jq and
# node and has NEITHER gh NOR stoke (lib/forge-forgejo.sh's header, probe task
# 278). So a reintroduced `gh` is not a style problem — it is `gh: command not
# found` mid-sweep, or a write that silently never happens.
#
# And it is invisible to the rest of the suite by construction: the contract
# tests stub `gh` as a shell function or on PATH, so they exercise a
# reintroduced call site happily and go green. This guard reads the SOURCE,
# which is the only place the difference is visible.
#
# It is deliberately a source-level check, and deliberately the ONLY one of
# its kind: every other guard here drives behaviour. This one cannot — the
# behaviour it forbids is unobservable in a harness that provides a `gh`.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# The backend that is ALLOWED to speak gh — it is the whole point of the file.
ALLOWED_FILE='lib/forge-github.sh'
# The one exemption a FILE cannot declare for itself. A workflow has no shell
# to source lib/forge.sh from and no forge_preflight to refuse with, so the
# CEREMONY_FORGE_CLIENT escape hatch that covers actions/refs-not-closing is
# unavailable to it. `.github/workflows/labels.yml`'s trigger job dispatches
# the sweep caller with `gh workflow run`; the 0.6.0 merge introduced it
# (#209 upstream) and it is the eighth call site that merge brought in — the
# one every reviewer's `*.sh` grep missed, this one included, until this guard
# read the workflows too.
#
# It is NOT ported here, deliberately. Forgejo's dispatch route exists but
# does not answer like GitHub's: `GET /actions/workflows` 404s on this
# instance while `POST .../dispatches` returns 500 rather than a 4xx, which is
# the same mis-status class #192 is open about. Porting on that evidence would
# be guessing, and the only way to finish measuring it is to dispatch a real
# workflow run on the operator's repo. So it is named here with its reason and
# its follow-up, which is what an exemption is for — an unnamed one is just a
# hole. Remove this entry when the port lands.
EXEMPT_WORKFLOWS='.github/workflows/labels.yml'
# A file may opt out by declaring the client it speaks, which makes
# forge_preflight refuse by name on a forge that cannot serve it. Today that
# is actions/refs-not-closing, whose only gather is GraphQL and which Forgejo
# therefore cannot run at all (#199 ports it and drops the declaration).
declares_gh_client() { grep -qE '^[[:space:]]*(export[[:space:]]+)?CEREMONY_FORGE_CLIENT=gh\b' "$1"; }
# A runtime invocation, not the word. `gh` must be at a command position and
# followed by a gh subcommand — and comment lines are stripped first, because
# these surfaces document at length what gh used to do here and a guard that
# went red on its own prose would be deleted within a week
# (@kimi-reviewer-andresmgsl, #198). Nothing here reads a comment as evidence.
gh_calls() { # $1 = file → "line:code" per runtime gh invocation
# Comments are BLANKED rather than dropped, so grep -n still reports the
# file's real line numbers. Trailing comments go too, not just whole-line
# ones: a workflow's `actions: write # ...gh workflow run...` is prose
# about a call site, and YAML puts it after the code rather than before it.
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$1" \
| grep -nE '(^|[^[:alnum:]_./$-])gh[[:space:]]+(api|issue|pr|release|repo|run|search|workflow|label|auth|browse|gist|secret|variable|ruleset)\b'
}
# The surfaces that run on a forge: executables and the workflows that call
# them. test/ is excluded on purpose — a test stubbing `gh` is the harness
# doing its job, and forbidding the string there would forbid the stubs that
# make the github backend testable at all.
scanned_files() {
local f
for f in "$ROOT"/lib/*.sh "$ROOT"/actions/*/*.sh "$ROOT"/bin/* \
"$ROOT"/.github/scripts/*.sh "$ROOT"/.github/workflows/*.yml; do
[ -f "$f" ] || continue
printf '%s\n' "${f#"$ROOT"/}"
done
}
offenders() {
local rel abs
while IFS= read -r rel; do
[ "$rel" = "$ALLOWED_FILE" ] && continue
[ "$rel" = "$EXEMPT_WORKFLOWS" ] && continue
abs="$ROOT/$rel"
declares_gh_client "$abs" && continue
gh_calls "$abs" | sed "s|^|$rel:|"
done < <(scanned_files)
}
# In-process, not `bash -c`: a subshell cannot see these functions, so the
# sweep would find nothing, report empty, and pass by looking at nothing —
# the blind-sweep shape this guard exists to forbid, inside the guard itself.
no_offenders() {
local found
found="$(offenders)"
[ -z "$found" ] || { printf '%s\n' "$found" | sed 's/^/ /'; return 1; }
}
check "no runtime gh outside the github backend or a declared-client file" 0 "" \
no_offenders
# --- the guard has teeth ------------------------------------------------------
# A guard nobody has watched fail is a guard nobody is testing. These drive the
# predicates directly, because the sweep above is a property of the whole tree
# and cannot be made to fail without editing it.
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
printf '%s\n' '#!/usr/bin/env bash' 'gh api "repos/$REPO/issues/1"' >"$TMP/bad.sh"
check "a reintroduced gh api read is seen" 0 "gh api" gh_calls "$TMP/bad.sh"
printf '%s\n' '#!/usr/bin/env bash' 'run gh issue comment "$n" --body x' >"$TMP/bad2.sh"
check "a reintroduced gh issue write is seen, staged or not" 0 "gh issue" \
gh_calls "$TMP/bad2.sh"
# The exact shape the 0.6.0 merge reintroduced, indented inside a function.
printf '%s\n' '#!/usr/bin/env bash' 'f() {' \
' guarded_read bodies gh api --paginate "repos/$REPO/issues/$1/comments"' '}' \
>"$TMP/bad3.sh"
check "...including one nested in a function behind guarded_read" 0 "gh api" \
gh_calls "$TMP/bad3.sh"
printf '%s\n' '#!/usr/bin/env bash' '# gh api used to live here (#188)' \
'# run gh issue comment — retired' >"$TMP/prose.sh"
check "prose about gh is not a call site" 1 "" gh_calls "$TMP/prose.sh"
printf '%s\n' '#!/usr/bin/env bash' 'forge_api "repos/$REPO/issues/1"' \
'echo "the gh client speaks /api/v3"' >"$TMP/good.sh"
check "the shim verb is not mistaken for a call site" 1 "" gh_calls "$TMP/good.sh"
# Neighbouring identifiers must not read as the binary: `gh_calls`, `$gh`,
# a path ending in /gh, and `regh api` are all not an invocation of gh.
printf '%s\n' '#!/usr/bin/env bash' 'gh_calls() { :; }' 'regh api foo' \
'echo "$gh api"' >"$TMP/lookalike.sh"
check "lookalike identifiers are not call sites" 1 "" gh_calls "$TMP/lookalike.sh"
printf '%s\n' '#!/usr/bin/env bash' 'export CEREMONY_FORGE_CLIENT=gh' \
'gh api graphql -f query=x' >"$TMP/declared.sh"
check "a declared-client file opts out" 0 "" declares_gh_client "$TMP/declared.sh"
check "...and an undeclared one does not" 1 "" declares_gh_client "$TMP/bad.sh"
# A mention of the variable in prose is not a declaration.
printf '%s\n' '#!/usr/bin/env bash' '# CEREMONY_FORGE_CLIENT=gh would opt out' \
>"$TMP/mentions.sh"
check "...nor does prose mentioning the variable" 1 "" \
declares_gh_client "$TMP/mentions.sh"
# The scan must actually reach the surfaces it claims to, or it passes by
# looking at nothing — the blind-sweep shape this repo keeps filing issues
# about, in its own guard.
scan_is_wide() { [ "$(scanned_files | wc -l)" -ge 20 ]; }
check "the scan reaches every executable surface" 0 "" scan_is_wide
scan_lists_backend() { scanned_files | grep -F lib/forge-github.sh; }
check "...including the backend it exempts" 0 "lib/forge-github.sh" scan_lists_backend
check "...and the backend really does speak gh, so the exemption is load-bearing" 0 "gh api" \
gh_calls "$ROOT/lib/forge-github.sh"
summary

View file

@ -122,35 +122,12 @@ chmod +x "$TMP/bin/gh"
action_boundary() {
local mode="$1"
# CEREMONY_FORGE=github is the environment this matrix has always assumed
# implicitly — it stubs `gh`. It is explicit now only because the entrypoint
# declares CEREMONY_FORGE_CLIENT=gh and preflights it (#198 spec 4); the
# incident matrix below is unchanged.
env PATH="$TMP/bin:$PATH" FAKE_GH_MODE="$mode" \
CEREMONY_FORGE=github \
GITHUB_REPOSITORY="heavy-duty/ceremony" PR_NUMBER=268 \
GITHUB_ACTION_PATH="$ROOT/actions/refs-not-closing" \
bash "$ENTRYPOINT"
}
# The declared-client refusal (#198 spec 4). This action is the one call site
# the 0.6.0 merge could NOT port — Forgejo serves no GraphQL at all — so on a
# Forgejo forge it must refuse by name, never produce a verdict from a graph
# it did not read. #199 removes the declaration by making the gather REST.
forgejo_boundary() {
env PATH="$TMP/bin:$PATH" FAKE_GH_MODE=success \
CEREMONY_FORGE=forgejo \
GITHUB_REPOSITORY="heavy-duty/ceremony" PR_NUMBER=268 \
GITHUB_ACTION_PATH="$ROOT/actions/refs-not-closing" \
bash "$ENTRYPOINT"
}
check "on a forgejo forge the action refuses instead of verdicting" 1 \
"cannot speak it" forgejo_boundary
check "...and the refusal names the client it declared" 1 "'gh' client" \
forgejo_boundary
check "...and names the client the forge actually needs" 1 "'rest' client" \
forgejo_boundary
check "action boundary fails when GraphQL read fails" 42 \
"fake GraphQL read failed" action_boundary failure
check "action boundary refuses a partial closing-reference page" 5 \

View file

@ -11,12 +11,6 @@ set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
# stubs already intercept (#188). Without this the verbs are simply undefined.
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
forge_select github
FACTS="$ROOT/lib/facts.sh"
DECIDE="$ROOT/lib/decide.sh"
@ -29,11 +23,7 @@ trap 'rm -rf "$TMP"' EXIT
mkdir -p "$TMP/stub"
cat >"$TMP/stub/gh" <<'EOF'
#!/usr/bin/env bash
# The label read is now GET commits/{sha}/pulls, a JSON array (#191).
if [ "$1" = api ]; then
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
exit 0
fi
if [ "$1" = api ]; then echo true; exit 0; fi
echo "gh stub: unexpected call: gh $*" >&2
exit 97
EOF
@ -87,7 +77,6 @@ chain() {
(
cd "${3:-$TMP/repo}" || exit 1
facts_out="$(env PATH="${4:-$TMP/stub}:$PATH" GITHUB_REPOSITORY=fixture/fixture \
CEREMONY_FORGE=github \
GH_TOKEN=stub VERSION_SOURCE=file MERGE_SHA="$1" EVENT_BEFORE="$2" \
bash "$FACTS")" || exit 1
printf '%s\n' "$facts_out"

View file

@ -106,22 +106,16 @@ fixture() {
"\$ROOT/lib/changelog.sh" >"$tree/bin/assemble"
printf '#!/usr/bin/env bash\n' >"$tree/lib/changelog.sh"
printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.sh"
# facts.sh sources BOTH on this tree: version.sh, and the forge shim #191
# put on the doors' path so a Forgejo consumer can publish (#198). The
# synthetic tree mirrors the real one, or every fixture below reports
# lib/forge.sh stale instead of exercising the case it is about.
printf '#!/usr/bin/env bash\n# shellcheck source=lib/version.sh\n. "%s"\n# shellcheck source=lib/forge.sh\n. "%s"\n' \
printf '#!/usr/bin/env bash\n# shellcheck source=lib/version.sh\n. "%s"\n' \
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/version.sh" \
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/forge.sh" \
>"$tree/lib/facts.sh"
printf '#!/usr/bin/env bash\n' >"$tree/lib/version.sh"
printf '#!/usr/bin/env bash\n' >"$tree/lib/forge.sh"
printf '%s\n' "$tree"
}
# Exact output is the record author's copy-paste source.
check "manifest prints the specified ordered release path" 0 \
$'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/facts.sh\nlib/changelog.sh\nlib/forge.sh' \
$'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/facts.sh\nlib/changelog.sh' \
bash "$PATH_SCRIPT"
check "real workflow and transitive dependencies match the manifest" 0 "" \
path_check "$ROOT"
@ -157,7 +151,7 @@ printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/changelog.sh" \
>"$tree/.github/workflows/release.yml"
sed -i 's| lib/forge.sh$| lib/forge.sh \\|' \
sed -i 's| lib/changelog.sh$| lib/changelog.sh \\|' \
"$tree/.github/scripts/release-path.sh"
printf ' lib/ruling.sh\n' >>"$tree/.github/scripts/release-path.sh"
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
@ -170,11 +164,7 @@ printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/changelog.sh" \
>"$tree/.github/workflows/release.yml"
# Only the version source is dropped; the forge source #191 added stays, or
# the fixture reports two stale paths and proves neither of them (#198).
printf '#!/usr/bin/env bash\n# shellcheck source=lib/forge.sh\n. "%s"\n' \
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/forge.sh" \
>"$tree/lib/facts.sh"
: >"$tree/lib/facts.sh"
check "removing facts' version source fails as a stale path" 1 \
"stale path: lib/version.sh" path_check "$tree"

View file

@ -4,12 +4,6 @@ set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
source "$ROOT/test/harness.sh"
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
# stubs already intercept (#188). Without this the verbs are undefined.
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
forge_select github
# shellcheck source=lib/ruling.sh
source "$ROOT/lib/ruling.sh"
@ -194,7 +188,6 @@ run() { "$@"; }
iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() {
if [ "$1" = api ]; then
shift
@ -207,7 +200,6 @@ gh() {
esac
shift
done
endpoint="$(forge_stub_path "$endpoint")"
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
[ -f "$file" ] || return 1
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi