Add build filters, build lookup and pre-release versions

Shipping Klepon from Xcode 27 meant checking whether one specific upload had finished processing. That needs to narrow builds by build number, marketing version or processing state, and to list the marketing versions TestFlight groups builds under. list_builds now takes a BuildFilter and queries /v1/builds with filter[app]. The related endpoint /v1/apps/{id}/builds only accepts fields and limit, so the filters have to go to the top-level collection. get_build reads one build by id. list_pre_release_versions queries /v1/preReleaseVersions with filter[app] and an optional filter[platform]. Both list calls page through list_all with 200 rows per page. The CLI gains --version, --pre-release-version and --processing-state on apps builds, plus apps build <id> and apps pre-release-versions. The MCP list_builds tool passes an empty filter, so its behavior is unchanged. Tests pin the exact filter keys, because a wrong key returns the wrong rows without an error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 8, 2026 at 23:11 UTC 5d9ad921645d061e48cb6a3dd17b385e02941c17
6 files changed +345 -25
CLAUDE.md new
+74
@@ -0,0 +1,74 @@
1 +# CLAUDE.md
2 +
3 +Guidance for Claude Code (and other agents) working in this repository.
4 +
5 +## What this is
6 +
7 +`smbcloud-ascapi` is a Rust workspace wrapping the App Store Connect API. It
8 +ships two front ends over one shared core:
9 +
10 +- the `ascapi` CLI (`crates/cli`), and
11 +- an MCP stdio server (`crates/mcp`, reachable via `ascapi --mcp`).
12 +
13 +Both go through `smbcloud-ascapi-frontend` so they can't drift. See `README.md`
14 +for the full crate table.
15 +
16 +## Workspace layout
17 +
18 +Domain crates (`aso`, `pricing`, `signing`, `reports`) know nothing about each
19 +other or the front ends. Each extends `smbcloud_ascapi_core::Client` with an
20 +**extension trait** (Rust only allows inherent impls in the crate that owns the
21 +type). Import a domain's `prelude::*` to pull its traits in at once.
22 +
23 +## Conventions
24 +
25 +- JSON:API responses deserialize through `ListDocument<T>` / the core
26 + envelopes; `Resource.id` is the ASC id.
27 +- CLI `ValueEnum` wrappers (`CliPlatform`, `CliBundleIdPlatform`,
28 + `CliProfileType`, …) wrap the domain enums so clap owns the arg parsing;
29 + map them to the domain type in the match arm (e.g. `.map(Platform::from)`).
30 +- Tests live in-module: `#[cfg(test)] mod tests { use super::*; … }` with plain
31 + `#[test]` fns (see `crates/aso/src/bundle_id.rs`, `crates/aso/src/build.rs`).
32 +- Pin exact ASC filter keys in assertions — e.g.
33 + `filter[preReleaseVersion.version]` vs `filter[version]`; the wrong key
34 + silently returns the wrong rows.
35 +
36 +## Builds API (CLI)
37 +
38 +All three are subcommands of `apps` (clap kebab-cases the command names):
39 +
40 +```sh
41 +# GET /v1/builds?filter[app]={id} — newest first. Check processingState
42 +# (VALID / INVALID / PROCESSING / FAILED).
43 +ascapi apps builds <APP_ID> \
44 + [--version <BUILD_NUMBER>] \ # filter[version], e.g. 42
45 + [--pre-release-version <MARKETING>] \ # filter[preReleaseVersion.version], e.g. 1.1.0
46 + [--processing-state <STATE>] # filter[processingState]
47 +
48 +# GET /v1/builds/{id} — a single build by its ASC id.
49 +ascapi apps build <BUILD_ID>
50 +
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 +
56 +The underlying API lives in `crates/aso/src/build.rs`: `BuildsApi` (in the
57 +`aso` prelude) plus `BuildFilter`, whose private `query_pairs` maps each set
58 +field to its ASC filter key.
59 +
60 +## Verify before committing
61 +
62 +```sh
63 +cargo fmt --all
64 +cargo clippy --all-targets
65 +cargo test
66 +```
67 +
68 +## Credentials
69 +
70 +The CLI needs an ASC API key: `--key-id`/`ASC_API_KEY`,
71 +`--issuer-id`/`ASC_ISSUER_ID`, and the `.p8` at
72 +`--private-key-path`/`ASC_PRIVATE_KEY_PATH` (defaults to
73 +`~/.appstoreconnect/private_keys/AuthKey_<key-id>.p8`). `--mcp` resolves
74 +credentials per tool call, so the server can list tools unconfigured.
README.md
+1 -1
@@ -8,7 +8,7 @@ from the other:
8 | Crate | What it is |
9 | --- | --- |
10 | `smbcloud-ascapi-core` | Shared transport: JWT auth, the HTTP client, JSON:API envelopes, error types |
11 -| `smbcloud-ascapi-aso` | App Metadata: apps, app infos, versions, bundle IDs, localizations, screenshots |
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 |
crates/aso/src/app_store_version.rs
+1 -1
@@ -30,7 +30,7 @@ pub enum Platform {
30 }
31
32 impl Platform {
33 - fn as_query_value(self) -> &'static str {
33 + pub(crate) fn as_query_value(self) -> &'static str {
34 match self {
35 Platform::Ios => "IOS",
36 Platform::MacOs => "MAC_OS",
crates/aso/src/build.rs
+225 -19
@@ -1,20 +1,26 @@
1 //! Read-only lookup for `Build` — the processed artifact behind an uploaded
2 -//! `.ipa`/`.pkg`. Uploading itself is out of this crate's scope (done via
3 -//! `xcrun altool`/Xcode, see the CLI's sibling build scripts); this module
4 -//! exists so a caller can check whether App Store Connect finished
5 -//! processing a build as `VALID` or `INVALID` after upload — the signal that
6 -//! actually confirms a binary-rejection fix worked, independent of an
7 -//! `AppStoreVersion`'s `appVersionState` (which only changes once a build is
8 -//! attached to the version and resubmitted — outside this crate's scope).
2 +//! `.ipa`/`.pkg` — and `PreReleaseVersion`, the version string App Store
3 +//! Connect groups builds under for TestFlight.
4 +//!
5 +//! Uploading the binary itself is out of this crate's scope (done via
6 +//! `xcrun altool`/Xcode); this module exists so a caller can check whether App
7 +//! Store Connect finished processing a build as `VALID` or `INVALID` after
8 +//! upload — the signal that actually confirms a binary-rejection fix worked,
9 +//! independent of an `AppStoreVersion`'s `appVersionState` (which only
10 +//! changes once a build is attached to the version and resubmitted — outside
11 +//! this crate's scope).
12
13 use async_trait::async_trait;
14 use reqwest::Method;
15 use serde::{Deserialize, Serialize};
13 -use smbcloud_ascapi_core::jsonapi::{ListDocument, Resource};
16 +use smbcloud_ascapi_core::jsonapi::{Document, Resource};
17 use smbcloud_ascapi_core::Client;
18 use smbcloud_ascapi_core::Result;
19
20 +use crate::app_store_version::Platform;
21 +
22 pub const RESOURCE_TYPE: &str = "builds";
23 +pub const PRE_RELEASE_VERSION_RESOURCE_TYPE: &str = "preReleaseVersions";
24
25 #[derive(Debug, Clone, Serialize, Deserialize)]
26 #[serde(rename_all = "camelCase")]
@@ -28,6 +34,50 @@ pub struct BuildAttributes {
34
35 pub type Build = Resource<BuildAttributes>;
36
37 +#[derive(Debug, Clone, Serialize, Deserialize)]
38 +#[serde(rename_all = "camelCase")]
39 +pub struct PreReleaseVersionAttributes {
40 + pub version: Option<String>,
41 + pub platform: Option<Platform>,
42 +}
43 +
44 +pub type PreReleaseVersion = Resource<PreReleaseVersionAttributes>;
45 +
46 +/// How to narrow a `list_builds` query. `None` fields are left off the
47 +/// request, so the default — all fields `None` — lists every build for the
48 +/// app, matching the original parameter-free behavior.
49 +#[derive(Debug, Clone, Default)]
50 +pub struct BuildFilter {
51 + /// `filter[version]` — the build (upload) number, e.g. `"42"`. Note this
52 + /// is the build's own `version`, *not* the marketing `preReleaseVersion`
53 + /// string; use [`BuildFilter::pre_release_version`] for the latter.
54 + pub version: Option<String>,
55 + /// `filter[preReleaseVersion.version]` — the marketing version the build
56 + /// sits under, e.g. `"1.1.0"`.
57 + pub pre_release_version: Option<String>,
58 + /// `filter[processingState]` — one of `PROCESSING`, `FAILED`, `INVALID`,
59 + /// `VALID`.
60 + pub processing_state: Option<String>,
61 +}
62 +
63 +impl BuildFilter {
64 + /// Collect the set fields into `filter[...]` query pairs. Returns owned
65 + /// strings because the values come from the caller at runtime.
66 + fn query_pairs(&self) -> Vec<(&'static str, String)> {
67 + let mut pairs = Vec::new();
68 + if let Some(v) = &self.version {
69 + pairs.push(("filter[version]", v.clone()));
70 + }
71 + if let Some(v) = &self.pre_release_version {
72 + pairs.push(("filter[preReleaseVersion.version]", v.clone()));
73 + }
74 + if let Some(v) = &self.processing_state {
75 + pairs.push(("filter[processingState]", v.clone()));
76 + }
77 + pairs
78 + }
79 +}
80 +
81 /// Uploaded builds, which a version must have attached.
82 ///
83 /// An extension trait rather than inherent methods, because `Client`
@@ -36,22 +86,178 @@ pub type Build = Resource<BuildAttributes>;
86 /// to call these on a `Client`.
87 #[async_trait]
88 pub trait BuildsApi {
39 - /// `GET /v1/apps/{app_id}/builds`, sorted newest-first by
40 - /// `uploadedDate` (client-side — this endpoint's `sort` query parameter
41 - /// is rejected by the API, unlike most other list endpoints), so the
42 - /// build most recently uploaded (e.g. by a fixplist re-upload) is
43 - /// `list_builds(app_id).await?.first()`.
44 - async fn list_builds(&self, app_id: &str) -> Result<Vec<Build>>;
89 + /// `GET /v1/builds?filter[app]={app_id}`, sorted newest-first by
90 + /// `uploadedDate` client-side, so the build most recently uploaded (e.g.
91 + /// by a fixplist re-upload) is `list_builds(app_id, &filter).await?.first()`.
92 + ///
93 + /// The top-level collection rather than `/v1/apps/{app_id}/builds`:
94 + /// that related-resource endpoint only accepts `fields`/`limit` and
95 + /// rejects `filter[...]` and `sort`.
96 + ///
97 + /// Pass `&BuildFilter::default()` for every build, or set fields to
98 + /// narrow by build number, pre-release (marketing) version, or
99 + /// processing state.
100 + async fn list_builds(&self, app_id: &str, filter: &BuildFilter) -> Result<Vec<Build>>;
101 +
102 + /// `GET /v1/builds/{id}` — a single build by its App Store Connect id.
103 + async fn get_build(&self, id: &str) -> Result<Build>;
104 +
105 + /// `GET /v1/preReleaseVersions?filter[app]={app_id}`, optionally narrowed
106 + /// to one platform (the related `/v1/apps/{app_id}/preReleaseVersions`
107 + /// endpoint does not accept `filter[platform]`). These are the marketing version strings (e.g. `1.1.0`)
108 + /// TestFlight groups builds under; a build's
109 + /// `filter[preReleaseVersion.version]` matches one of these.
110 + async fn list_pre_release_versions(
111 + &self,
112 + app_id: &str,
113 + filter_platform: Option<Platform>,
114 + ) -> Result<Vec<PreReleaseVersion>>;
115 }
116
117 #[async_trait]
118 impl BuildsApi for Client {
49 - async fn list_builds(&self, app_id: &str) -> Result<Vec<Build>> {
50 - let path = format!("/v1/apps/{app_id}/builds");
51 - let doc: ListDocument<BuildAttributes> =
52 - self.request(Method::GET, &path, &[], None::<&()>).await?;
53 - let mut builds = doc.data;
119 + async fn list_builds(&self, app_id: &str, filter: &BuildFilter) -> Result<Vec<Build>> {
120 + let owned = filter.query_pairs();
121 + let mut query: Vec<(&str, &str)> = vec![("filter[app]", app_id), ("limit", "200")];
122 + query.extend(owned.iter().map(|(k, v)| (*k, v.as_str())));
123 + let mut builds = self
124 + .list_all::<BuildAttributes>("/v1/builds", &query)
125 + .await?;
126 builds.sort_by(|a, b| b.attributes.uploaded_date.cmp(&a.attributes.uploaded_date));
127 Ok(builds)
128 }
129 +
130 + async fn get_build(&self, id: &str) -> Result<Build> {
131 + let path = format!("/v1/builds/{id}");
132 + let doc: Document<BuildAttributes> =
133 + self.request(Method::GET, &path, &[], None::<&()>).await?;
134 + Ok(doc.data)
135 + }
136 +
137 + async fn list_pre_release_versions(
138 + &self,
139 + app_id: &str,
140 + filter_platform: Option<Platform>,
141 + ) -> Result<Vec<PreReleaseVersion>> {
142 + let mut query: Vec<(&str, &str)> = vec![("filter[app]", app_id), ("limit", "200")];
143 + if let Some(platform) = filter_platform {
144 + query.push(("filter[platform]", platform.as_query_value()));
145 + }
146 + self.list_all::<PreReleaseVersionAttributes>("/v1/preReleaseVersions", &query)
147 + .await
148 + }
149 +}
150 +
151 +#[cfg(test)]
152 +mod tests {
153 + use super::*;
154 + use smbcloud_ascapi_core::jsonapi::ListDocument;
155 +
156 + #[test]
157 + fn default_filter_sends_no_query_pairs() {
158 + // `BuildFilter::default()` must list every build for the app — the
159 + // original parameter-free behavior — so it adds no `filter[...]`.
160 + assert!(BuildFilter::default().query_pairs().is_empty());
161 + }
162 +
163 + #[test]
164 + fn each_field_maps_to_its_asc_filter_key() {
165 + // The ASC filter keys are load-bearing: `version` is the build
166 + // number, but the marketing version is `preReleaseVersion.version`,
167 + // not `preReleaseVersion`. Getting these wrong silently returns the
168 + // wrong builds (or all of them), so pin every key and value.
169 + assert_eq!(
170 + BuildFilter {
171 + version: Some("42".into()),
172 + ..Default::default()
173 + }
174 + .query_pairs(),
175 + vec![("filter[version]", "42".to_string())],
176 + );
177 + assert_eq!(
178 + BuildFilter {
179 + pre_release_version: Some("1.1.0".into()),
180 + ..Default::default()
181 + }
182 + .query_pairs(),
183 + vec![("filter[preReleaseVersion.version]", "1.1.0".to_string())],
184 + );
185 + assert_eq!(
186 + BuildFilter {
187 + processing_state: Some("VALID".into()),
188 + ..Default::default()
189 + }
190 + .query_pairs(),
191 + vec![("filter[processingState]", "VALID".to_string())],
192 + );
193 + }
194 +
195 + #[test]
196 + fn all_fields_set_are_all_sent() {
197 + let pairs = BuildFilter {
198 + version: Some("42".into()),
199 + pre_release_version: Some("1.1.0".into()),
200 + processing_state: Some("VALID".into()),
201 + }
202 + .query_pairs();
203 + assert_eq!(
204 + pairs,
205 + vec![
206 + ("filter[version]", "42".to_string()),
207 + ("filter[preReleaseVersion.version]", "1.1.0".to_string()),
208 + ("filter[processingState]", "VALID".to_string()),
209 + ],
210 + );
211 + }
212 +
213 + #[test]
214 + fn deserializes_a_builds_list_response() {
215 + let body = serde_json::json!({
216 + "data": [
217 + {
218 + "id": "BUILD1",
219 + "type": "builds",
220 + "attributes": {
221 + "version": "42",
222 + "uploadedDate": "2024-01-02T03:04:05Z",
223 + "expired": false,
224 + "processingState": "VALID",
225 + "minOsVersion": "17.0"
226 + }
227 + }
228 + ]
229 + });
230 +
231 + let doc: ListDocument<BuildAttributes> =
232 + serde_json::from_value(body).expect("a builds list must deserialize");
233 + assert_eq!(doc.data.len(), 1);
234 + let build = &doc.data[0];
235 + assert_eq!(build.id, "BUILD1");
236 + assert_eq!(build.attributes.version.as_deref(), Some("42"));
237 + assert_eq!(build.attributes.processing_state.as_deref(), Some("VALID"));
238 + assert_eq!(build.attributes.expired, Some(false));
239 + }
240 +
241 + #[test]
242 + fn deserializes_a_pre_release_versions_response() {
243 + let body = serde_json::json!({
244 + "data": [
245 + {
246 + "id": "PRV1",
247 + "type": "preReleaseVersions",
248 + "attributes": {
249 + "version": "1.1.0",
250 + "platform": "IOS"
251 + }
252 + }
253 + ]
254 + });
255 +
256 + let doc: ListDocument<PreReleaseVersionAttributes> =
257 + serde_json::from_value(body).expect("a preReleaseVersions list must deserialize");
258 + assert_eq!(doc.data.len(), 1);
259 + let prv = &doc.data[0];
260 + assert_eq!(prv.attributes.version.as_deref(), Some("1.1.0"));
261 + assert_eq!(prv.attributes.platform, Some(Platform::Ios));
262 + }
263 }
crates/cli/src/main.rs
+43 -3
@@ -11,6 +11,7 @@ use smbcloud_ascapi_aso::app_store_version::{AppStoreVersionCreateAttributes, Pl
11 use smbcloud_ascapi_aso::app_store_version_localization::{
12 AppStoreVersionLocalizationCreateAttributes, AppStoreVersionLocalizationFields,
13 };
14 +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};
@@ -388,10 +389,32 @@ enum AppsCommand {
389 },
390 /// `GET /v1/apps/{id}/appInfos`.
391 Infos { app_id: String },
391 - /// `GET /v1/apps/{id}/builds` — newest first, so the
392 + /// `GET /v1/builds?filter[app]={id}` — newest first, so the
393 /// most recently uploaded build (e.g. after a fixplist re-upload) is
394 /// first. Check `processingState` (VALID/INVALID/PROCESSING/FAILED).
394 - Builds { app_id: String },
395 + Builds {
396 + app_id: String,
397 + /// `filter[version]` — the build (upload) number, e.g. `42`.
398 + #[arg(long)]
399 + version: Option<String>,
400 + /// `filter[preReleaseVersion.version]` — the marketing version the
401 + /// build sits under, e.g. `1.1.0`.
402 + #[arg(long)]
403 + pre_release_version: Option<String>,
404 + /// `filter[processingState]` — PROCESSING, FAILED, INVALID, or VALID.
405 + #[arg(long)]
406 + processing_state: Option<String>,
407 + },
408 + /// `GET /v1/builds/{id}` — a single build by its App Store Connect id.
409 + Build { id: String },
410 + /// `GET /v1/preReleaseVersions?filter[app]={id}` — the marketing version strings
411 + /// (e.g. `1.1.0`) TestFlight groups builds under.
412 + PreReleaseVersions {
413 + app_id: String,
414 + /// Restrict to one platform.
415 + #[arg(long, value_enum)]
416 + platform: Option<CliPlatform>,
417 + },
418 }
419
420 #[derive(Subcommand)]
@@ -841,7 +864,24 @@ async fn run_apps(client: &Client, command: AppsCommand, dry_run: bool) -> Resul
864 print_json(&client.update_app(&app_id, attributes).await?)
865 }
866 AppsCommand::Infos { app_id } => print_json(&client.list_app_infos(&app_id).await?),
844 - AppsCommand::Builds { app_id } => print_json(&client.list_builds(&app_id).await?),
867 + AppsCommand::Builds {
868 + app_id,
869 + version,
870 + pre_release_version,
871 + processing_state,
872 + } => {
873 + let filter = BuildFilter {
874 + version,
875 + pre_release_version,
876 + processing_state,
877 + };
878 + print_json(&client.list_builds(&app_id, &filter).await?)
879 + }
880 + AppsCommand::Build { id } => print_json(&client.get_build(&id).await?),
881 + AppsCommand::PreReleaseVersions { app_id, platform } => {
882 + let platform = platform.map(Platform::from);
883 + print_json(&client.list_pre_release_versions(&app_id, platform).await?)
884 + }
885 }
886 }
887
crates/mcp/src/lib.rs
+1 -1
@@ -197,7 +197,7 @@ macro_rules! ascapi_mcp_tools {
197 let client = Self::client()?;
198 let request = parameters.0;
199 let value = client
200 - .list_builds(&request.app_id)
200 + .list_builds(&request.app_id, &::smbcloud_ascapi_aso::build::BuildFilter::default())
201 .await
202 .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?;
203 Ok(::rmcp::model::CallToolResult::success(vec![