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