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.
469 lines
17 KiB
Python
469 lines
17 KiB
Python
"""Stable publication is ordered, held by default, and idempotent.
|
|
|
|
A green draft publishes when it opted into autopublish, or when a later green
|
|
release needs it resolved. A running older claim blocks only the versions above
|
|
it; already-resolvable older green claims still make progress.
|
|
"""
|
|
import hashlib
|
|
import json
|
|
|
|
import pytest
|
|
|
|
|
|
def _claims(*rows):
|
|
return [dict(version=version, state=state, autopublish=autopublish)
|
|
for version, state, autopublish in rows]
|
|
|
|
|
|
def _flips(steps):
|
|
return [step["flip"] for step in steps if "flip" in step]
|
|
|
|
|
|
def test_a_sole_green_draft_waits_without_autopublish():
|
|
from scripts.releases.sequencer import plan
|
|
|
|
assert plan(_claims(("0.21.5", "green", False)), head="0.21.4") == []
|
|
|
|
|
|
def test_autopublish_flips_the_current_green_release():
|
|
from scripts.releases.sequencer import plan
|
|
|
|
steps = plan(_claims(("0.21.5", "green", True)), head="0.21.4")
|
|
assert _flips(steps) == ["0.21.5"]
|
|
assert steps[-1] == {"advance": "0.21.5"}
|
|
|
|
|
|
def test_a_newer_green_release_flushes_the_older_waiting_draft():
|
|
from scripts.releases.sequencer import plan
|
|
|
|
steps = plan(_claims(
|
|
("0.21.5", "green", False),
|
|
("0.21.6", "green", False),
|
|
), head="0.21.4")
|
|
|
|
assert _flips(steps) == ["0.21.5", "0.21.6"]
|
|
assert steps == [
|
|
{"flip": "0.21.5"}, {"advance": "0.21.5"},
|
|
{"flip": "0.21.6"}, {"advance": "0.21.6"},
|
|
]
|
|
|
|
|
|
def test_a_running_newer_claim_does_not_flush_a_held_draft():
|
|
from scripts.releases.sequencer import plan
|
|
|
|
steps = plan(_claims(
|
|
("0.21.5", "green", False),
|
|
("0.21.6", "running", False),
|
|
("0.21.7", "green", True),
|
|
), head="0.21.4")
|
|
|
|
assert steps == []
|
|
assert plan(_claims(
|
|
("0.21.5", "running", False),
|
|
("0.21.6", "green", True),
|
|
), head="0.21.4") == []
|
|
assert plan(_claims(
|
|
("0.21.5", "green", False),
|
|
("0.21.6", "published", False),
|
|
), head="0.21.4") == []
|
|
|
|
|
|
def test_unstarted_claim_waits_for_its_grace_period_before_burning():
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from scripts.releases.sequencer import classify_runs
|
|
|
|
claimed = datetime(2026, 9, 22, 1, 0, tzinfo=timezone.utc)
|
|
assert classify_runs([], claimed_at=claimed, now=claimed + timedelta(minutes=59)) == (
|
|
"running", None,
|
|
)
|
|
assert classify_runs([], claimed_at=claimed, now=claimed + timedelta(hours=1)) == (
|
|
"burned", None,
|
|
)
|
|
|
|
|
|
def test_failed_run_retries_twice_after_backoff_before_burning():
|
|
from datetime import datetime, timezone
|
|
|
|
from scripts.releases.sequencer import classify_runs, retry_due
|
|
|
|
now = datetime(2026, 9, 22, 1, 30, tzinfo=timezone.utc)
|
|
failed = {
|
|
"id": 42, "status": "completed", "conclusion": "failure",
|
|
"run_attempt": 1, "updated_at": "2026-09-22T01:14:59Z",
|
|
}
|
|
state, retry = classify_runs([failed])
|
|
assert state == "running"
|
|
assert retry is not None
|
|
assert retry_due([{"version": "0.21.5", "state": state, "retry": retry}], now=now) == [{
|
|
"version": "0.21.5", "run_id": 42, "attempt": 2,
|
|
}]
|
|
newer = dict(retry)
|
|
newer["run_id"] = 43
|
|
assert retry_due([
|
|
{"version": "0.21.5", "state": state, "retry": retry},
|
|
{"version": "0.21.6", "state": state, "retry": newer},
|
|
], now=now) == [{"version": "0.21.5", "run_id": 42, "attempt": 2}]
|
|
failed["run_attempt"] = 2
|
|
state, retry = classify_runs([failed])
|
|
assert retry_due([{"version": "0.21.5", "state": state, "retry": retry}], now=now)[0]["attempt"] == 3
|
|
failed["run_attempt"] = 3
|
|
assert classify_runs([failed]) == ("burned", None)
|
|
|
|
|
|
def test_a_succeeded_run_is_green_only_with_its_draft():
|
|
from scripts.releases.sequencer import classify_final_release, classify_runs
|
|
|
|
succeeded = {"id": 9, "status": "completed", "conclusion": "success", "run_attempt": 1}
|
|
assert classify_runs([succeeded], has_draft=True) == ("green", None)
|
|
# The tool is the only thing that deletes drafts; a missing draft after
|
|
# success is an error, not an inferred abandonment.
|
|
with pytest.raises(ValueError, match="without a draft"):
|
|
classify_runs([succeeded], has_draft=False)
|
|
draft = {"id": 9, "tag_name": "rc.1-v0.21.5", "draft": True, "prerelease": False}
|
|
assert classify_final_release("v0.21.5", "rc.1-v0.21.5", draft) == ("green", True)
|
|
|
|
|
|
def test_a_burned_claim_is_spent_and_skipped():
|
|
from scripts.releases.sequencer import classify_final_release, plan
|
|
|
|
assert classify_final_release("v0.21.5", "v0.21.5-rc", None) == ("burned", False)
|
|
|
|
steps = plan(_claims(
|
|
("0.21.5", "burned", False),
|
|
("0.21.6", "green", True),
|
|
), head="0.21.4")
|
|
|
|
assert _flips(steps) == ["0.21.6"]
|
|
assert steps[-1] == {"advance": "0.21.6"}
|
|
|
|
|
|
def test_advances_each_published_version_and_ignores_a_later_burned_claim():
|
|
from scripts.releases.sequencer import plan
|
|
|
|
assert plan(_claims(
|
|
("0.21.5", "published", False),
|
|
("0.21.6", "published", False),
|
|
), head="0.21.4") == [
|
|
{"advance": "0.21.5"},
|
|
{"advance": "0.21.6"},
|
|
]
|
|
assert plan(_claims(
|
|
("0.21.5", "green", False),
|
|
("0.21.6", "burned", False),
|
|
), head="0.21.4") == []
|
|
|
|
|
|
def test_explicit_publish_uses_the_same_oldest_first_plan():
|
|
from scripts.releases.sequencer import plan
|
|
|
|
assert plan(_claims(
|
|
("0.21.5", "green", False),
|
|
("0.21.6", "green", False),
|
|
), head="0.21.4", requested_version="0.21.6") == [
|
|
{"flip": "0.21.5"},
|
|
{"advance": "0.21.5"},
|
|
{"flip": "0.21.6"},
|
|
{"advance": "0.21.6"},
|
|
]
|
|
|
|
|
|
def test_published_history_at_or_below_the_head_is_an_idempotent_noop():
|
|
from scripts.releases.sequencer import plan
|
|
|
|
assert plan(_claims(
|
|
("0.21.4", "published", False),
|
|
("0.21.5", "green", False),
|
|
), head="0.21.4") == []
|
|
|
|
|
|
def test_an_unpublished_claim_below_the_head_is_refused():
|
|
from scripts.releases.sequencer import plan
|
|
|
|
with pytest.raises(ValueError, match="backwards"):
|
|
plan(_claims(("0.21.4", "green", True)), head="0.21.5")
|
|
|
|
|
|
def _claim_message(version, attempt, commit, *, autopublish=False,
|
|
epoch=1_790_000_000):
|
|
return {"schema": 1, "version": version, "attempt": attempt, "commit": commit,
|
|
"autopublish": autopublish, "claimEpoch": epoch}
|
|
|
|
|
|
def _final_message(version, attempt, commit, *, release_id, epoch=1_790_000_000):
|
|
claim_tag = f"rc.{attempt}-v{version}"
|
|
return {
|
|
"schema": 1, "version": version, "commit": commit,
|
|
"autopublish": False, "claimEpoch": epoch, "claimTag": claim_tag,
|
|
"claimTagObject": "1" * 40, "releaseId": release_id,
|
|
"candidateManifestSha256": "a" * 64, "dockerManifestDigest": "sha256:" + "b" * 64,
|
|
"archive": f"releases/tag/{claim_tag}/",
|
|
}
|
|
|
|
|
|
def _discover_run(tags, releases=(), workflow_runs=()):
|
|
def run(argv):
|
|
if argv[:2] == ["git", "fetch"]:
|
|
return ""
|
|
if argv[:3] == ["git", "ls-remote", "--tags"]:
|
|
return "\n".join(
|
|
f"{sha}\trefs/tags/{tag}\n{commit}\trefs/tags/{tag}^{{}}"
|
|
for tag, (sha, commit, _message) in tags.items()
|
|
)
|
|
if argv[:2] == ["git", "rev-parse"]:
|
|
return tags[argv[-1].removeprefix("refs/tags/")][0]
|
|
if argv[:3] == ["git", "cat-file", "-t"]:
|
|
return "tag"
|
|
if argv[:3] == ["git", "cat-file", "-p"]:
|
|
message = next(m for s, _c, m in tags.values() if s == argv[3])
|
|
return f"tagger Fixture <fixture@example.test> {message['claimEpoch']} +0000\n"
|
|
if argv[:3] == ["git", "tag", "-l"]:
|
|
return json.dumps(tags[argv[3]][2])
|
|
if argv[:4] == ["gh", "api", "--paginate", "--slurp"]:
|
|
if "/releases?" in argv[-1]:
|
|
return json.dumps([list(releases)])
|
|
return json.dumps([{"workflow_runs": list(workflow_runs)}])
|
|
raise AssertionError(argv)
|
|
|
|
return run
|
|
|
|
|
|
def test_discover_reads_an_attempt_ref_and_keeps_the_version():
|
|
from scripts.releases.sequencer import discover
|
|
|
|
commit = "a" * 40
|
|
tags = {
|
|
"rc.2-v0.21.5": ("1" * 40, commit, _claim_message("0.21.5", 2, commit)),
|
|
"v0.21.5": ("2" * 40, commit,
|
|
_final_message("0.21.5", 2, commit, release_id=7)),
|
|
}
|
|
releases = [{
|
|
"id": 7, "tag_name": "v0.21.5", "draft": False, "prerelease": False,
|
|
"published_at": "2026-09-22T01:00:00Z",
|
|
}]
|
|
records = discover("example/project", _discover_run(tags, releases=releases))
|
|
assert records[0]["version"] == "0.21.5"
|
|
assert records[0]["claim_tag"] == "rc.2-v0.21.5"
|
|
assert records[0]["state"] == "published"
|
|
assert records[0]["claim_epoch"] == 1_790_000_000
|
|
|
|
|
|
def test_a_marker_ref_clears_the_attempt():
|
|
from scripts.releases.sequencer import discover
|
|
|
|
commit = "a" * 40
|
|
tags = {
|
|
"rc.1-v0.21.5": ("1" * 40, commit, _claim_message("0.21.5", 1, commit)),
|
|
"abandoned-rc.1-v0.21.5": ("2" * 40, commit, {}),
|
|
}
|
|
failed = {
|
|
"id": 42, "status": "completed", "conclusion": "failure",
|
|
"run_attempt": 1, "updated_at": "2026-09-22T01:14:59Z",
|
|
"head_branch": "rc.1-v0.21.5", "head_sha": commit,
|
|
}
|
|
records = discover("example/project",
|
|
_discover_run(tags, workflow_runs=[failed]))
|
|
assert records[0]["state"] == "burned"
|
|
|
|
|
|
def test_two_outstanding_attempts_are_refused_across_versions():
|
|
from scripts.releases.sequencer import discover
|
|
|
|
commit = "a" * 40
|
|
tags = {
|
|
"rc.1-v0.21.5": ("1" * 40, commit, _claim_message("0.21.5", 1, commit)),
|
|
"rc.1-v0.22.0": ("2" * 40, commit, _claim_message("0.22.0", 1, commit)),
|
|
}
|
|
with pytest.raises(ValueError, match="more than one outstanding attempt"):
|
|
discover("example/project", _discover_run(tags))
|
|
|
|
|
|
def _sequencer_fixture(*versions, manifest_digest, docker_digest="sha256:" + "b" * 64,
|
|
drafts_on_claim_tag=False):
|
|
"""Two green claims with their final tags; releases start as drafts."""
|
|
commit = "a" * 40
|
|
tags = {}
|
|
releases = []
|
|
for index, version in enumerate(versions, start=1):
|
|
claim_tag, tag = f"rc.1-v{version}", f"v{version}"
|
|
claim_object, final_object = str(index) * 40, str(index + 2) * 40
|
|
claim = {
|
|
"schema": 1, "version": version, "attempt": 1, "commit": commit,
|
|
"autopublish": False, "claimEpoch": 1_790_000_000 + index,
|
|
}
|
|
final = {
|
|
"schema": 1, "version": version, "commit": commit,
|
|
"autopublish": False, "claimEpoch": claim["claimEpoch"],
|
|
"claimTag": claim_tag, "claimTagObject": claim_object,
|
|
"releaseId": index,
|
|
"candidateManifestSha256": manifest_digest,
|
|
"dockerManifestDigest": docker_digest,
|
|
"archive": f"releases/tag/{claim_tag}/",
|
|
}
|
|
tags[claim_tag] = (claim_object, commit, claim)
|
|
tags[tag] = (final_object, commit, final)
|
|
releases.append({
|
|
"id": index, "tag_name": claim_tag if drafts_on_claim_tag else tag,
|
|
"draft": True, "prerelease": False, "published_at": None,
|
|
"body": "notes",
|
|
})
|
|
|
|
def run(argv):
|
|
if argv[:2] == ["git", "fetch"]:
|
|
return ""
|
|
if argv[:3] == ["git", "ls-remote", "--tags"]:
|
|
return "\n".join(
|
|
f"{sha}\trefs/tags/{tag}\n{target}\trefs/tags/{tag}^{{}}"
|
|
for tag, (sha, target, _message) in tags.items()
|
|
)
|
|
if argv[:2] == ["git", "ls-remote"]:
|
|
lines = []
|
|
for ref in argv[2:]:
|
|
name = ref.removeprefix("refs/tags/").removesuffix("^{}")
|
|
entry = tags.get(name)
|
|
sha, target = (entry[0], entry[1]) if entry else ("", "")
|
|
if sha:
|
|
lines.append(f"{sha}\t{ref if ref.endswith('^{}') else ref}")
|
|
if ref.endswith("^{}"):
|
|
lines[-1] = f"{target}\t{ref}"
|
|
return "\n".join(lines)
|
|
if argv[:2] == ["git", "rev-parse"]:
|
|
return tags[argv[-1].removeprefix("refs/tags/")][0]
|
|
if argv[:3] == ["git", "cat-file", "-t"]:
|
|
return "tag"
|
|
if argv[:3] == ["git", "cat-file", "-p"]:
|
|
epoch = next(message["claimEpoch"] for object_id, _target, message in tags.values()
|
|
if object_id == argv[3])
|
|
return f"tagger Fixture <fixture@example.test> {epoch} +0000\n"
|
|
if argv[:3] == ["git", "tag", "-l"]:
|
|
return json.dumps(tags[argv[3]][2])
|
|
if "tag" in argv and "-a" in argv:
|
|
tag = argv[argv.index("-a") + 1]
|
|
tags[tag] = (str(len(tags)) * 40, tags[claim_tag][1], tags[tag][2])
|
|
return ""
|
|
if argv[:2] == ["git", "push"]:
|
|
return ""
|
|
if argv[:3] == ["docker", "buildx", "imagetools"]:
|
|
return json.dumps(docker_digest)
|
|
if argv[:4] == ["gh", "api", "--paginate", "--slurp"]:
|
|
if "/releases?" in argv[-1]:
|
|
return json.dumps([releases])
|
|
return json.dumps([{"workflow_runs": []}])
|
|
if argv[:3] == ["gh", "api", "--method"]:
|
|
release = next(row for row in releases if row["id"] == int(argv[4].rsplit("/", 1)[1]))
|
|
for _flag, value in zip(argv[5::2], argv[6::2]):
|
|
name, _, raw = value.partition("=")
|
|
if name == "tag_name":
|
|
release["tag_name"] = raw
|
|
elif name == "draft":
|
|
release["draft"] = raw == "true"
|
|
if not release["draft"]:
|
|
release["published_at"] = "2026-09-22T01:00:00Z"
|
|
elif name == "body":
|
|
release["body"] = raw
|
|
elif name == "prerelease":
|
|
release["prerelease"] = raw == "true"
|
|
return "{}"
|
|
if argv[:2] == ["gh", "api"] and "/releases/" in argv[2]:
|
|
release = next(row for row in releases if row["id"] == int(argv[2].rsplit("/", 1)[1]))
|
|
return json.dumps(release)
|
|
raise AssertionError(argv)
|
|
|
|
return tags, releases, run
|
|
|
|
|
|
def test_reconcile_discovers_custody_flips_then_advances_oldest_first():
|
|
from scripts.releases.sequencer import reconcile
|
|
|
|
manifest_digest = hashlib.sha256(b"m").hexdigest()
|
|
_tags, releases, run = _sequencer_fixture("0.21.5", "0.21.6", manifest_digest=manifest_digest)
|
|
events = []
|
|
archive_keys = []
|
|
head = ["0.21.4"]
|
|
|
|
def read_archive(key):
|
|
archive_keys.append(key)
|
|
return b"m"
|
|
|
|
def advance(record):
|
|
events.append(("advance", record["tag"]))
|
|
head[0] = record["version"]
|
|
|
|
steps = reconcile(
|
|
{"GITHUB_REPOSITORY": "example/project", "REQUESTED_VERSION": "0.21.6"},
|
|
run=run, read_head=lambda: head[0], advance_head=advance,
|
|
read_archive=read_archive,
|
|
)
|
|
|
|
assert steps == [
|
|
{"flip": "0.21.5"}, {"advance": "0.21.5"},
|
|
{"flip": "0.21.6"}, {"advance": "0.21.6"},
|
|
]
|
|
assert events == [
|
|
("advance", "v0.21.5"), ("advance", "v0.21.6"),
|
|
]
|
|
# Each pass hashes the manifest from its own attempt archive path.
|
|
assert archive_keys == [
|
|
"releases/tag/rc.1-v0.21.5/release-candidates.json",
|
|
"releases/tag/rc.1-v0.21.6/release-candidates.json",
|
|
]
|
|
for release, version in zip(releases, ("0.21.5", "0.21.6")):
|
|
assert release["tag_name"] == f"v{version}" and release["draft"] is False
|
|
|
|
|
|
def test_the_store_check_joins_the_pass_after_the_aliases_move(monkeypatch):
|
|
from scripts.releases import channel_releases, docker, sequencer, store
|
|
|
|
manifest_digest = hashlib.sha256(b"m").hexdigest()
|
|
_tags, releases, run = _sequencer_fixture(
|
|
"0.21.5", manifest_digest=manifest_digest, drafts_on_claim_tag=True)
|
|
events = []
|
|
head = ["0.21.4"]
|
|
monkeypatch.setattr(
|
|
channel_releases, "advance_stable",
|
|
lambda env, record, root: (head.append(record["version"]),
|
|
events.append(("feed", record["version"]))))
|
|
monkeypatch.setattr(
|
|
docker, "promote_stable",
|
|
lambda claim_tag, digest: events.append(("aliases", claim_tag)))
|
|
seen_env = []
|
|
monkeypatch.setattr(
|
|
store, "check_from_env",
|
|
lambda env: seen_env.append(env) or events.append(("store",)))
|
|
|
|
steps = sequencer.reconcile(
|
|
{"GITHUB_REPOSITORY": "example/project", "MS_STORE_PRODUCT_ID": "9NTEST"},
|
|
run=run, read_head=lambda: head[-1], advance_head=None,
|
|
read_archive=lambda _key: b"m",
|
|
)
|
|
|
|
assert steps == [{"advance": "0.21.5"}]
|
|
assert events == [("feed", "0.21.5"), ("aliases", "rc.1-v0.21.5"), ("store",)]
|
|
assert seen_env[0]["MS_STORE_PRODUCT_ID"] == "9NTEST"
|
|
|
|
|
|
def test_a_publish_that_died_before_the_retarget_is_repaired():
|
|
from scripts.releases.sequencer import reconcile
|
|
|
|
manifest_digest = hashlib.sha256(b"m").hexdigest()
|
|
_tags, releases, run = _sequencer_fixture(
|
|
"0.21.5", manifest_digest=manifest_digest, drafts_on_claim_tag=True)
|
|
events = []
|
|
head = ["0.21.4"]
|
|
|
|
def advance(record):
|
|
events.append(("advance", record["tag"]))
|
|
head[0] = record["version"]
|
|
|
|
steps = reconcile(
|
|
{"GITHUB_REPOSITORY": "example/project"},
|
|
run=run, read_head=lambda: head[0], advance_head=advance,
|
|
read_archive=lambda _key: b"m",
|
|
)
|
|
|
|
# The final tag existed and the draft was still on the attempt ref: the
|
|
# retarget and the publication rerun, then the head advances.
|
|
assert steps == [{"advance": "0.21.5"}]
|
|
assert events == [("advance", "v0.21.5")]
|
|
assert releases[0]["tag_name"] == "v0.21.5" and releases[0]["draft"] is False
|