Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 57 additions & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use smbcloud_ascapi_aso::build::BuildFilter;
use smbcloud_ascapi_aso::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform};
use smbcloud_ascapi_aso::prelude::*;
use smbcloud_ascapi_core::{ApiKey, Client};
use smbcloud_ascapi_frontend::await_processing::{await_processing, AwaitConfig};
use smbcloud_ascapi_frontend::review;
use smbcloud_ascapi_frontend::upload::{self, upload_package, validate_app, AltoolAuth, AltoolOp};
use smbcloud_ascapi_pricing::app_price::PriceKind;
Expand All @@ -30,6 +31,7 @@ use smbcloud_ascapi_signing::profile::{
};
use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Duration;

/// Add/update App Store Connect app metadata (apps, app infos, app store
/// versions, and their localizations) from the terminal or a script.
Expand Down Expand Up @@ -575,6 +577,27 @@ enum AppsCommand {
#[arg(long)]
wait: bool,
},
/// Poll `GET /v1/builds?filter[app]={id}` until the newest matching build
/// finishes processing (VALID) or fails (INVALID/FAILED), or the timeout
/// elapses. This is the signal that confirms an upload or a
/// binary-rejection fix worked. Narrow with `--version` /
/// `--pre-release-version` when more than one upload is in flight.
AwaitProcessing {
app_id: String,
/// `filter[version]` — the build (upload) number, e.g. `42`.
#[arg(long)]
version: Option<String>,
/// `filter[preReleaseVersion.version]` — the marketing version the
/// build sits under, e.g. `1.1.0`.
#[arg(long)]
pre_release_version: Option<String>,
/// Seconds between polls.
#[arg(long, default_value_t = 30)]
interval: u64,
/// Give up after this many seconds without a terminal state.
#[arg(long, default_value_t = 30 * 60)]
timeout: u64,
},
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -1225,6 +1248,40 @@ async fn run_apps(
}
Ok(())
}
AppsCommand::AwaitProcessing {
app_id,
version,
pre_release_version,
interval,
timeout,
} => {
let filter = BuildFilter {
version,
pre_release_version,
processing_state: None,
};
let config = AwaitConfig {
interval: Duration::from_secs(interval),
timeout: Duration::from_secs(timeout),
};
let outcome = await_processing(client, &app_id, &filter, config)
.await
.map_err(anyhow::Error::msg)?;
print_json(&outcome)?;
if outcome.timed_out {
anyhow::bail!(
"timed out after {}s waiting for processing",
outcome.waited_secs
);
}
if !outcome.valid {
anyhow::bail!(
"build finished processing but is not valid (state: {})",
outcome.processing_state.as_deref().unwrap_or("unknown")
);
}
Ok(())
}
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/frontend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ serde_json = { workspace = true }
smbcloud-ascapi-aso = { workspace = true }
smbcloud-ascapi-core = { workspace = true }
smbcloud-ascapi-signing = { workspace = true }
tokio = { workspace = true, features = ["time"] }
243 changes: 243 additions & 0 deletions crates/frontend/src/await_processing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
//! Block until App Store Connect finishes processing an uploaded build.
//!
//! `xcrun altool --upload-package` returns as soon as the binary is
//! accepted for delivery, long before App Store Connect finishes
//! processing it. Processing is the step that turns a freshly uploaded
//! build `VALID` (ready to attach to a version) or `INVALID`/`FAILED`
//! (a binary rejection) — the signal that actually confirms an upload
//! or a binary-rejection fix worked. This module polls
//! [`BuildsApi::list_builds`] until a build reaches a terminal state or a
//! timeout elapses.
//!
//! The classification of a `processingState` string is a pure function
//! ([`classify`]) so the terminal-vs-pending decision is unit-tested
//! against the exact strings Apple returns, with the async polling loop
//! kept separate. Like every result type in this crate, [`AwaitOutcome`]
//! carries only identifiers and state — never key or binary material.

use std::time::Duration;

use serde::Serialize;
use smbcloud_ascapi_aso::build::{Build, BuildFilter};
use smbcloud_ascapi_aso::prelude::*;
use smbcloud_ascapi_core::Client;

/// How a build's `processingState` reads: either it's settled (and how), or
/// App Store Connect is still working on it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessingClass {
/// `VALID` — processing finished and the build can be attached to a
/// version.
Valid,
/// `INVALID` or `FAILED` — a binary rejection; processing is over but
/// the build is unusable.
Failed,
/// `PROCESSING`, a missing state, or any string Apple adds later: keep
/// polling. Treating the unknown as pending means a new terminal state
/// we don't recognize just times out rather than being reported as
/// success.
Pending,
}

impl ProcessingClass {
/// Whether this class ends the poll. `Valid` and `Failed` are terminal;
/// `Pending` is not.
pub fn is_terminal(self) -> bool {
!matches!(self, ProcessingClass::Pending)
}
}

/// Classify a build's `processingState` (case-insensitively, since the field
/// is a fixed Apple enum but we don't want a casing change to read as
/// "still pending" forever).
pub fn classify(processing_state: Option<&str>) -> ProcessingClass {
match processing_state
.map(str::trim)
.unwrap_or("")
.to_ascii_uppercase()
.as_str()
{
"VALID" => ProcessingClass::Valid,
"INVALID" | "FAILED" => ProcessingClass::Failed,
_ => ProcessingClass::Pending,
}
}

/// How long to wait and how often to re-check, for [`await_processing`].
#[derive(Debug, Clone, Copy)]
pub struct AwaitConfig {
/// Delay between polls.
pub interval: Duration,
/// Give up after this long without a terminal state.
pub timeout: Duration,
}

impl Default for AwaitConfig {
fn default() -> Self {
// 30s between polls, 30min ceiling: processing usually finishes in a
// few minutes but can stall, and a tighter poll just burns API quota.
Self {
interval: Duration::from_secs(30),
timeout: Duration::from_secs(30 * 60),
}
}
}

/// The result of waiting on a build, as reported to callers. Identifiers and
/// state only — no key or binary material, per the crate's result-type rule.
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct AwaitOutcome {
/// The build's App Store Connect id.
pub build_id: String,
/// The build (upload) number, e.g. `"42"`.
pub version: Option<String>,
/// The `processingState` the build ended on, verbatim from Apple.
pub processing_state: Option<String>,
/// Whether that state is `VALID`. `false` means `INVALID`/`FAILED`.
pub valid: bool,
/// Whether the wait timed out before the build settled. When true,
/// `valid` is false and `processing_state` is the last state seen.
pub timed_out: bool,
/// How long the wait took, in seconds.
pub waited_secs: u64,
}

/// Shape an [`AwaitOutcome`] from the build the poll settled on.
fn outcome(build: &Build, timed_out: bool, waited: Duration) -> AwaitOutcome {
let processing_state = build.attributes.processing_state.clone();
AwaitOutcome {
build_id: build.id.clone(),
version: build.attributes.version.clone(),
valid: !timed_out && classify(processing_state.as_deref()) == ProcessingClass::Valid,
timed_out,
processing_state,
waited_secs: waited.as_secs(),
}
}

/// Poll [`BuildsApi::list_builds`] for `app_id` narrowed by `filter` until the
/// newest matching build reaches a terminal `processingState`, or `config`'s
/// timeout elapses.
///
/// `list_builds` returns newest-first, so the first element is the build a
/// fresh upload produced; narrow with `filter.version` /
/// `filter.pre_release_version` when more than one upload is in flight.
///
/// Returns `Err` only when the API call fails or no matching build exists at
/// all. A build that stays `PROCESSING` past the timeout is a successful
/// `Ok` with `timed_out: true` — the caller decides whether a timeout is a
/// failure, the same way `valid: false` signals a binary rejection.
pub async fn await_processing(
client: &Client,
app_id: &str,
filter: &BuildFilter,
config: AwaitConfig,
) -> Result<AwaitOutcome, String> {
let start = std::time::Instant::now();
loop {
let builds = client
.list_builds(app_id, filter)
.await
.map_err(|error| error.to_string())?;
let build = builds
.into_iter()
.next()
.ok_or_else(|| format!("no build found for app {app_id} matching the given filter"))?;

let class = classify(build.attributes.processing_state.as_deref());
if class.is_terminal() {
return Ok(outcome(&build, false, start.elapsed()));
}

let elapsed = start.elapsed();
if elapsed >= config.timeout {
return Ok(outcome(&build, true, elapsed));
}

// Don't overshoot the timeout waiting for the next poll.
let remaining = config.timeout - elapsed;
tokio::time::sleep(config.interval.min(remaining)).await;
}
}

#[cfg(test)]
mod tests {
use super::*;
use smbcloud_ascapi_aso::build::BuildAttributes;
use smbcloud_ascapi_core::jsonapi::Resource;

#[test]
fn classifies_terminal_and_pending_states() {
// Pin the exact Apple strings: the whole point is to not report an
// unrecognized or still-processing state as success.
assert_eq!(classify(Some("VALID")), ProcessingClass::Valid);
assert_eq!(classify(Some("INVALID")), ProcessingClass::Failed);
assert_eq!(classify(Some("FAILED")), ProcessingClass::Failed);
assert_eq!(classify(Some("PROCESSING")), ProcessingClass::Pending);
}

#[test]
fn classifies_unknown_and_missing_as_pending() {
// A state we don't recognize (a casing change, a new Apple value, or
// an absent field) must read as pending so it times out rather than
// being mistaken for a terminal result.
assert_eq!(classify(None), ProcessingClass::Pending);
assert_eq!(classify(Some("")), ProcessingClass::Pending);
assert_eq!(classify(Some("SOMETHING_NEW")), ProcessingClass::Pending);
}

#[test]
fn classify_is_case_and_whitespace_insensitive() {
assert_eq!(classify(Some(" valid ")), ProcessingClass::Valid);
assert_eq!(classify(Some("invalid")), ProcessingClass::Failed);
}

#[test]
fn only_valid_and_failed_are_terminal() {
assert!(ProcessingClass::Valid.is_terminal());
assert!(ProcessingClass::Failed.is_terminal());
assert!(!ProcessingClass::Pending.is_terminal());
}

fn build(id: &str, state: Option<&str>) -> Build {
Resource {
id: id.to_string(),
resource_type: "builds".to_string(),
attributes: BuildAttributes {
version: Some("42".to_string()),
uploaded_date: Some("2024-01-02T03:04:05Z".to_string()),
expired: Some(false),
processing_state: state.map(str::to_string),
min_os_version: Some("17.0".to_string()),
},
relationships: None,
}
}

#[test]
fn outcome_reports_valid_build() {
let out = outcome(&build("B1", Some("VALID")), false, Duration::from_secs(5));
assert_eq!(out.build_id, "B1");
assert_eq!(out.version.as_deref(), Some("42"));
assert_eq!(out.processing_state.as_deref(), Some("VALID"));
assert!(out.valid);
assert!(!out.timed_out);
assert_eq!(out.waited_secs, 5);
}

#[test]
fn outcome_reports_failed_build_as_not_valid() {
let out = outcome(&build("B2", Some("INVALID")), false, Duration::from_secs(1));
assert!(!out.valid);
assert!(!out.timed_out);
}

#[test]
fn timed_out_outcome_is_never_valid() {
// Even if the last state seen were VALID, a timed-out wait must not
// claim success: `valid` is gated on `!timed_out`.
let out = outcome(&build("B3", Some("VALID")), true, Duration::from_secs(1800));
assert!(out.timed_out);
assert!(!out.valid);
}
}
4 changes: 4 additions & 0 deletions crates/frontend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
//! `profileContent`, and [`profiles::download_profile`] returns the path
//! it wrote instead.

pub mod await_processing;
pub mod certificates;
pub mod enums;
pub mod env;
Expand All @@ -30,6 +31,9 @@ pub mod review;
pub mod time;
pub mod upload;

pub use await_processing::{
await_processing, classify, AwaitConfig, AwaitOutcome, ProcessingClass,
};
pub use certificates::{
certificate_type_from_str, issue_certificate, CertificateSummary, IssuedCertificate,
};
Expand Down
Loading