#!/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. #205 owns the port; remove this entry when it 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