feat(release): validate one receipt without the others

Each install arm starts from its own receipt, so stable must accept a
manifest that covers only one arch or the Windows bundle. The per-row
checks move into one helper shared by validate_candidates and the new
validate_receipt, and the per-target transition logic into one helper
shared by plan_transitions and plan_receipt_transitions. The full
manifest still has to cover every desktop target.
This commit is contained in:
ethernet
2026-09-23 13:13:36 -04:00
parent 3f2e40f07d
commit c0100f5f1c
2 changed files with 144 additions and 26 deletions

View File

@@ -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/<ref>/
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/<ref>/
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,