From dcf72a9af8cfe95f127e516c5df6908962a42283 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:29:51 +0000 Subject: [PATCH 1/5] feat: add refs-not-closing guard core --- .github/workflows/refs-guard.yml | 18 +++ actions/refs-not-closing/action.yml | 58 ++++++++++ actions/refs-not-closing/refs-not-closing.sh | 116 +++++++++++++++++++ test/refs-not-closing.test.sh | 94 +++++++++++++++ 4 files changed, 286 insertions(+) create mode 100644 .github/workflows/refs-guard.yml create mode 100644 actions/refs-not-closing/action.yml create mode 100755 actions/refs-not-closing/refs-not-closing.sh create mode 100755 test/refs-not-closing.test.sh diff --git a/.github/workflows/refs-guard.yml b/.github/workflows/refs-guard.yml new file mode 100644 index 0000000..ef4964e --- /dev/null +++ b/.github/workflows/refs-guard.yml @@ -0,0 +1,18 @@ +name: Refs guard + +on: + # Body edits are load-bearing: #200 gained its accidental closing keyword + # after the PR opened, with no new commit to wake ordinary CI (#218). + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + pull-requests: read + +jobs: + refs-not-closing: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: ./actions/refs-not-closing diff --git a/actions/refs-not-closing/action.yml b/actions/refs-not-closing/action.yml new file mode 100644 index 0000000..22c1f3f --- /dev/null +++ b/actions/refs-not-closing/action.yml @@ -0,0 +1,58 @@ +name: Refs not closing +description: >- + Refuse a pull request whose `Refs #N` promise contradicts GitHub's + closing-issue graph (#218). GitHub recognizes closing keywords anywhere + in a PR body, including ordinary prose and code spans; the action reads + the graph once and lets a pure script decide whether any Refs target is + already scheduled to close. +runs: + using: composite + steps: + - name: refs targets are not closing + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + owner="${GITHUB_REPOSITORY%%/*}" + name="${GITHUB_REPOSITORY#*/}" + [ -n "$PR_NUMBER" ] || { + echo "refs-not-closing: pull request number is unavailable" >&2 + exit 1 + } + + 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 -er ' + .data.repository.pullRequest.closingIssuesReferences + | if .pageInfo.hasNextPage then + error("more than 100 closing issue references; refusing a partial verdict") + else + .nodes[].number + end + ' <<<"$facts" >"$closing_file" + + mapfile -t closing_issues <"$closing_file" + bash "$GITHUB_ACTION_PATH/refs-not-closing.sh" \ + "$body_file" "${closing_issues[@]}" diff --git a/actions/refs-not-closing/refs-not-closing.sh b/actions/refs-not-closing/refs-not-closing.sh new file mode 100755 index 0000000..946a6c8 --- /dev/null +++ b/actions/refs-not-closing/refs-not-closing.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +# refs-not-closing.sh [ ...] — compare the +# issues a PR promises merely to reference with GitHub's closing-issue graph +# (#218). The graph is authoritative because it includes both closing +# keywords and sidebar links. The body still matters: only an issue named by +# `Ref #N` or `Refs #N` is protected, so an ordinary `Closes #N` PR remains +# untouched. +# +# This decision stays network-free so test/refs-not-closing.test.sh can drive +# the incident matrix offline. The composite action gathers both facts in one +# GraphQL read and passes them here. A failed or partial read never reaches +# this script: action.yml refuses it before asking for a verdict. + +body_file="${1:-}" +shift || true + +[ -n "$body_file" ] && [ -f "$body_file" ] || { + echo "refs-not-closing: body file is missing or unreadable: ${body_file:-}" >&2 + exit 1 +} + +declare -A closing=() +for issue in "$@"; do + case "$issue" in + ''|*[!0-9]*) + echo "refs-not-closing: invalid closing issue number: '$issue'" >&2 + exit 1 + ;; + esac + closing["$issue"]=1 +done + +mapfile -t refs_targets < <( + awk ' + { + rest = tolower($0) + while (match(rest, /(^|[^[:alnum:]_])refs?[[:space:]]+#[0-9]+/)) { + token = substr(rest, RSTART, RLENGTH) + sub(/^.*#/, "", token) + print token + 0 + rest = substr(rest, RSTART + RLENGTH) + } + } + ' "$body_file" | sort -nu +) + +intersections=() +for issue in "${refs_targets[@]}"; do + if [ -n "${closing[$issue]:-}" ]; then + intersections+=("$issue") + fi +done + +if [ "${#intersections[@]}" -eq 0 ]; then + echo "refs-not-closing: no Refs target appears in GitHub's closing-issue graph" + exit 0 +fi + +sentence_for_issue() { + local issue="$1" mode="$2" + awk -v issue="$issue" -v mode="$mode" ' + { + text = text separator $0 + separator = "\n" + } + END { + count = split(text, sentence, /[.!?][[:space:]]+|\n+/) + if (mode == "closing") { + needle = "(^|[^[:alnum:]_])(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[[:space:]]+#[[:space:]]*" issue "([^0-9]|$)" + } else { + needle = "(^|[^[:alnum:]_])refs?[[:space:]]+#[[:space:]]*" issue "([^0-9]|$)" + } + for (i = 1; i <= count; i++) { + lower = tolower(sentence[i]) + if (match(lower, needle)) { + matched = substr(sentence[i], RSTART, RLENGTH) + sub(/^[^[:alnum:]_]*/, "", matched) + sub(/[^0-9]*$/, "", matched) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", sentence[i]) + printf "%s\t%s\n", matched, sentence[i] + exit + } + } + } + ' "$body_file" +} + +{ + printf 'refs-not-closing: Refs target(s) also scheduled to close:' + printf ' #%s' "${intersections[@]}" + printf '\n' + + for issue in "${intersections[@]}"; do + detail="$(sentence_for_issue "$issue" closing)" + if [ -z "$detail" ]; then + detail="$(sentence_for_issue "$issue" refs)" + printf " #%s: GitHub reports a closing reference; no adjacent closing keyword was found, so inspect the Development sidebar link.\n" "$issue" + fi + if [ -n "$detail" ]; then + matched="${detail%%$'\t'*}" + sentence="${detail#*$'\t'}" + printf ' matched: %s\n' "$matched" + printf ' sentence: %s\n' "$sentence" + fi + done + + cat <<'EOF' + A `Refs #N` PR must not close N. Remove the sidebar closing link or rewrite + an adjacent closing-keyword sentence so the number comes first (`#N is + closed by hand`) or the number is omitted (`triage closes the issue by + hand`). Backticks do not protect a closing keyword from GitHub's parser. +EOF +} >&2 +exit 1 diff --git a/test/refs-not-closing.test.sh b/test/refs-not-closing.test.sh new file mode 100755 index 0000000..71696d7 --- /dev/null +++ b/test/refs-not-closing.test.sh @@ -0,0 +1,94 @@ +#!/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" +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 still fails" 1 "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 + +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. These structural assertions keep a +# future edit from suppressing a failed/partial GraphQL read or splitting the +# one authoritative query into several drifting reads. +check "action performs exactly one GraphQL read" 0 "1" \ + bash -c 'test "$(grep -c "gh api graphql" "$1")" -eq 1; printf 1' _ "$ACTION" +check "action refuses a partial closing-reference page" 0 "hasNextPage" \ + grep -F "hasNextPage" "$ACTION" +check "action does not suppress GraphQL failure" 1 "" \ + grep -E 'gh api graphql.*(\|\| true|\| true)' "$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):' "$WORKFLOW" +check "workflow grants read-only pull request access" 0 "pull-requests: read" \ + grep -F "pull-requests: read" "$WORKFLOW" + +summary From 66b136efc039380d8895948f187ebeaba9937f35 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:31:34 +0000 Subject: [PATCH 2/5] docs: wire refs guard into ceremony flow --- .github/labeler.yml | 3 +++ BUILDER.md | 7 ++++++ REVIEWER.md | 7 +++++- actions/refs-not-closing/action.yml | 6 ++++-- changelog.d/218.md | 4 ++++ docs/CONSUMERS.md | 33 +++++++++++++++++++++++++++-- test/refs-not-closing.test.sh | 4 ++++ 7 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 changelog.d/218.md diff --git a/.github/labeler.yml b/.github/labeler.yml index 1859654..aa1a7aa 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -32,9 +32,12 @@ scope:guards: - actions/changelog-armed/** - actions/changelog-monotonic/** - actions/drill-recorded/** + - actions/refs-not-closing/** + - .github/workflows/refs-guard.yml - test/changelog-armed.test.sh - test/changelog-monotonic.test.sh - test/drill-recorded.test.sh + - test/refs-not-closing.test.sh scope:labels: - changed-files: - any-glob-to-any-file: diff --git a/BUILDER.md b/BUILDER.md index 9066716..d8376ea 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -182,6 +182,13 @@ triage bug, and the move is to say so on the issue, not to guess. absent that instruction `Closes #N` remains the default. The exception was bought the hard way: #143 carried `Closes #137` as doctrine then required, and the merge closed #137 with its post-merge criterion unmet (#151). + On a `Refs #N` PR, never put a closing keyword (`close`, `closes`, + `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`) + immediately before `#N` anywhere in the body — including the sentence + explaining why the PR does not close it. GitHub reads the whole body by + adjacency, not intent. Put the number first (`#N is closed by hand`) or + omit it (`triage closes the issue by hand`). A code span does not protect + the phrase: a backticked `Closes #199` still closed #199 (#200, #218). Drafts are invisible to the reviewer panel on purpose — the draft phase is yours. - **The issue's acceptance criteria are your definition of done.** Reproduce diff --git a/REVIEWER.md b/REVIEWER.md index f41565b..7d8e74e 100644 --- a/REVIEWER.md +++ b/REVIEWER.md @@ -34,7 +34,12 @@ In order of authority: not a defect: the issue directs it, triage owns that close, and a request-changes on the "missing" keyword enforces the bug the shape exists to fix — `Closes #137` closed its issue with a post-merge - criterion unmet (#151). Check every + criterion unmet (#151). For a `Refs #N` body, also verify that no closing + keyword immediately precedes `#N` anywhere in the body, even in prose + explaining the hand close or inside a code span: GitHub used those exact + shapes to close #209, #212 and #199 (#200, #218). The safe forms put the + number first (`#N is closed by hand`) or omit it (`triage closes the issue + by hand`). Check every criterion; a PR that ships less than the issue says is a request-changes even if the code is beautiful. 2. **The repo's load-bearing constraints** — the rules bought with diff --git a/actions/refs-not-closing/action.yml b/actions/refs-not-closing/action.yml index 22c1f3f..7dc795a 100644 --- a/actions/refs-not-closing/action.yml +++ b/actions/refs-not-closing/action.yml @@ -44,9 +44,11 @@ runs: .data.repository.pullRequest | if . == null then error("pull request was not returned") else .body // "" end ' <<<"$facts" >"$body_file" - jq -er ' + jq -r ' .data.repository.pullRequest.closingIssuesReferences - | if .pageInfo.hasNextPage then + | 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 diff --git a/changelog.d/218.md b/changelog.d/218.md new file mode 100644 index 0000000..cdbdc46 --- /dev/null +++ b/changelog.d/218.md @@ -0,0 +1,4 @@ +### Added + +- Pull requests that promise `Refs #N` now fail a read-only, body-edit-aware + guard if GitHub would close N through a keyword or sidebar link (#218). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 437d64b..fc12b4c 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -124,14 +124,41 @@ the machinery at all: consumer. In particular, `0.1.0` carries `changelog-armed`, `changelog-monotonic` and `drill-recorded` plus `docs-sync`, but not `changelog-assembled` or `runner-isolated`. -6. **Labels automation** (optional but recommended): the two callers from +6. **`.github/workflows/refs-guard.yml`** — the body-aware guard is its own + caller because `edited` is load-bearing: #200 gained its accidental + closing keyword after the PR opened, with no push to wake ordinary CI. + It costs the consumer one read-only workflow file and no other machinery: + + ```yaml + name: Refs guard + + on: + pull_request: + types: [opened, edited, reopened, synchronize] + + permissions: + contents: read + pull-requests: read + + jobs: + refs-not-closing: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: heavy-duty/ceremony/actions/refs-not-closing@ + ``` + + `refs-not-closing` is **unreleased** until the first tag carrying #218. + Adopt this caller with that ordinary pin bump; never point only this file + at a moving or newer ref. +7. **Labels automation** (optional but recommended): the two callers from [Labels automation](#labels-automation) — the event-facing labels caller and the sweep caller (#209) — plus `.github/labels.conf` (panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the path→scope globs). Run the sweep caller's `workflow_dispatch` once — **this bootstraps the taxonomy, `release` label included** — and use it again whenever an operator needs a full-board sweep immediately. -7. **The artifact hook** (optional): `.github/actions/release-artifact/` +8. **The artifact hook** (optional): `.github/actions/release-artifact/` per [The artifact hook](#the-artifact-hook). No hook → the source tarball is the package. @@ -156,6 +183,8 @@ precisely so the machinery is safe to work on sibling `push:` silently kills a door (rig's review catch). - [ ] Swap the guard *script* steps in `ci.yml` for the `uses:` steps in the bootstrap list above (with `fetch-depth: 0` on the checkout). +- [ ] Add `refs-guard.yml` from the bootstrap list with the same ceremony + pin as the release caller and CI guard steps. - [ ] Replace `labels.yml` with the caller from [Labels automation](#labels-automation) and add the sweep caller `labels-sweep.yml` beside it (#209); extract diff --git a/test/refs-not-closing.test.sh b/test/refs-not-closing.test.sh index 71696d7..04a12c6 100755 --- a/test/refs-not-closing.test.sh +++ b/test/refs-not-closing.test.sh @@ -64,6 +64,10 @@ 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 + for number in 207 191 190 176 165 164; do body "incident-$number" "Refs #$number" check "#$number incident replays green" 0 "no Refs target" \ From 869d05bf8531dc8ede00fb26bea71b4dbd222fb1 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:34:35 +0000 Subject: [PATCH 3/5] fix: satisfy CI shellcheck gate --- actions/refs-not-closing/refs-not-closing.sh | 4 ++-- test/refs-not-closing.test.sh | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/actions/refs-not-closing/refs-not-closing.sh b/actions/refs-not-closing/refs-not-closing.sh index 946a6c8..8e54cac 100755 --- a/actions/refs-not-closing/refs-not-closing.sh +++ b/actions/refs-not-closing/refs-not-closing.sh @@ -16,10 +16,10 @@ set -euo pipefail body_file="${1:-}" shift || true -[ -n "$body_file" ] && [ -f "$body_file" ] || { +if [ -z "$body_file" ] || [ ! -f "$body_file" ]; then echo "refs-not-closing: body file is missing or unreadable: ${body_file:-}" >&2 exit 1 -} +fi declare -A closing=() for issue in "$@"; do diff --git a/test/refs-not-closing.test.sh b/test/refs-not-closing.test.sh index 04a12c6..184e384 100755 --- a/test/refs-not-closing.test.sh +++ b/test/refs-not-closing.test.sh @@ -45,7 +45,7 @@ check "failure prints the surrounding sentence" 1 \ 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.' +body code-span 'Refs #5' '' "The body must not contain \`Closes #5\` anywhere." check "backticked closing keyword still fails" 1 "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 @@ -61,7 +61,7 @@ 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`.' +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.' @@ -82,8 +82,13 @@ check "invalid closing set is a loud failure" 1 "invalid closing issue" \ # The action owns the network boundary. These structural assertions keep a # future edit from suppressing a failed/partial GraphQL read or splitting the # one authoritative query into several drifting reads. +one_graphql_read() { + [ "$(grep -c "gh api graphql" "$ACTION")" -eq 1 ] + printf '1\n' +} + check "action performs exactly one GraphQL read" 0 "1" \ - bash -c 'test "$(grep -c "gh api graphql" "$1")" -eq 1; printf 1' _ "$ACTION" + one_graphql_read check "action refuses a partial closing-reference page" 0 "hasNextPage" \ grep -F "hasNextPage" "$ACTION" check "action does not suppress GraphQL failure" 1 "" \ From 022d1fcda6bca7548ba917c7a9f2272e1932a437 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:58:56 +0000 Subject: [PATCH 4/5] fix: exercise refs guard action boundary --- actions/refs-not-closing/action.yml | 46 +----------- actions/refs-not-closing/refs-not-closing.sh | 18 +++-- actions/refs-not-closing/run.sh | 49 +++++++++++++ test/refs-not-closing.test.sh | 73 +++++++++++++++++--- 4 files changed, 126 insertions(+), 60 deletions(-) create mode 100755 actions/refs-not-closing/run.sh diff --git a/actions/refs-not-closing/action.yml b/actions/refs-not-closing/action.yml index 7dc795a..10ffeae 100644 --- a/actions/refs-not-closing/action.yml +++ b/actions/refs-not-closing/action.yml @@ -13,48 +13,4 @@ runs: env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - owner="${GITHUB_REPOSITORY%%/*}" - name="${GITHUB_REPOSITORY#*/}" - [ -n "$PR_NUMBER" ] || { - echo "refs-not-closing: pull request number is unavailable" >&2 - exit 1 - } - - 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" - - mapfile -t closing_issues <"$closing_file" - bash "$GITHUB_ACTION_PATH/refs-not-closing.sh" \ - "$body_file" "${closing_issues[@]}" + run: bash "$GITHUB_ACTION_PATH/run.sh" diff --git a/actions/refs-not-closing/refs-not-closing.sh b/actions/refs-not-closing/refs-not-closing.sh index 8e54cac..0f2af98 100755 --- a/actions/refs-not-closing/refs-not-closing.sh +++ b/actions/refs-not-closing/refs-not-closing.sh @@ -36,7 +36,7 @@ mapfile -t refs_targets < <( awk ' { rest = tolower($0) - while (match(rest, /(^|[^[:alnum:]_])refs?[[:space:]]+#[0-9]+/)) { + while (match(rest, /(^|[^[:alnum:]_])refs?[[:space:]]*:?[[:space:]]*[[]?#[0-9]+/)) { token = substr(rest, RSTART, RLENGTH) sub(/^.*#/, "", token) print token + 0 @@ -61,16 +61,24 @@ fi sentence_for_issue() { local issue="$1" mode="$2" awk -v issue="$issue" -v mode="$mode" ' + /^[[:space:]]*$/ { + if (paragraph != "") { + text = text paragraph "\n\n" + paragraph = "" + } + next + } { - text = text separator $0 - separator = "\n" + if (paragraph != "") paragraph = paragraph " " + paragraph = paragraph $0 } END { - count = split(text, sentence, /[.!?][[:space:]]+|\n+/) + text = text paragraph + count = split(text, sentence, /[.!?][[:space:]]+|\n\n+/) if (mode == "closing") { needle = "(^|[^[:alnum:]_])(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[[:space:]]+#[[:space:]]*" issue "([^0-9]|$)" } else { - needle = "(^|[^[:alnum:]_])refs?[[:space:]]+#[[:space:]]*" issue "([^0-9]|$)" + needle = "(^|[^[:alnum:]_])refs?[[:space:]]*:?[[:space:]]*\\[?#[[:space:]]*" issue "([^0-9]|$)" } for (i = 1; i <= count; i++) { lower = tolower(sentence[i]) diff --git a/actions/refs-not-closing/run.sh b/actions/refs-not-closing/run.sh new file mode 100755 index 0000000..d281ce6 --- /dev/null +++ b/actions/refs-not-closing/run.sh @@ -0,0 +1,49 @@ +#!/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. + +owner="${GITHUB_REPOSITORY%%/*}" +name="${GITHUB_REPOSITORY#*/}" +[ -n "${PR_NUMBER:-}" ] || { + echo "refs-not-closing: pull request number is unavailable" >&2 + exit 1 +} + +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" + +mapfile -t closing_issues <"$closing_file" +bash "$GITHUB_ACTION_PATH/refs-not-closing.sh" \ + "$body_file" "${closing_issues[@]}" diff --git a/test/refs-not-closing.test.sh b/test/refs-not-closing.test.sh index 184e384..60db210 100755 --- a/test/refs-not-closing.test.sh +++ b/test/refs-not-closing.test.sh @@ -10,6 +10,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 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)" @@ -46,7 +47,8 @@ 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 still fails" 1 "Closes #5" guard code-span 5 +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 @@ -68,6 +70,19 @@ 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" \ @@ -79,24 +94,62 @@ 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. These structural assertions keep a -# future edit from suppressing a failed/partial GraphQL read or splitting the -# one authoritative query into several drifting reads. +# The action owns the network boundary. Drive its executable entrypoint with +# a fake `gh` so failures are behavioral assertions, not YAML text guesses. +mkdir -p "$TMP/bin" +cat >"$TMP/bin/gh" <<'EOF' +#!/usr/bin/env bash +set -u +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 + ;; +esac +printf '{"data":{"repository":{"pullRequest":{"body":"Refs #5","closingIssuesReferences":{"nodes":[],"pageInfo":{"hasNextPage":%s}}}}}}\n' "$has_next" +EOF +chmod +x "$TMP/bin/gh" + +action_boundary() { + local mode="$1" + env PATH="$TMP/bin:$PATH" FAKE_GH_MODE="$mode" \ + GITHUB_REPOSITORY="heavy-duty/ceremony" PR_NUMBER=268 \ + GITHUB_ACTION_PATH="$ROOT/actions/refs-not-closing" \ + bash "$ENTRYPOINT" +} + +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 + one_graphql_read() { - [ "$(grep -c "gh api graphql" "$ACTION")" -eq 1 ] + [ "$(grep -c "gh api graphql" "$ENTRYPOINT")" -eq 1 ] printf '1\n' } check "action performs exactly one GraphQL read" 0 "1" \ one_graphql_read -check "action refuses a partial closing-reference page" 0 "hasNextPage" \ - grep -F "hasNextPage" "$ACTION" -check "action does not suppress GraphQL failure" 1 "" \ - grep -E 'gh api graphql.*(\|\| true|\| true)' "$ACTION" +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):' "$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" From 360b262c470793e62f56fbaeed6282a1bb98d77d Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:00:40 +0000 Subject: [PATCH 5/5] fix: satisfy refs guard shellcheck --- actions/refs-not-closing/run.sh | 2 ++ test/refs-not-closing.test.sh | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/actions/refs-not-closing/run.sh b/actions/refs-not-closing/run.sh index d281ce6..6a0dd4a 100755 --- a/actions/refs-not-closing/run.sh +++ b/actions/refs-not-closing/run.sh @@ -12,6 +12,8 @@ name="${GITHUB_REPOSITORY#*/}" 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) { diff --git a/test/refs-not-closing.test.sh b/test/refs-not-closing.test.sh index 60db210..b81a2dd 100755 --- a/test/refs-not-closing.test.sh +++ b/test/refs-not-closing.test.sh @@ -143,7 +143,7 @@ one_graphql_read() { 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" + 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"