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