diff --git a/scripts/releases/stable.py b/scripts/releases/stable.py index 09b109ac74..7f2c6f76fb 100644 --- a/scripts/releases/stable.py +++ b/scripts/releases/stable.py @@ -82,11 +82,16 @@ def stable_windows_version(epoch: object) -> str: return f"{instant.year}.{hour_of_year}.{second_of_hour}.0" -def validate_candidates(manifest: dict, tag: str, commit: str, public_base: str, - release_epoch: int | None = None, *, archive: str) -> dict: - """`tag` is the plain payload identity; `archive` is the releases/tag// - prefix every artifact URL must live under. Stable attempts name the attempt - ref as their archive; the two are separate fields and never overloaded.""" +RECEIPT_TARGETS = { + "darwin-arm64": ("macos/arm64",), + "darwin-x64": ("macos/x64",), + "win32-bundle": ("windows/x64", "windows/arm64"), +} + + +def _validated_rows(manifest: dict, tag: str, commit: str, public_base: str, + release_epoch: int | None, *, archive: str) -> dict: + """The per-row checks shared by the full-manifest and receipt validators.""" require_stable_identity(tag, commit) if manifest.get("schema") != 2 or manifest.get("tag") != tag or manifest.get("commit") != commit or not isinstance(manifest.get("packages"), list): raise ValueError("Candidate manifest does not match release identity") @@ -127,11 +132,33 @@ def validate_candidates(manifest: dict, tag: str, commit: str, public_base: str, elif item.get("version") != f"{tag[1:]}-1": raise ValueError("Termux candidate version differs from the admitted release") rows[target] = item + return rows + + +def validate_candidates(manifest: dict, tag: str, commit: str, public_base: str, + release_epoch: int | None = None, *, archive: str) -> dict: + """`tag` is the plain payload identity; `archive` is the releases/tag// + prefix every artifact URL must live under. Stable attempts name the attempt + ref as their archive; the two are separate fields and never overloaded.""" + rows = _validated_rows(manifest, tag, commit, public_base, release_epoch, archive=archive) if any(target not in rows for target in DESKTOP_TARGETS): raise ValueError("Candidate manifest must cover Windows and macOS on both architectures") return rows +def validate_receipt(manifest: dict, receipt: str, tag: str, commit: str, public_base: str, + release_epoch: int | None = None, *, archive: str) -> dict: + """One per-arch or bundle receipt: exactly that group's rows and no others.""" + targets = RECEIPT_TARGETS.get(receipt) + if targets is None: + raise ValueError(f"Unknown receipt: {receipt}") + rows = _validated_rows(manifest, tag, commit, public_base, release_epoch, archive=archive) + if set(rows) != set(targets): + raise ValueError( + f"Receipt {receipt} requires exactly {', '.join(targets)} and nothing else") + return rows + + def windows_version(value: str) -> tuple[int, ...]: if not isinstance(value, str) or not re.fullmatch(r"\d+\.\d+\.\d+\.\d+", value): raise ValueError("Windows package version must have four numeric components") @@ -141,30 +168,44 @@ def windows_version(value: str) -> tuple[int, ...]: return result +def _transition_row(target: str, left: dict, right: dict) -> dict: + if left["identity"] != right["identity"] or left["commit"] == right["commit"] or left["artifact"]["sha256"] == right["artifact"]["sha256"]: + raise ValueError("Update must preserve package identity and change the build") + if right["platform"] == "windows": + if (left["publisher"], left["applicationId"]) != (right["publisher"], right["applicationId"]): + raise ValueError("Update must preserve publisher and applicationId") + newer = windows_version(right["version"]) > windows_version(left["version"]) + else: + if left["teamId"] != right["teamId"]: + raise ValueError("Update must preserve signing team") + newer = tuple(map(int, right["version"].split("."))) > tuple(map(int, left["version"].split("."))) + if not newer: + raise ValueError("New package version must increase") + return {"target": target.replace("/", "-"), "transition": { + "schema": 1, "platform": right["platform"], "arch": right["arch"], "old": left, "new": right, + }} + + def plan_transitions(previous: dict, candidate: dict, public_base: str) -> list[dict]: old = validate_candidates(previous, previous.get("tag"), previous.get("commit"), public_base, archive=previous.get("archive")) new = validate_candidates(candidate, candidate.get("tag"), candidate.get("commit"), public_base, archive=candidate.get("archive")) - result = [] - for target in DESKTOP_TARGETS: - left, right = old[target], new[target] - if left["identity"] != right["identity"] or left["commit"] == right["commit"] or left["artifact"]["sha256"] == right["artifact"]["sha256"]: - raise ValueError("Update must preserve package identity and change the build") - if right["platform"] == "windows": - if (left["publisher"], left["applicationId"]) != (right["publisher"], right["applicationId"]): - raise ValueError("Update must preserve publisher and applicationId") - newer = windows_version(right["version"]) > windows_version(left["version"]) - else: - if left["teamId"] != right["teamId"]: - raise ValueError("Update must preserve signing team") - newer = tuple(map(int, right["version"].split("."))) > tuple(map(int, left["version"].split("."))) - if not newer: - raise ValueError("New package version must increase") - result.append({"target": target.replace("/", "-"), "transition": { - "schema": 1, "platform": right["platform"], "arch": right["arch"], "old": left, "new": right, - }}) - return result + return [_transition_row(target, old[target], new[target]) for target in DESKTOP_TARGETS] + + +def plan_receipt_transitions(previous: dict, receipt_manifest: dict, receipt: str, + public_base: str) -> list[dict]: + """The same transitions, but only for one receipt's rows.""" + targets = RECEIPT_TARGETS.get(receipt) + if targets is None: + raise ValueError(f"Unknown receipt: {receipt}") + old = validate_candidates(previous, previous.get("tag"), previous.get("commit"), public_base, + archive=previous.get("archive")) + new = validate_receipt(receipt_manifest, receipt, receipt_manifest.get("tag"), + receipt_manifest.get("commit"), public_base, + archive=receipt_manifest.get("archive")) + return [_transition_row(target, old[target], new[target]) for target in targets] def read_manifest(url: str, expected_hash: str | None = None, *, expected_origin: str | None = None, diff --git a/tests/scripts/test_stable_release.py b/tests/scripts/test_stable_release.py index e2695ed733..fe3872d254 100644 --- a/tests/scripts/test_stable_release.py +++ b/tests/scripts/test_stable_release.py @@ -14,8 +14,8 @@ from scripts.releases.draft_warning import ( WARNING_CLOSE, WARNING_OPEN, strip_draft_warning, ) from scripts.releases.stable import ( - check_claim, ensure_final_tag, plan_transitions, read_manifest, require_stable_identity, - require_success, validate_candidates, + check_claim, ensure_final_tag, plan_receipt_transitions, plan_transitions, read_manifest, + require_stable_identity, require_success, validate_candidates, validate_receipt, ) BASE = "https://releases.example" @@ -98,6 +98,83 @@ def test_validate_candidates_keys_the_archive_by_the_attempt_ref(): "v1.2.4", commit, BASE, archive="v1.2.4") +RECEIPTS = ( + ("darwin-arm64", ("macos/arm64",)), + ("darwin-x64", ("macos/x64",)), + ("win32-bundle", ("windows/x64", "windows/arm64")), +) + + +def _receipt_rows(manifest, targets): + out = copy.deepcopy(manifest) + out["packages"] = [row for row in manifest["packages"] + if f"{row['platform']}/{row['arch']}" in targets] + return out + + +def test_each_receipt_accepts_its_own_rows(): + commit = "b" * 40 + manifest = candidates("v1.2.4", commit, "2" * 64, archive="rc.2-v1.2.4") + for receipt, targets in RECEIPTS: + rows = validate_receipt(_receipt_rows(manifest, targets), receipt, + manifest["tag"], commit, BASE, manifest["releaseEpoch"], + archive="rc.2-v1.2.4") + assert set(rows) == set(targets) + + +def test_a_mac_receipt_with_both_arches_or_a_termux_row_is_refused(): + commit = "b" * 40 + manifest = candidates("v1.2.4", commit, "2" * 64, archive="rc.2-v1.2.4") + both = _receipt_rows(manifest, {"macos/arm64", "macos/x64"}) + with pytest.raises(ValueError, match="eceipt"): + validate_receipt(both, "darwin-arm64", manifest["tag"], commit, BASE, + manifest["releaseEpoch"], archive="rc.2-v1.2.4") + termux = _receipt_rows(manifest, {"macos/arm64"}) + termux["packages"].append({"platform": "termux", "arch": "aarch64", "tag": manifest["tag"], + "commit": commit, "identity": "test.application", + "version": "1.2.4-1", + "artifact": {"sha256": "2" * 64, + "url": f"{BASE}/releases/tag/rc.2-v1.2.4/hermes.deb"}}) + with pytest.raises(ValueError, match="eceipt"): + validate_receipt(termux, "darwin-arm64", manifest["tag"], commit, BASE, + manifest["releaseEpoch"], archive="rc.2-v1.2.4") + + +def test_a_win32_bundle_receipt_with_one_arch_is_refused(): + commit = "b" * 40 + manifest = candidates("v1.2.4", commit, "2" * 64, archive="rc.2-v1.2.4") + one = _receipt_rows(manifest, {"windows/x64"}) + with pytest.raises(ValueError, match="eceipt"): + validate_receipt(one, "win32-bundle", manifest["tag"], commit, BASE, + manifest["releaseEpoch"], archive="rc.2-v1.2.4") + + +def test_an_unknown_receipt_is_refused(): + commit = "b" * 40 + manifest = candidates("v1.2.4", commit, "2" * 64, archive="rc.2-v1.2.4") + with pytest.raises(ValueError, match="Unknown receipt"): + validate_receipt(manifest, "darwin", manifest["tag"], commit, BASE, + manifest["releaseEpoch"], archive="rc.2-v1.2.4") + + +def test_validate_candidates_still_refuses_a_missing_target(): + commit = "b" * 40 + manifest = candidates("v1.2.4", commit, "2" * 64, archive="rc.2-v1.2.4") + with pytest.raises(ValueError, match="both architectures"): + validate_candidates(_receipt_rows(manifest, {"macos/arm64", "windows/arm64"}), + manifest["tag"], commit, BASE, manifest["releaseEpoch"], + archive="rc.2-v1.2.4") + + +def test_plan_receipt_transitions_yields_only_the_receipts_rows(): + old = candidates("v1.2.3", "a" * 40, "1" * 64) + new = candidates("v1.2.4", "b" * 40, "2" * 64, archive="rc.2-v1.2.4") + for receipt, targets in RECEIPTS: + rows = plan_receipt_transitions(old, _receipt_rows(new, targets), receipt, BASE) + assert {row["target"] for row in rows} == {target.replace("/", "-") for target in targets} + assert all(row["transition"]["new"]["commit"] == new["commit"] for row in rows) + + def test_transitions_bind_all_arches_identity_version_and_archive(): old = candidates("v1.2.3", "a" * 40, "1" * 64) old["schema"] = 1