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.
- zyplux_cerberus-0.2.0/PKG-INFO +64 -0
- zyplux_cerberus-0.2.0/README.md +54 -0
- zyplux_cerberus-0.2.0/pyproject.toml +18 -0
- zyplux_cerberus-0.2.0/src/cerberus/__init__.py +8 -0
- zyplux_cerberus-0.2.0/src/cerberus/cerberus.toml +25 -0
- zyplux_cerberus-0.2.0/src/cerberus/checks/__init__.py +40 -0
- zyplux_cerberus-0.2.0/src/cerberus/checks/ci_workflow_check.py +65 -0
- zyplux_cerberus-0.2.0/src/cerberus/checks/codeowners_check.py +40 -0
- zyplux_cerberus-0.2.0/src/cerberus/checks/justfile_check.py +120 -0
- zyplux_cerberus-0.2.0/src/cerberus/checks/ruleset_check.py +55 -0
- zyplux_cerberus-0.2.0/src/cerberus/checks/secrets_check.py +37 -0
- zyplux_cerberus-0.2.0/src/cerberus/checks/workflow_tooling_check.py +86 -0
- zyplux_cerberus-0.2.0/src/cerberus/cli.py +220 -0
- zyplux_cerberus-0.2.0/src/cerberus/config.py +47 -0
- zyplux_cerberus-0.2.0/src/cerberus/context.py +79 -0
- zyplux_cerberus-0.2.0/src/cerberus/gh.py +65 -0
- zyplux_cerberus-0.2.0/src/cerberus/justfile.py +59 -0
- zyplux_cerberus-0.2.0/src/cerberus/model.py +81 -0
- zyplux_cerberus-0.2.0/src/cerberus/proc.py +23 -0
- zyplux_cerberus-0.2.0/src/cerberus/source.py +195 -0
|
@@ -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
|