Skip to content

Commit 2005f10

Browse files
Release blocker: failed publication recovery must use the current plan (#250)
1 parent 2d096bc commit 2005f10

3 files changed

Lines changed: 281 additions & 41 deletions

File tree

.github/workflows/release-plan-recovery.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,6 @@ jobs:
9191
if [ "$publication_action" = dispatch ]; then
9292
gh workflow run publish.yml --ref "$RELEASE_TAG" \
9393
-f release_tag="$RELEASE_TAG" -f release_plan="$PLAN_TAG" -f publish=true
94-
elif [ "$publication_action" = rerun ]; then
95-
gh run rerun "$run_id"
9694
else
9795
printf 'Durable publication run %s is %s/%s; no duplicate dispatch is needed.\n' \
9896
"$run_id" "$status" "${conclusion:-pending}"

scripts/ci/component-release-recovery.py

Lines changed: 147 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
from pathlib import Path
2626
from typing import Any
2727

28+
import tomllib
29+
2830
SCHEMA = "durable-workflow.release-plan/v1"
2931
STATE_SCHEMA = "durable-workflow.component-release-recovery/v1"
3032
CONTROL_REPOSITORY = "durable-workflow/.github"
@@ -36,6 +38,8 @@
3638
VERSION_PATTERN = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?$")
3739
ALPHA_VERSION_PATTERN = re.compile(r"^2\.0\.0-alpha\.[1-9][0-9]*$")
3840
BETA_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:
117121
class 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

125138
class 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+
257341
def 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

396483
def 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+
802922
def 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

Comments
 (0)