From d3efe3baecac3347e5b45fd45d6afed3c42f8290 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 30 Sep 2026 23:46:46 +0200 Subject: [PATCH] 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 --- .github/workflows/pr.yml | 6 ++- scripts/ci/select-lanes.sh | 68 ++++++++++++++++++++++++------ tests/test_lane_wiring_contract.sh | 3 ++ tests/test_select_lanes.sh | 48 ++++++++++++++++++++- 4 files changed, 109 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9f45fb0cb..dbaca5098 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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, diff --git a/scripts/ci/select-lanes.sh b/scripts/ci/select-lanes.sh index 98cec269a..6d618bcb7 100755 --- a/scripts/ci/select-lanes.sh +++ b/scripts/ci/select-lanes.sh @@ -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 diff --git a/tests/test_lane_wiring_contract.sh b/tests/test_lane_wiring_contract.sh index f57bcb11a..a176079a7 100755 --- a/tests/test_lane_wiring_contract.sh +++ b/tests/test_lane_wiring_contract.sh @@ -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}") diff --git a/tests/test_select_lanes.sh b/tests/test_select_lanes.sh index a421051c2..6cdc4bf84 100755 --- a/tests/test_select_lanes.sh +++ b/tests/test_select_lanes.sh @@ -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: