Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
#!/bin/sh
# Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
#
# The collection half of the os-regen deferred merge (#8047). `pre-commit` may
# accept a MERGE commit whose regeneration is deferred to the next commit — but
# at the moment it decides, that next commit does not exist yet, so it can only
# RECORD the deferral. Something has to be the event that notices the deferral
# was never discharged. Only two events can follow a merge commit: another
# commit (which `pre-commit` already refuses while the artifacts are stale) and
# the push. This is the push.
# TWO refusals, one event. Both are about something that is cheap to repair now
# and expensive — or impossible — the moment the push lands.
#
# 1. The collection half of the os-regen deferred merge (#8047). `pre-commit`
# may accept a MERGE commit whose regeneration is deferred to the next commit —
# but at the moment it decides, that next commit does not exist yet, so it can
# only RECORD the deferral. Something has to be the event that notices the
# deferral was never discharged. Only two events can follow a merge commit:
# another commit (which `pre-commit` already refuses while the artifacts are
# stale) and the push. This is the push.
#
# 2. Card relations in commit messages. The relation is declared ONCE, in the PR
# body; a commit carrying it is refused here rather than on the pull request,
# because on a PUSHED branch nothing an author may legally do removes the
# message — the check that used to report it read the PR's commit list, so a new
# commit on top joins that list, and two rounds each paid a full redo. Before
# the push, the repair is an ordinary reword. The script's header is the
# authority; this file is the invocation.
#
# Registered by the same `core.hooksPath=.githooks` that registers `pre-commit`,
# so it needs no change to `scripts/setup-git-hooks.mjs` and no separate opt-in.
#
# Cheap by construction, exactly like `pre-commit`: with no pending marker the
# script exits before doing any work, which is every push in this repo that did
# not just defer a merge. The ref list git writes on stdin is drained and
# ignored on purpose — the marker is per-worktree state, not per-ref state, so
# which refs are being pushed cannot change the answer.
# The ref list git writes on stdin is read ONCE, here, and handed to the check
# that needs it. The regen marker is per-worktree state rather than per-ref
# state, so which refs are being pushed cannot change that answer; the card
# check is the opposite — the refs are its whole input, since they say which
# commits this push would publish.

REFS=$(cat)
ROOT=$(git rev-parse --show-toplevel)

if [ -z "$OS_SKIP_REGEN_CHECK" ]; then
cat >/dev/null
node "$(git rev-parse --show-toplevel)/scripts/check-regen-pending.mjs" --pre-push || exit 1
node "$ROOT/scripts/check-regen-pending.mjs" --pre-push || exit 1
fi

printf '%s\n' "$REFS" | node "$ROOT/scripts/check-commit-card-trailers.mjs" --pre-push || exit 1
15 changes: 15 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,21 @@ jobs:
- name: Part-of closing-keyword guard self-test
run: pnpm check:partof-closing-keyword

# Commit card-trailer refusal self-test. The check itself is a PRE-PUSH
# hook — it judges the commits a push would publish, which no CI job has
# and no CI job should try to reconstruct — so what runs HERE is its
# self-test, the half whose verdict depends on nothing but the script.
# Unconditional for the same reason as the step above.
#
# It is the only thing standing behind that hook: a hook is registered per
# clone, runs on a developer's machine, and reports to nobody, so a break
# in its rule is invisible everywhere else. The self-test also pins the
# WIRING (the hook still calls the script, and still runs the os-regen
# deferral check it carried before), so unwiring it reddens here rather
# than going quiet. Pure functions plus two file reads; ~0.1s.
- name: Commit card-trailer pre-push refusal self-test
run: pnpm check:commit-card-trailers

# Publish-smoke tarball pin-set self-test. The assertion it pins lives on
# the RELEASE path (scripts/publish-smoke-pack.mjs runs only inside the
# packed-tarball smoke), so without this step a regression in it would be
Expand Down
53 changes: 7 additions & 46 deletions .github/workflows/partof-closing-keyword-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@ 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.
# `contents: read` checks the repo out to get at the script, and naming a
# `permissions:` block at all sets every scope NOT listed to `none`, so that one
# line is the whole grant. It used to also grant `pull-requests: read`, for a
# step that gathered the PR's commit messages; that rule is a pre-push hook now
# (the script header says why), so the scope went with it. 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 }}
Expand Down Expand Up @@ -93,47 +93,8 @@ jobs:
# 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
- name: A PR body may not close the card it is only part of
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"check:pm-governed-prose": "node scripts/pm/check-governed-prose.mjs --self-test && node scripts/pm/check-governed-prose.mjs",
"check:publish-smoke-pin": "node scripts/publish-smoke-pack.mjs --self-test",
"check:partof-closing-keyword": "node scripts/check-partof-closing-keyword.mjs --self-test",
"check:commit-card-trailers": "node scripts/check-commit-card-trailers.mjs --self-test",
"check:single-claim-paths": "node scripts/check-single-claim-paths.mjs --self-test",
"check:pnpm-filter-targets": "node scripts/pnpm-filter-targets.mjs --self-test && node scripts/check-pnpm-filter-targets.mjs --self-test && node scripts/check-pnpm-filter-targets.mjs",
"check:turbo-task-graph": "node scripts/check-turbo-task-graph.mjs --self-test && node scripts/check-turbo-task-graph.mjs",
Expand Down
Loading
Loading