mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
1 parent
ae5bb0bdea
commit
d3efe3baec
4 files changed
+109
-16
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user