Add builds await-processing, workflow build tracking, and processing-aware attach

- Add builds await-processing command to poll until a build finishes processing
- Track uploaded build version across workflow steps via lastUploadedBuildVersion
- attach-latest-build now waits for a just-uploaded build to appear and process
- attach-latest-build prompts to wait when the latest build is still processing
- Shared awaitBuildProcessing helper eliminates duplicated polling logic
- Dot-based progress indicator for builds not yet available in the API
- Note on builds list about recently uploaded builds taking time to appear
- Bump version to 0.2.1
This commit is contained in:
Kerem Erkan
2026-02-13 18:46:17 +01:00
parent 62495c48a2
commit 1dfcc1c27b
6 changed files with 200 additions and 6 deletions

View File

@@ -84,6 +84,7 @@ asc-client builds list [--bundle-id <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 <bundle-id> [--build-version X] # Wait for build to finish processing
asc-client run-workflow <file> [--yes] # Run commands from a workflow file
```

View File

@@ -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 <bundle-id>
asc-client builds await-processing <bundle-id> --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

View File

@@ -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]
)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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: "")