diff --git a/.github/actions/repo-gate/action.yml b/.github/actions/repo-gate/action.yml index 4e7044d3..0e9da65b 100644 --- a/.github/actions/repo-gate/action.yml +++ b/.github/actions/repo-gate/action.yml @@ -1,6 +1,16 @@ name: Repository gate description: Check action pins and line-ending contracts. +inputs: + exclude-globs: + description: >- + Newline-separated git pathspec patterns, for example 'themes/PaperMod/**'. Each becomes a + `--exclude` argument for repo_gate.py. A caller vendoring a subtree it does not author can + scope every check's tracked-file scan out of it this way. Empty by default: nothing is + excluded, and every check keeps scanning the whole repository. + required: false + default: '' + runs: using: composite steps: @@ -8,4 +18,11 @@ runs: shell: bash env: GH_TOKEN: ${{ github.token }} - run: python3 "$GITHUB_ACTION_PATH/repo_gate.py" + EXCLUDE_GLOBS: ${{ inputs.exclude-globs }} + run: | + set -Eeuo pipefail + args=() + while IFS= read -r glob; do + [ -n "$glob" ] && args+=(--exclude "$glob") + done <<< "$EXCLUDE_GLOBS" + python3 "$GITHUB_ACTION_PATH/repo_gate.py" "${args[@]}" diff --git a/.github/actions/repo-gate/repo_gate.py b/.github/actions/repo-gate/repo_gate.py index 315d159d..46508234 100755 --- a/.github/actions/repo-gate/repo_gate.py +++ b/.github/actions/repo-gate/repo_gate.py @@ -115,9 +115,23 @@ def pin_resolves(nwo: str, sha: str, cache: dict[tuple[str, str], bool | None]) return cache[key] -def tracked(root: Path) -> list[str]: - out = sh("git", "-C", str(root), "ls-files") - return [l for l in out.split("\n") if l] +def tracked(root: Path, exclude: list[str] | None = None) -> list[str]: + """Every git-tracked path, narrowed by `exclude` pathspec patterns where the caller gives any. + + Each pattern becomes a `:!` exclude pathspec, appended to `git ls-files` after `--`. + A caller vendoring a subtree it does not author, per GOVERNANCE.md's carry-versus-reach test, + can scope every check out of that subtree this way. No check itself needs to change. + Additive only: an empty or absent `exclude` scans exactly what it always has. + """ + args = ["git", "-C", str(root), "ls-files"] + if exclude: + args += ["--", *(f":!{pattern}" for pattern in exclude)] + result = subprocess.run(args, capture_output=True, text=True, check=False) + # A failed command and a valid empty result would otherwise look the same to the caller. + # The command's own stderr (a bad pathspec, an unreadable root) is surfaced rather than dropped. + if result.returncode != 0 and result.stderr.strip(): + print(f"git ls-files failed: {result.stderr.strip()}", file=sys.stderr) + return [l for l in result.stdout.split("\n") if l] def workflow_files(files: list[str]) -> list[str]: @@ -341,12 +355,43 @@ def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser() ap.add_argument("--root", default=".") ap.add_argument("--check", action="append", choices=sorted(CHECKS)) + ap.add_argument( + "--exclude", + action="append", + default=[], + metavar="PATTERN", + help="git pathspec pattern to exclude from every check's tracked-file scan " + "(for example 'themes/PaperMod/**'); repeatable", + ) a = ap.parse_args(argv) root = Path(a.root).resolve() - files = tracked(root) + files = tracked(root, a.exclude) if not files: - print(f"{root}: not a git repo or no tracked files", file=sys.stderr) + # `tracked()` already printed git's own stderr above where the command itself failed. + # This distinguishes that root cause from a caller's exclude matching every tracked file. + if a.exclude: + print( + f"{root}: no tracked files remain after excluding " + f"{', '.join(a.exclude)} (or the ls-files command above failed)", + file=sys.stderr, + ) + else: + print(f"{root}: not a git repo or no tracked files", file=sys.stderr) return 2 + if a.exclude: + # Compared against the unfiltered scan. + # A pattern matching nothing tracked is not reported as narrowing, which would misreport a caller's own typo as having worked. + excluded = len(tracked(root)) - len(files) + if excluded > 0: + print( + f"note: {len(a.exclude)} exclude pattern(s) narrowed the tracked-file scan " + f"by {excluded} file(s): {', '.join(a.exclude)}" + ) + else: + print( + f"note: {len(a.exclude)} exclude pattern(s) matched no tracked file, so " + f"nothing was narrowed: {', '.join(a.exclude)}" + ) total = 0 for name in a.check or sorted(CHECKS): diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 0a1ded2b..47efcfd7 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -3,7 +3,7 @@ name: Validate task # The fleet validation gate, hosted here once and reached by every repo's test-pull-request stub and its own publish-release stub. # Three jobs: lint (the fleet doc-lint block plus language lint by tree detection, the prose gate, and the repo gate), unit-test (a generic dotnet test or uv run pytest, skipped where the caller has no test project), and validate (the validate hook, a repo's own domain checks such as an ESPHome compile, a Hugo build, a KiCad ERC, a codegen-drift check, or PowerShell tests). # No permissions beyond contents: read where a job needs one, since every job here only checks out and reads. -# No required inputs, markdown-exclude-globs is the one optional input, and CODECOV_TOKEN is the one optional secret, since coverage upload is best-effort. +# No required inputs, markdown-exclude-globs and repo-gate-exclude-globs are the two optional inputs, and CODECOV_TOKEN is the one optional secret, since coverage upload is best-effort. # Hub-owned gates and default hooks resolve through $/ at the reusable workflow's commit, so each implementation is reproducible against the caller's released pin without a second checkout. on: workflow_call: @@ -14,6 +14,12 @@ on: required: false type: string default: '' + # Threaded to the repo-gate composite action's own exclude-globs input, unvalidated. + repo-gate-exclude-globs: + description: Git pathspec patterns to exclude, one per line (for example 'themes/PaperMod/**'), narrowing what sha-pin/eol/eol-coverage scan via repo_gate.py's --exclude. + required: false + type: string + default: '' secrets: CODECOV_TOKEN: required: false @@ -243,8 +249,11 @@ jobs: with: base: ${{ github.event.pull_request.base.sha }} + # An empty repo-gate-exclude-globs leaves the action's own input at its default, so the default caller excludes nothing. - name: Check repo gates step uses: $/.github/actions/repo-gate + with: + exclude-globs: ${{ inputs.repo-gate-exclude-globs }} # No job-level if: here, since GitHub Actions does not evaluate hashFiles in a job condition, only a step one. # Every step below carries its own tree-detection guard instead. diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 83bc704b..9cbed25d 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -365,6 +365,16 @@ A repo that vendors a theme or imports content it does not author narrows the Li `validate-task.yml` appends each line after `**/*.md` in the Lint Markdown step's own `globs:` block, unvalidated. A negated glob excludes, the intended use, but a non-negated one adds to what is linted rather than narrowing it. +The repo gate's `sha-pin` and `eol-coverage` checks read the same tracked-file list, hitting the same wall for a vendored subtree carrying its own CI. PaperMod's own workflows pin actions by floating tag, which Blog does not author. Per `themes/README.md`'s byte-identical-to-upstream invariant, Blog does not locally edit them either. `repo-gate-exclude-globs` narrows that scan the same way, one git pathspec pattern per line: + +```yaml + with: + repo-gate-exclude-globs: | + themes/PaperMod/** +``` + +`validate-task.yml` passes each line straight through to the `repo-gate` composite action's own `exclude-globs` input. That input turns each line into a `--exclude` argument for `repo_gate.py`. Unlike `markdown-exclude-globs`, a line here is never negated: it is always a pathspec to drop from `git ls-files`. So `themes/PaperMod/**` excludes, rather than `!themes/PaperMod/**`. + ## Adopting the Pure Functions Neither `get-version-task.yml` nor `publish-plan-task.yml` has a caller-stub snippet of its own, since a caller reaching either one is a job inside a repo's own `publish-release.yml` or a future `build-release-task.yml`, not a standalone top-level workflow. A repo whose publisher reads NBGV's version outputs directly, without carrying the whole release orchestrator, reaches `get-version-task.yml` by pin in place of its own copy: diff --git a/scripts/tests/test_repo_gate.py b/scripts/tests/test_repo_gate.py index a0d2ed10..ec9649cb 100755 --- a/scripts/tests/test_repo_gate.py +++ b/scripts/tests/test_repo_gate.py @@ -489,6 +489,19 @@ def test_the_sha_pin_scan_is_not_vacuous(self) -> None: """A workflow glob that matched nothing would print `0 issue(s)` and read as clean.""" self.assertGreaterEqual(len(repo_gate.workflow_files(repo_gate.tracked(REPO))), 4) + def test_excluding_every_workflow_empties_the_sha_pin_scan(self) -> None: + """Proves the `--exclude` plumbing actually reaches sha-pin's own tracked-file scan. + + Two directories carry a `workflows/*.yml` path in this repo: the real workflows and the + `catalog/snippets/workflows/` examples the audit also scans, so both are excluded. + """ + self.assertEqual( + [], + repo_gate.workflow_files( + repo_gate.tracked(REPO, [".github/workflows/**", "catalog/snippets/workflows/**"]) + ), + ) + def test_the_representative_eol_check_runs_against_this_repo(self) -> None: self.assertEqual([], repo_gate.check_eol_coverage(REPO, repo_gate.tracked(REPO))) @@ -531,6 +544,80 @@ def test_an_unreadable_workflow_is_skipped_rather_than_raising(self) -> None: self.assertEqual([], repo_gate.check_sha_pin(REPO, [".github/workflows/absent.yml"])) +class TestExcludeGlobs(unittest.TestCase): + """A caller vendoring a subtree it does not author needs `tracked()` narrowed, not re-derived. + + Own git repo rather than REPO, so a case proves the exclude reaches a real subtree it built + rather than one this repo happens to carry today. + """ + + def setUp(self) -> None: + if shutil.which("git") is None: + self.skipTest("git absent, so ls-files cannot be asked") + repo_gate.NOTES.clear() + self.tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) + subprocess.run(["git", "-C", str(self.tmp), "init", "-q", "."], check=True) + for rel in ("kept.py", "vendor/upstream.py", "vendor/nested/deep.py"): + p = self.tmp / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("pass\n", encoding="utf-8") + subprocess.run(["git", "-C", str(self.tmp), "add", "-A"], check=True, capture_output=True) + + def test_a_pathspec_pattern_drops_the_matching_subtree(self) -> None: + self.assertEqual( + ["kept.py", "vendor/nested/deep.py", "vendor/upstream.py"], + sorted(repo_gate.tracked(self.tmp)), + ) + self.assertEqual(["kept.py"], repo_gate.tracked(self.tmp, ["vendor/**"])) + + def test_no_exclude_scans_exactly_as_before(self) -> None: + """`exclude=[]` and `exclude=None` are both the CLI's own no-flag default.""" + baseline = repo_gate.tracked(self.tmp) + self.assertEqual(baseline, repo_gate.tracked(self.tmp, [])) + self.assertEqual(baseline, repo_gate.tracked(self.tmp, None)) + + def test_every_pattern_given_is_applied(self) -> None: + self.assertEqual([], repo_gate.tracked(self.tmp, ["kept.py", "vendor/**"])) + + def test_the_cli_wires_the_exclude_flag_through_to_tracked(self) -> None: + with contextlib.redirect_stdout(io.StringIO()) as out: + code = repo_gate.main( + ["--root", str(self.tmp), "--check", "sha-pin", "--exclude", "vendor/**"] + ) + self.assertEqual(0, code) + self.assertIn( + "note: 1 exclude pattern(s) narrowed the tracked-file scan by 2 file(s): vendor/**", + out.getvalue(), + ) + + def test_the_narrowing_note_is_silent_when_no_exclude_was_given(self) -> None: + with contextlib.redirect_stdout(io.StringIO()) as out: + repo_gate.main(["--root", str(self.tmp), "--check", "sha-pin"]) + self.assertNotIn("narrowed the tracked-file scan", out.getvalue()) + + def test_a_pattern_matching_nothing_is_not_reported_as_narrowing(self) -> None: + """A caller's own typo drops zero files, and a false 'narrowed' note would hide that.""" + with contextlib.redirect_stdout(io.StringIO()) as out: + repo_gate.main( + ["--root", str(self.tmp), "--check", "sha-pin", "--exclude", "no/such/path/**"] + ) + printed = out.getvalue() + self.assertIn("matched no tracked file, so nothing was narrowed", printed) + self.assertNotIn("narrowed the tracked-file scan by", printed) + + def test_a_failed_ls_files_call_prints_gits_own_error(self) -> None: + """A command failure and a valid empty result both return `[]`; only this prints why.""" + proc = subprocess.CompletedProcess([], 128, "", "fatal: Invalid pathspec magic 'bogus'\n") + with ( + mock.patch.object(repo_gate.subprocess, "run", return_value=proc), + contextlib.redirect_stderr(io.StringIO()) as err, + ): + files = repo_gate.tracked(self.tmp, ["bogus/**"]) + self.assertEqual([], files) + self.assertIn("git ls-files failed", err.getvalue()) + self.assertIn("Invalid pathspec magic", err.getvalue()) + + class TestHarness(unittest.TestCase): def test_this_module_collects_a_plausible_number_of_cases(self) -> None: loaded = unittest.defaultTestLoader.loadTestsFromModule(sys.modules[__name__])