From 3768652bc9e06c01d0e8b42b577897f6e82bf067 Mon Sep 17 00:00:00 2001 From: ThePhaseless Date: Tue, 6 Oct 2026 17:31:34 +0200 Subject: [PATCH] feat(app-store): a release replaces a version waiting for review App Store Connect takes one submission at a time and locks a waiting version's screenshots. A production release now withdraws an earlier submission that App Review has not started, reuses the withdrawn version and submits it with the new build and screenshots, as Google Play cancels changes in review. One already in review still stops the run. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++- scripts/app_store_submit.py | 49 +++++++++++++++++++++++++++------- tests/test_app_store_submit.py | 31 ++++++++++++++++++--- 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2133378..0657245 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,9 @@ them. the tracks they joined, production included. - **Production:** publish a release without ticking "Set as a pre-release". Google Play gets it on the production track, App Review gets the iOS build - (released once approved), and Pages serves it at the site root. + (released once approved), and Pages serves it at the site root. An earlier + version still waiting for App Review is withdrawn and replaced, as Google + Play does; one that App Review has started stops the run. - **Closed and internal tests** are not automated: upload the builds attached to a release in Play Console and App Store Connect. - **Promotion:** untick "Set as a pre-release" on a tested pre-release. The diff --git a/scripts/app_store_submit.py b/scripts/app_store_submit.py index 15cf7bc..90edb39 100644 --- a/scripts/app_store_submit.py +++ b/scripts/app_store_submit.py @@ -459,14 +459,37 @@ def submit(submission_id, version_id): time.sleep(30) -def open_submission(app_id): - """The unsent review submission, if any, with the versions it contains.""" - submissions = call("GET", "/v1/reviewSubmissions", { +def review_submissions(app_id): + return call("GET", "/v1/reviewSubmissions", { "filter[app]": app_id, "filter[platform]": "IOS", "filter[state]": "READY_FOR_REVIEW,WAITING_FOR_REVIEW,IN_REVIEW,UNRESOLVED_ISSUES", })["data"] + + +def withdraw(submission_id, app_id): + """Takes a submission that App Review has not started out of review.""" + call("PATCH", f"/v1/reviewSubmissions/{submission_id}", body={"data": { + "type": "reviewSubmissions", + "id": submission_id, + "attributes": {"canceled": True}, + }}) + for _ in range(40): + if all(s["id"] != submission_id for s in review_submissions(app_id)): + print("Withdrew the version waiting for review; this release replaces it.") + return + time.sleep(15) + sys.exit("The submission waiting for review was not withdrawn in 10 minutes.") + + +def open_submission(app_id, replace_waiting=False): + """The unsent review submission, if any, with the versions it contains; + with replace_waiting, withdraws one that waits for App Review first.""" + submissions = review_submissions(app_id) busy = [s for s in submissions if s["attributes"]["state"] != "READY_FOR_REVIEW"] + if busy and replace_waiting and busy[0]["attributes"]["state"] == "WAITING_FOR_REVIEW": + withdraw(busy[0]["id"], app_id) + return open_submission(app_id) if busy: sys.exit( f"A submission is already {busy[0]['attributes']['state']}; " @@ -591,6 +614,14 @@ def update_screenshots(version_id): wait_for_screenshots(uploaded) +def app_store_versions(app_id): + return call("GET", f"/v1/apps/{app_id}/appStoreVersions", { + "filter[platform]": "IOS", + "fields[appStoreVersions]": "versionString,appVersionState,releaseType", + "limit": "200", + })["data"] + + def main(): bundle_id = os.environ["BUNDLE_ID"] version = os.environ["VERSION"] @@ -615,11 +646,7 @@ def main(): public_beta(app, build, name) return - versions = call("GET", f"/v1/apps/{app_id}/appStoreVersions", { - "filter[platform]": "IOS", - "fields[appStoreVersions]": "versionString,appVersionState,releaseType", - "limit": "200", - })["data"] + versions = app_store_versions(app_id) same = next((v for v in versions if v["attributes"]["versionString"] == version), None) state = same["attributes"]["appVersionState"] if same else None if state in SUBMITTED or state == "READY_FOR_REVIEW": @@ -647,11 +674,13 @@ def main(): "App Store updates need \"What's New\": write it in the GitHub " "release description and re-run this job." ) - submission_id, contents = open_submission(app_id) + # An earlier version still waiting for App Review gives way to this one, + # as on Google Play; a withdrawn version becomes editable and is reused. + submission_id, contents = open_submission(app_id, replace_waiting=True) if contents: sys.exit("An unsent submission already has items; check it in App Store Connect.") - version_id = app_store_version(app_id, version, versions) + version_id = app_store_version(app_id, version, app_store_versions(app_id)) call("PATCH", f"/v1/appStoreVersions/{version_id}", body={"data": { "type": "appStoreVersions", "id": version_id, diff --git a/tests/test_app_store_submit.py b/tests/test_app_store_submit.py index 27a2784..ce0d84a 100644 --- a/tests/test_app_store_submit.py +++ b/tests/test_app_store_submit.py @@ -313,9 +313,10 @@ def __call__(self, request): return 200, {"data": {"type": "appStoreVersionLocalizations", "id": "l1", "attributes": {}}} if path == "/v1/reviewSubmissions" and method == "GET": + states = request.query["filter[state]"].split(",") return 200, {"data": [ {"type": "reviewSubmissions", "id": s, "attributes": {"state": v["state"]}} - for s, v in self.submissions.items() + for s, v in self.submissions.items() if v["state"] in states ]} if path == "/v1/reviewSubmissions" and method == "POST": self.submissions["rs-new"] = {"state": "READY_FOR_REVIEW", "items": []} @@ -346,6 +347,13 @@ def __call__(self, request): "Version is not ready to be submitted yet, " "please try again later."}]} submission = self.submissions[match.group(1)] + if body["data"]["attributes"].get("canceled"): + assert submission["state"] == "WAITING_FOR_REVIEW", submission + submission["state"] = "COMPLETE" + for version_id in submission["items"]: + self.versions[version_id]["state"] = "DEVELOPER_REJECTED" + return 200, {"data": {"type": "reviewSubmissions", "id": match.group(1), + "attributes": {"state": "CANCELING"}}} submission["state"] = "WAITING_FOR_REVIEW" for version_id in submission["items"]: self.versions[version_id]["state"] = "WAITING_FOR_REVIEW" @@ -443,14 +451,31 @@ def test_editable_version_gets_the_new_version_number(self): def test_submission_in_review_blocks_a_new_one(self): mock = AppStoreMock( - submissions={"rs1": {"state": "WAITING_FOR_REVIEW", "items": []}}, + submissions={"rs1": {"state": "IN_REVIEW", "items": []}}, ) result, api = self.submit(mock, "build", "prod", notes="x") self.assertNotEqual(result.returncode, 0) - self.assertIn("A submission is already WAITING_FOR_REVIEW", result.stderr) + self.assertIn("A submission is already IN_REVIEW", result.stderr) self.assertNotIn("POST /v1/appStoreVersions", api.calls()) self.assertNotIn("POST /v1/reviewSubmissions", api.calls()) + def test_new_release_replaces_a_version_waiting_for_review(self): + versions = copy.deepcopy(LIVE) + versions["v-wait"] = {"version": "4.2.1", "state": "WAITING_FOR_REVIEW", + "build": "b-old"} + mock = AppStoreMock(versions=versions, submissions={ + "rs-wait": {"state": "WAITING_FOR_REVIEW", "items": ["v-wait"]}, + }) + result, api = self.submit(mock, "build", "prod", notes="Poprawki") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Withdrew the version waiting for review", result.stdout) + # The withdrawn version is reused for this one, with the new build. + self.assertNotIn("POST /v1/appStoreVersions", api.calls()) + self.assertEqual(mock.versions["v-wait"]["version"], "4.3.0") + self.assertEqual(mock.versions["v-wait"]["build"], "b1") + self.assertEqual(mock.versions["v-wait"]["state"], "WAITING_FOR_REVIEW") + self.assertEqual(mock.submissions["rs-wait"]["state"], "COMPLETE") + def test_beta_becomes_a_public_testflight_beta(self): mock = AppStoreMock(versions={ "v-older": {