Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/actions/repo-gate/action.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
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:
- name: Check repository policy step
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[@]}"
57 changes: 52 additions & 5 deletions .github/actions/repo-gate/repo_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,25 @@ 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 `:!<pattern>` 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)
if result.returncode != 0:
# A failed command's stdout is never trusted, even where it is non-empty.
# A partial listing read as complete is a scan that missed files and said nothing.
reason = result.stderr.strip() or f"exit {result.returncode}, no stderr"
print(f"git ls-files failed: {reason}", file=sys.stderr)
return []
return [l for l in result.stdout.split("\n") if l]
Comment thread
qodo-code-review[bot] marked this conversation as resolved.


def workflow_files(files: list[str]) -> list[str]:
Expand Down Expand Up @@ -341,12 +357,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):
Expand Down
11 changes: 10 additions & 1 deletion .github/workflows/validate-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 sha-pin and tracked-path eol-coverage scans via repo_gate.py's --exclude.
required: false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
type: string
default: ''
secrets:
CODECOV_TOKEN:
required: false
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs/reusable-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
109 changes: 109 additions & 0 deletions scripts/tests/test_repo_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
"""
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)))

Expand Down Expand Up @@ -531,6 +544,102 @@ 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())

def test_a_failed_call_is_never_trusted_even_with_nonempty_stdout(self) -> None:
"""A partial listing read as a complete one is a scan that missed files silently."""
proc = subprocess.CompletedProcess([], 128, "kept.py\n", "fatal: something went wrong\n")
with (
mock.patch.object(repo_gate.subprocess, "run", return_value=proc),
contextlib.redirect_stderr(io.StringIO()),
):
files = repo_gate.tracked(self.tmp, ["vendor/**"])
self.assertEqual([], files)

def test_a_failure_with_no_stderr_still_prints_a_reason(self) -> None:
"""Empty stderr on a nonzero exit must not read as a silent, unexplained empty scan."""
proc = subprocess.CompletedProcess([], 1, "", "")
with (
mock.patch.object(repo_gate.subprocess, "run", return_value=proc),
contextlib.redirect_stderr(io.StringIO()) as err,
):
files = repo_gate.tracked(self.tmp, ["vendor/**"])
self.assertEqual([], files)
self.assertIn("git ls-files failed", err.getvalue())
self.assertIn("exit 1", 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__])
Expand Down