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:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: "")
|
||||
|
||||
Reference in New Issue
Block a user