Skip to content

Commit 25ffa6d

Browse files
committed
Add CI notebook health check that fails the build when latest version has failed notebooks
1 parent bcbef3d commit 25ffa6d

2 files changed

Lines changed: 150 additions & 0 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ jobs:
5252
- name: Build indexes
5353
run: python scripts/build-indexes.py
5454

55+
# Inspect notebook execution health across all versions. Any version that
56+
# still has outputs with success: false is reported in the step summary.
57+
# The step fails only when the *latest* version of a package contains
58+
# failed notebooks — historical versions can have unfixable code bugs
59+
# that we tolerate, but a broken latest blocks deployment.
60+
- name: Notebook health check
61+
run: python scripts/check-notebook-health.py
62+
env:
63+
GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }}
64+
5565
- name: Check for changes
5666
id: check_changes
5767
run: |

scripts/check-notebook-health.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Inspect all notebook outputs under static/ and report failures.
4+
5+
Behaviour:
6+
- Writes a Markdown summary table to $GITHUB_STEP_SUMMARY (if set) listing
7+
every failed notebook per package/version with its truncated error.
8+
- Emits ::warning:: log lines so GitHub annotates each failed notebook
9+
visibly on the Actions run page.
10+
- Exits with code 1 iff the *latest* version of any package has at least one
11+
failed notebook output. Latest is determined from the package manifest's
12+
`latestTag`. Historical versions can fail without blocking deployment.
13+
14+
Usage (called from CI):
15+
python scripts/check-notebook-health.py
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import json
21+
import os
22+
import sys
23+
from pathlib import Path
24+
25+
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
26+
27+
# Skip directories under static/ that aren't packages.
28+
NON_PACKAGE_DIRS = {"pyodide"}
29+
30+
31+
def _load_json(path: Path) -> dict | None:
32+
try:
33+
with open(path, "r", encoding="utf-8") as f:
34+
return json.load(f)
35+
except (json.JSONDecodeError, OSError):
36+
return None
37+
38+
39+
def _strip_ansi(s: str) -> str:
40+
"""Remove ANSI escape sequences from a string."""
41+
out = []
42+
i = 0
43+
while i < len(s):
44+
if s[i] == "\x1b" and i + 1 < len(s) and s[i + 1] == "[":
45+
j = i + 2
46+
while j < len(s) and not (0x40 <= ord(s[j]) <= 0x7E):
47+
j += 1
48+
i = j + 1
49+
else:
50+
out.append(s[i])
51+
i += 1
52+
return "".join(out)
53+
54+
55+
def collect_failures() -> dict[tuple[str, str], list[tuple[str, str]]]:
56+
"""Return {(package, tag): [(notebook_stem, error_message), ...]} for failed outputs."""
57+
failures: dict[tuple[str, str], list[tuple[str, str]]] = {}
58+
59+
for pkg_dir in sorted(p for p in STATIC_DIR.iterdir() if p.is_dir()):
60+
if pkg_dir.name in NON_PACKAGE_DIRS:
61+
continue
62+
for version_dir in sorted(p for p in pkg_dir.iterdir() if p.is_dir()):
63+
if not version_dir.name.startswith("v"):
64+
continue
65+
outputs_dir = version_dir / "outputs"
66+
if not outputs_dir.exists():
67+
continue
68+
for output_file in sorted(outputs_dir.glob("*.json")):
69+
data = _load_json(output_file)
70+
if data is None or data.get("success") is not False:
71+
continue
72+
error = _strip_ansi(str(data.get("error", "unknown error"))).strip()
73+
if len(error) > 200:
74+
error = error[:197] + "..."
75+
failures.setdefault((pkg_dir.name, version_dir.name), []).append(
76+
(output_file.stem, error)
77+
)
78+
return failures
79+
80+
81+
def latest_tags() -> dict[str, str]:
82+
"""Return {package_id: latest_tag} from each package's top-level manifest."""
83+
result: dict[str, str] = {}
84+
for pkg_dir in STATIC_DIR.iterdir():
85+
if not pkg_dir.is_dir() or pkg_dir.name in NON_PACKAGE_DIRS:
86+
continue
87+
manifest = _load_json(pkg_dir / "manifest.json")
88+
if manifest and "latestTag" in manifest:
89+
result[pkg_dir.name] = manifest["latestTag"]
90+
return result
91+
92+
93+
def main() -> int:
94+
failures = collect_failures()
95+
latest = latest_tags()
96+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
97+
98+
if not failures:
99+
msg = "All notebook outputs report success.\n"
100+
print(msg, end="")
101+
if summary_path:
102+
Path(summary_path).write_text("### Notebook health\n\n" + msg)
103+
return 0
104+
105+
# GitHub annotations — appear inline on the Actions run page.
106+
for (pkg, tag), items in failures.items():
107+
for stem, err in items:
108+
print(f"::warning title=Notebook failure::{pkg}/{tag}/{stem}: {err}")
109+
110+
# Step summary.
111+
lines = ["### Notebook health\n", f"\n{sum(len(v) for v in failures.values())} failed notebook output(s) across {len(failures)} version(s).\n"]
112+
lines.append("\n| Package | Version | Latest? | Notebook | Error |\n")
113+
lines.append("|---|---|---|---|---|\n")
114+
for (pkg, tag), items in sorted(failures.items()):
115+
is_latest = "**yes**" if latest.get(pkg) == tag else ""
116+
for stem, err in items:
117+
# Escape pipe characters for Markdown tables.
118+
err_md = err.replace("|", "\\|").replace("\n", " ")
119+
lines.append(f"| {pkg} | {tag} | {is_latest} | `{stem}` | {err_md} |\n")
120+
121+
if summary_path:
122+
Path(summary_path).write_text("".join(lines))
123+
else:
124+
sys.stdout.write("".join(lines))
125+
126+
# Hard-fail iff latest version of any package has failures.
127+
blocking = sorted({pkg for (pkg, tag) in failures if latest.get(pkg) == tag})
128+
if blocking:
129+
print(
130+
f"\nBlocking: latest version of {', '.join(blocking)} has failed notebooks.",
131+
file=sys.stderr,
132+
)
133+
return 1
134+
135+
print("\nFailures only in historical versions — not blocking deployment.")
136+
return 0
137+
138+
139+
if __name__ == "__main__":
140+
sys.exit(main())

0 commit comments

Comments
 (0)