45
pub output: serde_json::Value,
46
}
47
48
+/// Which altool operation to run against the package: the pre-flight
49
+/// `--validate-app` or the real `--upload-package`. They take the same
50
+/// authentication and the same output format, so they share one argv
51
+/// builder.
52
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53
+pub enum AltoolOp {
54
+ /// `--validate-app`: run App Store Connect's checks without delivering
55
+ /// the binary. Catches most of what a failed upload would, in seconds.
56
+ Validate,
57
+ /// `--upload-package`: deliver the binary for real.
58
+ Upload,
59
+}
60
+
61
+impl AltoolOp {
62
+ fn flag(self) -> &'static str {
63
+ match self {
64
+ AltoolOp::Validate => "--validate-app",
65
+ AltoolOp::Upload => "--upload-package",
66
+ }
67
+ }
68
+}
69
+
70
/// The arguments after `xcrun`. Separate from [`upload_package`] so the
71
/// exact command line is testable and `--dry-run` can print it.
50
-pub fn altool_args(package: &Path, auth: &AltoolAuth, wait: bool) -> Vec<OsString> {
72
+pub fn altool_args(package: &Path, auth: &AltoolAuth, op: AltoolOp, wait: bool) -> Vec<OsString> {
73
let mut args: Vec<OsString> = vec![
74
"altool".into(),
53
- "--upload-package".into(),
75
+ op.flag().into(),
76
package.as_os_str().to_owned(),
77
"--api-key".into(),
78
auth.key_id.clone().into(),
83
"--output-format".into(),
84
"json".into(),
85
];
64
- if wait {
86
+ // --wait only means anything for an upload; validation is already
87
+ // synchronous, so don't pass a flag altool would reject there.
88
+ if wait && op == AltoolOp::Upload {
89
args.push("--wait".into());
90
}
91
args
99
.map(str::to_ascii_lowercase);
100
match extension.as_deref() {
101
Some("ipa") | Some("pkg") => {}
102
+ // A `.app` is a directory, so the generic "not a file" check below
103
+ // would mislabel the most common mistake — pointing altool at the
104
+ // built app instead of the exported archive. Name it precisely.
105
+ Some("app") => {
106
+ return Err(format!(
107
+ "{} is an .app bundle, not an uploadable package; export the \
108
+ archive first (xcodebuild -exportArchive) to get an .ipa/.pkg",
109
+ package.display()
110
+ ))
111
+ }
112
_ => {
113
return Err(format!(
114
"{} is not an .ipa or .pkg; export the archive first \
117
))
118
}
119
}
86
- if !package.is_file() {
120
+ // Distinguish "nothing here" from "here, but a directory" so the error
121
+ // points at the real problem instead of a generic "does not exist".
122
+ if !package.exists() {
123
return Err(format!("{} does not exist", package.display()));
124
}
125
+ if !package.is_file() {
126
+ return Err(format!(
127
+ "{} is a directory, not a package file",
128
+ package.display()
129
+ ));
130
+ }
131
Ok(())
132
}
133
145
package: &Path,
146
auth: &AltoolAuth,
147
wait: bool,
148
+) -> Result<UploadOutcome, String> {
149
+ run_altool(package, auth, AltoolOp::Upload, wait)
150
+}
151
+
152
+/// Run `xcrun altool --validate-app` and report what happened.
153
+///
154
+/// A pre-flight check that runs App Store Connect's validation without
155
+/// delivering the binary: it catches most of what a failed upload would
156
+/// (bad entitlements, missing icons, bundle-id mismatches) in seconds
157
+/// instead of minutes, before anything is uploaded. Same success/`Err`
158
+/// contract as [`upload_package`].
159
+pub fn validate_app(package: &Path, auth: &AltoolAuth) -> Result<UploadOutcome, String> {
160
+ // --wait is upload-only; validation is already synchronous.
161
+ run_altool(package, auth, AltoolOp::Validate, false)
162
+}
163
+
164
+fn run_altool(
165
+ package: &Path,
166
+ auth: &AltoolAuth,
167
+ op: AltoolOp,
168
+ wait: bool,
169
) -> Result<UploadOutcome, String> {
170
validate_package(package)?;
171
if !auth.private_key_path.is_file() {
176
}
177
178
let output = Command::new("xcrun")
116
- .args(altool_args(package, auth, wait))
179
+ .args(altool_args(package, auth, op, wait))
180
.stdin(Stdio::null())
181
.stderr(Stdio::inherit())
182
.output()
187
188
fn outcome(package: &Path, exit_code: Option<i32>, stdout: &[u8]) -> UploadOutcome {
189
let output = parse_output(stdout);
190
+ // A zero exit is necessary but not sufficient: altool has been seen to
191
+ // exit 0 while reporting errors in its JSON body (e.g. the
192
+ // `product-errors` array). Trust the exit code only when the body
193
+ // doesn't contradict it.
194
+ let success = exit_code == Some(0) && !has_errors(&output);
195
UploadOutcome {
196
package: package.display().to_string(),
129
- success: exit_code == Some(0),
197
+ success,
198
exit_code,
199
delivery_id: find_delivery_id(&output),
200
output,
201
}
202
}
203
204
+/// altool reports failures in its JSON body under a handful of keys
205
+/// (`product-errors` / `product_errors` for upload, `errors` for
206
+/// validation). A non-empty array under any of them means the operation
207
+/// failed regardless of the exit code.
208
+fn has_errors(value: &serde_json::Value) -> bool {
209
+ const ERROR_KEYS: [&str; 3] = ["product-errors", "product_errors", "errors"];
210
+ match value {
211
+ serde_json::Value::Object(map) => {
212
+ ERROR_KEYS.iter().any(|key| {
213
+ matches!(map.get(*key), Some(serde_json::Value::Array(errors)) if !errors.is_empty())
214
+ }) || map.values().any(has_errors)
215
+ }
216
+ serde_json::Value::Array(items) => items.iter().any(has_errors),
217
+ _ => false,
218
+ }
219
+}
220
+
221
/// With `--wait`, altool writes two JSON documents back to back: the build's
222
/// processing status, then the upload result. Read them as a stream and
223
/// return an array when there is more than one. Anything that isn't JSON
268
269
#[test]
270
fn passes_the_resolved_key_file_rather_than_letting_altool_search() {
186
- let args = strings(altool_args(Path::new("/out/Klepon.ipa"), &auth(), false));
271
+ let args = strings(altool_args(
272
+ Path::new("/out/Klepon.ipa"),
273
+ &auth(),
274
+ AltoolOp::Upload,
275
+ false,
276
+ ));
277
assert_eq!(
278
args,
279
[
294
295
#[test]
296
fn never_puts_key_material_on_the_command_line() {
207
- let args = strings(altool_args(Path::new("/out/Klepon.ipa"), &auth(), true));
297
+ let args = strings(altool_args(
298
+ Path::new("/out/Klepon.ipa"),
299
+ &auth(),
300
+ AltoolOp::Upload,
301
+ true,
302
+ ));
303
assert!(!args.iter().any(|arg| arg == "--auth-string"));
304
assert_eq!(args.last().map(String::as_str), Some("--wait"));
305
}
306
307
+ #[test]
308
+ fn validation_uses_validate_app_and_ignores_wait() {
309
+ let args = strings(altool_args(
310
+ Path::new("/out/Klepon.ipa"),
311
+ &auth(),
312
+ AltoolOp::Validate,
313
+ true,
314
+ ));
315
+ assert_eq!(args[1], "--validate-app");
316
+ assert!(
317
+ !args.iter().any(|arg| arg == "--wait"),
318
+ "--wait is upload-only: {args:?}"
319
+ );
320
+ }
321
+
322
+ #[test]
323
+ fn points_at_the_archive_when_given_an_app_bundle() {
324
+ let error = validate_package(Path::new("/out/Klepon.app")).unwrap_err();
325
+ assert!(error.contains(".app"), "unhelpful error: {error}");
326
+ assert!(error.contains("exportArchive"), "unhelpful error: {error}");
327
+ }
328
+
329
#[test]
330
fn rejects_an_unexported_archive() {
331
let error = validate_package(Path::new("/out/Klepon.xcarchive")).unwrap_err();
368
assert_eq!(documents[0]["build-status"], "VALID");
369
}
370
371
+ #[test]
372
+ fn reports_failure_when_altool_exits_zero_but_the_body_has_errors() {
373
+ let body = br#"{"product-errors":[{"message":"Invalid bundle"}]}"#;
374
+ let outcome = outcome(Path::new("/out/Klepon.ipa"), Some(0), body);
375
+ assert!(
376
+ !outcome.success,
377
+ "a non-empty product-errors array means failure even on exit 0"
378
+ );
379
+ }
380
+
381
#[test]
382
fn keeps_non_json_output_as_text_and_reports_failure() {
383
let outcome = outcome(