Compare commits
No commits in common. "e035130f6564589e8a116a8d713a9c33744a0106" and "a663c631bf9050019d81bb3442298fd313a541a1" have entirely different histories.
e035130f65
...
a663c631bf
75 changed files with 633 additions and 4085 deletions
4
.github/labels.conf
vendored
4
.github/labels.conf
vendored
|
|
@ -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
|
||||
|
|
|
|||
11
.github/scripts/release-path.sh
vendored
11
.github/scripts/release-path.sh
vendored
|
|
@ -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
|
||||
|
|
|
|||
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/labels-sweep.yml
vendored
2
.github/workflows/labels-sweep.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
2
.github/workflows/labels.yml
vendored
2
.github/workflows/labels.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
20
.github/workflows/release-exercise.yml
vendored
20
.github/workflows/release-exercise.yml
vendored
|
|
@ -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).
|
||||
|
|
|
|||
46
.github/workflows/release.yml
vendored
46
.github/workflows/release.yml
vendored
|
|
@ -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[@]}"
|
||||
|
|
|
|||
306
CHANGELOG.md
306
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
27
README.md
27
README.md
|
|
@ -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 1–2 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
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.6.1-dev
|
||||
0.5.1-dev
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:-}" ] || {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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
4
changelog.d/218.md
Normal 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
5
changelog.d/230.md
Normal 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
6
changelog.d/231.md
Normal 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
5
changelog.d/232.md
Normal 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
10
changelog.d/236.md
Normal 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
5
changelog.d/237.md
Normal 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
4
changelog.d/238.md
Normal 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
3
changelog.d/241.md
Normal 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
5
changelog.d/242.md
Normal 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
20
changelog.d/247.md
Normal 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
3
changelog.d/248.md
Normal 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
14
changelog.d/251.md
Normal 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
9
changelog.d/252.md
Normal 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
3
changelog.d/253.md
Normal 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
16
changelog.d/254.md
Normal 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
3
changelog.d/257.md
Normal 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
7
changelog.d/258.md
Normal 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
6
changelog.d/260.md
Normal 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
20
changelog.d/262.md
Normal 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
4
changelog.d/264.md
Normal 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
5
changelog.d/266.md
Normal 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
19
changelog.d/267.md
Normal 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
6
changelog.d/272.md
Normal 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
10
changelog.d/276.md
Normal 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
5
changelog.d/280.md
Normal 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
5
changelog.d/281.md
Normal 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
5
changelog.d/282.md
Normal 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
6
changelog.d/284.md
Normal 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
5
changelog.d/288.md
Normal 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
3
changelog.d/292.md
Normal 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
13
changelog.d/293.md
Normal 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
6
changelog.d/302.md
Normal 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
11
changelog.d/304.md
Normal 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
6
changelog.d/307.md
Normal 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
6
changelog.d/311.md
Normal 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).
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
147
drills/0.4.1.md
147
drills/0.4.1.md
|
|
@ -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:23–15: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.
|
||||
|
|
|
|||
272
drills/0.6.0.md
272
drills/0.6.0.md
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
62
lib/facts.sh
62
lib/facts.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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[@]}"
|
||||
}
|
||||
224
lib/forge.sh
224
lib/forge.sh
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue