1919import json
2020import shutil
2121import sys
22+ from datetime import datetime , timezone
2223from 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+
216279def 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