development
rs 244 lines 9.17 KB
Raw
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 uses_non_exempt_encryption: None,
213 },
214 relationships: None,
215 }
216 }
217
218 #[test]
219 fn outcome_reports_valid_build() {
220 let out = outcome(&build("B1", Some("VALID")), false, Duration::from_secs(5));
221 assert_eq!(out.build_id, "B1");
222 assert_eq!(out.version.as_deref(), Some("42"));
223 assert_eq!(out.processing_state.as_deref(), Some("VALID"));
224 assert!(out.valid);
225 assert!(!out.timed_out);
226 assert_eq!(out.waited_secs, 5);
227 }
228
229 #[test]
230 fn outcome_reports_failed_build_as_not_valid() {
231 let out = outcome(&build("B2", Some("INVALID")), false, Duration::from_secs(1));
232 assert!(!out.valid);
233 assert!(!out.timed_out);
234 }
235
236 #[test]
237 fn timed_out_outcome_is_never_valid() {
238 // Even if the last state seen were VALID, a timed-out wait must not
239 // claim success: `valid` is gated on `!timed_out`.
240 let out = outcome(&build("B3", Some("VALID")), true, Duration::from_secs(1800));
241 assert!(out.timed_out);
242 assert!(!out.valid);
243 }
244 }