@momentiq/dark-factory-cli 0.1.0-alpha.4
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.
- package/LICENSE +201 -0
- package/README.md +150 -0
- package/dist/adapters/_shared.d.ts +45 -0
- package/dist/adapters/_shared.d.ts.map +1 -0
- package/dist/adapters/_shared.js +200 -0
- package/dist/adapters/_shared.js.map +1 -0
- package/dist/adapters/codex-sdk.d.ts +279 -0
- package/dist/adapters/codex-sdk.d.ts.map +1 -0
- package/dist/adapters/codex-sdk.js +930 -0
- package/dist/adapters/codex-sdk.js.map +1 -0
- package/dist/adapters/critic-result-schema.d.ts +215 -0
- package/dist/adapters/critic-result-schema.d.ts.map +1 -0
- package/dist/adapters/critic-result-schema.js +153 -0
- package/dist/adapters/critic-result-schema.js.map +1 -0
- package/dist/adapters/critic.d.ts +62 -0
- package/dist/adapters/critic.d.ts.map +1 -0
- package/dist/adapters/critic.js +79 -0
- package/dist/adapters/critic.js.map +1 -0
- package/dist/adapters/cursor-sdk.d.ts +153 -0
- package/dist/adapters/cursor-sdk.d.ts.map +1 -0
- package/dist/adapters/cursor-sdk.js +818 -0
- package/dist/adapters/cursor-sdk.js.map +1 -0
- package/dist/adapters/gemini-sdk.d.ts +113 -0
- package/dist/adapters/gemini-sdk.d.ts.map +1 -0
- package/dist/adapters/gemini-sdk.js +532 -0
- package/dist/adapters/gemini-sdk.js.map +1 -0
- package/dist/adapters/grok-direct-sdk.d.ts +148 -0
- package/dist/adapters/grok-direct-sdk.d.ts.map +1 -0
- package/dist/adapters/grok-direct-sdk.js +694 -0
- package/dist/adapters/grok-direct-sdk.js.map +1 -0
- package/dist/branch-protection/audit_branch_protection.py +759 -0
- package/dist/branch-protection/index.d.ts +25 -0
- package/dist/branch-protection/index.d.ts.map +1 -0
- package/dist/branch-protection/index.js +70 -0
- package/dist/branch-protection/index.js.map +1 -0
- package/dist/branch-protection/spec-default.yaml +314 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +581 -0
- package/dist/cli.js.map +1 -0
- package/dist/cycle-doc-validator/index.d.ts +43 -0
- package/dist/cycle-doc-validator/index.d.ts.map +1 -0
- package/dist/cycle-doc-validator/index.js +69 -0
- package/dist/cycle-doc-validator/index.js.map +1 -0
- package/dist/cycle-doc-validator/validate_cycle_doc.py +1260 -0
- package/dist/cycle-tracker-sync/attribute_pr_cycle_ref.py +384 -0
- package/dist/cycle-tracker-sync/index.d.ts +20 -0
- package/dist/cycle-tracker-sync/index.d.ts.map +1 -0
- package/dist/cycle-tracker-sync/index.js +71 -0
- package/dist/cycle-tracker-sync/index.js.map +1 -0
- package/dist/cycle-tracker-sync/sync_cycle_trackers.py +1093 -0
- package/dist/evidence/audit-trail.d.ts +59 -0
- package/dist/evidence/audit-trail.d.ts.map +1 -0
- package/dist/evidence/audit-trail.js +283 -0
- package/dist/evidence/audit-trail.js.map +1 -0
- package/dist/evidence/index.d.ts +4 -0
- package/dist/evidence/index.d.ts.map +1 -0
- package/dist/evidence/index.js +29 -0
- package/dist/evidence/index.js.map +1 -0
- package/dist/evidence/per-sha.d.ts +13 -0
- package/dist/evidence/per-sha.d.ts.map +1 -0
- package/dist/evidence/per-sha.js +97 -0
- package/dist/evidence/per-sha.js.map +1 -0
- package/dist/evidence/quality-gates.d.ts +19 -0
- package/dist/evidence/quality-gates.d.ts.map +1 -0
- package/dist/evidence/quality-gates.js +212 -0
- package/dist/evidence/quality-gates.js.map +1 -0
- package/dist/git.d.ts +40 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +414 -0
- package/dist/git.js.map +1 -0
- package/dist/glob.d.ts +4 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +99 -0
- package/dist/glob.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +12 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +42 -0
- package/dist/paths.js.map +1 -0
- package/dist/policy/baseline.d.ts +15 -0
- package/dist/policy/baseline.d.ts.map +1 -0
- package/dist/policy/baseline.js +115 -0
- package/dist/policy/baseline.js.map +1 -0
- package/dist/policy/config.d.ts +64 -0
- package/dist/policy/config.d.ts.map +1 -0
- package/dist/policy/config.js +363 -0
- package/dist/policy/config.js.map +1 -0
- package/dist/policy/gate.d.ts +80 -0
- package/dist/policy/gate.d.ts.map +1 -0
- package/dist/policy/gate.js +1019 -0
- package/dist/policy/gate.js.map +1 -0
- package/dist/policy/index.d.ts +7 -0
- package/dist/policy/index.d.ts.map +1 -0
- package/dist/policy/index.js +14 -0
- package/dist/policy/index.js.map +1 -0
- package/dist/policy/merge-queue.d.ts +183 -0
- package/dist/policy/merge-queue.d.ts.map +1 -0
- package/dist/policy/merge-queue.js +310 -0
- package/dist/policy/merge-queue.js.map +1 -0
- package/dist/policy/profile.d.ts +98 -0
- package/dist/policy/profile.d.ts.map +1 -0
- package/dist/policy/profile.js +156 -0
- package/dist/policy/profile.js.map +1 -0
- package/dist/policy/tdd-classifier.d.ts +18 -0
- package/dist/policy/tdd-classifier.d.ts.map +1 -0
- package/dist/policy/tdd-classifier.js +79 -0
- package/dist/policy/tdd-classifier.js.map +1 -0
- package/dist/prompt.d.ts +13 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +175 -0
- package/dist/prompt.js.map +1 -0
- package/dist/report.d.ts +88 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +376 -0
- package/dist/report.js.map +1 -0
- package/dist/runner.d.ts +30 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +456 -0
- package/dist/runner.js.map +1 -0
- package/dist/security.d.ts +2 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +19 -0
- package/dist/security.js.map +1 -0
- package/dist/trusted-surface/rebind.d.ts +10 -0
- package/dist/trusted-surface/rebind.d.ts.map +1 -0
- package/dist/trusted-surface/rebind.js +154 -0
- package/dist/trusted-surface/rebind.js.map +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cycle 318.4 Component 3 — branch-protection drift audit.
|
|
3
|
+
|
|
4
|
+
Reads ``tools/branch-protection/spec.yaml`` (the declarative source of
|
|
5
|
+
truth shipped by Cycle 318.3) and compares it against the live GitHub
|
|
6
|
+
Repository Ruleset. Exits non-zero on any divergence and prints a
|
|
7
|
+
structured field-by-field report (``[branch-protection-audit] FAIL:
|
|
8
|
+
...`` followed by one ``field: ... intended: ... live: ... detail:
|
|
9
|
+
...`` block per divergence — NOT a unified diff). Catches passive
|
|
10
|
+
drift (someone toggles a setting in the GitHub UI without a PR) AND
|
|
11
|
+
active drift (a PR weakens the spec without removing a corresponding
|
|
12
|
+
rule from the ruleset).
|
|
13
|
+
|
|
14
|
+
What gets compared:
|
|
15
|
+
|
|
16
|
+
* Set of REQUIRED status-check contexts (status_checks where
|
|
17
|
+
``status: required`` in spec, vs ``required_status_checks`` array
|
|
18
|
+
in the live rule). ``status: planned`` contexts are aspirational —
|
|
19
|
+
they are NOT expected on the live ruleset yet, but the audit
|
|
20
|
+
flags any LIVE context that is neither ``required`` nor ``planned``
|
|
21
|
+
in the spec (drift: "live has something we didn't plan for").
|
|
22
|
+
|
|
23
|
+
* Strict status-check policy: ``strict_required_status_checks_policy``
|
|
24
|
+
in spec.required_status_checks vs the live rule parameters.
|
|
25
|
+
|
|
26
|
+
* Pull-request rule settings: ``required_approving_review_count``,
|
|
27
|
+
``required_review_thread_resolution``, ``allowed_merge_methods``.
|
|
28
|
+
|
|
29
|
+
* Merge-queue settings: ``merge_method``, ``grouping_strategy``,
|
|
30
|
+
``max_entries_to_build``, etc.
|
|
31
|
+
|
|
32
|
+
* Forbidden bypass surfaces:
|
|
33
|
+
- ``repo_variable`` entries: queried via paginated ``gh api``;
|
|
34
|
+
any match is a hard fail.
|
|
35
|
+
- ``disabled_workflow`` pattern: ``.github/workflows/*.yml.disabled``
|
|
36
|
+
files in the working tree. Each file is a divergence unless
|
|
37
|
+
the spec explicitly lists it under ``known_violations`` for an
|
|
38
|
+
active cycle's resolution window.
|
|
39
|
+
- ``pr_title_skip``: grep ``.github/workflows/*.yml`` for any
|
|
40
|
+
check that gates on the configured title pattern (currently
|
|
41
|
+
``[skip ci]``).
|
|
42
|
+
|
|
43
|
+
Inputs:
|
|
44
|
+
|
|
45
|
+
positional ``spec_path`` — path to ``tools/branch-protection/spec.yaml``.
|
|
46
|
+
|
|
47
|
+
Environment:
|
|
48
|
+
|
|
49
|
+
GH_TOKEN — token with repo-read on rulesets. Without this the live
|
|
50
|
+
fetch fails (gh CLI surfaces 401); the audit will print a clear
|
|
51
|
+
diagnostic and exit non-zero.
|
|
52
|
+
|
|
53
|
+
REPO_ACTIONS_VARIABLES_JSON — optional JSON object from the GitHub
|
|
54
|
+
Actions ``vars`` context (``${{ toJSON(vars) }}``). Used only as a
|
|
55
|
+
fallback when ``gh api repos/<repo>/actions/variables`` is not readable
|
|
56
|
+
by the workflow token. This keeps forbidden repo-variable checks
|
|
57
|
+
fail-closed locally while letting CI evaluate known kill-switch names
|
|
58
|
+
without requiring a privileged PAT on PR-controlled workflow code.
|
|
59
|
+
|
|
60
|
+
Outputs:
|
|
61
|
+
|
|
62
|
+
exit 0 — spec == live, no divergence;
|
|
63
|
+
exit 1 — divergence (structured field-by-field report on stdout);
|
|
64
|
+
exit 2 — usage / configuration error (e.g., spec.yaml missing).
|
|
65
|
+
"""
|
|
66
|
+
from __future__ import annotations
|
|
67
|
+
|
|
68
|
+
import argparse
|
|
69
|
+
import json
|
|
70
|
+
import os
|
|
71
|
+
import re
|
|
72
|
+
import subprocess
|
|
73
|
+
import sys
|
|
74
|
+
from dataclasses import dataclass, field
|
|
75
|
+
from pathlib import Path
|
|
76
|
+
from typing import Any
|
|
77
|
+
|
|
78
|
+
import yaml
|
|
79
|
+
|
|
80
|
+
def _resolve_repo_root() -> Path:
|
|
81
|
+
"""Resolve the consumer repo root.
|
|
82
|
+
|
|
83
|
+
Phase C extraction (cycle 331.1): same shape as
|
|
84
|
+
``cycle_doc_validator/validate_cycle_doc.py``. Resolution order:
|
|
85
|
+
``DF_REPO_ROOT`` env var → ``git rev-parse --show-toplevel`` → legacy
|
|
86
|
+
``__file__`` parents[2] fallback for in-tree pytest runs.
|
|
87
|
+
"""
|
|
88
|
+
override = os.environ.get("DF_REPO_ROOT")
|
|
89
|
+
if override:
|
|
90
|
+
return Path(override).resolve()
|
|
91
|
+
try:
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
94
|
+
check=True,
|
|
95
|
+
capture_output=True,
|
|
96
|
+
text=True,
|
|
97
|
+
)
|
|
98
|
+
return Path(result.stdout.strip()).resolve()
|
|
99
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
100
|
+
return Path(__file__).resolve().parents[2]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
REPO_ROOT = _resolve_repo_root()
|
|
104
|
+
WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class Divergence:
|
|
109
|
+
field: str
|
|
110
|
+
intended: Any
|
|
111
|
+
live: Any
|
|
112
|
+
detail: str = ""
|
|
113
|
+
|
|
114
|
+
def render(self) -> str:
|
|
115
|
+
lines = [f" field: {self.field}"]
|
|
116
|
+
lines.append(f" intended: {self.intended!r}")
|
|
117
|
+
lines.append(f" live: {self.live!r}")
|
|
118
|
+
if self.detail:
|
|
119
|
+
lines.append(f" detail: {self.detail}")
|
|
120
|
+
return "\n".join(lines)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class AuditReport:
|
|
125
|
+
divergences: list[Divergence] = field(default_factory=list)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def ok(self) -> bool:
|
|
129
|
+
return not self.divergences
|
|
130
|
+
|
|
131
|
+
def add(self, *args: Any, **kwargs: Any) -> None:
|
|
132
|
+
self.divergences.append(Divergence(*args, **kwargs))
|
|
133
|
+
|
|
134
|
+
def render(self) -> str:
|
|
135
|
+
if self.ok:
|
|
136
|
+
return "[branch-protection-audit] OK: spec.yaml matches live ruleset.\n"
|
|
137
|
+
body = "\n".join(d.render() for d in self.divergences)
|
|
138
|
+
return (
|
|
139
|
+
"[branch-protection-audit] FAIL: spec.yaml vs live ruleset divergence:\n"
|
|
140
|
+
f"{body}\n"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load_spec(path: Path) -> dict[str, Any]:
|
|
145
|
+
if not path.exists():
|
|
146
|
+
raise FileNotFoundError(f"spec.yaml not found at {path}")
|
|
147
|
+
with path.open(encoding="utf-8") as fh:
|
|
148
|
+
data = yaml.safe_load(fh)
|
|
149
|
+
if not isinstance(data, dict):
|
|
150
|
+
raise ValueError(f"{path} did not parse to a mapping")
|
|
151
|
+
return data
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def fetch_live_ruleset(repo: str, ruleset_id: int) -> dict[str, Any]:
|
|
155
|
+
"""Fetch the live ruleset via gh api. Raises on transport failure."""
|
|
156
|
+
args = ["gh", "api", f"repos/{repo}/rulesets/{ruleset_id}"]
|
|
157
|
+
try:
|
|
158
|
+
result = subprocess.run(
|
|
159
|
+
args,
|
|
160
|
+
check=True,
|
|
161
|
+
capture_output=True,
|
|
162
|
+
text=True,
|
|
163
|
+
timeout=30,
|
|
164
|
+
)
|
|
165
|
+
except FileNotFoundError as exc:
|
|
166
|
+
raise RuntimeError(
|
|
167
|
+
"gh CLI not on PATH — install GitHub CLI or run inside an environment where it is available."
|
|
168
|
+
) from exc
|
|
169
|
+
except subprocess.CalledProcessError as exc:
|
|
170
|
+
raise RuntimeError(
|
|
171
|
+
f"gh api repos/{repo}/rulesets/{ruleset_id} failed (exit {exc.returncode}):\n"
|
|
172
|
+
f" stderr: {exc.stderr.strip()}"
|
|
173
|
+
) from exc
|
|
174
|
+
return json.loads(result.stdout)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class RepoVariableFetchError(RuntimeError):
|
|
178
|
+
"""Raised when the repo variables API is unreachable.
|
|
179
|
+
|
|
180
|
+
We surface this rather than returning ``[]`` so the audit can fail
|
|
181
|
+
closed when a forbidden ``repo_variable`` rule cannot be evaluated.
|
|
182
|
+
Silently returning an empty list would hide a regression where
|
|
183
|
+
``SKIP_PR_CI`` got re-introduced and the audit step happened to lose
|
|
184
|
+
its ``gh`` PATH or permission scope at the same time.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def fetch_repo_variables(repo: str) -> list[str]:
|
|
189
|
+
"""List repo variable names via gh api.
|
|
190
|
+
|
|
191
|
+
Raises :class:`RepoVariableFetchError` on any transport failure so
|
|
192
|
+
the caller can decide whether to fail-closed (the default for
|
|
193
|
+
``check_forbidden_bypasses``) or skip the check explicitly.
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
result = subprocess.run(
|
|
197
|
+
[
|
|
198
|
+
"gh",
|
|
199
|
+
"api",
|
|
200
|
+
"--paginate",
|
|
201
|
+
f"repos/{repo}/actions/variables",
|
|
202
|
+
"--jq",
|
|
203
|
+
".variables[].name",
|
|
204
|
+
],
|
|
205
|
+
check=True,
|
|
206
|
+
capture_output=True,
|
|
207
|
+
text=True,
|
|
208
|
+
timeout=30,
|
|
209
|
+
)
|
|
210
|
+
except FileNotFoundError as exc:
|
|
211
|
+
raise RepoVariableFetchError(
|
|
212
|
+
"gh CLI not on PATH; cannot enumerate repo variables for forbidden-bypass check."
|
|
213
|
+
) from exc
|
|
214
|
+
except subprocess.CalledProcessError as exc:
|
|
215
|
+
raise RepoVariableFetchError(
|
|
216
|
+
f"gh api repos/{repo}/actions/variables failed (exit {exc.returncode}): "
|
|
217
|
+
f"{exc.stderr.strip()}"
|
|
218
|
+
) from exc
|
|
219
|
+
except subprocess.TimeoutExpired as exc:
|
|
220
|
+
raise RepoVariableFetchError(
|
|
221
|
+
f"gh api repos/{repo}/actions/variables timed out after {exc.timeout}s"
|
|
222
|
+
) from exc
|
|
223
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def repo_variables_from_actions_context() -> list[str] | None:
|
|
227
|
+
"""Return variable names from the GitHub Actions ``vars`` JSON fallback.
|
|
228
|
+
|
|
229
|
+
``None`` means the fallback was not supplied. An empty list means it
|
|
230
|
+
was supplied and no variables are visible to the workflow.
|
|
231
|
+
"""
|
|
232
|
+
raw = os.environ.get("REPO_ACTIONS_VARIABLES_JSON")
|
|
233
|
+
if raw is None or not raw.strip():
|
|
234
|
+
return None
|
|
235
|
+
try:
|
|
236
|
+
data = json.loads(raw)
|
|
237
|
+
except json.JSONDecodeError as exc:
|
|
238
|
+
raise RepoVariableFetchError(
|
|
239
|
+
"REPO_ACTIONS_VARIABLES_JSON is not valid JSON"
|
|
240
|
+
) from exc
|
|
241
|
+
if not isinstance(data, dict):
|
|
242
|
+
raise RepoVariableFetchError(
|
|
243
|
+
"REPO_ACTIONS_VARIABLES_JSON must be a JSON object"
|
|
244
|
+
)
|
|
245
|
+
return [str(name) for name in data if name]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def extract_live_status_check_contexts(live: dict[str, Any]) -> tuple[list[str], bool | None]:
|
|
249
|
+
"""Pull (contexts, strict_required) from the live ruleset."""
|
|
250
|
+
for rule in live.get("rules", []):
|
|
251
|
+
if rule.get("type") == "required_status_checks":
|
|
252
|
+
params = rule.get("parameters", {})
|
|
253
|
+
contexts = [
|
|
254
|
+
c.get("context") for c in params.get("required_status_checks", [])
|
|
255
|
+
]
|
|
256
|
+
return [c for c in contexts if c], params.get(
|
|
257
|
+
"strict_required_status_checks_policy"
|
|
258
|
+
)
|
|
259
|
+
return [], None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def extract_live_pr_rule(live: dict[str, Any]) -> dict[str, Any]:
|
|
263
|
+
for rule in live.get("rules", []):
|
|
264
|
+
if rule.get("type") == "pull_request":
|
|
265
|
+
params = dict(rule.get("parameters") or {})
|
|
266
|
+
params["__present__"] = True
|
|
267
|
+
return params
|
|
268
|
+
return {"__present__": False}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def extract_live_merge_queue(live: dict[str, Any]) -> dict[str, Any]:
|
|
272
|
+
for rule in live.get("rules", []):
|
|
273
|
+
if rule.get("type") == "merge_queue":
|
|
274
|
+
params = dict(rule.get("parameters") or {})
|
|
275
|
+
params["__present__"] = True
|
|
276
|
+
return params
|
|
277
|
+
return {"__present__": False}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def extract_live_linear(live: dict[str, Any]) -> bool:
|
|
281
|
+
return any(r.get("type") == "required_linear_history" for r in live.get("rules", []))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def extract_live_deletion_blocked(live: dict[str, Any]) -> bool:
|
|
285
|
+
return any(r.get("type") == "deletion" for r in live.get("rules", []))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def extract_live_non_fast_forward(live: dict[str, Any]) -> bool:
|
|
289
|
+
return any(r.get("type") == "non_fast_forward" for r in live.get("rules", []))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def compare_status_check_contexts(
|
|
293
|
+
spec: dict[str, Any],
|
|
294
|
+
live: dict[str, Any],
|
|
295
|
+
report: AuditReport,
|
|
296
|
+
) -> None:
|
|
297
|
+
branch_spec = spec["branches"]["main"]
|
|
298
|
+
rsc_spec = branch_spec.get("required_status_checks", {})
|
|
299
|
+
raw_contexts = rsc_spec.get("contexts", [])
|
|
300
|
+
|
|
301
|
+
required: set[str] = set()
|
|
302
|
+
planned: set[str] = set()
|
|
303
|
+
for entry in raw_contexts:
|
|
304
|
+
if not isinstance(entry, dict):
|
|
305
|
+
continue
|
|
306
|
+
context = entry.get("context")
|
|
307
|
+
if not context:
|
|
308
|
+
continue
|
|
309
|
+
status = entry.get("status", "required")
|
|
310
|
+
if status == "required":
|
|
311
|
+
required.add(context)
|
|
312
|
+
elif status == "planned":
|
|
313
|
+
planned.add(context)
|
|
314
|
+
# Other statuses (e.g., 'audit') are advisory; ignored here.
|
|
315
|
+
|
|
316
|
+
live_contexts, live_strict = extract_live_status_check_contexts(live)
|
|
317
|
+
live_set = set(live_contexts)
|
|
318
|
+
|
|
319
|
+
missing_required = required - live_set
|
|
320
|
+
if missing_required:
|
|
321
|
+
report.add(
|
|
322
|
+
field="required_status_checks.contexts.required",
|
|
323
|
+
intended=sorted(required),
|
|
324
|
+
live=sorted(live_set),
|
|
325
|
+
detail=f"missing in live: {sorted(missing_required)}",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
unplanned_live = live_set - required - planned
|
|
329
|
+
if unplanned_live:
|
|
330
|
+
report.add(
|
|
331
|
+
field="required_status_checks.contexts.unplanned_in_live",
|
|
332
|
+
intended=sorted(required | planned),
|
|
333
|
+
live=sorted(live_set),
|
|
334
|
+
detail=(
|
|
335
|
+
f"live ruleset requires checks NOT declared in spec.yaml: "
|
|
336
|
+
f"{sorted(unplanned_live)}. Add them to "
|
|
337
|
+
"branches.main.required_status_checks.contexts or remove "
|
|
338
|
+
"from the live ruleset."
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
intended_strict = bool(rsc_spec.get("strict_required_status_checks_policy"))
|
|
343
|
+
if live_strict is not None and intended_strict != bool(live_strict):
|
|
344
|
+
report.add(
|
|
345
|
+
field="required_status_checks.strict_required_status_checks_policy",
|
|
346
|
+
intended=intended_strict,
|
|
347
|
+
live=bool(live_strict),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def compare_pull_request_rule(
|
|
352
|
+
spec: dict[str, Any], live: dict[str, Any], report: AuditReport
|
|
353
|
+
) -> None:
|
|
354
|
+
branch_spec = spec["branches"]["main"]
|
|
355
|
+
pr_spec = branch_spec.get("required_pull_request_reviews", {})
|
|
356
|
+
pr_live = extract_live_pr_rule(live)
|
|
357
|
+
if not pr_live.get("__present__"):
|
|
358
|
+
if pr_spec:
|
|
359
|
+
report.add(
|
|
360
|
+
field="pull_request_rule",
|
|
361
|
+
intended=pr_spec,
|
|
362
|
+
live=None,
|
|
363
|
+
detail="spec declares pull-request rule but live ruleset has none.",
|
|
364
|
+
)
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
for key in (
|
|
368
|
+
"required_approving_review_count",
|
|
369
|
+
"required_review_thread_resolution",
|
|
370
|
+
"dismiss_stale_reviews_on_push",
|
|
371
|
+
"require_code_owner_reviews",
|
|
372
|
+
"require_last_push_approval",
|
|
373
|
+
):
|
|
374
|
+
if key in pr_spec:
|
|
375
|
+
# spec uses ``require_code_owner_reviews``; live uses
|
|
376
|
+
# ``require_code_owner_review`` (no trailing s). Map both.
|
|
377
|
+
live_key = key.rstrip("s") if key == "require_code_owner_reviews" else key
|
|
378
|
+
if pr_live.get(live_key) != pr_spec[key]:
|
|
379
|
+
report.add(
|
|
380
|
+
field=f"pull_request.{key}",
|
|
381
|
+
intended=pr_spec[key],
|
|
382
|
+
live=pr_live.get(live_key),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if "allowed_merge_methods" in pr_spec:
|
|
386
|
+
intended = sorted(pr_spec["allowed_merge_methods"])
|
|
387
|
+
live_methods = sorted(pr_live.get("allowed_merge_methods", []))
|
|
388
|
+
if intended != live_methods:
|
|
389
|
+
report.add(
|
|
390
|
+
field="pull_request.allowed_merge_methods",
|
|
391
|
+
intended=intended,
|
|
392
|
+
live=live_methods,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def compare_merge_queue(
|
|
397
|
+
spec: dict[str, Any], live: dict[str, Any], report: AuditReport
|
|
398
|
+
) -> None:
|
|
399
|
+
branch_spec = spec["branches"]["main"]
|
|
400
|
+
mq_spec = branch_spec.get("merge_queue", {})
|
|
401
|
+
mq_live = extract_live_merge_queue(live)
|
|
402
|
+
if not mq_spec.get("enabled", True) and not mq_live.get("__present__"):
|
|
403
|
+
return # both disabled — fine.
|
|
404
|
+
if not mq_live.get("__present__"):
|
|
405
|
+
report.add(
|
|
406
|
+
field="merge_queue",
|
|
407
|
+
intended=mq_spec,
|
|
408
|
+
live=None,
|
|
409
|
+
detail="spec declares merge_queue but live ruleset has none.",
|
|
410
|
+
)
|
|
411
|
+
return
|
|
412
|
+
for key in (
|
|
413
|
+
"merge_method",
|
|
414
|
+
"grouping_strategy",
|
|
415
|
+
"check_response_timeout_minutes",
|
|
416
|
+
"max_entries_to_build",
|
|
417
|
+
"max_entries_to_merge",
|
|
418
|
+
"min_entries_to_merge",
|
|
419
|
+
"min_entries_to_merge_wait_minutes",
|
|
420
|
+
):
|
|
421
|
+
if key in mq_spec and mq_live.get(key) != mq_spec[key]:
|
|
422
|
+
report.add(
|
|
423
|
+
field=f"merge_queue.{key}",
|
|
424
|
+
intended=mq_spec[key],
|
|
425
|
+
live=mq_live.get(key),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def compare_linear_history(
|
|
430
|
+
spec: dict[str, Any], live: dict[str, Any], report: AuditReport
|
|
431
|
+
) -> None:
|
|
432
|
+
intended = bool(spec["branches"]["main"].get("required_linear_history", False))
|
|
433
|
+
actual = extract_live_linear(live)
|
|
434
|
+
if intended != actual:
|
|
435
|
+
report.add(
|
|
436
|
+
field="required_linear_history",
|
|
437
|
+
intended=intended,
|
|
438
|
+
live=actual,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def compare_block_flags(
|
|
443
|
+
spec: dict[str, Any], live: dict[str, Any], report: AuditReport
|
|
444
|
+
) -> None:
|
|
445
|
+
branch_spec = spec["branches"]["main"]
|
|
446
|
+
if "deletion_blocked" in branch_spec:
|
|
447
|
+
intended = bool(branch_spec["deletion_blocked"])
|
|
448
|
+
actual = extract_live_deletion_blocked(live)
|
|
449
|
+
if intended != actual:
|
|
450
|
+
report.add(
|
|
451
|
+
field="deletion_blocked", intended=intended, live=actual,
|
|
452
|
+
)
|
|
453
|
+
if "non_fast_forward_blocked" in branch_spec:
|
|
454
|
+
intended = bool(branch_spec["non_fast_forward_blocked"])
|
|
455
|
+
actual = extract_live_non_fast_forward(live)
|
|
456
|
+
if intended != actual:
|
|
457
|
+
report.add(
|
|
458
|
+
field="non_fast_forward_blocked", intended=intended, live=actual,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def compare_enforcement(
|
|
463
|
+
spec: dict[str, Any], live: dict[str, Any], report: AuditReport
|
|
464
|
+
) -> None:
|
|
465
|
+
intended = spec.get("ruleset", {}).get("enforcement", "active")
|
|
466
|
+
actual = live.get("enforcement")
|
|
467
|
+
if actual != intended:
|
|
468
|
+
report.add(field="ruleset.enforcement", intended=intended, live=actual)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def check_forbidden_bypasses(
|
|
472
|
+
spec: dict[str, Any],
|
|
473
|
+
repo_variables: list[str],
|
|
474
|
+
workflows_dir: Path,
|
|
475
|
+
report: AuditReport,
|
|
476
|
+
) -> None:
|
|
477
|
+
"""Verify each forbidden bypass entry is actually closed."""
|
|
478
|
+
for entry in spec.get("forbidden_bypasses", []):
|
|
479
|
+
if not isinstance(entry, dict):
|
|
480
|
+
continue
|
|
481
|
+
kind = entry.get("kind")
|
|
482
|
+
if kind == "repo_variable":
|
|
483
|
+
name = entry.get("name")
|
|
484
|
+
if name and name in repo_variables:
|
|
485
|
+
report.add(
|
|
486
|
+
field=f"forbidden_bypasses[repo_variable={name}]",
|
|
487
|
+
intended="absent",
|
|
488
|
+
live="present",
|
|
489
|
+
detail=(
|
|
490
|
+
f"Repo variable `{name}` is set in GitHub Actions. "
|
|
491
|
+
f"This was a documented bypass surface; remove it via "
|
|
492
|
+
f"`gh variable delete {name}`."
|
|
493
|
+
),
|
|
494
|
+
)
|
|
495
|
+
elif kind == "disabled_workflow":
|
|
496
|
+
pattern = entry.get("pattern", "*.yml.disabled")
|
|
497
|
+
known = set(entry.get("known_violations", []) or [])
|
|
498
|
+
# Match the bare filename glob (e.g., ``*.yml.disabled``)
|
|
499
|
+
# against entries in the workflows directory. Paths are
|
|
500
|
+
# rendered repo-relative when the workflows dir is under
|
|
501
|
+
# REPO_ROOT; otherwise (unit tests with tmp_path) we use the
|
|
502
|
+
# workflows-dir-relative path.
|
|
503
|
+
#
|
|
504
|
+
# GitHub Actions also accepts `.yaml`, so if the spec
|
|
505
|
+
# pattern uses `*.yml.disabled` we also scan
|
|
506
|
+
# `*.yaml.disabled` to catch the equivalent rename-disable
|
|
507
|
+
# of a `.yaml` workflow.
|
|
508
|
+
bare = pattern.split("/")[-1]
|
|
509
|
+
extra_globs: list[str] = []
|
|
510
|
+
if bare == "*.yml.disabled":
|
|
511
|
+
extra_globs.append("*.yaml.disabled")
|
|
512
|
+
matches = [
|
|
513
|
+
p for p in workflows_dir.glob(bare) if p.is_file()
|
|
514
|
+
]
|
|
515
|
+
for g in extra_globs:
|
|
516
|
+
matches.extend(p for p in workflows_dir.glob(g) if p.is_file())
|
|
517
|
+
found: list[str] = []
|
|
518
|
+
for p in matches:
|
|
519
|
+
try:
|
|
520
|
+
found.append(str(p.relative_to(REPO_ROOT)))
|
|
521
|
+
except ValueError:
|
|
522
|
+
found.append(str(p.relative_to(workflows_dir)))
|
|
523
|
+
found = sorted(found)
|
|
524
|
+
extras = sorted(set(found) - known)
|
|
525
|
+
still_present = sorted(set(found) & known)
|
|
526
|
+
if extras:
|
|
527
|
+
report.add(
|
|
528
|
+
field=f"forbidden_bypasses[disabled_workflow={pattern}]",
|
|
529
|
+
intended="no matches outside known_violations",
|
|
530
|
+
live=extras,
|
|
531
|
+
detail=(
|
|
532
|
+
"Disabled-workflow files exist that are not enumerated "
|
|
533
|
+
f"in spec.yaml's `known_violations`: {extras}. Either "
|
|
534
|
+
"delete them, re-enable, or add to allowed_bypasses."
|
|
535
|
+
),
|
|
536
|
+
)
|
|
537
|
+
if still_present:
|
|
538
|
+
report.add(
|
|
539
|
+
field=f"forbidden_bypasses[disabled_workflow={pattern}]",
|
|
540
|
+
intended="known_violations resolved before merge",
|
|
541
|
+
live=still_present,
|
|
542
|
+
detail=(
|
|
543
|
+
"Files listed in `known_violations` are still present. "
|
|
544
|
+
"Cycle 318.4 must resolve them (delete / re-enable / "
|
|
545
|
+
"move to allowed_bypasses)."
|
|
546
|
+
),
|
|
547
|
+
)
|
|
548
|
+
elif kind == "pr_title_skip":
|
|
549
|
+
pattern = entry.get("pattern", "[skip ci]")
|
|
550
|
+
hits = grep_pr_title_skip(workflows_dir, pattern)
|
|
551
|
+
if hits:
|
|
552
|
+
report.add(
|
|
553
|
+
field=f"forbidden_bypasses[pr_title_skip={pattern}]",
|
|
554
|
+
intended="no workflow gates on this title token",
|
|
555
|
+
live=hits,
|
|
556
|
+
detail=(
|
|
557
|
+
f"Workflow files reference the forbidden title-skip "
|
|
558
|
+
f"token {pattern!r}: {hits}. Remove the if: clause."
|
|
559
|
+
),
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def grep_pr_title_skip(workflows_dir: Path, pattern: str) -> list[str]:
|
|
564
|
+
"""Return relative paths of workflow files that gate on a forbidden PR title pattern.
|
|
565
|
+
|
|
566
|
+
Strict match only: a workflow expression that calls
|
|
567
|
+
``contains(github.event.pull_request.title, '<pattern>')`` (single
|
|
568
|
+
or double quotes, case-insensitive). Comments are stripped before
|
|
569
|
+
matching so a documentation reference to ``[skip ci]`` does not
|
|
570
|
+
trip the audit. A previous broad-fallback heuristic was removed —
|
|
571
|
+
it false-positived on incidental prose that happened to mention
|
|
572
|
+
both ``contains`` and ``pull_request.title`` without forming an
|
|
573
|
+
ambient-bypass gate.
|
|
574
|
+
"""
|
|
575
|
+
hits: list[str] = []
|
|
576
|
+
escaped = re.escape(pattern)
|
|
577
|
+
expr = re.compile(
|
|
578
|
+
rf"contains\([^)]*pull_request\.title[^)]*[\"']{escaped}[\"']",
|
|
579
|
+
re.IGNORECASE,
|
|
580
|
+
)
|
|
581
|
+
if not workflows_dir.exists():
|
|
582
|
+
return hits
|
|
583
|
+
# GitHub Actions accepts both `.yml` and `.yaml` per
|
|
584
|
+
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
|
|
585
|
+
# — a forbidden title-skip gate hiding inside `foo.yaml` would
|
|
586
|
+
# otherwise slip past the audit. Glob both extensions; sort the
|
|
587
|
+
# combined list so the report is deterministic.
|
|
588
|
+
workflow_paths = sorted(
|
|
589
|
+
list(workflows_dir.glob("*.yml")) + list(workflows_dir.glob("*.yaml"))
|
|
590
|
+
)
|
|
591
|
+
for path in workflow_paths:
|
|
592
|
+
try:
|
|
593
|
+
text = path.read_text(encoding="utf-8")
|
|
594
|
+
except OSError:
|
|
595
|
+
continue
|
|
596
|
+
stripped_lines = [
|
|
597
|
+
(line.split("#", 1)[0] if "#" in line else line) for line in text.split("\n")
|
|
598
|
+
]
|
|
599
|
+
joined = "\n".join(stripped_lines)
|
|
600
|
+
if expr.search(joined):
|
|
601
|
+
hits.append(str(path.relative_to(REPO_ROOT)))
|
|
602
|
+
return hits
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def run_audit(spec_path: Path, repo: str, ruleset_id: int | None = None) -> AuditReport:
|
|
606
|
+
spec = load_spec(spec_path)
|
|
607
|
+
rsid = ruleset_id or spec.get("ruleset", {}).get("id")
|
|
608
|
+
if not rsid:
|
|
609
|
+
raise ValueError(
|
|
610
|
+
f"{spec_path} does not declare ruleset.id; cannot fetch live state."
|
|
611
|
+
)
|
|
612
|
+
live = fetch_live_ruleset(repo, int(rsid))
|
|
613
|
+
# Only call the variables API if the spec has at least one
|
|
614
|
+
# ``repo_variable`` forbidden-bypass entry. When no entry needs it,
|
|
615
|
+
# skipping the API call avoids exercising a permission scope the
|
|
616
|
+
# token may legitimately not have. When an entry exists, fail-closed
|
|
617
|
+
# on transport failure so a regression in the audit's variable
|
|
618
|
+
# visibility is surfaced rather than masked.
|
|
619
|
+
needs_repo_vars = any(
|
|
620
|
+
isinstance(e, dict) and e.get("kind") == "repo_variable"
|
|
621
|
+
for e in spec.get("forbidden_bypasses", []) or []
|
|
622
|
+
)
|
|
623
|
+
report = AuditReport()
|
|
624
|
+
if needs_repo_vars:
|
|
625
|
+
try:
|
|
626
|
+
repo_vars = fetch_repo_variables(repo)
|
|
627
|
+
except RepoVariableFetchError as exc:
|
|
628
|
+
try:
|
|
629
|
+
repo_vars = repo_variables_from_actions_context()
|
|
630
|
+
except RepoVariableFetchError as fallback_exc:
|
|
631
|
+
report.add(
|
|
632
|
+
field="forbidden_bypasses[repo_variable].fetch_failure",
|
|
633
|
+
intended="readable repo-variables API or vars-context fallback",
|
|
634
|
+
live=f"{exc}; fallback failed: {fallback_exc}",
|
|
635
|
+
detail=(
|
|
636
|
+
"Cannot enumerate repo Actions variables to check forbidden-bypass "
|
|
637
|
+
"rules. Either grant the audit token repository Variables:read "
|
|
638
|
+
"access, pass REPO_ACTIONS_VARIABLES_JSON from `${{ toJSON(vars) }}`, "
|
|
639
|
+
"or remove the repo_variable entries from spec.yaml's "
|
|
640
|
+
"forbidden_bypasses. Fail-closed so a missing variable source cannot "
|
|
641
|
+
"silently hide a re-introduced kill switch like SKIP_PR_CI."
|
|
642
|
+
),
|
|
643
|
+
)
|
|
644
|
+
repo_vars = []
|
|
645
|
+
if repo_vars is None:
|
|
646
|
+
report.add(
|
|
647
|
+
field="forbidden_bypasses[repo_variable].fetch_failure",
|
|
648
|
+
intended="readable repo-variables API or vars-context fallback",
|
|
649
|
+
live=str(exc),
|
|
650
|
+
detail=(
|
|
651
|
+
"Cannot enumerate repo Actions variables to check forbidden-bypass "
|
|
652
|
+
"rules. Either grant the audit token repository Variables:read "
|
|
653
|
+
"access, pass REPO_ACTIONS_VARIABLES_JSON from `${{ toJSON(vars) }}`, "
|
|
654
|
+
"or remove the repo_variable entries from spec.yaml's "
|
|
655
|
+
"forbidden_bypasses. Fail-closed so a missing variable source cannot "
|
|
656
|
+
"silently hide a re-introduced kill switch like SKIP_PR_CI."
|
|
657
|
+
),
|
|
658
|
+
)
|
|
659
|
+
# Fall through to the standard audit path with an empty
|
|
660
|
+
# repo_vars list — the repo_variable rules will not surface
|
|
661
|
+
# additional divergences (the fetch_failure entry above is
|
|
662
|
+
# the canonical signal), but disabled_workflow and
|
|
663
|
+
# pr_title_skip checks STILL run. Without this, a combined
|
|
664
|
+
# failure (variables API down AND a disabled-workflow file
|
|
665
|
+
# reappears) would only surface the API failure and hide
|
|
666
|
+
# the second regression.
|
|
667
|
+
repo_vars = []
|
|
668
|
+
else:
|
|
669
|
+
repo_vars = []
|
|
670
|
+
compare_enforcement(spec, live, report)
|
|
671
|
+
compare_status_check_contexts(spec, live, report)
|
|
672
|
+
compare_pull_request_rule(spec, live, report)
|
|
673
|
+
compare_merge_queue(spec, live, report)
|
|
674
|
+
compare_linear_history(spec, live, report)
|
|
675
|
+
compare_block_flags(spec, live, report)
|
|
676
|
+
check_forbidden_bypasses(spec, repo_vars, WORKFLOWS_DIR, report)
|
|
677
|
+
return report
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def audit_in_memory(
|
|
681
|
+
spec: dict[str, Any],
|
|
682
|
+
live: dict[str, Any],
|
|
683
|
+
repo_vars: list[str] | None = None,
|
|
684
|
+
workflows_dir: Path | None = None,
|
|
685
|
+
) -> AuditReport:
|
|
686
|
+
"""Pure-function entry point used by unit tests.
|
|
687
|
+
|
|
688
|
+
Skips the live-fetch step; the caller injects spec / live / repo_vars
|
|
689
|
+
directly. Makes the audit logic testable without hitting GitHub.
|
|
690
|
+
"""
|
|
691
|
+
report = AuditReport()
|
|
692
|
+
compare_enforcement(spec, live, report)
|
|
693
|
+
compare_status_check_contexts(spec, live, report)
|
|
694
|
+
compare_pull_request_rule(spec, live, report)
|
|
695
|
+
compare_merge_queue(spec, live, report)
|
|
696
|
+
compare_linear_history(spec, live, report)
|
|
697
|
+
compare_block_flags(spec, live, report)
|
|
698
|
+
check_forbidden_bypasses(
|
|
699
|
+
spec,
|
|
700
|
+
repo_vars or [],
|
|
701
|
+
workflows_dir or WORKFLOWS_DIR,
|
|
702
|
+
report,
|
|
703
|
+
)
|
|
704
|
+
return report
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def main(argv: list[str] | None = None) -> int:
|
|
708
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
709
|
+
parser.add_argument(
|
|
710
|
+
"spec_path",
|
|
711
|
+
nargs="?",
|
|
712
|
+
default=str(REPO_ROOT / "tools" / "branch-protection" / "spec.yaml"),
|
|
713
|
+
help="Path to spec.yaml (defaults to tools/branch-protection/spec.yaml).",
|
|
714
|
+
)
|
|
715
|
+
parser.add_argument(
|
|
716
|
+
"--repo",
|
|
717
|
+
default=os.environ.get("REPO") or os.environ.get("GITHUB_REPOSITORY") or "momentiq-ai/sage3c",
|
|
718
|
+
)
|
|
719
|
+
parser.add_argument(
|
|
720
|
+
"--use-bundled-default-spec",
|
|
721
|
+
action="store_true",
|
|
722
|
+
help=(
|
|
723
|
+
"Fall back to the bundled spec-default.yaml when the consumer "
|
|
724
|
+
"repo has no tools/branch-protection/spec.yaml. Useful for "
|
|
725
|
+
"first-run audits where the consumer hasn't yet authored their "
|
|
726
|
+
"own desired-state spec."
|
|
727
|
+
),
|
|
728
|
+
)
|
|
729
|
+
args = parser.parse_args(argv)
|
|
730
|
+
|
|
731
|
+
spec_path = Path(args.spec_path)
|
|
732
|
+
if args.use_bundled_default_spec and not spec_path.exists():
|
|
733
|
+
bundled = Path(__file__).resolve().parent / "spec-default.yaml"
|
|
734
|
+
if bundled.exists():
|
|
735
|
+
spec_path = bundled
|
|
736
|
+
print(
|
|
737
|
+
f"[branch-protection-audit] using bundled default spec: {bundled}",
|
|
738
|
+
file=sys.stderr,
|
|
739
|
+
)
|
|
740
|
+
args.spec_path = str(spec_path)
|
|
741
|
+
|
|
742
|
+
try:
|
|
743
|
+
report = run_audit(Path(args.spec_path), args.repo)
|
|
744
|
+
except FileNotFoundError as exc:
|
|
745
|
+
print(f"[branch-protection-audit] FAIL: {exc}", file=sys.stderr)
|
|
746
|
+
return 2
|
|
747
|
+
except ValueError as exc:
|
|
748
|
+
print(f"[branch-protection-audit] FAIL: {exc}", file=sys.stderr)
|
|
749
|
+
return 2
|
|
750
|
+
except RuntimeError as exc:
|
|
751
|
+
print(f"[branch-protection-audit] FAIL: {exc}", file=sys.stderr)
|
|
752
|
+
return 1
|
|
753
|
+
|
|
754
|
+
print(report.render())
|
|
755
|
+
return 0 if report.ok else 1
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
if __name__ == "__main__":
|
|
759
|
+
sys.exit(main())
|