From e78399d27a301fe29f7d9ca6d42339d7fe508c9a Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Fri, 28 Aug 2026 11:41:14 +0200 Subject: [PATCH] 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) --- .github/workflows/gate.yml | 41 +++++++ ci/gate.py | 211 +++++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 .github/workflows/gate.yml create mode 100644 ci/gate.py diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml new file mode 100644 index 000000000..3c954aebd --- /dev/null +++ b/.github/workflows/gate.yml @@ -0,0 +1,41 @@ +# Emits the `gate` status check required by the paiml org ruleset "Green Main". +# +# Without a job whose NAME is literally `gate`, every PR in this repo reports +# mergeStateStatus: BLOCKED while every visible check is green -- the missing +# context is one the repo cannot produce, so no contributor can unblock it. +# +# The job name below IS the status-check context. Renaming it to something +# friendlier silently re-breaks merging for the whole repo. +name: Gate + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + gate: + name: gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install YAML parser + run: python3 -m pip install --quiet pyyaml + + # Probe the instrument before trusting it: every checker is fed an input + # it MUST reject. A checker that cannot fail would make this gate a green + # light that measures nothing. + - name: Gate self-test (every checker must reject a broken fixture) + run: python3 ci/gate.py --self-test + + # Each check prints its denominator and fails when it inspected 0 files. + - name: Gate checks + run: python3 ci/gate.py diff --git a/ci/gate.py b/ci/gate.py new file mode 100644 index 000000000..d6e1044b0 --- /dev/null +++ b/ci/gate.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Repository gate: checks that the book's example sources are still parseable. + +This repo has no build and no test suite -- it is the source listing for +*Python for DevOps*. So the gate checks what the repo actually IS: Python +examples, notebooks, shell scripts, Kubernetes/CI YAML and JSON that a reader +is expected to be able to run. + +Two rules keep this from becoming a green light that means nothing: + + * Every check prints its DENOMINATOR ("checked N files") and FAILS when N is + zero. A check that silently inspected nothing must never read as a pass. + * `--self-test` feeds every checker a deliberately broken fixture and fails + unless the checker rejects it. A checker that cannot fail is not a check. + +Usage: + python3 ci/gate.py --self-test # prove the instruments can fail + python3 ci/gate.py # run the checks against the repo +""" + +from __future__ import annotations + +import argparse +import ast +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +# Vendored JS dependencies are not ours to lint. +EXCLUDE_SUBSTRINGS = ("node_modules/",) +# Helm chart templates are Go text/template, not YAML, and do not parse as YAML. +YAML_EXCLUDE_SUBSTRINGS = EXCLUDE_SUBSTRINGS + ("/templates/",) + + +def tracked(*globs: str, exclude: tuple[str, ...] = EXCLUDE_SUBSTRINGS) -> list[str]: + """Files git tracks matching any glob, minus vendored/templated paths.""" + out = subprocess.run( + ["git", "ls-files", "-z", "--", *globs], + capture_output=True, + text=True, + check=True, + ).stdout + files = [f for f in out.split("\0") if f] + return sorted(f for f in files if not any(x in f for x in exclude)) + + +# --- checkers ------------------------------------------------------------- +# Each returns a list of "path: reason" strings; empty means the file is fine. + + +def check_python(path: str) -> list[str]: + try: + ast.parse(Path(path).read_bytes(), filename=path) + except SyntaxError as exc: + return [f"{path}: line {exc.lineno}: {exc.msg}"] + return [] + + +def _cells_error(cells: list) -> str | None: + bad = [ + i + for i, cell in enumerate(cells) + if not isinstance(cell, dict) or "cell_type" not in cell + ] + return f"cell {bad[0]} has no 'cell_type'" if bad else None + + +def _notebook_error(doc: object) -> str | None: + if not isinstance(doc, dict): + return f"top level is {type(doc).__name__}, expected object" + if "nbformat" not in doc: + return "missing 'nbformat' key" + if not isinstance(doc.get("cells"), list): + return "'cells' is missing or not a list" + return _cells_error(doc["cells"]) + + +def check_notebook(path: str) -> list[str]: + try: + doc = json.loads(Path(path).read_text(encoding="utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + return [f"{path}: not valid JSON: {exc}"] + error = _notebook_error(doc) + return [f"{path}: {error}"] if error else [] + + +def check_shell(path: str) -> list[str]: + proc = subprocess.run( + ["bash", "-n", path], capture_output=True, text=True, check=False + ) + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout).strip().splitlines() + return [f"{path}: {detail[0] if detail else 'bash -n failed'}"] + return [] + + +def check_yaml(path: str) -> list[str]: + import yaml # imported lazily so --self-test can report a clear error + + try: + list(yaml.safe_load_all(Path(path).read_text(encoding="utf-8"))) + except (yaml.YAMLError, UnicodeDecodeError) as exc: + return [f"{path}: {str(exc).splitlines()[0]}"] + return [] + + +def check_json(path: str) -> list[str]: + try: + json.loads(Path(path).read_text(encoding="utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + return [f"{path}: {exc}"] + return [] + + +CHECKS = ( + # name, checker, globs, exclude, broken fixture (suffix, bytes) + ( + "python-syntax", + check_python, + ("*.py",), + EXCLUDE_SUBSTRINGS, + (".py", "def broken(:\n"), + ), + ( + "notebook-structure", + check_notebook, + ("*.ipynb",), + EXCLUDE_SUBSTRINGS, + (".ipynb", '{"nbformat": 4, "cells": "not-a-list"}'), + ), + ( + "shell-syntax", + check_shell, + ("*.sh",), + EXCLUDE_SUBSTRINGS, + (".sh", "if true; then\n echo unterminated\n"), + ), + ( + "yaml-parse", + check_yaml, + ("*.yml", "*.yaml"), + YAML_EXCLUDE_SUBSTRINGS, + (".yaml", "a:\n - b\n c: broken indent\n"), + ), + ( + "json-parse", + check_json, + ("*.json",), + EXCLUDE_SUBSTRINGS, + (".json", '{"trailing": "comma",}'), + ), +) + + +def self_test() -> int: + """Prove every checker rejects an input it must reject.""" + failures = 0 + with tempfile.TemporaryDirectory() as tmp: + for name, checker, _globs, _exclude, (suffix, payload) in CHECKS: + fixture = Path(tmp) / f"broken{suffix}" + fixture.write_text(payload, encoding="utf-8") + problems = checker(str(fixture)) + if problems: + print(f" ok {name}: rejected its broken fixture") + else: + print(f" FAIL {name}: ACCEPTED a broken fixture -- checker is inert") + failures += 1 + print(f"self-test: probed {len(CHECKS)} checkers, {failures} inert") + return 1 if failures else 0 + + +def _run_one(spec) -> bool: + """Run one check. Returns True only if it inspected files and all passed.""" + name, checker, globs, exclude, _fixture = spec + files = tracked(*globs, exclude=exclude) + problems = [problem for path in files for problem in checker(path)] + passed = bool(files) and not problems + label = "ok " if passed else "FAIL" + print(f" {label} {name}: checked {len(files)} files, {len(problems)} bad") + if not files: + print(" -> inspected 0 files; that is a failure, not a pass") + for problem in problems: + print(f" -> {problem}") + return passed + + +def run_checks() -> int: + failed = sum(1 for spec in CHECKS if not _run_one(spec)) + print(f"gate: ran {len(CHECKS)} checks, {failed} failed") + return 1 if failed else 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--self-test", + action="store_true", + help="assert every checker rejects a deliberately broken fixture", + ) + args = parser.parse_args() + if args.self_test: + print("== gate self-test: can each checker fail? ==") + return self_test() + print("== gate: repository checks ==") + return run_checks() + + +if __name__ == "__main__": + sys.exit(main())