feat(aso): add app-store-versions update for version strings (#36)

* feat(aso): add app-store-versions update for version strings App Store Connect has no action to rename a version, and a version can't be deleted once a build has been uploaded for the platform. That left no way to change a staged version's number (say 1.0.9 to 1.1.0) through the CLI, even though the API allows it: PATCH /v1/appStoreVersions/{id} with a versionString attribute. Add AppStoreVersionUpdateFields and update_app_store_version to the aso crate, and an "app-store-versions update <id> --version-string <s> [--copyright <c>]" subcommand. Only the fields passed are sent, so a version-string change leaves copyright alone, and --dry-run prints the body like the other mutating commands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cli): reject malformed --version-string before sending `asc app-store-versions create` and `update` sent whatever string they were given, so a typo like `1.1.0.0` or `v1.1` came back as an opaque App Store Connect error. Apple takes one to three period-separated integers, so validate the shape locally — before the dry-run print, so `--dry-run` catches it too — and name the flag in the message. Co-Authored-By: siGit Code v1.6.8-acp <noreply@sigit.si> * test(aso): lock the app-store-version response parsing contract App Store Connect replies to a version-string rename with a partial payload, so a `PATCH` response that carries nothing but the platform has to deserialize into a whole `AppStoreVersionAttributes` — the update has already landed by the time we parse it. Pin both halves: every optional attribute omitted, and the attributes that are present read back. Co-Authored-By: siGit Code v1.6.8-acp <noreply@sigit.si> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: siGit Code v1.6.8-acp <noreply@sigit.si> Co-authored-by: OndeCode v1.0.3-acp <noreply@ondeinference.com>

Seto Elkahfi committed Oct 13, 2026 at 19:32 UTC dae71cd3a26f11f4a5deed3a4fdceb9bc2eb9948
2 files changed +180 -3
crates/aso/src/app_store_version.rs
+99 -2
@@ -9,8 +9,8 @@ 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, UpdateRelationshipsBody,
13 - UpdateRelationshipsData,
12 + CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData,
13 + UpdateRelationshipsBody, UpdateRelationshipsData,
14 };
15 use smbcloud_ascapi_core::Client;
16 use smbcloud_ascapi_core::Result;
@@ -68,6 +68,21 @@ pub struct AppStoreVersionRelationships {
68 pub app: ToOne,
69 }
70
71 +/// Editable attributes for a `PATCH /v1/appStoreVersions/{id}`. App Store
72 +/// Connect has no "rename a version" action: changing `version_string` on
73 +/// the platform's editable version (e.g. a staged `1.0.9` to `1.1.0`) is a
74 +/// plain attribute update. Only the fields set here are sent, so passing
75 +/// just `version_string` leaves `copyright` untouched. The version must be
76 +/// in an editable state (`PREPARE_FOR_SUBMISSION`, `DEVELOPER_REJECTED`).
77 +#[derive(Debug, Clone, Default, Serialize)]
78 +#[serde(rename_all = "camelCase")]
79 +pub struct AppStoreVersionUpdateFields {
80 + #[serde(skip_serializing_if = "Option::is_none")]
81 + pub version_string: Option<String>,
82 + #[serde(skip_serializing_if = "Option::is_none")]
83 + pub copyright: Option<String>,
84 +}
85 +
86 /// Per-platform version records, the unit the store reviews.
87 ///
88 /// An extension trait rather than inherent methods, because `Client`
@@ -93,6 +108,15 @@ pub trait AppStoreVersionsApi {
108 attributes: AppStoreVersionCreateAttributes,
109 ) -> Result<AppStoreVersion>;
110
111 + /// `PATCH /v1/appStoreVersions/{id}` with attributes — e.g. change the
112 + /// version string of the platform's editable version. Apple exposes no
113 + /// separate rename action; this is how `1.0.9` becomes `1.1.0`.
114 + async fn update_app_store_version(
115 + &self,
116 + id: &str,
117 + fields: AppStoreVersionUpdateFields,
118 + ) -> Result<AppStoreVersion>;
119 +
120 /// `DELETE /v1/appStoreVersions/{id}` — only allowed while the version
121 /// hasn't been submitted for review.
122 async fn delete_app_store_version(&self, id: &str) -> Result<()>;
@@ -158,6 +182,24 @@ impl AppStoreVersionsApi for Client {
182 Ok(doc.data)
183 }
184
185 + async fn update_app_store_version(
186 + &self,
187 + id: &str,
188 + fields: AppStoreVersionUpdateFields,
189 + ) -> Result<AppStoreVersion> {
190 + let path = format!("/v1/appStoreVersions/{id}");
191 + let body = UpdateBody {
192 + data: UpdateData {
193 + resource_type: RESOURCE_TYPE,
194 + id: id.to_string(),
195 + attributes: fields,
196 + },
197 + };
198 + let doc: Document<AppStoreVersionAttributes> =
199 + self.request(Method::PATCH, &path, &[], Some(&body)).await?;
200 + Ok(doc.data)
201 + }
202 +
203 async fn delete_app_store_version(&self, id: &str) -> Result<()> {
204 let path = format!("/v1/appStoreVersions/{id}");
205 self.request_no_content::<()>(Method::DELETE, &path, &[], None)
@@ -198,3 +240,58 @@ impl AppStoreVersionsApi for Client {
240 pub struct AppStoreVersionBuildRelationship {
241 pub build: ToOne,
242 }
243 +
244 +#[cfg(test)]
245 +mod tests {
246 + use super::*;
247 + use serde_json::json;
248 +
249 + /// `PATCH` responses are deserialized as a whole
250 + /// [`AppStoreVersionAttributes`], so every attribute except `platform`
251 + /// has to stay optional — otherwise a response that carries only the
252 + /// platform (as a rename does) fails to parse after App Store Connect
253 + /// has already applied the update. Making one of these required turns
254 + /// this into a failure.
255 + #[test]
256 + fn a_version_response_omitting_every_optional_attribute_still_parses() {
257 + let doc: Document<AppStoreVersionAttributes> = serde_json::from_value(json!({
258 + "data": {
259 + "id": "b1f9f3a2-6f9a-4a4e-8a4f-3f5b0a9c0d11",
260 + "type": "appStoreVersions",
261 + "attributes": { "platform": "IOS" }
262 + }
263 + }))
264 + .unwrap();
265 +
266 + assert_eq!(doc.data.attributes.platform, Platform::Ios);
267 + assert!(doc.data.attributes.version_string.is_none());
268 + assert!(doc.data.attributes.copyright.is_none());
269 + }
270 +
271 + /// The other half of that contract: a response that does carry the
272 + /// attributes round-trips, so `versionString` is read back from the
273 + /// rename request's own reply.
274 + #[test]
275 + fn a_version_response_reads_back_the_renamed_string() {
276 + let doc: Document<AppStoreVersionAttributes> = serde_json::from_value(json!({
277 + "data": {
278 + "id": "b1f9f3a2-6f9a-4a4e-8a4f-3f5b0a9c0d11",
279 + "type": "appStoreVersions",
280 + "attributes": {
281 + "platform": "MAC_OS",
282 + "versionString": "1.1.0",
283 + "appVersionState": "PREPARE_FOR_SUBMISSION",
284 + "copyright": "2026 SplitFire AB"
285 + }
286 + }
287 + }))
288 + .unwrap();
289 +
290 + assert_eq!(doc.data.attributes.platform, Platform::MacOs);
291 + assert_eq!(doc.data.attributes.version_string.as_deref(), Some("1.1.0"));
292 + assert_eq!(
293 + doc.data.attributes.app_version_state.as_deref(),
294 + Some("PREPARE_FOR_SUBMISSION")
295 + );
296 + }
297 +}
crates/cli/src/main.rs
+81 -1
@@ -7,7 +7,9 @@ use smbcloud_ascapi_aso::app_info_localization::{
7 use smbcloud_ascapi_aso::app_screenshot_set::{
8 AppScreenshotSetCreateAttributes, ScreenshotDisplayType,
9 };
10 -use smbcloud_ascapi_aso::app_store_version::{AppStoreVersionCreateAttributes, Platform};
10 +use smbcloud_ascapi_aso::app_store_version::{
11 + AppStoreVersionCreateAttributes, AppStoreVersionUpdateFields, Platform,
12 +};
13 use smbcloud_ascapi_aso::app_store_version_localization::{
14 AppStoreVersionLocalizationCreateAttributes, AppStoreVersionLocalizationFields,
15 };
@@ -675,6 +677,18 @@ enum AppStoreVersionsCommand {
677 #[arg(long)]
678 copyright: Option<String>,
679 },
680 + /// `PATCH /v1/appStoreVersions/{id}` — edit a version's own attributes,
681 + /// e.g. rename the platform's editable version (`--version-string
682 + /// 1.1.0`). The version must be in an editable state. Apple has no
683 + /// separate rename action and such a version can't be deleted once a
684 + /// build exists, so this is the way to bump its number.
685 + Update {
686 + id: String,
687 + #[arg(long)]
688 + version_string: Option<String>,
689 + #[arg(long)]
690 + copyright: Option<String>,
691 + },
692 /// `DELETE /v1/appStoreVersions/{id}`.
693 Delete { id: String },
694 /// `PATCH /v1/appStoreVersions/{id}` — attach a `Build` (see
@@ -917,6 +931,26 @@ fn dry_run_guard(dry_run: bool, value: &impl serde::Serialize) -> Result<bool> {
931 Ok(dry_run)
932 }
933
934 +/// Validate a `--version-string` before sending it. Apple takes one to
935 +/// three period-separated integers (`1.1` and `1.1.0` are both fine) and
936 +/// answers anything else with an opaque error, so the shape is checked
937 +/// here where the message can name the offending flag. Shared by create
938 +/// and update because both write the same attribute.
939 +fn validate_version_string(version_string: &str) -> Result<()> {
940 + let parts = version_string.split('.').collect::<Vec<_>>();
941 + let well_formed = parts.len() <= 3
942 + && parts
943 + .iter()
944 + .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()));
945 + if !well_formed {
946 + anyhow::bail!(
947 + "invalid --version-string `{version_string}`: expected one to three \
948 + period-separated numbers, e.g. `1.1.0`"
949 + );
950 + }
951 + Ok(())
952 +}
953 +
954 /// Maximum length App Store Connect accepts for a "What to Test" note
955 /// (`whatsNew` on `betaBuildLocalizations`). Apple's answer for anything
956 /// longer is an opaque 400, so the CLI rejects locally and names the
@@ -1423,6 +1457,7 @@ async fn run_app_store_versions(
1457 version_string,
1458 copyright,
1459 } => {
1460 + validate_version_string(&version_string)?;
1461 let attributes = AppStoreVersionCreateAttributes {
1462 platform: platform.into(),
1463 version_string,
@@ -1433,6 +1468,26 @@ async fn run_app_store_versions(
1468 }
1469 print_json(&client.create_app_store_version(&app_id, attributes).await?)
1470 }
1471 + AppStoreVersionsCommand::Update {
1472 + id,
1473 + version_string,
1474 + copyright,
1475 + } => {
1476 + if version_string.is_none() && copyright.is_none() {
1477 + anyhow::bail!("nothing to update: pass --version-string and/or --copyright");
1478 + }
1479 + if let Some(version_string) = version_string.as_deref() {
1480 + validate_version_string(version_string)?;
1481 + }
1482 + let fields = AppStoreVersionUpdateFields {
1483 + version_string,
1484 + copyright,
1485 + };
1486 + if dry_run_guard(dry_run, &fields)? {
1487 + return Ok(());
1488 + }
1489 + print_json(&client.update_app_store_version(&id, fields).await?)
1490 + }
1491 AppStoreVersionsCommand::Delete { id } => {
1492 if dry_run {
1493 println!("# dry run — would DELETE /v1/appStoreVersions/{id}");
@@ -2048,6 +2103,7 @@ fn base64_decode(input: &str) -> Result<Vec<u8>> {
2103 #[cfg(test)]
2104 mod tests {
2105 use super::sales_report_as_json;
2106 + use super::validate_version_string;
2107 use super::validate_whats_new;
2108 use super::WHATS_NEW_MAX_LEN;
2109 use flate2::write::GzEncoder;
@@ -2102,6 +2158,30 @@ mod tests {
2158 .contains("reading the TSV header from Apple's sales report"));
2159 }
2160
2161 + #[test]
2162 + fn version_string_accepts_one_to_three_numbers() {
2163 + for version_string in ["1", "1.1", "1.1.0", "12.345.6789"] {
2164 + assert!(
2165 + validate_version_string(version_string).is_ok(),
2166 + "{version_string} should be accepted"
2167 + );
2168 + }
2169 + }
2170 +
2171 + #[test]
2172 + fn version_string_rejects_non_numeric_or_too_many_parts() {
2173 + for version_string in [
2174 + "", "1.1.0.0", "1..0", "1.1.", ".1", "v1.1", "1.1-beta", "1,1",
2175 + ] {
2176 + let error = validate_version_string(version_string)
2177 + .expect_err(&format!("{version_string} should be rejected"));
2178 + assert!(
2179 + error.to_string().starts_with("invalid --version-string"),
2180 + "{error}"
2181 + );
2182 + }
2183 + }
2184 +
2185 #[test]
2186 fn sales_report_disambiguates_blank_and_duplicate_headers() {
2187 let rows = sales_report_as_json(&gzip("Country\t\tCountry\nUS\t3\tCA\n")).unwrap();