#!/usr/bin/env bash # Contract tests for actions/refs-not-closing (issue #218). Bodies and # closing-reference sets are fixtures: no network and no pull request are # involved. set -u, not -e: failures are behavior for the harness to inspect. set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=test/harness.sh . "$ROOT/test/harness.sh" SCRIPT="$ROOT/actions/refs-not-closing/refs-not-closing.sh" ACTION="$ROOT/actions/refs-not-closing/action.yml" ENTRYPOINT="$ROOT/actions/refs-not-closing/run.sh" WORKFLOW="$ROOT/.github/workflows/refs-guard.yml" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT body() { local name="$1" shift printf '%s\n' "$@" >"$TMP/$name.md" } guard() { local name="$1" shift bash "$SCRIPT" "$TMP/$name.md" "$@" } body ref-5 'Refs #5' check "Refs target with empty closing set passes" 0 "no Refs target" guard ref-5 check "Refs target with itself closing fails" 1 "#5" guard ref-5 5 check "Refs target with another issue closing passes" 0 "no Refs target" guard ref-5 9 body ordinary 'Closes #5' check "ordinary Closes PR remains green" 0 "no Refs target" guard ordinary 5 body mixed 'Refs #5' '' 'This PR legitimately Closes #9.' check "Refs #5 plus Closes #9 remains green" 0 "no Refs target" guard mixed 9 body prose 'Refs #5' '' 'Triage closes #5 by hand after the live proof.' check "closing prose for a Refs target fails" 1 "closes #5" guard prose 5 check "failure prints the surrounding sentence" 1 \ "sentence: Triage closes #5 by hand after the live proof" guard prose 5 check "failure offers number-first rewrite" 1 "#N is" guard prose 5 check "failure offers number-free rewrite" 1 "closes the issue" guard prose 5 body code-span 'Refs #5' '' "The body must not contain \`Closes #5\` anywhere." check "backticked closing keyword is reported as the match" 1 \ "matched: Closes #5" guard code-span 5 check "backtick failure explains that code spans do not protect" 1 \ "Backticks do not protect" guard code-span 5 body adjacency 'Refs #5' '' 'Triage closes #9 and #5 after the proof.' check "non-adjacent #5 does not join closing set #9" 0 "no Refs target" \ guard adjacency 9 body empty '' check "empty body remains green" 0 "no Refs target" guard empty 5 body incidents-211 'Refs #209' 'Triage closes #209 by hand.' check "#211 incident replays red" 1 "#209" guard incidents-211 209 body incidents-214 'Refs #212' 'Triage closes #212 and #209 on that evidence.' check "#214 incident replays red" 1 "#212" guard incidents-214 212 body incidents-200 'Refs #199' "A later edit added \`Closes #199\`." check "#200 incident replays red" 1 "#199" guard incidents-200 199 body multiple 'Refs #5 and Refs #7.' 'Triage closes #5 and fixes #7 by hand.' check "failure names every intersecting issue" 1 \ "scheduled to close: #5 #7" guard multiple 5 7 body soft-wrap 'Refs #5' '' 'Triage closes' '#5 by hand after the live proof.' check "soft-wrapped closing prose is reported as one sentence" 1 \ "sentence: Triage closes #5 by hand after the live proof" \ guard soft-wrap 5 body refs-colon 'Refs: #5' '' 'Triage closes #5 after proof.' check "Refs colon form is protected" 1 "matched: closes #5" \ guard refs-colon 5 body refs-link 'Refs [#5](https://example.test/issues/5)' '' \ 'Triage closes #5 after proof.' check "linked Refs form is protected" 1 "matched: closes #5" \ guard refs-link 5 for number in 207 191 190 176 165 164; do body "incident-$number" "Refs #$number" check "#$number incident replays green" 0 "no Refs target" \ guard "incident-$number" done check "missing body is a loud failure" 1 "missing or unreadable" \ bash "$SCRIPT" "$TMP/missing.md" check "invalid closing set is a loud failure" 1 "invalid closing issue" \ guard ref-5 nope # 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 [ -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 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" # 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 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 # 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' # 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 "composite delegates to the tested entrypoint" 0 "run.sh" \ grep -F "run: bash \"\$GITHUB_ACTION_PATH/run.sh\"" "$ACTION" check "workflow wakes on body edits" 0 "types: [opened, edited, reopened, synchronize]" \ grep -F "types: [opened, edited, reopened, synchronize]" "$WORKFLOW" check "workflow is pull_request-only" 1 "" \ grep -E '^ (push|pull_request_target|workflow_dispatch|schedule|issue_comment):' \ "$WORKFLOW" check "workflow grants read-only pull request access" 0 "pull-requests: read" \ grep -F "pull-requests: read" "$WORKFLOW" summary