Read back the build attached to an App Store Version (#42)

set-build printed only the version resource, whose build relationship carries links, so a wrong build only showed up at review. Add GET /v1/appStoreVersions/{id}/build, a shared attach_build that reads the build back and fails on a missing or different id, a read-only `app-store-versions build` command and `app_store_version_build_get` MCP tool, and a --no-verify escape on set-build. README gains a Credentials section. Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 21, 2026 at 13:21 UTC 0ef60df7c0c0b7e395b5220ddab11c7173e24a02
9 files changed +247 -11
.agents/AGENTS.md
+10
@@ -197,6 +197,16 @@ The shortest path from a signed `.pkg` to a TestFlight group:
197 ## Review submissions (CLI)
198
199 ```sh
200 +# GET /v1/appStoreVersions/{id}/build — the build attached to a version, or
201 +# null (with "no build attached" on stderr). Run it before submitting.
202 +asc app-store-versions build <APP_STORE_VERSION_ID>
203 +
204 +# PATCH the version's build relationship, then read the build back and fail if
205 +# it isn't the one requested. Prints the build's id, version, processingState
206 +# and usesNonExemptEncryption. --no-verify skips the read-back; --dry-run
207 +# stays offline.
208 +asc app-store-versions set-build <APP_STORE_VERSION_ID> --build-id <BUILD_ID> [--no-verify]
209 +
210 # GET /v1/reviewSubmissions?filter[app]={id}
211 asc review-submissions list <APP_ID> [--platform <...>] [--state READY_FOR_REVIEW]
212
README.md
+13
@@ -28,6 +28,19 @@ use smbcloud_ascapi_aso::prelude::*; // or pricing::prelude, signing::prelu
28
29 MCP Registry name: `mcp-name: io.github.smbcloudXYZ/ascapi`
30
31 +## Credentials
32 +
33 +The CLI signs requests with an App Store Connect API key:
34 +
35 +| Flag | Environment variable | Notes |
36 +| --- | --- | --- |
37 +| `--key-id` | `ASC_API_KEY` | The key id. There is no `ASC_KEY_ID`. |
38 +| `--issuer-id` | `ASC_ISSUER_ID` | The issuer id from App Store Connect. |
39 +| `--private-key-path` | `ASC_PRIVATE_KEY_PATH` | Defaults to `~/.appstoreconnect/private_keys/AuthKey_<key-id>.p8`. |
40 +
41 +With `--mcp`, credentials are resolved on each tool call, so the server can
42 +list its tools before they are configured.
43 +
44 ## Copyright
45
46 © 2026 PT Sigit Mitra Bangun ([smbCloud](https://smbcloud.xyz)).
crates/aso/src/app_store_version.rs
+47 -2
@@ -9,12 +9,14 @@ use async_trait::async_trait;
9 use reqwest::Method;
10 use serde::{Deserialize, Serialize};
11 use smbcloud_ascapi_core::jsonapi::{
12 - CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData,
13 - UpdateRelationshipsBody, UpdateRelationshipsData,
12 + CreateBody, CreateData, Document, OptionalDocument, Resource, ResourceId, ToOne, UpdateBody,
13 + UpdateData, UpdateRelationshipsBody, UpdateRelationshipsData,
14 };
15 use smbcloud_ascapi_core::Client;
16 use smbcloud_ascapi_core::Result;
17
18 +use crate::build::{Build, BuildAttributes};
19 +
20 pub const RESOURCE_TYPE: &str = "appStoreVersions";
21
22 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -132,6 +134,12 @@ pub trait AppStoreVersionsApi {
134 id: &str,
135 build_id: &str,
136 ) -> Result<AppStoreVersion>;
137 +
138 + /// `GET /v1/appStoreVersions/{id}/build` — the build currently attached
139 + /// to this version, or `None` when there isn't one. The version resource
140 + /// itself only carries a link to it, so this is how to confirm what a
141 + /// submission would send.
142 + async fn get_app_store_version_build(&self, id: &str) -> Result<Option<Build>>;
143 }
144
145 #[async_trait]
@@ -230,6 +238,13 @@ impl AppStoreVersionsApi for Client {
238 self.request(Method::PATCH, &path, &[], Some(&body)).await?;
239 Ok(doc.data)
240 }
241 +
242 + async fn get_app_store_version_build(&self, id: &str) -> Result<Option<Build>> {
243 + let path = format!("/v1/appStoreVersions/{id}/build");
244 + let doc: OptionalDocument<BuildAttributes> =
245 + self.request(Method::GET, &path, &[], None::<&()>).await?;
246 + Ok(doc.data)
247 + }
248 }
249
250 /// The `relationships` payload for pointing an App Store Version at a
@@ -294,4 +309,34 @@ mod tests {
309 Some("PREPARE_FOR_SUBMISSION")
310 );
311 }
312 +
313 + /// Apple answers `{"data": null}` for a version with no build attached;
314 + /// that has to parse as "none", not as an error.
315 + #[test]
316 + fn a_null_build_body_parses_to_none() {
317 + let doc: OptionalDocument<BuildAttributes> =
318 + serde_json::from_value(json!({ "data": null })).unwrap();
319 + assert!(doc.data.is_none());
320 + }
321 +
322 + #[test]
323 + fn a_populated_build_body_parses_with_its_version() {
324 + let doc: OptionalDocument<BuildAttributes> = serde_json::from_value(json!({
325 + "data": {
326 + "id": "build-1",
327 + "type": "builds",
328 + "attributes": {
329 + "version": "42",
330 + "processingState": "VALID",
331 + "usesNonExemptEncryption": false
332 + }
333 + }
334 + }))
335 + .unwrap();
336 +
337 + let build = doc.data.unwrap();
338 + assert_eq!(build.id, "build-1");
339 + assert_eq!(build.attributes.version.as_deref(), Some("42"));
340 + assert_eq!(build.attributes.uses_non_exempt_encryption, Some(false));
341 + }
342 }
crates/cli/src/main.rs
+26 -3
@@ -19,6 +19,7 @@ use smbcloud_ascapi_aso::build::{BuildFilter, BuildUpdateAttributes};
19 use smbcloud_ascapi_aso::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform};
20 use smbcloud_ascapi_aso::prelude::*;
21 use smbcloud_ascapi_core::{ApiKey, Client};
22 +use smbcloud_ascapi_frontend::attach_build::attach_build;
23 use smbcloud_ascapi_frontend::await_processing::{await_processing, AwaitConfig};
24 use smbcloud_ascapi_frontend::review;
25 use smbcloud_ascapi_frontend::upload::{self, upload_package, validate_app, AltoolAuth, AltoolOp};
@@ -722,7 +723,14 @@ enum AppStoreVersionsCommand {
723 id: String,
724 #[arg(long)]
725 build_id: String,
726 + /// Skip the read-back check that the version now reports this build.
727 + #[arg(long)]
728 + no_verify: bool,
729 },
730 + /// `GET /v1/appStoreVersions/{id}/build` — the build attached to this
731 + /// version, or `null` when there is none. Read-only; run it before
732 + /// `review-submissions submit`.
733 + Build { id: String },
734 }
735
736 #[derive(Subcommand)]
@@ -743,7 +751,8 @@ enum ReviewSubmissionsCommand {
751 Submit {
752 app_id: String,
753 /// The App Store Version to submit (see `app-store-versions list`).
746 - /// It needs a build attached (`app-store-versions set-build`).
754 + /// It needs a build attached (`app-store-versions set-build`;
755 + /// check with `app-store-versions build`).
756 #[arg(long)]
757 version_id: String,
758 },
@@ -1687,14 +1696,28 @@ async fn run_app_store_versions(
1696 println!("deleted {id}");
1697 Ok(())
1698 }
1690 - AppStoreVersionsCommand::SetBuild { id, build_id } => {
1699 + AppStoreVersionsCommand::Build { id } => {
1700 + let build = client.get_app_store_version_build(&id).await?;
1701 + if build.is_none() {
1702 + eprintln!("no build attached");
1703 + }
1704 + print_json(&build)
1705 + }
1706 + AppStoreVersionsCommand::SetBuild {
1707 + id,
1708 + build_id,
1709 + no_verify,
1710 + } => {
1711 if dry_run {
1712 println!(
1713 "# dry run — would PATCH /v1/appStoreVersions/{id} relationships.build → {build_id}"
1714 );
1715 return Ok(());
1716 }
1697 - print_json(&client.set_app_store_version_build(&id, &build_id).await?)
1717 + let attached = attach_build(client, &id, &build_id, !no_verify)
1718 + .await
1719 + .map_err(anyhow::Error::msg)?;
1720 + print_json(&attached)
1721 }
1722 }
1723 }
crates/core/src/jsonapi.rs
+8
@@ -11,6 +11,14 @@ pub struct Document<A> {
11 pub data: Resource<A>,
12 }
13
14 +/// A single-resource document whose `data` may be `null`, as Apple returns
15 +/// for a to-one related resource that isn't set, e.g. `GET
16 +/// /v1/appStoreVersions/{id}/build` on a version with no build attached.
17 +#[derive(Debug, Clone, Deserialize)]
18 +pub struct OptionalDocument<A> {
19 + pub data: Option<Resource<A>>,
20 +}
21 +
22 /// A resource-collection JSON:API document — the body of e.g. `GET
23 /// /v1/apps`.
24 ///
crates/frontend/src/attach_build.rs new
+103
@@ -0,0 +1,103 @@
1 +//! Attaching a build to an App Store Version, and confirming it took.
2 +//!
3 +//! The PATCH response is the version resource, whose `build` relationship
4 +//! holds only links, so it cannot say which build is attached. Reading the
5 +//! build back is what catches a wrong attachment before App Review does.
6 +
7 +use serde::Serialize;
8 +use smbcloud_ascapi_aso::build::Build;
9 +use smbcloud_ascapi_aso::prelude::*;
10 +use smbcloud_ascapi_core::Client;
11 +
12 +/// The outcome of [`attach_build`].
13 +#[derive(Debug, Clone, Serialize)]
14 +#[serde(rename_all = "camelCase")]
15 +pub struct AttachedBuild {
16 + pub version_id: String,
17 + /// The build read back from App Store Connect: its id, `version`,
18 + /// `processingState` and `usesNonExemptEncryption`. `None` only when
19 + /// verification was skipped.
20 + pub build: Option<Build>,
21 +}
22 +
23 +/// Point `version_id` at `build_id`, then (when `verify`) read the attached
24 +/// build back and fail unless it is the one requested.
25 +pub async fn attach_build(
26 + client: &Client,
27 + version_id: &str,
28 + build_id: &str,
29 + verify: bool,
30 +) -> Result<AttachedBuild, String> {
31 + client
32 + .set_app_store_version_build(version_id, build_id)
33 + .await
34 + .map_err(|error| error.to_string())?;
35 + if !verify {
36 + return Ok(AttachedBuild {
37 + version_id: version_id.to_string(),
38 + build: None,
39 + });
40 + }
41 + let attached = client
42 + .get_app_store_version_build(version_id)
43 + .await
44 + .map_err(|error| error.to_string())?;
45 + check_attached(build_id, attached.as_ref())?;
46 + Ok(AttachedBuild {
47 + version_id: version_id.to_string(),
48 + build: attached,
49 + })
50 +}
51 +
52 +/// The read-back check, split out so it can be tested without a network.
53 +fn check_attached(expected: &str, attached: Option<&Build>) -> Result<(), String> {
54 + match attached {
55 + None => Err(format!(
56 + "attached build {expected}, but the version reports no build"
57 + )),
58 + Some(build) if build.id != expected => Err(format!(
59 + "asked to attach build {expected}, but the version reports build {}",
60 + build.id
61 + )),
62 + Some(_) => Ok(()),
63 + }
64 +}
65 +
66 +#[cfg(test)]
67 +mod tests {
68 + use super::*;
69 + use smbcloud_ascapi_aso::build::BuildAttributes;
70 +
71 + fn build(id: &str) -> Build {
72 + Build {
73 + id: id.to_string(),
74 + resource_type: "builds".to_string(),
75 + attributes: BuildAttributes {
76 + version: Some("1".to_string()),
77 + uploaded_date: None,
78 + expired: None,
79 + processing_state: None,
80 + min_os_version: None,
81 + uses_non_exempt_encryption: None,
82 + },
83 + relationships: None,
84 + }
85 + }
86 +
87 + #[test]
88 + fn a_matching_build_passes() {
89 + assert!(check_attached("b1", Some(&build("b1"))).is_ok());
90 + }
91 +
92 + #[test]
93 + fn no_build_is_an_error_naming_the_expected_id() {
94 + let error = check_attached("b1", None).unwrap_err();
95 + assert!(error.contains("b1"));
96 + }
97 +
98 + #[test]
99 + fn a_different_build_names_both_ids() {
100 + let error = check_attached("b1", Some(&build("b2"))).unwrap_err();
101 + assert!(error.contains("b1") && error.contains("b2"));
102 + }
103 +}
crates/frontend/src/lib.rs
+2
@@ -20,6 +20,7 @@
20 //! `profileContent`, and [`profiles::download_profile`] returns the path
21 //! it wrote instead.
22
23 +pub mod attach_build;
24 pub mod await_processing;
25 pub mod certificates;
26 pub mod enums;
@@ -31,6 +32,7 @@ pub mod review;
32 pub mod time;
33 pub mod upload;
34
35 +pub use attach_build::{attach_build, AttachedBuild};
36 pub use await_processing::{
37 await_processing, classify, AwaitConfig, AwaitOutcome, ProcessingClass,
38 };
crates/mcp/src/lib.rs
+35 -5
@@ -51,6 +51,7 @@ macro_rules! ascapi_mcp_tools {
51 $app_store_version_create_name:literal,
52 $app_store_version_delete_name:literal,
53 $app_store_version_set_build_name:literal,
54 + $app_store_version_build_get_name:literal,
55 $app_info_localization_list_name:literal,
56 $app_info_localization_create_name:literal,
57 $app_info_localization_update_name:literal,
@@ -305,7 +306,7 @@ macro_rules! ascapi_mcp_tools {
306 name = $app_store_version_get_name,
307 title = "Get one app store version",
308 annotations(title = "Get one app store version", read_only_hint = true, idempotent_hint = true),
308 - description = "Purpose: fetch one App Store Version's attributes by id. When to use vs siblings: use app_store_version_list when you have an app id rather than a version id. Behavior: returns the single version resource with its platform, version string, and release state. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_<key-id>.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: attributes only; its localizations and attached build are fetched with their own tools."
309 + description = "Purpose: fetch one App Store Version's attributes by id. When to use vs siblings: use app_store_version_list when you have an app id rather than a version id. Behavior: returns the single version resource with its platform, version string, and release state. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_<key-id>.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: attributes only; its localizations are fetched with their own tools, and its attached build with app_store_version_build_get."
310 )]
311 async fn app_store_version_get(
312 &self,
@@ -387,7 +388,7 @@ macro_rules! ascapi_mcp_tools {
388 name = $app_store_version_set_build_name,
389 title = "Attach a build to a version",
390 annotations(title = "Attach a build to a version", read_only_hint = false, destructive_hint = false, idempotent_hint = true),
390 - description = "Purpose: attach an uploaded build to an App Store Version, which a version needs before it can be submitted. When to use vs siblings: get the build id from build_list and the version id from app_store_version_list. Behavior: points the version's build relationship at the given build, replacing whatever was attached. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_<key-id>.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: the build must have finished processing, and its platform must match the version's; Apple rejects the pairing otherwise."
391 + description = "Purpose: attach an uploaded build to an App Store Version, which a version needs before it can be submitted. When to use vs siblings: get the build id from build_list and the version id from app_store_version_list. Behavior: points the version's build relationship at the given build, replacing whatever was attached, then reads the build back and fails if the version does not report it; returns the version id and the attached build with its version, processing state, and export-compliance answer. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_<key-id>.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: the build must have finished processing, and its platform must match the version's; Apple rejects the pairing otherwise."
392 )]
393 async fn app_store_version_set_build(
394 &self,
@@ -399,12 +400,41 @@ macro_rules! ascapi_mcp_tools {
400 use ::smbcloud_ascapi_signing::prelude::*;
401 let client = Self::client()?;
402 let request = parameters.0;
402 - client
403 - .set_app_store_version_build(&request.version_id, &request.build_id)
403 + let attached = ::smbcloud_ascapi_frontend::attach_build(
404 + &client,
405 + &request.version_id,
406 + &request.build_id,
407 + true,
408 + )
409 + .await
410 + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error, None))?;
411 + Ok(::rmcp::model::CallToolResult::success(vec![
412 + ::rmcp::model::ContentBlock::json(&attached)?,
413 + ]))
414 + }
415 +
416 + #[::rmcp::tool(
417 + name = $app_store_version_build_get_name,
418 + title = "Get the build attached to a version",
419 + annotations(title = "Get the build attached to a version", read_only_hint = true, idempotent_hint = true),
420 + description = "Purpose: read which build is attached to an App Store Version, so it can be checked before the version is submitted for review. When to use vs siblings: app_store_version_get returns the version without its build; use this after app_store_version_set_build or before review_submission_submit. Behavior: returns the attached build with its id, version string, processing state, and export-compliance answer, or null when no build is attached. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_<key-id>.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: reports only the current attachment, not earlier builds; use build_list for the builds uploaded for an app."
421 + )]
422 + async fn app_store_version_build_get(
423 + &self,
424 + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::IdRequest>,
425 + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> {
426 + #[allow(unused_imports)]
427 + use ::smbcloud_ascapi_aso::prelude::*;
428 + #[allow(unused_imports)]
429 + use ::smbcloud_ascapi_signing::prelude::*;
430 + let client = Self::client()?;
431 + let request = parameters.0;
432 + let value = client
433 + .get_app_store_version_build(&request.id)
434 .await
435 .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?;
436 Ok(::rmcp::model::CallToolResult::success(vec![
407 - ::rmcp::model::ContentBlock::json(&::serde_json::json!({ "versionId": request.version_id, "buildId": request.build_id }))?,
437 + ::rmcp::model::ContentBlock::json(&value)?,
438 ]))
439 }
440
crates/mcp/src/server.rs
+3 -1
@@ -41,6 +41,7 @@ ascapi_mcp_tools!(
41 "app_store_version_create",
42 "app_store_version_delete",
43 "app_store_version_set_build",
44 + "app_store_version_build_get",
45 "app_info_localization_list",
46 "app_info_localization_create",
47 "app_info_localization_update",
@@ -107,7 +108,7 @@ mod tests {
108 /// The canonical tool names. Adding or renaming one is a
109 /// public-contract change, and this list is what makes that
110 /// deliberate rather than accidental.
110 - const CANONICAL_TOOL_NAMES: [&str; 30] = [
111 + const CANONICAL_TOOL_NAMES: [&str; 31] = [
112 "app_list",
113 "app_get",
114 "app_update",
@@ -120,6 +121,7 @@ mod tests {
121 "app_store_version_create",
122 "app_store_version_delete",
123 "app_store_version_set_build",
124 + "app_store_version_build_get",
125 "app_info_localization_list",
126 "app_info_localization_create",
127 "app_info_localization_update",