The Partner Center submission API has no call that releases a held (Manual) submission, and update refuses a committed one, so the release step's PUT and re-commit could not work. The publication pass now only reads the submission: a certified one prints a GitHub warning to click Publish now in Partner Center, one still in certification says what comes next, a live one is a no-op, and a failed one leaves the run red. It finds the held submission through pendingApplicationSubmission on the app, instead of posting a probe submission and scraping an id out of the 409 error. The HTTP runner also sends the request headers it is given: before, every API call after the token went out without Authorization.
428 lines
18 KiB
Python
428 lines
18 KiB
Python
"""Discover and reconcile stable releases in version order.
|
|
|
|
The planner is pure. Production discovery reads only remote annotated tags,
|
|
GitHub releases/runs, and the protected R2 head; execution flips exact release
|
|
IDs before advancing each eligible head oldest-first.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
from scripts.releases.versioning import (
|
|
marker_ref, outstanding_attempts, parse_attempt_ref, parse_marker_ref,
|
|
version_from_tag,
|
|
)
|
|
|
|
SHA256 = re.compile(r"[a-f0-9]{64}")
|
|
DOCKER_DIGEST = re.compile(r"sha256:[a-f0-9]{64}")
|
|
MAX_ATTEMPTS = 3
|
|
RETRY_BACKOFF = timedelta(minutes=15)
|
|
CLAIM_GRACE = timedelta(hours=1)
|
|
|
|
|
|
def _key(version: str) -> tuple[int, int, int]:
|
|
major, minor, patch = version.split(".")
|
|
return int(major), int(minor), int(patch)
|
|
|
|
|
|
def plan(claims: list[dict], *, head: str | None,
|
|
requested_version: str | None = None) -> list[dict]:
|
|
"""Return each eligible draft flip immediately followed by its head advance."""
|
|
ordered = sorted(claims, key=lambda claim: _key(claim["version"]))
|
|
head_key = _key(head) if head is not None else None
|
|
steps: list[dict] = []
|
|
flush_green_chain = False
|
|
|
|
for index, claim in enumerate(ordered):
|
|
version = claim["version"]
|
|
version_key = _key(version)
|
|
state = claim["state"]
|
|
|
|
if head_key is not None and version_key <= head_key:
|
|
if state in {"published", "burned"}:
|
|
continue
|
|
raise ValueError(f"refusing to move the head backwards from {head}")
|
|
|
|
if state == "published":
|
|
steps.append({"advance": version})
|
|
continue
|
|
if state == "burned":
|
|
continue
|
|
if state == "running":
|
|
break
|
|
if state != "green":
|
|
raise ValueError(f"unknown claim state {state!r}")
|
|
|
|
has_later_green_claim = False
|
|
for later in ordered[index + 1:]:
|
|
if later["state"] == "running":
|
|
break
|
|
if later["state"] == "green":
|
|
has_later_green_claim = True
|
|
break
|
|
eligible = (
|
|
claim.get("autopublish", False)
|
|
or version == requested_version
|
|
or has_later_green_claim
|
|
or flush_green_chain
|
|
)
|
|
if not eligible:
|
|
break
|
|
steps.extend(({"flip": version}, {"advance": version}))
|
|
flush_green_chain = flush_green_chain or has_later_green_claim
|
|
|
|
return steps
|
|
|
|
|
|
def output(argv: list[str]) -> str:
|
|
return subprocess.check_output(argv, text=True, encoding="utf-8").strip()
|
|
|
|
|
|
def _pages(argv: list[str], run=output) -> list:
|
|
pages = json.loads(run(["gh", "api", "--paginate", "--slurp", *argv]))
|
|
if not isinstance(pages, list):
|
|
raise ValueError("GitHub pagination returned an invalid response")
|
|
return pages
|
|
|
|
|
|
def _release_rows(repository: str, run=output) -> list[dict]:
|
|
rows = []
|
|
for page in _pages([f"repos/{repository}/releases?per_page=100"], run):
|
|
if not isinstance(page, list):
|
|
raise ValueError("GitHub releases response is invalid")
|
|
rows.extend(page)
|
|
return rows
|
|
|
|
|
|
def _workflow_runs(repository: str, run=output) -> list[dict]:
|
|
rows = []
|
|
endpoint = f"repos/{repository}/actions/workflows/stable-release.yml/runs?event=workflow_dispatch&per_page=100"
|
|
for page in _pages([endpoint], run):
|
|
if not isinstance(page, dict) or not isinstance(page.get("workflow_runs"), list):
|
|
raise ValueError("GitHub workflow runs response is invalid")
|
|
rows.extend(page["workflow_runs"])
|
|
return rows
|
|
|
|
|
|
def _utc(value: str) -> datetime:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
if parsed.tzinfo is None:
|
|
raise ValueError("Workflow timestamp must include a timezone")
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
def classify_runs(runs: list[dict], *, claimed_at: datetime | None = None,
|
|
now: datetime | None = None, has_draft: bool = False) -> tuple[str, dict | None]:
|
|
"""Keep failed claims live until two failed-job retries are exhausted.
|
|
|
|
Success with a draft on the attempt ref is green: the final tag moves to
|
|
publish. A missing draft after success is an error, because the tool is
|
|
the only thing that deletes drafts; abandonment is the marker ref.
|
|
"""
|
|
if not runs:
|
|
if claimed_at is None:
|
|
raise ValueError("A claim without a workflow needs its immutable claim time")
|
|
now = now or datetime.now(timezone.utc)
|
|
return ("running", None) if now < claimed_at + CLAIM_GRACE else ("burned", None)
|
|
run_ids = {row.get("id") for row in runs}
|
|
if len(run_ids) != 1 or None in run_ids:
|
|
raise ValueError("Stable claim owns multiple workflow runs")
|
|
latest = max(runs, key=lambda row: row.get("run_attempt", 0))
|
|
attempt = latest.get("run_attempt")
|
|
if not isinstance(attempt, int) or attempt < 1:
|
|
raise ValueError("Stable workflow run attempt is invalid")
|
|
if latest.get("status") != "completed":
|
|
return "running", None
|
|
if latest.get("conclusion") == "success":
|
|
if not has_draft:
|
|
raise ValueError("Stable workflow succeeded without a draft release")
|
|
return "green", None
|
|
if attempt >= MAX_ATTEMPTS:
|
|
return "burned", None
|
|
updated_at = latest.get("updated_at")
|
|
if not isinstance(updated_at, str):
|
|
raise ValueError("Failed stable workflow has no completion time")
|
|
return "running", {
|
|
"run_id": latest["id"],
|
|
"attempt": attempt,
|
|
"due_at": _utc(updated_at) + RETRY_BACKOFF,
|
|
}
|
|
|
|
|
|
def retry_due(records: list[dict], *, now: datetime | None = None) -> list[dict]:
|
|
"""Return the oldest unresolved retry once its backoff has elapsed."""
|
|
now = now or datetime.now(timezone.utc)
|
|
for record in records:
|
|
if record.get("state") != "running":
|
|
continue
|
|
retry = record.get("retry")
|
|
if retry is None or retry["due_at"] > now:
|
|
return []
|
|
return [{
|
|
"version": record["version"], "run_id": retry["run_id"],
|
|
"attempt": retry["attempt"] + 1,
|
|
}]
|
|
return []
|
|
|
|
|
|
def classify_final_release(tag: str, claim_tag: str, release: dict | None) -> tuple[str, bool]:
|
|
"""Derive green/published/abandoned state from the exact GitHub release."""
|
|
if release is None:
|
|
return "burned", False
|
|
if release.get("prerelease") is not False:
|
|
raise ValueError(f"{tag} has no valid GitHub release")
|
|
needs_retarget = release.get("tag_name") == claim_tag
|
|
if release.get("tag_name") not in {claim_tag, tag}:
|
|
raise ValueError(f"{tag} release identity changed")
|
|
if release.get("draft") is True:
|
|
return "green", needs_retarget
|
|
if release.get("draft") is False and release.get("published_at"):
|
|
if needs_retarget:
|
|
raise ValueError(f"{claim_tag} was published before final retargeting")
|
|
return "published", False
|
|
raise ValueError(f"{tag} release state is invalid")
|
|
|
|
|
|
def _remote_tags(run=output) -> dict[str, dict[str, str]]:
|
|
"""Every remote receipt tag, attempt ref, and abandon marker ref.
|
|
|
|
Three listings, because a fetch or ls-remote glob is filtered here by the
|
|
ref parsers, not widened.
|
|
"""
|
|
refs: dict[str, dict[str, str]] = {}
|
|
for line in run([
|
|
"git", "ls-remote", "--tags", "origin",
|
|
"refs/tags/v*", "refs/tags/rc.*", "refs/tags/abandoned-rc.*",
|
|
]).splitlines():
|
|
sha, ref = line.split()
|
|
peeled = ref.endswith("^{}")
|
|
tag = ref.removeprefix("refs/tags/").removesuffix("^{}")
|
|
if (version_from_tag(tag) or parse_attempt_ref(tag)
|
|
or parse_marker_ref(tag)):
|
|
refs.setdefault(tag, {})["commit" if peeled else "object"] = sha
|
|
return refs
|
|
|
|
|
|
def _tag_message(tag: str, expected_object: str, run=output) -> dict:
|
|
local_object = run(["git", "rev-parse", f"refs/tags/{tag}"])
|
|
if local_object != expected_object or run(["git", "cat-file", "-t", local_object]) != "tag":
|
|
raise ValueError(f"{tag} differs from its remote annotated object")
|
|
try:
|
|
message = json.loads(run(["git", "tag", "-l", tag, "--format=%(contents)"]))
|
|
except json.JSONDecodeError as error:
|
|
raise ValueError(f"{tag} metadata is invalid") from error
|
|
if not isinstance(message, dict):
|
|
raise ValueError(f"{tag} metadata is invalid")
|
|
return message
|
|
|
|
|
|
def discover(repository: str, run=output) -> list[dict]:
|
|
"""Derive every stable claim state from remote refs and GitHub objects."""
|
|
from scripts.releases.stable import tagger_epoch
|
|
|
|
run([
|
|
"git", "fetch", "origin", "+refs/tags/v*:refs/tags/v*",
|
|
"+refs/tags/rc.*:refs/tags/rc.*",
|
|
"+refs/tags/abandoned-rc.*:refs/tags/abandoned-rc.*",
|
|
])
|
|
refs = _remote_tags(run)
|
|
outstanding = outstanding_attempts(list(refs), lambda v: f"v{v}" in refs)
|
|
if len(outstanding) > 1:
|
|
named = ", ".join(ref for *_rest, ref in outstanding)
|
|
raise ValueError(f"more than one outstanding attempt ({named})")
|
|
releases = _release_rows(repository, run)
|
|
workflow_runs = _workflow_runs(repository, run)
|
|
records = []
|
|
|
|
for claim_tag, claim_ref in refs.items():
|
|
parsed = parse_attempt_ref(claim_tag)
|
|
if parsed is None:
|
|
continue
|
|
version, attempt = parsed
|
|
if set(claim_ref) != {"object", "commit"}:
|
|
raise ValueError(f"{claim_tag} must be an annotated remote tag")
|
|
tag = f"v{version}"
|
|
commit = claim_ref["commit"]
|
|
claim = _tag_message(claim_tag, claim_ref["object"], run)
|
|
claim_epoch = claim.get("claimEpoch")
|
|
expected_claim = {
|
|
"schema": 1, "version": version, "attempt": attempt, "commit": commit,
|
|
"autopublish": claim["autopublish"], "claimEpoch": claim_epoch,
|
|
}
|
|
if (claim != expected_claim or not isinstance(claim["autopublish"], bool)
|
|
or not isinstance(claim_epoch, int) or claim_epoch <= 0):
|
|
raise ValueError(f"{claim_tag} metadata is invalid")
|
|
if tagger_epoch(claim_ref["object"], run) != claim_epoch:
|
|
raise ValueError(f"{claim_tag} epoch differs from its annotated tagger timestamp")
|
|
|
|
family_releases = [row for row in releases if row.get("tag_name") in {claim_tag, tag}]
|
|
if len(family_releases) > 1:
|
|
raise ValueError(f"{claim_tag} owns multiple GitHub releases")
|
|
release = family_releases[0] if family_releases else None
|
|
final_ref = refs.get(tag)
|
|
needs_retarget = False
|
|
final = None
|
|
retry = None
|
|
|
|
if final_ref is not None:
|
|
if set(final_ref) != {"object", "commit"}:
|
|
raise ValueError(f"{tag} must be an annotated remote tag")
|
|
if final_ref["commit"] != commit:
|
|
raise ValueError(f"{tag} points at a different commit than {claim_tag}")
|
|
final = _tag_message(tag, final_ref["object"], run)
|
|
expected_final = {
|
|
"schema": 1, "version": version, "commit": commit,
|
|
"claimTag": claim_tag, "claimTagObject": claim_ref["object"],
|
|
"autopublish": claim["autopublish"],
|
|
"claimEpoch": claim_epoch,
|
|
"releaseId": final.get("releaseId"),
|
|
"candidateManifestSha256": final.get("candidateManifestSha256"),
|
|
"dockerManifestDigest": final.get("dockerManifestDigest"),
|
|
"archive": f"releases/tag/{claim_tag}/",
|
|
}
|
|
if (final != expected_final
|
|
or not isinstance(final["releaseId"], int) or final["releaseId"] <= 0
|
|
or not SHA256.fullmatch(final["candidateManifestSha256"] or "")
|
|
or not DOCKER_DIGEST.fullmatch(final["dockerManifestDigest"] or "")):
|
|
raise ValueError(f"{tag} metadata differs from {claim_tag}")
|
|
if release is None or release.get("id") != final["releaseId"]:
|
|
state, needs_retarget = "burned", False
|
|
else:
|
|
state, needs_retarget = classify_final_release(tag, claim_tag, release)
|
|
elif marker_ref(version, attempt) in refs:
|
|
# The attempt was abandoned and its version has no final tag.
|
|
state, retry = "burned", None
|
|
else:
|
|
if release is not None and (release.get("tag_name") != claim_tag
|
|
or release.get("draft") is not True
|
|
or release.get("prerelease") is not False):
|
|
raise ValueError(f"{claim_tag} draft state is invalid")
|
|
matching_runs = [row for row in workflow_runs
|
|
if row.get("head_branch") == claim_tag and row.get("head_sha") == commit]
|
|
state, retry = classify_runs(
|
|
matching_runs,
|
|
claimed_at=datetime.fromtimestamp(claim_epoch, tz=timezone.utc),
|
|
has_draft=release is not None,
|
|
)
|
|
|
|
records.append({
|
|
"version": version,
|
|
"attempt": attempt,
|
|
"state": state,
|
|
"autopublish": claim["autopublish"],
|
|
"claim_tag": claim_tag,
|
|
"claim_object": claim_ref["object"],
|
|
"claim_epoch": claim_epoch,
|
|
"tag": tag,
|
|
"commit": commit,
|
|
"release_id": release.get("id") if release else None,
|
|
"needs_retarget": needs_retarget,
|
|
"candidate_manifest_sha256": final["candidateManifestSha256"] if final else None,
|
|
"docker_manifest_digest": final["dockerManifestDigest"] if final else None,
|
|
"retry": retry if final_ref is None else None,
|
|
})
|
|
|
|
return sorted(records, key=lambda record: _key(record["version"]))
|
|
|
|
|
|
def reconcile(env: dict, *, run=output, read_head=None, advance_head=None,
|
|
read_archive=None) -> list[dict]:
|
|
"""Converge GitHub publication and protected heads oldest-first."""
|
|
from scripts.releases import channel_releases, docker, stable, store
|
|
|
|
repository = env["GITHUB_REPOSITORY"]
|
|
# The archive copy is the only authority for the candidate manifest digest;
|
|
# nothing records it before the publication pass hashes it.
|
|
read_archive = read_archive or channel_releases.read_archive_bytes
|
|
records = discover(repository, run)
|
|
retries = retry_due(records)
|
|
if retries:
|
|
for retry in retries:
|
|
endpoint = f"repos/{repository}/actions/runs/{retry['run_id']}"
|
|
run([
|
|
"gh", "api", "--method", "POST",
|
|
f"{endpoint}/rerun-failed-jobs",
|
|
])
|
|
for attempt in range(6):
|
|
current = json.loads(run(["gh", "api", endpoint]))
|
|
if (current.get("id") == retry["run_id"]
|
|
and current.get("run_attempt") == retry["attempt"]
|
|
and current.get("status") != "completed"):
|
|
break
|
|
if attempt < 5:
|
|
time.sleep(5)
|
|
else:
|
|
raise ValueError(f"Stable retry {retry['run_id']} did not enter attempt {retry['attempt']}")
|
|
return [{"retry": retry["version"], "attempt": retry["attempt"]} for retry in retries]
|
|
for record in records:
|
|
if record["needs_retarget"]:
|
|
# Recovery for a publish that died after the receipt tag: the tag
|
|
# exists and the release is still a draft, so the retarget and the
|
|
# publication rerun. A public release can never be repaired.
|
|
stable.edit_draft_release(repository, record["release_id"], record["tag"],
|
|
record["commit"], run=run)
|
|
stable.publish_release_draft(repository, record["release_id"], record["tag"],
|
|
run=run)
|
|
if any(record["needs_retarget"] for record in records):
|
|
records = discover(repository, run)
|
|
|
|
read_head = read_head or (lambda: channel_releases.stable_head_version(env))
|
|
head = read_head()
|
|
requested = env.get("REQUESTED_VERSION") or None
|
|
steps = plan(records, head=head, requested_version=requested)
|
|
by_version = {record["version"]: record for record in records}
|
|
|
|
if advance_head is None:
|
|
def production_advance(record: dict) -> None:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
channel_releases.advance_stable(env, record, Path(directory))
|
|
# The stable/latest aliases move onto the attempt's image here, in
|
|
# the publication pass with the feed pointer, never in the green
|
|
# build that pushed the image under the attempt ref.
|
|
docker.promote_stable(record["claim_tag"], record["docker_manifest_digest"])
|
|
# The Store check joins the pass here (after the feeds and aliases
|
|
# move). It never releases the held submission: the API cannot,
|
|
# so it prints the Publish now step. A failed submission is red.
|
|
store.check_from_env(env)
|
|
advance_head = production_advance
|
|
|
|
for step in steps:
|
|
if "flip" in step:
|
|
record = by_version[step["flip"]]
|
|
record["docker_manifest_digest"] = stable.publish_attempt(
|
|
record, repository=repository, run=run, read_archive=read_archive)
|
|
else:
|
|
advance_head(by_version[step["advance"]])
|
|
|
|
final = {record["version"]: record for record in discover(repository, run)}
|
|
for step in steps:
|
|
if "flip" in step and final[step["flip"]]["state"] != "published":
|
|
raise ValueError(f"Stable release {step['flip']} did not publish")
|
|
if requested and (requested not in final or final[requested]["state"] != "published"):
|
|
raise ValueError(f"Requested stable release {requested} did not publish")
|
|
if any("advance" in step for step in steps):
|
|
expected = next(step["advance"] for step in reversed(steps) if "advance" in step)
|
|
if read_head() != expected:
|
|
raise ValueError("Stable protected head did not reach the planned version")
|
|
return steps
|
|
|
|
|
|
def main(argv: list[str] | None = None, env: dict | None = None) -> None:
|
|
if argv is None:
|
|
argv = sys.argv[1:]
|
|
if argv:
|
|
raise ValueError("The stable sequencer takes no positional arguments")
|
|
print(json.dumps(reconcile(dict(os.environ if env is None else env)), sort_keys=True))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|