Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions .github/scripts/check_consumed_files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Go-live guardrail: a PR must not break a file a live lecture consumes.

For every manifest sidecar lectures/<datafile>.yml:

- the manifest's `filename` must match the sidecar's own name
- if `consumers` is non-empty (a lecture reads this file in production):
* the data file must exist
* `integrity.sha256` must be recorded
* the committed bytes must hash to it

Files with no manifest yet (Phase 6 backfill pending) or an empty `consumers`
list are out of scope here — the full validation suite (schema, dtypes,
invariants) is PLAN Phase 5 and will subsume this check.
"""
from __future__ import annotations

import hashlib
import pathlib
import sys

import yaml

LECTURES = pathlib.Path(__file__).resolve().parents[2] / "lectures"


def sha256(path: pathlib.Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()


def main() -> int:
errors = []
manifests = sorted(LECTURES.glob("*.yml"))
checked = 0

for manifest_path in manifests:
try:
manifest = yaml.safe_load(manifest_path.read_text())
except yaml.YAMLError as exc:
errors.append(f"{manifest_path.name}: invalid YAML — {exc}")
continue
if not isinstance(manifest, dict):
errors.append(
f"{manifest_path.name}: manifest must be a YAML mapping, got "
f"{type(manifest).__name__} — is the file empty or malformed?"
)
continue

declared = manifest.get("filename")
expected = manifest_path.name[: -len(".yml")]
if declared != expected:
errors.append(
f"{manifest_path.name}: `filename: {declared}` does not match "
f"the sidecar's own name (expected {expected!r})"
)
continue

consumers = manifest.get("consumers") or []
if not consumers:
continue # nothing live reads it — out of scope for this guardrail

checked += 1
data_path = LECTURES / declared
if not data_path.exists():
errors.append(
f"{declared}: consumed by {len(consumers)} lecture(s) but the "
f"data file is missing — this would break a live lecture build"
)
continue

integrity = manifest.get("integrity")
# A non-dict integrity (e.g. a stray string) is treated as missing, so
# it lands in the "not recorded" error below instead of crashing here.
recorded = integrity.get("sha256") if isinstance(integrity, dict) else None
if not recorded:
errors.append(
f"{declared}: consumed but integrity.sha256 is not recorded — "
f"a consumed file must carry its hash so changes are deliberate"
)
continue

actual = sha256(data_path)
if actual != recorded:
errors.append(
f"{declared}: bytes do not match the manifest (sha256 {actual} "
f"!= recorded {recorded}). If this is a deliberate in-place "
f"correction, update integrity.sha256 in the same PR and plan "
f"rebuilds for its consumers (AGENTS.md, 'Corrections vs "
f"vintages')"
)

for e in errors:
print(f"::error::{e}")
print(
f"{len(manifests)} manifest(s) found, {checked} consumed file(s) "
f"checked, {len(errors)} error(s)"
)
return 1 if errors else 0


if __name__ == "__main__":
sys.exit(main())
27 changes: 27 additions & 0 deletions .github/workflows/consumed-file-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Go-live guardrail (PLAN Phase 5): a PR must not break a file a live lecture
# consumes. The narrowest possible check — full PR validation (manifest schema,
# dtypes, invariant tests) comes later in Phase 5 and will subsume this job.

name: consumed-file-check

on:
pull_request:
push:
branches: [main]

jobs:
consumed-files:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# No LFS-tracked files today (PLAN Phase 3). Set proactively: if a
# consumed file is ever LFS-tracked, this job must hash the bytes,
# not the pointer — a pointer hash would fail loudly here, which is
# the correct behaviour, but only with lfs available to fetch.
lfs: true
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pyyaml
- run: python .github/scripts/check_consumed_files.py
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ The sidecar naming uses the **full filename** (`mpd2020.xlsx.yml`, not `mpd2020.
**Go-live guardrails** — the minimal subset that must precede the first repoint (Phase 8); the rest of this phase follows at its own pace:

- [x] Branch protection on `main`: PRs required (no direct pushes; zero approvals so a solo maintainer can still merge), force-pushes and deletion blocked. Once a lecture repoints, `raw/main` is a production URL and an accidental force-push is a lecture outage (ruleset added 2026-07-17)
- [ ] Minimal consumed-file check: CI that asserts every file in `lectures/` whose manifest has a non-empty `consumers` list still exists and matches its manifest `sha256` — the narrowest possible test that a PR cannot break a live lecture. Subsumed later by the full PR validation below
- [x] Minimal consumed-file check: CI that asserts every file in `lectures/` whose manifest has a non-empty `consumers` list still exists and matches its manifest `sha256` — the narrowest possible test that a PR cannot break a live lecture. Subsumed later by the full PR validation below (added 2026-07-17: `.github/workflows/consumed-file-check.yml`)

Full automation:

Expand Down
Loading