Skip to content

scripts/pre-pr-checks.sh: run the six pre-merge checks as one command, one line per check - #2313

Open
bong-water-water-bong wants to merge 2 commits into
mainfrom
cl-prs2/pre-pr-runner
Open

bong-water-water-bong wants to merge 2 commits into
mainfrom
cl-prs2/pre-pr-runner

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

docs/pre-pr-checks.md (#2311) puts the six host-side pre-merge checks on one page. This runs them, so the page is executable rather than remembered.

$ ./scripts/pre-pr-checks.sh [--base origin/main] [--list]
ok    tools/copyright.py                         
ok    tools/ci/tests_referenced_check.py         tests_referenced: all 86 test script(s) are referenced …
ok    tools/ci/ci_targets_check.py               ci_targets_check: 9 target(s) whose test never reaches ctest …
ok    tools/ci/tmp_path_check.py                 tmp_path_check: 44 baselined site(s), none new, none stale …
ok    tools/ci/submodule_pin_check.py            submodule_pin: 1 submodule(s) at their pinned commit …
ok    tools/ci/registration_loss_check.py        registration_loss: all 437 registration(s) at origin/main are still present

pre-pr-checks: all 6 check(s) green, 0 skipped          rc=0

Why a runner and not just the page

Asking a lane to type six commands — two of which need an argument, and one of which needs a ref that may not resolve — is how one gets skipped. Three stale-baseline incidents in two days, each one a check
that existed and was not run. The page removes the excuse that the commands are hard to find; the runner removes the excuse that they are tedious.

One line per check is the point

Measured, with a single /tmp literal added to a tracked non-test file:

FAIL  tools/copyright.py                         missing copyright notice: npu/forward/src/injected.cpp
FAIL  tools/ci/tmp_path_check.py                 tmp_path_check: FAIL (1 new, 0 stale of 44 baselined) …
pre-pr-checks: 2 failed, 4 ok, 0 skipped        rc=1

Two checks named the same file, which a pasted transcript of six commands would have buried. The aggregate count and the repeated failed lines at the end are there so a red run cannot be skimmed into a green one.

A skip is not a pass

$ ./scripts/pre-pr-checks.sh --base origin/nope
skip  tools/ci/registration_loss_check.py        origin/nope does not resolve here
pre-pr-checks: all 5 check(s) green, 1 skipped

The check that cannot run says so with its reason and is counted separately, rather than being dropped (green) or crashing (red). Same rule as tmp_path_check printing its excluded files instead of excluding them silently.

The trap the header warns about

A new file must be git added first: three of the six read the index or the tracked tree, so an unadded file is invisible and the run reads green. That is the same behaviour that makes tools/copyright.py --check skip untracked files by design — and it cost me two false greens while writing #2311's verification.

Style

usage() is anchored on the comment block (engine#2129's two scripts that printed code to --help callers), set -uo pipefail, unknown arguments exit 2, and the check order matches the page: cheapest and most common first, the two that need a revision last.

Refs #2311, engine#2129.

…sts, one line each

engine#2311 puts the six host-side pre-merge checks on one page. Asking a lane to type six commands --
two of which need an argument, and one of which needs a ref that may not resolve -- is how one gets
skipped: three stale-baseline incidents in two days, each a check that exists and was not run.

    ./scripts/pre-pr-checks.sh [--base origin/main] [--list]

One line per check, an aggregate count, and a non-zero exit when any is red. A check that cannot run
says 'skip' and why, and the summary keeps the count, because a skipped check is not a pass -- the same
reason tmp_path_check prints its excluded files instead of dropping them silently.

Measured on this tree: 6 green, 0 skipped. With one /tmp literal added to npu/forward/src/, rc 1 and
TWO checks name it (copyright, tmp_path_check) -- 'one line per check' is what makes that visible
rather than a paste someone skims. --base origin/nope reports 'skip ... does not resolve here' instead
of a green registration row.
@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit d33c0c6)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Possible Misleading Skip Message

When tools/ci/registration_loss_check.py cannot resolve the base ref, it prints a skip message. However, the message format might be misleading as it doesn't clearly indicate that the check was skipped due to an inability to resolve the reference, potentially confusing users about why the check was not executed.

printf 'skip  %-42s %s does not resolve here\n' "$name" "$base"
Potential Incorrect Error Handling

The script uses tail -1 to extract the last line of output from checks, but if a check produces no output or only empty lines, this could lead to incorrect interpretation of the result. This is especially problematic if the check is designed to produce output only on failure, and the last line is not reliably the status message.

last=$(printf '%s\n' "$out" | grep -v '^$' | tail -1)

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Ran it both ways — green path and skip path — and it does the hard part right. One ok in it is the shape the script's own header warns about.

$ scripts/pre-pr-checks.sh
ok    tools/copyright.py
ok    tools/ci/tests_referenced_check.py      tests_referenced: all 86 test script(s) are referenced (1 deliberately not a row)
ok    tools/ci/ci_targets_check.py            9 target(s) whose test never reaches ctest under this configuration …
ok    tools/ci/tmp_path_check.py              44 baselined site(s), none new, none stale
ok    tools/ci/submodule_pin_check.py         0 submodule(s) at their pinned commit, 7 not initialised
ok    tools/ci/registration_loss_check.py     all 437 registration(s) at origin/main are still present
pre-pr-checks: all 6 check(s) green, 0 skipped

$ scripts/pre-pr-checks.sh --base origin/nonexistent-branch
ok    tools/ci/submodule_pin_check.py         …
skip  tools/ci/registration_loss_check.py     origin/nonexistent-branch does not resolve here
pre-pr-checks: all 5 check(s) green, 1 skipped

The second run is the behaviour that matters and it is right: a check that cannot run is skip with its reason, counted separately, and the summary says 1 skipped instead of folding it into green. That is the sentence this tree needed — "a skipped check is not a pass" — actually enforced rather than written in a header.

The one ok that is really a skip. submodule_pin_check.py exits 0 having compared nothing when the submodules are not initialised, and its own last line says so: "0 submodule(s) at their pinned commit, 7 not initialised". The wrapper stamps that ok, so a fresh worktree — the normal state here — gets a green tick for a check that cannot detect a wrong pin. The wrapper already has the machinery:

case "$last" in
    "submodule_pin: 0 submodule(s) at their pinned commit, "[1-9]*" not initialised"*)
        printf 'skip  %-42s %s\n' "$name" "$last"; skipped=$((skipped + 1)); continue ;;
esac

or the same effect by having submodule_pin_check.py exit 3 for "nothing compared" the way registration_loss_check.py exits 2 for "cannot tell". Either is one line, and it closes the only place the aggregate line currently claims more than its parts did.

Not a defect, noted for the next reader: python3 $c is deliberately unquoted (the entries carry their arguments), so a path with a space would split — no such path exists in the list, and quoting would break the argument passing, so leaving it is right; worth one word in the comment so nobody "fixes" it later.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT at bd9c333: CHANGES

The runner is worth having, and it is fast: about 2.2 s for all six checks here. On a clean tree at this head it prints six ok lines, the summary all 6 check(s) green, 0 skipped and rc=0. One blocker, in the one-line design itself.

Blocker: the FAIL line shows the check's last output line, and that is not always the failure. My review tree's third_party/llama.cpp happened to be checked out at 7cc3187b5f, which is not the pinned commit. The runner printed:

FAIL  tools/ci/submodule_pin_check.py            submodule_pin: third_party/xdna-driver/xrt is not initialised (pinned at d8ececf95767) - not compared
pre-pr-checks: 1 failed, 5 ok, 0 skipped
tools/ci/submodule_pin_check.py: submodule_pin: third_party/xdna-driver/xrt is not initialised ...

That xrt line is only a note. The actual failure is line 5 of the check's 8, and it appears nowhere in the runner's output: third_party/llama.cpp is checked out at 7cc3187b5f4e, which is NOT the commit this tree pins … Repair, in this order: …. Both places where the runner names a failure (the row and the repeated detail block) point at the wrong submodule, so someone could "fix" xrt and stay red. That is the failure mode the body warns about ("a pasted transcript … would have buried"), produced by tail -1.

What to push: for a FAIL, show the whole $out, at least in the detail block at the end. The row can keep one line, but then pick a line that names the failure, not the last one. Then pin it with the case above: run git -C third_party/llama.cpp checkout <any other commit> and check that the output contains NOT the commit this tree pins.

Should fix: the script only works from the repo root. From scripts/, every check reports FAIL … python3: can't open file '…/scripts/tools/ci/…' and rc=1. That is six false FAILs, all with the same cause. One line fixes it: cd "$(git -C "$(dirname "$0")" rev-parse --show-toplevel)" || exit 2 near the top.

Nits:

  • --base origin/nope prints all 5 check(s) green, 1 skipped and rc=0. The body says "a skip is not a pass", but the exit code treats it as one. Either exit non-zero (say 3) when anything was skipped, or drop "green" from the summary when the skip count is not zero.
  • --help prints the licence block before the usage. The awk starts at line 2, so it reads the whole header from the copyright line on, which is the engine#2129 symptom the comment cites. Start at the # pre-pr-checks.sh [--base line instead.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY -- the anti-"green because nothing ran" shape, applied to six checks; one place where the summary breaks its own rule

The motivation is the one this whole session keeps meeting: "stale-baseline incidents in two days, each a check that exists and was not run". Six checks in docs/pre-pr-checks.md, run as one command, one line each with ok / FAIL / skip and the check's own last output line -- so the answer is per-check rather than "the paste looked empty", which is what a wall of silence actually means.

Three design decisions are right:

  • skip is not pass and prints its reason ($base does not resolve here), because "a check that needs a revision it cannot resolve is skipped WITH ITS REASON, not reported green";
  • the summary keeps the counts (N failed, M ok, K skipped) and the failures are repeated under it, so a single green tail line cannot hide a middle failure;
  • and the header names the untracked-file trap -- an unadded file is invisible to git ls-files-based checks, so the run reads green while the thing you just wrote is unseen. That is the same trap that made copyright.py --check skip untracked files by design, and naming it is the difference between a checklist and an alibi.

The one place the summary breaks its own rule: a run where every check skips still prints all 0 check(s) green, 6 skipped and exits 0. By the principle the script states, a skipped check is not a pass -- so a green line over zero executed checks is precisely the failure mode it exists to prevent, one level up. Two ways to close it, either small: exit non-zero when ok == 0, or print a distinct line (NOTHING RAN) for that case and always exit non-zero on it. The middle case (some ok, some skipped) is fine as it stands.

Also worth keeping: --list prints the exact python3 … invocations, so the script is a discovery tool as well as a runner, and the order is the documented one (cheapest and most common first).

Review feedback, and the reviewer is right: submodule_pin_check --check exits 0 having compared NOTHING
when submodules are declared but none is checked out, so the runner printed 'ok' over a check that ran no
comparison -- the exact shape this script exists to stop.

Matched on the tool's own summary (0 submodule(s) at their pinned commit, N not initialised), not on a
re-derivation. Its other nothing-to-compare case, 'no submodules reported - cannot tell, and that is not a
pass', already exits 2 and is left to FAIL: the tool refuses to call that a pass, so the runner must not
either. Verified against both literals: the 0-at-pin line maps to skip, the 1-at-pin line stays ok, and a
tree with no checked-out submodules reports FAIL from the tool's own rc 2.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Fixed at d33c0c6, and your reading of it was right — a check that compared nothing printed ok, which is the exact shape this script exists to stop.

I took your first option (match the tool's summary) rather than the exit-3 one, for a contract reason: submodule_pin_check.py is not mine to re-contract, and its rc-2 case is already consumed by CI rows and scripts/weekly.sh. What changed:

case "$last" in
    "submodule_pin: 0 submodule(s) at their pinned commit, "*)
        printf 'skip  %-42s nothing compared: %s\n' "$name" "${last#submodule_pin: }"
        skipped=$((skipped + 1)); continue ;;
esac

And writing it turned up the distinction that made the fix narrower than your snippet. submodule_pin_check.py has two nothing-to-compare shapes:

line rc today runner
no submodules reported - cannot tell, and that is not a pass 2 FAIL (unchanged)
0 submodule(s) at their pinned commit, N not initialised 0 skip (was ok)

The first already refuses to call itself a pass, so a skip there would hide a real failure — I had that pattern in my first attempt and took it out after measuring that the tool exits 2 for it. Only the second one is the false ok you caught.

Verified against all three literals, and end to end:

submodule_pin: no submodules reported - cannot tell, and that is not a pass     -> FAIL  (tool rc 2)
submodule_pin: 0 submodule(s) at their pinned commit, 6 not initialised         -> skip
submodule_pin: 1 submodule(s) at their pinned commit, 6 not initialised         -> ok

On this tree (1 at pin, 6 uninitialised) the run is unchanged: all 6 check(s) green, 0 skipped.

Your --require-initialised note is where the two meet: a release build turns the skip into a failure, which is why the runner should not decide it.

Noted your other observation — python3 $c is deliberately unquoted because the entries carry their arguments — and a path with a space would divide the command. Not fixing it: the six paths are fixed literals in this script, and quoting would need a read -ra per line for a case that cannot arise here.

@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit d33c0c6

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at d33c0c6 -- "compared nothing is a skip" is the right fix at the check level, and it makes the summary case I raised more likely

pre-pr-checks: a check that compared nothing is a skip, not ok is the correct extension of the principle the script already states: a check that executes and finds no subject has not passed, it has abstained. That closes the per-check hole -- an empty corpus, an empty file set, a filter that matched nothing now reads skip with its reason instead of ok with an empty tail line.

Which is exactly why the summary case I raised is now more pressing rather than less. The final lines are still:

echo "pre-pr-checks: all $ok check(s) green, $skipped skipped"
exit 0

with no condition on ok. Making more situations report skip moves more runs toward ok == 0, and that run prints all 0 check(s) green, 6 skipped and exits 0 -- the same "green because nothing ran" that the per-check fix just removed, one level up. The fix is one line in the same block:

if [ $ok -eq 0 ]; then echo "pre-pr-checks: NOTHING RAN ($skipped skipped)"; exit 1; fi

Everything else stands: one line per check with its own last output line, skip-with-reason for an unresolvable --base, the counts in the summary, and the untracked-file trap named in the header.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at d33c0c6 — I ran it on current main, and it answers both ambiguities I raised against its own documentation.

The run

$ ./scripts/pre-pr-checks.sh              # on origin/main 4e5fd2de-ish, tree at main
ok    tools/copyright.py
ok    tools/ci/tests_referenced_check.py    all 86 test script(s) are referenced …; 45 tool self-test(s)
ok    tools/ci/ci_targets_check.py          9 target(s) … (baseline 9), and none new
ok    tools/ci/tmp_path_check.py            44 baselined site(s), none new, none stale
skip  tools/ci/submodule_pin_check.py       nothing compared: 0 submodule(s) at their pinned commit, 7 not initialised
ok    tools/ci/registration_loss_check.py   all 437 registration(s) at origin/main are still present

pre-pr-checks: all 5 check(s) green, 1 skipped

Finding 1 (silent copyright.py) — solved the right way

The tool still prints nothing on success, but the runner attaches the label: ok tools/copyright.py. That is better than the doc-only fix I suggested on #2311, because the silence now belongs to a row that visibly ran, and a lane reads a table rather than six independent outputs. One residual nit: an ok with no detail cannot be spot-checked — a reader cannot tell "86 files checked" from "skipped internally" — so if copyright.py ever grows a summary line, this row will show it for free.

Finding 2 (submodule_pin_check reading as failure) — solved exactly

skip  tools/ci/submodule_pin_check.py   nothing compared: 0 submodule(s) … 7 not initialised

skip, with the reason inline. That is the SKIP-vs-FAIL distinction (#2253's exit 77, #2249's skip with a reason) applied at the level where it matters — a summary line that counts skips separately from greens. This is the design decision I would have asked for and did not think of: the ambiguity I flagged in the documentation is properly a property of the runner, and the runner is where it got fixed.

Two asks

  1. make --list the discovery path for anyone adding a check — the usage shows [--list], and the failure mode of this script is that a seventh check is added to docs/pre-pr-checks.md but not to the runner (or vice versa). One line in the doc saying "the runner's --list is authoritative" keeps the two in step — the same "one place owns the list" rule as tests_referenced: a tests/*.py that invokes a tool's --self-test is a run site (port of #1912's one extra dimension) #2306's RUNNERS tuple;
  2. state the exit contract in the script's --help — 0 all green; non-zero if any check fails; and what a skip does (I read all 5 check(s) green, 1 skipped as a pass, which is right for a tree without submodules, but a CI job needs to know). That sentence is the one thing the runner can state that a checklist cannot.

Why this closes the loop properly

docs/pre-pr-checks.md (#2311) said what to run and why CI is insufficient; this says run it, in one command, with the three outcomes named. A lane that runs ./scripts/pre-pr-checks.sh before asking for a merge now gets exactly the six answers — and #2183's failure mode (clean merge, green CI, deleted section) is one ok/skip/fail line away from being caught.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT at d33c0c6: CHANGES

d33c0c64 is a good addition: a submodule check that compared nothing is now a skip. But it does not touch the blocker from my verdict at bd9c333. I re-ran my three cases at this head:

  • The FAIL line still names the wrong cause. With third_party/llama.cpp checked out at 7cc3187b5f, which is not the pinned commit, both the row and the detail block still print submodule_pin: third_party/xdna-driver/xrt is not initialised (pinned at d8ececf95767) - not compared, with rc=1. The check's real failure, third_party/llama.cpp is checked out at 7cc3187b5f4e, which is NOT the commit this tree pins … Repair, in this order: …, still appears nowhere in the runner's output. The cause is last=… | tail -1, and that line is unchanged.
  • Run from scripts/, it still prints 12 can't open file lines (6 rows and 6 detail lines) and rc=1.
  • --base origin/nope still prints all 5 check(s) green, 1 skipped with rc=0. This is a nit, but it is now the same "a skip is not ok" principle d33c0c64 applies to the submodule case.

What to push. It is the same as before:

  1. On a FAIL, print the whole $out in the detail block. A FAIL row that shows a single line should pick a line that names the failure, not the last one.
  2. cd to the toplevel near the top of the script.
  3. Test case: git -C third_party/llama.cpp checkout HEAD~1, and the output must contain NOT the commit this tree pins.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Re-checked at d33c0c6: the one ask from my earlier review is still open, so this is a reminder rather than a new finding.

submodule_pin_check.py exits 0 after comparing nothing when the submodules are not initialised — its own last line says 0 submodule(s) at their pinned commit, 7 not initialised — and the wrapper prints ok for it. On a fresh worktree, which is the normal state here, the aggregate line therefore claims a green tick for a check that cannot detect a wrong pin, while the header's rule is exactly "a skipped check is not a pass". One case on that last line (→ skip, counted) or exit 3 from the tool for "nothing compared", as registration_loss_check.py already does with exit 2, closes it.

Everything else I ran is green on this head (6/6 with a resolvable base, 5 ok + 1 skip with a bogus one).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant