zyplux-cerberus 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.3
2
+ Name: zyplux-cerberus
3
+ Version: 0.2.0
4
+ Summary: Cross-repo invariant verifier for the zyplux organization
5
+ Requires-Dist: pyyaml>=6.0.3
6
+ Requires-Dist: rust-just>=1.53
7
+ Requires-Dist: typer>=0.20
8
+ Requires-Python: >=3.14
9
+ Description-Content-Type: text/markdown
10
+
11
+ # cerberus
12
+
13
+ Verifies repository invariants — CI workflows, branch-protection rulesets, CODEOWNERS, workflow secrets, and justfile conventions. Run it two ways: as a per-repo linter against a checkout (`cerberus`), or as a central scan across every repo in the org (`cerberus org`).
14
+
15
+ ## Requirements
16
+
17
+ - [`uv`](https://docs.astral.sh/uv/) and Python 3.14
18
+ - [`gh`](https://cli.github.com/), authenticated against the org — only for `cerberus org`
19
+
20
+ The `justfile` check shells out to `just`, which ships with the package (via [`rust-just`](https://pypi.org/project/rust-just/)) — no separate install.
21
+
22
+ ## Lint a repo
23
+
24
+ ```sh
25
+ uv run cerberus # lint the current directory
26
+ uv run cerberus PATH # lint a checkout at PATH
27
+ ```
28
+
29
+ Runs the content checks (`justfile`, `ci-workflow`, `codeowners`) against the checkout and exits non-zero on any failure or error — warnings do not fail the run — so it drops into CI like any linter. Control-plane checks (`ruleset`, `workflow-secrets`) read GitHub org/admin state the checkout cannot see, so they are skipped here and reported by `cerberus org`.
30
+
31
+ Run `cerberus list` to see every check, its scope, and what it verifies.
32
+
33
+ | Option | Description |
34
+ | --------------- | ---------------------------------------------------- |
35
+ | `--check NAME` | Limit to named check(s); repeatable |
36
+ | `--config PATH` | Use a `cerberus.toml` other than the bundled |
37
+ | `--fix` | Auto-fix fixable problems (e.g. trailing whitespace) |
38
+
39
+ ## Scan the org
40
+
41
+ `cerberus org` takes the org as a required argument — a bare name, `github.com/<org>`, or a full URL.
42
+
43
+ ```sh
44
+ uv run cerberus org zyplux # scan every repo, report findings
45
+ uv run cerberus org github.com/zyplux # same, org given as a URL
46
+ uv run cerberus org zyplux --repo api # scan only the named repo(s)
47
+ ```
48
+
49
+ Runs all checks, including the control-plane ones the local linter skips. Accepts `--repo`/`-r` and `--check`. A failure or error exits non-zero; warnings do not.
50
+
51
+ ## Checks
52
+
53
+ | ID | Scope | Verifies |
54
+ | ------------------ | ------------- | ----------------------------------------------------------------------------------- |
55
+ | `justfile` | content | Recipe names, aliases, `check` pipeline, wrapped tool calls, no trailing whitespace |
56
+ | `ci-workflow` | content | `ci.yml` exists, exposes a `ci` check, runs on PRs |
57
+ | `workflow-tooling` | content | Workflows set up only the workspace toolchain (uv, bun), not extra tools |
58
+ | `codeowners` | content | `CODEOWNERS` present and covers `/.github/` |
59
+ | `ruleset` | control-plane | Default branch protected by the org baseline ruleset |
60
+ | `workflow-secrets` | control-plane | Every secret referenced in workflows is provisioned |
61
+
62
+ ## Config
63
+
64
+ Policy — org name, excluded repos, ruleset name, required recipes and aliases — lives in [`cerberus.toml`](src/cerberus/cerberus.toml). Override it with `--config PATH`.
@@ -0,0 +1,54 @@
1
+ # cerberus
2
+
3
+ Verifies repository invariants — CI workflows, branch-protection rulesets, CODEOWNERS, workflow secrets, and justfile conventions. Run it two ways: as a per-repo linter against a checkout (`cerberus`), or as a central scan across every repo in the org (`cerberus org`).
4
+
5
+ ## Requirements
6
+
7
+ - [`uv`](https://docs.astral.sh/uv/) and Python 3.14
8
+ - [`gh`](https://cli.github.com/), authenticated against the org — only for `cerberus org`
9
+
10
+ The `justfile` check shells out to `just`, which ships with the package (via [`rust-just`](https://pypi.org/project/rust-just/)) — no separate install.
11
+
12
+ ## Lint a repo
13
+
14
+ ```sh
15
+ uv run cerberus # lint the current directory
16
+ uv run cerberus PATH # lint a checkout at PATH
17
+ ```
18
+
19
+ Runs the content checks (`justfile`, `ci-workflow`, `codeowners`) against the checkout and exits non-zero on any failure or error — warnings do not fail the run — so it drops into CI like any linter. Control-plane checks (`ruleset`, `workflow-secrets`) read GitHub org/admin state the checkout cannot see, so they are skipped here and reported by `cerberus org`.
20
+
21
+ Run `cerberus list` to see every check, its scope, and what it verifies.
22
+
23
+ | Option | Description |
24
+ | --------------- | ---------------------------------------------------- |
25
+ | `--check NAME` | Limit to named check(s); repeatable |
26
+ | `--config PATH` | Use a `cerberus.toml` other than the bundled |
27
+ | `--fix` | Auto-fix fixable problems (e.g. trailing whitespace) |
28
+
29
+ ## Scan the org
30
+
31
+ `cerberus org` takes the org as a required argument — a bare name, `github.com/<org>`, or a full URL.
32
+
33
+ ```sh
34
+ uv run cerberus org zyplux # scan every repo, report findings
35
+ uv run cerberus org github.com/zyplux # same, org given as a URL
36
+ uv run cerberus org zyplux --repo api # scan only the named repo(s)
37
+ ```
38
+
39
+ Runs all checks, including the control-plane ones the local linter skips. Accepts `--repo`/`-r` and `--check`. A failure or error exits non-zero; warnings do not.
40
+
41
+ ## Checks
42
+
43
+ | ID | Scope | Verifies |
44
+ | ------------------ | ------------- | ----------------------------------------------------------------------------------- |
45
+ | `justfile` | content | Recipe names, aliases, `check` pipeline, wrapped tool calls, no trailing whitespace |
46
+ | `ci-workflow` | content | `ci.yml` exists, exposes a `ci` check, runs on PRs |
47
+ | `workflow-tooling` | content | Workflows set up only the workspace toolchain (uv, bun), not extra tools |
48
+ | `codeowners` | content | `CODEOWNERS` present and covers `/.github/` |
49
+ | `ruleset` | control-plane | Default branch protected by the org baseline ruleset |
50
+ | `workflow-secrets` | control-plane | Every secret referenced in workflows is provisioned |
51
+
52
+ ## Config
53
+
54
+ Policy — org name, excluded repos, ruleset name, required recipes and aliases — lives in [`cerberus.toml`](src/cerberus/cerberus.toml). Override it with `--config PATH`.
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "zyplux-cerberus"
3
+ version = "0.2.0"
4
+ description = "Cross-repo invariant verifier for the zyplux organization"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = ["pyyaml>=6.0.3", "rust-just>=1.53", "typer>=0.20"]
8
+
9
+ [project.scripts]
10
+ cerberus = "cerberus.cli:app"
11
+
12
+ [build-system]
13
+ requires = ["uv_build>=0.11,<0.12"]
14
+ build-backend = "uv_build"
15
+
16
+ [tool.uv.build-backend]
17
+ module-name = "cerberus"
18
+ module-root = "src"
@@ -0,0 +1,8 @@
1
+ """Cross-repo invariant verifier for the zyplux organization."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("zyplux-cerberus")
7
+ except PackageNotFoundError: # running from a source tree that was never installed
8
+ __version__ = "0+unknown"
@@ -0,0 +1,25 @@
1
+ org = "zyplux"
2
+ exclude_repos = [".github", "client-roadmap-feedback", "zyplux-ai-pages"]
3
+ ruleset_name = "default-branch-baseline"
4
+ default_recipe_marker = "just --list"
5
+
6
+ [aliases.required]
7
+ i = "install"
8
+ k = "knip"
9
+ tc = "typecheck"
10
+ l = "lint"
11
+ t = "test"
12
+ c = "check"
13
+
14
+ [aliases.recommended]
15
+ u = "upgrade"
16
+ ui = "upgrade-interactive"
17
+
18
+ [recipes]
19
+ required = ["default", "install", "knip", "typecheck", "lint", "test", "check"]
20
+ recommended = ["upgrade", "upgrade-interactive", "clean"]
21
+ check_pipeline = ["install", "knip", "typecheck", "lint", "test"]
22
+ wrapped_tools = ["ruff", "pyrefly", "pytest", "rumdl", "vulture", "cerberus", "eslint", "prettier", "knip", "tsc"]
23
+
24
+ [ci]
25
+ allowed_setup_actions = ["astral-sh/setup-uv", "oven-sh/setup-bun"]
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+
6
+ from cerberus.checks import (
7
+ ci_workflow_check,
8
+ codeowners_check,
9
+ justfile_check,
10
+ ruleset_check,
11
+ secrets_check,
12
+ workflow_tooling_check,
13
+ )
14
+ from cerberus.context import Context
15
+ from cerberus.model import CheckResult, Repo, Scope
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Check:
20
+ id: str
21
+ summary: str
22
+ scope: Scope
23
+ run: Callable[[Repo, Context], CheckResult]
24
+
25
+
26
+ ALL: tuple[Check, ...] = tuple(
27
+ Check(module.ID, module.SUMMARY, module.SCOPE, module.run)
28
+ for module in (
29
+ justfile_check,
30
+ ci_workflow_check,
31
+ workflow_tooling_check,
32
+ ruleset_check,
33
+ secrets_check,
34
+ codeowners_check,
35
+ )
36
+ )
37
+
38
+ BY_ID: dict[str, Check] = {check.id: check for check in ALL}
39
+
40
+ CONTENT: tuple[Check, ...] = tuple(c for c in ALL if c.scope is Scope.CONTENT)
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import yaml
6
+
7
+ from cerberus.context import Context
8
+ from cerberus.model import CheckResult, Repo, Scope
9
+
10
+ ID = "ci-workflow"
11
+ SUMMARY = "ci.yml exists, exposes a `ci` check, runs on PRs (push to main recommended)"
12
+ SCOPE = Scope.CONTENT
13
+
14
+ YamlMapping = dict[str | bool, Any]
15
+ ON_KEY_AS_PYYAML_BOOL = True
16
+
17
+
18
+ def _triggers(workflow: YamlMapping) -> set[str]:
19
+ raw = workflow.get("on", workflow.get(ON_KEY_AS_PYYAML_BOOL))
20
+ if isinstance(raw, dict):
21
+ return set(raw.keys())
22
+ if isinstance(raw, list):
23
+ return set(raw)
24
+ if isinstance(raw, str):
25
+ return {raw}
26
+ return set()
27
+
28
+
29
+ def _exposes_ci_job(workflow: YamlMapping) -> bool:
30
+ jobs = workflow.get("jobs", {})
31
+ if not isinstance(jobs, dict):
32
+ return False
33
+ return any(job_id == "ci" or (j or {}).get("name") == "ci" for job_id, j in jobs.items())
34
+
35
+
36
+ def run(repo: Repo, ctx: Context) -> CheckResult:
37
+ res = CheckResult(ID, repo.name)
38
+ content = ctx.file(repo, ".github/workflows/ci.yml") or ctx.file(
39
+ repo, ".github/workflows/ci.yaml"
40
+ )
41
+ if content is None:
42
+ res.fail("no .github/workflows/ci.yml")
43
+ return res
44
+
45
+ try:
46
+ workflow = yaml.safe_load(content)
47
+ except yaml.YAMLError as err:
48
+ res.error(f"ci.yml is not valid YAML: {err}")
49
+ return res
50
+ if not isinstance(workflow, dict):
51
+ res.error("ci.yml did not parse to a mapping")
52
+ return res
53
+
54
+ if not _exposes_ci_job(workflow):
55
+ res.fail("no job named `ci` (the required status-check context)")
56
+
57
+ triggers = _triggers(workflow)
58
+ if "pull_request" not in triggers and "pull_request_target" not in triggers:
59
+ res.fail("ci.yml does not trigger on pull_request")
60
+ if "push" not in triggers:
61
+ res.warn("ci.yml does not trigger on push (to main)")
62
+
63
+ if not res.problems:
64
+ res.ok("ci workflow present and wired")
65
+ return res
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from cerberus.context import Context
4
+ from cerberus.model import CheckResult, Repo, Scope
5
+
6
+ ID = "codeowners"
7
+ SUMMARY = "CODEOWNERS present and covers /.github/"
8
+ SCOPE = Scope.CONTENT
9
+
10
+ _LOCATIONS = (".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS")
11
+
12
+
13
+ def _covers_github(pattern: str) -> bool:
14
+ if pattern == "*":
15
+ return True
16
+ top = pattern.lstrip("/").split("/", 1)[0]
17
+ return top == ".github"
18
+
19
+
20
+ def run(repo: Repo, ctx: Context) -> CheckResult:
21
+ res = CheckResult(ID, repo.name)
22
+
23
+ content = next((c for path in _LOCATIONS if (c := ctx.file(repo, path)) is not None), None)
24
+ if content is None:
25
+ res.fail("no CODEOWNERS file")
26
+ return res
27
+
28
+ owned_lines = [
29
+ line
30
+ for line in content.splitlines()
31
+ if line.strip() and not line.lstrip().startswith("#") and "@" in line
32
+ ]
33
+ if not owned_lines:
34
+ res.fail("CODEOWNERS has no ownership rules")
35
+ elif not any(_covers_github(line.split()[0]) for line in owned_lines):
36
+ res.warn("CODEOWNERS does not cover `/.github/`")
37
+
38
+ if not res.problems:
39
+ res.ok("CODEOWNERS present, covers /.github/")
40
+ return res
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Iterable
5
+
6
+ from cerberus import justfile
7
+ from cerberus.context import Context
8
+ from cerberus.model import CheckResult, Repo, Scope
9
+
10
+ ID = "justfile"
11
+ SUMMARY = "recipe names, aliases, check pipeline, wrapped tool calls, no trailing whitespace"
12
+ SCOPE = Scope.CONTENT
13
+
14
+ _SEGMENT_SPLIT = re.compile(r"&&|\|\||[|;]")
15
+ _RECIPE_LINE_PREFIXES = "@-"
16
+ _TRAILING_WS = re.compile(r"[ \t]+(?=\r?\n|\Z)")
17
+
18
+
19
+ def _trailing_ws_lines(content: str) -> list[int]:
20
+ return [n for n, line in enumerate(content.splitlines(), start=1) if line != line.rstrip(" \t")]
21
+
22
+
23
+ def _strip_trailing_ws(content: str) -> str:
24
+ return _TRAILING_WS.sub("", content)
25
+
26
+
27
+ def _leading_command(segment: str) -> str | None:
28
+ tokens = segment.split()
29
+ while tokens and "=" in tokens[0] and not tokens[0].startswith("-"):
30
+ tokens = tokens[1:]
31
+ if not tokens:
32
+ return None
33
+ return tokens[0].lstrip(_RECIPE_LINE_PREFIXES)
34
+
35
+
36
+ def _bare_tool_calls(bodies: dict[str, str], wrapped_tools: Iterable[str]) -> list[tuple[str, str]]:
37
+ """Find recipes that invoke a managed tool directly instead of through its runner.
38
+
39
+ A managed tool (`ruff`, `rumdl`, ...) must run via `uv run`/`bunx`, so a recipe
40
+ line whose leading command is the tool itself relies on an ambient install and
41
+ breaks on a fresh checkout. Wrappers like `uv run ruff` lead with `uv`, so they
42
+ are accepted; only a denylisted tool in command position is flagged.
43
+ """
44
+ tools = set(wrapped_tools)
45
+ seen: set[tuple[str, str]] = set()
46
+ calls: list[tuple[str, str]] = []
47
+ for recipe, body in bodies.items():
48
+ for line in body.split("\n"):
49
+ for segment in _SEGMENT_SPLIT.split(line):
50
+ command = _leading_command(segment)
51
+ if command is None or command not in tools:
52
+ continue
53
+ if (recipe, command) not in seen:
54
+ seen.add((recipe, command))
55
+ calls.append((recipe, command))
56
+ return calls
57
+
58
+
59
+ def run(repo: Repo, ctx: Context) -> CheckResult:
60
+ res = CheckResult(ID, repo.name)
61
+ content = ctx.file(repo, "justfile")
62
+ if content is None:
63
+ res.fail("no justfile at repo root")
64
+ return res
65
+
66
+ ws_lines = _trailing_ws_lines(content)
67
+ if ws_lines:
68
+ if ctx.fix:
69
+ ctx.write_file(repo, "justfile", _strip_trailing_ws(content))
70
+ else:
71
+ res.fail(f"trailing whitespace on line(s) {', '.join(map(str, ws_lines))}")
72
+
73
+ try:
74
+ jf = justfile.parse(content)
75
+ except justfile.JustfileError as err:
76
+ res.error(f"could not parse justfile: {err}")
77
+ return res
78
+
79
+ cfg = ctx.config
80
+
81
+ for alias, target in cfg.required_aliases.items():
82
+ actual = jf.aliases.get(alias)
83
+ if actual is None:
84
+ res.fail(f"missing alias `{alias} := {target}`")
85
+ elif actual != target:
86
+ res.fail(f"alias `{alias}` targets `{actual}`, expected `{target}`")
87
+
88
+ for alias, target in cfg.recommended_aliases.items():
89
+ actual = jf.aliases.get(alias)
90
+ if actual is None:
91
+ res.warn(f"missing recommended alias `{alias} := {target}`")
92
+ elif actual != target:
93
+ res.warn(f"alias `{alias}` targets `{actual}`, expected `{target}`")
94
+
95
+ for name in cfg.required_recipes:
96
+ if name not in jf.recipes:
97
+ res.fail(f"missing required recipe `{name}`")
98
+
99
+ for name in cfg.recommended_recipes:
100
+ if name not in jf.recipes:
101
+ res.warn(f"missing recommended recipe `{name}`")
102
+
103
+ if "default" in jf.recipes and cfg.default_recipe_marker not in jf.bodies.get("default", ""):
104
+ res.fail(f"`default` recipe should run `{cfg.default_recipe_marker}`")
105
+
106
+ if "check" in jf.recipes:
107
+ deps = jf.recipes["check"]
108
+ if not justfile.is_subsequence(list(cfg.check_pipeline), deps):
109
+ res.fail(
110
+ f"`check` dependencies {deps} must contain {list(cfg.check_pipeline)} in order"
111
+ )
112
+
113
+ for recipe, tool in _bare_tool_calls(jf.bodies, cfg.wrapped_tools):
114
+ res.fail(
115
+ f"recipe `{recipe}` runs `{tool}` directly; managed tools must run via `uv run`/`bunx`"
116
+ )
117
+
118
+ if not res.problems:
119
+ res.ok("justfile conforms")
120
+ return res
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from cerberus.context import Context
6
+ from cerberus.model import CheckResult, Repo, Scope
7
+
8
+ ID = "ruleset"
9
+ SUMMARY = "default branch protected by the org baseline ruleset"
10
+ SCOPE = Scope.CONTROL_PLANE
11
+
12
+
13
+ def _by_type(rules: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
14
+ return {rule["type"]: rule for rule in rules}
15
+
16
+
17
+ def run(repo: Repo, ctx: Context) -> CheckResult:
18
+ res = CheckResult(ID, repo.name)
19
+ cfg = ctx.config
20
+
21
+ if not ctx.ruleset_active(cfg.ruleset_name):
22
+ res.fail(f"org ruleset `{cfg.ruleset_name}` is absent or not active")
23
+
24
+ rules = _by_type(ctx.branch_rules(repo))
25
+
26
+ pr = rules.get("pull_request")
27
+ if pr is None:
28
+ res.fail("default branch does not require a pull request")
29
+ else:
30
+ params = pr.get("parameters", {})
31
+ if params.get("required_approving_review_count", 0) < 1:
32
+ res.fail("pull requests do not require an approving review")
33
+ if not params.get("require_code_owner_review"):
34
+ res.warn("code-owner review not required")
35
+
36
+ checks = rules.get("required_status_checks")
37
+ if checks is None:
38
+ res.fail("no required status checks on default branch")
39
+ else:
40
+ contexts = [
41
+ c.get("context") for c in checks.get("parameters", {}).get("required_status_checks", [])
42
+ ]
43
+ if "ci" not in contexts:
44
+ res.fail(f"required status checks {contexts} do not include `ci`")
45
+
46
+ if "required_linear_history" not in rules:
47
+ res.warn("linear history not enforced")
48
+ if "non_fast_forward" not in rules:
49
+ res.warn("force-pushes not blocked")
50
+ if "deletion" not in rules:
51
+ res.warn("branch deletion not blocked")
52
+
53
+ if not res.problems:
54
+ res.ok("default branch baseline enforced")
55
+ return res
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from cerberus.context import Context
6
+ from cerberus.model import CheckResult, Repo, Scope
7
+
8
+ ID = "workflow-secrets"
9
+ SUMMARY = "every secret referenced in workflows is provisioned"
10
+ SCOPE = Scope.CONTROL_PLANE
11
+
12
+ _SECRET_REF = re.compile(r"secrets\.([A-Za-z_][A-Za-z0-9_]*)")
13
+ _ALWAYS_PRESENT = frozenset({"GITHUB_TOKEN"})
14
+
15
+
16
+ def run(repo: Repo, ctx: Context) -> CheckResult:
17
+ res = CheckResult(ID, repo.name)
18
+ workflows = ctx.workflows(repo)
19
+ if not workflows:
20
+ res.skip("no workflows")
21
+ return res
22
+
23
+ referenced: set[str] = set()
24
+ for content in workflows.values():
25
+ referenced.update(_SECRET_REF.findall(content))
26
+ referenced -= _ALWAYS_PRESENT
27
+
28
+ if not referenced:
29
+ res.ok("no external secrets referenced")
30
+ return res
31
+
32
+ for name in sorted(referenced):
33
+ if ctx.secret_available(repo, name):
34
+ res.ok(f"`{name}` provisioned")
35
+ else:
36
+ res.fail(f"`{name}` referenced in workflows but not set at repo or org scope")
37
+ return res
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+ from cerberus.context import Context
9
+ from cerberus.model import CheckResult, Repo, Scope
10
+
11
+ ID = "workflow-tooling"
12
+ SUMMARY = "workflows set up only the workspace toolchain (uv, bun), not extra tools"
13
+ SCOPE = Scope.CONTENT
14
+
15
+ _SETUP_ACTION = re.compile(r"^(setup-|.*-toolchain$)", re.IGNORECASE)
16
+
17
+ _INSTALL_COMMANDS = (
18
+ re.compile(r"\bapt(?:-get)?\s+(?:install|add)\b", re.IGNORECASE),
19
+ re.compile(r"\bpip3?\s+install\b", re.IGNORECASE),
20
+ re.compile(r"\bpipx\s+install\b", re.IGNORECASE),
21
+ re.compile(r"\bcargo\s+install\b", re.IGNORECASE),
22
+ re.compile(r"\bgo\s+install\b", re.IGNORECASE),
23
+ re.compile(r"\bbrew\s+install\b", re.IGNORECASE),
24
+ re.compile(r"\bnpm\s+(?:install|i)\b[^\n]*?\s(?:-g|--global)\b", re.IGNORECASE),
25
+ re.compile(r"\b(?:pnpm|yarn)\b[^\n]*?\bglobal\b", re.IGNORECASE),
26
+ )
27
+
28
+
29
+ def _action_repo(uses: str) -> str:
30
+ identity = uses.split("@", 1)[0].strip()
31
+ parts = identity.split("/")
32
+ return parts[1] if len(parts) >= 2 else identity
33
+
34
+
35
+ def _is_setup_action(uses: str) -> bool:
36
+ repo = _action_repo(uses)
37
+ return bool(_SETUP_ACTION.match(repo)) or "install-action" in repo.lower()
38
+
39
+
40
+ def _steps(workflow: dict[str, Any]) -> list[dict[str, Any]]:
41
+ jobs = workflow.get("jobs")
42
+ if not isinstance(jobs, dict):
43
+ return []
44
+ steps: list[dict[str, Any]] = []
45
+ for job in jobs.values():
46
+ job_steps = job.get("steps") if isinstance(job, dict) else None
47
+ if isinstance(job_steps, list):
48
+ steps.extend(step for step in job_steps if isinstance(step, dict))
49
+ return steps
50
+
51
+
52
+ def run(repo: Repo, ctx: Context) -> CheckResult:
53
+ res = CheckResult(ID, repo.name)
54
+ workflows = ctx.workflows(repo)
55
+ if not workflows:
56
+ res.skip("no workflow files to scan")
57
+ return res
58
+
59
+ allowed = set(ctx.config.allowed_setup_actions)
60
+ for name, content in sorted(workflows.items()):
61
+ try:
62
+ workflow = yaml.safe_load(content)
63
+ except yaml.YAMLError as err:
64
+ res.warn(f"{name}: not valid YAML ({err})")
65
+ continue
66
+ if not isinstance(workflow, dict):
67
+ continue
68
+
69
+ for step in _steps(workflow):
70
+ uses = step.get("uses")
71
+ if isinstance(uses, str) and _is_setup_action(uses):
72
+ identity = uses.split("@", 1)[0].strip()
73
+ if identity not in allowed:
74
+ res.fail(f"{name}: installs a tool via `{identity}`; the toolchain is uv + bun")
75
+
76
+ script = step.get("run")
77
+ if isinstance(script, str):
78
+ for pattern in _INSTALL_COMMANDS:
79
+ hit = pattern.search(script)
80
+ if hit is not None:
81
+ res.fail(f"{name}: installs a tool with `{hit.group(0).strip()}`")
82
+ break
83
+
84
+ if not res.problems:
85
+ res.ok("workflows install no extra tools")
86
+ return res