fix(ios): safely cancel review with mismatched build
This commit is contained in:
1 parent
f4c821c4e4
commit
3923c62ebf
3 files changed
+135
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user