2828import tomllib
2929
3030SCHEMA = "durable-workflow.release-plan/v1"
31+ PREPARATION_SCHEMA = "durable-workflow.release-preparation/v1"
3132STATE_SCHEMA = "durable-workflow.component-release-recovery/v1"
3233CONTROL_REPOSITORY = "durable-workflow/.github"
3334PLAN_TAG_PREFIX = "release-plan/"
3839VERSION_PATTERN = re .compile (r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?$" )
3940ALPHA_VERSION_PATTERN = re .compile (r"^2\.0\.0-alpha\.[1-9][0-9]*$" )
4041BETA_VERSION_PATTERN = re .compile (r"^2\.0\.0-beta\.[1-9][0-9]*$" )
42+ MARKDOWN_MEDIA_TYPE = "text/markdown"
43+
44+ SOURCE_CHANGELOGS = {"workflow" , "waterline" , "sdk-php" , "sdk-python" }
4145SOURCE_MANIFEST_VERSION_CONFLICT = "source-manifest-version-conflict"
4246PYTHON_SOURCE_MANIFEST = {"path" : "pyproject.toml" , "package" : "durable-workflow" }
4347
44- # SHA-256 of durable-workflow/sdk-rust's release recovery workflow at main
45- # commit 31e87f4aa13a7fd255fd277a62c43c96ee1532ab. The verifier normalizes only
48+ # SHA-256 of durable-workflow/sdk-rust's prepared-plan recovery workflow. The
49+ # verifier normalizes only
4650# CRLF line endings to LF before hashing. Exact source identity is the bounded
4751# security contract because arbitrary shell execution cannot be proven safe by
4852# source-pattern matching.
49- SDK_RUST_RELEASE_RECOVERY_SHA256 = "d04219e3ffcc1c12a7a223efc832abee56c371b9dc60a7ef5a44a156b2ab4f64 "
53+ SDK_RUST_RELEASE_RECOVERY_SHA256 = "58b452f99b60fc272afe1833352906659e3836457a844b285a13e9fc7b24dcbb "
5054
5155
5256@dataclass (frozen = True )
@@ -234,6 +238,95 @@ def validate_plan(plan: Any) -> None:
234238 raise RecoveryError ("beta plans require an immutable beta authorization" )
235239
236240
241+ def manifest_digest (value : Any ) -> str :
242+ return hashlib .sha256 (canonical_json (value )).hexdigest ()
243+
244+
245+ def validate_release_preparation (preparation : Any , plan : dict [str , Any ]) -> None :
246+ if not isinstance (preparation , dict ) or set (preparation ) != {
247+ "schema" ,
248+ "release_plan" ,
249+ "components" ,
250+ }:
251+ raise RecoveryError ("release preparation has an invalid top-level shape" , "plan-discovery" )
252+ if preparation ["schema" ] != PREPARATION_SCHEMA or preparation ["release_plan" ] != {
253+ "tag" : f"{ PLAN_TAG_PREFIX } { plan ['plan' ]} " ,
254+ "sha256" : manifest_digest (plan ),
255+ }:
256+ raise RecoveryError ("release preparation names a different immutable plan" , "plan-discovery" )
257+ components = preparation ["components" ]
258+ if not isinstance (components , dict ) or set (components ) != set (COMPONENTS ):
259+ raise RecoveryError ("release preparation does not cover the exact component tuple" , "plan-discovery" )
260+ release_dates : set [str ] = set ()
261+ for name , entry in components .items ():
262+ identity = plan ["components" ][name ]
263+ if not isinstance (entry , dict ) or set (entry ) != {
264+ "version" ,
265+ "source_commit" ,
266+ "release_notes" ,
267+ }:
268+ raise RecoveryError (f"release preparation for { name } has an invalid shape" , "plan-discovery" )
269+ if entry ["version" ] != identity ["version" ] or entry ["source_commit" ] != identity ["commit" ]:
270+ raise RecoveryError (
271+ f"release preparation for { name } names a different planned identity" ,
272+ "plan-discovery" ,
273+ )
274+ notes = entry ["release_notes" ]
275+ if not isinstance (notes , dict ) or set (notes ) != {
276+ "format" ,
277+ "heading" ,
278+ "markdown" ,
279+ "release_date" ,
280+ "sha256" ,
281+ "source" ,
282+ }:
283+ raise RecoveryError (f"release preparation for { name } has invalid release notes" , "plan-discovery" )
284+ release_date = notes ["release_date" ]
285+ try :
286+ parsed_date = dt .date .fromisoformat (release_date )
287+ except (TypeError , ValueError ) as error :
288+ raise RecoveryError (
289+ f"release preparation for { name } has an invalid release date" ,
290+ "plan-discovery" ,
291+ ) from error
292+ heading = f"## [{ identity ['version' ]} ] - { parsed_date .isoformat ()} "
293+ markdown = notes ["markdown" ]
294+ if (
295+ notes ["format" ] != MARKDOWN_MEDIA_TYPE
296+ or release_date != parsed_date .isoformat ()
297+ or notes ["heading" ] != heading
298+ or not isinstance (markdown , str )
299+ or not markdown .startswith (f"{ heading } \n \n " )
300+ or not markdown .endswith ("\n " )
301+ or notes ["sha256" ] != hashlib .sha256 (markdown .encode ()).hexdigest ()
302+ ):
303+ raise RecoveryError (
304+ f"release preparation for { name } has mismatched versioned note content" ,
305+ "plan-discovery" ,
306+ )
307+ source = notes ["source" ]
308+ expected_kind = "changelog-unreleased" if name in SOURCE_CHANGELOGS else "source-commit-message"
309+ expected_source_url = (
310+ f"https://github.com/{ COMPONENTS [name ].repository } /blob/{ identity ['commit' ]} /CHANGELOG.md"
311+ if name in SOURCE_CHANGELOGS
312+ else f"https://github.com/{ COMPONENTS [name ].repository } /commit/{ identity ['commit' ]} "
313+ )
314+ if (
315+ not isinstance (source , dict )
316+ or set (source ) != {"kind" , "sha256" , "url" }
317+ or source ["kind" ] != expected_kind
318+ or not re .fullmatch (r"[0-9a-f]{64}" , str (source ["sha256" ]))
319+ or source ["url" ] != expected_source_url
320+ ):
321+ raise RecoveryError (
322+ f"release preparation for { name } has invalid note-source evidence" ,
323+ "plan-discovery" ,
324+ )
325+ release_dates .add (release_date )
326+ if len (release_dates ) != 1 :
327+ raise RecoveryError ("release preparation components do not share one release date" , "plan-discovery" )
328+
329+
237330def resolve_tag (client : PublicClient , repository : str , tag : str ) -> str | None :
238331 encoded = urllib .parse .quote (tag , safe = "" )
239332 try :
@@ -338,7 +431,11 @@ def require_python_source_manifest_version(
338431 return evidence
339432
340433
341- def discover_plan (client : PublicClient , requested_tag : str | None ) -> tuple [str , str , dict [str , Any ]]:
434+ def discover_plan (
435+ client : PublicClient , requested_tag : str | None , component_name : str
436+ ) -> tuple [str , str , dict [str , Any ], dict [str , Any ] | None ]:
437+ if component_name not in COMPONENTS :
438+ raise RecoveryError (f"unknown release component: { component_name } " , "plan-discovery" )
342439 if requested_tag :
343440 tag = requested_tag
344441 if not tag .startswith (PLAN_TAG_PREFIX ):
@@ -372,12 +469,42 @@ def discover_plan(client: PublicClient, requested_tag: str | None) -> tuple[str,
372469 if tag != f"{ PLAN_TAG_PREFIX } { plan ['plan' ]} " :
373470 raise RecoveryError ("release plan tag and document identity differ" , "plan-discovery" )
374471 assets = {asset .get ("name" ): asset for asset in release .get ("assets" , [])}
375- if "release-plan.json" not in assets :
376- raise RecoveryError (f"release plan { tag } lacks its durable mirror asset" , "plan-discovery" )
377- mirror = client .bytes (assets ["release-plan.json" ]["browser_download_url" ])
378- if mirror != canonical_json (plan ):
379- raise RecoveryError (f"release plan { tag } mirror differs from immutable Git authority" , "plan-discovery" )
380- return tag , commit , plan
472+ try :
473+ preparation = read_record (client , tag , commit , "release-preparation.json" )
474+ except NotFound :
475+ preparation = None
476+ if preparation is not None :
477+ validate_release_preparation (preparation , plan )
478+ records = [("release-plan.json" , plan )]
479+ if preparation is not None :
480+ records .append (("release-preparation.json" , preparation ))
481+ for filename , value in records :
482+ if filename not in assets :
483+ raise RecoveryError (
484+ f"release plan { tag } lacks its durable { filename } mirror asset" ,
485+ "plan-discovery" ,
486+ )
487+ mirror = client .bytes (assets [filename ]["browser_download_url" ])
488+ if mirror != canonical_json (value ):
489+ raise RecoveryError (
490+ f"release plan { tag } { filename } mirror differs from immutable Git authority" ,
491+ "plan-discovery" ,
492+ )
493+ if preparation is None :
494+ if "release-preparation.json" in assets :
495+ raise RecoveryError (
496+ f"release plan { tag } release-preparation.json mirror lacks immutable Git authority" ,
497+ "plan-discovery" ,
498+ )
499+ try :
500+ verify_component (client , component_name , plan ["components" ][component_name ])
501+ except NotFound as error :
502+ raise RecoveryError (
503+ f"release plan { tag } lacks immutable release-preparation.json; "
504+ "only completed legacy releases may recover without it" ,
505+ "plan-discovery" ,
506+ ) from error
507+ return tag , commit , plan , preparation
381508
382509
383510def verify_recovery_workflow_source (name : str , source : str ) -> None :
@@ -393,11 +520,13 @@ def verify_recovery_workflow_source(name: str, source: str) -> None:
393520 )
394521 return
395522
396- if not re .search (r"(?m)^ schedule:\s*$" , source ) or not re .search (
397- r"(?m)^ workflow_dispatch:\s*$" , source
523+ if (
524+ not re .search (r"(?m)^ schedule:\s*$" , source )
525+ or not re .search (r"(?m)^ workflow_dispatch:\s*$" , source )
526+ or "--preparation-output" not in source
398527 ):
399528 raise RecoveryError (
400- f"{ component .repository } recovery workflow lacks scheduled or manual discovery" ,
529+ f"{ component .repository } recovery workflow lacks scheduled/ manual prepared-plan discovery" ,
401530 "default-branch-preflight" ,
402531 )
403532 if component .release_workflow is None :
@@ -925,12 +1054,17 @@ def resolve_component(
9251054 tag : str ,
9261055 record_commit : str ,
9271056 plan : dict [str , Any ],
1057+ preparation : dict [str , Any ] | None ,
9281058) -> tuple [dict [str , Any ], dict [str , str ]]:
9291059 if component_name not in COMPONENTS :
9301060 raise RecoveryError (f"unknown release component: { component_name } " )
9311061 branches , recovery_workflows = verify_plan_authority (client , plan )
9321062 component = COMPONENTS [component_name ]
9331063 identity = plan ["components" ][component_name ]
1064+ prepared_identity = None
1065+ if preparation is not None :
1066+ validate_release_preparation (preparation , plan )
1067+ prepared_identity = preparation ["components" ][component_name ]
9341068 upstream : dict [str , Any ] = {}
9351069 for dependency in component .dependencies :
9361070 try :
@@ -952,22 +1086,27 @@ def resolve_component(
9521086 if component_name == "sdk-python"
9531087 else None
9541088 )
955- if existing_tag is None :
956- with contextlib .suppress (NotFound ):
957- VERIFIERS [component .distribution ](
958- client ,
959- component ,
960- identity ["version" ],
961- identity ["commit" ],
962- )
963- action = "publish"
9641089 completed : dict [str , Any ] | None = None
9651090 if existing_tag is not None :
966- try :
1091+ with contextlib . suppress ( NotFound ) :
9671092 completed = verify_component (client , component_name , identity )
968- action = "skip"
969- except NotFound :
970- pass
1093+ if completed is not None :
1094+ action = "skip"
1095+ else :
1096+ if preparation is None :
1097+ raise RecoveryError (
1098+ f"release plan { tag } lacks release preparation required before publishing { component_name } " ,
1099+ "plan-discovery" ,
1100+ )
1101+ if existing_tag is None :
1102+ with contextlib .suppress (NotFound ):
1103+ VERIFIERS [component .distribution ](
1104+ client ,
1105+ component ,
1106+ identity ["version" ],
1107+ identity ["commit" ],
1108+ )
1109+ action = "publish"
9711110 state = base_state (component_name , tag , plan )
9721111 state .update (
9731112 {
@@ -988,6 +1127,14 @@ def resolve_component(
9881127 ),
9891128 }
9901129 )
1130+ if prepared_identity is not None :
1131+ state ["release_preparation" ] = {
1132+ "record_commit" : record_commit ,
1133+ "record_sha256" : manifest_digest (preparation ),
1134+ "release_date" : prepared_identity ["release_notes" ]["release_date" ],
1135+ "release_notes_sha256" : prepared_identity ["release_notes" ]["sha256" ],
1136+ "source" : prepared_identity ["release_notes" ]["source" ],
1137+ }
9911138 outputs = {
9921139 "action" : action ,
9931140 "plan" : str (plan ["plan" ]),
@@ -1000,6 +1147,13 @@ def resolve_component(
10001147 "release_workflow" : component .release_workflow or "" ,
10011148 "release_tag_input" : component .release_tag_input or "" ,
10021149 }
1150+ if prepared_identity is not None :
1151+ outputs .update (
1152+ {
1153+ "release_date" : str (prepared_identity ["release_notes" ]["release_date" ]),
1154+ "release_notes_sha256" : str (prepared_identity ["release_notes" ]["sha256" ]),
1155+ }
1156+ )
10031157 return state , outputs
10041158
10051159
@@ -1032,6 +1186,7 @@ def main() -> int:
10321186 resolve .add_argument ("--component" , required = True , choices = sorted (COMPONENTS ))
10331187 resolve .add_argument ("--plan-tag" )
10341188 resolve .add_argument ("--plan-output" , required = True , type = Path )
1189+ resolve .add_argument ("--preparation-output" , required = True , type = Path )
10351190 resolve .add_argument ("--evidence" , required = True , type = Path )
10361191 resolve .add_argument ("--github-output" , type = Path )
10371192 resolve .add_argument ("--allow-empty" , action = "store_true" )
@@ -1070,9 +1225,18 @@ def main() -> int:
10701225 record_commit : str | None = None
10711226 plan : dict [str , Any ] | None = None
10721227 try :
1073- tag , record_commit , plan = discover_plan (client , args .plan_tag )
1228+ tag , record_commit , plan , preparation = discover_plan (client , args .plan_tag , args . component )
10741229 args .plan_output .write_bytes (canonical_json (plan ))
1075- state , outputs = resolve_component (client , args .component , tag , record_commit , plan )
1230+ if preparation is not None :
1231+ args .preparation_output .write_bytes (canonical_json (preparation ))
1232+ state , outputs = resolve_component (
1233+ client ,
1234+ args .component ,
1235+ tag ,
1236+ record_commit ,
1237+ plan ,
1238+ preparation ,
1239+ )
10761240 args .evidence .write_bytes (canonical_json (state ))
10771241 write_output (args .github_output , outputs )
10781242 except RecoveryError as error :
0 commit comments