~17 min · testflight, upload-gates, archive, app-intents, verification
Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"A late-failure gate has not met the artifact until its first real run."
Refusals That Arrive After a Green Run
Some defects pass every local step (suite, archive, ** EXPORT SUCCEEDED **, the upload line) and are refused by App Store Connect afterwards, in an email. Two cost the family real builds. An App Intent whose description said "this iPhone" got the whole binary rejected with ITMS-90626 Invalid Siri Support, because Siri reads that text aloud and an app may not name the user's device in it. The travel journal spent builds 2 and 3 on that one word, because the same string rode both before the first email was read. And a watch app built with a generated Info.plist had no CFBundleIconName, even with its icon catalog named and compiling, and the upload was refused for it.
The answer is to read, before uploading, what Apple will read after: the archive, not the build settings that were supposed to produce it. The family's upload gate is one script vendored into every app, and it runs between the archive and the upload.
The Gate That Refused a Good Build
The first version of the archive check was written in an evening and proved against fixtures its author built. On its first real run it refused native Pippa's build 16: Pippa.app has no CFBundleIconName. Builds 2 to 15 of the same app had been uploaded, processed and installed on the owner's phone and iPad. Reading the archives settled it: with an explicit Info.plist, the asset compiler writes the icon name only nested, under CFBundleIcons → CFBundlePrimaryIcon, and App Store Connect accepts that for an iOS app, as fifteen accepted uploads showed. The gate now accepts either location for the iOS app and stays strict about the top-level key for a watch app, whose refusal had arrived with neither form present. The fixtures had never carried the real tool's shape. A late-failure gate is not proven until it has met a real artifact, and that first run is part of its proof.
What Else Runs Before the Archive
The 1024-pixel icon must be exactly that size and have no alpha channel. The shared kit's sources must be clean, because a path dependency compiles whatever its working tree holds: one run passed the suite and then failed the archive inside the kit, which another session was editing at that minute. And HEAD must not move during the build; that guard also trips on another session's commit, so before an upload the sessions sharing the checkout are asked to hold commits for the few minutes an archive takes, and a tripped run simply reruns with the same number.
What Only a Test Can Catch, and What Needs No Test
Device names in intent metadata are silent until the email, so a source-reading test fails on them, with comments stripped and an empty folder counted as a failure. The other App Intents rule, that every App Shortcut phrase contains \(.applicationName), is not silent at all: the metadata processor halts the build with the file and line, so there is no archive to upload. A test for it could never fail, and a test that cannot fail is a defect, not insurance.
Code
archive-gate.zsh: the icon, the icon names, and matching build numbers, read from the archive·bash
#!/bin/zsh
# Check the ARTIFACT, not the settings: read what App Store Connect will read after upload.
set -euo pipefail
archive=$1 app_name=$2 icon=$3
app="$archive/Products/Applications/$app_name.app"
pb=/usr/libexec/PlistBuddy
fail() { print -u2 "HARD_FAIL $1"; exit 1; }
# The 1024-point marketing icon: exact size, no alpha channel.
[[ "$(sips -g pixelWidth -g pixelHeight "$icon" | awk '/pixel/ {print $2}' | paste -sd x -)" == 1024x1024 ]] \
|| fail "$icon is not 1024x1024"
sips -g hasAlpha "$icon" | grep -q 'hasAlpha: no' || fail "$icon has an alpha channel"
[[ -d "$app" ]] || fail "no $app_name.app in $archive"
# The iOS app: with an explicit Info.plist, actool writes the icon name NESTED. Accept either.
"$pb" -c 'Print :CFBundleIconName' "$app/Info.plist" >/dev/null 2>&1 \
|| "$pb" -c 'Print :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconName' "$app/Info.plist" >/dev/null 2>&1 \
|| fail "$app_name.app has no CFBundleIconName, top-level or nested"
# An embedded watch app is held to the top-level key and a compiled catalog.
for watch in "$app"/Watch/*.app(N); do
"$pb" -c 'Print :CFBundleIconName' "$watch/Info.plist" >/dev/null 2>&1 \
|| fail "${watch:t} has no top-level CFBundleIconName"
[[ -f "$watch/Assets.car" ]] || fail "${watch:t} has no compiled asset catalog"
done
# Every bundle inside carries the host's build number.
host_build=$("$pb" -c 'Print :CFBundleVersion' "$app/Info.plist")
for inner in "$app"/PlugIns/*.appex(N) "$app"/Watch/*.app(N); do
inner_build=$("$pb" -c 'Print :CFBundleVersion' "$inner/Info.plist")
[[ "$inner_build" == "$host_build" ]] || fail "${inner:t} is build $inner_build, host is $host_build"
done
print "ARCHIVE_GATE_OK $app_name.app build $host_build"
A source-reading test for ITMS-90626, run red with "this iPhone" and green without it·swift
import Foundation
import Testing
/// App Store Connect refuses the whole binary (ITMS-90626) when App Intent metadata names the
/// hardware, and the refusal arrives by email after a green upload. So read the source here.
struct IntentMetadataTests {
static let deviceWords = ["iphone", "ipad", "ipod", "apple watch"]
static var intentSources: URL {
URL(filePath: #filePath)
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
.appending(path: "Sources/SparkIntents")
}
@Test func intentMetadataNamesNoDevice() throws {
let files = try FileManager.default
.contentsOfDirectory(at: Self.intentSources, includingPropertiesForKeys: nil)
.filter { $0.pathExtension == "swift" }
// A scan that finds nothing to read cannot fail, so an empty folder is itself a failure.
#expect(!files.isEmpty, "no intent sources at \(Self.intentSources.path)")
for file in files {
let code = try String(contentsOf: file, encoding: .utf8)
.split(separator: "\n", omittingEmptySubsequences: false)
.map { line in line.range(of: "//").map { String(line[..<$0.lowerBound]) } ?? String(line) }
.joined(separator: "\n")
.lowercased()
for word in Self.deviceWords {
#expect(!code.contains(word),
"\(file.lastPathComponent) says '\(word)': ITMS-90626 refuses device names in intent metadata")
}
}
}
}
Run archive-gate.zsh against an archive you have already uploaded successfully and confirm it passes; if it refuses, work out whether the gate or the archive is wrong before changing either. Then make two negative fixtures: a 1024-pixel PNG with an alpha channel, and a copy of the archive whose share extension's CFBundleVersion you edit by hand. Finally, add IntentMetadataTests to a package with one intent file, see it fail on a description containing a device name, and fix the string.
Hint
Copy the archive with cp -R before editing it, and change the extension's number with PlistBuddy -c 'Set :CFBundleVersion 999'. The test locates the sources from #filePath, so move it with its folder structure or adjust the three deletingLastPathComponent() calls.
Progress
Progress is local-only — sign in to sync across devices.