ceremony/test/no-runtime-gh.test.sh

240 lines
12 KiB
Bash
Raw Normal View History

merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198) `git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`. 18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and `lib/forge-forgejo.sh` conflict in none and come out byte-identical. The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md` keep this forge's; CHANGELOG keeps both sides and names the upstream commit this tree carries. The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole functions to files this tree already owned, so `git merge` took its side without raising a conflict — and with them, EIGHT runtime `gh` call sites that #188 had removed. Seven are ported onto the shim: two reads and four comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot declare a client for and whose Forgejo equivalent this instance answers with 500 rather than a 4xx — named with its reason rather than ported on a guess. test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the diff could not: four reviewers reading it each found a different subset, and the contract suite stubs `gh`, so a reintroduced call site passes it. Three seams the resolution decides are silent when resolved wrongly, and each now has a case that fails on the wrong one: the merged record's `merged_at` third column (without it every sort key ties and the highest PR number comes back), the open gather's one-BODY-row-per-line feed (a whole decoded body as one record loses every declaration including the first), and the whole-board read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged. The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would drop every `Closes #N` link on the open side and reclaim a claim the PR was holding — the existing base64 round-trip case is red without it. actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather is GraphQL, which Forgejo does not serve at all. #199 ports it. test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean. Refs #198
2026-08-05 11:56:23 +00:00
#!/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'
# 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).
fix(198): the workflow declares and refuses instead of being exempted by name (#198) @codex-reviewer-andresmgsl's blocker 1 is right and the filename exemption was the wrong shape. It exempted the whole FILE — any later `gh` call anywhere in labels.yml would have ridden in free — and it let the merge ship a step that dies with `command not found` on every sweep on this forge, which #197's bar does not permit. The declaration mechanism already existed; a workflow simply could not reach it. It can: `CEREMONY_FORGE_CLIENT: gh` in the step's env is the same declaration actions/refs-not-closing carries, and the refusal that a script gets from forge_preflight is inline here because a workflow has no shell to call it from. The dispatch now warns by name, cites #205, and exits 0 rather than reddening every sweep for a known gap. So the guard needs no exemption list at all. It now requires the pair — declared AND refusing — and reports a declaration that carries no refusal, which is a permission slip for `command not found`. That predicate was wrong on its first write, and its mutation test caught it: `refuses_when_unavailable` matched the word `forge_preflight` inside labels.yml's own comment explaining that it has NO forge_preflight to call. A guard reading prose as evidence is the blind sweep again, in the guard written to forbid it. Comments are stripped now, as gh_calls already stripped them. Blocker 4: the nudge strips a trailing slash from the server URL. Reverting the strip reds two cases. Blockers 2 and 3 were already fixed in 97e63ac, before either review landed. test/run.sh 28 files 0 failed under CI's env; shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker, vendored and changelog-armed all clean, with every file tracked this time. Refs #198
2026-08-05 12:16:53 +00:00
# Both spellings, because both surfaces must be able to declare: `=` for a
# shell script, `:` for a workflow's env block. A filename exemption was the
# first shape here and @codex-reviewer-andresmgsl was right to reject it —
# it exempts the whole FILE, so any later gh call anywhere in that workflow
# would ride in free, and it lets a declaration exist without a refusal.
declares_gh_client() { grep -qE '^[[:space:]]*(export[[:space:]]+)?CEREMONY_FORGE_CLIENT[=:][[:space:]]*gh[[:space:]]*$' "$1"; }
fix(198): the action fails closed, the caller decides scheduling, the guard decides the forge (#198) @codex-reviewer-andresmgsl's second review, both points taken. The refs action goes back to `forge_preflight || exit 1`. 97e63ac had it exit 0 with a notice so the PR check would not be red, and that conflated two different questions: "this action cannot produce a verdict" is the ACTION's contract and must stay a refusal, while "this check should not block the board" is the CALLER's decision. The caller now carries it — refs-guard.yml skips unless github.server_url is github.com, mirroring forge_detect positively. A skipped check is a green head; an action that reports success it did not earn is not. The leaked preflight_err temp file goes with the revert. The workflow guard asked the wrong question. `command -v gh` alone passes the moment a Forgejo runner image happens to ship gh, and then dispatches against a forge that cannot serve it — the client/forge mismatch forge_preflight exists to prevent. It decides the FORGE first now, mirroring forge_detect positively, and the binary second. The source guard splits to match: a declaration guarded only by binary presence is reported, with a fixture that fails on exactly that shape. The warning text was also wrong on the facts, as noted: issue-event sweeps ARE this caller's event-driven wakes, so they are precisely what is lost. It now says the hourly scheduled sweep survives and every event-driven wake through this caller does not, until #205. Point 1 of that review — jq 1.6 accepting an empty payload — was already fixed in 728102a, pushed before the review landed. Verified under the runner's jq 1.6 as well as 1.7: 28 test files, 0 failed both ways. shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker, vendored, changelog-armed all clean with every file tracked. Refs #198
2026-08-05 12:30:28 +00:00
# Declaring is half of it. #197's bar is "declared AND refuses loudly", and the
# refusal has TWO halves that a single check conflates
# (@codex-reviewer-andresmgsl, #198):
#
# * the FORGE — a gh dispatch is wrong on a forge that cannot serve it, and
# asking only "is gh installed?" passes the moment a Forgejo runner image
# happens to ship gh, which is the client/forge mismatch forge_preflight
# exists to prevent;
# * the BINARY — present or not on this runner.
#
# forge_preflight answers both, so a script that calls it satisfies both. A
# workflow has no shell to call it from and must do both inline.
#
# Comments are stripped first, for the same reason gh_calls strips them and
# with the same lesson learned the hard way: the first version of this
# predicate was satisfied by the word `forge_preflight` inside labels.yml's own
# comment EXPLAINING that it has no forge_preflight to call. A guard that reads
# prose as evidence is the blind sweep again, and it passed its own mutation
# test because of it.
strip_comments() { sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$1"; }
refuses_wrong_forge() {
strip_comments "$1" | grep -qE 'forge_preflight|GITHUB_SERVER_URL.*github\.com'
}
refuses_missing_binary() {
strip_comments "$1" | grep -qE 'forge_preflight|command -v gh'
}
fix(198): the workflow declares and refuses instead of being exempted by name (#198) @codex-reviewer-andresmgsl's blocker 1 is right and the filename exemption was the wrong shape. It exempted the whole FILE — any later `gh` call anywhere in labels.yml would have ridden in free — and it let the merge ship a step that dies with `command not found` on every sweep on this forge, which #197's bar does not permit. The declaration mechanism already existed; a workflow simply could not reach it. It can: `CEREMONY_FORGE_CLIENT: gh` in the step's env is the same declaration actions/refs-not-closing carries, and the refusal that a script gets from forge_preflight is inline here because a workflow has no shell to call it from. The dispatch now warns by name, cites #205, and exits 0 rather than reddening every sweep for a known gap. So the guard needs no exemption list at all. It now requires the pair — declared AND refusing — and reports a declaration that carries no refusal, which is a permission slip for `command not found`. That predicate was wrong on its first write, and its mutation test caught it: `refuses_when_unavailable` matched the word `forge_preflight` inside labels.yml's own comment explaining that it has NO forge_preflight to call. A guard reading prose as evidence is the blind sweep again, in the guard written to forbid it. Comments are stripped now, as gh_calls already stripped them. Blocker 4: the nudge strips a trailing slash from the server URL. Reverting the strip reds two cases. Blockers 2 and 3 were already fixed in 97e63ac, before either review landed. test/run.sh 28 files 0 failed under CI's env; shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker, vendored and changelog-armed all clean, with every file tracked this time. Refs #198
2026-08-05 12:16:53 +00:00
refuses_when_unavailable() {
fix(198): the action fails closed, the caller decides scheduling, the guard decides the forge (#198) @codex-reviewer-andresmgsl's second review, both points taken. The refs action goes back to `forge_preflight || exit 1`. 97e63ac had it exit 0 with a notice so the PR check would not be red, and that conflated two different questions: "this action cannot produce a verdict" is the ACTION's contract and must stay a refusal, while "this check should not block the board" is the CALLER's decision. The caller now carries it — refs-guard.yml skips unless github.server_url is github.com, mirroring forge_detect positively. A skipped check is a green head; an action that reports success it did not earn is not. The leaked preflight_err temp file goes with the revert. The workflow guard asked the wrong question. `command -v gh` alone passes the moment a Forgejo runner image happens to ship gh, and then dispatches against a forge that cannot serve it — the client/forge mismatch forge_preflight exists to prevent. It decides the FORGE first now, mirroring forge_detect positively, and the binary second. The source guard splits to match: a declaration guarded only by binary presence is reported, with a fixture that fails on exactly that shape. The warning text was also wrong on the facts, as noted: issue-event sweeps ARE this caller's event-driven wakes, so they are precisely what is lost. It now says the hourly scheduled sweep survives and every event-driven wake through this caller does not, until #205. Point 1 of that review — jq 1.6 accepting an empty payload — was already fixed in 728102a, pushed before the review landed. Verified under the runner's jq 1.6 as well as 1.7: 28 test files, 0 failed both ways. shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker, vendored, changelog-armed all clean with every file tracked. Refs #198
2026-08-05 12:30:28 +00:00
refuses_wrong_forge "$1" && refuses_missing_binary "$1"
fix(198): the workflow declares and refuses instead of being exempted by name (#198) @codex-reviewer-andresmgsl's blocker 1 is right and the filename exemption was the wrong shape. It exempted the whole FILE — any later `gh` call anywhere in labels.yml would have ridden in free — and it let the merge ship a step that dies with `command not found` on every sweep on this forge, which #197's bar does not permit. The declaration mechanism already existed; a workflow simply could not reach it. It can: `CEREMONY_FORGE_CLIENT: gh` in the step's env is the same declaration actions/refs-not-closing carries, and the refusal that a script gets from forge_preflight is inline here because a workflow has no shell to call it from. The dispatch now warns by name, cites #205, and exits 0 rather than reddening every sweep for a known gap. So the guard needs no exemption list at all. It now requires the pair — declared AND refusing — and reports a declaration that carries no refusal, which is a permission slip for `command not found`. That predicate was wrong on its first write, and its mutation test caught it: `refuses_when_unavailable` matched the word `forge_preflight` inside labels.yml's own comment explaining that it has NO forge_preflight to call. A guard reading prose as evidence is the blind sweep again, in the guard written to forbid it. Comments are stripped now, as gh_calls already stripped them. Blocker 4: the nudge strips a trailing slash from the server URL. Reverting the strip reds two cases. Blockers 2 and 3 were already fixed in 97e63ac, before either review landed. test/run.sh 28 files 0 failed under CI's env; shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker, vendored and changelog-armed all clean, with every file tracked this time. Refs #198
2026-08-05 12:16:53 +00:00
}
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198) `git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`. 18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and `lib/forge-forgejo.sh` conflict in none and come out byte-identical. The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md` keep this forge's; CHANGELOG keeps both sides and names the upstream commit this tree carries. The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole functions to files this tree already owned, so `git merge` took its side without raising a conflict — and with them, EIGHT runtime `gh` call sites that #188 had removed. Seven are ported onto the shim: two reads and four comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot declare a client for and whose Forgejo equivalent this instance answers with 500 rather than a 4xx — named with its reason rather than ported on a guess. test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the diff could not: four reviewers reading it each found a different subset, and the contract suite stubs `gh`, so a reintroduced call site passes it. Three seams the resolution decides are silent when resolved wrongly, and each now has a case that fails on the wrong one: the merged record's `merged_at` third column (without it every sort key ties and the highest PR number comes back), the open gather's one-BODY-row-per-line feed (a whole decoded body as one record loses every declaration including the first), and the whole-board read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged. The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would drop every `Closes #N` link on the open side and reclaim a claim the PR was holding — the existing base64 round-trip case is red without it. actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather is GraphQL, which Forgejo does not serve at all. #199 ports it. test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean. Refs #198
2026-08-05 11:56:23 +00:00
# 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
abs="$ROOT/$rel"
fix(198): the workflow declares and refuses instead of being exempted by name (#198) @codex-reviewer-andresmgsl's blocker 1 is right and the filename exemption was the wrong shape. It exempted the whole FILE — any later `gh` call anywhere in labels.yml would have ridden in free — and it let the merge ship a step that dies with `command not found` on every sweep on this forge, which #197's bar does not permit. The declaration mechanism already existed; a workflow simply could not reach it. It can: `CEREMONY_FORGE_CLIENT: gh` in the step's env is the same declaration actions/refs-not-closing carries, and the refusal that a script gets from forge_preflight is inline here because a workflow has no shell to call it from. The dispatch now warns by name, cites #205, and exits 0 rather than reddening every sweep for a known gap. So the guard needs no exemption list at all. It now requires the pair — declared AND refusing — and reports a declaration that carries no refusal, which is a permission slip for `command not found`. That predicate was wrong on its first write, and its mutation test caught it: `refuses_when_unavailable` matched the word `forge_preflight` inside labels.yml's own comment explaining that it has NO forge_preflight to call. A guard reading prose as evidence is the blind sweep again, in the guard written to forbid it. Comments are stripped now, as gh_calls already stripped them. Blocker 4: the nudge strips a trailing slash from the server URL. Reverting the strip reds two cases. Blockers 2 and 3 were already fixed in 97e63ac, before either review landed. test/run.sh 28 files 0 failed under CI's env; shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker, vendored and changelog-armed all clean, with every file tracked this time. Refs #198
2026-08-05 12:16:53 +00:00
if declares_gh_client "$abs"; then
refuses_when_unavailable "$abs" && continue
printf '%s: declares CEREMONY_FORGE_CLIENT=gh but carries no refusal\n' "$rel"
continue
fi
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198) `git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`. 18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and `lib/forge-forgejo.sh` conflict in none and come out byte-identical. The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md` keep this forge's; CHANGELOG keeps both sides and names the upstream commit this tree carries. The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole functions to files this tree already owned, so `git merge` took its side without raising a conflict — and with them, EIGHT runtime `gh` call sites that #188 had removed. Seven are ported onto the shim: two reads and four comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot declare a client for and whose Forgejo equivalent this instance answers with 500 rather than a 4xx — named with its reason rather than ported on a guess. test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the diff could not: four reviewers reading it each found a different subset, and the contract suite stubs `gh`, so a reintroduced call site passes it. Three seams the resolution decides are silent when resolved wrongly, and each now has a case that fails on the wrong one: the merged record's `merged_at` third column (without it every sort key ties and the highest PR number comes back), the open gather's one-BODY-row-per-line feed (a whole decoded body as one record loses every declaration including the first), and the whole-board read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged. The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would drop every `Closes #N` link on the open side and reclaim a claim the PR was holding — the existing base64 round-trip case is red without it. actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather is GraphQL, which Forgejo does not serve at all. #199 ports it. test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean. Refs #198
2026-08-05 11:56:23 +00:00
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
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198) `git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`. 18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and `lib/forge-forgejo.sh` conflict in none and come out byte-identical. The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md` keep this forge's; CHANGELOG keeps both sides and names the upstream commit this tree carries. The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole functions to files this tree already owned, so `git merge` took its side without raising a conflict — and with them, EIGHT runtime `gh` call sites that #188 had removed. Seven are ported onto the shim: two reads and four comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot declare a client for and whose Forgejo equivalent this instance answers with 500 rather than a 4xx — named with its reason rather than ported on a guess. test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the diff could not: four reviewers reading it each found a different subset, and the contract suite stubs `gh`, so a reintroduced call site passes it. Three seams the resolution decides are silent when resolved wrongly, and each now has a case that fails on the wrong one: the merged record's `merged_at` third column (without it every sort key ties and the highest PR number comes back), the open gather's one-BODY-row-per-line feed (a whole decoded body as one record loses every declaration including the first), and the whole-board read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged. The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would drop every `Closes #N` link on the open side and reclaim a claim the PR was holding — the existing base64 round-trip case is red without it. actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather is GraphQL, which Forgejo does not serve at all. #199 ports it. test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean. Refs #198
2026-08-05 11:56:23 +00:00
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"
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198) `git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`. 18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and `lib/forge-forgejo.sh` conflict in none and come out byte-identical. The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md` keep this forge's; CHANGELOG keeps both sides and names the upstream commit this tree carries. The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole functions to files this tree already owned, so `git merge` took its side without raising a conflict — and with them, EIGHT runtime `gh` call sites that #188 had removed. Seven are ported onto the shim: two reads and four comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot declare a client for and whose Forgejo equivalent this instance answers with 500 rather than a 4xx — named with its reason rather than ported on a guess. test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the diff could not: four reviewers reading it each found a different subset, and the contract suite stubs `gh`, so a reintroduced call site passes it. Three seams the resolution decides are silent when resolved wrongly, and each now has a case that fails on the wrong one: the merged record's `merged_at` third column (without it every sort key ties and the highest PR number comes back), the open gather's one-BODY-row-per-line feed (a whole decoded body as one record loses every declaration including the first), and the whole-board read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged. The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would drop every `Closes #N` link on the open side and reclaim a claim the PR was holding — the existing base64 round-trip case is red without it. actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather is GraphQL, which Forgejo does not serve at all. #199 ports it. test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean. Refs #198
2026-08-05 11:56:23 +00:00
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.
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198) `git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`. 18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and `lib/forge-forgejo.sh` conflict in none and come out byte-identical. The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md` keep this forge's; CHANGELOG keeps both sides and names the upstream commit this tree carries. The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole functions to files this tree already owned, so `git merge` took its side without raising a conflict — and with them, EIGHT runtime `gh` call sites that #188 had removed. Seven are ported onto the shim: two reads and four comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot declare a client for and whose Forgejo equivalent this instance answers with 500 rather than a 4xx — named with its reason rather than ported on a guess. test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the diff could not: four reviewers reading it each found a different subset, and the contract suite stubs `gh`, so a reintroduced call site passes it. Three seams the resolution decides are silent when resolved wrongly, and each now has a case that fails on the wrong one: the merged record's `merged_at` third column (without it every sort key ties and the highest PR number comes back), the open gather's one-BODY-row-per-line feed (a whole decoded body as one record loses every declaration including the first), and the whole-board read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged. The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would drop every `Closes #N` link on the open side and reclaim a claim the PR was holding — the existing base64 round-trip case is red without it. actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather is GraphQL, which Forgejo does not serve at all. #199 ports it. test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean. Refs #198
2026-08-05 11:56:23 +00:00
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"
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198) `git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`. 18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and `lib/forge-forgejo.sh` conflict in none and come out byte-identical. The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md` keep this forge's; CHANGELOG keeps both sides and names the upstream commit this tree carries. The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole functions to files this tree already owned, so `git merge` took its side without raising a conflict — and with them, EIGHT runtime `gh` call sites that #188 had removed. Seven are ported onto the shim: two reads and four comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot declare a client for and whose Forgejo equivalent this instance answers with 500 rather than a 4xx — named with its reason rather than ported on a guess. test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the diff could not: four reviewers reading it each found a different subset, and the contract suite stubs `gh`, so a reintroduced call site passes it. Three seams the resolution decides are silent when resolved wrongly, and each now has a case that fails on the wrong one: the merged record's `merged_at` third column (without it every sort key ties and the highest PR number comes back), the open gather's one-BODY-row-per-line feed (a whole decoded body as one record loses every declaration including the first), and the whole-board read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged. The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would drop every `Closes #N` link on the open side and reclaim a claim the PR was holding — the existing base64 round-trip case is red without it. actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather is GraphQL, which Forgejo does not serve at all. #199 ports it. test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean. Refs #198
2026-08-05 11:56:23 +00:00
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.
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198) `git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`. 18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and `lib/forge-forgejo.sh` conflict in none and come out byte-identical. The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md` keep this forge's; CHANGELOG keeps both sides and names the upstream commit this tree carries. The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole functions to files this tree already owned, so `git merge` took its side without raising a conflict — and with them, EIGHT runtime `gh` call sites that #188 had removed. Seven are ported onto the shim: two reads and four comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot declare a client for and whose Forgejo equivalent this instance answers with 500 rather than a 4xx — named with its reason rather than ported on a guess. test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the diff could not: four reviewers reading it each found a different subset, and the contract suite stubs `gh`, so a reintroduced call site passes it. Three seams the resolution decides are silent when resolved wrongly, and each now has a case that fails on the wrong one: the merged record's `merged_at` third column (without it every sort key ties and the highest PR number comes back), the open gather's one-BODY-row-per-line feed (a whole decoded body as one record loses every declaration including the first), and the whole-board read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged. The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would drop every `Closes #N` link on the open side and reclaim a claim the PR was holding — the existing base64 round-trip case is red without it. actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather is GraphQL, which Forgejo does not serve at all. #199 ports it. test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean. Refs #198
2026-08-05 11:56:23 +00:00
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"
fix(198): the workflow declares and refuses instead of being exempted by name (#198) @codex-reviewer-andresmgsl's blocker 1 is right and the filename exemption was the wrong shape. It exempted the whole FILE — any later `gh` call anywhere in labels.yml would have ridden in free — and it let the merge ship a step that dies with `command not found` on every sweep on this forge, which #197's bar does not permit. The declaration mechanism already existed; a workflow simply could not reach it. It can: `CEREMONY_FORGE_CLIENT: gh` in the step's env is the same declaration actions/refs-not-closing carries, and the refusal that a script gets from forge_preflight is inline here because a workflow has no shell to call it from. The dispatch now warns by name, cites #205, and exits 0 rather than reddening every sweep for a known gap. So the guard needs no exemption list at all. It now requires the pair — declared AND refusing — and reports a declaration that carries no refusal, which is a permission slip for `command not found`. That predicate was wrong on its first write, and its mutation test caught it: `refuses_when_unavailable` matched the word `forge_preflight` inside labels.yml's own comment explaining that it has NO forge_preflight to call. A guard reading prose as evidence is the blind sweep again, in the guard written to forbid it. Comments are stripped now, as gh_calls already stripped them. Blocker 4: the nudge strips a trailing slash from the server URL. Reverting the strip reds two cases. Blockers 2 and 3 were already fixed in 97e63ac, before either review landed. test/run.sh 28 files 0 failed under CI's env; shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker, vendored and changelog-armed all clean, with every file tracked this time. Refs #198
2026-08-05 12:16:53 +00:00
# A workflow declares in YAML, not shell — both spellings must count, or the
# only surface that cannot call forge_preflight is also the only one that
# cannot declare.
printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \
' CEREMONY_FORGE_CLIENT: gh' ' run: gh workflow run x' \
>"$TMP/declared.yml"
check "...and so does a workflow declaring it in YAML" 0 "" \
declares_gh_client "$TMP/declared.yml"
# Declared is not enough: #197's bar is declared AND refuses loudly.
check "a declaration without a refusal is not enough" 1 "" \
refuses_when_unavailable "$TMP/declared.yml"
printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \
' CEREMONY_FORGE_CLIENT: gh' \
' run: |' \
' command -v gh >/dev/null || { echo "::warning::not woken"; exit 0; }' \
' gh workflow run x' >"$TMP/declared-refusing.yml"
fix(198): the action fails closed, the caller decides scheduling, the guard decides the forge (#198) @codex-reviewer-andresmgsl's second review, both points taken. The refs action goes back to `forge_preflight || exit 1`. 97e63ac had it exit 0 with a notice so the PR check would not be red, and that conflated two different questions: "this action cannot produce a verdict" is the ACTION's contract and must stay a refusal, while "this check should not block the board" is the CALLER's decision. The caller now carries it — refs-guard.yml skips unless github.server_url is github.com, mirroring forge_detect positively. A skipped check is a green head; an action that reports success it did not earn is not. The leaked preflight_err temp file goes with the revert. The workflow guard asked the wrong question. `command -v gh` alone passes the moment a Forgejo runner image happens to ship gh, and then dispatches against a forge that cannot serve it — the client/forge mismatch forge_preflight exists to prevent. It decides the FORGE first now, mirroring forge_detect positively, and the binary second. The source guard splits to match: a declaration guarded only by binary presence is reported, with a fixture that fails on exactly that shape. The warning text was also wrong on the facts, as noted: issue-event sweeps ARE this caller's event-driven wakes, so they are precisely what is lost. It now says the hourly scheduled sweep survives and every event-driven wake through this caller does not, until #205. Point 1 of that review — jq 1.6 accepting an empty payload — was already fixed in 728102a, pushed before the review landed. Verified under the runner's jq 1.6 as well as 1.7: 28 test files, 0 failed both ways. shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker, vendored, changelog-armed all clean with every file tracked. Refs #198
2026-08-05 12:30:28 +00:00
# Binary presence ALONE is not a refusal: a Forgejo runner that ships gh would
# sail past it and dispatch against a forge that cannot serve the call.
check "...and a declaration guarded only by binary presence still is not" 1 "" \
fix(198): the workflow declares and refuses instead of being exempted by name (#198) @codex-reviewer-andresmgsl's blocker 1 is right and the filename exemption was the wrong shape. It exempted the whole FILE — any later `gh` call anywhere in labels.yml would have ridden in free — and it let the merge ship a step that dies with `command not found` on every sweep on this forge, which #197's bar does not permit. The declaration mechanism already existed; a workflow simply could not reach it. It can: `CEREMONY_FORGE_CLIENT: gh` in the step's env is the same declaration actions/refs-not-closing carries, and the refusal that a script gets from forge_preflight is inline here because a workflow has no shell to call it from. The dispatch now warns by name, cites #205, and exits 0 rather than reddening every sweep for a known gap. So the guard needs no exemption list at all. It now requires the pair — declared AND refusing — and reports a declaration that carries no refusal, which is a permission slip for `command not found`. That predicate was wrong on its first write, and its mutation test caught it: `refuses_when_unavailable` matched the word `forge_preflight` inside labels.yml's own comment explaining that it has NO forge_preflight to call. A guard reading prose as evidence is the blind sweep again, in the guard written to forbid it. Comments are stripped now, as gh_calls already stripped them. Blocker 4: the nudge strips a trailing slash from the server URL. Reverting the strip reds two cases. Blockers 2 and 3 were already fixed in 97e63ac, before either review landed. test/run.sh 28 files 0 failed under CI's env; shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker, vendored and changelog-armed all clean, with every file tracked this time. Refs #198
2026-08-05 12:16:53 +00:00
refuses_when_unavailable "$TMP/declared-refusing.yml"
fix(198): the action fails closed, the caller decides scheduling, the guard decides the forge (#198) @codex-reviewer-andresmgsl's second review, both points taken. The refs action goes back to `forge_preflight || exit 1`. 97e63ac had it exit 0 with a notice so the PR check would not be red, and that conflated two different questions: "this action cannot produce a verdict" is the ACTION's contract and must stay a refusal, while "this check should not block the board" is the CALLER's decision. The caller now carries it — refs-guard.yml skips unless github.server_url is github.com, mirroring forge_detect positively. A skipped check is a green head; an action that reports success it did not earn is not. The leaked preflight_err temp file goes with the revert. The workflow guard asked the wrong question. `command -v gh` alone passes the moment a Forgejo runner image happens to ship gh, and then dispatches against a forge that cannot serve it — the client/forge mismatch forge_preflight exists to prevent. It decides the FORGE first now, mirroring forge_detect positively, and the binary second. The source guard splits to match: a declaration guarded only by binary presence is reported, with a fixture that fails on exactly that shape. The warning text was also wrong on the facts, as noted: issue-event sweeps ARE this caller's event-driven wakes, so they are precisely what is lost. It now says the hourly scheduled sweep survives and every event-driven wake through this caller does not, until #205. Point 1 of that review — jq 1.6 accepting an empty payload — was already fixed in 728102a, pushed before the review landed. Verified under the runner's jq 1.6 as well as 1.7: 28 test files, 0 failed both ways. shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker, vendored, changelog-armed all clean with every file tracked. Refs #198
2026-08-05 12:30:28 +00:00
check "...though it does satisfy the binary half on its own" 0 "" \
refuses_missing_binary "$TMP/declared-refusing.yml"
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \
' CEREMONY_FORGE_CLIENT: gh' \
' run: |' \
' [ "$GITHUB_SERVER_URL" = "https://github.com" ] || exit 0' \
' command -v gh >/dev/null || exit 0' \
' gh workflow run x' >"$TMP/declared-both.yml"
check "...and a declaration guarding BOTH forge and binary is" 0 "" \
refuses_when_unavailable "$TMP/declared-both.yml"
fix(labels): wake the sweep over REST, so board events reconcile in seconds `.github/workflows/labels.yml` dispatched the sweep with `gh workflow run`, the eighth runtime gh call site the 0.6.0 merge reintroduced and the only one !204 did not port. On this forge the runner carries neither gh nor a GitHub API, so the step refused and the entire event-driven reconcile path ended there — every transition waiting up to an hour for the scheduled sweep. The workflow-dispatch endpoint has the SAME shape on both forges: POST {api}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches {"ref": "<branch>", "inputs": {...}} -> 204, empty body so the step no longer decides a forge at all. The CEREMONY_FORGE_CLIENT=gh declaration and both inline refusals are removed rather than ported, and test/no-runtime-gh.test.sh now asserts their ABSENCE — an opt-out with no gh behind it is a standing permission slip. The ref is supplied explicitly and taken from the repository, never from GITHUB_REF_NAME: on a pull_request_target run that is `<n>/merge`, which is not a branch. A non-204 still fails the job, keeping the misconfiguration alarm the trigger exists to be, and the diagnostic explains Forgejo's empty 500 rather than passing a bare status to a reader who will go looking for an outage that is not there. test/labels-dispatch.test.sh extracts the shipped step and executes it against a recording stub, asserting the method, endpoint, ref and inputs actually sent. Dropping the inputs or ignoring a non-204 both red the suite. Refs #205
2026-08-05 16:58:05 +00:00
# labels.yml WAS the real customer for that pair. #205 ported its dispatch to
# REST, so it no longer speaks gh and must no longer declare a client — the
# exemption is spent, not inherited. Asserting its ABSENCE is what stops the
# declaration coming back as cover for a re-added `gh` call: an opt-out with no
# gh behind it is a standing permission slip.
check "labels.yml no longer declares a client, because it speaks none (#205)" 1 "" \
fix(198): the workflow declares and refuses instead of being exempted by name (#198) @codex-reviewer-andresmgsl's blocker 1 is right and the filename exemption was the wrong shape. It exempted the whole FILE — any later `gh` call anywhere in labels.yml would have ridden in free — and it let the merge ship a step that dies with `command not found` on every sweep on this forge, which #197's bar does not permit. The declaration mechanism already existed; a workflow simply could not reach it. It can: `CEREMONY_FORGE_CLIENT: gh` in the step's env is the same declaration actions/refs-not-closing carries, and the refusal that a script gets from forge_preflight is inline here because a workflow has no shell to call it from. The dispatch now warns by name, cites #205, and exits 0 rather than reddening every sweep for a known gap. So the guard needs no exemption list at all. It now requires the pair — declared AND refusing — and reports a declaration that carries no refusal, which is a permission slip for `command not found`. That predicate was wrong on its first write, and its mutation test caught it: `refuses_when_unavailable` matched the word `forge_preflight` inside labels.yml's own comment explaining that it has NO forge_preflight to call. A guard reading prose as evidence is the blind sweep again, in the guard written to forbid it. Comments are stripped now, as gh_calls already stripped them. Blocker 4: the nudge strips a trailing slash from the server URL. Reverting the strip reds two cases. Blockers 2 and 3 were already fixed in 97e63ac, before either review landed. test/run.sh 28 files 0 failed under CI's env; shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker, vendored and changelog-armed all clean, with every file tracked this time. Refs #198
2026-08-05 12:16:53 +00:00
declares_gh_client "$ROOT/.github/workflows/labels.yml"
fix(labels): wake the sweep over REST, so board events reconcile in seconds `.github/workflows/labels.yml` dispatched the sweep with `gh workflow run`, the eighth runtime gh call site the 0.6.0 merge reintroduced and the only one !204 did not port. On this forge the runner carries neither gh nor a GitHub API, so the step refused and the entire event-driven reconcile path ended there — every transition waiting up to an hour for the scheduled sweep. The workflow-dispatch endpoint has the SAME shape on both forges: POST {api}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches {"ref": "<branch>", "inputs": {...}} -> 204, empty body so the step no longer decides a forge at all. The CEREMONY_FORGE_CLIENT=gh declaration and both inline refusals are removed rather than ported, and test/no-runtime-gh.test.sh now asserts their ABSENCE — an opt-out with no gh behind it is a standing permission slip. The ref is supplied explicitly and taken from the repository, never from GITHUB_REF_NAME: on a pull_request_target run that is `<n>/merge`, which is not a branch. A non-204 still fails the job, keeping the misconfiguration alarm the trigger exists to be, and the diagnostic explains Forgejo's empty 500 rather than passing a bare status to a reader who will go looking for an outage that is not there. test/labels-dispatch.test.sh extracts the shipped step and executes it against a recording stub, asserting the method, endpoint, ref and inputs actually sent. Dropping the inputs or ignoring a non-204 both red the suite. Refs #205
2026-08-05 16:58:05 +00:00
# shellcheck disable=SC2016 # `$SWEEP_WORKFLOW` is the literal the YAML must
# carry: the endpoint has to be built from the caller's input, not hardcoded.
labels_yml_dispatches_by_rest() {
grep -qF 'actions/workflows/$SWEEP_WORKFLOW/dispatches' \
"$ROOT/.github/workflows/labels.yml"
}
check "...and dispatches the sweep over REST instead" 0 "" labels_yml_dispatches_by_rest
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198) `git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`. 18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and `lib/forge-forgejo.sh` conflict in none and come out byte-identical. The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md` keep this forge's; CHANGELOG keeps both sides and names the upstream commit this tree carries. The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole functions to files this tree already owned, so `git merge` took its side without raising a conflict — and with them, EIGHT runtime `gh` call sites that #188 had removed. Seven are ported onto the shim: two reads and four comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot declare a client for and whose Forgejo equivalent this instance answers with 500 rather than a 4xx — named with its reason rather than ported on a guess. test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the diff could not: four reviewers reading it each found a different subset, and the contract suite stubs `gh`, so a reintroduced call site passes it. Three seams the resolution decides are silent when resolved wrongly, and each now has a case that fails on the wrong one: the merged record's `merged_at` third column (without it every sort key ties and the highest PR number comes back), the open gather's one-BODY-row-per-line feed (a whole decoded body as one record loses every declaration including the first), and the whole-board read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged. The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would drop every `Closes #N` link on the open side and reclaim a claim the PR was holding — the existing base64 round-trip case is red without it. actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather is GraphQL, which Forgejo does not serve at all. #199 ports it. test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean. Refs #198
2026-08-05 11:56:23 +00:00
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