Merge pull request #268 from andriujoseba/build/218-refs-not-closing
feat: guard Refs PRs from closing issues
This commit is contained in:
commit
8f4478f67e
10 changed files with 416 additions and 3 deletions
3
.github/labeler.yml
vendored
3
.github/labeler.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
actions/refs-not-closing/action.yml
Normal file
16
actions/refs-not-closing/action.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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: bash "$GITHUB_ACTION_PATH/run.sh"
|
||||
124
actions/refs-not-closing/refs-not-closing.sh
Executable file
124
actions/refs-not-closing/refs-not-closing.sh
Executable file
|
|
@ -0,0 +1,124 @@
|
|||
#!/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
|
||||
|
||||
if [ -z "$body_file" ] || [ ! -f "$body_file" ]; then
|
||||
echo "refs-not-closing: body file is missing or unreadable: ${body_file:-<none>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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:]]*:?[[: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" '
|
||||
/^[[:space:]]*$/ {
|
||||
if (paragraph != "") {
|
||||
text = text paragraph "\n\n"
|
||||
paragraph = ""
|
||||
}
|
||||
next
|
||||
}
|
||||
{
|
||||
if (paragraph != "") paragraph = paragraph " "
|
||||
paragraph = paragraph $0
|
||||
}
|
||||
END {
|
||||
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:]]*\\[?#[[: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
|
||||
51
actions/refs-not-closing/run.sh
Executable file
51
actions/refs-not-closing/run.sh
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
#!/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
|
||||
}
|
||||
|
||||
# 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) {
|
||||
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[@]}"
|
||||
4
changelog.d/218.md
Normal file
4
changelog.d/218.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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@<pinned-tag>
|
||||
```
|
||||
|
||||
`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
|
||||
|
|
|
|||
156
test/refs-not-closing.test.sh
Executable file
156
test/refs-not-closing.test.sh
Executable file
|
|
@ -0,0 +1,156 @@
|
|||
#!/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. 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" "$ENTRYPOINT")" -eq 1 ]
|
||||
printf '1\n'
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
Loading…
Reference in a new issue