fix(release): check the held Store submission instead of releasing it

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.
This commit is contained in:
ethernet
2026-09-23 17:14:46 -04:00
parent 20f3b5f38b
commit eb877f88c5
4 changed files with 167 additions and 154 deletions

View File

@@ -388,9 +388,10 @@ def reconcile(env: dict, *, run=output, read_head=None, advance_head=None,
# 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 release joins the pass here (after the feeds and
# aliases move), not before. A failure leaves the run red.
store.release_from_env(env)
# 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:

View File

@@ -1,9 +1,10 @@
"""Store submission control for the release pipeline.
The green run submits the verified ``.msixbundle`` with auto-publish off
(``targetPublishMode: "Manual"``); the publication pass releases it (or turns
auto-publish on while it is still in certification). Both entry points act on
the submission's current state, so both are safe to rerun.
(``targetPublishMode: "Manual"``). The submission API has no call that
releases a held submission, so publication never edits it: the publication
pass only checks its state and tells a person to click Publish now in Partner
Center once it is certified. Both entry points are safe to rerun.
Green run — ``python -m scripts.releases.store submit <package>`` (Windows
runner, ``msstore`` CLI configured beforehand by
@@ -25,25 +26,18 @@ runner, ``msstore`` CLI configured beforehand by
the complete submission JSON, so it sets packages and publish mode at once.
6. ``msstore submission publish <productId>`` — commits; certification starts.
Publication pass — ``scripts.releases.store.release(...)`` (Ubuntu runner,
Partner Center submission REST API, stdlib ``urllib`` only):
Publication pass — ``scripts.releases.store.check(...)`` (Ubuntu runner,
Partner Center submission REST API, stdlib ``urllib`` only, read-only):
1. ``POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token`` with
``grant_type=client_credentials`` and scope
``https://manage.devcenter.microsoft.com/.default`` (the same MS_STORE_*
credentials the CLI uses).
2. ``POST https://manage.devcenter.microsoft.com/v1.0/my/applications/{id}/submissions``
to find the in-flight submission: ``201`` means none existed (the fresh
probe draft is deleted immediately with ``DELETE .../submissions/{id}``,
nothing else is touched); ``409`` means one exists and its id is in the
error body.
3. ``GET .../submissions/{id}/status``. Terminal or already-live states
(``PendingPublication, Publishing, Published``) are a no-op ("already-live").
4. Otherwise the held submission is released by rewriting it with
``targetPublishMode: "Immediate"`` (``PUT .../submissions/{id}``) and
committing it (``POST .../submissions/{id}/commit``). A certified
submission (status ``Release``) goes live now ("released"); one still in
certification goes live when certification passes ("auto-publish").
2. ``GET https://manage.devcenter.microsoft.com/v1.0/my/applications/{id}`` —
``pendingApplicationSubmission.id`` names the held submission.
3. ``GET .../submissions/{id}/status``. ``Release`` (certified, held by
Manual) and the certification states print a Publish now instruction as
a GitHub warning; the live states are a no-op; a failed state raises.
Sources for the commands and fields above:
- msstore CLI commands and options (``submission status/get/update/delete
@@ -57,18 +51,13 @@ Sources for the commands and fields above:
(``PendingCommit, CommitStarted, PreProcessing, Certification,
CertificationFailed, Release, PendingPublication, Publishing, Published, ...``):
https://learn.microsoft.com/en-us/windows/uwp/monetize/manage-app-submissions
- REST methods (create/update/commit/delete/status):
https://learn.microsoft.com/en-us/windows/uwp/monetize/manage-app-submissions#methods-for-managing-app-submissions
(individual pages: create-an-app-submission, update-an-app-submission,
commit-an-app-submission, delete-an-app-submission,
get-status-for-an-app-submission)
- REST methods (get an app, get submission status):
https://learn.microsoft.com/en-us/windows/uwp/monetize/get-an-app
https://learn.microsoft.com/en-us/windows/uwp/monetize/get-status-for-an-app-submission
The documented methods are get, create, update, commit, delete and status;
none releases a held submission, and update refuses a committed one (409).
- Azure AD client-credentials token:
https://learn.microsoft.com/en-us/windows/uwp/monetize/create-and-manage-submissions-using-windows-store-services#obtain-an-azure-ad-access-token
Unverified on paper but guarded in code: whether the API accepts a ``PUT``
update on a submission that is committed and held (certified, waiting for
release). If it refuses, the release step fails and the publication run stays
red; rerunning after a fix from Partner Center is safe.
"""
from __future__ import annotations
@@ -135,7 +124,7 @@ def _http_run(request: dict) -> dict:
"""Default injected runner: one HTTP request, stdlib only."""
url = request["url"]
data = None
headers = {"Content-Type": "application/json"}
headers = {"Content-Type": "application/json", **request.get("headers", {})}
body = request.get("body")
if body is not None:
data = json.dumps(body).encode()
@@ -171,71 +160,65 @@ def _token(tenant_id: str, client_id: str, client_secret: str, run) -> str:
return response["body"]["access_token"]
def _in_progress_id(conflict_body) -> str:
"""The 409 body names the in-progress submission; pull its id out."""
text = json.dumps(conflict_body) if not isinstance(conflict_body, str) \
else conflict_body
digits = "".join(character if character.isdigit() or character == " "
else " " for character in text).split()
long_ids = [token for token in digits if len(token) >= 10]
if not long_ids:
raise StoreError(f"cannot find the in-flight submission id in {text!r}")
return long_ids[0]
# Statuses of a committed submission that has not been decided yet.
_IN_CERTIFICATION = {"CommitStarted", "PreProcessing", "Certification"}
_FAILED = {"CommitFailed", "PreProcessingFailed", "CertificationFailed", "PublishFailed",
"Canceled"}
def release(*, product_id: str, tenant_id: str, client_id: str,
client_secret: str, run=_http_run) -> str:
"""Release the held submission, or let it go live when certified."""
def check(*, product_id: str, tenant_id: str, client_id: str, client_secret: str,
run=_http_run) -> str:
"""Report the held submission's state and what a person must do next.
The submission API has no call that releases a held (Manual) submission,
so the publication pass never edits or commits it: a certified submission
is put live by "Publish now" in Partner Center. A failed submission leaves
the run red.
"""
token = _token(tenant_id, client_id, client_secret, run)
def call(method: str, path: str, body: dict | None = None) -> dict:
request = {"method": method, "url": API_ROOT + path}
if body is not None:
request["body"] = body
request["headers"] = {"Authorization": f"Bearer {token}"}
return run(request)
def get(path: str) -> dict:
response = run({"method": "GET", "url": API_ROOT + path,
"headers": {"Authorization": f"Bearer {token}"}})
if response["status"] != 200:
raise StoreError(f"GET {path} failed: {response['status']}")
return response["body"]
submissions_path = f"/applications/{product_id}/submissions"
probe = call("POST", submissions_path)
if probe["status"] == 201:
# Nothing was in flight; do not leave the probe draft behind.
probe_id = probe["body"].get("id")
if probe_id:
call("DELETE", f"{submissions_path}/{probe_id}")
app = get(f"/applications/{product_id}")
pending = (app.get("pendingApplicationSubmission") or {}).get("id")
if not pending:
_notice(f"No Store submission is pending for {product_id}. The green run submits "
"one; check its stable-store job.")
return "no-submission"
if probe["status"] != 409:
raise StoreError(f"unexpected submission probe reply: {probe['status']}")
submission_id = _in_progress_id(probe["body"])
status = call("GET", f"{submissions_path}/{submission_id}/status")
if status["status"] != 200:
raise StoreError(f"submission status failed: {status['status']}")
state = status["body"]["status"]
state = get(f"/applications/{product_id}/submissions/{pending}/status").get("status")
if state in _FAILED:
raise StoreError(f"Store submission {pending} is {state}; resubmit from a green run")
if state in _ALREADY_LIVE:
return "already-live"
submission = call("GET", f"{submissions_path}/{submission_id}")
if submission["status"] != 200:
raise StoreError(f"submission read failed: {submission['status']}")
held = submission["body"]
held["targetPublishMode"] = "Immediate"
updated = call("PUT", f"{submissions_path}/{submission_id}", held)
if updated["status"] != 200:
raise StoreError(f"submission update failed: {updated['status']}")
committed = call("POST", f"{submissions_path}/{submission_id}/commit")
if committed["status"] != 200:
raise StoreError(f"submission commit failed: {committed['status']}")
return "released" if state == _CERTIFIED_HELD else "auto-publish"
if state == _CERTIFIED_HELD:
_notice(f"Store submission {pending} passed certification and is held. "
"Open it in Partner Center and click Publish now.")
return "needs-publish-now"
if state in _IN_CERTIFICATION:
_notice(f"Store submission {pending} is still in certification ({state}). When it "
"passes, open it in Partner Center and click Publish now.")
return "in-certification"
raise StoreError(f"Store submission {pending} has an unexpected status: {state!r}")
def release_from_env(env: dict, run=_http_run) -> str:
"""Release the Store submission when the environment configures it."""
def _notice(text: str) -> None:
# A GitHub annotation, so the manual step shows on the run's summary page.
print(f"::warning title=Microsoft Store::{text}")
def check_from_env(env: dict, run=_http_run) -> str:
"""Check the Store submission when the environment configures it."""
product_id = env.get("MS_STORE_PRODUCT_ID")
if not product_id:
print("Store release skipped: MS_STORE_PRODUCT_ID is not configured.",
print("Store check skipped: MS_STORE_PRODUCT_ID is not configured.",
file=sys.stderr)
return "not-configured"
return release(
return check(
product_id=product_id,
tenant_id=env["MS_STORE_TENANT_ID"],
client_id=env["MS_STORE_CLIENT_ID"],
@@ -251,11 +234,11 @@ def main(argv: list[str]) -> int:
result = submit(package, product_id=product_id)
print(json.dumps(result, sort_keys=True))
return 0
if argv[:1] == ["release"]:
print(json.dumps({"result": release_from_env(dict(os.environ))},
if argv[:1] == ["check"]:
print(json.dumps({"result": check_from_env(dict(os.environ))},
sort_keys=True))
return 0
print("usage: python -m scripts.releases.store submit <package> | release",
print("usage: python -m scripts.releases.store submit <package> | check",
file=sys.stderr)
return 2

View File

@@ -411,7 +411,7 @@ def test_reconcile_discovers_custody_flips_then_advances_oldest_first():
assert release["tag_name"] == f"v{version}" and release["draft"] is False
def test_the_store_release_joins_the_pass_after_the_aliases_move(monkeypatch):
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()
@@ -428,7 +428,7 @@ def test_the_store_release_joins_the_pass_after_the_aliases_move(monkeypatch):
lambda claim_tag, digest: events.append(("aliases", claim_tag)))
seen_env = []
monkeypatch.setattr(
store, "release_from_env",
store, "check_from_env",
lambda env: seen_env.append(env) or events.append(("store",)))
steps = sequencer.reconcile(

View File

@@ -99,109 +99,138 @@ def test_submit_keeps_the_submission_in_draft_until_the_mode_is_set():
class _Api:
"""Stands in for the Partner Center submission REST API."""
"""Stands in for the Partner Center submission REST API (read-only use)."""
def __init__(self, status, in_flight=True, fail_update=False):
def __init__(self, status, pending="1152921504621243540"):
self.requests = []
self.status = status
self.in_flight = in_flight
self.fail_update = fail_update
self.updated = None
self.committed = False
self.pending = pending
def __call__(self, request):
self.requests.append(request)
url, method = request["url"], request["method"]
if url.endswith("/token"):
return {"status": 200, "body": {"access_token": "tok"}}
if url.endswith("/submissions") and method == "POST":
if self.in_flight:
return {
"status": 409,
"body": {"code": "InvalidOperation", "message":
"The app already has an in-progress submission: "
"1152921504621243540"},
}
return {"status": 201, "body": {"id": "probe", "status": "PendingCommit"}}
if url.endswith("/status") and method == "GET":
if method != "GET":
raise AssertionError(f"the check must never write: {method} {url}")
if request.get("headers", {}).get("Authorization") != "Bearer tok":
return {"status": 401, "body": {"code": "Unauthorized"}}
if url.endswith("/applications/9NTEST"):
app = {"id": "9NTEST"}
if self.pending:
app["pendingApplicationSubmission"] = {"id": self.pending}
return {"status": 200, "body": app}
if url.endswith(f"/submissions/{self.pending}/status"):
return {"status": 200, "body": {"status": self.status}}
if "/submissions/" in url and method == "GET":
return {"status": 200, "body": {
"id": "1152921504621243540", "targetPublishMode": "Manual",
"friendlyName": "Submission 2"}}
if "/submissions/" in url and method == "DELETE":
return {"status": 204, "body": ""}
if "/submissions/" in url and method == "PUT":
if self.fail_update:
return {"status": 500, "body": {"code": "ServiceError"}}
self.updated = request["body"]
return {"status": 200, "body": {"id": "1152921504621243540"}}
if url.endswith("/commit") and method == "POST":
self.committed = True
return {"status": 200, "body": {"status": "CommitStarted"}}
raise AssertionError(request)
def _release(status, **kwargs):
from scripts.releases.store import release
def _check(status, **kwargs):
from scripts.releases.store import check
api = _Api(status, **kwargs)
result = release(product_id="9NTEST", tenant_id="T", client_id="C",
client_secret="S", run=api)
result = check(product_id="9NTEST", tenant_id="T", client_id="C",
client_secret="S", run=api)
return api, result
def test_release_publishes_a_certified_submission():
api, result = _release("Release")
assert result == "released"
assert api.updated["targetPublishMode"] == "Immediate"
assert api.committed is True
def test_a_certified_held_submission_asks_for_publish_now(capsys):
_api, result = _check("Release")
assert result == "needs-publish-now"
out = capsys.readouterr().out
assert out.startswith("::warning title=Microsoft Store::") and "Publish now" in out
def test_release_turns_auto_publish_on_when_not_certified():
api, result = _release("Certification")
assert result == "auto-publish"
assert api.updated["targetPublishMode"] == "Immediate"
assert api.committed is True
@pytest.mark.parametrize("status", ["CommitStarted", "PreProcessing", "Certification"])
def test_a_submission_in_certification_says_what_comes_next(status, capsys):
_api, result = _check(status)
assert result == "in-certification"
out = capsys.readouterr().out
assert "Publish now" in out and status in out
def test_release_is_a_noop_when_the_submission_already_goes_live():
api, result = _release("PendingPublication")
@pytest.mark.parametrize("status", ["PendingPublication", "Publishing", "Published"])
def test_a_live_submission_is_a_noop(status, capsys):
_api, result = _check(status)
assert result == "already-live"
assert api.updated is None and api.committed is False
assert capsys.readouterr().out == ""
def test_release_without_an_in_flight_submission_deletes_its_probe_draft():
api, result = _release("Certification", in_flight=False)
assert result == "no-submission"
deleted = [r for r in api.requests
if r["method"] == "DELETE" and "/submissions/" in r["url"]]
assert len(deleted) == 1
assert api.committed is False
def test_a_failed_store_call_leaves_the_run_red():
@pytest.mark.parametrize("status", ["CertificationFailed", "CommitFailed", "PublishFailed",
"Canceled"])
def test_a_failed_submission_leaves_the_run_red_and_says_to_resubmit(status):
from scripts.releases.store import StoreError
with pytest.raises(StoreError):
_release("Certification", fail_update=True)
def test_release_from_env_skips_when_the_store_is_not_configured(capsys):
from scripts.releases.store import release_from_env
assert release_from_env({}) == "not-configured"
with pytest.raises(StoreError, match="resubmit from a green run"):
_check(status)
def test_release_from_env_passes_the_configured_credentials():
from scripts.releases.store import release_from_env
def test_an_unknown_submission_status_leaves_the_run_red():
from scripts.releases.store import StoreError
api = _Api("Release")
with pytest.raises(StoreError, match="unexpected status"):
_check("SomethingNew")
def test_no_pending_submission_is_reported_not_invented(capsys):
_api, result = _check("Release", pending=None)
assert result == "no-submission"
assert "::warning" in capsys.readouterr().out
def test_the_check_never_writes_to_the_store():
# _Api raises on any non-GET after the token; every state must pass.
for status in ("Release", "Certification", "Published"):
api, _ = _check(status)
assert all(r["method"] == "GET" for r in api.requests[1:])
def test_http_run_sends_the_request_headers(monkeypatch):
import urllib.request
from scripts.releases.store import _http_run
sent = {}
class _Response:
status = 200
def read(self):
return b"{}"
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def fake_urlopen(request):
sent.update({key.lower(): value for key, value in request.header_items()})
return _Response()
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
_http_run({"method": "GET", "url": "https://example.test/x",
"headers": {"Authorization": "Bearer tok"}})
assert sent["authorization"] == "Bearer tok"
def test_check_from_env_skips_when_the_store_is_not_configured():
from scripts.releases.store import check_from_env
assert check_from_env({}) == "not-configured"
def test_check_from_env_passes_the_configured_credentials():
from scripts.releases.store import check_from_env
api = _Api("Published")
env = {
"MS_STORE_PRODUCT_ID": "9NTEST",
"MS_STORE_TENANT_ID": "TENANT",
"MS_STORE_CLIENT_ID": "CLIENT",
"MS_STORE_CLIENT_SECRET": "SECRET",
}
assert release_from_env(env, run=api) == "released"
assert check_from_env(env, run=api) == "already-live"
token = next(r for r in api.requests if r["url"].endswith("/token"))
assert token["form"]["client_id"] == "CLIENT"
assert token["form"]["client_secret"] == "SECRET"