diff --git a/Tests/ScriptsTests/test_submit_app_store_review.py b/Tests/ScriptsTests/test_submit_app_store_review.py index 3ed1474..b9b599e 100644 --- a/Tests/ScriptsTests/test_submit_app_store_review.py +++ b/Tests/ScriptsTests/test_submit_app_store_review.py @@ -175,6 +175,208 @@ def request(self, method, path, params=None, body=None, allowed=(200,)): self.assertEqual([request[0] for request in client.requests], ["GET"]) + def test_reuses_removed_version_when_replacement_creation_is_blocked(self): + class ReplacementClient: + def __init__(self): + self.requests: list[tuple[Any, ...]] = [] + + def request(self, method, path, params=None, body=None, allowed=(200,)): + self.requests.append((method, path, params, body, allowed)) + if method == "GET" and path == "/apps/app-id/appStoreVersions": + version_string = params.get("filter[versionString]") if params else None + if version_string == "1.0.14": + return {"data": []} + if version_string == "1.0.13": + return { + "data": [ + { + "id": "version-1-0-13", + "attributes": { + "versionString": "1.0.13", + "appStoreState": "DEVELOPER_REJECTED", + }, + } + ] + } + if method == "POST" and path == "/appStoreVersions": + raise submit_app_store_review.AppStoreConnectError( + "App Store Connect request failed: POST /appStoreVersions", + status=409, + payload={"errors": [{"detail": "You cannot create a new version of the App in the current state."}]}, + ) + if method == "PATCH" and path == "/appStoreVersions/version-1-0-13": + return { + "data": { + "id": "version-1-0-13", + "attributes": {"versionString": "1.0.14", "appStoreState": "PREPARE_FOR_SUBMISSION"}, + } + } + raise AssertionError(f"unexpected request: {method} {path}") + + client = ReplacementClient() + args = SimpleNamespace( + version="1.0.14", + remove_active_review_version="1.0.13", + release_type="AFTER_APPROVAL", + copyright="2026 Shiny Computers Leasing LLC", + uses_idfa=False, + ) + + version = None + try: + submit_app_store_review.create_app_store_version_with_retry( + client, + "app-id", + args, + attempts=1, + retry_seconds=0, + ) + except submit_app_store_review.AppStoreConnectError as error: + self.assertTrue(submit_app_store_review.is_version_creation_state_conflict(error)) + version = submit_app_store_review.reuse_removed_app_store_version(client, "app-id", "1.0.13", args) + if version is None: + self.fail("expected replacement version creation to be blocked") + + self.assertEqual(version["id"], "version-1-0-13") + patch_body = next(request[3] for request in client.requests if request[0] == "PATCH") + self.assertEqual(patch_body["data"]["attributes"]["versionString"], "1.0.14") + + def test_supersede_run_force_prepares_existing_replacement_version(self): + class ExistingReplacementClient: + def request(self, method, path, params=None, body=None, allowed=(200,)): + if method == "GET" and path == "/apps/app-id/appStoreVersions": + return { + "data": [ + { + "id": "version-1-0-14", + "attributes": {"versionString": "1.0.14", "appStoreState": "PREPARE_FOR_SUBMISSION"}, + } + ] + } + if method == "PATCH" and path == "/appStoreVersions/version-1-0-14": + return {"data": {"id": "version-1-0-14"}} + raise AssertionError(f"unexpected request: {method} {path}") + + args = SimpleNamespace( + version="1.0.14", + remove_active_review_version="1.0.13", + release_type="AFTER_APPROVAL", + copyright="2026 Shiny Computers Leasing LLC", + uses_idfa=False, + ) + + version, force_prepare = submit_app_store_review.ensure_replacement_version( + ExistingReplacementClient(), + "app-id", + args, + ) + + self.assertEqual(version["id"], "version-1-0-14") + self.assertTrue(force_prepare) + + def test_force_prepare_review_submission_reuses_existing_submission(self): + class ReviewSubmissionClient: + def __init__(self, state="READY_FOR_REVIEW"): + self.requests: list[tuple[Any, ...]] = [] + self.state = state + + def request(self, method, path, params=None, body=None, allowed=(200,)): + self.requests.append((method, path, params, body, allowed)) + if method == "GET" and path == "/reviewSubmissions": + return { + "data": [ + { + "id": "old-submission", + "attributes": {"state": self.state}, + "relationships": { + "items": {"data": [{"type": "reviewSubmissionItems", "id": "old-item"}]} + }, + } + ], + "included": [ + { + "id": "old-item", + "type": "reviewSubmissionItems", + "relationships": { + "appStoreVersion": { + "data": {"type": "appStoreVersions", "id": "version-1-0-13"} + } + }, + } + ], + } + if method == "POST" and path == "/reviewSubmissions": + return {"data": {"id": "new-submission", "attributes": {"state": "READY_FOR_REVIEW"}}} + if method == "POST" and path == "/reviewSubmissionItems": + return {"data": {"id": "new-item"}} + if method == "PATCH" and path == "/reviewSubmissions/old-submission": + return {"data": {"id": "old-submission", "attributes": {"state": "WAITING_FOR_REVIEW"}}} + raise AssertionError(f"unexpected request: {method} {path}") + + client = ReviewSubmissionClient() + args = SimpleNamespace(dry_run=False) + + submission = submit_app_store_review.ensure_review_submission( + client, + "app-id", + "version-1-0-13", + args, + force_prepare=True, + ) + + self.assertEqual(submission["id"], "old-submission") + post_paths = [request[1] for request in client.requests if request[0] == "POST"] + self.assertEqual(post_paths, ["/reviewSubmissionItems"]) + patch_paths = [request[1] for request in client.requests if request[0] == "PATCH"] + self.assertEqual(patch_paths, ["/reviewSubmissions/old-submission"]) + + def test_force_prepare_review_submission_returns_submitted_existing_submission(self): + class ReviewSubmissionClient: + def __init__(self): + self.requests: list[tuple[Any, ...]] = [] + + def request(self, method, path, params=None, body=None, allowed=(200,)): + self.requests.append((method, path, params, body, allowed)) + if method == "GET" and path == "/reviewSubmissions": + return { + "data": [ + { + "id": "old-submission", + "attributes": {"state": "WAITING_FOR_REVIEW"}, + "relationships": { + "items": {"data": [{"type": "reviewSubmissionItems", "id": "old-item"}]} + }, + } + ], + "included": [ + { + "id": "old-item", + "type": "reviewSubmissionItems", + "relationships": { + "appStoreVersion": { + "data": {"type": "appStoreVersions", "id": "version-1-0-13"} + } + }, + } + ], + } + raise AssertionError(f"unexpected request: {method} {path}") + + client = ReviewSubmissionClient() + args = SimpleNamespace(dry_run=False) + + submission = submit_app_store_review.ensure_review_submission( + client, + "app-id", + "version-1-0-13", + args, + force_prepare=True, + ) + + self.assertEqual(submission["id"], "old-submission") + mutation_methods = [request[0] for request in client.requests if request[0] in {"POST", "PATCH"}] + self.assertEqual(mutation_methods, []) + def test_dry_run_main_returns_before_version_or_submission_mutations(self): class MainClient: def __init__(self): @@ -253,6 +455,99 @@ def request(self, method, path, params=None, body=None, allowed=(200,)): mutation_methods = [request[0] for request in client.requests if request[0] != "GET"] self.assertEqual(mutation_methods, []) + def test_ensure_review_submission_reuses_empty_ready_submission(self): + class EmptyReviewSubmissionClient: + def __init__(self): + self.requests: list[tuple[Any, ...]] = [] + + def request(self, method, path, params=None, body=None, allowed=(200,)): + self.requests.append((method, path, params, body, allowed)) + if method == "GET" and path == "/reviewSubmissions": + return { + "data": [ + { + "id": "empty-submission", + "attributes": {"state": "READY_FOR_REVIEW"}, + "relationships": {"items": {"data": []}}, + } + ] + } + if method == "POST" and path == "/reviewSubmissionItems": + return {"data": {"id": "item-1"}} + if method == "PATCH" and path == "/reviewSubmissions/empty-submission": + return {"data": {"id": "empty-submission", "attributes": {"state": "WAITING_FOR_REVIEW"}}} + raise AssertionError(f"unexpected request: {method} {path}") + + client = EmptyReviewSubmissionClient() + args = SimpleNamespace(dry_run=False) + + submission = submit_app_store_review.ensure_review_submission( + client, + "app-id", + "version-1-0-14", + args, + force_prepare=False, + ) + + self.assertEqual(submission["id"], "empty-submission") + post_paths = [request[1] for request in client.requests if request[0] == "POST"] + self.assertEqual(post_paths, ["/reviewSubmissionItems"]) + patch_paths = [request[1] for request in client.requests if request[0] == "PATCH"] + self.assertEqual(patch_paths, ["/reviewSubmissions/empty-submission"]) + + def test_ensure_review_submission_submits_ready_submission_when_force_prepare_is_false(self): + class ReadyReviewSubmissionClient: + def __init__(self): + self.requests: list[tuple[Any, ...]] = [] + + def request(self, method, path, params=None, body=None, allowed=(200,)): + self.requests.append((method, path, params, body, allowed)) + if method == "GET" and path == "/reviewSubmissions": + return { + "data": [ + { + "id": "ready-submission", + "attributes": {"state": "READY_FOR_REVIEW"}, + "relationships": { + "items": {"data": [{"type": "reviewSubmissionItems", "id": "item-1"}]} + }, + } + ], + "included": [ + { + "id": "item-1", + "type": "reviewSubmissionItems", + "relationships": { + "appStoreVersion": { + "data": {"type": "appStoreVersions", "id": "version-1-0-14"} + } + }, + } + ], + } + if method == "POST" and path == "/reviewSubmissionItems": + raise submit_app_store_review.AppStoreConnectError( + "already exists", status=409, payload={} + ) + if method == "PATCH" and path == "/reviewSubmissions/ready-submission": + return {"data": {"id": "ready-submission", "attributes": {"state": "WAITING_FOR_REVIEW"}}} + raise AssertionError(f"unexpected request: {method} {path}") + + client = ReadyReviewSubmissionClient() + args = SimpleNamespace(dry_run=False) + + submission = submit_app_store_review.ensure_review_submission( + client, + "app-id", + "version-1-0-14", + args, + force_prepare=False, + ) + + self.assertEqual(submission["id"], "ready-submission") + patch_paths = [request[1] for request in client.requests if request[0] == "PATCH"] + self.assertEqual(patch_paths, ["/reviewSubmissions/ready-submission"]) + if __name__ == "__main__": unittest.main() diff --git a/scripts/submit-app-store-review.py b/scripts/submit-app-store-review.py index 9e8bfa1..0fcc04b 100755 --- a/scripts/submit-app-store-review.py +++ b/scripts/submit-app-store-review.py @@ -313,15 +313,21 @@ def create_app_store_version(client: ASCClient, app_id: str, args: argparse.Name )["data"] -def create_app_store_version_with_retry(client: ASCClient, app_id: str, args: argparse.Namespace) -> dict[str, Any]: - for attempt in range(CREATE_VERSION_MAX_ATTEMPTS): +def create_app_store_version_with_retry( + client: ASCClient, + app_id: str, + args: argparse.Namespace, + attempts: int = CREATE_VERSION_MAX_ATTEMPTS, + retry_seconds: int = CREATE_VERSION_RETRY_SECONDS, +) -> dict[str, Any]: + for attempt in range(attempts): try: return create_app_store_version(client, app_id, args) except AppStoreConnectError as error: - if attempt == CREATE_VERSION_MAX_ATTEMPTS - 1 or not is_version_creation_state_conflict(error): + if attempt == attempts - 1 or not is_version_creation_state_conflict(error): raise print("App Store Connect is still releasing the previous review state; retrying version creation") - time.sleep(CREATE_VERSION_RETRY_SECONDS) + time.sleep(retry_seconds) raise AppStoreConnectError(f"failed to create App Store version {args.version}") @@ -360,6 +366,53 @@ def ensure_version(client: ASCClient, app_id: str, args: argparse.Namespace) -> return version +def reuse_removed_app_store_version( + client: ASCClient, app_id: str, source_version_string: str, args: argparse.Namespace +) -> dict[str, Any]: + version = app_store_version(client, app_id, source_version_string) + if version is None: + raise AppStoreConnectError(f"App Store version {source_version_string} is not available to reuse") + state = version_state(version) + if state in LOCKED_VERSION_STATES: + raise AppStoreConnectError( + f"App Store version {source_version_string} is still {state}; cannot reuse it as {args.version}", + payload=version, + ) + updated = client.request( + "PATCH", + f"/appStoreVersions/{version['id']}", + body={ + "data": { + "type": "appStoreVersions", + "id": version["id"], + "attributes": { + "versionString": args.version, + "releaseType": args.release_type, + "copyright": args.copyright, + "usesIdfa": args.uses_idfa, + }, + } + }, + )["data"] + print(f"Reused App Store version {source_version_string} as {args.version}: {updated['id']}") + return updated + + +def ensure_replacement_version(client: ASCClient, app_id: str, args: argparse.Namespace) -> tuple[dict[str, Any], bool]: + try: + version = ensure_version(client, app_id, args) + return version, bool(args.remove_active_review_version) + except AppStoreConnectError as error: + source_version = args.remove_active_review_version + if not source_version or not is_version_creation_state_conflict(error): + raise + print( + f"App Store Connect still blocks creating {args.version}; " + f"reusing removed version {source_version} instead" + ) + return reuse_removed_app_store_version(client, app_id, source_version, args), True + + def version_build_id(client: ASCClient, version_id: str) -> str | None: payload = client.request( "GET", @@ -515,7 +568,13 @@ def ensure_metadata(client: ASCClient, version_id: str, source_localization: dic print(f"Updated review detail: {review_detail_id}") -def ensure_review_submission(client: ASCClient, app_id: str, version_id: str, args: argparse.Namespace) -> dict[str, Any]: +def ensure_review_submission( + client: ASCClient, + app_id: str, + version_id: str, + args: argparse.Namespace, + force_prepare: bool = False, +) -> dict[str, Any]: submissions = client.request( "GET", "/reviewSubmissions", @@ -529,21 +588,32 @@ def ensure_review_submission(client: ASCClient, app_id: str, version_id: str, ar }, ) existing = None + included = included_by_id(submissions) for submission in submissions.get("data", []): state = submission["attributes"].get("state") - if state in ACTIVE_REVIEW_SUBMISSION_STATES and relationship_id(submission, "appStoreVersionForReview") == version_id: + if state not in ACTIVE_REVIEW_SUBMISSION_STATES: + continue + submission_version_id = relationship_id(submission, "appStoreVersionForReview") + item_ids = [item["id"] for item in submission.get("relationships", {}).get("items", {}).get("data", [])] + item_version_ids = {relationship_id(included.get(item_id, {}), "appStoreVersion") for item_id in item_ids} + if submission_version_id == version_id or version_id in item_version_ids or state == "READY_FOR_REVIEW": existing = submission break if existing: - print(f"Using existing review submission: {existing['id']} ({existing['attributes'].get('state')})") - return existing - submission = client.request( - "POST", - "/reviewSubmissions", - body={"data": {"type": "reviewSubmissions", "relationships": {"app": {"data": {"type": "apps", "id": app_id}}}}}, - allowed=(201,), - )["data"] - print(f"Created review submission: {submission['id']}") + state = existing["attributes"].get("state") + if state in {"WAITING_FOR_REVIEW", "IN_REVIEW"}: + print(f"Review submission is already submitted: {existing['id']} ({state})") + return existing + print(f"Using existing review submission: {existing['id']} ({state})") + submission = existing + else: + submission = client.request( + "POST", + "/reviewSubmissions", + body={"data": {"type": "reviewSubmissions", "relationships": {"app": {"data": {"type": "apps", "id": app_id}}}}}, + allowed=(201,), + )["data"] + print(f"Created review submission: {submission['id']}") try: item = client.request( "POST", @@ -567,6 +637,10 @@ def ensure_review_submission(client: ASCClient, app_id: str, version_id: str, ar if args.dry_run: print("Dry run: review submission was prepared but not submitted") return submission + state = submission["attributes"].get("state") if submission.get("attributes") else None + if state in {"WAITING_FOR_REVIEW", "IN_REVIEW"}: + print(f"Review submission is already submitted: {submission['id']} ({state})") + return submission submitted = client.request( "PATCH", f"/reviewSubmissions/{submission['id']}", @@ -644,10 +718,16 @@ def main() -> int: if args.dry_run: print("Dry run: metadata and build validated; no App Store Connect changes were made") return 0 - version = ensure_version(client, app_id, args) + version, reused_removed_version = ensure_replacement_version(client, app_id, args) attach_build(client, version, build, args) ensure_metadata(client, version["id"], source_localization, source_review_detail, args) - submission = ensure_review_submission(client, app_id, version["id"], args) + submission = ensure_review_submission( + client, + app_id, + version["id"], + args, + force_prepare=reused_removed_version, + ) final = client.request( "GET", f"/appStoreVersions/{version['id']}",