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'
|
|
|
|
|
|
|
|
|
|
# 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
|
2026-08-05 11:58:28 +00:00
|
|
|
# hole. #205 owns the port; remove this entry when it lands.
|
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
|
|
|
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
|