diff --git a/.github/workflows/refs-guard.yml b/.github/workflows/refs-guard.yml index 5b6abbe..c865484 100644 --- a/.github/workflows/refs-guard.yml +++ b/.github/workflows/refs-guard.yml @@ -12,18 +12,22 @@ permissions: jobs: refs-not-closing: - # The action is gh-only until #199: its whole gather is a GraphQL query, - # and Forgejo serves no GraphQL at all. The ACTION refuses by name on a - # backend it cannot speak (that is its contract, and its contract test); - # scheduling it where it can only refuse is this workflow's decision, and - # a permanently red required check would block every merge on this forge - # for a gap #199 already owns. So the job does not run there — a skipped - # check is a green head, an invented verdict is not. + # The action is portable (#199): its gather is two REST reads through the + # shim plus this repo's own closing-keyword parser, so it produces a real + # verdict on either forge. It still refuses by name when it cannot read — + # that is its contract, and its contract test. # - # The condition mirrors lib/forge.sh's forge_detect positively: only - # github.com is accepted, and anything else — Forgejo, or a host this - # file has not met — is not run. "Never 'probably github'." - if: ${{ github.server_url == 'https://github.com' }} + # #199 removed the forge gate that used to sit here. While the action's + # only gather was GraphQL it could do nothing but refuse on Forgejo, and + # scheduling a permanently red required check would have blocked every + # merge on this forge; a skipped check is a green head, an invented + # verdict is not. The gather is REST now, so the job RUNS here and + # produces verdicts again. + # + # Deleting the action's client declaration without deleting this gate + # would have left it portable and never scheduled — a guard that passes + # by never running, which is this repo's blind-sweep shape wearing a + # different hat (@kimi-reviewer-andresmgsl, #198). runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index f5386b5..4197e4e 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -29,6 +29,8 @@ TRIAGE_ACTORS=() . "$(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/issue_references.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/issue_references.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. @@ -267,16 +269,6 @@ post_merge_transition_marker() { # $1 merged PR number printf 'post-merge-transition-pr-%s\n' "$1" } -issue_references() { # text on stdin -> LOCAL/CROSSreference - # A qualified reference belongs to another repository. Classify the whole - # token before extracting numbers so rig#112 can never become local #112. - { grep -Eo '([[:alnum:]_.-]+/)?[[:alnum:]_.-]+#[0-9]+|#[0-9]+' || true; } \ - | awk ' - index($0, "#") == 1 { print "LOCAL\t" substr($0, 2); next } - { print "CROSS\t" $0 } - ' -} - blocked_reference_records() { # body on stdin -> classified reference records # Every occurrence of the marker contributes a clause. Binding to the first # occurrence alone dropped the later sentences of a repeated declaration diff --git a/actions/refs-not-closing/run.sh b/actions/refs-not-closing/run.sh index d0dbbf4..e0959b6 100755 --- a/actions/refs-not-closing/run.sh +++ b/actions/refs-not-closing/run.sh @@ -1,76 +1,87 @@ #!/usr/bin/env bash set -euo pipefail -# The composite action's executable boundary (#218). Keeping the GraphQL -# 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. +# The composite action's executable boundary (#218). Keeping the gather here +# lets the offline contract test replace the forge and prove that failed and +# partial reads cannot accidentally produce a green verdict. # -# 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. +# THE GATHER IS REST, THROUGH THE SHIM (#199). It was a single GraphQL query +# issued through `gh`, asking GitHub for `closingIssuesReferences` — its own +# parse of the closing keywords. 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 was nothing to translate it to, so it is re-expressed — exactly as +# #188 re-expressed its own two GraphQL sites — over two reads both backends +# already serve, plus a parser this repo owns. +# +# WHAT THE GRAPH GAVE THAT TWO READS MUST REPLACE. This file used to call the +# graph "authoritative because it includes both closing keywords and sidebar +# links". Those two halves resolve differently here: +# +# sidebar links Forgejo has no such concept — an issue is closed by a +# keyword, never by a manual link. Nothing is lost. +# commit messages Forgejo DOES honour closing keywords in commit messages. +# A body-only parse would miss a PR that closes an issue +# from a commit subject and let through exactly the +# contradiction this action exists to catch. +# +# Hence both reads, unioned. The commit half is not optional. # shellcheck source=lib/forge.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh" -export CEREMONY_FORGE_CLIENT=gh -# Fail CLOSED, at the action boundary. An earlier head here exited 0 with a -# notice so the PR check would not be red; @codex-reviewer-andresmgsl was -# right that this conflates two different questions. "This action cannot -# produce a verdict" is the ACTION's contract and must stay a refusal; "this -# check should not block the board" is the CALLER's decision, and it belongs -# in .github/workflows/refs-guard.yml, which skips on a backend this action -# cannot speak until #199 ports it. -forge_preflight || exit 1 +# shellcheck source=lib/issue_references.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/issue_references.sh" +# shellcheck source=lib/closes_references.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh" -owner="${GITHUB_REPOSITORY%%/*}" -name="${GITHUB_REPOSITORY#*/}" +# No CEREMONY_FORGE_CLIENT declaration any more (#199 removes #198's): this +# file speaks the shim, not a client. Fail CLOSED at the action boundary all +# the same — "this action cannot produce a verdict" is the ACTION's contract +# and stays a refusal, while "this check should not block the board" is the +# CALLER's decision (@codex-reviewer-andresmgsl, #198). +forge_preflight || exit 1 +forge_select "" || exit 1 + +REPO="${REPO:-${GITHUB_REPOSITORY:-}}" +[ -n "$REPO" ] || { + echo "refs-not-closing: set REPO or GITHUB_REPOSITORY to owner/name" >&2 + exit 1 +} [ -n "${PR_NUMBER:-}" ] || { echo "refs-not-closing: pull request number is unavailable" >&2 exit 1 } -# GraphQL variables are literal API syntax; the shell must not expand them. -# shellcheck disable=SC2016 -facts="$(gh api graphql \ - -f query='query($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - body - closingIssuesReferences(first: 100) { - nodes { number } - pageInfo { hasNextPage } - } - } - } - }' \ - -F owner="$owner" -F name="$name" -F number="$PR_NUMBER")" - body_file="$(mktemp)" closing_file="$(mktemp)" trap 'rm -f "$body_file" "$closing_file"' EXIT -jq -er ' - .data.repository.pullRequest - | if . == null then error("pull request was not returned") else .body // "" end -' <<<"$facts" >"$body_file" -jq -r ' - .data.repository.pullRequest.closingIssuesReferences - | if . == null then - error("closing issue references were not returned") - elif .pageInfo.hasNextPage then - error("more than 100 closing issue references; refusing a partial verdict") - else - .nodes[].number - end -' <<<"$facts" >"$closing_file" + +# A read that fails must never reach the parser: an empty body parses to an +# empty closing set, which is a PASSING verdict this action never earned. +# `set -e` covers the assignment, and the explicit checks below name which +# read failed rather than leaving the operator to guess. +if ! forge_api "repos/$REPO/pulls/$PR_NUMBER" --jq '.body // ""' >"$body_file"; then + echo "refs-not-closing: could not read PR $PR_NUMBER's body — refusing a verdict" >&2 + exit 1 +fi + +# --paginate carries the completeness proof: the forgejo backend walks pages +# and then compares what it collected against the server's declared +# x-total-count, refusing a short gather (#188, #4699). That IS this action's +# `hasNextPage` refusal, relocated rather than reinvented — upstream refused +# past 100 closing references rather than issue a partial verdict, and an +# incomplete commit read is the same failure wearing REST's clothes. +commits_file="$(mktemp)" +trap 'rm -f "$body_file" "$closing_file" "$commits_file"' EXIT +if ! forge_api --paginate "repos/$REPO/pulls/$PR_NUMBER/commits" \ + --jq '.[].commit.message' >"$commits_file"; then + echo "refs-not-closing: could not read PR $PR_NUMBER's commits completely — refusing a partial verdict" >&2 + exit 1 +fi + +# The union. closes_references is line-oriented, so concatenating the body and +# every commit message and parsing once IS the union of parsing each — and it +# keeps one parse to reason about instead of two that could drift. +cat "$body_file" "$commits_file" | closes_references >"$closing_file" mapfile -t closing_issues <"$closing_file" bash "$GITHUB_ACTION_PATH/refs-not-closing.sh" \ diff --git a/changelog.d/199.md b/changelog.d/199.md new file mode 100644 index 0000000..7f881d0 --- /dev/null +++ b/changelog.d/199.md @@ -0,0 +1,36 @@ +### Fixed + +- `actions/refs-not-closing` gathers over REST through the forge shim instead + of one GraphQL query, so it produces a real verdict on Forgejo — which + serves no GraphQL surface at all — rather than refusing (#199). + +- The closing set is parsed by `lib/closes_references.sh` over the PR body + **and** every commit message, unioned. Forgejo honours closing keywords in + commit messages, so a body-only port would miss a PR that closes an issue + from a commit subject (#199). + +- The `hasNextPage` refusal is carried onto the paginated commit read: an + incomplete gather refuses instead of returning a partial verdict, reusing + the backend's `x-total-count` completeness proof (#199). + +- A failed read never reaches the parser. An unread body parses to an empty + closing set, which is a passing verdict the action never earned (#199). + +- `.github/workflows/refs-guard.yml` no longer gates the job on the forge. + A portable action behind a github-only gate is a guard that passes by never + running (#199, #198). + +- The `CEREMONY_FORGE_CLIENT=gh` declaration is gone, and the contract test + asserts its absence: an opt-out with no `gh` behind it is a standing + permission slip (#199). + +### Added + +- `lib/issue_references.sh` — the LOCAL/CROSS classifier, moved out of + `actions/issueflow-reconcile`'s executable so a second caller can use it + without sourcing a reconciler, which would run one (#199, #61). + +- `test/refs-not-closing.test.sh` drives the action's boundary on **both** + backends with stubs at the transport, proving one fixture yields the same + verdict on each — including a closing keyword that appears only in a commit + message (#199). diff --git a/lib/issue_references.sh b/lib/issue_references.sh new file mode 100644 index 0000000..0aa06e9 --- /dev/null +++ b/lib/issue_references.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# lib/issue_references.sh — the LOCAL / CROSS reference classifier (#61). +# +# Sourced, never executed: no set -e/-u — the sourcing script owns its shell +# options, as lib/closes_references.sh and lib/forge.sh do. +# +# WHY IT LIVES HERE. It was defined inside actions/issueflow-reconcile's +# executable, and lib/closes_references.sh's header recorded the resulting +# wart in prose: "DEPENDENCY: issue_references, from issueflow-reconcile.sh". +# That was tolerable while the reconciler was its only caller. #199 makes +# actions/refs-not-closing a second one, and a composite action cannot source +# another action's program to borrow one function — sourcing a reconciler +# runs a reconciler. +# +# So the dependency the comment described is now a file, and both callers +# source it the same way. Nothing about the classifier changed. +# +# A qualified reference belongs to another repository. The whole token is +# classified BEFORE any number is extracted, so `rig#112` can never be read +# as local `#112` — which is the entire point of the function. + +issue_references() { # text on stdin -> LOCAL/CROSSreference + { grep -Eo '([[:alnum:]_.-]+/)?[[:alnum:]_.-]+#[0-9]+|#[0-9]+' || true; } \ + | awk ' + index($0, "#") == 1 { print "LOCAL\t" substr($0, 2); next } + { print "CROSS\t" $0 } + ' +} diff --git a/test/refs-not-closing.test.sh b/test/refs-not-closing.test.sh index e57305b..e5ab437 100755 --- a/test/refs-not-closing.test.sh +++ b/test/refs-not-closing.test.sh @@ -94,96 +94,151 @@ check "missing body is a loud failure" 1 "missing or unreadable" \ check "invalid closing set is a loud failure" 1 "invalid closing issue" \ guard ref-5 nope -# The action owns the network boundary. Drive its executable entrypoint with -# a fake `gh` so failures are behavioral assertions, not YAML text guesses. +# The action owns the network boundary. #199 made that boundary REST through +# the shim, so it is driven here on BOTH backends with stubs at the transport +# — a fake `gh api` for the github backend, a fake `curl` for the forgejo one. +# Stubbing the shim itself would prove only that the test can stub the shim. mkdir -p "$TMP/bin" + +# The fixture, one PR, expressed once and served by both stubs. The body +# REFERENCES #5 while a commit CLOSES it: the contradiction this action +# exists to catch, and the case a body-only port would miss (#199). +PR_BODY_DEFAULT='Refs #5' +COMMIT_MSG_DEFAULT='Closes #5 + +body text' + cat >"$TMP/bin/gh" <<'EOF' #!/usr/bin/env bash set -u -# Every call is recorded, so a probe can assert the gather did NOT run — a -# refusal that still reads is not a refusal (#198). -[ -z "${GH_CALL_LOG:-}" ] || printf '%s\n' "$*" >>"$GH_CALL_LOG" -case "${FAKE_GH_MODE:-success}" in - failure) - echo "fake GraphQL read failed" >&2 - exit 42 - ;; - partial) - has_next=true - ;; - success) - has_next=false - ;; - *) - echo "unknown fake mode: ${FAKE_GH_MODE:-}" >&2 - exit 2 - ;; +[ -z "${FORGE_CALL_LOG:-}" ] || printf '%s\n' "$*" >>"$FORGE_CALL_LOG" +endpoint=""; jqexpr="" +shift # `api` +while [ $# -gt 0 ]; do + case "$1" in + --paginate) ;; + --jq) jqexpr="$2"; shift ;; + -*) ;; + *) [ -n "$endpoint" ] || endpoint="$1" ;; + esac + shift +done +case "${FAKE_FORGE_MODE:-success}" in + body-fails) case "$endpoint" in */commits*) ;; *) echo "fake body read failed" >&2; exit 42 ;; esac ;; + commits-fails) case "$endpoint" in */commits*) echo "fake commit read failed" >&2; exit 42 ;; esac ;; esac -printf '{"data":{"repository":{"pullRequest":{"body":"Refs #5","closingIssuesReferences":{"nodes":[],"pageInfo":{"hasNextPage":%s}}}}}}\n' "$has_next" +case "$endpoint" in + */commits*) out="$(jq -nc --arg m "${FAKE_COMMIT_MSG:-}" '[{commit:{message:$m}}]')" ;; + *) out="$(jq -nc --arg b "${FAKE_PR_BODY:-}" '{body:$b}')" ;; +esac +if [ -n "$jqexpr" ]; then printf '%s' "$out" | jq -r "$jqexpr"; else printf '%s' "$out"; fi EOF 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 \ +# curl as the forgejo backend calls it: -D -o . The +# x-total-count header is what forge_api's completeness proof compares +# against, so `partial` here is a REAL short gather, not a flag the test +# invents (#188, #4699). +cat >"$TMP/bin/curl" <<'EOF' +#!/usr/bin/env bash +set -u +hdr=""; body=""; url="" +while [ $# -gt 0 ]; do + case "$1" in + -D) hdr="$2"; shift 2 ;; + -o) body="$2"; shift 2 ;; + -H) shift 2 ;; + -sS|-s|-S) shift ;; + *) url="$1"; shift ;; + esac +done +[ -z "${FORGE_CALL_LOG:-}" ] || printf '%s\n' "$url" >>"$FORGE_CALL_LOG" +case "${FAKE_FORGE_MODE:-success}" in + body-fails) case "$url" in *"/commits"*) ;; *) echo "fake body read failed" >&2; exit 42 ;; esac ;; + commits-fails) case "$url" in *"/commits"*) echo "fake commit read failed" >&2; exit 42 ;; esac ;; +esac +total=1 +case "$url" in + *"/commits"*) + payload="$(jq -nc --arg m "${FAKE_COMMIT_MSG:-}" '[{commit:{message:$m}}]')" + # `partial`: declare 9, serve 1 and then nothing. Serving the same item on + # every page instead would NOT be a short gather — forge_api walks until a + # short page, so a stub that never runs out lets it collect exactly the + # declared total and pass honestly. The first draft of this stub did that + # and the case passed while proving nothing. + if [ "${FAKE_FORGE_MODE:-success}" = partial ]; then + total=9 + grep -q 'page=1\b' <<<"$url" || payload='[]' + fi + ;; + *) payload="$(jq -nc --arg b "${FAKE_PR_BODY:-}" '{body:$b}')" ;; +esac +printf 'HTTP/1.1 200 OK\r\nx-total-count: %s\r\n\r\n' "$total" >"$hdr" +printf '%s' "$payload" >"$body" +EOF +chmod +x "$TMP/bin/curl" + +# boundary [body] [commit-message] +boundary() { + local forge="$1" mode="$2" + local body="${3-$PR_BODY_DEFAULT}" msg="${4-$COMMIT_MSG_DEFAULT}" + env PATH="$TMP/bin:$PATH" \ + FAKE_FORGE_MODE="$mode" FAKE_PR_BODY="$body" FAKE_COMMIT_MSG="$msg" \ + CEREMONY_FORGE="$forge" \ + CEREMONY_FORGE_API="https://forge.example/api/v1" GH_TOKEN=tok \ + REPO="heavy-duty/ceremony" 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 GH_CALL_LOG="$TMP/gh-calls" \ - CEREMONY_FORGE=forgejo \ - GITHUB_REPOSITORY="heavy-duty/ceremony" PR_NUMBER=268 \ - GITHUB_ACTION_PATH="$ROOT/actions/refs-not-closing" \ - bash "$ENTRYPOINT" -} -# The contract on a forge this action cannot speak: refuse, by name, non-zero, -# and read nothing. FAIL CLOSED — an earlier head made this exit 0 so the PR -# check would not be red, which conflated the ACTION's contract with the -# CALLER's scheduling decision (@codex-reviewer-andresmgsl, #198). The caller -# is .github/workflows/refs-guard.yml, which skips on a backend this action -# cannot speak; the action itself never reports success it did not earn. -check "on a forgejo forge the action refuses, non-zero" 1 \ - "cannot speak it" forgejo_boundary -check "...naming the client it declared" 1 "'gh' client" forgejo_boundary -check "...and the client the forge actually needs" 1 "'rest' client" forgejo_boundary -# The teeth: it must not have READ anything. The stub counts its own calls, so -# a gather that ran despite the refusal is visible here. -forgejo_read_count() { - : >"$TMP/gh-calls" - forgejo_boundary >/dev/null 2>&1 - wc -l <"$TMP/gh-calls" -} -check "...and reached the forge zero times" 0 "0" forgejo_read_count -# The caller carries the scheduling half, positively: only github.com runs it. -check "the caller skips the job on any non-github forge" 0 \ - "github.server_url == 'https://github.com'" \ - grep -F "if:" "$ROOT/.github/workflows/refs-guard.yml" +# THE PORTABILITY CLAIM, driven rather than asserted: one fixture, both +# backends, same verdict. This is the acceptance criterion #199 states. +check "a Refs promise contradicted by a commit is caught — github backend" 1 \ + "#5" boundary github success +check "...and identically on the forgejo backend" 1 \ + "#5" boundary forgejo success -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 \ - "refusing a partial verdict" action_boundary partial -check "action boundary accepts a complete GraphQL read" 0 \ - "no Refs target" action_boundary success +# The half a body-only port would miss: the keyword is ONLY in the commit. +check "a closing keyword only in a commit message is detected — github" 1 \ + "#5" boundary github success 'Refs #5' 'Closes #5' +check "...and on forgejo" 1 \ + "#5" boundary forgejo success 'Refs #5' 'Closes #5' +# ...and the same PR passes when nothing closes it, so the case above is +# detecting the keyword rather than just failing on every input. +check "a body that only references, with no closing keyword anywhere, passes" 0 \ + "" boundary forgejo success 'Refs #5' 'plain commit subject' -one_graphql_read() { - [ "$(grep -c "gh api graphql" "$ENTRYPOINT")" -eq 1 ] - printf '1\n' -} +# A failed read must never reach the parser: an empty body parses to an empty +# closing set, which is a PASSING verdict the action never earned. +check "a failed body read refuses, non-zero — github" 1 \ + "refusing a verdict" boundary github body-fails +check "...and on forgejo" 1 "refusing a verdict" boundary forgejo body-fails +check "a failed commit read refuses, non-zero" 1 \ + "refusing a partial verdict" boundary github commits-fails + +# The `hasNextPage` refusal, relocated (#199 spec 3): a short paginated gather +# is the REST equivalent, and the forgejo backend's x-total-count proof is +# what catches it. This is the case that would silently pass if the port had +# dropped --paginate. +check "an incomplete commit read refuses a partial verdict" 1 \ + "refusing a partial verdict" boundary forgejo partial + +# Strip comments first. The entrypoint's prose NAMES gh, GraphQL and +# CEREMONY_FORGE_CLIENT to explain what it replaced, so a raw grep asserts on +# the explanation rather than the code — it passes with the call still there. +entrypoint_code() { sed 's/#.*//' "$ENTRYPOINT"; } +invokes_gh() { entrypoint_code | grep -qE '(^|[^[:alnum:]_])gh[[:space:]]'; } +holds_graphql() { entrypoint_code | grep -qi 'graphql'; } +declares_client() { entrypoint_code | grep -q 'CEREMONY_FORGE_CLIENT'; } +check "the entrypoint invokes no gh (#199)" 1 "" invokes_gh +check "...and holds no GraphQL" 1 "" holds_graphql +check "...and declares no forge client, because it speaks the shim" 1 "" declares_client +# The caller must SCHEDULE it now. A portable action behind a github-only gate +# is a guard that passes by never running (@kimi-reviewer-andresmgsl, #198). +check "the caller no longer gates the job on the forge" 1 "" \ + grep -F "github.server_url" "$ROOT/.github/workflows/refs-guard.yml" -check "action performs exactly one GraphQL read" 0 "1" \ - one_graphql_read check "composite delegates to the tested entrypoint" 0 "run.sh" \ grep -F "run: bash \"\$GITHUB_ACTION_PATH/run.sh\"" "$ACTION"