diff --git a/CLAUDE.md b/CLAUDE.md index f194093..626a919 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,7 @@ asc-client builds list [--bundle-id ] # List builds asc-client builds archive [--workspace X] [--scheme X] [--output X] # Archive Xcode project asc-client builds upload [file] # Upload build via altool asc-client builds validate [file] # Validate build via altool +asc-client builds await-processing [--build-version X] # Wait for build to finish processing asc-client run-workflow [--yes] # Run commands from a workflow file ``` diff --git a/README.md b/README.md index 8b37177..abaebcd 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,10 @@ asc-client builds validate MyApp.ipa # Upload a build to App Store Connect asc-client builds upload MyApp.ipa + +# Wait for a build to finish processing +asc-client builds await-processing +asc-client builds await-processing --build-version 903 ``` The `archive` command auto-detects the `.xcworkspace` or `.xcodeproj` in the current directory and resolves the scheme if only one exists. It accepts `.ipa`, `.pkg`, or `.xcarchive` files for `upload` and `validate`. When given an `.xcarchive`, it automatically exports to `.ipa` before uploading. @@ -295,6 +299,9 @@ builds archive --scheme MyApp builds validate --latest --bundle-id com.example.MyApp builds upload --latest --bundle-id com.example.MyApp +# Wait for the build to finish processing +builds await-processing com.example.MyApp + # Update localizations and attach the build apps update-localizations com.example.MyApp --file localizations.json apps attach-latest-build com.example.MyApp diff --git a/Sources/asc-client/ASCClient.swift b/Sources/asc-client/ASCClient.swift index e44cf59..9b330d8 100644 --- a/Sources/asc-client/ASCClient.swift +++ b/Sources/asc-client/ASCClient.swift @@ -5,7 +5,7 @@ struct ASCClient: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "asc-client", abstract: "A command-line tool for the App Store Connect API.", - version: "0.2.0", + version: "0.2.1", subcommands: [ConfigureCommand.self, AppsCommand.self, BuildsCommand.self, RunWorkflowCommand.self, InstallCompletionsCommand.self] ) diff --git a/Sources/asc-client/Commands/AppsCommand.swift b/Sources/asc-client/Commands/AppsCommand.swift index 1af18eb..e745e59 100644 --- a/Sources/asc-client/Commands/AppsCommand.swift +++ b/Sources/asc-client/Commands/AppsCommand.swift @@ -316,6 +316,39 @@ struct AppsCommand: AsyncParsableCommand { let versionString = appVersion.attributes?.versionString ?? "unknown" + // If a build was just uploaded in this workflow, wait for it to appear and process + if let pendingBuild = lastUploadedBuildVersion { + print("Waiting for uploaded build \(pendingBuild) to become available...") + print() + let awaitedBuild = try await awaitBuildProcessing( + appID: app.id, + buildVersion: pendingBuild, + client: client + ) + let uploaded = awaitedBuild.attributes?.uploadedDate.map { formatDate($0) } ?? "—" + print() + print("Version: \(versionString)") + print("Build: \(pendingBuild) VALID \(uploaded)") + print() + + guard confirm("Attach this build? [y/N] ") else { + print("Cancelled.") + return + } + + try await client.send( + Resources.v1.appStoreVersions.id(appVersion.id).relationships.build.patch( + AppStoreVersionBuildLinkageRequest( + data: .init(id: awaitedBuild.id) + ) + ) + ) + + print() + print("Attached build \(pendingBuild) (uploaded \(uploaded)) to version \(versionString).") + return + } + let buildsResponse = try await client.send( Resources.v1.builds.get( filterPreReleaseVersionVersion: [versionString], @@ -329,14 +362,33 @@ struct AppsCommand: AsyncParsableCommand { throw ValidationError("No builds found for version \(versionString). Upload a build first via Xcode or Transporter.") } - let buildNumber = build.attributes?.version ?? "unknown" - let uploaded = build.attributes?.uploadedDate.map { formatDate($0) } ?? "—" - let state = build.attributes?.processingState.map { "\($0)" } ?? "—" + var latestBuild = build + let buildNumber = latestBuild.attributes?.version ?? "unknown" + let state = latestBuild.attributes?.processingState + let uploaded = latestBuild.attributes?.uploadedDate.map { formatDate($0) } ?? "—" print("Version: \(versionString)") - print("Build: \(buildNumber) \(state) \(uploaded)") + print("Build: \(buildNumber) \(state.map { "\($0)" } ?? "—") \(uploaded)") print() + if state == .processing { + if confirm("Build \(buildNumber) is still processing. Wait for it to finish? [y/N] ") { + print() + latestBuild = try await awaitBuildProcessing( + appID: app.id, + buildVersion: buildNumber, + client: client + ) + print() + } else { + print("Cancelled.") + return + } + } else if state == .failed || state == .invalid { + print("Build \(buildNumber) has state \(state.map { "\($0)" } ?? "—") and cannot be attached.") + throw ExitCode.failure + } + guard confirm("Attach this build? [y/N] ") else { print("Cancelled.") return @@ -865,6 +917,66 @@ func findVersion(appID: String, versionString: String?, client: AppStoreConnectC return version } +/// Polls until a build finishes processing. Returns the final build. +/// Throws on timeout or if the build ends in a non-valid state. +func awaitBuildProcessing( + appID: String, + buildVersion: String?, + client: AppStoreConnectClient, + interval: Int = 30, + timeout: Int = 30 +) async throws -> Build { + let deadline = Date().addingTimeInterval(Double(timeout * 60)) + var waitingElapsed = 0 + var waitingStarted = false + + while Date() < deadline { + let request = Resources.v1.builds.get( + filterVersion: buildVersion.map { [$0] }, + filterApp: [appID], + sort: [.minusUploadedDate], + limit: 1 + ) + let response = try await client.send(request) + + if let build = response.data.first, + let state = build.attributes?.processingState { + let version = build.attributes?.version ?? "?" + + // End the "not found" line if we were waiting + if waitingStarted { + print() + waitingStarted = false + } + + switch state { + case .valid: + print("Build \(version) is ready (VALID).") + return build + case .failed, .invalid: + print("Build \(version) processing ended with state: \(state)") + throw ExitCode.failure + case .processing: + print("Build \(version): still processing...") + } + } else { + waitingElapsed += interval + if !waitingStarted { + print("Build not found yet", terminator: "") + waitingStarted = true + } + print("...\(waitingElapsed)s", terminator: "") + fflush(stdout) + } + + try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) + } + + if waitingStarted { print() } + print("\nTimed out after \(timeout) minutes.") + throw ExitCode.failure +} + /// Fetches builds for the app matching the given version, prompts the user to pick one, and attaches it. /// Returns the selected build. @discardableResult diff --git a/Sources/asc-client/Commands/BuildsCommand.swift b/Sources/asc-client/Commands/BuildsCommand.swift index 4213f63..803a9d5 100644 --- a/Sources/asc-client/Commands/BuildsCommand.swift +++ b/Sources/asc-client/Commands/BuildsCommand.swift @@ -7,7 +7,7 @@ struct BuildsCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "builds", abstract: "Manage builds.", - subcommands: [List.self, Archive.self, Upload.self, Validate.self] + subcommands: [List.self, AwaitProcessing.self, Archive.self, Upload.self, Validate.self] ) struct List: AsyncParsableCommand { @@ -49,6 +49,55 @@ struct BuildsCommand: AsyncParsableCommand { headers: ["Version", "State", "Uploaded"], rows: allBuilds.map { [$0.0, $0.1, $0.2] } ) + + print() + print("Note: Recently uploaded builds may take a few minutes to appear.") + } + } + + struct AwaitProcessing: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "await-processing", + abstract: "Wait for a build to finish processing." + ) + + @Argument(help: "The app's bundle identifier.") + var bundleID: String + + @Option(name: .long, help: "Build version number to wait for (e.g. 903). If omitted, waits for the latest build.") + var buildVersion: String? + + @Option(name: .long, help: "Polling interval in seconds (default: 30).") + var interval: Int = 30 + + @Option(name: .long, help: "Timeout in minutes (default: 30).") + var timeout: Int = 30 + + func run() async throws { + let client = try ClientFactory.makeClient() + let app = try await findApp(bundleID: bundleID, client: client) + + let effectiveVersion = buildVersion ?? lastUploadedBuildVersion + let label: String + if let v = effectiveVersion { + label = "build \(v)" + if buildVersion == nil { + print("Using build version \(v) from previous upload step.") + } + } else { + label = "latest build" + } + print("Waiting for \(label) to finish processing...") + print(" Polling every \(interval)s, timeout \(timeout)m") + print() + + _ = try await awaitBuildProcessing( + appID: app.id, + buildVersion: effectiveVersion, + client: client, + interval: interval, + timeout: timeout + ) } } @@ -245,9 +294,11 @@ struct BuildsCommand: AsyncParsableCommand { } let expandedPath: String + var uploadedBuildNumber: String? if latest { let archive = try findLatestArchive(bundleID: bundleID) expandedPath = archive.path + uploadedBuildNumber = archive.buildNumber print("Found archive: \((archive.path as NSString).lastPathComponent)") print(" Bundle ID: \(archive.bundleID)") print(" Version: \(archive.version) (\(archive.buildNumber))") @@ -256,6 +307,10 @@ struct BuildsCommand: AsyncParsableCommand { guard confirm("Upload this archive? [y/N] ") else { return } } else { expandedPath = try resolveFilePath(file, prompt: "Path to .ipa, .pkg, or .xcarchive file: ") + // Try to extract build number from .xcarchive + if expandedPath.hasSuffix(".xcarchive") { + uploadedBuildNumber = buildNumberFromArchive(expandedPath) + } } let config = try Config.load() @@ -287,6 +342,10 @@ struct BuildsCommand: AsyncParsableCommand { if process.terminationStatus != 0 { throw ExitCode(process.terminationStatus) } + + if let buildNumber = uploadedBuildNumber { + lastUploadedBuildVersion = buildNumber + } } } @@ -564,3 +623,14 @@ private func resolveUploadable(_ path: String) throws -> (String, String?) { return (ipaPath, tempDir) } + +/// Extracts CFBundleVersion from an .xcarchive's Info.plist, or nil if unavailable. +private func buildNumberFromArchive(_ archivePath: String) -> String? { + let plistPath = (archivePath as NSString).appendingPathComponent("Info.plist") + guard let data = FileManager.default.contents(atPath: plistPath), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let appProps = plist["ApplicationProperties"] as? [String: Any], + let buildNumber = appProps["CFBundleVersion"] as? String + else { return nil } + return buildNumber +} diff --git a/Sources/asc-client/Formatting.swift b/Sources/asc-client/Formatting.swift index 544ab6d..8fc0ed7 100644 --- a/Sources/asc-client/Formatting.swift +++ b/Sources/asc-client/Formatting.swift @@ -3,6 +3,10 @@ import Foundation /// When true, all interactive confirmation prompts are automatically accepted. nonisolated(unsafe) var autoConfirm = false +/// Set by `builds upload` after a successful upload so subsequent workflow steps +/// (e.g. `await-processing`, `attach-latest-build`) can wait for this specific build. +nonisolated(unsafe) var lastUploadedBuildVersion: String? + /// Prints a [y/N] prompt and returns true if the user (or --yes flag) confirms. func confirm(_ prompt: String) -> Bool { print(prompt, terminator: "")