From 4e28d437d6f683df018b7e67c3b1e0c3395740e4 Mon Sep 17 00:00:00 2001 From: clad2 Date: Wed, 5 Aug 2026 17:11:19 +0000 Subject: [PATCH] fix(refs-not-closing): gather over REST, so the guard produces verdicts here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action's entire gather was one GraphQL query asking GitHub for its own parse of the closing keywords. Forgejo serves no GraphQL at all — /api/graphql 404s here and a forgejo-runner job arrives with GITHUB_GRAPHQL_URL empty — so there was nothing to translate it to. It is re-expressed, as #188 re-expressed its own two GraphQL sites, over two reads both backends serve plus this repo's own parser. The graph was called authoritative for including "closing keywords and sidebar links". Those halves resolve differently here: Forgejo has no sidebar-link concept, so nothing is lost there, but it DOES honour closing keywords in commit messages. A body-only port would miss a PR that closes an issue from a commit subject — exactly the contradiction this action exists to catch — so the closing set unions the body and every commit message. The hasNextPage refusal is relocated, not dropped: --paginate carries the forgejo backend's x-total-count completeness proof, and a short gather refuses rather than returning a partial verdict. lib/issue_references.sh extracts the LOCAL/CROSS classifier from issueflow-reconcile's executable. closes_references.sh's header recorded that dependency in prose; a composite action cannot source a reconciler to borrow one function, because sourcing a reconciler runs one. refs-guard.yml's github-only gate is removed in the same change. A portable action behind that gate is a guard that passes by never running. The contract test drives the boundary on BOTH backends with stubs at the transport. Mutations: body-only parse reds 4 cases, dropping --paginate reds the partial-gather case, ignoring a failed read reds 9. Refs #199 --- .github/workflows/refs-guard.yml | 26 ++- .../issueflow-reconcile.sh | 12 +- actions/refs-not-closing/run.sh | 127 ++++++----- changelog.d/199.md | 36 +++ lib/issue_references.sh | 28 +++ test/refs-not-closing.test.sh | 205 +++++++++++------- 6 files changed, 280 insertions(+), 154 deletions(-) create mode 100644 changelog.d/199.md create mode 100644 lib/issue_references.sh 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"