forked from heavy-duty/ceremony
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
253 lines
10 KiB
Bash
Executable file
253 lines
10 KiB
Bash
Executable file
#!/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 <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 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
|