Files
ethernet 4148f6d1f3 feat(release): Store certifies before the gate; publication switches it live
stable-store commits the Store submission held (targetPublishMode Manual)
instead of leaving an uncommitted draft, so certification runs before the
release gate and a run whose stable-publish fails still shows customers
nothing. The publication pass then PUTs the held submission with
Immediate and commits it again, so it goes live without a Partner Center
click.

Microsoft documents no call that releases a held submission and does not
say whether update accepts a committed one (409 is documented only as
"the current state of the app"). A 409 from the switch leaves the
submission held and prints the Publish now instruction; the release
stays green. The first real release shows which answer the API gives.

Reconcile receipt-bound Store work independently of channel advancement, including
already-current published releases, so an interruption cannot strand a held
submission. Record each upload's claim in certification notes and replace a held
Manual predecessor only after its exact remote abandon marker matches the claim
commit. Unknown owners and publishing submissions remain protected.
2026-10-20 15:50:30 -04:00

538 lines
26 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 hashlib
import json
import os
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone, UTC
from pathlib import Path
from scripts.releases import lanes as lane_set
from scripts.releases.progress import error as log_error, group, log, logged_run, notice
from scripts.releases.versioning import (
marker_ref, outstanding_attempts, parse_attempt_ref, parse_marker_ref,
tag_record, version_from_tag,
)
MAX_ATTEMPTS = 3
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"}:
log(f" plan {version}: {state}, at or below head {head} — nothing to do")
continue
raise ValueError(f"refusing to move the head backwards from {head}")
if state == "published":
log(f" plan {version}: published, above head {head} — advance")
steps.append({"advance": version})
continue
if state == "burned":
log(f" plan {version}: burned — skipped")
continue
if state == "running":
log(f" plan {version}: still running — stop here; nothing newer can move past it")
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:
log(f" plan {version}: green but held — not autopublish, not requested "
f"(requested={requested_version!r}), and no later green claim needs it; stop")
break
why = [name for name, hit in (
("autopublish", claim.get("autopublish", False)),
("requested", version == requested_version),
("a later green claim needs it", has_later_green_claim),
("flushing a green chain", flush_green_chain),
) if hit]
log(f" plan {version}: green — publish then advance ({', '.join(why)})")
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 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(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
return "running", {"run_id": latest["id"], "attempt": attempt}
def retry_due(records: list[dict]) -> list[dict]:
"""Return the oldest unresolved claim's retry, if it is waiting on one.
No backoff: the failure event's own reconcile pass reruns the failed jobs,
because nothing else wakes the reconciler to apply a delayed retry.
"""
for record in records:
if record.get("state") != "running":
continue
retry = record.get("retry")
if retry is None:
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 = tag_record(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 is_claim_run, tagger_epoch, validate_claim, validate_final
log("Fetching stable receipt tags, attempt refs and abandon markers from origin")
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)
attempts = sorted(tag for tag in refs if parse_attempt_ref(tag))
log(f"Found {len(refs)} remote stable refs: {len(attempts)} attempt refs, "
f"{sum(1 for tag in refs if version_from_tag(tag))} receipt tags, "
f"{sum(1 for tag in refs if parse_marker_ref(tag))} abandon markers")
outstanding = outstanding_attempts(list(refs), lambda v: f"v{v}" in refs)
if outstanding:
log(f"Outstanding attempts (no receipt tag, not abandoned): "
f"{', '.join(ref for *_rest, ref in outstanding)}")
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)
log(f"Read {len(releases)} GitHub releases")
workflow_runs = _workflow_runs(repository, run)
log(f"Read {len(workflow_runs)} dispatched Stable Release runs")
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)
try:
# Normalized: a claim cut before lanes existed reads as the lane set it gated.
claim = validate_claim(claim, version=version, attempt=attempt, commit=commit)
except ValueError as error:
raise ValueError(f"{claim_tag} metadata is invalid") from error
claim_epoch = claim["claimEpoch"]
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 marker_ref(version, attempt) in refs:
# An abandoned attempt is spent whatever its version became: a later attempt
# may have won and been tagged at another commit (v0.21.6: rc.4 after rc.1-3).
state, retry = "burned", None
why = f"abandon marker {marker_ref(version, attempt)} exists"
elif 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)
try:
validate_final(final, version=version, commit=commit, claim_tag=claim_tag,
claim_object=claim_ref["object"], claim=claim)
except ValueError as error:
raise ValueError(f"{tag} metadata differs from {claim_tag}") from error
if release is None or release.get("id") != final["releaseId"]:
state, needs_retarget = "burned", False
why = (f"{tag} exists but its release {final['releaseId']} is "
+ ("missing" if release is None else f"not the one found ({release.get('id')})"))
else:
state, needs_retarget = classify_final_release(tag, claim_tag, release)
why = (f"{tag} receipt exists; release {release.get('id')} on "
f"{release.get('tag_name')}, draft={release.get('draft')}"
+ ("; needs retarget onto the final tag" if needs_retarget else ""))
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 is_claim_run(
title=row.get("display_title"), head_branch=row.get("head_branch"),
head_sha=row.get("head_sha"), claim_tag=claim_tag, commit=commit)]
state, retry = classify_runs(
matching_runs,
claimed_at=datetime.fromtimestamp(claim_epoch, tz=UTC),
has_draft=release is not None,
)
latest = max(matching_runs, key=lambda row: row.get("run_attempt", 0), default=None)
why = ("no Stable Release run yet" if latest is None else
f"run {latest.get('id')} attempt {latest.get('run_attempt')} "
f"{latest.get('status')}/{latest.get('conclusion') or '-'}")
why += f"; draft {'on ' + claim_tag if release is not None else 'missing'}"
if retry:
why += f"; failed-job retry due (attempt {retry['attempt'] + 1} of {MAX_ATTEMPTS})"
log(f" {claim_tag} @ {commit[:10]}: {state.upper()} — {why}"
+ (" [autopublish]" if claim["autopublish"] else "")
+ f" [{lane_set.describe(claim['lanes'])}]")
records.append({
"version": version,
"attempt": attempt,
"state": state,
"autopublish": claim["autopublish"],
"lanes": claim["lanes"],
"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,
})
# Attempt order inside a version: git lists refs by name (rc.10 before rc.2), and
# reconcile indexes by version with the last record winning, i.e. the newest attempt.
return sorted(records, key=lambda record: (_key(record["version"]), record["attempt"]))
def channel_head(desktop_head: str | None, records: list[dict],
stable_alias_digest: str | None) -> str | None:
"""The newest version whose publication pass finished.
A release that advances the desktop channel finishes when the protected R2
head names it. One that does not finishes when the Docker ``stable`` alias
carries the digest its final receipt binds, or, when it moves no channel at
all, as soon as it is public.
"""
versions = [desktop_head] if desktop_head is not None else []
versions += [record["version"] for record in records
if record["state"] == "published"
and (not lane_set.moves_channels(record["lanes"])
or (stable_alias_digest is not None
and record["docker_manifest_digest"] == stable_alias_digest))]
return max(versions, key=_key, default=None)
def _reconcile_store(env: dict, records: list[dict], read_archive) -> None:
from scripts.releases import store
# Channels are already durable; Store recovery must not depend on advancing them again.
for record in sorted(records, key=lambda row: _key(row["version"]), reverse=True):
if record["state"] != "published" or "windows-store" not in record["lanes"]:
continue
log(f"Reconciling the Microsoft Store submission for {record['tag']}")
try:
raw = read_archive(f"releases/tag/{record['claim_tag']}/release-candidates.json")
if hashlib.sha256(raw).hexdigest() != record["candidate_manifest_sha256"]:
raise store.StoreError("the archived candidate manifest differs from the receipt")
result = store.release_from_env(env, json.loads(raw))
except (store.StoreError, ValueError, OSError) as error:
print(f"::warning title=Microsoft Store::{error}. The release is published; "
"check its Store submission in Partner Center.")
return
# A newer public release may have failed its advisory upload, leaving an older one held.
if result != "superseded":
return
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
repository = env["GITHUB_REPOSITORY"]
run = logged_run(run)
requested = env.get("REQUESTED_VERSION") or None
log(f"Stable sequencer: repository={repository} requested={requested or '(none)'}")
# 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
with group("Discover stable claims"):
records = discover(repository, run)
retries = retry_due(records)
if retries:
for retry in retries:
log(f"Rerunning failed jobs of {retry['version']}'s run {retry['run_id']} "
f"(attempt {retry['attempt']} of {MAX_ATTEMPTS}); publication waits for it")
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:
log(f" run {retry['run_id']} is at attempt {current.get('run_attempt')} "
f"({current.get('status')}); waiting 5s for attempt {retry['attempt']}")
time.sleep(5)
else:
raise ValueError(f"Stable retry {retry['run_id']} did not enter attempt {retry['attempt']}")
notice(", ".join(f"{r['version']} retry attempt {r['attempt']} queued" for r in retries),
title="Stable sequencer")
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.
with group(f"Repair {record['version']}: retarget release {record['release_id']} "
f"onto {record['tag']} and publish it"):
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):
with group("Rediscover after repair"):
records = discover(repository, run)
read_head = read_head or (lambda: channel_head(
channel_releases.stable_head_version(env), discover(repository, run),
docker.stable_alias_digest()))
with group("Read the stable channel head"):
head = read_head()
log(f"Channel head: {head or '(none)'}")
with group("Plan"):
steps = plan(records, head=head, requested_version=requested)
log("Steps: " + (", ".join(f"{kind} {version}" for step in steps
for kind, version in step.items()) or "(none)"))
by_version = {record["version"]: record for record in records}
production = advance_head is None
if production:
def production_advance(record: dict) -> None:
if lane_set.advances_desktop(record["lanes"]):
log(f"Advancing the protected desktop channel to {record['version']} from "
f"releases/tag/{record['claim_tag']}/ "
f"(receipt manifest {record['candidate_manifest_sha256']})")
with tempfile.TemporaryDirectory() as directory:
channel_releases.advance_stable(env, record, Path(directory))
log(f"Desktop channel head is now {record['version']}")
else:
log(f"{record['version']} did not build every desktop package the channel serves "
"(windows, macos-arm64): the desktop channel stays on the previous release")
if "docker" in record["lanes"]:
log(f"Tagging Docker {record['tag']}/stable/latest onto {record['claim_tag']} "
f"({record['docker_manifest_digest']})")
# The final vX.Y.Z and 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"], record["tag"])
log("Docker aliases moved")
advance_head = production_advance
for number, step in enumerate(steps, start=1):
if "flip" in step:
record = by_version[step["flip"]]
with group(f"[{number}/{len(steps)}] Publish {record['version']}: write {record['tag']}, "
f"retarget release {record['release_id']} from {record['claim_tag']}, make it public"):
# discover() ran before the receipt tag existed, so the record has no
# receipt digests yet; the advance below must check the ones just written.
record.update(stable.publish_attempt(
record, repository=repository, run=run, read_archive=read_archive))
log(f"Receipt {record['tag']}: manifest {record['candidate_manifest_sha256']}, "
f"docker {record['docker_manifest_digest']}")
notice(f"{record['tag']} is public", title="Stable sequencer")
else:
record = by_version[step["advance"]]
with group(f"[{number}/{len(steps)}] Advance channels to {record['version']}"):
advance_head(record)
notice(f"Stable channels advanced to {record['version']}", title="Stable sequencer")
with group("Verify"):
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)
reached = read_head()
log(f"Channel head after the pass: {reached} (planned {expected})")
if reached != expected:
raise ValueError("Stable protected head did not reach the planned version")
if production:
_reconcile_store(env, list(final.values()), read_archive)
log("Stable sequencer finished: " + (", ".join(
f"{kind} {version}" for step in steps for kind, version in step.items()) or "nothing to do"))
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")
try:
steps = reconcile(dict(os.environ if env is None else env))
except Exception as exc:
log_error(f"{type(exc).__name__}: {exc}", title="Stable sequencer failed")
raise
print(json.dumps(steps, sort_keys=True))
if __name__ == "__main__":
main()