fix(ios): safely cancel review with mismatched build

This commit is contained in:
tianma-if committed 2026-10-02 20:03:22 +08:00
1 parent f4c821c4e4
commit 3923c62ebf
3 files changed
+135

No files matched your search

+16
View File
@@ -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
+73
View File
@@ -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}")
+46
View File
@@ -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);
});
});