Skip to content

lint(security-posture): record which intakes can reach security-owd-alias — measured, annotated, pinned #6788

lint(security-posture): record which intakes can reach security-owd-alias — measured, annotated, pinned

lint(security-posture): record which intakes can reach security-owd-alias — measured, annotated, pinned #6788

# A PR that declares itself only `Part of #N` must not also tell GitHub to
# close `#N`. GitHub's closing-keyword parser matches the keyword plus the
# number and ignores the surrounding prose entirely — negations and modals
# included — so the sentence an author writes to PREVENT an auto-close is
# exactly what performs it on merge. That happened: a half-delivered card was
# closed `completed` two seconds after its PR merged, by its own warning
# sentence, and a closed card reads as finished, so it was found only by a
# post-merge inventory re-pull. The author wrote the warning correctly and
# still lost the card, which is why this is mechanical and not advice.
#
# The rule, the wording of the failure, and the code-stripping this depends on
# all live in `scripts/check-partof-closing-keyword.mjs` and the predicate it
# reuses; that header is authoritative, this file is the invocation.
#
# Sibling shape, deliberately copied rather than reinvented: the Duplicate Fix
# Guard is this repo's other PR-body-scoped blocking check, and it takes the
# same trigger set for the same measured reason.
name: Part-of Closing-Keyword Guard
# `edited` is load-bearing, not decoration. The body is this check's whole
# input, and GitHub does not re-deliver a `pull_request` event when a body
# changes under any other activity type — while `rerun_failed_jobs` replays the
# frozen payload, so a re-run of a fixed body stays red forever. With `edited`
# subscribed, rewording the sentence fires a fresh event carrying the fresh
# body and the check goes green with no push and no re-run.
#
# No `merge_group:` trigger, and that is not an oversight: a merge-queue event
# carries no pull request and therefore no body, so this check has nothing to
# judge there. That also keeps it out of the required-context registry, whose
# entries must report on queue builds; see the script header on where branch
# protection fits.
on:
pull_request:
types: [opened, edited, reopened, synchronize]
# `contents: read` checks the repo out to get at the script. `pull-requests:
# read` is what the commit-list gather below needs, and naming a `permissions:`
# block at all sets every scope NOT listed to `none`, so both must be spelled.
# Read-only is the whole grant: this gate reports, and never closes a PR,
# comments, or edits a body.
permissions:
contents: read
pull-requests: read
concurrency:
group: partof-closing-keyword-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
partof-closing-keyword:
name: Part-of PR must not also close its card
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
# Pinned to the same major and spelling as every other setup-node in this
# repo, and for a measured reason rather than tidiness. The first draft of
# this file used `actions/setup-node@v5` with `node-version-file`, and the
# job died IN THIS STEP, before the script ever ran:
#
# ##[error]Unable to locate executable file: pnpm.
#
# v5 defaults `package-manager-cache: true` (the run log echoes it as an
# input), which reads `packageManager: pnpm@...` out of package.json and
# shells out to pnpm to locate the store to cache. This job installs no
# package manager on purpose — the script is dependency-free and imports
# one sibling module — so pnpm is not on PATH and the action hard-errors.
# The failure is worth naming because nothing in the step that failed
# mentions pnpm: it is an implicit default of the action, invisible in the
# workflow source, and the misleading first read is that the `run:` line
# below invoked a package manager. It does not, and never did.
#
# The check-links workflow's ADR-links job is the known-good shape this
# now matches exactly: checkout, setup-node, one `node scripts/check-*.mjs`
# call, no install and no corepack. It is green on this repo today.
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
# The body reaches the script through `env:`, never through `${{ }}`
# inside the `run:` script. An expression interpolated into a shell line
# is substituted before bash ever sees it, so a PR body is arbitrary
# attacker-controlled text landing in a command; through `env:` it is
# inert data. The script's self-test pins this spelling.
#
# `PR_NUMBER` is not only for the message: GitHub renders a null body as
# an EMPTY value, so it is the witness that separates "this PR has an
# empty body" (a real, clean verdict) from "this step was handed nothing"
# (a wiring failure, which exits 2 and says so).
#
# No install step: the script imports one sibling module and reads no
# workspace package, so `node` on the pinned runtime is the whole
# toolchain it needs.
# RULE 2's input. The script judges it but never fetches it: the judging
# path stays HTTP-free, and the gather is a step of its own so that a
# network failure reads as a failed gather rather than as a verdict about
# somebody's PR.
#
# The endpoint is chosen over `git log base..head` deliberately. It
# returns exactly the set GitHub will squash. The git walk needs the merge
# base present to exclude what is already on the default branch, and the
# checkout above is depth 1 — so on a branch that has merged `main` back
# in, the walk cannot exclude those commits and would report another
# author's landed trailers as this PR's. Deepening until the merge base
# appears is unbounded, and `fetch-depth: 0` clones the whole repository
# to read a handful of messages.
#
# `--paginate` is load-bearing: without it a PR over one page silently
# loses its later commits, and a rule that read half the commits would
# report the unread half as clean. `--jq` emits one JSON object per line,
# and JSON escapes the newlines inside a commit message, so one row really
# is one line. The messages go to a FILE rather than into the environment:
# they are multi-line attacker-controlled text, and a path is inert where
# a body of prose is not.
#
# No pipeline here, on purpose. A `run:` block executes as `bash -e`
# WITHOUT pipefail, so `gh ... | jq ...` would take jq's exit code and a
# failed gather would reach the script as an empty file. It is a single
# redirect, so a failing `gh` fails the step; and if it ever did produce an
# empty file, the script reads zero rows as a failed gather, not as a PR
# with no commits.
- name: Gather the PR's commit messages
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: >
gh api --paginate "/repos/$REPO/pulls/$PR_NUMBER/commits"
--jq '.[] | {sha: .sha, message: .commit.message}'
> "$RUNNER_TEMP/pr-commits.jsonl"
- name: A PR body may not close the card it is only part of, and no commit may carry a card trailer
env:
PR_BODY: ${{ github.event.pull_request.body }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_COMMITS_FILE: ${{ runner.temp }}/pr-commits.jsonl
run: node scripts/check-partof-closing-keyword.mjs