Looks like this was a bug that has been resolved in Xcode 16.1.
Post
Replies
Boosts
Views
Activity
Thought I'd share my solution here for anyone experiencing the same issues and have experience with Python. I made some simple scripts that can be used to manually extract out the download URLs of each artifact.
# generate_token.py
import jwt
import time
from datetime import datetime, timedelta
# Define your App Store Connect API credentials
KEY_ID = '________'
ISSUER_ID = '________'
PRIVATE_KEY_PATH = '________.p8'
with open(PRIVATE_KEY_PATH, 'r') as key_file:
private_key = key_file.read()
def generate_token():
headers = {
"alg": "ES256",
"kid": KEY_ID,
"typ": "JWT"
}
payload = {
"iss": ISSUER_ID,
"exp": int(time.time()) + 20 * 60, # Token expiration in 20 minutes
"aud": "appstoreconnect-v1"
}
token = jwt.encode(payload, private_key, algorithm="ES256", headers=headers)
return token
# fetch_workflows.py
import time
import requests
from datetime import datetime, timedelta
def fetch_products(token):
url = f"https://api.appstoreconnect.apple.com/v1/ciProducts"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
response = requests.get(url, headers=headers)
response.raise_for_status()
return response.json().get("data", [])
def fetch_workflows(product_id, token):
url = f"https://api.appstoreconnect.apple.com/v1/ciProducts/{product_id}/workflows"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
response = requests.get(url, headers=headers)
response.raise_for_status()
return response.json().get("data", [])
if __name__ == "__main__":
from generate_token import generate_token
products = fetch_products(generate_token())
print(f"Found {len(products)} products")
for product in products:
print(f"{product['attributes']['name']} ({product['attributes']['productType']}): {product['id']}")
workflows = fetch_workflows(product['id'], generate_token())
print(f"Found {len(workflows)} workflows")
for workflow in workflows:
print(f"{workflow['attributes']['name']}: {workflow['id']}")
# fetch_build_runs.py
import time
import requests
from datetime import datetime, timedelta
# Fetch all build runs for the specified workflow
def fetch_build_runs(workflow_id, token):
url = f"https://api.appstoreconnect.apple.com/v1/ciWorkflows/{workflow_id}/buildRuns"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
params = {
"limit": 200 # Adjust the limit as needed; 200 is the maximum
}
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
return response.json().get("data", [])
if __name__ == "__main__":
import argparse
from generate_token import generate_token
parser = argparse.ArgumentParser(description="Fetch all build runs for a given workflow ID.")
parser.add_argument("workflow_id", help="The workflow ID for which to fetch build runs for.")
args = parser.parse_args()
build_runs = fetch_build_runs(args.workflow_id, generate_token())
print(f"Found {len(build_runs)} runs for workflow {args.workflow_id}")
for build_run in build_runs:
print(f"Build {build_run['attributes']['number']}: {build_run['id']}")
# fetch_build_actions.py
import time
import requests
from datetime import datetime, timedelta
def fetch_build_actions(build_run_id, token):
url = f"https://api.appstoreconnect.apple.com/v1/ciBuildRuns/{build_run_id}/actions"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
response = requests.get(url, headers=headers)
response.raise_for_status()
return response.json().get("data", [])
if __name__ == "__main__":
import argparse
from generate_token import generate_token
parser = argparse.ArgumentParser(description="Fetch all build actions for a given build run ID.")
parser.add_argument("build_run_id", help="The build run ID for which to fetch build actions from")
args = parser.parse_args()
build_actions = fetch_build_actions(args.build_run_id, generate_token())
print(f"Found {len(build_actions)} actions for run {args.build_run_id}")
for build_action in build_actions:
print(f"{build_action['attributes']['name']}: {build_action['id']}")
# fetch_artifacts.py
import time
import requests
from datetime import datetime, timedelta
def fetch_artifacts(build_action_id, token):
url = f"https://api.appstoreconnect.apple.com/v1/ciBuildActions/{build_action_id}/artifacts"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
response = requests.get(url, headers=headers)
response.raise_for_status()
return response.json().get("data", [])
if __name__ == "__main__":
import argparse
from generate_token import generate_token
parser = argparse.ArgumentParser(description="Fetch all artifacts for a given build action ID.")
parser.add_argument("build_action_id", help="The build action ID for which to fetch artifacts.")
args = parser.parse_args()
artifacts = fetch_artifacts(args.build_action_id, generate_token())
print(f"Found {len(artifacts)} artifacts for action {args.build_action_id}")
for artifact in artifacts:
print(f"{artifact['attributes']['fileName']}: {artifact['attributes']['downloadUrl']}")
Note that you will need to pip install cryptography pyjwt requests.
Encountering the same issues here. First time using XCode Cloud yesterday, not a great first experience.
I was also struggling with this issue - draggable views within a popover could not be dropped outside of it. The popover must be dismissed before drop destinations in the presenting view can be detected.
As the OP pointed out, it is possible to detect when a drag gesture starts, but a better workaround I found is using DropDelegate. The basic idea is to attach a custom delegate to the popover that detects when the dragged view exits its bounds, allowing us to control when we want to dismiss it.
/// A work-around thst allows `.draggable` views presented within a `.popover` modal
/// to be dropped outside of the popover.
/// ```swift
/// NavigationStack {
/// Text("Drop Zone")
/// .dropDestination(for: String.self) { items, location in
/// true
/// } isTargeted: {
/// print("isTargeted \($0)")
/// }
/// .toolbar {
/// ToolbarItem(placement: .topBarTrailing) {
/// Button(“Press Me”) {
/// isPresented = true
/// }
/// .popover(isPresented: $isPresented) {
/// NavigationStack {
/// ZStack {
/// Color.green.ignoresSafeArea()
/// Color.red.frame(width: 50, height: 50).draggable("Payload")
/// }
/// .navigationTitle("Modal")
/// }
/// .frame(minWidth: 300, minHeight: 500)
/// .onDrop(of: [.plainText], delegate: ExitDropDelegate(onDropExit: { isPresented = false }))
/// }
/// }
/// }
/// }
/// ```
/// In this example, the drop zone in the outer `NavigationStack` does not detect the draggable
/// view hovering over it, due to the popover being modally presented. The `ExitDropDelegate`
/// dismisses the popover when the view is dragged outside of its bounds, allowing the drop zone to
/// be targeted.
struct ExitDropDelegate: DropDelegate {
var onDropExit: (() -> Void)? = nil
func performDrop(info: DropInfo) -> Bool { false }
func dropUpdated(info: DropInfo) -> DropProposal? {
/// Hides the green plus button
.init(operation: .move)
}
func dropExited(info: DropInfo) { onDropExit?() }
}
This approach can be extended with more complex logic, for example if we want to dismiss the popover only after the user hovers the view outside of the popover for a certain period of time, then we can add a timer to the DropDelegate that starts when the dropExited is called, and invalidated when dropEntered is called before the timer fires.