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 archive [--workspace X] [--scheme X] [--output X] # Archive Xcode project
|
||||||
asc-client builds upload [file] # Upload build via altool
|
asc-client builds upload [file] # Upload build via altool
|
||||||
asc-client builds validate [file] # Validate 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
|
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
|
# Upload a build to App Store Connect
|
||||||
asc-client builds upload MyApp.ipa
|
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.
|
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 validate --latest --bundle-id com.example.MyApp
|
||||||
builds upload --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
|
# Update localizations and attach the build
|
||||||
apps update-localizations com.example.MyApp --file localizations.json
|
apps update-localizations com.example.MyApp --file localizations.json
|
||||||
apps attach-latest-build com.example.MyApp
|
apps attach-latest-build com.example.MyApp
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ struct ASCClient: AsyncParsableCommand {
|
|||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "asc-client",
|
commandName: "asc-client",
|
||||||
abstract: "A command-line tool for the App Store Connect API.",
|
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]
|
subcommands: [ConfigureCommand.self, AppsCommand.self, BuildsCommand.self, RunWorkflowCommand.self, InstallCompletionsCommand.self]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -316,6 +316,39 @@ struct AppsCommand: AsyncParsableCommand {
|
|||||||
|
|
||||||
let versionString = appVersion.attributes?.versionString ?? "unknown"
|
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(
|
let buildsResponse = try await client.send(
|
||||||
Resources.v1.builds.get(
|
Resources.v1.builds.get(
|
||||||
filterPreReleaseVersionVersion: [versionString],
|
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.")
|
throw ValidationError("No builds found for version \(versionString). Upload a build first via Xcode or Transporter.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let buildNumber = build.attributes?.version ?? "unknown"
|
var latestBuild = build
|
||||||
let uploaded = build.attributes?.uploadedDate.map { formatDate($0) } ?? "—"
|
let buildNumber = latestBuild.attributes?.version ?? "unknown"
|
||||||
let state = build.attributes?.processingState.map { "\($0)" } ?? "—"
|
let state = latestBuild.attributes?.processingState
|
||||||
|
let uploaded = latestBuild.attributes?.uploadedDate.map { formatDate($0) } ?? "—"
|
||||||
|
|
||||||
print("Version: \(versionString)")
|
print("Version: \(versionString)")
|
||||||
print("Build: \(buildNumber) \(state) \(uploaded)")
|
print("Build: \(buildNumber) \(state.map { "\($0)" } ?? "—") \(uploaded)")
|
||||||
print()
|
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 {
|
guard confirm("Attach this build? [y/N] ") else {
|
||||||
print("Cancelled.")
|
print("Cancelled.")
|
||||||
return
|
return
|
||||||
@@ -865,6 +917,66 @@ func findVersion(appID: String, versionString: String?, client: AppStoreConnectC
|
|||||||
return version
|
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.
|
/// Fetches builds for the app matching the given version, prompts the user to pick one, and attaches it.
|
||||||
/// Returns the selected build.
|
/// Returns the selected build.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ struct BuildsCommand: AsyncParsableCommand {
|
|||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "builds",
|
commandName: "builds",
|
||||||
abstract: "Manage 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 {
|
struct List: AsyncParsableCommand {
|
||||||
@@ -49,6 +49,55 @@ struct BuildsCommand: AsyncParsableCommand {
|
|||||||
headers: ["Version", "State", "Uploaded"],
|
headers: ["Version", "State", "Uploaded"],
|
||||||
rows: allBuilds.map { [$0.0, $0.1, $0.2] }
|
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
|
let expandedPath: String
|
||||||
|
var uploadedBuildNumber: String?
|
||||||
if latest {
|
if latest {
|
||||||
let archive = try findLatestArchive(bundleID: bundleID)
|
let archive = try findLatestArchive(bundleID: bundleID)
|
||||||
expandedPath = archive.path
|
expandedPath = archive.path
|
||||||
|
uploadedBuildNumber = archive.buildNumber
|
||||||
print("Found archive: \((archive.path as NSString).lastPathComponent)")
|
print("Found archive: \((archive.path as NSString).lastPathComponent)")
|
||||||
print(" Bundle ID: \(archive.bundleID)")
|
print(" Bundle ID: \(archive.bundleID)")
|
||||||
print(" Version: \(archive.version) (\(archive.buildNumber))")
|
print(" Version: \(archive.version) (\(archive.buildNumber))")
|
||||||
@@ -256,6 +307,10 @@ struct BuildsCommand: AsyncParsableCommand {
|
|||||||
guard confirm("Upload this archive? [y/N] ") else { return }
|
guard confirm("Upload this archive? [y/N] ") else { return }
|
||||||
} else {
|
} else {
|
||||||
expandedPath = try resolveFilePath(file, prompt: "Path to .ipa, .pkg, or .xcarchive file: ")
|
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()
|
let config = try Config.load()
|
||||||
@@ -287,6 +342,10 @@ struct BuildsCommand: AsyncParsableCommand {
|
|||||||
if process.terminationStatus != 0 {
|
if process.terminationStatus != 0 {
|
||||||
throw ExitCode(process.terminationStatus)
|
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)
|
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.
|
/// When true, all interactive confirmation prompts are automatically accepted.
|
||||||
nonisolated(unsafe) var autoConfirm = false
|
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.
|
/// Prints a [y/N] prompt and returns true if the user (or --yes flag) confirms.
|
||||||
func confirm(_ prompt: String) -> Bool {
|
func confirm(_ prompt: String) -> Bool {
|
||||||
print(prompt, terminator: "")
|
print(prompt, terminator: "")
|
||||||
|
|||||||
Reference in New Issue
Block a user