Read both JSON documents altool prints with --wait

With --wait, Xcode 27's altool writes two JSON documents to stdout: the build's processing status, then the upload result. Parsing that as one value failed, so the output came back as a plain string and delivery_id was null even on a successful upload. That happened on the first real upload, smbcloud-mailx visionOS 1.1.0 (1791527508). The output is now read as a stream of documents and returned as an array when there is more than one, so delivery_id is found again. The delivery UUID is also the build's App Store Connect id, and the field docs now say so. The new test uses the document shape from that upload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 9, 2026 at 08:36 UTC d8342adbed4c601fe10f48bbd466f31d8e26e6dd
1 file changed +48 -6
crates/frontend/src/upload.rs
+48 -6
@@ -37,10 +37,11 @@ pub struct UploadOutcome {
37 /// altool's exit status; `None` if it was killed by a signal.
38 pub exit_code: Option<i32>,
39 /// The delivery UUID altool reports for a successful upload, if its
40 - /// output carried one. `altool --build-status --delivery-id` takes it.
40 + /// output carried one. It is also the build's App Store Connect id, so
41 + /// `ascapi apps build <delivery_id>` reads the build back.
42 pub delivery_id: Option<String>,
42 - /// altool's `--output-format json` body when it parsed, otherwise its
43 - /// stdout as a string.
43 + /// altool's `--output-format json` body when it parsed (an array of
44 + /// both documents with `--wait`), otherwise its stdout as a string.
45 pub output: serde_json::Value,
46 }
47
@@ -122,9 +123,7 @@ pub fn upload_package(
123 }
124
125 fn outcome(package: &Path, exit_code: Option<i32>, stdout: &[u8]) -> UploadOutcome {
125 - let output = serde_json::from_slice::<serde_json::Value>(stdout).unwrap_or_else(|_| {
126 - serde_json::Value::String(String::from_utf8_lossy(stdout).trim().to_string())
127 - });
126 + let output = parse_output(stdout);
127 UploadOutcome {
128 package: package.display().to_string(),
129 success: exit_code == Some(0),
@@ -134,6 +133,21 @@ fn outcome(package: &Path, exit_code: Option<i32>, stdout: &[u8]) -> UploadOutco
133 }
134 }
135
136 +/// With `--wait`, altool writes two JSON documents back to back: the build's
137 +/// processing status, then the upload result. Read them as a stream and
138 +/// return an array when there is more than one. Anything that isn't JSON
139 +/// is kept as text.
140 +fn parse_output(stdout: &[u8]) -> serde_json::Value {
141 + let documents: Result<Vec<serde_json::Value>, _> = serde_json::Deserializer::from_slice(stdout)
142 + .into_iter()
143 + .collect();
144 + match documents {
145 + Ok(mut documents) if documents.len() == 1 => documents.remove(0),
146 + Ok(documents) if !documents.is_empty() => serde_json::Value::Array(documents),
147 + _ => serde_json::Value::String(String::from_utf8_lossy(stdout).trim().to_string()),
148 + }
149 +}
150 +
151 /// altool has spelled this key differently across releases
152 /// (`delivery-uuid` in the legacy tool), so search the body for any of
153 /// the spellings rather than pinning one path.
@@ -209,6 +223,34 @@ mod tests {
223 assert_eq!(outcome.delivery_id.as_deref(), Some("d1e2f3"));
224 }
225
226 + /// The shape Xcode 27's altool prints with `--wait`, trimmed from a real
227 + /// smbcloud-mailx visionOS upload: a status document, then the upload
228 + /// document, with no separator between them.
229 + #[test]
230 + fn reads_both_documents_altool_prints_with_wait() {
231 + let body = br#"{
232 + "build-status" : "VALID",
233 + "bundle-short-version-string" : "1.1.0",
234 + "bundle-version" : "1791527508",
235 + "delivery-uuid" : "3a12861d-5afd-4fc1-8b2d-96c80e055b1c"
236 +}
237 +{
238 + "details" : {
239 + "delivery-uuid" : "3a12861d-5afd-4fc1-8b2d-96c80e055b1c"
240 + },
241 + "success-message" : "No errors uploading archive"
242 +}
243 +"#;
244 + let outcome = outcome(Path::new("/out/MailXVision.ipa"), Some(0), body);
245 + assert_eq!(
246 + outcome.delivery_id.as_deref(),
247 + Some("3a12861d-5afd-4fc1-8b2d-96c80e055b1c")
248 + );
249 + let documents = outcome.output.as_array().expect("two documents");
250 + assert_eq!(documents.len(), 2);
251 + assert_eq!(documents[0]["build-status"], "VALID");
252 + }
253 +
254 #[test]
255 fn keeps_non_json_output_as_text_and_reports_failure() {
256 let outcome = outcome(