forked from heavy-duty/ceremony
feat: add refs-not-closing guard core
This commit is contained in:
parent
db63b677bb
commit
dcf72a9af8
4 changed files with 286 additions and 0 deletions
18
.github/workflows/refs-guard.yml
vendored
Normal file
18
.github/workflows/refs-guard.yml
vendored
Normal file
|
|
@ -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
|
||||||
58
actions/refs-not-closing/action.yml
Normal file
58
actions/refs-not-closing/action.yml
Normal file
|
|
@ -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[@]}"
|
||||||
116
actions/refs-not-closing/refs-not-closing.sh
Executable file
116
actions/refs-not-closing/refs-not-closing.sh
Executable file
|
|
@ -0,0 +1,116 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# refs-not-closing.sh <body-file> [<closing-issue-number> ...] — 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:-<none>}" >&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
|
||||||
94
test/refs-not-closing.test.sh
Executable file
94
test/refs-not-closing.test.sh
Executable file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue