Merge pull request 'actions/refs-not-closing — gather over REST, so the guard produces verdicts on this forge (#199)' (#214) from build/199-refs-not-closing-rest into main

Reviewed-on: heavy-duty/ceremony#214
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
This commit is contained in:
andres 2026-08-05 17:53:27 +00:00
commit 9daeeb756e
6 changed files with 280 additions and 154 deletions

View file

@ -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

View file

@ -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/CROSS<TAB>reference
# 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

View file

@ -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" \

36
changelog.d/199.md Normal file
View file

@ -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).

28
lib/issue_references.sh Normal file
View file

@ -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/CROSS<TAB>reference
{ grep -Eo '([[:alnum:]_.-]+/)?[[:alnum:]_.-]+#[0-9]+|#[0-9]+' || true; } \
| awk '
index($0, "#") == 1 { print "LOCAL\t" substr($0, 2); next }
{ print "CROSS\t" $0 }
'
}

View file

@ -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 <headers> -o <body> <url>. 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 <forge> <mode> [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"