diff --git a/.github/workflows/ios-xcode-cloud.yml b/.github/workflows/ios-xcode-cloud.yml index 75edea76..64ee9dd1 100644 --- a/.github/workflows/ios-xcode-cloud.yml +++ b/.github/workflows/ios-xcode-cloud.yml @@ -13,6 +13,7 @@ on: - monitor - describe - store-status + - cancel-review default: inspect workflow_id: description: Optional Xcode Cloud workflow ID; auto-selects a single enabled Archive workflow when empty @@ -30,6 +31,10 @@ on: description: App Store Connect build resource ID to inspect required: false type: string + current_review_build_id: + description: Exact linked build ID expected before canceling a mismatched review + required: false + type: string permissions: contents: read @@ -57,6 +62,7 @@ jobs: XCODE_CLOUD_BUILD_RUN_ID: ${{ inputs.build_run_id }} APP_STORE_VERSION: ${{ inputs.app_store_version }} APP_STORE_BUILD_ID: ${{ inputs.app_store_build_id }} + CURRENT_REVIEW_BUILD_ID: ${{ inputs.current_review_build_id }} run: | case "$XCODE_CLOUD_MODE" in inspect) @@ -95,6 +101,16 @@ jobs: --version "$APP_STORE_VERSION" \ --build-id "$APP_STORE_BUILD_ID" ;; + cancel-review) + if [[ -z "$APP_STORE_VERSION" || -z "$APP_STORE_BUILD_ID" || -z "$CURRENT_REVIEW_BUILD_ID" ]]; then + echo "::error::app_store_version, app_store_build_id, and current_review_build_id are required in cancel-review mode" + exit 1 + fi + python3 scripts/xcode-cloud-control.py cancel-review \ + --version "$APP_STORE_VERSION" \ + --current-build-id "$CURRENT_REVIEW_BUILD_ID" \ + --replacement-build-id "$APP_STORE_BUILD_ID" + ;; *) echo "::error::Unsupported Xcode Cloud mode: $XCODE_CLOUD_MODE" exit 1 diff --git a/scripts/xcode-cloud-control.py b/scripts/xcode-cloud-control.py index 98d1fbd2..21c7233f 100644 --- a/scripts/xcode-cloud-control.py +++ b/scripts/xcode-cloud-control.py @@ -547,6 +547,71 @@ def command_store_status(args: argparse.Namespace, client: AppStoreConnect) -> N ) +def command_cancel_review(args: argparse.Namespace, client: AppStoreConnect) -> None: + version_query = urllib.parse.urlencode( + { + "filter[platform]": "IOS", + "filter[versionString]": args.version, + "fields[appStoreVersions]": "versionString,appStoreState,build", + "include": "build", + "limit": 10, + } + ) + versions = client.request("GET", f"/v1/apps/{client.app_id}/appStoreVersions?{version_query}").get("data") or [] + if len(versions) != 1: + raise SystemExit(f"Expected exactly one iOS App Store version {args.version}; found {len(versions)}") + version = versions[0] + attributes = version.get("attributes") or {} + linked_build = ((version.get("relationships") or {}).get("build") or {}).get("data") or {} + if attributes.get("appStoreState") != "WAITING_FOR_REVIEW" or linked_build.get("id") != args.current_build_id: + raise SystemExit("App Store version state or linked build changed; refusing to cancel review") + + replacement = client.request( + "GET", f"/v1/builds/{args.replacement_build_id}?fields[builds]=version,processingState" + ).get("data") or {} + prerelease = client.request( + "GET", f"/v1/builds/{args.replacement_build_id}/preReleaseVersion" + ).get("data") or {} + if ( + (replacement.get("attributes") or {}).get("processingState") != "VALID" + or (prerelease.get("attributes") or {}).get("version") != args.version + ): + raise SystemExit("Replacement build is not VALID for the requested marketing version") + + review_query = urllib.parse.urlencode( + { + "filter[platform]": "IOS", + "filter[state]": "WAITING_FOR_REVIEW", + "fields[reviewSubmissions]": "state,appStoreVersionForReview", + "include": "appStoreVersionForReview", + "limit": 50, + } + ) + submissions = client.request("GET", f"/v1/apps/{client.app_id}/reviewSubmissions?{review_query}").get("data") or [] + matching = [ + submission for submission in submissions + if (((submission.get("relationships") or {}).get("appStoreVersionForReview") or {}).get("data") or {}).get("id") == version.get("id") + ] + if len(matching) != 1: + raise SystemExit(f"Expected one waiting review submission for {args.version}; found {len(matching)}") + submission_id = matching[0]["id"] + items = client.request("GET", f"/v1/reviewSubmissions/{submission_id}/items?limit=50").get("data") or [] + if len(items) != 1 or ( + (((items[0].get("relationships") or {}).get("appStoreVersion") or {}).get("data") or {}).get("id") != version.get("id") + ): + raise SystemExit("Review submission contains other items; refusing to cancel it") + + response = client.request( + "PATCH", + f"/v1/reviewSubmissions/{submission_id}", + {"data": {"type": "reviewSubmissions", "id": submission_id, "attributes": {"canceled": True}}}, + ) + print( + f"Canceled review submission {submission_id} for {args.version}; " + f"state={((response.get('data') or {}).get('attributes') or {}).get('state')}" + ) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest="command", required=True) @@ -575,6 +640,11 @@ def build_parser() -> argparse.ArgumentParser: status = sub.add_parser("store-status", help="Inspect an App Store version/build") status.add_argument("--version", required=True) status.add_argument("--build-id", required=True) + + cancel = sub.add_parser("cancel-review", help="Cancel a mismatched iOS review submission") + cancel.add_argument("--version", required=True) + cancel.add_argument("--current-build-id", required=True) + cancel.add_argument("--replacement-build-id", required=True) return parser @@ -596,6 +666,9 @@ def main(argv: list[str] | None = None) -> None: if args.command == "store-status": command_store_status(args, client) return + if args.command == "cancel-review": + command_cancel_review(args, client) + return raise SystemExit(f"Unsupported command: {args.command}") diff --git a/scripts/xcode-cloud-control.test.mjs b/scripts/xcode-cloud-control.test.mjs index 2eb93313..267a98e5 100644 --- a/scripts/xcode-cloud-control.test.mjs +++ b/scripts/xcode-cloud-control.test.mjs @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; describe("Xcode Cloud control", () => { test("exposes start, wait, and VALID-build resolution for store delivery", () => { @@ -62,4 +63,49 @@ describe("Xcode Cloud control", () => { expect(pbxproj).not.toContain("MARKETING_VERSION = 1.74.0;"); expect(pbxproj).toContain(`MARKETING_VERSION = ${marketing};`); }); + + test("cancels only the matching single-item review with a valid replacement build", () => { + const program = ` +import argparse, importlib.util +spec = importlib.util.spec_from_file_location("cloud", "scripts/xcode-cloud-control.py") +cloud = importlib.util.module_from_spec(spec) +spec.loader.exec_module(cloud) +class Client: + app_id = "app" + def __init__(self, linked="old", items=1): + self.linked, self.items, self.patches = linked, items, [] + def request(self, method, path, body=None): + if method == "PATCH": + self.patches.append((path, body)) + return {"data": {"attributes": {"state": "CANCELING"}}} + if "/appStoreVersions?" in path: + return {"data": [{"id": "version", "attributes": {"appStoreState": "WAITING_FOR_REVIEW"}, + "relationships": {"build": {"data": {"id": self.linked}}}}]} + if "/builds/new?" in path: + return {"data": {"attributes": {"processingState": "VALID"}}} + if "/builds/new/preReleaseVersion" in path: + return {"data": {"attributes": {"version": "1.98.0"}}} + if "/reviewSubmissions?" in path: + return {"data": [{"id": "submission", "relationships": { + "appStoreVersionForReview": {"data": {"id": "version"}}}}]} + if "/reviewSubmissions/submission/items" in path: + return {"data": [{"relationships": {"appStoreVersion": {"data": {"id": "version"}}}}] * self.items} + raise AssertionError(path) +args = argparse.Namespace(version="1.98.0", current_build_id="old", replacement_build_id="new") +good = Client() +cloud.command_cancel_review(args, good) +assert len(good.patches) == 1 +assert good.patches[0][1]["data"]["attributes"] == {"canceled": True} +for unsafe in (Client(linked="changed"), Client(items=2)): + try: + cloud.command_cancel_review(args, unsafe) + except SystemExit: + pass + else: + raise AssertionError("Unsafe review was not rejected") + assert unsafe.patches == [] +`; + const result = spawnSync("python3", ["-c", program], { encoding: "utf8" }); + expect(result.status).toBe(0); + }); });