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": {