Skip to content

Commit afdd7e0

Browse files
committed
feat(ci): decouple volume badges into per-volume endpoint status badges
1 parent 1be4449 commit afdd7e0

8 files changed

Lines changed: 250 additions & 5 deletions

File tree

.github/workflows/book-validate-dev.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,3 +668,12 @@ jobs:
668668
echo "" >> $GITHUB_STEP_SUMMARY
669669
echo "## 🔗 Link Check (non-blocking)" >> $GITHUB_STEP_SUMMARY
670670
echo "- Result: \`${{ needs.check-links.result }}\` (see job for detail)" >> $GITHUB_STEP_SUMMARY
671+
672+
- name: 🏷️ Update and Publish per-volume status badges
673+
if: always()
674+
env:
675+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
676+
GITHUB_REPOSITORY: ${{ github.repository }}
677+
GITHUB_RUN_ID: ${{ github.run_id }}
678+
run: |
679+
python3 site/scripts/update_volume_status.py --publish-gh-pages

.github/workflows/site-publish-live.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ jobs:
140140
# disappears into 3000 px of empty whitespace, and dark mode is unstyled.
141141
for item in "$BUILD_DIR"/*; do
142142
basename="$(basename "$item")"
143-
if [[ -d "$basename" ]] && [[ "$basename" =~ ^(book|kits|tinytorch|labs|mlsysim|slides|instructors|interviews|staffml|about|community|newsletter)$ ]]; then
143+
if [[ -d "$basename" ]] && [[ "$basename" =~ ^(book|kits|tinytorch|labs|mlsysim|slides|instructors|interviews|staffml|about|community|newsletter|status)$ ]]; then
144144
continue
145145
fi
146146
rm -rf "./$basename"

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@
1212

1313
<!-- Build Status: Core 4-Volume Series -->
1414
<p align="center">
15-
<a href="https://github.com/harvard-edge/cs249r_book/actions/workflows/book-validate-dev.yml"><img src="https://img.shields.io/github/actions/workflow/status/harvard-edge/cs249r_book/book-validate-dev.yml?branch=dev&event=push&label=Vol%20I%3A%20Foundations&logo=book&cacheSeconds=300" alt="Vol I: Foundations"></a>
16-
<a href="https://github.com/harvard-edge/cs249r_book/actions/workflows/book-validate-dev.yml"><img src="https://img.shields.io/github/actions/workflow/status/harvard-edge/cs249r_book/book-validate-dev.yml?branch=dev&event=push&label=Vol%20II%3A%20Scaling&logo=book&cacheSeconds=300" alt="Vol II: Scaling"></a>
17-
<a href="https://github.com/harvard-edge/cs249r_book/actions/workflows/book-validate-dev.yml"><img src="https://img.shields.io/github/actions/workflow/status/harvard-edge/cs249r_book/book-validate-dev.yml?branch=dev&event=push&label=Vol%20III%3A%20Agentic&logo=robot&cacheSeconds=300" alt="Vol III: Agentic"></a>
18-
<a href="https://github.com/harvard-edge/cs249r_book/actions/workflows/book-validate-dev.yml"><img src="https://img.shields.io/github/actions/workflow/status/harvard-edge/cs249r_book/book-validate-dev.yml?branch=dev&event=push&label=Vol%20IV%3A%20Physical%20AI&logo=gear&cacheSeconds=300" alt="Vol IV: Physical AI"></a>
15+
<a href="https://github.com/harvard-edge/cs249r_book/actions/workflows/book-validate-dev.yml"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fharvard-edge%2Fcs249r_book%2Fgh-pages%2Fstatus%2Fvol1.json&logo=book&cacheSeconds=300" alt="Vol I: Foundations"></a>
16+
<a href="https://github.com/harvard-edge/cs249r_book/actions/workflows/book-validate-dev.yml"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fharvard-edge%2Fcs249r_book%2Fgh-pages%2Fstatus%2Fvol2.json&logo=book&cacheSeconds=300" alt="Vol II: Scaling"></a>
17+
<a href="https://github.com/harvard-edge/cs249r_book/actions/workflows/book-validate-dev.yml"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fharvard-edge%2Fcs249r_book%2Fgh-pages%2Fstatus%2Fvol3.json&logo=robot&cacheSeconds=300" alt="Vol III: Agentic"></a>
18+
<a href="https://github.com/harvard-edge/cs249r_book/actions/workflows/book-validate-dev.yml"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fharvard-edge%2Fcs249r_book%2Fgh-pages%2Fstatus%2Fvol4.json&logo=gear&cacheSeconds=300" alt="Vol IV: Physical AI"></a>
1919
</p>
2020

2121
<!-- Build Status: Companion Ecosystem & Tools -->
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
#!/usr/bin/env python3
2+
"""Update per-volume status badge JSON files based on GitHub Actions matrix results.
3+
4+
Reads job outcomes for the current workflow run and generates Shields.io endpoint
5+
badge JSON files for each volume under `site/status/vol{1,2,3,4}.json`.
6+
Optionally publishes the updated JSONs to the `gh-pages` branch.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import os
13+
import subprocess
14+
import sys
15+
from pathlib import Path
16+
import urllib.request
17+
18+
REPO_ROOT = Path(__file__).resolve().parents[2]
19+
STATUS_DIR = REPO_ROOT / "site" / "status"
20+
21+
VOLUMES = [
22+
{"key": "vol1", "marker": "Vol I (", "label": "Vol I: Foundations"},
23+
{"key": "vol2", "marker": "Vol II (", "label": "Vol II: Scaling"},
24+
{"key": "vol3", "marker": "Vol III (", "label": "Vol III: Agentic"},
25+
{"key": "vol4", "marker": "Vol IV (", "label": "Vol IV: Physical AI"},
26+
]
27+
28+
29+
def fetch_workflow_jobs(repo: str, run_id: str, token: str) -> list[dict]:
30+
"""Fetch all jobs for a given workflow run using the GitHub API."""
31+
jobs: list[dict] = []
32+
page = 1
33+
while True:
34+
url = f"https://api.github.com/repos/{repo}/actions/runs/{run_id}/jobs?per_page=100&page={page}"
35+
req = urllib.request.Request(
36+
url,
37+
headers={
38+
"Accept": "application/vnd.github+json",
39+
"Authorization": f"Bearer {token}",
40+
"User-Agent": "volume-status-updater",
41+
},
42+
)
43+
try:
44+
with urllib.request.urlopen(req) as resp:
45+
data = json.loads(resp.read().decode("utf-8"))
46+
page_jobs = data.get("jobs", [])
47+
jobs.extend(page_jobs)
48+
if len(page_jobs) < 100:
49+
break
50+
page += 1
51+
except Exception as e:
52+
print(f"⚠️ Warning: Could not fetch jobs from GitHub API: {e}", file=sys.stderr)
53+
break
54+
return jobs
55+
56+
57+
def evaluate_volume_status(volume: dict, jobs: list[dict]) -> dict:
58+
"""Determine the badge JSON for a volume based on its matrix build jobs."""
59+
vol_marker = volume["marker"]
60+
vol_jobs = [j for j in jobs if vol_marker in j.get("name", "")]
61+
62+
badge_path = STATUS_DIR / f"{volume['key']}.json"
63+
existing_badge = {}
64+
if badge_path.exists():
65+
try:
66+
existing_badge = json.loads(badge_path.read_text(encoding="utf-8"))
67+
except Exception:
68+
pass
69+
70+
if not vol_jobs:
71+
# If no jobs ran for this volume in this run (e.g. single-volume build),
72+
# preserve the existing status if available, else default to passing.
73+
if existing_badge:
74+
return existing_badge
75+
return {
76+
"schemaVersion": 1,
77+
"label": volume["label"],
78+
"message": "passing",
79+
"color": "brightgreen",
80+
}
81+
82+
# Check for failures
83+
failed = any(
84+
j.get("conclusion") in ["failure", "timed_out", "action_required", "stale"]
85+
for j in vol_jobs
86+
)
87+
if failed:
88+
return {
89+
"schemaVersion": 1,
90+
"label": volume["label"],
91+
"message": "failing",
92+
"color": "red",
93+
}
94+
95+
# Check if all completed jobs succeeded
96+
all_succeeded = all(
97+
j.get("conclusion") in ["success", "skipped"]
98+
for j in vol_jobs
99+
if j.get("status") == "completed"
100+
)
101+
102+
if all_succeeded:
103+
return {
104+
"schemaVersion": 1,
105+
"label": volume["label"],
106+
"message": "passing",
107+
"color": "brightgreen",
108+
}
109+
110+
# If still in progress or inconclusive, keep existing or mark in-progress
111+
return existing_badge or {
112+
"schemaVersion": 1,
113+
"label": volume["label"],
114+
"message": "building",
115+
"color": "yellow",
116+
}
117+
118+
119+
def update_status_files(jobs: list[dict]) -> None:
120+
"""Update all volume JSON files in site/status/."""
121+
STATUS_DIR.mkdir(parents=True, exist_ok=True)
122+
for vol in VOLUMES:
123+
badge_data = evaluate_volume_status(vol, jobs)
124+
out_path = STATUS_DIR / f"{vol['key']}.json"
125+
out_path.write_text(json.dumps(badge_data, indent=2) + "\n", encoding="utf-8")
126+
print(f"📊 {vol['key']}: {badge_data['message']} ({badge_data['color']}) -> {out_path}")
127+
128+
129+
def publish_to_gh_pages(repo: str, token: str) -> None:
130+
"""Clone gh-pages and push updated status files."""
131+
print("🚀 Publishing per-volume status files to gh-pages...")
132+
repo_dir = REPO_ROOT / "_temp_gh_pages_status"
133+
if repo_dir.exists():
134+
subprocess.run(["rm", "-rf", str(repo_dir)], check=False)
135+
136+
try:
137+
subprocess.run(
138+
[
139+
"git",
140+
"clone",
141+
"--depth=1",
142+
"--branch=gh-pages",
143+
f"https://x-access-token:{token}@github.com/{repo}.git",
144+
str(repo_dir),
145+
],
146+
check=True,
147+
capture_output=True,
148+
)
149+
150+
dest_dir = repo_dir / "status"
151+
dest_dir.mkdir(parents=True, exist_ok=True)
152+
153+
for vol in VOLUMES:
154+
src = STATUS_DIR / f"{vol['key']}.json"
155+
if src.exists():
156+
(dest_dir / f"{vol['key']}.json").write_text(
157+
src.read_text(encoding="utf-8"), encoding="utf-8"
158+
)
159+
160+
subprocess.run(["git", "config", "user.name", "github-actions[bot]"], cwd=str(repo_dir), check=True)
161+
subprocess.run(
162+
["git", "config", "user.email", "github-actions[bot]@users.noreply.github.com"],
163+
cwd=str(repo_dir),
164+
check=True,
165+
)
166+
167+
subprocess.run(["git", "add", "status"], cwd=str(repo_dir), check=True)
168+
169+
diff_check = subprocess.run(
170+
["git", "diff", "--cached", "--quiet"], cwd=str(repo_dir), check=False
171+
)
172+
if diff_check.returncode == 0:
173+
print("🟡 No changes in volume status files; skipping push.")
174+
return
175+
176+
subprocess.run(["git", "commit", "-m", "🏷️ Update per-volume status badges"], cwd=str(repo_dir), check=True)
177+
178+
for attempt in range(1, 4):
179+
push_res = subprocess.run(["git", "push", "origin", "gh-pages"], cwd=str(repo_dir), check=False)
180+
if push_res.returncode == 0:
181+
print(f"✅ Successfully pushed volume status badges to gh-pages on attempt {attempt}")
182+
return
183+
print(f"⚠️ Push failed on attempt {attempt}, rebasing...")
184+
subprocess.run(["git", "pull", "--rebase", "origin", "gh-pages"], cwd=str(repo_dir), check=False)
185+
186+
print("⚠️ Could not push volume status files to gh-pages after 3 attempts (non-fatal)")
187+
finally:
188+
if repo_dir.exists():
189+
subprocess.run(["rm", "-rf", str(repo_dir)], check=False)
190+
191+
192+
def main() -> int:
193+
token = os.environ.get("GITHUB_TOKEN", "")
194+
repo = os.environ.get("GITHUB_REPOSITORY", "harvard-edge/cs249r_book")
195+
run_id = os.environ.get("GITHUB_RUN_ID", "")
196+
197+
jobs = []
198+
if token and run_id:
199+
print(f"🔎 Fetching job results for run {run_id} in {repo}...")
200+
jobs = fetch_workflow_jobs(repo, run_id, token)
201+
print(f" Found {len(jobs)} total jobs in run.")
202+
203+
update_status_files(jobs)
204+
205+
if "--publish-gh-pages" in sys.argv and token and repo:
206+
publish_to_gh_pages(repo, token)
207+
208+
return 0
209+
210+
211+
if __name__ == "__main__":
212+
sys.exit(main())

site/status/vol1.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"schemaVersion": 1,
3+
"label": "Vol I: Foundations",
4+
"message": "passing",
5+
"color": "brightgreen"
6+
}

site/status/vol2.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"schemaVersion": 1,
3+
"label": "Vol II: Scaling",
4+
"message": "passing",
5+
"color": "brightgreen"
6+
}

site/status/vol3.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"schemaVersion": 1,
3+
"label": "Vol III: Agentic",
4+
"message": "passing",
5+
"color": "brightgreen"
6+
}

site/status/vol4.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"schemaVersion": 1,
3+
"label": "Vol IV: Physical AI",
4+
"message": "passing",
5+
"color": "brightgreen"
6+
}

0 commit comments

Comments
 (0)