2525from pathlib import Path
2626from typing import Any
2727
28+ import tomllib
29+
2830SCHEMA = "durable-workflow.release-plan/v1"
2931STATE_SCHEMA = "durable-workflow.component-release-recovery/v1"
3032CONTROL_REPOSITORY = "durable-workflow/.github"
3638VERSION_PATTERN = re .compile (r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?$" )
3739ALPHA_VERSION_PATTERN = re .compile (r"^2\.0\.0-alpha\.[1-9][0-9]*$" )
3840BETA_VERSION_PATTERN = re .compile (r"^2\.0\.0-beta\.[1-9][0-9]*$" )
41+ SOURCE_MANIFEST_VERSION_CONFLICT = "source-manifest-version-conflict"
42+ PYTHON_SOURCE_MANIFEST = {"path" : "pyproject.toml" , "package" : "durable-workflow" }
3943
4044# SHA-256 of durable-workflow/sdk-rust's release recovery workflow at main
4145# commit 31e87f4aa13a7fd255fd277a62c43c96ee1532ab. The verifier normalizes only
@@ -117,9 +121,18 @@ class Component:
117121class RecoveryError (RuntimeError ):
118122 """A release plan cannot safely advance."""
119123
120- def __init__ (self , message : str , phase : str = "preflight" ) -> None :
124+ def __init__ (
125+ self ,
126+ message : str ,
127+ phase : str = "preflight" ,
128+ * ,
129+ evidence : dict [str , Any ] | None = None ,
130+ resume_action : str | None = None ,
131+ ) -> None :
121132 super ().__init__ (message )
122133 self .phase = phase
134+ self .evidence = evidence
135+ self .resume_action = resume_action
123136
124137
125138class NotFound (RecoveryError ):
@@ -254,6 +267,77 @@ def read_record(client: PublicClient, tag: str, commit: str, filename: str) -> A
254267 raise RecoveryError (f"immutable record { tag } :{ filename } is not valid JSON" , "plan-discovery" ) from error
255268
256269
270+ def require_python_source_manifest_version (
271+ client : PublicClient ,
272+ identity : dict [str , str ],
273+ existing_tag : str | None ,
274+ ) -> dict [str , Any ]:
275+ component = COMPONENTS ["sdk-python" ]
276+ path = PYTHON_SOURCE_MANIFEST ["path" ]
277+ encoded_path = urllib .parse .quote (path , safe = "/" )
278+ raw = client .bytes (
279+ f"https://api.github.com/repos/{ component .repository } /contents/{ encoded_path } ?ref={ identity ['commit' ]} " ,
280+ accept = "application/vnd.github.raw+json" ,
281+ )
282+ if len (raw ) > 1024 * 1024 :
283+ raise RecoveryError ("sdk-python source manifest exceeds 1 MiB" , "source-manifest-preflight" )
284+ try :
285+ manifest = tomllib .loads (raw .decode ("utf-8" ))
286+ except (UnicodeDecodeError , tomllib .TOMLDecodeError ) as error :
287+ raise RecoveryError (
288+ "sdk-python pyproject.toml is not valid UTF-8 TOML at the planned commit" ,
289+ "source-manifest-preflight" ,
290+ ) from error
291+ project = manifest .get ("project" )
292+ declared_package = project .get ("name" ) if isinstance (project , dict ) else None
293+ declared_version = project .get ("version" ) if isinstance (project , dict ) else None
294+ if (
295+ declared_package != PYTHON_SOURCE_MANIFEST ["package" ]
296+ or not isinstance (declared_version , str )
297+ or not VERSION_PATTERN .fullmatch (declared_version )
298+ ):
299+ raise RecoveryError (
300+ "sdk-python pyproject.toml has no exact durable-workflow project identity at the planned commit" ,
301+ "source-manifest-preflight" ,
302+ )
303+
304+ evidence = {
305+ "declared_version" : declared_version ,
306+ "package" : declared_package ,
307+ "path" : path ,
308+ "sha256" : hashlib .sha256 (raw ).hexdigest (),
309+ "source_commit" : identity ["commit" ],
310+ "url" : f"https://github.com/{ component .repository } /blob/{ identity ['commit' ]} /{ path } " ,
311+ }
312+ if declared_version != identity ["version" ]:
313+ source_tag = {
314+ "tag" : identity ["version" ],
315+ "status" : "present" if existing_tag is not None else "absent" ,
316+ "commit" : existing_tag ,
317+ }
318+ if existing_tag is None :
319+ tag_instruction = f"keep { component .repository } @{ identity ['version' ]} absent"
320+ else :
321+ tag_instruction = f"keep immutable { component .repository } @{ identity ['version' ]} at { existing_tag } unchanged"
322+ raise RecoveryError (
323+ f"planned sdk-python version { identity ['version' ]} does not match pyproject.toml "
324+ f"project.version { declared_version } at { identity ['commit' ]} " ,
325+ "source-manifest-preflight" ,
326+ evidence = {
327+ "classification" : SOURCE_MANIFEST_VERSION_CONFLICT ,
328+ "planned_identity" : dict (identity ),
329+ "source_manifest" : evidence ,
330+ "source_tag" : source_tag ,
331+ },
332+ resume_action = (
333+ f"Hand off this terminal source-manifest conflict to the protected { CONTROL_REPOSITORY } "
334+ f"control-plane release-plan supersession workflow for corrected successor allocation; "
335+ f"{ tag_instruction } and do not rerun repository recovery for this plan"
336+ ),
337+ )
338+ return evidence
339+
340+
257341def discover_plan (client : PublicClient , requested_tag : str | None ) -> tuple [str , str , dict [str , Any ]]:
258342 if requested_tag :
259343 tag = requested_tag
@@ -372,25 +456,28 @@ def select_publication_run(
372456 raise RecoveryError ("publication run metadata is incomplete" , "publication" )
373457 exact_runs .append (run )
374458
375- selected = next ((run for run in exact_runs if run ["status" ] != "completed" ), None )
376- action = "wait"
377- if selected is None :
378- selected = next (
379- (run for run in exact_runs if run ["status" ] == "completed" and run .get ("conclusion" ) == "success" ),
380- None ,
381- )
382- action = "complete"
383- if selected is None :
384- selected = next ((run for run in exact_runs if run ["status" ] == "completed" ), None )
385- action = "rerun"
386- if selected is None :
387- return {"action" : "dispatch" , "run_id" : None , "status" : None , "conclusion" : None }
388- return {
389- "action" : action ,
390- "run_id" : selected ["databaseId" ],
391- "status" : selected ["status" ],
392- "conclusion" : selected .get ("conclusion" ),
393- }
459+ active = next ((run for run in exact_runs if run ["status" ] != "completed" ), None )
460+ if active is not None :
461+ return {
462+ "action" : "wait" ,
463+ "run_id" : active ["databaseId" ],
464+ "status" : active ["status" ],
465+ "conclusion" : active .get ("conclusion" ),
466+ }
467+
468+ successful = next (
469+ (run for run in exact_runs if run ["status" ] == "completed" and run .get ("conclusion" ) == "success" ),
470+ None ,
471+ )
472+ if successful is not None :
473+ return {
474+ "action" : "complete" ,
475+ "run_id" : successful ["databaseId" ],
476+ "status" : successful ["status" ],
477+ "conclusion" : successful .get ("conclusion" ),
478+ }
479+
480+ return {"action" : "dispatch" , "run_id" : None , "status" : None , "conclusion" : None }
394481
395482
396483def verify_plan_authority (
@@ -799,6 +886,39 @@ def base_state(component: str, tag: str | None = None, plan: dict[str, Any] | No
799886 }
800887
801888
889+ def resolution_failure_state (
890+ component_name : str ,
891+ tag : str | None ,
892+ record_commit : str | None ,
893+ plan : dict [str , Any ] | None ,
894+ error : RecoveryError ,
895+ ) -> dict [str , Any ]:
896+ failure = base_state (component_name , tag , plan )
897+ if record_commit is not None :
898+ failure ["plan_record_commit" ] = record_commit
899+ durable_evidence : dict [str , Any ] = {
900+ "release_plan" : tag ,
901+ "source_tag" : f"https://github.com/{ COMPONENTS [component_name ].repository } /releases" ,
902+ "actions" : f"https://github.com/{ COMPONENTS [component_name ].repository } /actions" ,
903+ }
904+ if error .evidence is not None :
905+ durable_evidence ["failure" ] = error .evidence
906+ failure .update (
907+ {
908+ "phase" : error .phase ,
909+ "outcome" : "failed" ,
910+ "reason" : str (error ),
911+ "durable_evidence" : durable_evidence ,
912+ "resume_action" : error .resume_action
913+ or (
914+ f"Run { COMPONENTS [component_name ].repository } Actions workflow "
915+ f"Release plan recovery{ f' for { tag } ' if tag else '' } "
916+ ),
917+ }
918+ )
919+ return failure
920+
921+
802922def resolve_component (
803923 client : PublicClient ,
804924 component_name : str ,
@@ -827,6 +947,11 @@ def resolve_component(
827947 f"points to { existing_tag } , not { identity ['commit' ]} " ,
828948 "tag-preflight" ,
829949 )
950+ source_manifest = (
951+ require_python_source_manifest_version (client , identity , existing_tag )
952+ if component_name == "sdk-python"
953+ else None
954+ )
830955 if existing_tag is None :
831956 with contextlib .suppress (NotFound ):
832957 VERIFIERS [component .distribution ](
@@ -853,6 +978,7 @@ def resolve_component(
853978 "recovery_workflows" : recovery_workflows ,
854979 "upstream" : upstream ,
855980 "source_tag" : {"status" : "present" if existing_tag else "absent" , "commit" : existing_tag },
981+ "source_manifest" : source_manifest ,
856982 "declared_identity" : identity ,
857983 "public_evidence" : completed ,
858984 "resume_action" : (
@@ -967,25 +1093,7 @@ def main() -> int:
9671093 args .evidence .write_bytes (canonical_json (idle ))
9681094 write_output (args .github_output , {"action" : "none" })
9691095 return 0
970- failure = base_state (args .component , tag , plan )
971- if record_commit is not None :
972- failure ["plan_record_commit" ] = record_commit
973- failure .update (
974- {
975- "phase" : error .phase ,
976- "outcome" : "failed" ,
977- "reason" : str (error ),
978- "durable_evidence" : {
979- "release_plan" : tag ,
980- "source_tag" : f"https://github.com/{ COMPONENTS [args .component ].repository } /releases" ,
981- "actions" : f"https://github.com/{ COMPONENTS [args .component ].repository } /actions" ,
982- },
983- "resume_action" : (
984- f"Run { COMPONENTS [args .component ].repository } Actions workflow "
985- f"Release plan recovery{ f' for { tag } ' if tag else '' } "
986- ),
987- }
988- )
1096+ failure = resolution_failure_state (args .component , tag , record_commit , plan , error )
9891097 args .evidence .write_bytes (canonical_json (failure ))
9901098 raise
9911099 else :
0 commit comments