8000 ci: check only the packages and stacks a pull request changes by bryan-minimal · Pull Request #936 · gominimal/pkgs · GitHub
[go: up one dir, main page]

Skip to content
Merged
29 changes: 21 additions & 8 deletions .github/scripts/blast_radius_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
Dependency edges come from two places, both computed from the checkout with
no network and no `minimal dump`:

1. Recipe imports: `import "../<pkg>/build.ncl"` in packages/*/build.ncl —
the real dependency graph as recipes express it (build and runtime deps
are both declared this way).
1. Recipe imports: `import "../<pkg>/build.ncl"`, or any other path into
another package's directory (a shared helper such as
`../hex0/amd64-ladder.ncl`), in any .ncl under packages/<pkg>/ — the real
dependency graph as recipes express it (build and runtime deps are both
declared this way).
2. Name references: any quoted string in a non-package .ncl (stacks/,
profiles/, ...) matching a package name. Stacks reference packages by
name (`build_packages = ["go", ...]`), not by import, so an import-only
Expand All @@ -40,7 +42,13 @@
import sys
from pathlib import Path

IMPORT_RE = re.compile(r'import "\.\./([a-z0-9_.+-]+)/build\.ncl"')
# Any path into another package's directory: `import "../gcc/build.ncl"`, a
# shared helper (`import "../hex0/amd64-ladder.ncl"`), or a file a helper
# names. Matching every `"../<pkg>/` string rather than only build.ncl imports
# makes the edge coarse (importing hex0's ladder helper counts as depending on
# hex0) but never misses one, which is the direction both this report and the
# CI scoping in ci_scope.py need.
IMPORT_RE = re.compile(r'"\.\./([a-z0-9_.+-]+)/')
STRING_RE = re.compile(r'"([a-z0-9_.+-]+)"')


Expand All @@ -49,10 +57,15 @@ def load_graph(root: Path):
pkgs = {p.name for p in pkg_dir.iterdir() if (p / "build.ncl").is_file()}
importers = {p: set() for p in pkgs}
for p in sorted(pkgs):
src = (pkg_dir / p / "build.ncl").read_text(encoding="utf-8", errors="replace")
for dep in set(IMPORT_RE.findall(src)):
if dep in pkgs and dep != p:
importers[dep].add(p)
# Every .ncl in the directory, not just build.ncl: a helper's own
# imports (jdk/cve-2026-111943.ncl imports ca-certificates') are
# dependencies of every package that imports the helper, and the
# coarse edge to the helper's package carries them there.
for f in sorted((pkg_dir / p).rglob("*.ncl")):
src = f.read_text(encoding="utf-8", errors="replace")
for dep in set(IMPORT_RE.findall(src)):
if dep in pkgs and dep != p:
importers[dep].add(p)

name_refs = {} # pkg -> sorted list of non-package .ncl files naming it
for f in sorted(root.rglob("*.ncl")):
Expand Down
153 changes: 153 additions & 0 deletions .github/scripts/ci_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Decide what a pull request's minimal-check must cover.

Reads the PR's changed files (the pulls/<n>/files API records: `filename`,
`previous_filename` on a rename, `status`) and writes `mode=full|scoped|none`
plus, for `scoped`, the `packages=` and `stacks=` lists to GITHUB_OUTPUT.

A scoped check covers each touched package AND every package that depends on
it, transitively, plus the stacks that name any of those. A change that is
only right for its own package can still break its importers (renaming gcc's
`libstdcpp` output breaks every `subsetOf gcc` caller), so checking the
touched directories alone would pass a PR that breaks the catalog. The graph
is blast_radius_report.load_graph: every `"../<pkg>/` path in any .ncl under a
package directory, so an edit to a shared helper (hex0's amd64-ladder.ncl)
reaches the packages that import it.

Everything falls back to the full check whenever the scope cannot be trusted
or would not be cheaper:

- the API returned fewer records than the PR's `changed_files` (the files
endpoint stops at 3,000, and `--paginate` does not lift that);
- a file outside packages/<pkg>/ and stacks/<stack>/ changed (workflows,
scripts, minimal.toml, or a loose file such as packages/common.ncl);
- a package or stack directory is gone: `mip check` would be handed a name
that no longer exists, and its former importers need checking anyway;
- the scoped set exceeds MAX_SCOPED packages. The dependents distribution has
a gap there: 485 of 593 packages reach at most 48 others, and past that it
climbs quickly (go 57, git 73, gettext 103, then 140+). Those wide packages
are where breakage spreads, and checking 50+ packages costs most of what
the full check does.

A rename counts both its paths, so moving a file out of a package checks the
package it left, and renaming stack.ncl to NOTES.md is not mistaken for prose.
Only prose (*.md outside packages/ and stacks/, legal/) and tests/<name>/
projects are set aside; a PR of nothing else checks nothing. A .md inside a
recipe directory is NOT prose: a package can ship one (`{ file = "NOTES.md" }
| Local`, or read it from build.sh), so editing it checks that package. A tests/<name>/ directory is a
self-contained min project with its own [upstream] pin (AGENTS.md "Tests",
gominimal/pkgs#976): mip never evaluates it as a package, so it cannot break
one, and its own workflow (if any) runs it. A loose file directly under
tests/ is not a project and still runs the full check.
"""

import argparse
import json
import os
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__fil 8000 e__).resolve().parent))
from blast_radius_report import load_graph, transitive_dependents # noqa: E402

MAX_SCOPED = 50
PROSE_RE = re.compile(r"^(?!(packages|stacks)/)(.*/)?[^/]*\.md$|^legal/")
TESTS_RE = re.compile(r"^tests/[^/]+/")
SCOPED_RE = re.compile(r"^(packages|stacks)/([^/]+)/")


def decide(records, changed_files, root: Path, max_scoped=MAX_SCOPED):
"""Return (mode, packages, stacks, reasons). packages/stacks are sorted lists."""
reasons = []
if changed_files is not None and len(records) != changed_files:
return "full", [], [], [
f"the files API returned {len(records)} of the PR's {changed_files} changed files"
]

paths = []
for r in records:
paths.append(r["filename"])
if r.get("previous_filename"):
paths.append(r["previous_filename"])
code = [p for p in paths if not PROSE_RE.search(p) and not TESTS_RE.match(p)]

other = [p for p in code if not SCOPED_RE.match(p)]
if other:
return "full", [], [], ["the PR changes files outside packages/<name>/ and stacks/<name>/:"] + [
" " + p for p in sorted(set(other))
]

seed_pkgs, seed_stacks = set(), set()
for p in code:
kind, name = SCOPED_RE.match(p).groups()
(seed_pkgs if kind == "packages" else seed_stacks).add(name)
if not seed_pkgs and not seed_stacks:
return "none", [], [], ["the PR changes only prose or tests/<name>/ projects"]

gone = sorted(
[f"packages/{n}" for n in seed_pkgs if not (root / "packages" / n / "build.ncl").is_file()]
+ [f"stacks/{n}" for n in seed_stacks if not (root / "stacks" / n / "stack.ncl").is_file()]
)
if gone:
return "full", [], [], ["the PR removes " + ", ".join(gone) + "; whatever used it needs checking"]

pkgs, importers, name_refs = load_graph(root)
scoped = set(seed_pkgs)
for p in seed_pkgs:
deps = transitive_dependents(p, importers)
scoped |= deps
if deps:
reasons.append(f"{p}: {len(deps)} dependent package(s)")
if len(scoped) > max_scoped:
return "full", [], [], reasons + [
f"the scoped set is {len(scoped)} packages, over the {max_scoped} limit"
]

stacks = set(seed_stacks)
for p in scoped:
for ref in name_refs.get(p, ()):
m = re.match(r"^stacks/([^/]+)/stack\.ncl$", ref)
if m:
stacks.add(m.group(1))
return "scoped", sorted(scoped), sorted(stacks), reasons


def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument(
"--files-jsonl", required=True, help="pulls/<n>/files records, one JSON object per line"
)
ap.add_argument("--changed-files", type=int, required=True, help="the PR's changed_files count")
ap.add_argument("--root", default=".")
args = ap.parse_args()

records = [json.loads(line) for line in Path(args.files_jsonl).read_text().splitlines() if line.strip()]
mode, packages, stacks, reasons = decide(records, args.changed_files, Path(args.root).resolve())

headline = {
"full": "full check",
"none": "nothing to check",
"scoped": f"scoped check: {len(packages)} package(s), {len(stacks)} stack(s)",
}[mode]
print(headline)
for r in reasons:
print(" " + r)
if mode == "scoped":
print(" packages: " + " ".join(packages))
print(" stacks: " + (" ".join(stacks) or "-"))

out = os.environ.get("GITHUB_OUTPUT")
if out:
with open(out, "a", encoding="utf-8") as fh:
fh.write(f"mode={mode}\n")
if mode == "scoped":
# Ten packages or fewer don't need the disk-free step.
fh.write("small=" + ("true" if len(packages) <= 10 else "false") + "\n")
fh.write("packages=" + ",".join(packages) + "\n")
fh.write("stacks=" + ",".join(stacks) + "\n")
return 0


if __name__ == "__main__":
sys.exit(main())
194 changes: 194 additions & 0 deletions .github/scripts/test_ci_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Tests for ci_scope.decide against this checkout's real package graph.

Each case also runs `old_decide`, the previous shell logic in ci.yml (touched
directories only), and asserts it gets the case wrong. That negative control
keeps every case honest: a case the old logic already handled would prove
nothing about the fix.

Run: python3 -I .github/scripts/test_ci_scope.py
"""

import re
import sys
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from blast_radius_report import load_graph, transitive_dependents # noqa: E402
from ci_scope import decide # noqa: E402

ROOT = Path(__file__).resolve().parents[2]
UNLIMITED = 10**6


def old_decide(records):
"""The pre-review ci.yml scope step, transliterated."""
files = [r["filename"] for r in records]
code = [f for f in files if not re.search(r"(^|/)[^/]*\.md$|^legal/", f)]
pkgs = sorted({m.group(1) for f in code if (m := re.match(r"^packages/([^/]*)/", f))})
stacks = sorted({m.group(1) for f in code if (m := re.match(r"^stacks/([^/]*)/", f))})
if [f for f in code if not re.match(r"^(packages|stacks)/", f)]:
return "full", [], []
if not pkgs and not stacks:
return "none", [], []
return "scoped", pkgs, stacks


def rec(filename, status="modified", previous=None):
r = {"filename": filename, "status": status}
if previous:
r["previous_filename"] = previous
return r


def files_referencing(needle):
out = set()
for f in (ROOT / "packages").rglob("*.ncl"):
if needle in f.read_text(encoding="utf-8", errors="replace"):
out.add(f.relative_to(ROOT / "packages").parts[0])
return out


class Graph:
pkgs, importers, name_refs = load_graph(ROOT)


def leaves(n):
"""n packages nothing imports and no stack names, for the cheap cases."""
names = sorted(
p for p in Graph.pkgs if not Graph.importers[p] and not Graph.name_refs.get(p)
)
assert len(names) >= n
return names[:n]


class Scope(unittest.TestCase):
def check(self, records, changed_files=None, max_scoped=None):
if changed_files is None:
changed_files = len(records)
kw = {} if max_scoped is None else {"max_scoped": max_scoped}
return decide(records, changed_files, ROOT, **kw)

def test_gcc_change_reaches_every_subsetof_gcc_caller(self):
callers = files_referencing("subsetOf gcc")
self.assertGreater(len(callers), 100)
records = [rec("packages/gcc/build.ncl")]
mode, pkgs, _, _ = self.check(records, max_scoped=UNLIMITED)
self.assertEqual(mode, "scoped")
self.assertLessEqual(callers - {"gcc"}, set(pkgs))
# At the real limit that is far too many: the full check runs.
self.assertEqual(self.check(records)[0], "full")
# Negative control: the old logic checked gcc alone.
self.assertEqual(old_decide(records), ("scoped", ["gcc"], []))

def test_shared_helper_reaches_its_importers(self):
importers = files_referencing('"../hex0/amd64-ladder.ncl"')
self.assertGreaterEqual(len(importers), 40)
# The edge itself, not just reachability: most importers also reach
# hex0 through the stage0 chain, which would hide a regex that only
# matches build.ncl imports.
self.assertLessEqual(importers - {"hex0"}, Graph.importers["hex0"])
records = [rec("packages/hex0/amd64-ladder.ncl")]
mode, pkgs, _, _ = self.check(records, max_scoped=UNLIMITED)
self.assertLessEqual(importers, set(pkgs) | {"hex0"})
self.assertEqual(self.check(records)[0], "full")
self.assertEqual(old_decide(records), ("scoped", ["hex0"], []))

def test_helper_imported_by_a_helper(self):
# jdk-21 imports jdk/cve-2026-111943.ncl, which imports
# ca-certificates/cve-2026-111943.ncl.
records = [rec("packages/ca-certificates/cve-2026-111943.ncl")]
# Reachability is checked with the cap lifted, as
# test_shared_helper_reaches_its_importers does: how many packages
# depend on ca-certificates changes as pkgs grows (it passed 50 when
# node and node-lts gained it), so the default cap's verdict here is
# not a property of the helper chain.
mode, pkgs, _, _ = self.check(records, max_scoped=UNLIMITED)
self.assertEqual(mode, "scoped")
self.assertIn("jdk-21", pkgs)
self.assertNotIn("jdk-21", old_decide(records)[1])

def test_rename_across_packages_checks_both(self):
a, b = leaves(2)
records = [rec(f"packages/{b}/fix.patch", "renamed", previous=f"packages/{a}/fix.patch")]
mode, pkgs, _, _ = self.check(records)
self.assertEqual((mode, pkgs), ("scoped", sorted([a, b])))
self.assertEqual(old_decide(records), ("scoped", [b], []))

def test_rename_to_prose_is_not_prose(self):
stack = sorted(p.parent.name for p in (ROOT / "stacks").glob("*/stack.ncl"))[0]
records = [rec(f"stacks/{stack}/NOTES.md", "renamed", previous=f"stacks/{stack}/stack.ncl")]
mode, _, stacks, _ = self.check(records)
self.assertEqual(mode, "scoped")
self.assertIn(stack, stacks)
self.assertEqual(old_decide(records)[0], "none")

def test_prose_only_checks_nothing(self):
records = [rec("README.md"), rec("docs/guide.md"), rec("legal/x.txt")]
self.assertEqual(self.check(records)[0], "none")
self.assertEqual(old_decide(records)[0], "none") # unchanged behaviour

def test_md_inside_a_recipe_checks_the_recipe(self):
# A package can ship a .md (`{ file = "NOTES.md" } | Local`); an edit to
# it alone must not scope to "none" (review on #936).
(a,) = leaves(1)
records = [rec(f"packages/{a}/NOTES.md")]
self.assertEqual(self.check(records)[:2], ("scoped", [a]))
self.assertEqual(old_decide(records)[0], "none") # the gap this closes

def test_tests_project_checks_nothing(self):
# tests/<name>/ is a self-contained min project (pkgs#976), never a package.
records = [rec("tests/tls-trust/run.sh"), rec("tests/tls-trust/lib/common.sh", "added")]
self.assertEqual(self.check(records)[0], "none")
self.assertEqual(old_decide(records)[0], "full")

def test_tests_project_plus_package_scopes_to_the_package(self):
(a,) = leaves(1)
records = [rec("tests/tls-trust/minimal.toml"), rec(f"packages/{a}/build.ncl")]
self.assertEqual(self.check(records)[:3], ("scoped", [a], []))
self.assertEqual(old_decide(records)[0], "full")

def test_loose_file_under_tests_runs_everything(self):
records = [rec("tests/run-all.sh", "added")]
self.assertEqual(self.check(records)[0], "full")
self.assertEqual(old_decide(records)[0], "full") # unchanged: not a project

def test_truncated_file_list_runs_everything(self):
(a,) = leaves(1)
records = [rec(f"packages/{a}/build.ncl")]
self.assertEqual(self.check(records, changed_files=3001)[0], "full")
self.assertEqual(old_decide(records)[0], "scoped")

def test_removed_package_runs_everything(self):
records = [rec("packages/no-such-package-936/build.ncl", "removed")]
self.assertEqual(self.check(records)[0], "full")
self.assertEqual(old_decide(records), ("scoped", ["no-such-package-936"], []))

def test_loose_file_under_packages_runs_everything(self):
records = [rec("packages/common.ncl", "added")]
self.assertEqual(self.check(records)[0], "full")
self.assertEqual(old_decide(records)[0], "none")

def test_stack_naming_a_changed_package_is_checked(self):
for p in sorted(Graph.pkgs):
refs = [r for r in Graph.name_refs.get(p, ()) if r.startswith("stacks/")]
if refs and len(transitive_dependents(p, Graph.importers)) <= 5:
break
else:
self.skipTest("no low-fan-out package is named by a stack")
stack = refs[0].split("/")[1]
records = [rec(f"packages/{p}/build.ncl")]
mode, _, stacks, _ = self.check(records)
self.assertEqual(mode, "scoped")
self.assertIn(stack, stacks)
self.assertEqual(old_decide(records), ("scoped", [p], []))

def test_leaf_change_stays_small(self):
# The point of scoping: a leaf package checks itself and nothing else.
(a,) = leaves(1)
self.assertEqual(self.check([rec(f"packages/{a}/build.ncl")])[:3], ("scoped", [a], []))


if __name__ == "__main__":
unittest.main(verbosity=2)
Loading
Loading
0