fix: exercise refs guard action boundary
This commit is contained in:
parent
869d05bf85
commit
022d1fcda6
4 changed files with 126 additions and 60 deletions
|
|
@ -13,48 +13,4 @@ runs:
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
run: |
|
run: bash "$GITHUB_ACTION_PATH/run.sh"
|
||||||
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[@]}"
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ mapfile -t refs_targets < <(
|
||||||
awk '
|
awk '
|
||||||
{
|
{
|
||||||
rest = tolower($0)
|
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)
|
token = substr(rest, RSTART, RLENGTH)
|
||||||
sub(/^.*#/, "", token)
|
sub(/^.*#/, "", token)
|
||||||
print token + 0
|
print token + 0
|
||||||
|
|
@ -61,16 +61,24 @@ fi
|
||||||
sentence_for_issue() {
|
sentence_for_issue() {
|
||||||
local issue="$1" mode="$2"
|
local issue="$1" mode="$2"
|
||||||
awk -v issue="$issue" -v mode="$mode" '
|
awk -v issue="$issue" -v mode="$mode" '
|
||||||
|
/^[[:space:]]*$/ {
|
||||||
|
if (paragraph != "") {
|
||||||
|
text = text paragraph "\n\n"
|
||||||
|
paragraph = ""
|
||||||
|
}
|
||||||
|
next
|
||||||
|
}
|
||||||
{
|
{
|
||||||
text = text separator $0
|
if (paragraph != "") paragraph = paragraph " "
|
||||||
separator = "\n"
|
paragraph = paragraph $0
|
||||||
}
|
}
|
||||||
END {
|
END {
|
||||||
count = split(text, sentence, /[.!?][[:space:]]+|\n+/)
|
text = text paragraph
|
||||||
|
count = split(text, sentence, /[.!?][[:space:]]+|\n\n+/)
|
||||||
if (mode == "closing") {
|
if (mode == "closing") {
|
||||||
needle = "(^|[^[:alnum:]_])(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[[:space:]]+#[[:space:]]*" issue "([^0-9]|$)"
|
needle = "(^|[^[:alnum:]_])(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[[:space:]]+#[[:space:]]*" issue "([^0-9]|$)"
|
||||||
} else {
|
} else {
|
||||||
needle = "(^|[^[:alnum:]_])refs?[[:space:]]+#[[:space:]]*" issue "([^0-9]|$)"
|
needle = "(^|[^[:alnum:]_])refs?[[:space:]]*:?[[:space:]]*\\[?#[[:space:]]*" issue "([^0-9]|$)"
|
||||||
}
|
}
|
||||||
for (i = 1; i <= count; i++) {
|
for (i = 1; i <= count; i++) {
|
||||||
lower = tolower(sentence[i])
|
lower = tolower(sentence[i])
|
||||||
|
|
|
||||||
49
actions/refs-not-closing/run.sh
Executable file
49
actions/refs-not-closing/run.sh
Executable file
|
|
@ -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[@]}"
|
||||||
|
|
@ -10,6 +10,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
SCRIPT="$ROOT/actions/refs-not-closing/refs-not-closing.sh"
|
SCRIPT="$ROOT/actions/refs-not-closing/refs-not-closing.sh"
|
||||||
ACTION="$ROOT/actions/refs-not-closing/action.yml"
|
ACTION="$ROOT/actions/refs-not-closing/action.yml"
|
||||||
|
ENTRYPOINT="$ROOT/actions/refs-not-closing/run.sh"
|
||||||
WORKFLOW="$ROOT/.github/workflows/refs-guard.yml"
|
WORKFLOW="$ROOT/.github/workflows/refs-guard.yml"
|
||||||
|
|
||||||
TMP="$(mktemp -d)"
|
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
|
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 "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 \
|
check "backtick failure explains that code spans do not protect" 1 \
|
||||||
"Backticks do not protect" guard code-span 5
|
"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 \
|
check "failure names every intersecting issue" 1 \
|
||||||
"scheduled to close: #5 #7" guard multiple 5 7
|
"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
|
for number in 207 191 190 176 165 164; do
|
||||||
body "incident-$number" "Refs #$number"
|
body "incident-$number" "Refs #$number"
|
||||||
check "#$number incident replays green" 0 "no Refs target" \
|
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" \
|
check "invalid closing set is a loud failure" 1 "invalid closing issue" \
|
||||||
guard ref-5 nope
|
guard ref-5 nope
|
||||||
|
|
||||||
# The action owns the network boundary. These structural assertions keep a
|
# The action owns the network boundary. Drive its executable entrypoint with
|
||||||
# future edit from suppressing a failed/partial GraphQL read or splitting the
|
# a fake `gh` so failures are behavioral assertions, not YAML text guesses.
|
||||||
# one authoritative query into several drifting reads.
|
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() {
|
one_graphql_read() {
|
||||||
[ "$(grep -c "gh api graphql" "$ACTION")" -eq 1 ]
|
[ "$(grep -c "gh api graphql" "$ENTRYPOINT")" -eq 1 ]
|
||||||
printf '1\n'
|
printf '1\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
check "action performs exactly one GraphQL read" 0 "1" \
|
check "action performs exactly one GraphQL read" 0 "1" \
|
||||||
one_graphql_read
|
one_graphql_read
|
||||||
check "action refuses a partial closing-reference page" 0 "hasNextPage" \
|
check "composite delegates to the tested entrypoint" 0 "run.sh" \
|
||||||
grep -F "hasNextPage" "$ACTION"
|
grep -F 'run: bash "$GITHUB_ACTION_PATH/run.sh"' "$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]" \
|
check "workflow wakes on body edits" 0 "types: [opened, edited, reopened, synchronize]" \
|
||||||
grep -F "types: [opened, edited, reopened, synchronize]" "$WORKFLOW"
|
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" \
|
check "workflow grants read-only pull request access" 0 "pull-requests: read" \
|
||||||
grep -F "pull-requests: read" "$WORKFLOW"
|
grep -F "pull-requests: read" "$WORKFLOW"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue