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",