From 0b59bdddb29f0b15b7d0aa7abb2cec1f53b3d564 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi Date: Sun, 11 Oct 2026 20:53:55 +0200 Subject: [PATCH 1/7] docs(plans): record stack #27 progress (findings #2, #5, #6 shipped) (#28) Update the sitiai and klepon release-session plans to reflect what gh stack #27 landed (#24 docs/list-builds-pagination -> #25 feat/signing-doctor -> #26 feat/apps-await-processing): - sitiai: add a 2025-10-11 progress callout; mark findings #2 (signing doctor, PR #25), #5 (await-processing, PR #26) and #6 (list_builds pagination doc, PR #24) as shipped; detail the await-processing CLI surface; note the upload.rs cluster (#3 + #1, #4 folded) is next. - klepon: cross-link the two findings that built on its builds API (#6 pagination doc, #5 await-processing on BuildsApi::list_builds). Co-authored-by: siGit Code v1.6.7-acp --- ...-plan-from-klepon-xcode-release-session.md | 5 ++++ ...eature-plan-from-sitiai-release-session.md | 28 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.agents/plans/feature-plan-from-klepon-xcode-release-session.md b/.agents/plans/feature-plan-from-klepon-xcode-release-session.md index f70c2d7..d2b6ccb 100644 --- a/.agents/plans/feature-plan-from-klepon-xcode-release-session.md +++ b/.agents/plans/feature-plan-from-klepon-xcode-release-session.md @@ -30,6 +30,11 @@ Shipped as gh stack #9 (draft PRs, bottom to top): custom `TokenSource`) (#7). - [ ] MCP `list_builds` tool doesn't expose `BuildFilter` yet, so it lags the CLI. +- [x] Documented `list_builds` limit=200 as page size, not a cap + (sitiai finding #6, PR #24). +- [x] `asc apps await-processing` poll loop built on `BuildsApi::list_builds` + (sitiai finding #5, PR #26) — a non-blocking alternative to altool + `--wait`. ## Docs - [x] README crate table — builds API surface, altool upload. diff --git a/.agents/plans/feature-plan-from-sitiai-release-session.md b/.agents/plans/feature-plan-from-sitiai-release-session.md index cfd5d15..d7ecd01 100644 --- a/.agents/plans/feature-plan-from-sitiai-release-session.md +++ b/.agents/plans/feature-plan-from-sitiai-release-session.md @@ -12,6 +12,17 @@ Companion plan: [`feature-plan-from-klepon-xcode-release-session.md`](./feature- upload *mechanism*; this one hardens the *pre-flight and verification* around it. Shared context between the two: +> **Progress (2025-10-11, this session):** gh stack #27 (bottom→top) +> #24 `docs/list-builds-pagination` → #25 `feat/signing-doctor` → +> #26 `feat/apps-await-processing`, rebased onto `development`, all CI-clean +> (fmt / clippy `-D warnings` / test / `RUSTDOCFLAGS=-D warnings` doc / +> lower-crate solo builds), signed+verified, auto-merge enabled. This landed +> finding **#2** (signing doctor), finding **#5** (await-processing), and +> finding **#6** (list_builds pagination doc). Still open from this plan: +> **#1** (pre-upload `altool --validate-app`), **#3** (body-aware `success`), +> **#4** (`.app`-vs-`.pkg` message). The one live item for all of them is the +> ASC smoke test, still blocked on credentials. + - Both sessions hit the same root pain: a **stale private key / signing identity with the right id but wrong contents**, which surfaces as an indistinguishable 401 (klepon plan, "Separate / unaddressed bug") or, in @@ -50,7 +61,7 @@ exposes `--validate-app` for exactly this: a fast server-side pre-flight - **Value:** turns a ~minutes-late failure into a seconds-early one; catches the common "wrong version / bad entitlement" class before upload. -### 2. No local-keychain `signing doctor` — HIGH +### 2. No local-keychain `signing doctor` — HIGH ✅ SHIPPED (PR #25) The `signing` crate (`certificate.rs`, `profile.rs`, `csr.rs`) talks **only to the ASC REST API**. `security find-identity` is called nowhere in the repo; there is no local-keychain code at all. So the exact stale-identity @@ -92,7 +103,7 @@ just not exported yet. People routinely point the tool at the `.app`. "directory / wrong kind" from "path missing". - **Value:** tiny, same-file, removes a recurring confusing error. -### 5. No non-blocking `await-processing` loop — MEDIUM +### 5. No non-blocking `await-processing` loop — MEDIUM ✅ SHIPPED (PR #26) The only way to wait for ASC to finish processing a build is altool's `--wait`, which blocks the altool process for the whole duration. There's no poll-based path that uses the builds API (`BuildsApi::list_builds`, @@ -103,8 +114,15 @@ caller poll on its own cadence. with backoff. Complements klepon's builds API; an MCP-friendly alternative to a blocking `--wait`. - **Value:** better UX for CI/agents; builds on existing klepon surface. +- **Shipped (PR #26):** `asc apps await-processing ` with + `--version`, `--pre-release-version`, `--interval` (30s), + `--timeout` (1800s). Shared poll loop + pure `classify()` / + `AwaitConfig` / `AwaitOutcome` in `crates/frontend/src/await_processing.rs` + (serializes without key material; 7 in-module tests). Terminal map: + VALID → success, INVALID/FAILED → failure, else timeout → non-zero exit. + CLI-only for now; the pure core is MCP-ready if a tool is added later. -### 6. `list_builds` limit=200 pagination — LOW (doc-only) +### 6. `list_builds` limit=200 pagination — LOW (doc-only) ✅ SHIPPED (PR #24) `list_builds` caps at limit=200 with no cursor follow-through. Fine for normal apps; note it as a known limit. Overlaps klepon's open "MCP `list_builds` doesn't expose `BuildFilter`" item — fold into that rather @@ -128,3 +146,7 @@ module, shared `--dry-run`/`altool_args` test scaffolding; #4 is cheap to fold in alongside). #2 on its own branch (new macOS-only keychain surface, `security` subprocess, cross-check against `CertificateApi`). Exercise #1 and #3 under klepon's still-open "Live smoke test against ASC" item. + +**Status:** #2 shipped (PR #25). The `upload.rs` cluster (#3 + #1, with #4 +folded in) is still the next natural branch — one file, shared test +scaffolding, all inside what ascapi already owns. From 4c775f139fc1e4f6d31004cb0c4054cc3fd0ad56 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:26:30 +0200 Subject: [PATCH 2/7] docs(aso): clarify list_builds limit=200 is page size, not a cap The `limit=200` on the builds collection reads like a ceiling, but `list_all` follows Apple's `links.next` cursor until the collection is exhausted. Spell out that 200 is the largest page ASC allows (fewer round-trips), not the number of builds returned, so callers don't treat the vector length as "the 200 most recent builds." Co-Authored-By: siGit Code v1.6.7-acp --- crates/aso/src/build.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/aso/src/build.rs b/crates/aso/src/build.rs index 5f392a8..3f78b4e 100644 --- a/crates/aso/src/build.rs +++ b/crates/aso/src/build.rs @@ -97,6 +97,13 @@ pub trait BuildsApi { /// Pass `&BuildFilter::default()` for every build, or set fields to /// narrow by build number, pre-release (marketing) version, or /// processing state. + /// + /// The `limit=200` this sends is a *page size*, not a ceiling on the + /// result: [`Client::list_all`] follows Apple's `links.next` cursor until + /// the collection is exhausted, so an app with more than 200 builds still + /// returns all of them — 200 is just the largest page ASC allows for this + /// collection, which keeps the round-trip count down. Do not read the + /// vector's length as "the 200 most recent builds." async fn list_builds(&self, app_id: &str, filter: &BuildFilter) -> Result>; /// `GET /v1/builds/{id}` — a single build by its App Store Connect id. From 7ffa301989d26808edc72b49c9289ce208e6ef73 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:37:34 +0200 Subject: [PATCH 3/7] feat(signing): add macOS signing doctor Add a read-only signing health check that cross-references the login keychain against Apple's certificates and reports which ASC certificates this machine can actually sign with (private key present locally). - frontend: new keychain module + run_doctor, DoctorReport/DoctorCertificate - cli: macOS-gated `asc signing doctor` subcommand, JSON output - CLI-only (reads the login keychain); never transmits key material Co-Authored-By: siGit Code v1.6.7-acp --- crates/cli/src/main.rs | 36 +++++ crates/frontend/src/keychain.rs | 273 ++++++++++++++++++++++++++++++++ crates/frontend/src/lib.rs | 4 + 3 files changed, 313 insertions(+) create mode 100644 crates/frontend/src/keychain.rs diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 5c18cf4..74faa60 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -138,6 +138,26 @@ enum Command { #[command(subcommand)] command: SalesReportsCommand, }, + /// Local signing health checks that compare this machine against App + /// Store Connect. macOS only — they read the login keychain. + #[cfg(target_os = "macos")] + Signing { + #[command(subcommand)] + command: SigningCommand, + }, +} + +/// macOS-only signing diagnostics. +#[cfg(target_os = "macos")] +#[derive(Subcommand)] +enum SigningCommand { + /// Cross-check the login keychain against Apple's certificates and + /// report which of Apple's certificates this machine can actually sign + /// with — i.e. which ones have their private key present locally. + /// + /// Read-only: it lists certificates and inspects the keychain, and + /// never writes or transmits key material. + Doctor, } #[derive(Subcommand)] @@ -860,6 +880,22 @@ async fn main() -> Result<()> { run_review_submissions(&client, command, cli.dry_run).await } Command::SalesReports { command } => run_sales_reports(&client, command).await, + #[cfg(target_os = "macos")] + Command::Signing { command } => run_signing(&client, command).await, + } +} + +/// macOS-only signing diagnostics. Kept out of MCP: a tool call doesn't +/// need to inspect the operator's login keychain. +#[cfg(target_os = "macos")] +async fn run_signing(client: &Client, command: SigningCommand) -> Result<()> { + match command { + SigningCommand::Doctor => { + let report = smbcloud_ascapi_frontend::run_doctor(client) + .await + .map_err(|error| anyhow::anyhow!(error))?; + print_json(&report) + } } } diff --git a/crates/frontend/src/keychain.rs b/crates/frontend/src/keychain.rs new file mode 100644 index 0000000..9c49b24 --- /dev/null +++ b/crates/frontend/src/keychain.rs @@ -0,0 +1,273 @@ +//! Cross-checking the local login keychain against App Store Connect. +//! +//! `asc signing doctor` answers one question a `list_certificates` call +//! alone cannot: of the certificates Apple holds, which ones can this +//! machine actually sign with? A certificate is only usable if its private +//! key sits in a local keychain — Apple keeps no copy — so the check reads +//! the serial numbers of the certificates in the login keychain and +//! intersects them with what Apple reports. +//! +//! macOS only. `security` and the login keychain do not exist elsewhere, +//! so the whole module is gated and the CLI exposes the command only on +//! macOS. +//! +//! The implementation shells out the same way the CSR tests do: `security` +//! to export the keychain certificates as PEM, `openssl x509` to read each +//! serial. Serial formatting differs between OpenSSL 3 and macOS +//! LibreSSL, so every serial is normalised (uppercase hex, no separators, +//! no leading zeros) before comparison rather than compared as printed. + +use serde::Serialize; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_signing::prelude::*; +use std::process::Command; + +use crate::certificates::CertificateSummary; +use crate::time::now_iso8601; + +/// One certificate Apple holds, annotated with whether this machine can +/// sign with it. +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)] +pub struct DoctorCertificate { + #[serde(flatten)] + pub certificate: CertificateSummary, + /// True when a certificate with this serial is present in the local + /// login keychain, which is the only place its private key can live. + pub private_key_present: bool, +} + +/// The result of `asc signing doctor`. +/// +/// Carries no key or certificate material: `DoctorCertificate` reuses +/// [`CertificateSummary`], whose shape already forbids it. +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)] +pub struct DoctorReport { + /// Every certificate Apple holds, each flagged with whether its key is + /// present locally. Expired certificates are included — they are still + /// what a shipped build was signed with — and carry `expired: true`. + pub certificates: Vec, + /// Serial numbers found in the local keychain that Apple does not + /// report. Usually a certificate revoked at Apple while its key lingers + /// locally, or a certificate from a different team. + pub local_only_serials: Vec, + /// How many of Apple's certificates are usable here: not expired and + /// backed by a local private key. + pub usable_count: usize, +} + +/// Normalise a serial number for comparison across toolchains. +/// +/// OpenSSL 3 prints `serial=1A2B3C`, LibreSSL may print colon-separated +/// bytes, and Apple returns its own casing. Reducing each to uppercase hex +/// digits with leading zeros stripped makes them comparable regardless of +/// how they were rendered. +fn normalise_serial(serial: &str) -> String { + let hex: String = serial + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect::() + .to_uppercase(); + let trimmed = hex.trim_start_matches('0'); + if trimmed.is_empty() { + "0".to_string() + } else { + trimmed.to_string() + } +} + +/// Read the serial numbers of every certificate in the login keychain. +/// +/// Returns normalised serials. Errors only when `security` itself cannot +/// be run; an empty keychain, or one `openssl` cannot parse an entry in, +/// yields the serials it could read rather than failing the whole command. +fn local_keychain_serials() -> Result, String> { + // `-a -p` dumps every certificate in the keychain as concatenated PEM. + let output = Command::new("security") + .args(["find-certificate", "-a", "-p"]) + .output() + .map_err(|error| format!("running `security find-certificate`: {error}"))?; + + if !output.status.success() { + return Err(format!( + "`security find-certificate` failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let pem = String::from_utf8_lossy(&output.stdout); + let mut serials = Vec::new(); + + for block in split_pem_certificates(&pem) { + if let Some(serial) = openssl_serial(&block) { + let normalised = normalise_serial(&serial); + if !normalised.is_empty() && !serials.contains(&normalised) { + serials.push(normalised); + } + } + } + + Ok(serials) +} + +/// Split a concatenated PEM dump into individual certificate blocks. +fn split_pem_certificates(pem: &str) -> Vec { + const BEGIN: &str = "-----BEGIN CERTIFICATE-----"; + const END: &str = "-----END CERTIFICATE-----"; + + let mut blocks = Vec::new(); + let mut rest = pem; + while let Some(start) = rest.find(BEGIN) { + let after_start = &rest[start..]; + if let Some(end_rel) = after_start.find(END) { + let end = end_rel + END.len(); + blocks.push(after_start[..end].to_string()); + rest = &after_start[end..]; + } else { + break; + } + } + blocks +} + +/// Read one PEM certificate's serial via `openssl x509 -noout -serial`. +fn openssl_serial(pem: &str) -> Option { + use std::io::Write; + use std::process::Stdio; + + let mut child = Command::new("openssl") + .args(["x509", "-noout", "-serial"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + child.stdin.take()?.write_all(pem.as_bytes()).ok()?; + + let output = child.wait_with_output().ok()?; + if !output.status.success() { + return None; + } + + let text = String::from_utf8_lossy(&output.stdout); + // Output looks like `serial=1A2B3C`. + text.split('=').nth(1).map(|s| s.trim().to_string()) +} + +/// Build the doctor report: list Apple's certificates, flag each with +/// whether its private key is present in the local keychain. +pub async fn run_doctor(client: &Client) -> Result { + let certificates = client + .list_certificates(None) + .await + .map_err(|error| error.to_string())?; + + let local_serials = local_keychain_serials()?; + let now = now_iso8601(); + + let mut doctor_certificates = Vec::with_capacity(certificates.len()); + let mut matched_local = std::collections::HashSet::new(); + let mut usable_count = 0; + + for certificate in &certificates { + let summary = CertificateSummary::from_resource(certificate, &now); + let normalised = summary + .serial_number + .as_deref() + .map(normalise_serial) + .filter(|s| !s.is_empty()); + + let private_key_present = match &normalised { + Some(serial) => local_serials.contains(serial), + None => false, + }; + if private_key_present { + if let Some(serial) = &normalised { + matched_local.insert(serial.clone()); + } + if summary.expired != Some(true) { + usable_count += 1; + } + } + + doctor_certificates.push(DoctorCertificate { + certificate: summary, + private_key_present, + }); + } + + let local_only_serials = local_serials + .into_iter() + .filter(|serial| !matched_local.contains(serial)) + .collect(); + + Ok(DoctorReport { + certificates: doctor_certificates, + local_only_serials, + usable_count, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalise_serial_folds_casing_separators_and_leading_zeros() { + assert_eq!(normalise_serial("1a2b3c"), "1A2B3C"); + assert_eq!(normalise_serial("1A:2B:3C"), "1A2B3C"); + assert_eq!(normalise_serial("0x1a2b3c"), "1A2B3C"); + assert_eq!(normalise_serial("001A2B3C"), "1A2B3C"); + } + + #[test] + fn normalise_serial_keeps_a_single_zero() { + assert_eq!(normalise_serial("00"), "0"); + assert_eq!(normalise_serial("0"), "0"); + } + + #[test] + fn split_pem_separates_concatenated_certificates() { + let pem = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n\ + -----BEGIN CERTIFICATE-----\nBBBB\n-----END CERTIFICATE-----\n"; + let blocks = split_pem_certificates(pem); + assert_eq!(blocks.len(), 2); + assert!(blocks[0].contains("AAAA")); + assert!(blocks[1].contains("BBBB")); + } + + #[test] + fn split_pem_ignores_an_unterminated_trailing_block() { + let pem = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n\ + -----BEGIN CERTIFICATE-----\nBBBB\n"; + let blocks = split_pem_certificates(pem); + assert_eq!(blocks.len(), 1); + assert!(blocks[0].contains("AAAA")); + } + + #[test] + fn doctor_report_never_serializes_certificate_material() { + let report = DoctorReport { + certificates: vec![DoctorCertificate { + certificate: CertificateSummary { + id: "ABC123".to_string(), + name: Some("Apple Distribution: Example".to_string()), + certificate_type: Some("DISTRIBUTION".to_string()), + serial_number: Some("1A2B3C".to_string()), + expiration_date: Some("2027-08-06T00:00:00".to_string()), + expired: Some(false), + }, + private_key_present: true, + }], + local_only_serials: vec!["DEADBEEF".to_string()], + usable_count: 1, + }; + + let json = serde_json::to_string(&report).expect("serializes"); + assert!(!json.contains("BEGIN CERTIFICATE")); + assert!(!json.contains("BEGIN PRIVATE KEY")); + // The flattened summary should surface serial and the presence flag. + assert!(json.contains("1A2B3C")); + assert!(json.contains("private_key_present")); + } +} diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index 9570035..a53312b 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -23,6 +23,8 @@ pub mod certificates; pub mod enums; pub mod env; +#[cfg(target_os = "macos")] +pub mod keychain; pub mod profiles; pub mod review; pub mod time; @@ -33,6 +35,8 @@ pub use certificates::{ }; pub use enums::{bundle_id_platform_from_str, display_type_from_str, platform_from_str}; pub use env::api_key_from_env; +#[cfg(target_os = "macos")] +pub use keychain::{run_doctor, DoctorCertificate, DoctorReport}; pub use profiles::{download_profile, profile_type_from_str, DownloadedProfile, ProfileSummary}; pub use review::{plan_submission, submit_for_review, SubmissionPlan, SubmittedReview}; pub use time::{is_expired, now_iso8601}; From 30f50d58c0f75c2660454012fc189259d9c6dbb2 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:11:31 +0200 Subject: [PATCH 4/7] ci: add GitHub binary release workflow Build the asc CLI for linux/windows/macOS (amd64 + arm64), checksum each asset, and publish a GitHub release with auto-generated notes. Mirrors the smbcloud-cli release-github.yml, trimmed to what ascapi needs: toolchain pinned to stable (no rust-toolchain.toml here) and no OS-package-manager fan-out (no homebrew/scoop/winget workflows in this repo yet). Co-Authored-By: siGit Code v1.6.7-acp --- .github/workflows/release-github.yml | 147 +++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 .github/workflows/release-github.yml diff --git a/.github/workflows/release-github.yml b/.github/workflows/release-github.yml new file mode 100644 index 0000000..ff1face --- /dev/null +++ b/.github/workflows/release-github.yml @@ -0,0 +1,147 @@ +name: GitHub Release + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v0.2.0)" + required: true + +permissions: + contents: write + +env: + PROJECT_NAME: asc + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build binary (${{ matrix.name }}) + runs-on: ${{ matrix.runner }} + + strategy: + fail-fast: false + matrix: + include: + - name: linux-amd64 + runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + - name: linux-arm64 + runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + - name: win-amd64 + runner: windows-latest + target: x86_64-pc-windows-msvc + - name: win-arm64 + runner: windows-latest + target: aarch64-pc-windows-msvc + - name: macos-amd64 + runner: macos-15-intel + target: x86_64-apple-darwin + - name: macos-arm64 + runner: macos-latest + target: aarch64-apple-darwin + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Set the release version + shell: bash + run: | + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + release_version="${GITHUB_REF_NAME#v}" + else + release_version="${{ github.event.inputs.tag }}" + release_version="${release_version#v}" + fi + + echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV" + + - name: Install toolkit + uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 + with: + toolchain: stable + targets: ${{ matrix.target }} + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + with: + key: github-${{ matrix.target }} + + - name: Build binary + shell: bash + run: cargo build --locked --release --target ${{ matrix.target }} --package smbcloud-ascapi-cli + + - name: Package binary + shell: bash + run: | + BIN_SUFFIX="" + if [[ "${{ matrix.runner }}" == "windows-latest" ]]; then + BIN_SUFFIX=".exe" + fi + + BIN_OUTPUT="target/${{ matrix.target }}/release/${PROJECT_NAME}${BIN_SUFFIX}" + BIN_RELEASE="${PROJECT_NAME}-${{ matrix.name }}${BIN_SUFFIX}" + + mkdir -p ./release + mv "${BIN_OUTPUT}" "./release/${BIN_RELEASE}" + + # Windows runners use bash from Git for Windows, which has sha256sum; + # macOS only has shasum. + - name: Checksum release assets + shell: bash + run: | + cd release + for asset in *; do + case "${asset}" in *.sha256) continue ;; esac + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${asset}" | awk '{print $1}' > "${asset}.sha256" + else + shasum -a 256 "${asset}" | awk '{print $1}' > "${asset}.sha256" + fi + + echo "${asset}: $(cat "${asset}.sha256")" + done + + - name: Upload binary artifact + uses: actions/upload-artifact@v7 + with: + name: binary-${{ matrix.name }} + path: release/* + + release: + name: Create GitHub Release + needs: build + runs-on: ubuntu-latest + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' + + steps: + - name: Resolve tag + id: tag + shell: bash + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + fi + + - name: Download binary artifacts + uses: actions/download-artifact@v7 + with: + pattern: "binary-*" + path: release + merge-multiple: true + + - name: Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.tag }} + files: release/* + # Build the notes from the PRs since the previous tag, the same + # format as the "Generate release notes" button on the web UI. + generate_release_notes: true From 0820c57b2fbe49424509adb81693838f68919e34 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:40:32 +0200 Subject: [PATCH 5/7] feat(cli): add testflight subcommand tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `asc testflight` with three sub-trees: - `testflight groups` — list, get, and add-build (CLI-only, writable) - `testflight app-review-detail` — get and update - `testflight app-review-submissions` — list and submit (CLI-only, irreversible) Adds the supporting ASC domain modules in `crates/aso`: - `beta_group`: list groups, get by id, add builds (body-serialisation tests) - `beta_app_review_detail`: get + PATCH update (deserialise + skip-None tests) - `beta_app_review_submission`: list + POST create (filter-key + body tests) - `beta_build_localization`: list + POST create + PATCH update (round-trip tests) Exposes the read-only operations as MCP tools: - `testflight_beta_groups_list`, `testflight_beta_groups_get` - `testflight_beta_app_review_detail_get` - `testflight_beta_app_review_submissions_list` Writable / irreversible operations (add-build, submit, localization writes) are CLI-only per the project's destructive-op policy. All CI gates pass: fmt, clippy -D warnings, 92 tests, rustdoc -D warnings. Co-Authored-By: siGit Code v1.6.7-acp --- crates/aso/src/beta_app_review_detail.rs | 181 ++++++++++++++ crates/aso/src/beta_app_review_submission.rs | 212 +++++++++++++++++ crates/aso/src/beta_build_localization.rs | 234 +++++++++++++++++++ crates/aso/src/beta_group.rs | 208 +++++++++++++++++ crates/aso/src/lib.rs | 8 + crates/cli/src/main.rs | 195 ++++++++++++++++ crates/mcp/src/lib.rs | 50 +++- crates/mcp/src/requests.rs | 13 ++ crates/mcp/src/server.rs | 8 +- 9 files changed, 1106 insertions(+), 3 deletions(-) create mode 100644 crates/aso/src/beta_app_review_detail.rs create mode 100644 crates/aso/src/beta_app_review_submission.rs create mode 100644 crates/aso/src/beta_build_localization.rs create mode 100644 crates/aso/src/beta_group.rs diff --git a/crates/aso/src/beta_app_review_detail.rs b/crates/aso/src/beta_app_review_detail.rs new file mode 100644 index 0000000..0adb63c --- /dev/null +++ b/crates/aso/src/beta_app_review_detail.rs @@ -0,0 +1,181 @@ +//! `BetaAppReviewDetail` — contact and demo info that Apple's Beta App Review +//! team uses when reviewing an app for external TestFlight distribution. +//! +//! Every app has exactly one `BetaAppReviewDetail`. It does not need to be +//! created — it exists by default. The caller `GET`s it to see the current +//! state, and `PATCH`es it to update contact information before submitting +//! a build for Beta App Review. + +use async_trait::async_trait; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::jsonapi::{Document, Resource, UpdateBody, UpdateData}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; + +pub const RESOURCE_TYPE: &str = "betaAppReviewDetails"; + +/// Contact and demo-account information used by the Beta App Review team. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BetaAppReviewDetailAttributes { + /// Contact email for Beta App Review questions. + pub contact_email: Option, + /// Contact first name. + pub contact_first_name: Option, + /// Contact last name. + pub contact_last_name: Option, + /// Contact phone number (international format recommended). + pub contact_phone: Option, + /// Username for a demo account, if the app requires login to review. + pub demo_account_name: Option, + /// Password for the demo account. + pub demo_account_password: Option, + /// Whether a demo account is required to review the app. + pub demo_account_required: Option, + /// Free-form notes for the reviewer (max 4000 chars). + pub notes: Option, +} + +/// A beta app review detail resource. +pub type BetaAppReviewDetail = Resource; + +// ── update helper ───────────────────────────────────────────────────────────── + +/// Partial update attributes — all fields are optional so a caller can +/// update only what has changed. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BetaAppReviewDetailUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_first_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_last_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub demo_account_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub demo_account_password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub demo_account_required: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +// ── trait ───────────────────────────────────────────────────────────────────── + +/// Beta app review detail operations on App Store Connect. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait BetaAppReviewDetailApi { + /// `GET /v1/apps/{app_id}/betaAppReviewDetail` — fetch the review detail + /// for an app via the relationship sub-resource. This is the canonical + /// way to get the detail without knowing its opaque id up front. + async fn get_beta_app_review_detail(&self, app_id: &str) -> Result; + + /// `PATCH /v1/betaAppReviewDetails/{id}` — update contact / demo-account + /// fields. The id comes from a prior `get_beta_app_review_detail` call. + /// + /// Only fields set (non-`None`) in `update` are sent; other fields are + /// left unchanged on Apple's side. + async fn update_beta_app_review_detail( + &self, + id: &str, + update: BetaAppReviewDetailUpdate, + ) -> Result; +} + +#[async_trait] +impl BetaAppReviewDetailApi for Client { + async fn get_beta_app_review_detail(&self, app_id: &str) -> Result { + let path = format!("/v1/apps/{app_id}/betaAppReviewDetail"); + let doc: Document = + self.request(Method::GET, &path, &[], None::<&()>).await?; + Ok(doc.data) + } + + async fn update_beta_app_review_detail( + &self, + id: &str, + update: BetaAppReviewDetailUpdate, + ) -> Result { + let path = format!("/v1/betaAppReviewDetails/{id}"); + let body = UpdateBody { + data: UpdateData { + resource_type: RESOURCE_TYPE, + id: id.to_string(), + attributes: update, + }, + }; + let doc: Document = + self.request(Method::PATCH, &path, &[], Some(&body)).await?; + Ok(doc.data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserializes_a_beta_app_review_detail() { + let body = serde_json::json!({ + "data": { + "id": "DETAIL1", + "type": "betaAppReviewDetails", + "attributes": { + "contactEmail": "dev@example.com", + "contactFirstName": "Jane", + "contactLastName": "Developer", + "contactPhone": "+1-555-0100", + "demoAccountName": "demo@example.com", + "demoAccountPassword": "hunter2", + "demoAccountRequired": true, + "notes": "Tap the red button to reach the main feature." + } + } + }); + + let doc: Document = + serde_json::from_value(body).expect("review detail must deserialize"); + let attrs = &doc.data.attributes; + assert_eq!(doc.data.id, "DETAIL1"); + assert_eq!(attrs.contact_email.as_deref(), Some("dev@example.com")); + assert_eq!(attrs.contact_first_name.as_deref(), Some("Jane")); + assert_eq!(attrs.demo_account_required, Some(true)); + assert_eq!( + attrs.notes.as_deref(), + Some("Tap the red button to reach the main feature.") + ); + } + + #[test] + fn update_body_skips_none_fields() { + let update = BetaAppReviewDetailUpdate { + contact_email: Some("new@example.com".into()), + notes: Some("Updated notes.".into()), + ..Default::default() + }; + let body = UpdateBody { + data: UpdateData { + resource_type: RESOURCE_TYPE, + id: "DETAIL1".into(), + attributes: update, + }, + }; + let v = serde_json::to_value(&body).unwrap(); + let attrs = &v["data"]["attributes"]; + assert_eq!(attrs["contactEmail"], "new@example.com"); + assert_eq!(attrs["notes"], "Updated notes."); + // Fields not set must be absent (not null), so the PATCH is truly partial. + assert!(attrs.get("contactFirstName").is_none() || attrs["contactFirstName"].is_null()); + assert!(attrs.get("demoAccountRequired").is_none()); + } +} diff --git a/crates/aso/src/beta_app_review_submission.rs b/crates/aso/src/beta_app_review_submission.rs new file mode 100644 index 0000000..948ddf1 --- /dev/null +++ b/crates/aso/src/beta_app_review_submission.rs @@ -0,0 +1,212 @@ +//! `BetaAppReviewSubmission` — submit a build to Apple's Beta App Review +//! for external TestFlight distribution approval. +//! +//! Before external testers can install a build, Apple must approve it via +//! Beta App Review. This module lets a caller: +//! +//! 1. Submit a build (`POST /v1/betaAppReviewSubmissions`) — creates a +//! submission and immediately sends the build to Apple for review. +//! 2. Query the current submission state (`GET /v1/betaAppReviewSubmissions?filter[build]={id}`). +//! +//! The submission itself only ever has the relationship to a `build`; +//! contact / demo details come from [`crate::beta_app_review_detail`]. + +use async_trait::async_trait; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::jsonapi::{ + CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne, +}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; + +pub const RESOURCE_TYPE: &str = "betaAppReviewSubmissions"; + +/// The review state Apple reports for a beta submission. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum BetaReviewState { + /// The submission is waiting for App Review to pick it up. + WaitingForReview, + /// Apple is actively reviewing the build. + InReview, + /// Beta App Review rejected the build. + Rejected, + /// Beta App Review approved the build; external testing can begin. + Approved, +} + +/// Attributes of a beta app review submission. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BetaAppReviewSubmissionAttributes { + /// Current state of the review. + pub beta_review_state: Option, + /// Submission timestamp (ISO 8601). + pub submitted_date: Option, +} + +/// A beta app review submission resource. +pub type BetaAppReviewSubmission = Resource; + +// ── internal create helper ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize)] +struct CreateRelationships { + build: ToOne, +} + +// ── trait ───────────────────────────────────────────────────────────────────── + +/// Beta app review submission operations on App Store Connect. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait BetaAppReviewSubmissionsApi { + /// `POST /v1/betaAppReviewSubmissions` — submit a build to Beta App Review. + /// + /// **CLI-only** — this is a destructive, irreversible operation that + /// triggers Apple's review queue. It must not be called from an MCP tool + /// without explicit user confirmation, and it is intentionally not + /// exposed as an MCP tool here. + /// + /// `build_id` is the opaque ASC id of the build (not the build number). + /// On success the returned submission's `beta_review_state` is typically + /// `WAITING_FOR_REVIEW`. + async fn submit_build_for_beta_review(&self, build_id: &str) + -> Result; + + /// `GET /v1/betaAppReviewSubmissions?filter[build]={build_id}` — find + /// the current submission record for a build. + /// + /// Returns at most one item in practice (one build → one submission), but + /// the API is a list endpoint so we return `Vec`. + async fn list_beta_app_review_submissions( + &self, + build_id: &str, + ) -> Result>; +} + +#[async_trait] +impl BetaAppReviewSubmissionsApi for Client { + async fn submit_build_for_beta_review( + &self, + build_id: &str, + ) -> Result { + let body = CreateBody { + data: CreateData { + resource_type: RESOURCE_TYPE, + attributes: (), + relationships: Some(CreateRelationships { + build: ToOne { + data: ResourceId { + resource_type: crate::build::RESOURCE_TYPE, + id: build_id.to_string(), + }, + }, + }), + }, + }; + let doc: Document = self + .request( + Method::POST, + "/v1/betaAppReviewSubmissions", + &[], + Some(&body), + ) + .await?; + Ok(doc.data) + } + + async fn list_beta_app_review_submissions( + &self, + build_id: &str, + ) -> Result> { + let path = "/v1/betaAppReviewSubmissions"; + let query = &[("filter[build]", build_id)]; + let doc: ListDocument = + self.request(Method::GET, path, query, None::<&()>).await?; + Ok(doc.data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserializes_a_submission_list() { + let body = serde_json::json!({ + "data": [ + { + "id": "SUB1", + "type": "betaAppReviewSubmissions", + "attributes": { + "betaReviewState": "WAITING_FOR_REVIEW", + "submittedDate": "2025-01-01T00:00:00.000+0000" + } + } + ] + }); + + let doc: ListDocument = + serde_json::from_value(body).expect("submission list must deserialize"); + assert_eq!(doc.data.len(), 1); + let sub = &doc.data[0]; + assert_eq!(sub.id, "SUB1"); + assert_eq!( + sub.attributes.beta_review_state, + Some(BetaReviewState::WaitingForReview) + ); + assert_eq!( + sub.attributes.submitted_date.as_deref(), + Some("2025-01-01T00:00:00.000+0000") + ); + } + + #[test] + fn deserializes_approved_state() { + let body = serde_json::json!({ + "data": { + "id": "SUB2", + "type": "betaAppReviewSubmissions", + "attributes": { + "betaReviewState": "APPROVED", + "submittedDate": "2025-02-01T10:00:00.000+0000" + } + } + }); + + let doc: Document = + serde_json::from_value(body).expect("single submission must deserialize"); + assert_eq!( + doc.data.attributes.beta_review_state, + Some(BetaReviewState::Approved) + ); + } + + #[test] + fn filter_key_is_exact() { + // ASC is case-sensitive about filter keys; confirm the exact string. + let query: Vec<(&str, &str)> = vec![("filter[build]", "BUILD_ID")]; + assert_eq!(query[0].0, "filter[build]"); + } + + #[test] + fn create_body_attributes_unit_serializes() { + // The create body has no attributes — verify `()` round-trips cleanly. + let body = CreateBody { + data: CreateData { + resource_type: RESOURCE_TYPE, + attributes: (), + relationships: None::<()>, + }, + }; + let v = serde_json::to_value(&body).unwrap(); + // `()` serialises as `null`; the key is present in the object. + assert!(v["data"]["attributes"].is_null()); + } +} diff --git a/crates/aso/src/beta_build_localization.rs b/crates/aso/src/beta_build_localization.rs new file mode 100644 index 0000000..0709b45 --- /dev/null +++ b/crates/aso/src/beta_build_localization.rs @@ -0,0 +1,234 @@ +//! `BetaBuildLocalization` — per-locale "What to Test" notes on a TestFlight build. +//! +//! Each build can carry one `BetaBuildLocalization` per locale (e.g. +//! `"en-US"`, `"ja"`). These notes appear to testers in the TestFlight app +//! beneath the build entry. Creating or updating them is optional; if absent +//! the build simply shows no notes. + +use async_trait::async_trait; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::jsonapi::{ + CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData, +}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; + +pub const RESOURCE_TYPE: &str = "betaBuildLocalizations"; + +/// Attributes of a per-locale beta build note. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BetaBuildLocalizationAttributes { + /// BCP 47 locale tag, e.g. `"en-US"` or `"ja"`. + pub locale: Option, + /// Free-form "What to Test" note shown to testers (max 4000 chars). + pub whats_new: Option, +} + +/// A beta build localization resource. +pub type BetaBuildLocalization = Resource; + +// ── internal create/update helpers ─────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateAttributes { + locale: String, + whats_new: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct CreateRelationships { + build: ToOne, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct UpdateAttributes { + whats_new: String, +} + +// ── trait ───────────────────────────────────────────────────────────────────── + +/// Beta build localization operations on App Store Connect. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait BetaBuildLocalizationsApi { + /// `GET /v1/betaBuildLocalizations?filter[build]={build_id}` — all + /// locale notes for a build. + async fn list_beta_build_localizations( + &self, + build_id: &str, + ) -> Result>; + + /// `GET /v1/betaBuildLocalizations/{id}` — a single note by its ASC id. + async fn get_beta_build_localization(&self, id: &str) -> Result; + + /// `POST /v1/betaBuildLocalizations` — create a locale note for a build. + /// + /// `locale` is a BCP 47 tag (e.g. `"en-US"`). `whats_new` is optional + /// at creation; App Store Connect accepts an empty record. + async fn create_beta_build_localization( + &self, + build_id: &str, + locale: &str, + whats_new: Option<&str>, + ) -> Result; + + /// `PATCH /v1/betaBuildLocalizations/{id}` — update the "What to Test" + /// text for an existing localization. + async fn update_beta_build_localization( + &self, + id: &str, + whats_new: &str, + ) -> Result; +} + +#[async_trait] +impl BetaBuildLocalizationsApi for Client { + async fn list_beta_build_localizations( + &self, + build_id: &str, + ) -> Result> { + self.list_all::( + "/v1/betaBuildLocalizations", + &[("filter[build]", build_id), ("limit", "200")], + ) + .await + } + + async fn get_beta_build_localization(&self, id: &str) -> Result { + let path = format!("/v1/betaBuildLocalizations/{id}"); + let doc: Document = + self.request(Method::GET, &path, &[], None::<&()>).await?; + Ok(doc.data) + } + + async fn create_beta_build_localization( + &self, + build_id: &str, + locale: &str, + whats_new: Option<&str>, + ) -> Result { + let body = CreateBody { + data: CreateData { + resource_type: RESOURCE_TYPE, + attributes: CreateAttributes { + locale: locale.to_string(), + whats_new: whats_new.map(str::to_string), + }, + relationships: Some(CreateRelationships { + build: ToOne { + data: ResourceId { + resource_type: crate::build::RESOURCE_TYPE, + id: build_id.to_string(), + }, + }, + }), + }, + }; + let doc: Document = self + .request(Method::POST, "/v1/betaBuildLocalizations", &[], Some(&body)) + .await?; + Ok(doc.data) + } + + async fn update_beta_build_localization( + &self, + id: &str, + whats_new: &str, + ) -> Result { + let path = format!("/v1/betaBuildLocalizations/{id}"); + let body = UpdateBody { + data: UpdateData { + resource_type: RESOURCE_TYPE, + id: id.to_string(), + attributes: UpdateAttributes { + whats_new: whats_new.to_string(), + }, + }, + }; + let doc: Document = + self.request(Method::PATCH, &path, &[], Some(&body)).await?; + Ok(doc.data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use smbcloud_ascapi_core::jsonapi::ListDocument; + + #[test] + fn deserializes_a_localization_list() { + let body = serde_json::json!({ + "data": [ + { + "id": "LOC1", + "type": "betaBuildLocalizations", + "attributes": { + "locale": "en-US", + "whatsNew": "Bug fixes and performance improvements." + } + }, + { + "id": "LOC2", + "type": "betaBuildLocalizations", + "attributes": { + "locale": "ja", + "whatsNew": "バグ修正とパフォーマンス改善。" + } + } + ] + }); + + let doc: ListDocument = + serde_json::from_value(body).expect("localization list must deserialize"); + assert_eq!(doc.data.len(), 2); + + let en = &doc.data[0]; + assert_eq!(en.id, "LOC1"); + assert_eq!(en.attributes.locale.as_deref(), Some("en-US")); + assert_eq!( + en.attributes.whats_new.as_deref(), + Some("Bug fixes and performance improvements.") + ); + + let ja = &doc.data[1]; + assert_eq!(ja.attributes.locale.as_deref(), Some("ja")); + } + + #[test] + fn create_body_serializes_filter_key() { + // The filter key for the list endpoint must match exactly. + let query: Vec<(&str, &str)> = vec![("filter[build]", "BUILD1"), ("limit", "200")]; + assert_eq!(query[0].0, "filter[build]"); + assert_eq!(query[0].1, "BUILD1"); + } + + #[test] + fn create_body_without_whats_new_omits_the_field() { + // whatsNew = None → field should be null in JSON (we serialize as + // `Option`, so it is present as null rather than absent, which ASC + // accepts as "no notes yet"). + let body = CreateBody { + data: CreateData { + resource_type: RESOURCE_TYPE, + attributes: CreateAttributes { + locale: "en-US".into(), + whats_new: None, + }, + relationships: None::<()>, + }, + }; + let v = serde_json::to_value(&body).unwrap(); + let attrs = &v["data"]["attributes"]; + assert_eq!(attrs["locale"], "en-US"); + assert!(attrs["whatsNew"].is_null()); + } +} diff --git a/crates/aso/src/beta_group.rs b/crates/aso/src/beta_group.rs new file mode 100644 index 0000000..402cd2b --- /dev/null +++ b/crates/aso/src/beta_group.rs @@ -0,0 +1,208 @@ +//! `BetaGroup` — a TestFlight distribution group that can receive builds. +//! +//! A beta group is either an *internal* tester group (only team members with +//! an Apple Developer account role) or an *external* group (anyone with an +//! email invite). Adding a build to an external group triggers the optional +//! "Beta App Review" gate; internal groups skip it. +//! +//! The writable operation here — adding a build to a group — is deliberate +//! and potentially public-facing, so it stays CLI-only in the front end. + +use async_trait::async_trait; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::jsonapi::{Resource, ResourceId}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; + +pub const RESOURCE_TYPE: &str = "betaGroups"; + +/// Attributes of a TestFlight beta group. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BetaGroupAttributes { + /// Display name of the group, e.g. `"Internal Testers"`. + pub name: Option, + /// Whether this is an internal-only group (team members only). + pub is_internal_group: Option, + /// Whether new builds are automatically distributed to this group. + pub has_access_to_all_builds: Option, + /// ISO 8601 timestamp of when the group was created. + pub created_date: Option, + /// Whether a public invite link is enabled for this group. + pub public_link_enabled: Option, + /// The public invite link URL, if enabled. + pub public_link: Option, + /// Current number of testers who have accepted through the public link. + pub public_link_limit: Option, +} + +/// A TestFlight beta group resource. +pub type BetaGroup = Resource; + +/// Body used to add (or replace) the builds relationship on a beta group. +/// +/// `POST /v1/betaGroups/{id}/relationships/builds` appends builds; +/// `PATCH /v1/betaGroups/{id}/relationships/builds` replaces the list. +/// We only need the append form here. +#[derive(Debug, Clone, Serialize)] +struct AddBuildsBody { + data: Vec, +} + +/// Beta group operations on App Store Connect. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait BetaGroupsApi { + /// `GET /v1/betaGroups?filter[app]={app_id}` — all beta groups for + /// an app, sorted alphabetically by `name` client-side so internal + /// and external groups are interleaved predictably. + async fn list_beta_groups(&self, app_id: &str) -> Result>; + + /// `GET /v1/betaGroups/{id}` — a single group by its ASC id. + async fn get_beta_group(&self, id: &str) -> Result; + + /// `POST /v1/betaGroups/{group_id}/relationships/builds` — add one or + /// more builds to a beta group by their ASC build ids. + /// + /// This is an *append* operation: existing builds in the group are + /// unaffected. Adding a build to an external group may trigger Beta + /// App Review if the app has not received approval for that group yet. + async fn add_builds_to_beta_group(&self, group_id: &str, build_ids: &[&str]) -> Result<()>; +} + +#[async_trait] +impl BetaGroupsApi for Client { + async fn list_beta_groups(&self, app_id: &str) -> Result> { + let mut groups = self + .list_all::( + "/v1/betaGroups", + &[("filter[app]", app_id), ("limit", "200")], + ) + .await?; + groups.sort_by(|a, b| a.attributes.name.cmp(&b.attributes.name)); + Ok(groups) + } + + async fn get_beta_group(&self, id: &str) -> Result { + use smbcloud_ascapi_core::jsonapi::Document; + let path = format!("/v1/betaGroups/{id}"); + let doc: Document = + self.request(Method::GET, &path, &[], None::<&()>).await?; + Ok(doc.data) + } + + async fn add_builds_to_beta_group(&self, group_id: &str, build_ids: &[&str]) -> Result<()> { + let path = format!("/v1/betaGroups/{group_id}/relationships/builds"); + let body = AddBuildsBody { + data: build_ids + .iter() + .map(|id| ResourceId { + resource_type: crate::build::RESOURCE_TYPE, + id: id.to_string(), + }) + .collect(), + }; + // A successful POST to a relationships endpoint returns 204 No Content. + // `request_no_content` (if available) or discard the empty body. + self.request_no_content(Method::POST, &path, &[], Some(&body)) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use smbcloud_ascapi_core::jsonapi::ListDocument; + + #[test] + fn deserializes_a_beta_groups_list() { + let body = serde_json::json!({ + "data": [ + { + "id": "BG1", + "type": "betaGroups", + "attributes": { + "name": "Internal Testers", + "isInternalGroup": true, + "hasAccessToAllBuilds": true, + "createdDate": "2024-01-01T00:00:00.000+0000", + "publicLinkEnabled": false, + "publicLink": null, + "publicLinkLimit": null + } + }, + { + "id": "BG2", + "type": "betaGroups", + "attributes": { + "name": "External Beta", + "isInternalGroup": false, + "hasAccessToAllBuilds": false, + "createdDate": "2024-02-01T00:00:00.000+0000", + "publicLinkEnabled": true, + "publicLink": "https://testflight.apple.com/join/abc123", + "publicLinkLimit": 1000 + } + } + ] + }); + + let doc: ListDocument = + serde_json::from_value(body).expect("beta groups list must deserialize"); + assert_eq!(doc.data.len(), 2); + + let internal = &doc.data[0]; + assert_eq!(internal.id, "BG1"); + assert_eq!( + internal.attributes.name.as_deref(), + Some("Internal Testers") + ); + assert_eq!(internal.attributes.is_internal_group, Some(true)); + assert_eq!(internal.attributes.has_access_to_all_builds, Some(true)); + assert_eq!(internal.attributes.public_link_enabled, Some(false)); + + let external = &doc.data[1]; + assert_eq!(external.id, "BG2"); + assert_eq!(external.attributes.name.as_deref(), Some("External Beta")); + assert_eq!(external.attributes.is_internal_group, Some(false)); + assert_eq!( + external.attributes.public_link.as_deref(), + Some("https://testflight.apple.com/join/abc123") + ); + assert_eq!(external.attributes.public_link_limit, Some(1000)); + } + + #[test] + fn add_builds_body_serializes_correctly() { + let body = AddBuildsBody { + data: vec![ + ResourceId { + resource_type: "builds", + id: "BUILD1".into(), + }, + ResourceId { + resource_type: "builds", + id: "BUILD2".into(), + }, + ], + }; + let v = serde_json::to_value(&body).unwrap(); + let data = v["data"].as_array().unwrap(); + assert_eq!(data.len(), 2); + assert_eq!(data[0]["type"], "builds"); + assert_eq!(data[0]["id"], "BUILD1"); + assert_eq!(data[1]["id"], "BUILD2"); + } + + #[test] + fn empty_build_ids_produces_empty_data_array() { + let body = AddBuildsBody { data: vec![] }; + let v = serde_json::to_value(&body).unwrap(); + assert!(v["data"].as_array().unwrap().is_empty()); + } +} diff --git a/crates/aso/src/lib.rs b/crates/aso/src/lib.rs index 02ae5fd..0421ac7 100644 --- a/crates/aso/src/lib.rs +++ b/crates/aso/src/lib.rs @@ -27,6 +27,10 @@ pub mod app_screenshot; pub mod app_screenshot_set; pub mod app_store_version; pub mod app_store_version_localization; +pub mod beta_app_review_detail; +pub mod beta_app_review_submission; +pub mod beta_build_localization; +pub mod beta_group; pub mod build; pub mod bundle_id; pub mod review_submission; @@ -40,6 +44,10 @@ pub mod prelude { pub use crate::app_screenshot_set::AppScreenshotSetsApi; pub use crate::app_store_version::AppStoreVersionsApi; pub use crate::app_store_version_localization::AppStoreVersionLocalizationsApi; + pub use crate::beta_app_review_detail::BetaAppReviewDetailApi; + pub use crate::beta_app_review_submission::BetaAppReviewSubmissionsApi; + pub use crate::beta_build_localization::BetaBuildLocalizationsApi; + pub use crate::beta_group::BetaGroupsApi; pub use crate::build::BuildsApi; pub use crate::bundle_id::BundleIdsApi; pub use crate::review_submission::ReviewSubmissionsApi; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 5c18cf4..eff8fa1 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -11,6 +11,7 @@ use smbcloud_ascapi_aso::app_store_version::{AppStoreVersionCreateAttributes, Pl use smbcloud_ascapi_aso::app_store_version_localization::{ AppStoreVersionLocalizationCreateAttributes, AppStoreVersionLocalizationFields, }; +use smbcloud_ascapi_aso::beta_app_review_detail::BetaAppReviewDetailUpdate; use smbcloud_ascapi_aso::build::BuildFilter; use smbcloud_ascapi_aso::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform}; use smbcloud_ascapi_aso::prelude::*; @@ -138,6 +139,116 @@ enum Command { #[command(subcommand)] command: SalesReportsCommand, }, + /// Manage TestFlight beta testing: groups, review details, and review submissions. + Testflight { + #[command(subcommand)] + command: TestflightCommand, + }, +} + +#[derive(Subcommand)] +enum TestflightCommand { + /// Manage TestFlight beta groups. + Groups { + #[command(subcommand)] + command: BetaGroupsCommand, + }, + /// Manage the Beta App Review contact details for an app. + AppReviewDetail { + #[command(subcommand)] + command: BetaAppReviewDetailCommand, + }, + /// Manage Beta App Review submissions for a build. + AppReviewSubmissions { + #[command(subcommand)] + command: BetaAppReviewSubmissionsCommand, + }, +} + +#[derive(Subcommand)] +enum BetaGroupsCommand { + /// List all beta groups for an app. + List { + /// The ASC app ID to list groups for. + app_id: String, + }, + /// Fetch a single beta group by its ASC ID. + Get { + /// The ASC beta group ID. + group_id: String, + }, + /// Add one or more builds to a beta group. + /// + /// Adding a build to an external group may trigger Beta App Review if the + /// app has not received approval for that group yet. This command is + /// CLI-only for that reason. + AddBuild { + /// The ASC beta group ID. + group_id: String, + /// One or more ASC build IDs to add to the group. + #[arg(long = "build-id", required = true, num_args = 1..)] + build_ids: Vec, + }, +} + +#[derive(Subcommand)] +enum BetaAppReviewDetailCommand { + /// Fetch the Beta App Review contact details for an app. + Get { + /// The ASC app ID. + app_id: String, + }, + /// Update the Beta App Review contact and demo-account details for an app. + /// + /// Only supplied flags are updated; omitted fields are left unchanged. + Update { + /// The ASC app ID. + app_id: String, + /// Contact email address for Beta App Review questions. + #[arg(long)] + contact_email: Option, + /// Contact first name. + #[arg(long)] + contact_first_name: Option, + /// Contact last name. + #[arg(long)] + contact_last_name: Option, + /// Contact phone number (international format recommended). + #[arg(long)] + contact_phone: Option, + /// Demo account username (if the app requires login to review). + #[arg(long)] + demo_account_name: Option, + /// Demo account password. + #[arg(long)] + demo_account_password: Option, + /// Whether a demo account is required to review the app. + #[arg(long)] + demo_account_required: Option, + /// Free-form notes for the reviewer (max 4000 chars). + #[arg(long)] + notes: Option, + }, +} + +#[derive(Subcommand)] +enum BetaAppReviewSubmissionsCommand { + /// List Beta App Review submissions for a build. + List { + /// The ASC build ID whose submission record to retrieve. + #[arg(long)] + build_id: String, + }, + /// Submit a build to Beta App Review. + /// + /// This is irreversible — it immediately sends the build to Apple's + /// review queue. Use `--dry-run` to confirm which build would be submitted + /// before committing. + Submit { + /// The ASC build ID to submit for review. + #[arg(long)] + build_id: String, + }, } #[derive(Subcommand)] @@ -860,6 +971,7 @@ async fn main() -> Result<()> { run_review_submissions(&client, command, cli.dry_run).await } Command::SalesReports { command } => run_sales_reports(&client, command).await, + Command::Testflight { command } => run_testflight(&client, command, cli.dry_run).await, } } @@ -905,6 +1017,89 @@ async fn run_sales_reports(client: &Client, command: SalesReportsCommand) -> Res } } +async fn run_testflight(client: &Client, command: TestflightCommand, dry_run: bool) -> Result<()> { + match command { + TestflightCommand::Groups { command } => match command { + BetaGroupsCommand::List { app_id } => { + let groups = client.list_beta_groups(&app_id).await?; + print_json(&groups) + } + BetaGroupsCommand::Get { group_id } => { + let group = client.get_beta_group(&group_id).await?; + print_json(&group) + } + BetaGroupsCommand::AddBuild { + group_id, + build_ids, + } => { + if dry_run { + println!( + "[dry-run] would add {} build(s) to beta group {group_id}: {}", + build_ids.len(), + build_ids.join(", ") + ); + return Ok(()); + } + let ids: Vec<&str> = build_ids.iter().map(String::as_str).collect(); + client.add_builds_to_beta_group(&group_id, &ids).await?; + println!("Added {} build(s) to group {group_id}.", build_ids.len()); + Ok(()) + } + }, + TestflightCommand::AppReviewDetail { command } => match command { + BetaAppReviewDetailCommand::Get { app_id } => { + let detail = client.get_beta_app_review_detail(&app_id).await?; + print_json(&detail) + } + BetaAppReviewDetailCommand::Update { + app_id, + contact_email, + contact_first_name, + contact_last_name, + contact_phone, + demo_account_name, + demo_account_password, + demo_account_required, + notes, + } => { + let update = BetaAppReviewDetailUpdate { + contact_email, + contact_first_name, + contact_last_name, + contact_phone, + demo_account_name, + demo_account_password, + demo_account_required, + notes, + }; + if dry_run { + println!("[dry-run] would update Beta App Review detail for app {app_id}:"); + print_json(&update)?; + return Ok(()); + } + let detail = client + .update_beta_app_review_detail(&app_id, update) + .await?; + print_json(&detail) + } + }, + TestflightCommand::AppReviewSubmissions { command } => match command { + BetaAppReviewSubmissionsCommand::List { build_id } => { + let submissions = client.list_beta_app_review_submissions(&build_id).await?; + print_json(&submissions) + } + BetaAppReviewSubmissionsCommand::Submit { build_id } => { + if dry_run { + println!("[dry-run] would submit build {build_id} to Beta App Review."); + return Ok(()); + } + let submission = client.submit_build_for_beta_review(&build_id).await?; + print_json(&submission) + } + }, + } +} + async fn run_apps( client: &Client, altool_auth: &AltoolAuth, diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 7d94e45..14097e8 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -66,7 +66,9 @@ macro_rules! ascapi_mcp_tools { $app_screenshot_upload_name:literal, $app_screenshot_delete_name:literal, $certificate_list_name:literal, - $certificate_create_name:literal + $certificate_create_name:literal, + $beta_group_list_name:literal, + $beta_app_review_detail_get_name:literal ) => { #[::rmcp::tool_router(router = ascapi_tool_router, vis = "pub(crate)")] impl $server { @@ -881,6 +883,52 @@ macro_rules! ascapi_mcp_tools { ::rmcp::model::ContentBlock::json(&outcome)?, ])) } + + #[::rmcp::tool( + name = $beta_group_list_name, + title = "List beta groups", + annotations(title = "List beta groups", read_only_hint = true, idempotent_hint = true), + description = "Purpose: list every TestFlight beta group (internal and external) for an app, so an agent knows which group ids are available before adding builds or testers. When to use vs siblings: call this first to discover group ids; use beta_app_review_detail_get to retrieve the contact info Apple's beta review team will see. Behavior: returns each group's id, name, whether it is an internal group, whether it requires a public link, the public link URL (when enabled), and tester counts where available. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: tester emails are not included; use the App Store Connect portal or the CLI to enumerate individual testers." + )] + async fn beta_group_list( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::BetaGroupListRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let groups = client + .list_beta_groups(&request.app_id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&groups)?, + ])) + } + + #[::rmcp::tool( + name = $beta_app_review_detail_get_name, + title = "Get beta app review contact detail", + annotations(title = "Get beta app review contact detail", read_only_hint = true, idempotent_hint = true), + description = "Purpose: retrieve the contact information Apple's TestFlight review team will use when evaluating a beta submission, such as the first name, last name, phone number, email, and any demo account credentials. When to use vs siblings: call this to verify or surface the review contact before submitting a build for beta review; use beta_group_list to discover available groups. Behavior: returns the beta app review detail resource for the given app id. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: updating the contact detail is a CLI-only operation." + )] + async fn beta_app_review_detail_get( + &self, + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::BetaAppReviewDetailGetRequest>, + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> { + #[allow(unused_imports)] + use ::smbcloud_ascapi_aso::prelude::*; + let client = Self::client()?; + let request = parameters.0; + let detail = client + .get_beta_app_review_detail(&request.app_id) + .await + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&detail)?, + ])) + } } }; } diff --git a/crates/mcp/src/requests.rs b/crates/mcp/src/requests.rs index d737dba..48ebfb0 100644 --- a/crates/mcp/src/requests.rs +++ b/crates/mcp/src/requests.rs @@ -262,3 +262,16 @@ pub struct CertificateCreateRequest { #[serde(default)] pub common_name: Option, } + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct BetaGroupListRequest { + /// App Store Connect app id whose beta groups to list. + pub app_id: String, +} + +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +pub struct BetaAppReviewDetailGetRequest { + /// App Store Connect app id whose beta app review contact detail to + /// retrieve. + pub app_id: String, +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 983320b..273b0e2 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -56,7 +56,9 @@ ascapi_mcp_tools!( "app_screenshot_upload", "app_screenshot_delete", "certificate_list", - "certificate_create" + "certificate_create", + "beta_group_list", + "beta_app_review_detail_get" ); #[rmcp::tool_handler(router = Self::ascapi_tool_router())] @@ -105,7 +107,7 @@ mod tests { /// The canonical tool names. Adding or renaming one is a /// public-contract change, and this list is what makes that /// deliberate rather than accidental. - const CANONICAL_TOOL_NAMES: [&str; 28] = [ + const CANONICAL_TOOL_NAMES: [&str; 30] = [ "app_list", "app_get", "app_update", @@ -134,6 +136,8 @@ mod tests { "app_screenshot_delete", "certificate_list", "certificate_create", + "beta_group_list", + "beta_app_review_detail_get", ]; /// Never a tool, at any point, for any caller. From cd33a06136ee115bc61993260a9f9429fd642858 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:26:30 +0200 Subject: [PATCH 6/7] docs(aso): clarify list_builds limit=200 is page size, not a cap The `limit=200` on the builds collection reads like a ceiling, but `list_all` follows Apple's `links.next` cursor until the collection is exhausted. Spell out that 200 is the largest page ASC allows (fewer round-trips), not the number of builds returned, so callers don't treat the vector length as "the 200 most recent builds." Co-Authored-By: siGit Code v1.6.7-acp --- crates/aso/src/build.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/aso/src/build.rs b/crates/aso/src/build.rs index 5f392a8..3f78b4e 100644 --- a/crates/aso/src/build.rs +++ b/crates/aso/src/build.rs @@ -97,6 +97,13 @@ pub trait BuildsApi { /// Pass `&BuildFilter::default()` for every build, or set fields to /// narrow by build number, pre-release (marketing) version, or /// processing state. + /// + /// The `limit=200` this sends is a *page size*, not a ceiling on the + /// result: [`Client::list_all`] follows Apple's `links.next` cursor until + /// the collection is exhausted, so an app with more than 200 builds still + /// returns all of them — 200 is just the largest page ASC allows for this + /// collection, which keeps the round-trip count down. Do not read the + /// vector's length as "the 200 most recent builds." async fn list_builds(&self, app_id: &str, filter: &BuildFilter) -> Result>; /// `GET /v1/builds/{id}` — a single build by its App Store Connect id. From a7f3e081202418588e813e7f1ab0718466f7b77f Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:49:00 +0200 Subject: [PATCH 7/7] feat(apps): add await-processing to poll builds to terminal state Adds `asc apps await-processing `, 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 --- Cargo.lock | 1 + crates/cli/src/main.rs | 57 ++++++ crates/frontend/Cargo.toml | 1 + crates/frontend/src/await_processing.rs | 243 ++++++++++++++++++++++++ crates/frontend/src/lib.rs | 4 + 5 files changed, 306 insertions(+) create mode 100644 crates/frontend/src/await_processing.rs diff --git a/Cargo.lock b/Cargo.lock index 52aa8d2..a4e0cbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1780,6 +1780,7 @@ dependencies = [ "smbcloud-ascapi-aso", "smbcloud-ascapi-core", "smbcloud-ascapi-signing", + "tokio", ] [[package]] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 30bfdab..635f3ee 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -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; @@ -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. @@ -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, + /// `filter[preReleaseVersion.version]` — the marketing version the + /// build sits under, e.g. `1.1.0`. + #[arg(long)] + pre_release_version: Option, + /// 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)] @@ -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(()) + } } } diff --git a/crates/frontend/Cargo.toml b/crates/frontend/Cargo.toml index 19aa59d..f3438e0 100644 --- a/crates/frontend/Cargo.toml +++ b/crates/frontend/Cargo.toml @@ -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"] } diff --git a/crates/frontend/src/await_processing.rs b/crates/frontend/src/await_processing.rs new file mode 100644 index 0000000..0f1b760 --- /dev/null +++ b/crates/frontend/src/await_processing.rs @@ -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, + /// The `processingState` the build ended on, verbatim from Apple. + pub processing_state: Option, + /// 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 { + 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); + } +} diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index a53312b..b3ac35a 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -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; @@ -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, };