Files
edgeever/scripts/xcode-cloud-control.py
T
tianma-if ac142b2af5 fix(release): print Xcode Cloud action issues when a run fails
Cloud archive #50 failed without an issue summary. Dump action issues and
any attached App Store Connect builds so the next retry can be diagnosed.
2026-09-20 22:42:31 +08:00

591 lines
21 KiB
Python

#!/usr/bin/env python3
"""Start, monitor, and resolve Xcode Cloud App Store builds."""
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_APP_ID = "6792625631"
API_ROOT = "https://api.appstoreconnect.apple.com"
def emit(key: str, value) -> None:
if value is None or value == "":
return
print(f"{key}={value}", flush=True)
def load_jwt():
try:
import jwt # type: ignore
return jwt
except ImportError:
import subprocess
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "PyJWT", "cryptography", "-q"],
stdout=subprocess.DEVNULL,
)
import jwt # type: ignore
return jwt
class AppStoreConnect:
def __init__(self) -> None:
self.app_id = os.environ.get("ASC_APP_ID", DEFAULT_APP_ID).strip()
self.issuer = os.environ["ASC_API_ISSUER"]
self.key_id = os.environ["ASC_API_KEY_ID"]
raw_key = os.environ.get("ASC_API_KEY_BASE64", "").strip()
if not raw_key:
raise SystemExit("ASC_API_KEY_BASE64 is required")
self.private_key = base64.b64decode(raw_key).decode()
self.jwt = load_jwt()
self.token = None
self.token_exp = 0
def ensure_token(self) -> str:
now = int(time.time())
if self.token and now < self.token_exp - 60:
return self.token
self.token_exp = now + 20 * 60
encoded = self.jwt.encode(
{
"iss": self.issuer,
"iat": now,
"exp": self.token_exp,
"aud": "appstoreconnect-v1",
},
self.private_key,
algorithm="ES256",
headers={"kid": self.key_id, "typ": "JWT"},
)
self.token = encoded.decode() if isinstance(encoded, bytes) else encoded
return self.token
def invalidate_token(self) -> None:
self.token = None
self.token_exp = 0
def request(self, method: str, path_or_url: str, body=None, attempts: int = 5):
url = (
path_or_url
if path_or_url.startswith("http")
else API_ROOT + path_or_url
)
last_error = None
for attempt in range(1, attempts + 1):
request = urllib.request.Request(
url,
data=None if body is None else json.dumps(body).encode(),
method=method,
)
request.add_header("Authorization", f"Bearer {self.ensure_token()}")
if body is not None:
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request) as response:
payload = response.read().decode()
return json.loads(payload) if payload else {}
except urllib.error.HTTPError as error:
details = error.read().decode()
last_error = (
f"App Store Connect API {method} {path_or_url} "
f"failed ({error.code}): {details}"
)
if error.code in (401, 429, 500, 502, 503, 504) and attempt < attempts:
if error.code == 401:
self.invalidate_token()
print(
f"{last_error} retrying {attempt}/{attempts - 1}",
flush=True,
)
time.sleep(min(30, 2 ** attempt))
continue
raise SystemExit(last_error)
raise SystemExit(last_error or f"App Store Connect API {method} {path_or_url} failed")
def collect(self, path: str) -> list:
items = []
next_path = path
while next_path:
payload = self.request("GET", next_path)
items.extend(payload.get("data") or [])
next_path = (payload.get("links") or {}).get("next")
return items
def source_sha(run: dict) -> str:
attributes = run.get("attributes") or {}
commit = attributes.get("sourceCommit") or {}
if isinstance(commit, dict):
return str(commit.get("commitSha") or "")
return ""
def select_archive_workflow(client: AppStoreConnect, workflow_id: str = "") -> dict:
product = client.request("GET", f"/v1/apps/{client.app_id}/ciProduct").get("data")
if not product:
raise SystemExit("No Xcode Cloud product found for EdgeEver")
workflows = client.collect(
f"/v1/ciProducts/{product['id']}/workflows?"
+ urllib.parse.urlencode(
{"limit": 50, "fields[ciWorkflows]": "name,isEnabled,lastModifiedDate"}
)
)
if not workflows:
raise SystemExit("No Xcode Cloud workflows found for EdgeEver")
if workflow_id:
matches = [workflow for workflow in workflows if workflow["id"] == workflow_id]
else:
matches = [
workflow
for workflow in workflows
if (workflow.get("attributes") or {}).get("isEnabled")
and re.search(
r"archive|app\s*store|release",
str((workflow.get("attributes") or {}).get("name", "")),
re.IGNORECASE,
)
]
if len(matches) != 1:
listing = "\n".join(
f"- id={workflow['id']} name={(workflow.get('attributes') or {}).get('name')} "
f"enabled={(workflow.get('attributes') or {}).get('isEnabled')}"
for workflow in workflows
)
raise SystemExit(
f"Expected exactly one matching enabled Archive workflow, found {len(matches)}.\n"
f"{listing}"
)
return matches[0]
def list_git_references(client: AppStoreConnect, workflow_id: str) -> list:
repository = client.request(
"GET",
f"/v1/ciWorkflows/{workflow_id}/repository",
).get("data")
if not repository:
raise SystemExit(f"Xcode Cloud workflow {workflow_id} has no repository")
return client.collect(
f"/v1/scmRepositories/{repository['id']}/gitReferences?limit=200"
)
def canonical_git_ref(value: str) -> str:
value = value.strip()
if value.startswith("refs/"):
return value
if re.fullmatch(r"v\d+\.\d+\.\d+", value):
return f"refs/tags/{value}"
return f"refs/heads/{value}"
def find_git_reference(references: list, git_ref: str) -> dict | None:
wanted = canonical_git_ref(git_ref)
for reference in references:
attributes = reference.get("attributes") or {}
if attributes.get("isDeleted"):
continue
canonical = str(attributes.get("canonicalName") or "")
name = str(attributes.get("name") or "")
if canonical == wanted or name == git_ref or f"refs/tags/{name}" == wanted:
return reference
if f"refs/heads/{name}" == wanted:
return reference
return None
def resolve_git_reference(
client: AppStoreConnect,
workflow_id: str,
git_ref: str,
retries: int = 8,
) -> dict:
last_names = []
for attempt in range(1, retries + 1):
references = list_git_references(client, workflow_id)
last_names = [
str((item.get("attributes") or {}).get("canonicalName") or "")
for item in references
]
match = find_git_reference(references, git_ref)
if match:
return match
if attempt < retries:
print(
f"Xcode Cloud does not yet list {git_ref}; retrying {attempt}/{retries - 1}",
flush=True,
)
time.sleep(min(30, 5 * attempt))
sample = ", ".join(name for name in last_names[:12] if name)
raise SystemExit(
f"Xcode Cloud git reference {git_ref} was not found. Known refs: {sample}"
)
def start_build(client: AppStoreConnect, workflow_id: str, git_reference_id: str | None) -> dict:
relationships = {
"workflow": {"data": {"type": "ciWorkflows", "id": workflow_id}},
}
if git_reference_id:
relationships["sourceBranchOrTag"] = {
"data": {"type": "scmGitReferences", "id": git_reference_id}
}
result = client.request(
"POST",
"/v1/ciBuildRuns",
{
"data": {
"type": "ciBuildRuns",
"attributes": {},
"relationships": relationships,
}
},
)
build = result.get("data")
if not build:
raise SystemExit(f"Xcode Cloud did not return a build run: {result}")
return build
def read_build_run(client: AppStoreConnect, build_run_id: str) -> dict:
return client.request(
"GET",
f"/v1/ciBuildRuns/{build_run_id}?"
+ urllib.parse.urlencode(
{
"fields[ciBuildRuns]": (
"number,executionProgress,completionStatus,sourceCommit,"
"isPullRequestBuild,startReason"
)
}
),
)["data"]
def wait_for_cloud_build(
client: AppStoreConnect,
build_run_id: str,
require_sha: str,
timeout_seconds: int,
) -> dict:
deadline = time.time() + timeout_seconds
sha_checked = False
while True:
run = read_build_run(client, build_run_id)
attributes = run.get("attributes") or {}
progress = attributes.get("executionProgress")
completion = attributes.get("completionStatus")
sha = source_sha(run)
print(
f"Xcode Cloud build #{attributes.get('number')} "
f"progress={progress} completion={completion} sha={sha or 'pending'}",
flush=True,
)
if require_sha and sha:
if sha != require_sha:
raise SystemExit(
f"Xcode Cloud source commit {sha} does not match required {require_sha}."
)
sha_checked = True
if progress == "COMPLETE":
if require_sha and not sha_checked:
raise SystemExit(
f"Xcode Cloud build finished without a source commit; required {require_sha}."
)
if completion != "SUCCEEDED":
describe_failed_run(client, build_run_id)
raise SystemExit(f"Xcode Cloud build completed with {completion}")
return run
if time.time() >= deadline:
raise SystemExit("Timed out waiting for Xcode Cloud build")
time.sleep(30)
def describe_failed_run(client: AppStoreConnect, build_run_id: str) -> None:
actions = client.request(
"GET",
f"/v1/ciBuildRuns/{build_run_id}/actions?limit=200&"
"fields[ciBuildActions]=name,actionType,executionProgress,completionStatus,issueCounts",
)
for action in actions.get("data", []):
action_attributes = action.get("attributes") or {}
print(
f"- action={action_attributes.get('name')} "
f"type={action_attributes.get('actionType')} "
f"progress={action_attributes.get('executionProgress')} "
f"completion={action_attributes.get('completionStatus')} "
f"issues={action_attributes.get('issueCounts')}",
flush=True,
)
action_id = action.get("id")
if not action_id:
continue
issue_payload = client.request(
"GET",
f"/v1/ciBuildActions/{action_id}/issues?limit=50",
)
for issue in issue_payload.get("data") or []:
issue_attributes = issue.get("attributes") or {}
print(
f" issue type={issue_attributes.get('issueType')} "
f"category={issue_attributes.get('category')} "
f"message={issue_attributes.get('message')}",
flush=True,
)
def wait_for_app_store_build(
client: AppStoreConnect,
build_run_id: str,
timeout_seconds: int,
) -> dict:
deadline = time.time() + timeout_seconds
while True:
payload = client.request(
"GET",
f"/v1/ciBuildRuns/{build_run_id}/builds?limit=50&"
"fields[builds]=version,processingState,uploadedDate",
)
builds = payload.get("data") or []
if builds:
build = builds[0]
attributes = build.get("attributes") or {}
state = attributes.get("processingState")
print(
f"App Store build id={build.get('id')} version={attributes.get('version')} "
f"processing={state} uploaded={attributes.get('uploadedDate')}",
flush=True,
)
if state == "VALID":
return build
if state in {"INVALID", "FAILED"}:
raise SystemExit(
f"App Store Connect rejected build {attributes.get('version')} ({state})."
)
else:
print(
"App Store Connect has not attached a build to the Xcode Cloud run yet.",
flush=True,
)
if time.time() >= deadline:
raise SystemExit(
"Timed out waiting for a VALID App Store Connect build. "
"Confirm Xcode Cloud shared ASC API secrets so ci_post_xcodebuild can upload the IPA."
)
time.sleep(30)
def print_run_outputs(run: dict, store_build: dict | None = None) -> None:
attributes = run.get("attributes") or {}
emit("build_run_id", run.get("id"))
emit("cloud_number", attributes.get("number"))
emit("source_sha", source_sha(run))
if store_build:
store_attributes = store_build.get("attributes") or {}
emit("app_store_build_id", store_build.get("id"))
emit("build_number", store_attributes.get("version"))
emit("processing_state", store_attributes.get("processingState"))
def command_inspect(client: AppStoreConnect) -> None:
product = client.request("GET", f"/v1/apps/{client.app_id}/ciProduct").get("data")
if not product:
raise SystemExit("No Xcode Cloud product found for EdgeEver")
workflows = client.collect(
f"/v1/ciProducts/{product['id']}/workflows?"
+ urllib.parse.urlencode(
{"limit": 50, "fields[ciWorkflows]": "name,isEnabled,lastModifiedDate"}
)
)
print("Available Xcode Cloud workflows:")
for workflow in workflows:
attributes = workflow.get("attributes") or {}
print(
f"- id={workflow['id']} name={attributes.get('name')} "
f"enabled={attributes.get('isEnabled')} modified={attributes.get('lastModifiedDate')}"
)
def command_start(args: argparse.Namespace, client: AppStoreConnect) -> None:
workflow = select_archive_workflow(client, args.workflow_id)
workflow_id = workflow["id"]
emit("workflow_id", workflow_id)
emit("workflow_name", (workflow.get("attributes") or {}).get("name"))
git_reference_id = None
if args.git_ref:
reference = resolve_git_reference(client, workflow_id, args.git_ref)
git_reference_id = reference["id"]
emit(
"git_ref",
(reference.get("attributes") or {}).get("canonicalName"),
)
run = start_build(client, workflow_id, git_reference_id)
attributes = run.get("attributes") or {}
print(
f"Started Xcode Cloud build id={run['id']} number={attributes.get('number')} "
f"status={attributes.get('executionProgress')}",
flush=True,
)
emit("build_run_id", run["id"])
if args.wait or args.wait_valid:
run = wait_for_cloud_build(
client,
run["id"],
args.require_sha,
args.timeout_seconds,
)
store_build = None
if args.wait_valid:
store_build = wait_for_app_store_build(
client,
run["id"],
args.valid_timeout_seconds,
)
print_run_outputs(run, store_build)
def command_describe(args: argparse.Namespace, client: AppStoreConnect) -> None:
run = read_build_run(client, args.build_run_id)
attributes = run.get("attributes") or {}
print(
f"Xcode Cloud build id={run.get('id')} number={attributes.get('number')} "
f"progress={attributes.get('executionProgress')} "
f"completion={attributes.get('completionStatus')} "
f"sha={source_sha(run) or 'pending'}",
flush=True,
)
describe_failed_run(client, args.build_run_id)
builds = client.request(
"GET",
f"/v1/ciBuildRuns/{args.build_run_id}/builds?limit=50&"
"fields[builds]=version,processingState,uploadedDate",
).get("data") or []
if not builds:
print("No App Store Connect builds are attached to this Cloud run.", flush=True)
return
for build in builds:
build_attributes = build.get("attributes") or {}
print(
f"App Store build id={build.get('id')} version={build_attributes.get('version')} "
f"processing={build_attributes.get('processingState')} "
f"uploaded={build_attributes.get('uploadedDate')}",
flush=True,
)
def command_wait(args: argparse.Namespace, client: AppStoreConnect) -> None:
run = wait_for_cloud_build(
client,
args.build_run_id,
args.require_sha,
args.timeout_seconds,
)
store_build = None
if args.wait_valid:
store_build = wait_for_app_store_build(
client,
args.build_run_id,
args.valid_timeout_seconds,
)
print_run_outputs(run, store_build)
def command_store_status(args: argparse.Namespace, client: AppStoreConnect) -> None:
if not args.version or not args.build_id:
raise SystemExit("store-status requires --version and --build-id")
version_query = urllib.parse.urlencode(
{
"filter[platform]": "IOS",
"filter[versionString]": args.version,
"fields[appStoreVersions]": "platform,versionString,appStoreState,releaseType,build",
"limit": 10,
}
)
versions = client.request("GET", f"/v1/apps/{client.app_id}/appStoreVersions?{version_query}")
print(f"App Store version records for {args.version}: {len(versions.get('data', []))}")
for version in versions.get("data", []):
attributes = version.get("attributes") or {}
print(
f"- version id={version.get('id')} state={attributes.get('appStoreState')} "
f"releaseType={attributes.get('releaseType')}"
)
build = client.request(
"GET",
f"/v1/builds/{args.build_id}?fields[builds]=version,processingState,uploadedDate",
)["data"]
attributes = build.get("attributes") or {}
print(
f"Requested build id={build.get('id')} version={attributes.get('version')} "
f"processing={attributes.get('processingState')} "
f"uploaded={attributes.get('uploadedDate')}"
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("inspect", help="List Xcode Cloud workflows")
start = sub.add_parser("start", help="Start the Archive workflow")
start.add_argument("--workflow-id", default="")
start.add_argument("--git-ref", default="", help="Tag, branch, or refs/* name")
start.add_argument("--require-sha", default="")
start.add_argument("--wait", action="store_true")
start.add_argument("--wait-valid", action="store_true")
start.add_argument("--timeout-seconds", type=int, default=85 * 60)
start.add_argument("--valid-timeout-seconds", type=int, default=25 * 60)
describe = sub.add_parser("describe", help="Dump a Cloud run's actions, issues, and ASC builds")
describe.add_argument("--build-run-id", required=True)
wait = sub.add_parser("wait", help="Monitor an existing Xcode Cloud build")
wait.add_argument("--build-run-id", required=True)
wait.add_argument("--require-sha", default="")
wait.add_argument("--wait-valid", action="store_true")
wait.add_argument("--timeout-seconds", type=int, default=85 * 60)
wait.add_argument("--valid-timeout-seconds", type=int, default=25 * 60)
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)
return parser
def main(argv: list[str] | None = None) -> None:
args = build_parser().parse_args(argv)
client = AppStoreConnect()
if args.command == "inspect":
command_inspect(client)
return
if args.command == "start":
command_start(args, client)
return
if args.command == "describe":
command_describe(args, client)
return
if args.command == "wait":
command_wait(args, client)
return
if args.command == "store-status":
command_store_status(args, client)
return
raise SystemExit(f"Unsupported command: {args.command}")
if __name__ == "__main__":
main()