feat(apps): add await-processing to poll builds to terminal state

Adds `asc apps await-processing <APP_ID>`, which polls GET /v1/builds?filter[app]={id} until the newest matching build reaches a terminal state (VALID succeeds; INVALID/FAILED fail) or times out. Flags: --version, --pre-release-version, --interval (default 30s), --timeout (default 1800s). The poll loop and pure classify()/AwaitConfig/AwaitOutcome live in the shared frontend crate (await_processing.rs) so the behaviour is testable and reusable; AwaitOutcome serializes without key material. Co-Authored-By: siGit Code v1.6.7-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 11, 2026 at 20:49 UTC a7f3e081202418588e813e7f1ab0718466f7b77f
5 files changed +306
Cargo.lock
+1
@@ -1780,6 +1780,7 @@ dependencies = [
1780 "smbcloud-ascapi-aso",
1781 "smbcloud-ascapi-core",
1782 "smbcloud-ascapi-signing",
1783 + "tokio",
1784 ]
1785
1786 [[package]]
crates/cli/src/main.rs
+57
@@ -16,6 +16,7 @@ use smbcloud_ascapi_aso::build::BuildFilter;
16 use smbcloud_ascapi_aso::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform};
17 use smbcloud_ascapi_aso::prelude::*;
18 use smbcloud_ascapi_core::{ApiKey, Client};
19 +use smbcloud_ascapi_frontend::await_processing::{await_processing, AwaitConfig};
20 use smbcloud_ascapi_frontend::review;
21 use smbcloud_ascapi_frontend::upload::{self, upload_package, validate_app, AltoolAuth, AltoolOp};
22 use smbcloud_ascapi_pricing::app_price::PriceKind;
@@ -30,6 +31,7 @@ use smbcloud_ascapi_signing::profile::{
31 };
32 use std::collections::HashSet;
33 use std::path::PathBuf;
34 +use std::time::Duration;
35
36 /// Add/update App Store Connect app metadata (apps, app infos, app store
37 /// versions, and their localizations) from the terminal or a script.
@@ -575,6 +577,27 @@ enum AppsCommand {
577 #[arg(long)]
578 wait: bool,
579 },
580 + /// Poll `GET /v1/builds?filter[app]={id}` until the newest matching build
581 + /// finishes processing (VALID) or fails (INVALID/FAILED), or the timeout
582 + /// elapses. This is the signal that confirms an upload or a
583 + /// binary-rejection fix worked. Narrow with `--version` /
584 + /// `--pre-release-version` when more than one upload is in flight.
585 + AwaitProcessing {
586 + app_id: String,
587 + /// `filter[version]` — the build (upload) number, e.g. `42`.
588 + #[arg(long)]
589 + version: Option<String>,
590 + /// `filter[preReleaseVersion.version]` — the marketing version the
591 + /// build sits under, e.g. `1.1.0`.
592 + #[arg(long)]
593 + pre_release_version: Option<String>,
594 + /// Seconds between polls.
595 + #[arg(long, default_value_t = 30)]
596 + interval: u64,
597 + /// Give up after this many seconds without a terminal state.
598 + #[arg(long, default_value_t = 30 * 60)]
599 + timeout: u64,
600 + },
601 }
602
603 #[derive(Subcommand)]
@@ -1225,6 +1248,40 @@ async fn run_apps(
1248 }
1249 Ok(())
1250 }
1251 + AppsCommand::AwaitProcessing {
1252 + app_id,
1253 + version,
1254 + pre_release_version,
1255 + interval,
1256 + timeout,
1257 + } => {
1258 + let filter = BuildFilter {
1259 + version,
1260 + pre_release_version,
1261 + processing_state: None,
1262 + };
1263 + let config = AwaitConfig {
1264 + interval: Duration::from_secs(interval),
1265 + timeout: Duration::from_secs(timeout),
1266 + };
1267 + let outcome = await_processing(client, &app_id, &filter, config)
1268 + .await
1269 + .map_err(anyhow::Error::msg)?;
1270 + print_json(&outcome)?;
1271 + if outcome.timed_out {
1272 + anyhow::bail!(
1273 + "timed out after {}s waiting for processing",
1274 + outcome.waited_secs
1275 + );
1276 + }
1277 + if !outcome.valid {
1278 + anyhow::bail!(
1279 + "build finished processing but is not valid (state: {})",
1280 + outcome.processing_state.as_deref().unwrap_or("unknown")
1281 + );
1282 + }
1283 + Ok(())
1284 + }
1285 }
1286 }
1287
crates/frontend/Cargo.toml
+1
@@ -18,3 +18,4 @@ serde_json = { workspace = true }
18 smbcloud-ascapi-aso = { workspace = true }
19 smbcloud-ascapi-core = { workspace = true }
20 smbcloud-ascapi-signing = { workspace = true }
21 +tokio = { workspace = true, features = ["time"] }
crates/frontend/src/await_processing.rs new
+243
@@ -0,0 +1,243 @@
1 +//! Block until App Store Connect finishes processing an uploaded build.
2 +//!
3 +//! `xcrun altool --upload-package` returns as soon as the binary is
4 +//! accepted for delivery, long before App Store Connect finishes
5 +//! processing it. Processing is the step that turns a freshly uploaded
6 +//! build `VALID` (ready to attach to a version) or `INVALID`/`FAILED`
7 +//! (a binary rejection) — the signal that actually confirms an upload
8 +//! or a binary-rejection fix worked. This module polls
9 +//! [`BuildsApi::list_builds`] until a build reaches a terminal state or a
10 +//! timeout elapses.
11 +//!
12 +//! The classification of a `processingState` string is a pure function
13 +//! ([`classify`]) so the terminal-vs-pending decision is unit-tested
14 +//! against the exact strings Apple returns, with the async polling loop
15 +//! kept separate. Like every result type in this crate, [`AwaitOutcome`]
16 +//! carries only identifiers and state — never key or binary material.
17 +
18 +use std::time::Duration;
19 +
20 +use serde::Serialize;
21 +use smbcloud_ascapi_aso::build::{Build, BuildFilter};
22 +use smbcloud_ascapi_aso::prelude::*;
23 +use smbcloud_ascapi_core::Client;
24 +
25 +/// How a build's `processingState` reads: either it's settled (and how), or
26 +/// App Store Connect is still working on it.
27 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28 +pub enum ProcessingClass {
29 + /// `VALID` — processing finished and the build can be attached to a
30 + /// version.
31 + Valid,
32 + /// `INVALID` or `FAILED` — a binary rejection; processing is over but
33 + /// the build is unusable.
34 + Failed,
35 + /// `PROCESSING`, a missing state, or any string Apple adds later: keep
36 + /// polling. Treating the unknown as pending means a new terminal state
37 + /// we don't recognize just times out rather than being reported as
38 + /// success.
39 + Pending,
40 +}
41 +
42 +impl ProcessingClass {
43 + /// Whether this class ends the poll. `Valid` and `Failed` are terminal;
44 + /// `Pending` is not.
45 + pub fn is_terminal(self) -> bool {
46 + !matches!(self, ProcessingClass::Pending)
47 + }
48 +}
49 +
50 +/// Classify a build's `processingState` (case-insensitively, since the field
51 +/// is a fixed Apple enum but we don't want a casing change to read as
52 +/// "still pending" forever).
53 +pub fn classify(processing_state: Option<&str>) -> ProcessingClass {
54 + match processing_state
55 + .map(str::trim)
56 + .unwrap_or("")
57 + .to_ascii_uppercase()
58 + .as_str()
59 + {
60 + "VALID" => ProcessingClass::Valid,
61 + "INVALID" | "FAILED" => ProcessingClass::Failed,
62 + _ => ProcessingClass::Pending,
63 + }
64 +}
65 +
66 +/// How long to wait and how often to re-check, for [`await_processing`].
67 +#[derive(Debug, Clone, Copy)]
68 +pub struct AwaitConfig {
69 + /// Delay between polls.
70 + pub interval: Duration,
71 + /// Give up after this long without a terminal state.
72 + pub timeout: Duration,
73 +}
74 +
75 +impl Default for AwaitConfig {
76 + fn default() -> Self {
77 + // 30s between polls, 30min ceiling: processing usually finishes in a
78 + // few minutes but can stall, and a tighter poll just burns API quota.
79 + Self {
80 + interval: Duration::from_secs(30),
81 + timeout: Duration::from_secs(30 * 60),
82 + }
83 + }
84 +}
85 +
86 +/// The result of waiting on a build, as reported to callers. Identifiers and
87 +/// state only — no key or binary material, per the crate's result-type rule.
88 +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
89 +pub struct AwaitOutcome {
90 + /// The build's App Store Connect id.
91 + pub build_id: String,
92 + /// The build (upload) number, e.g. `"42"`.
93 + pub version: Option<String>,
94 + /// The `processingState` the build ended on, verbatim from Apple.
95 + pub processing_state: Option<String>,
96 + /// Whether that state is `VALID`. `false` means `INVALID`/`FAILED`.
97 + pub valid: bool,
98 + /// Whether the wait timed out before the build settled. When true,
99 + /// `valid` is false and `processing_state` is the last state seen.
100 + pub timed_out: bool,
101 + /// How long the wait took, in seconds.
102 + pub waited_secs: u64,
103 +}
104 +
105 +/// Shape an [`AwaitOutcome`] from the build the poll settled on.
106 +fn outcome(build: &Build, timed_out: bool, waited: Duration) -> AwaitOutcome {
107 + let processing_state = build.attributes.processing_state.clone();
108 + AwaitOutcome {
109 + build_id: build.id.clone(),
110 + version: build.attributes.version.clone(),
111 + valid: !timed_out && classify(processing_state.as_deref()) == ProcessingClass::Valid,
112 + timed_out,
113 + processing_state,
114 + waited_secs: waited.as_secs(),
115 + }
116 +}
117 +
118 +/// Poll [`BuildsApi::list_builds`] for `app_id` narrowed by `filter` until the
119 +/// newest matching build reaches a terminal `processingState`, or `config`'s
120 +/// timeout elapses.
121 +///
122 +/// `list_builds` returns newest-first, so the first element is the build a
123 +/// fresh upload produced; narrow with `filter.version` /
124 +/// `filter.pre_release_version` when more than one upload is in flight.
125 +///
126 +/// Returns `Err` only when the API call fails or no matching build exists at
127 +/// all. A build that stays `PROCESSING` past the timeout is a successful
128 +/// `Ok` with `timed_out: true` — the caller decides whether a timeout is a
129 +/// failure, the same way `valid: false` signals a binary rejection.
130 +pub async fn await_processing(
131 + client: &Client,
132 + app_id: &str,
133 + filter: &BuildFilter,
134 + config: AwaitConfig,
135 +) -> Result<AwaitOutcome, String> {
136 + let start = std::time::Instant::now();
137 + loop {
138 + let builds = client
139 + .list_builds(app_id, filter)
140 + .await
141 + .map_err(|error| error.to_string())?;
142 + let build = builds
143 + .into_iter()
144 + .next()
145 + .ok_or_else(|| format!("no build found for app {app_id} matching the given filter"))?;
146 +
147 + let class = classify(build.attributes.processing_state.as_deref());
148 + if class.is_terminal() {
149 + return Ok(outcome(&build, false, start.elapsed()));
150 + }
151 +
152 + let elapsed = start.elapsed();
153 + if elapsed >= config.timeout {
154 + return Ok(outcome(&build, true, elapsed));
155 + }
156 +
157 + // Don't overshoot the timeout waiting for the next poll.
158 + let remaining = config.timeout - elapsed;
159 + tokio::time::sleep(config.interval.min(remaining)).await;
160 + }
161 +}
162 +
163 +#[cfg(test)]
164 +mod tests {
165 + use super::*;
166 + use smbcloud_ascapi_aso::build::BuildAttributes;
167 + use smbcloud_ascapi_core::jsonapi::Resource;
168 +
169 + #[test]
170 + fn classifies_terminal_and_pending_states() {
171 + // Pin the exact Apple strings: the whole point is to not report an
172 + // unrecognized or still-processing state as success.
173 + assert_eq!(classify(Some("VALID")), ProcessingClass::Valid);
174 + assert_eq!(classify(Some("INVALID")), ProcessingClass::Failed);
175 + assert_eq!(classify(Some("FAILED")), ProcessingClass::Failed);
176 + assert_eq!(classify(Some("PROCESSING")), ProcessingClass::Pending);
177 + }
178 +
179 + #[test]
180 + fn classifies_unknown_and_missing_as_pending() {
181 + // A state we don't recognize (a casing change, a new Apple value, or
182 + // an absent field) must read as pending so it times out rather than
183 + // being mistaken for a terminal result.
184 + assert_eq!(classify(None), ProcessingClass::Pending);
185 + assert_eq!(classify(Some("")), ProcessingClass::Pending);
186 + assert_eq!(classify(Some("SOMETHING_NEW")), ProcessingClass::Pending);
187 + }
188 +
189 + #[test]
190 + fn classify_is_case_and_whitespace_insensitive() {
191 + assert_eq!(classify(Some(" valid ")), ProcessingClass::Valid);
192 + assert_eq!(classify(Some("invalid")), ProcessingClass::Failed);
193 + }
194 +
195 + #[test]
196 + fn only_valid_and_failed_are_terminal() {
197 + assert!(ProcessingClass::Valid.is_terminal());
198 + assert!(ProcessingClass::Failed.is_terminal());
199 + assert!(!ProcessingClass::Pending.is_terminal());
200 + }
201 +
202 + fn build(id: &str, state: Option<&str>) -> Build {
203 + Resource {
204 + id: id.to_string(),
205 + resource_type: "builds".to_string(),
206 + attributes: BuildAttributes {
207 + version: Some("42".to_string()),
208 + uploaded_date: Some("2024-01-02T03:04:05Z".to_string()),
209 + expired: Some(false),
210 + processing_state: state.map(str::to_string),
211 + min_os_version: Some("17.0".to_string()),
212 + },
213 + relationships: None,
214 + }
215 + }
216 +
217 + #[test]
218 + fn outcome_reports_valid_build() {
219 + let out = outcome(&build("B1", Some("VALID")), false, Duration::from_secs(5));
220 + assert_eq!(out.build_id, "B1");
221 + assert_eq!(out.version.as_deref(), Some("42"));
222 + assert_eq!(out.processing_state.as_deref(), Some("VALID"));
223 + assert!(out.valid);
224 + assert!(!out.timed_out);
225 + assert_eq!(out.waited_secs, 5);
226 + }
227 +
228 + #[test]
229 + fn outcome_reports_failed_build_as_not_valid() {
230 + let out = outcome(&build("B2", Some("INVALID")), false, Duration::from_secs(1));
231 + assert!(!out.valid);
232 + assert!(!out.timed_out);
233 + }
234 +
235 + #[test]
236 + fn timed_out_outcome_is_never_valid() {
237 + // Even if the last state seen were VALID, a timed-out wait must not
238 + // claim success: `valid` is gated on `!timed_out`.
239 + let out = outcome(&build("B3", Some("VALID")), true, Duration::from_secs(1800));
240 + assert!(out.timed_out);
241 + assert!(!out.valid);
242 + }
243 +}
crates/frontend/src/lib.rs
+4
@@ -20,6 +20,7 @@
20 //! `profileContent`, and [`profiles::download_profile`] returns the path
21 //! it wrote instead.
22
23 +pub mod await_processing;
24 pub mod certificates;
25 pub mod enums;
26 pub mod env;
@@ -30,6 +31,9 @@ pub mod review;
31 pub mod time;
32 pub mod upload;
33
34 +pub use await_processing::{
35 + await_processing, classify, AwaitConfig, AwaitOutcome, ProcessingClass,
36 +};
37 pub use certificates::{
38 certificate_type_from_str, issue_certificate, CertificateSummary, IssuedCertificate,
39 };