Skip to content

Commit e78399d

Browse files
Noah Giftclaude
andcommitted
ci: emit the gate status check this repo is required to produce
MEASURED DEFECT. The paiml org ruleset "Green Main" (id 13878864) requires exactly one status context: $ gh api orgs/paiml/rulesets/13878864 \ --jq '.rules[]|select(.type=="required_status_checks") |.parameters.required_status_checks[].context' gate This repo emits no such check. Its only workflows are `main.yml` (a `schedule`/`workflow_dispatch` README updater -- it never runs on a PR) and `pr-gate.yml` (a `pull_request_target` authorization gate). So: $ gh pr view 18 -R paiml/python_devops_book \ --json mergeStateStatus,statusCheckRollup {"mergeStateStatus":"BLOCKED","statusCheckRollup":[]} BLOCKED with an empty rollup: no check is red, the required one simply cannot be produced. Nothing a contributor does can clear it, and the only way through is an admin override the org rules correctly forbid. Eight repos are in this state. A ruleset naming a context no workflow emits does not raise the bar -- it closes the repo. FIX. Add a job whose name is literally `gate` (the ruleset matches the CONTEXT, so a friendlier display name would silently re-break merging), triggered on `pull_request` and on `push` to `master`, on `ubuntu-latest` to match the runner this repo's existing job already uses. The gate measures something real. This repo has no build and no test suite -- it is the source listing for *Python for DevOps* -- so there are no CI jobs to aggregate, and `ci/gate.py` instead checks what the repo actually IS: python-syntax 47 files ast.parse every tracked .py notebook-structure 5 files JSON + nbformat/cells/cell_type shell-syntax 8 files bash -n yaml-parse 51 files safe_load_all (Helm Go-templates excluded) json-parse 9 files json.loads Two properties keep it from becoming a green that means nothing: * Every check prints its DENOMINATOR and FAILS when it inspected zero files. A check that silently matches nothing must not read as a pass. * `--self-test` runs first and feeds every checker an input it MUST reject, failing the job if any checker accepts it. A checker that cannot fail is not a check. Verified in both directions before pushing. Self-test: 5/5 checkers rejected their broken fixtures. Clean tree: all 120 tracked files pass. Injected defects, one per shape: appending `def broken(:` to src/chap07-Monitoring/web.py and truncating src/chap14-MLOps/regression-concepts/ml_regression.ipynb to `not json` each turned the gate red (exit 1), naming the file and the line. Vendored node_modules/ is excluded throughout; Helm chart templates/ are excluded from the YAML check because they are Go text/template, not YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0f8e5f2 commit e78399d

2 files changed

Lines changed: 252 additions & 0 deletions

File tree

‎.github/workflows/gate.yml‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Emits the `gate` status check required by the paiml org ruleset "Green Main".
2+
#
3+
# Without a job whose NAME is literally `gate`, every PR in this repo reports
4+
# mergeStateStatus: BLOCKED while every visible check is green -- the missing
5+
# context is one the repo cannot produce, so no contributor can unblock it.
6+
#
7+
# The job name below IS the status-check context. Renaming it to something
8+
# friendlier silently re-breaks merging for the whole repo.
9+
name: Gate
10+
11+
on:
12+
pull_request:
13+
push:
14+
branches: [master]
15+
16+
permissions:
17+
contents: read
18+
19+
jobs:
20+
gate:
21+
name: gate
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v4
25+
26+
- uses: actions/setup-python@v5
27+
with:
28+
python-version: '3.11'
29+
30+
- name: Install YAML parser
31+
run: python3 -m pip install --quiet pyyaml
32+
33+
# Probe the instrument before trusting it: every checker is fed an input
34+
# it MUST reject. A checker that cannot fail would make this gate a green
35+
# light that measures nothing.
36+
- name: Gate self-test (every checker must reject a broken fixture)
37+
run: python3 ci/gate.py --self-test
38+
39+
# Each check prints its denominator and fails when it inspected 0 files.
40+
- name: Gate checks
41+
run: python3 ci/gate.py

‎ci/gate.py‎

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
#!/usr/bin/env python3
2+
"""Repository gate: checks that the book's example sources are still parseable.
3+
4+
This repo has no build and no test suite -- it is the source listing for
5+
*Python for DevOps*. So the gate checks what the repo actually IS: Python
6+
examples, notebooks, shell scripts, Kubernetes/CI YAML and JSON that a reader
7+
is expected to be able to run.
8+
9+
Two rules keep this from becoming a green light that means nothing:
10+
11+
* Every check prints its DENOMINATOR ("checked N files") and FAILS when N is
12+
zero. A check that silently inspected nothing must never read as a pass.
13+
* `--self-test` feeds every checker a deliberately broken fixture and fails
14+
unless the checker rejects it. A checker that cannot fail is not a check.
15+
16+
Usage:
17+
python3 ci/gate.py --self-test # prove the instruments can fail
18+
python3 ci/gate.py # run the checks against the repo
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import argparse
24+
import ast
25+
import json
26+
import subprocess
27+
import sys
28+
import tempfile
29+
from pathlib import Path
30+
31+
# Vendored JS dependencies are not ours to lint.
32+
EXCLUDE_SUBSTRINGS = ("node_modules/",)
33+
# Helm chart templates are Go text/template, not YAML, and do not parse as YAML.
34+
YAML_EXCLUDE_SUBSTRINGS = EXCLUDE_SUBSTRINGS + ("/templates/",)
35+
36+
37+
def tracked(*globs: str, exclude: tuple[str, ...] = EXCLUDE_SUBSTRINGS) -> list[str]:
38+
"""Files git tracks matching any glob, minus vendored/templated paths."""
39+
out = subprocess.run(
40+
["git", "ls-files", "-z", "--", *globs],
41+
capture_output=True,
42+
text=True,
43+
check=True,
44+
).stdout
45+
files = [f for f in out.split("\0") if f]
46+
return sorted(f for f in files if not any(x in f for x in exclude))
47+
48+
49+
# --- checkers -------------------------------------------------------------
50+
# Each returns a list of "path: reason" strings; empty means the file is fine.
51+
52+
53+
def check_python(path: str) -> list[str]:
54+
try:
55+
ast.parse(Path(path).read_bytes(), filename=path)
56+
except SyntaxError as exc:
57+
return [f"{path}: line {exc.lineno}: {exc.msg}"]
58+
return []
59+
60+
61+
def _cells_error(cells: list) -> str | None:
62+
bad = [
63+
i
64+
for i, cell in enumerate(cells)
65+
if not isinstance(cell, dict) or "cell_type" not in cell
66+
]
67+
return f"cell {bad[0]} has no 'cell_type'" if bad else None
68+
69+
70+
def _notebook_error(doc: object) -> str | None:
71+
if not isinstance(doc, dict):
72+
return f"top level is {type(doc).__name__}, expected object"
73+
if "nbformat" not in doc:
74+
return "missing 'nbformat' key"
75+
if not isinstance(doc.get("cells"), list):
76+
return "'cells' is missing or not a list"
77+
return _cells_error(doc["cells"])
78+
79+
80+
def check_notebook(path: str) -> list[str]:
81+
try:
82+
doc = json.loads(Path(path).read_text(encoding="utf-8"))
83+
except (ValueError, UnicodeDecodeError) as exc:
84+
return [f"{path}: not valid JSON: {exc}"]
85+
error = _notebook_error(doc)
86+
return [f"{path}: {error}"] if error else []
87+
88+
89+
def check_shell(path: str) -> list[str]:
90+
proc = subprocess.run(
91+
["bash", "-n", path], capture_output=True, text=True, check=False
92+
)
93+
if proc.returncode != 0:
94+
detail = (proc.stderr or proc.stdout).strip().splitlines()
95+
return [f"{path}: {detail[0] if detail else 'bash -n failed'}"]
96+
return []
97+
98+
99+
def check_yaml(path: str) -> list[str]:
100+
import yaml # imported lazily so --self-test can report a clear error
101+
102+
try:
103+
list(yaml.safe_load_all(Path(path).read_text(encoding="utf-8")))
104+
except (yaml.YAMLError, UnicodeDecodeError) as exc:
105+
return [f"{path}: {str(exc).splitlines()[0]}"]
106+
return []
107+
108+
109+
def check_json(path: str) -> list[str]:
110+
try:
111+
json.loads(Path(path).read_text(encoding="utf-8"))
112+
except (ValueError, UnicodeDecodeError) as exc:
113+
return [f"{path}: {exc}"]
114+
return []
115+
116+
117+
CHECKS = (
118+
# name, checker, globs, exclude, broken fixture (suffix, bytes)
119+
(
120+
"python-syntax",
121+
check_python,
122+
("*.py",),
123+
EXCLUDE_SUBSTRINGS,
124+
(".py", "def broken(:\n"),
125+
),
126+
(
127+
"notebook-structure",
128+
check_notebook,
129+
("*.ipynb",),
130+
EXCLUDE_SUBSTRINGS,
131+
(".ipynb", '{"nbformat": 4, "cells": "not-a-list"}'),
132+
),
133+
(
134+
"shell-syntax",
135+
check_shell,
136+
("*.sh",),
137+
EXCLUDE_SUBSTRINGS,
138+
(".sh", "if true; then\n echo unterminated\n"),
139+
),
140+
(
141+
"yaml-parse",
142+
check_yaml,
143+
("*.yml", "*.yaml"),
144+
YAML_EXCLUDE_SUBSTRINGS,
145+
(".yaml", "a:\n - b\n c: broken indent\n"),
146+
),
147+
(
148+
"json-parse",
149+
check_json,
150+
("*.json",),
151+
EXCLUDE_SUBSTRINGS,
152+
(".json", '{"trailing": "comma",}'),
153+
),
154+
)
155+
156+
157+
def self_test() -> int:
158+
"""Prove every checker rejects an input it must reject."""
159+
failures = 0
160+
with tempfile.TemporaryDirectory() as tmp:
161+
for name, checker, _globs, _exclude, (suffix, payload) in CHECKS:
162+
fixture = Path(tmp) / f"broken{suffix}"
163+
fixture.write_text(payload, encoding="utf-8")
164+
problems = checker(str(fixture))
165+
if problems:
166+
print(f" ok {name}: rejected its broken fixture")
167+
else:
168+
print(f" FAIL {name}: ACCEPTED a broken fixture -- checker is inert")
169+
failures += 1
170+
print(f"self-test: probed {len(CHECKS)} checkers, {failures} inert")
171+
return 1 if failures else 0
172+
173+
174+
def _run_one(spec) -> bool:
175+
"""Run one check. Returns True only if it inspected files and all passed."""
176+
name, checker, globs, exclude, _fixture = spec
177+
files = tracked(*globs, exclude=exclude)
178+
problems = [problem for path in files for problem in checker(path)]
179+
passed = bool(files) and not problems
180+
label = "ok " if passed else "FAIL"
181+
print(f" {label} {name}: checked {len(files)} files, {len(problems)} bad")
182+
if not files:
183+
print(" -> inspected 0 files; that is a failure, not a pass")
184+
for problem in problems:
185+
print(f" -> {problem}")
186+
return passed
187+
188+
189+
def run_checks() -> int:
190+
failed = sum(1 for spec in CHECKS if not _run_one(spec))
191+
print(f"gate: ran {len(CHECKS)} checks, {failed} failed")
192+
return 1 if failed else 0
193+
194+
195+
def main() -> int:
196+
parser = argparse.ArgumentParser(description=__doc__)
197+
parser.add_argument(
198+
"--self-test",
199+
action="store_true",
200+
help="assert every checker rejects a deliberately broken fixture",
201+
)
202+
args = parser.parse_args()
203+
if args.self_test:
204+
print("== gate self-test: can each checker fail? ==")
205+
return self_test()
206+
print("== gate: repository checks ==")
207+
return run_checks()
208+
209+
210+
if __name__ == "__main__":
211+
sys.exit(main())

0 commit comments

Comments
 (0)