diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml new file mode 100644 index 0000000..7366bd8 --- /dev/null +++ b/.github/workflows/gate.yml @@ -0,0 +1,64 @@ +# Emits the `gate` status context required by the paiml org ruleset +# "Green Main" (id 13878864). Without a check literally named `gate`, every PR +# in this repo reports mergeStateStatus: BLOCKED while every visible check is +# green, and no contributor can fix it. +# +# The job id AND name must stay `gate` - the ruleset matches the CONTEXT, not a +# display name. Renaming it silently re-blocks every PR in this repository. +# +# This repo is a Jupyter Book / Jekyll course and has no test suite to +# aggregate, so the gate measures what the repo actually is: notebooks, YAML, +# JSON, helper scripts, and the table of contents. Every step prints its +# denominator and fails when it is zero. +name: gate + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: gate-${{ github.ref }} + cancel-in-progress: true + +jobs: + gate: + name: gate + # ubuntu-latest, not the self-hosted fleet: this repo is PUBLIC, and a + # `pull_request` job runs the head ref's own code, so a fork PR would + # execute untrusted content on the clean-room runners. Never bare + # `self-hosted` either - that matches the aarch64 gx10 node (paiml/infra#342). + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Install YAML parser + run: python3 -m pip install --quiet --disable-pip-version-check pyyaml + + # Runs every checker against deliberately broken fixtures and asserts it + # rejects them. A guard never shown to fail is not evidence of anything. + - name: Checkers can fail (selftest) + run: python3 scripts/ci_gate_check.py selftest + + - name: Notebooks parse + run: python3 scripts/ci_gate_check.py notebooks + + - name: YAML parses + run: python3 scripts/ci_gate_check.py yaml + + - name: JSON parses + run: python3 scripts/ci_gate_check.py json + + - name: Python compiles + run: python3 scripts/ci_gate_check.py python + + - name: Table of contents resolves + run: python3 scripts/ci_gate_check.py toc + + - name: Lessons published to content/lessons + run: python3 scripts/ci_gate_check.py lessons diff --git a/scripts/ci_gate_check.py b/scripts/ci_gate_check.py new file mode 100755 index 0000000..e450ac4 --- /dev/null +++ b/scripts/ci_gate_check.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Structural checks for the `gate` status context. + +This repository is a Jupyter Book / Jekyll course: notebooks, YAML config, a +table of contents and two helper scripts. There is no test suite to aggregate, +so the gate measures the things this repo actually IS. + +Every check prints its DENOMINATOR ("checked N ...") and FAILS when N is zero. +A check that silently inspected nothing must never read as a pass. + +`selftest` proves each checker can actually fail: it builds deliberately broken +fixtures in a temp directory and asserts every checker rejects them. A guard +that has never been shown to fail is not evidence of anything. + +Usage: + ci_gate_check.py notebooks | yaml | json | python | toc | lessons | selftest +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +try: + import yaml +except ImportError: # pragma: no cover - surfaced as a hard failure, never a skip + print("FAIL: PyYAML is not importable; the YAML checks cannot run.", file=sys.stderr) + print("An absent verifier is a NO-GO, not a pass.", file=sys.stderr) + sys.exit(2) + +REPO = Path(__file__).resolve().parent.parent + + +def discover(root: Path, suffixes: tuple[str, ...]) -> list[Path]: + """Tracked files under `root` matching `suffixes`. + + Uses `git ls-files` inside a checkout so untracked scratch files can never + inflate or deflate a denominator; falls back to a walk for selftest + fixtures, which are not git repositories. + """ + try: + out = subprocess.run( + ["git", "-C", str(root), "ls-files", "-z"], + check=True, + capture_output=True, + text=True, + ).stdout + names = [n for n in out.split("\0") if n] + except (subprocess.CalledProcessError, FileNotFoundError): + names = [ + str(p.relative_to(root)) + for p in root.rglob("*") + if p.is_file() + ] + return sorted(root / n for n in names if n.endswith(suffixes)) + + +def report(kind: str, n: int, errors: list[str]) -> int: + for e in errors: + print(f" ERROR: {e}") + print(f"checked {n} {kind}") + if n == 0: + print(f"FAIL: found 0 {kind} to check - the check inspected nothing.") + return 1 + if errors: + print(f"FAIL: {len(errors)} of {n} {kind} are broken.") + return 1 + print(f"OK: {n} {kind} pass.") + return 0 + + +# -------------------------------------------------------------------------- +# checkers: each returns (denominator, errors) +# -------------------------------------------------------------------------- + +def _cell_defects(cells: list, rel) -> list[str]: + return [ + f"{rel}: cell {i} lacks 'cell_type'/'source'" + for i, cell in enumerate(cells) + if not isinstance(cell, dict) + or "cell_type" not in cell + or "source" not in cell + ] + + +def _notebook_defects(nb, rel) -> list[str]: + if not isinstance(nb, dict): + return [f"{rel}: top level is {type(nb).__name__}, expected object"] + errors: list[str] = [] + if not isinstance(nb.get("nbformat"), int): + errors.append(f"{rel}: missing or non-integer 'nbformat'") + cells = nb.get("cells") + if not isinstance(cells, list): + errors.append(f"{rel}: missing or non-list 'cells'") + return errors + return errors + _cell_defects(cells, rel) + + +def check_notebooks(root: Path) -> tuple[int, list[str]]: + files = discover(root, (".ipynb",)) + errors: list[str] = [] + for f in files: + rel = f.relative_to(root) + try: + nb = json.loads(f.read_text(encoding="utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + errors.append(f"{rel}: not valid JSON: {exc}") + continue + errors.extend(_notebook_defects(nb, rel)) + return len(files), errors + + +def check_yaml(root: Path) -> tuple[int, list[str]]: + files = discover(root, (".yml", ".yaml")) + errors: list[str] = [] + for f in files: + try: + yaml.safe_load(f.read_text(encoding="utf-8")) + except (yaml.YAMLError, UnicodeDecodeError) as exc: + errors.append(f"{f.relative_to(root)}: invalid YAML: {exc}") + return len(files), errors + + +def check_json(root: Path) -> tuple[int, list[str]]: + files = discover(root, (".json",)) + errors: list[str] = [] + for f in files: + try: + json.loads(f.read_text(encoding="utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + errors.append(f"{f.relative_to(root)}: invalid JSON: {exc}") + return len(files), errors + + +def check_python(root: Path) -> tuple[int, list[str]]: + files = discover(root, (".py",)) + errors: list[str] = [] + for f in files: + try: + compile(f.read_text(encoding="utf-8"), str(f), "exec") + except (SyntaxError, ValueError, UnicodeDecodeError) as exc: + errors.append(f"{f.relative_to(root)}: does not compile: {exc}") + return len(files), errors + + +def _toc_urls(node, out: list[str]) -> None: + """Collect internal (non-external) urls from a jupyter-book toc tree.""" + if isinstance(node, list): + for item in node: + _toc_urls(item, out) + elif isinstance(node, dict): + url = node.get("url") + if isinstance(url, str) and not node.get("external") and url.startswith("/"): + out.append(url) + _toc_urls(node.get("sections"), out) + + +def check_toc(root: Path) -> tuple[int, list[str]]: + """Every internal toc entry must resolve to a real file under content/. + + This is what catches a renamed or deleted lesson: the book still builds a + sidebar entry, but the page behind it is gone. + """ + tocs = [p for p in discover(root, (".yml",)) if p.name == "toc.yml"] + errors: list[str] = [] + checked = 0 + for toc in tocs: + book_root = toc.parent.parent # /_data/toc.yml + content = book_root / "content" + if not content.is_dir(): + errors.append(f"{toc.relative_to(root)}: no content/ dir beside it") + continue + urls: list[str] = [] + _toc_urls(yaml.safe_load(toc.read_text(encoding="utf-8")), urls) + for url in urls: + checked += 1 + stem = content / url.lstrip("/") + if not any( + stem.with_suffix(ext).is_file() for ext in (".md", ".ipynb", ".markdown") + ): + errors.append( + f"{toc.relative_to(root)}: url {url} resolves to no " + f"{stem.relative_to(book_root)}.{{md,ipynb}}" + ) + return checked, errors + + +def check_lessons(root: Path) -> tuple[int, list[str]]: + """`make book` copies the top-level Colab notebooks into content/lessons/. + + If a lesson exists at the top level but not under content/lessons/, the + published book is silently missing it until someone re-runs `make book`. + """ + lessons_dir = root / "content" / "lessons" + top = [p for p in discover(root, (".ipynb",)) if p.parent == root] + errors: list[str] = [] + if not lessons_dir.is_dir(): + errors.append("content/lessons/ does not exist") + return len(top), errors + for nb in top: + if not (lessons_dir / nb.name).is_file(): + errors.append( + f"{nb.name} is not published to content/lessons/ (run `make book`)" + ) + return len(top), errors + + +CHECKS = { + "notebooks": ("notebook(s)", check_notebooks), + "yaml": ("YAML file(s)", check_yaml), + "json": ("JSON file(s)", check_json), + "python": ("Python file(s)", check_python), + "toc": ("table-of-contents entr(ies)", check_toc), + "lessons": ("top-level lesson notebook(s)", check_lessons), +} + + +# -------------------------------------------------------------------------- +# selftest: prove every checker can fail +# -------------------------------------------------------------------------- + +GOOD_NB = json.dumps( + {"nbformat": 4, "nbformat_minor": 0, "metadata": {}, "cells": [ + {"cell_type": "markdown", "source": ["# hi"], "metadata": {}}]} +) + + +def _fixture(tmp: Path, name: str, files: dict[str, str]) -> Path: + root = tmp / name + for rel, body in files.items(): + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body, encoding="utf-8") + return root + + +def _selftest_cases() -> list: + """(label, checker, broken fixture, empty fixture-or-None).""" + good_toc = ( + "- title: Home\n url: /intro\n" + "- title: Ext\n url: https://example.com\n external: true\n" + ) + no_source = {"README.md": "x"} + return [ + ("notebooks", check_notebooks, {"a.ipynb": "{not json"}, no_source), + ("notebooks-shape", check_notebooks, + {"a.ipynb": json.dumps({"nbformat": 4, "cells": [{"source": []}]})}, None), + ("yaml", check_yaml, {"a.yml": "a:\n - b\n c: bad indent\n"}, no_source), + ("json", check_json, {"a.json": "{,}"}, no_source), + ("python", check_python, {"a.py": "def broken(:\n"}, no_source), + ("toc", check_toc, + {"_data/toc.yml": good_toc, "content/other.md": "x"}, + {"_data/toc.yml": "- title: Ext\n url: https://e.com\n external: true\n", + "content/intro.md": "x"}), + ("lessons", check_lessons, + {"L1.ipynb": GOOD_NB, "content/lessons/keep.md": "x"}, no_source), + ] + + +def _assert_rejects(label: str, fn, root: Path) -> int: + """A checker must report at least one error on a deliberately broken tree.""" + n, errors = fn(root) + if not errors: + print(f" SELFTEST FAIL: {label} accepted a broken fixture") + return 1 + print(f" selftest ok: {label} rejects broken input (n={n})") + return 0 + + +def _assert_zero_denominator(label: str, fn, root: Path) -> int: + """A checker must report denominator 0 when there is nothing to inspect.""" + n, _ = fn(root) + if n != 0: + print(f" SELFTEST FAIL: {label} counted {n} on an empty fixture") + return 1 + print(f" selftest ok: {label} reports denominator 0 on empty input") + return 0 + + +def selftest() -> int: + cases = _selftest_cases() + failures = 0 + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + for i, (label, fn, broken, empty) in enumerate(cases): + failures += _assert_rejects(label, fn, _fixture(tmp, f"broken{i}", broken)) + if empty is not None: + failures += _assert_zero_denominator( + label, fn, _fixture(tmp, f"empty{i}", empty) + ) + print(f"checked {len(cases)} checker(s) for falsifiability") + if failures: + print(f"FAIL: {failures} selftest assertion(s) failed.") + return 1 + print("OK: every checker demonstrably fails on broken input.") + return 0 + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(__doc__) + return 2 + name = argv[1] + if name == "selftest": + return selftest() + if name not in CHECKS: + print(f"unknown check: {name}", file=sys.stderr) + return 2 + kind, fn = CHECKS[name] + n, errors = fn(REPO) + return report(kind, n, errors) + + +if __name__ == "__main__": + sys.exit(main(sys.argv))