Add apps upload over xcrun altool

`ascapi apps upload <package> [--wait]` runs `xcrun altool --upload-package` with the key id, issuer and .p8 path the CLI already resolved. Passing --p8-file-path stops altool from searching its key directories, where it can find a stale AuthKey_<id>.p8 and fail with a 401 even though the key the API calls use is fine. --auth-string is never used, because it would put the key in the process table. The package must be an existing .ipa or .pkg, so an unexported .xcarchive fails before altool runs. altool's stderr is passed through so progress shows, and its JSON stdout comes back with the delivery id picked out. A failed upload prints altool's output and exits non-zero. --dry-run prints the argv instead of running it. Upload is CLI-only. An MCP tool call that blocks for minutes and publishes a build is the wrong shape for that surface. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 8, 2026 at 23:21 UTC 0b231ac96a8ef7d894327685a103ed6bbb386aed
6 files changed +282 -6
CLAUDE.md
+11 -2
@@ -35,7 +35,7 @@ type). Import a domain's `prelude::*` to pull its traits in at once.
35
36 ## Builds API (CLI)
37
38 -All three are subcommands of `apps` (clap kebab-cases the command names):
38 +All are subcommands of `apps` (clap kebab-cases the command names):
39
40 ```sh
41 # GET /v1/builds?filter[app]={id} — newest first. Check processingState
@@ -51,11 +51,20 @@ ascapi apps build <BUILD_ID>
51 # GET /v1/preReleaseVersions?filter[app]={id} — the marketing version strings
52 # TestFlight groups builds under.
53 ascapi apps pre-release-versions <APP_ID> [--platform <ios|mac-os|tv-os|vision-os>]
54 +
55 +# xcrun altool --upload-package, authenticated with the same key id, issuer
56 +# and .p8 (--p8-file-path) as every other command. --dry-run prints the argv.
57 +ascapi apps upload <PATH.ipa|PATH.pkg> [--wait]
58 ```
59
60 The underlying API lives in `crates/aso/src/build.rs`: `BuildsApi` (in the
61 `aso` prelude) plus `BuildFilter`, whose private `query_pairs` maps each set
58 -field to its ASC filter key.
62 +field to its ASC filter key. The upload lives in
63 +`crates/frontend/src/upload.rs`. It always passes `--p8-file-path` and never
64 +`--auth-string`: letting altool search its key directories can pick up a
65 +stale `AuthKey_<id>.p8` and fail with a 401, and `--auth-string` would put
66 +the key in the process table. Upload is CLI-only, because a tool call that
67 +blocks for minutes and publishes a build doesn't belong in MCP.
68
69 ## Verify before committing
70
README.md
+1 -1
@@ -11,7 +11,7 @@ from the other:
11 | `smbcloud-ascapi-aso` | App Metadata: apps, app infos, versions, builds, pre-release versions, bundle IDs, localizations, screenshots |
12 | `smbcloud-ascapi-pricing` | Pricing: app price schedules, and the per-territory prices joined with their price points and currencies |
13 | `smbcloud-ascapi-signing` | Code signing: certificates and provisioning profiles, plus local RSA key pair and CSR generation |
14 -| `smbcloud-ascapi-frontend` | Operations both surfaces share, so the CLI and the MCP server agree by construction |
14 +| `smbcloud-ascapi-frontend` | Operations both surfaces share, so the CLI and the MCP server agree by construction, plus the CLI's `xcrun altool` upload |
15 | `smbcloud-ascapi-mcp` | The MCP contract and stdio server |
16 | `smbcloud-ascapi-cli` | The `ascapi` binary: clap command tree, plus `--mcp` |
17
crates/cli/src/main.rs
+44 -2
@@ -15,6 +15,7 @@ use smbcloud_ascapi_aso::build::BuildFilter;
15 use smbcloud_ascapi_aso::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform};
16 use smbcloud_ascapi_aso::prelude::*;
17 use smbcloud_ascapi_core::{ApiKey, Client};
18 +use smbcloud_ascapi_frontend::upload::{self, upload_package, AltoolAuth};
19 use smbcloud_ascapi_pricing::app_price::PriceKind;
20 use smbcloud_ascapi_pricing::prelude::*;
21 use smbcloud_ascapi_reports::prelude::*;
@@ -415,6 +416,16 @@ enum AppsCommand {
416 #[arg(long, value_enum)]
417 platform: Option<CliPlatform>,
418 },
419 + /// Upload an exported `.ipa`/`.pkg` with `xcrun altool --upload-package`,
420 + /// authenticated with the same key and `.p8` as every other command.
421 + /// Afterwards, find the build with `apps builds <APP_ID> --version <N>`.
422 + Upload {
423 + /// The exported package (`xcodebuild -exportArchive` output).
424 + package: PathBuf,
425 + /// Block until App Store Connect finishes processing the build.
426 + #[arg(long)]
427 + wait: bool,
428 + },
429 }
430
431 #[derive(Subcommand)]
@@ -766,6 +777,11 @@ async fn main() -> Result<()> {
777 path
778 });
779
780 + let altool_auth = AltoolAuth {
781 + key_id: key_id.clone(),
782 + issuer_id: issuer_id.clone(),
783 + private_key_path: private_key_path.clone(),
784 + };
785 let api_key = ApiKey::from_p8_file(&key_id, &issuer_id, &private_key_path)
786 .with_context(|| format!("loading App Store Connect API key from {private_key_path:?}"))?;
787 let client = Client::new(api_key);
@@ -777,7 +793,7 @@ async fn main() -> Result<()> {
793 };
794
795 match command {
780 - Command::Apps { command } => run_apps(&client, command, cli.dry_run).await,
796 + Command::Apps { command } => run_apps(&client, &altool_auth, command, cli.dry_run).await,
797 Command::BundleIds { command } => run_bundle_ids(&client, command, cli.dry_run).await,
798 Command::AppStoreVersions { command } => {
799 run_app_store_versions(&client, command, cli.dry_run).await
@@ -843,7 +859,12 @@ async fn run_sales_reports(client: &Client, command: SalesReportsCommand) -> Res
859 }
860 }
861
846 -async fn run_apps(client: &Client, command: AppsCommand, dry_run: bool) -> Result<()> {
862 +async fn run_apps(
863 + client: &Client,
864 + altool_auth: &AltoolAuth,
865 + command: AppsCommand,
866 + dry_run: bool,
867 +) -> Result<()> {
868 match command {
869 AppsCommand::List { bundle_id } => {
870 let apps = client.list_apps(bundle_id.as_deref()).await?;
@@ -882,6 +903,27 @@ async fn run_apps(client: &Client, command: AppsCommand, dry_run: bool) -> Resul
903 let platform = platform.map(Platform::from);
904 print_json(&client.list_pre_release_versions(&app_id, platform).await?)
905 }
906 + AppsCommand::Upload { package, wait } => {
907 + if dry_run {
908 + let mut argv = vec!["xcrun".to_string()];
909 + argv.extend(
910 + upload::altool_args(&package, altool_auth, wait)
911 + .iter()
912 + .map(|arg| arg.to_string_lossy().into_owned()),
913 + );
914 + return print_json(&serde_json::json!({ "dry_run": true, "command": argv }));
915 + }
916 + let auth = altool_auth.clone();
917 + let outcome =
918 + tokio::task::spawn_blocking(move || upload_package(&package, &auth, wait))
919 + .await?
920 + .map_err(anyhow::Error::msg)?;
921 + print_json(&outcome)?;
922 + if !outcome.success {
923 + anyhow::bail!("altool upload failed (exit code {:?})", outcome.exit_code);
924 + }
925 + Ok(())
926 + }
927 }
928 }
929
crates/frontend/Cargo.toml
+1 -1
@@ -5,7 +5,7 @@ edition.workspace = true
5 authors.workspace = true
6 license.workspace = true
7 repository.workspace = true
8 -description = "Shared operations behind the ascapi command line and MCP surfaces: credential resolution, result shaping, and certificate issuance."
8 +description = "Shared operations behind the ascapi command line and MCP surfaces: credential resolution, result shaping, certificate issuance, and altool uploads."
9
10 [lib]
11 path = "src/lib.rs"
crates/frontend/src/lib.rs
+2
@@ -25,6 +25,7 @@ pub mod enums;
25 pub mod env;
26 pub mod profiles;
27 pub mod time;
28 +pub mod upload;
29
30 pub use certificates::{
31 certificate_type_from_str, issue_certificate, CertificateSummary, IssuedCertificate,
@@ -33,3 +34,4 @@ pub use enums::{bundle_id_platform_from_str, display_type_from_str, platform_fro
34 pub use env::api_key_from_env;
35 pub use profiles::{download_profile, profile_type_from_str, DownloadedProfile, ProfileSummary};
36 pub use time::{is_expired, now_iso8601};
37 +pub use upload::{upload_package, AltoolAuth, UploadOutcome};
crates/frontend/src/upload.rs new
+223
@@ -0,0 +1,223 @@
1 +//! Uploading an `.ipa`/`.pkg` to App Store Connect through `xcrun altool`.
2 +//!
3 +//! The App Store Connect REST API has no binary-upload endpoint a client
4 +//! can drive end to end, so this shells out to Apple's own uploader. What
5 +//! this module adds over calling altool by hand is authentication parity:
6 +//! altool is handed the exact `.p8` the rest of the CLI resolved, via
7 +//! `--p8-file-path`, instead of searching `~/.appstoreconnect/private_keys`
8 +//! and friends for an `AuthKey_<id>.p8`. A search can find a stale key with
9 +//! the same id that the API then rejects with a 401, which is
10 +//! indistinguishable at the call site from a revoked key.
11 +//!
12 +//! Once the upload succeeds, look the build up with
13 +//! [`BuildsApi::list_builds`](smbcloud_ascapi_aso::build::BuildsApi::list_builds)
14 +//! filtered by build number, or pass `wait` to have altool block until App
15 +//! Store Connect finishes processing.
16 +
17 +use serde::Serialize;
18 +use std::ffi::OsString;
19 +use std::path::{Path, PathBuf};
20 +use std::process::{Command, Stdio};
21 +
22 +/// The API key altool authenticates with. Paths and ids only: altool's
23 +/// `--auth-string` would take the key itself, which would then sit in the
24 +/// process table for any local user to read, so it is never used.
25 +#[derive(Debug, Clone)]
26 +pub struct AltoolAuth {
27 + pub key_id: String,
28 + pub issuer_id: String,
29 + pub private_key_path: PathBuf,
30 +}
31 +
32 +/// The result of one altool upload, as reported to callers.
33 +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
34 +pub struct UploadOutcome {
35 + pub package: String,
36 + pub success: bool,
37 + /// altool's exit status; `None` if it was killed by a signal.
38 + pub exit_code: Option<i32>,
39 + /// The delivery UUID altool reports for a successful upload, if its
40 + /// output carried one. `altool --build-status --delivery-id` takes it.
41 + pub delivery_id: Option<String>,
42 + /// altool's `--output-format json` body when it parsed, otherwise its
43 + /// stdout as a string.
44 + pub output: serde_json::Value,
45 +}
46 +
47 +/// The arguments after `xcrun`. Separate from [`upload_package`] so the
48 +/// exact command line is testable and `--dry-run` can print it.
49 +pub fn altool_args(package: &Path, auth: &AltoolAuth, wait: bool) -> Vec<OsString> {
50 + let mut args: Vec<OsString> = vec![
51 + "altool".into(),
52 + "--upload-package".into(),
53 + package.as_os_str().to_owned(),
54 + "--api-key".into(),
55 + auth.key_id.clone().into(),
56 + "--api-issuer".into(),
57 + auth.issuer_id.clone().into(),
58 + "--p8-file-path".into(),
59 + auth.private_key_path.as_os_str().to_owned(),
60 + "--output-format".into(),
61 + "json".into(),
62 + ];
63 + if wait {
64 + args.push("--wait".into());
65 + }
66 + args
67 +}
68 +
69 +/// Check the package before spending minutes in altool on it.
70 +pub fn validate_package(package: &Path) -> Result<(), String> {
71 + let extension = package
72 + .extension()
73 + .and_then(|extension| extension.to_str())
74 + .map(str::to_ascii_lowercase);
75 + match extension.as_deref() {
76 + Some("ipa") | Some("pkg") => {}
77 + _ => {
78 + return Err(format!(
79 + "{} is not an .ipa or .pkg; export the archive first \
80 + (xcodebuild -exportArchive)",
81 + package.display()
82 + ))
83 + }
84 + }
85 + if !package.is_file() {
86 + return Err(format!("{} does not exist", package.display()));
87 + }
88 + Ok(())
89 +}
90 +
91 +/// Run `xcrun altool --upload-package` and report what happened.
92 +///
93 +/// Blocks for the length of the upload (and processing, with `wait`), so
94 +/// async callers should run it on a blocking thread. altool's stderr is
95 +/// passed through so its progress stays visible; stdout is captured for
96 +/// the JSON result.
97 +///
98 +/// A failed upload is an `Ok` with `success: false`, not an `Err`: altool's
99 +/// own error body is the useful part and the caller should see it. `Err`
100 +/// means altool could not be run at all.
101 +pub fn upload_package(
102 + package: &Path,
103 + auth: &AltoolAuth,
104 + wait: bool,
105 +) -> Result<UploadOutcome, String> {
106 + validate_package(package)?;
107 + if !auth.private_key_path.is_file() {
108 + return Err(format!(
109 + "could not read the App Store Connect private key at {}",
110 + auth.private_key_path.display()
111 + ));
112 + }
113 +
114 + let output = Command::new("xcrun")
115 + .args(altool_args(package, auth, wait))
116 + .stdin(Stdio::null())
117 + .stderr(Stdio::inherit())
118 + .output()
119 + .map_err(|error| format!("running xcrun altool (is Xcode installed?): {error}"))?;
120 +
121 + Ok(outcome(package, output.status.code(), &output.stdout))
122 +}
123 +
124 +fn outcome(package: &Path, exit_code: Option<i32>, stdout: &[u8]) -> UploadOutcome {
125 + let output = serde_json::from_slice::<serde_json::Value>(stdout).unwrap_or_else(|_| {
126 + serde_json::Value::String(String::from_utf8_lossy(stdout).trim().to_string())
127 + });
128 + UploadOutcome {
129 + package: package.display().to_string(),
130 + success: exit_code == Some(0),
131 + exit_code,
132 + delivery_id: find_delivery_id(&output),
133 + output,
134 + }
135 +}
136 +
137 +/// altool has spelled this key differently across releases
138 +/// (`delivery-uuid` in the legacy tool), so search the body for any of
139 +/// the spellings rather than pinning one path.
140 +fn find_delivery_id(value: &serde_json::Value) -> Option<String> {
141 + const KEYS: [&str; 4] = ["delivery-uuid", "deliveryUuid", "delivery-id", "deliveryId"];
142 + match value {
143 + serde_json::Value::Object(map) => KEYS
144 + .iter()
145 + .find_map(|key| map.get(*key).and_then(|v| v.as_str()).map(str::to_string))
146 + .or_else(|| map.values().find_map(find_delivery_id)),
147 + serde_json::Value::Array(items) => items.iter().find_map(find_delivery_id),
148 + _ => None,
149 + }
150 +}
151 +
152 +#[cfg(test)]
153 +mod tests {
154 + use super::*;
155 +
156 + fn auth() -> AltoolAuth {
157 + AltoolAuth {
158 + key_id: "ABC123".to_string(),
159 + issuer_id: "57246542-96fe-1a63-e053-0824d011072a".to_string(),
160 + private_key_path: PathBuf::from("/keys/AuthKey_ABC123.p8"),
161 + }
162 + }
163 +
164 + fn strings(args: Vec<OsString>) -> Vec<String> {
165 + args.into_iter()
166 + .map(|arg| arg.into_string().unwrap())
167 + .collect()
168 + }
169 +
170 + #[test]
171 + fn passes_the_resolved_key_file_rather_than_letting_altool_search() {
172 + let args = strings(altool_args(Path::new("/out/Klepon.ipa"), &auth(), false));
173 + assert_eq!(
174 + args,
175 + [
176 + "altool",
177 + "--upload-package",
178 + "/out/Klepon.ipa",
179 + "--api-key",
180 + "ABC123",
181 + "--api-issuer",
182 + "57246542-96fe-1a63-e053-0824d011072a",
183 + "--p8-file-path",
184 + "/keys/AuthKey_ABC123.p8",
185 + "--output-format",
186 + "json",
187 + ]
188 + );
189 + }
190 +
191 + #[test]
192 + fn never_puts_key_material_on_the_command_line() {
193 + let args = strings(altool_args(Path::new("/out/Klepon.ipa"), &auth(), true));
194 + assert!(!args.iter().any(|arg| arg == "--auth-string"));
195 + assert_eq!(args.last().map(String::as_str), Some("--wait"));
196 + }
197 +
198 + #[test]
199 + fn rejects_an_unexported_archive() {
200 + let error = validate_package(Path::new("/out/Klepon.xcarchive")).unwrap_err();
201 + assert!(error.contains("exportArchive"), "unhelpful error: {error}");
202 + }
203 +
204 + #[test]
205 + fn reads_a_delivery_id_from_nested_json() {
206 + let body = br#"{"tool-version":"9.0","details":{"delivery-uuid":"d1e2f3"}}"#;
207 + let outcome = outcome(Path::new("/out/Klepon.ipa"), Some(0), body);
208 + assert!(outcome.success);
209 + assert_eq!(outcome.delivery_id.as_deref(), Some("d1e2f3"));
210 + }
211 +
212 + #[test]
213 + fn keeps_non_json_output_as_text_and_reports_failure() {
214 + let outcome = outcome(
215 + Path::new("/out/Klepon.ipa"),
216 + Some(1),
217 + b"ERROR: bad bundle\n",
218 + );
219 + assert!(!outcome.success);
220 + assert_eq!(outcome.output, serde_json::json!("ERROR: bad bundle"));
221 + assert_eq!(outcome.delivery_id, None);
222 + }
223 +}