Skip to content

Commit e9025d3

Browse files
committed
Introduce .build-ok marker so failed builds are retried instead of skipped forever
1 parent 399e933 commit e9025d3

1 file changed

Lines changed: 80 additions & 5 deletions

File tree

scripts/build.py

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import json
2020
import shutil
2121
import sys
22+
from datetime import datetime, timezone
2223
from pathlib import Path
2324

2425
# Add scripts directory to path for imports
@@ -213,6 +214,68 @@ def generate_version_indexes(
213214
print(f" {len(search_items)} search items, {len(crossref_items)} crossref items")
214215

215216

217+
BUILD_OK_FILE = ".build-ok"
218+
219+
220+
def _is_build_ok(version_dir: Path) -> bool:
221+
"""
222+
Decide whether a version directory is a successful build that can be skipped.
223+
224+
Truth sources, in priority order:
225+
1. A `.build-ok` marker file → build completed successfully on a previous run.
226+
2. Implicit fallback for legacy builds that predate the marker:
227+
- api.json or manifest.json exists, AND
228+
- either outputs/ is missing (no notebooks) or every outputs/*.json reports
229+
success: true.
230+
This keeps healthy historical versions from being rebuilt unnecessarily.
231+
232+
A build that produced any failed notebook output is NOT ok — the version is
233+
rebuilt next run so the regression gets healed automatically.
234+
"""
235+
if not version_dir.exists():
236+
return False
237+
238+
if (version_dir / BUILD_OK_FILE).exists():
239+
return True
240+
241+
# Fallback for legacy versions without the marker file
242+
has_core = (version_dir / "manifest.json").exists() or (version_dir / "api.json").exists()
243+
if not has_core:
244+
return False
245+
246+
outputs_dir = version_dir / "outputs"
247+
if not outputs_dir.exists():
248+
# No notebooks to execute → nothing could have failed
249+
return True
250+
251+
for output_file in outputs_dir.glob("*.json"):
252+
try:
253+
with open(output_file, "r", encoding="utf-8") as f:
254+
data = json.load(f)
255+
except (json.JSONDecodeError, OSError):
256+
return False
257+
if data.get("success") is False:
258+
return False
259+
260+
return True
261+
262+
263+
def _write_build_ok(version_dir: Path, notebook_results: dict | None = None) -> None:
264+
"""Write the .build-ok marker capturing how this version was built."""
265+
summary: dict = {
266+
"builtAt": datetime.now(timezone.utc).isoformat(),
267+
"python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
268+
}
269+
270+
if notebook_results is not None:
271+
total = len(notebook_results)
272+
failed = sum(1 for r in notebook_results.values() if r.get("success") is False)
273+
summary["notebooks"] = {"total": total, "failed": failed}
274+
275+
with open(version_dir / BUILD_OK_FILE, "w", encoding="utf-8") as f:
276+
json.dump(summary, f, indent=2)
277+
278+
216279
def get_versions_to_build(
217280
package_id: str,
218281
tags: list[str],
@@ -227,6 +290,9 @@ def get_versions_to_build(
227290
- Always keep vX.Y.0 tags (historical milestones)
228291
- Keep exactly one "latest" non-.0 tag
229292
- When a newer tag exists, build it and delete old non-.0 latest
293+
- A version is considered already built only if `_is_build_ok` returns True
294+
(presence of `.build-ok` marker, or legacy build with all outputs success:true).
295+
Versions with failed outputs are retried automatically.
230296
"""
231297
min_version = MIN_SUPPORTED_VERSIONS.get(package_id, "0.1")
232298
output_dir = STATIC_DIR / package_id
@@ -240,14 +306,14 @@ def get_versions_to_build(
240306

241307
# Build missing .0 releases
242308
for tag in historical:
243-
if rebuild_all or not (output_dir / tag).exists():
309+
if rebuild_all or not _is_build_ok(output_dir / tag):
244310
to_build.append(tag)
245311

246312
# Handle latest tag
247313
if latest_tag:
248-
latest_exists = (output_dir / latest_tag).exists()
314+
latest_ok = _is_build_ok(output_dir / latest_tag)
249315

250-
if rebuild_all or not latest_exists:
316+
if rebuild_all or not latest_ok:
251317
to_build.append(latest_tag)
252318

253319
# If latest is not a .0 release, check if there's an old non-.0 to delete
@@ -326,9 +392,10 @@ def build_version(
326392
print(f" No notebooks found")
327393

328394
# 3. Execute notebooks
395+
notebook_results: dict = {}
329396
if execute and notebooks:
330397
print(f" Executing notebooks...")
331-
execute_notebooks(output_dir, notebooks, parallel=True)
398+
notebook_results = execute_notebooks(output_dir, notebooks, parallel=True)
332399

333400
# 4. Generate version manifest
334401
print(f" Generating manifest...")
@@ -351,7 +418,15 @@ def build_version(
351418
except Exception as e:
352419
print(f" Warning: {e}")
353420

354-
print(f" Done")
421+
# 7. Write .build-ok marker — only on a fully-clean run.
422+
# Partial failures (some notebook outputs success: false) leave the
423+
# marker absent so the next CI run picks the version up automatically.
424+
failed = sum(1 for r in notebook_results.values() if r.get("success") is False)
425+
if failed == 0:
426+
_write_build_ok(output_dir, notebook_results)
427+
print(f" Done (build-ok written)")
428+
else:
429+
print(f" Done with {failed} notebook failure(s) — marker withheld, will retry next run")
355430
return True
356431

357432
except Exception as e:

0 commit comments

Comments
 (0)