Answer a build's export compliance with asc apps set-encryption (#40)

* feat(aso,cli): answer a build's export compliance with apps set-encryption A build uploaded without ITSAppUsesNonExemptEncryption in every bundle's Info.plist shows "Missing Compliance", and review-submissions submit then fails with 409 STATE_ERROR.ENTITY_STATE_INVALID, which doesn't say why. This came up with the SplitFire AI Watch 1.1.0 container app, and the only fix was the App Store Connect web UI. BuildsApi gets update_build (PATCH /v1/builds/{id}) with BuildUpdateAttributes, and BuildAttributes now reads usesNonExemptEncryption, so apps build shows the current answer. The CLI command is apps set-encryption <BUILD_ID> --uses-non-exempt-encryption <true|false>, and it honors --dry-run. It stays out of MCP because the answer is a legal export declaration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(agents): document apps set-encryption under the Builds API Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 17, 2026 at 08:53 UTC 41f2d5f28e78d482abaefe95a3377fce5b37e5ef
4 files changed +105 -5
.agents/AGENTS.md
+6
@@ -50,6 +50,12 @@ asc apps builds <APP_ID> \
50 # GET /v1/builds/{id} — a single build by its ASC id.
51 asc apps build <BUILD_ID>
52
53 +# PATCH /v1/builds/{id} usesNonExemptEncryption — answer export compliance on a
54 +# build showing "Missing Compliance". Without an answer, review-submissions
55 +# submit fails with 409 STATE_ERROR.ENTITY_STATE_INVALID. false = "None of the
56 +# algorithms mentioned above" (HTTPS through Apple's OS only).
57 +asc apps set-encryption <BUILD_ID> --uses-non-exempt-encryption false
58 +
59 # GET /v1/preReleaseVersions?filter[app]={id} — the marketing version strings
60 # TestFlight groups builds under.
61 asc apps pre-release-versions <APP_ID> [--platform <ios|mac-os|tv-os|vision-os>]
crates/aso/src/build.rs
+72 -4
@@ -1,6 +1,7 @@
1 -//! Read-only lookup for `Build` — the processed artifact behind an uploaded
1 +//! Lookup for `Build` — the processed artifact behind an uploaded
2 //! `.ipa`/`.pkg` — and `PreReleaseVersion`, the version string App Store
3 -//! Connect groups builds under for TestFlight.
3 +//! Connect groups builds under for TestFlight. The one write is a build's
4 +//! export-compliance answer ([`BuildsApi::update_build`]).
5 //!
6 //! Uploading the binary itself is out of this crate's scope (done via
7 //! `xcrun altool`/Xcode); this module exists so a caller can check whether App
@@ -13,7 +14,7 @@
14 use async_trait::async_trait;
15 use reqwest::Method;
16 use serde::{Deserialize, Serialize};
16 -use smbcloud_ascapi_core::jsonapi::{Document, Resource};
17 +use smbcloud_ascapi_core::jsonapi::{Document, Resource, UpdateBody, UpdateData};
18 use smbcloud_ascapi_core::Client;
19 use smbcloud_ascapi_core::Result;
20
@@ -30,6 +31,12 @@ pub struct BuildAttributes {
31 pub expired: Option<bool>,
32 pub processing_state: Option<String>,
33 pub min_os_version: Option<String>,
34 + /// The build's export-compliance answer. `None` until someone answers it,
35 + /// either here or through `ITSAppUsesNonExemptEncryption` in the Info.plist
36 + /// of every bundle in the upload. A build with no answer shows "Missing
37 + /// Compliance" and App Review rejects its version with
38 + /// `STATE_ERROR.ENTITY_STATE_INVALID`.
39 + pub uses_non_exempt_encryption: Option<bool>,
40 }
41
42 pub type Build = Resource<BuildAttributes>;
@@ -43,6 +50,20 @@ pub struct PreReleaseVersionAttributes {
50
51 pub type PreReleaseVersion = Resource<PreReleaseVersionAttributes>;
52
53 +/// The writable attributes of `PATCH /v1/builds/{id}`. Unset fields are left
54 +/// off the body, so App Store Connect keeps their current value.
55 +#[derive(Debug, Clone, Default, Serialize)]
56 +#[serde(rename_all = "camelCase")]
57 +pub struct BuildUpdateAttributes {
58 + /// `false` answers export compliance with "None of the algorithms
59 + /// mentioned above": the app uses no encryption, or only what Apple's
60 + /// operating system provides (HTTPS through URLSession or
61 + /// Security.framework counts). `true` means non-exempt encryption, which
62 + /// then needs an `appEncryptionDeclaration`.
63 + #[serde(skip_serializing_if = "Option::is_none")]
64 + pub uses_non_exempt_encryption: Option<bool>,
65 +}
66 +
67 /// How to narrow a `list_builds` query. `None` fields are left off the
68 /// request, so the default — all fields `None` — lists every build for the
69 /// app, matching the original parameter-free behavior.
@@ -109,6 +130,10 @@ pub trait BuildsApi {
130 /// `GET /v1/builds/{id}` — a single build by its App Store Connect id.
131 async fn get_build(&self, id: &str) -> Result<Build>;
132
133 + /// `PATCH /v1/builds/{id}` — today, only to answer export compliance on a
134 + /// build uploaded without `ITSAppUsesNonExemptEncryption`.
135 + async fn update_build(&self, id: &str, attributes: BuildUpdateAttributes) -> Result<Build>;
136 +
137 /// `GET /v1/preReleaseVersions?filter[app]={app_id}`, optionally narrowed
138 /// to one platform (the related `/v1/apps/{app_id}/preReleaseVersions`
139 /// endpoint does not accept `filter[platform]`). These are the marketing version strings (e.g. `1.1.0`)
@@ -141,6 +166,20 @@ impl BuildsApi for Client {
166 Ok(doc.data)
167 }
168
169 + async fn update_build(&self, id: &str, attributes: BuildUpdateAttributes) -> Result<Build> {
170 + let path = format!("/v1/builds/{id}");
171 + let body = UpdateBody {
172 + data: UpdateData {
173 + resource_type: RESOURCE_TYPE,
174 + id: id.to_string(),
175 + attributes,
176 + },
177 + };
178 + let doc: Document<BuildAttributes> =
179 + self.request(Method::PATCH, &path, &[], Some(&body)).await?;
180 + Ok(doc.data)
181 + }
182 +
183 async fn list_pre_release_versions(
184 &self,
185 app_id: &str,
@@ -229,7 +268,8 @@ mod tests {
268 "uploadedDate": "2024-01-02T03:04:05Z",
269 "expired": false,
270 "processingState": "VALID",
232 - "minOsVersion": "17.0"
271 + "minOsVersion": "17.0",
272 + "usesNonExemptEncryption": false
273 }
274 }
275 ]
@@ -243,6 +283,34 @@ mod tests {
283 assert_eq!(build.attributes.version.as_deref(), Some("42"));
284 assert_eq!(build.attributes.processing_state.as_deref(), Some("VALID"));
285 assert_eq!(build.attributes.expired, Some(false));
286 + assert_eq!(build.attributes.uses_non_exempt_encryption, Some(false));
287 + }
288 +
289 + #[test]
290 + fn update_body_sends_only_the_set_attribute() {
291 + let body = UpdateBody {
292 + data: UpdateData {
293 + resource_type: RESOURCE_TYPE,
294 + id: "BUILD1".to_string(),
295 + attributes: BuildUpdateAttributes {
296 + uses_non_exempt_encryption: Some(false),
297 + },
298 + },
299 + };
300 + assert_eq!(
301 + serde_json::to_value(&body).unwrap(),
302 + serde_json::json!({
303 + "data": {
304 + "type": "builds",
305 + "id": "BUILD1",
306 + "attributes": { "usesNonExemptEncryption": false }
307 + }
308 + }),
309 + );
310 + assert_eq!(
311 + serde_json::to_value(BuildUpdateAttributes::default()).unwrap(),
312 + serde_json::json!({}),
313 + );
314 }
315
316 #[test]
crates/cli/src/main.rs
+26 -1
@@ -15,7 +15,7 @@ use smbcloud_ascapi_aso::app_store_version_localization::{
15 AppStoreVersionLocalizationFields,
16 };
17 use smbcloud_ascapi_aso::beta_app_review_detail::BetaAppReviewDetailUpdate;
18 -use smbcloud_ascapi_aso::build::BuildFilter;
18 +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};
@@ -602,6 +602,19 @@ enum AppsCommand {
602 },
603 /// `GET /v1/builds/{id}` — a single build by its App Store Connect id.
604 Build { id: String },
605 + /// `PATCH /v1/builds/{id}` `usesNonExemptEncryption` — answer export
606 + /// compliance for a build that shows "Missing Compliance" (it was uploaded
607 + /// without `ITSAppUsesNonExemptEncryption` in every bundle's Info.plist).
608 + /// App Review rejects a version whose build has no answer.
609 + SetEncryption {
610 + /// The build's App Store Connect id (from `apps builds`), not its build number.
611 + build_id: String,
612 + /// `false`: no encryption beyond what Apple's OS provides (HTTPS via
613 + /// URLSession or Security.framework counts), i.e. "None of the
614 + /// algorithms mentioned above". `true`: non-exempt encryption.
615 + #[arg(long, action = clap::ArgAction::Set)]
616 + uses_non_exempt_encryption: bool,
617 + },
618 /// `GET /v1/preReleaseVersions?filter[app]={id}` — the marketing version strings
619 /// (e.g. `1.1.0`) TestFlight groups builds under.
620 PreReleaseVersions {
@@ -1492,6 +1505,18 @@ async fn run_apps(
1505 print_json(&client.list_builds(&app_id, &filter).await?)
1506 }
1507 AppsCommand::Build { id } => print_json(&client.get_build(&id).await?),
1508 + AppsCommand::SetEncryption {
1509 + build_id,
1510 + uses_non_exempt_encryption,
1511 + } => {
1512 + let attributes = BuildUpdateAttributes {
1513 + uses_non_exempt_encryption: Some(uses_non_exempt_encryption),
1514 + };
1515 + if dry_run_guard(dry_run, &attributes)? {
1516 + return Ok(());
1517 + }
1518 + print_json(&client.update_build(&build_id, attributes).await?)
1519 + }
1520 AppsCommand::PreReleaseVersions { app_id, platform } => {
1521 let platform = platform.map(Platform::from);
1522 print_json(&client.list_pre_release_versions(&app_id, platform).await?)
crates/frontend/src/await_processing.rs
+1
@@ -209,6 +209,7 @@ mod tests {
209 expired: Some(false),
210 processing_state: state.map(str::to_string),
211 min_os_version: Some("17.0".to_string()),
212 + uses_non_exempt_encryption: None,
213 },
214 relationships: None,
215 }