ci: a truncated PR file list selects every lane

The pulls/files API lists at most 3000 files and says nothing when it stops,
and the lane decision is only as complete as that list: a cut list can drop
exactly the file that needed a lane.

select-lanes.sh takes --expect-files N (the PR's changed_files; batch rows
take "expect_files") and decides full with a file_list_truncated reason when
the list has fewer distinct paths than N (a complete list never does: renames
only add their old path), when N is at or over the 3000-file cap, or when the
list itself reaches the cap. The tier still classifies what was listed. An
empty or non-numeric count is a usage error, so a workflow that loses the
count fails loudly instead of skipping the check.

pr.yml passes github.event.pull_request.changed_files. The selector contract
covers short, complete, rename-inflated, at-cap and just-under-cap lists and
the github-output and batch channels; the wiring contract requires the count
to be passed.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-09-30 23:46:46 +02:00
1 parent ae5bb0bdea
commit d3efe3baec
4 files changed
+109 -16

No files matched your search

+5 -1
View File
@@ -108,10 +108,14 @@ jobs:
with:
persist-credentials: false
# The pulls/files API lists at most 3000 files: the PR's own count lets
# the selector notice a cut list and select everything instead.
- name: Select lanes
id: select
env:
CHANGED_FILES: ${{ github.event.pull_request.changed_files }}
run: |
scripts/ci/select-lanes.sh --format github-output "$RUNNER_TEMP/changed-files.txt" > "$RUNNER_TEMP/selection.txt"
scripts/ci/select-lanes.sh --format github-output --expect-files "$CHANGED_FILES" "$RUNNER_TEMP/changed-files.txt" > "$RUNNER_TEMP/selection.txt"
cat "$RUNNER_TEMP/selection.txt"
cat "$RUNNER_TEMP/selection.txt" >> "$GITHUB_OUTPUT"
# pr-smoke's legs, each tagged with its lane (kept in the job name,
+54 -14
View File
@@ -10,28 +10,35 @@
# `full` overrides it with every lane.
#
# It FAILS SAFE: full=true for tier T5, a path no rule knows, an empty file
# list, this script, any workflow, the test harness, Makefile.cbm (source
# registration and flag changes look identical in a file list) and vendored
# code. It is a pure function of the file SET: order, duplicates, blank lines
# and CRLF never change a byte of the output.
# list, a truncated one (file_list_truncated: the pulls/files API stops at
# 3000 files), this script, any workflow, the test harness, Makefile.cbm
# (source registration and flag changes look identical in a file list) and
# vendored code. It is a pure function of the file SET (and the expected
# count): order, duplicates, blank lines and CRLF never change a byte of the
# output.
#
# Usage: scripts/ci/select-lanes.sh [--format json|github-output] [FILE]
# Usage: scripts/ci/select-lanes.sh [--format json|github-output]
# [--expect-files N] [FILE]
# FILE (default stdin): changed paths, one per line.
# --expect-files N: the PR's changed_files; fewer distinct paths, or
# N or the list at the 3000-file API cap, means truncated -> full.
# json (default): {"full","lanes","reasons","tier"} on one line.
# github-output: tier=, full=, lanes=, reasons= lines ($GITHUB_OUTPUT).
# scripts/ci/select-lanes.sh --batch FILE
# FILE: JSON lines {"id":..., "files":[...]}; prints one json result
# per line with its "id" (the history replay's single process).
# FILE: JSON lines {"id":..., "files":[...], "expect_files"?: N};
# prints one json result per line with its "id" (the history
# replay's single process).
# scripts/ci/select-lanes.sh --list-lanes
# Every lane name, one per line (the universe ci-ok checks against).
# Exit: 0 = decided · 2 = usage error.
set -euo pipefail
usage() { sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//'; }
usage() { sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; }
FORMAT=json
MODE="select"
INPUT=""
EXPECT=""
while [ $# -gt 0 ]; do
case "$1" in
-h | --help)
@@ -49,6 +56,13 @@ while [ $# -gt 0 ]; do
[ $# -gt 1 ] && shift
;;
--list-lanes) MODE="list" ;;
--expect-files)
# An empty value (a workflow whose count went missing) must fail
# loudly, never quietly skip the truncation check.
EXPECT="${2:-}"
[ -n "$EXPECT" ] || EXPECT="missing"
[ $# -gt 1 ] && shift
;;
-*)
echo "select-lanes.sh: unknown option '$1'. Please consult --help." >&2
exit 2
@@ -70,6 +84,12 @@ json | github-output) ;;
exit 2
;;
esac
case "${EXPECT:-0}" in
*[!0-9]*)
echo "select-lanes.sh: --expect-files takes a file count. Please consult --help." >&2
exit 2
;;
esac
if [ "$MODE" = batch ] && [ -z "$INPUT" ]; then
echo "select-lanes.sh: --batch needs a file. Please consult --help." >&2
exit 2
@@ -88,12 +108,13 @@ if [ "$MODE" = select ] && [ -z "$INPUT" ]; then
INPUT="$STAGED"
fi
python3 - "$MODE" "$FORMAT" "$INPUT" <<'PY'
python3 - "$MODE" "$FORMAT" "$INPUT" "$EXPECT" <<'PY'
import json
import re
import sys
mode, fmt, source = sys.argv[1:4]
expect = int(sys.argv[4]) if sys.argv[4] else None
# ── Path -> category (first matching rule wins; order is part of the rule) ──
GATE_WORKFLOWS = {"pr.yml", "_test.yml", "_lint.yml", "_security.yml",
@@ -276,7 +297,23 @@ def triggers(path, cat):
return found
def select(paths):
LIST_CAP = 3000 # the pulls/files API lists at most this many files
def truncated(paths, expect):
"""A list that may be missing files: at or over the API cap, or shorter
than the PR's changed_files (a complete list is never shorter: every
changed file is listed, renames add their old path on top)."""
if len(paths) >= LIST_CAP:
return f"{len(paths)} paths listed, at the {LIST_CAP}-file API cap"
if expect is not None and expect >= LIST_CAP:
return f"the PR changes {expect} files, over the {LIST_CAP}-file API cap"
if expect is not None and len(paths) < expect:
return f"{len(paths)} paths listed for {expect} changed files"
return None
def select(paths, expect=None):
paths = sorted({p for p in paths if p})
by_cat, by_trigger = {}, {}
for path in paths:
@@ -302,7 +339,10 @@ def select(paths):
reasons.append("full: empty file list (fail safe)")
if tier == "T5-core":
reasons.append("full: tier T5-core")
full = tier == "T5-core" or bool(by_trigger) or not paths
short = truncated(paths, expect)
if short:
reasons.append(f"full: file_list_truncated: {short} (fail safe)")
full = tier == "T5-core" or bool(by_trigger) or not paths or bool(short)
if full:
lanes = set(FULL)
return {"full": full, "lanes": sorted(lanes), "reasons": sorted(reasons), "tier": tier}
@@ -324,13 +364,13 @@ elif mode == "batch":
for line in fh:
if line.strip():
row = json.loads(line)
print(dump({"id": row["id"], **select(row["files"])}))
print(dump({"id": row["id"], **select(row["files"], row.get("expect_files"))}))
elif fmt == "github-output":
decision = select(read_paths(source))
decision = select(read_paths(source), expect)
print(f"tier={decision['tier']}")
print(f"full={'true' if decision['full'] else 'false'}")
print(f"lanes={dump(decision['lanes'])}")
print(f"reasons={dump(decision['reasons'])}")
else:
print(dump(select(read_paths(source))))
print(dump(select(read_paths(source), expect)))
PY
+3
View File
@@ -81,6 +81,9 @@ pr = jobs(text("pr.yml"))
changes = pr.get("changes", "")
require("scripts/ci/select-lanes.sh --format github-output" in changes,
"pr.yml changes must run scripts/ci/select-lanes.sh --format github-output")
require("--expect-files" in changes and "github.event.pull_request.changed_files" in changes,
"pr.yml changes must pass the PR's changed_files to the selector (--expect-files), "
"so a truncated file list selects everything")
for key in ("tier", "full", "lanes"):
require(re.search(rf"(?m)^ {key}: \$\{{\{{ steps\.select\.outputs\.{key} \}}\}}$", changes),
f"pr.yml changes must output {key}")
+47 -1
View File
@@ -9,7 +9,8 @@
# workflow, the test harness, Makefile.cbm and vendored code select FULL;
# - determinism: order and duplicates never change a byte of the output;
# - one decision per input channel: stdin, a file, --batch and the
# github-output form all agree.
# github-output form all agree;
# - a truncated file list (the pulls/files API stops at 3000) selects FULL.
# The history replay (every September PR push + the real failures) is
# scripts/test-impact/replay-selector.sh, a separate step.
#
@@ -155,6 +156,51 @@ rc, _, err = run(["--definitely-not-a-flag"])
if rc != 2 or "Please consult --help." not in err:
failures.append("an unknown flag must exit 2 with 'Please consult --help.'")
# 7. A truncated file list is never trusted. The pulls/files API lists at most
# 3000 files, so fewer distinct paths than the PR's changed_files, a PR at
# the cap, or a list that reaches the cap selects FULL (file_list_truncated);
# the tier still classifies what was listed. A complete list decides as usual
# (renames add their old path, so a complete list may exceed the count).
DOCS = ["README.md", "docs/a.md", "docs/b.md"]
CAP_LIST = [f"docs/page{i:04d}.md" for i in range(3000)]
def truncation(label, files, args, want_full):
rc, out, err = run(args, "".join(f + "\n" for f in files))
if rc != 0:
failures.append(f"truncation: {label}: selector exited {rc}: {err.strip()}")
return
got = json.loads(out)
flagged = any("file_list_truncated" in r for r in got["reasons"])
if got["full"] is not want_full or flagged is not want_full:
failures.append(f"truncation: {label}: full={got['full']}, file_list_truncated "
f"reason={flagged}, expected {want_full}")
if want_full and set(got["lanes"]) != FULL:
failures.append(f"truncation: {label}: a truncated list must select every lane")
if got["tier"] != "T0a-docs":
failures.append(f"truncation: {label}: tier {got['tier']}, expected T0a-docs")
truncation("fewer paths than changed_files", DOCS, ["--expect-files", "5"], True)
truncation("complete list, count matches", DOCS, ["--expect-files", "3"], False)
truncation("renames list their old path too", DOCS, ["--expect-files", "2"], False)
truncation("a PR at the 3000-file cap", DOCS, ["--expect-files", "3000"], True)
truncation("a list that reaches the cap", CAP_LIST, [], True)
truncation("a complete list just under the cap", CAP_LIST[:2999], ["--expect-files", "2999"], False)
rc, out, _ = run(["--format", "github-output", "--expect-files", "5"], "".join(f + "\n" for f in DOCS))
if rc != 0 or "full=true" not in out.splitlines():
failures.append(f"truncation: github-output form must say full=true: {out!r}")
trunc_batch = work / "trunc.jsonl"
trunc_batch.write_text(json.dumps({"id": "t", "files": DOCS, "expect_files": 5}) + "\n", encoding="utf-8")
rc, out, _ = run(["--batch", str(trunc_batch)])
if rc != 0 or not json.loads(out.splitlines()[0])["full"]:
failures.append("truncation: --batch rows must honour expect_files")
for bad in ("many", ""): # "" = a workflow whose count went missing
rc, _, err = run(["--expect-files", bad], "README.md\n")
if rc != 2 or "Please consult --help." not in err:
failures.append(f"--expect-files {bad!r} must be a usage error (exit 2), never "
f"a silently skipped truncation check")
if failures:
print("LANE SELECTOR CONTRACT VIOLATED:")
for failure in failures: