Skip to content

Commit b7a6ddd

Browse files
TD-GCP-003: Prepare versioned changelogs without guessing release tags
1 parent 6772291 commit b7a6ddd

5 files changed

Lines changed: 312 additions & 28 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ jobs:
4040
resolve
4141
--component sdk-python
4242
--plan-output release-plan.json
43+
--preparation-output release-preparation.json
4344
--evidence release-recovery-evidence.json
4445
--github-output "$GITHUB_OUTPUT"
4546
)
@@ -103,6 +104,7 @@ jobs:
103104
name: sdk-python-release-recovery-${{ steps.recovery.outputs.plan || github.run_id }}
104105
path: |
105106
release-plan.json
107+
release-preparation.json
106108
release-recovery-evidence.json
107109
publication-runs.json
108110
if-no-files-found: warn

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
124124
workers upgrade.
125125

126126
### Changed
127+
- Release-plan recovery now consumes immutable, exact-version release-note
128+
preparation authority before publishing a newly recorded plan.
127129
- `tests/test_client.py` now closes the `schedule.history` polyglot
128130
parity slice. `test_get_schedule_history_matches_polyglot_fixture`
129131
asserts the full decoded payload envelope per event (`id`,

scripts/ci/component-release-recovery.py

Lines changed: 192 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import tomllib
2929

3030
SCHEMA = "durable-workflow.release-plan/v1"
31+
PREPARATION_SCHEMA = "durable-workflow.release-preparation/v1"
3132
STATE_SCHEMA = "durable-workflow.component-release-recovery/v1"
3233
CONTROL_REPOSITORY = "durable-workflow/.github"
3334
PLAN_TAG_PREFIX = "release-plan/"
@@ -38,15 +39,18 @@
3839
VERSION_PATTERN = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?$")
3940
ALPHA_VERSION_PATTERN = re.compile(r"^2\.0\.0-alpha\.[1-9][0-9]*$")
4041
BETA_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"}
4145
SOURCE_MANIFEST_VERSION_CONFLICT = "source-manifest-version-conflict"
4246
PYTHON_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+
237330
def 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

383510
def 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:

scripts/ci/sdk-rust-release-plan-recovery.fixture.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ jobs:
4646
resolve
4747
--component sdk-rust
4848
--plan-output release-plan.json
49+
--preparation-output release-preparation.json
4950
--evidence release-recovery-evidence.json
5051
--github-output "$GITHUB_OUTPUT"
5152
)
@@ -63,6 +64,7 @@ jobs:
6364
name: sdk-rust-release-recovery-${{ steps.recovery.outputs.plan || github.run_id }}
6465
path: |
6566
release-plan.json
67+
release-preparation.json
6668
release-recovery-evidence.json
6769
if-no-files-found: warn
6870

0 commit comments

Comments
 (0)