gowalk-cicd 1.0.143 → 1.0.145

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- 1.0.143
1
+ 1.0.145
package/action/action.yml CHANGED
@@ -290,6 +290,21 @@ runs:
290
290
  sudo xcode-select -s "$XCODE_PATH"
291
291
  xcodebuild -version
292
292
 
293
+ # Two store refusals are decided entirely by files already in the checkout,
294
+ # and both cost a full cycle to learn about: shipping the framework's
295
+ # PLACEHOLDER icon (Apple's guideline 2.3.8, discovered only after review)
296
+ # and an asset catalog that does not cover a device family the target
297
+ # declares (ITMS-90023, discovered only after the archive is built, signed
298
+ # and uploaded). Reading them before the build means the job fails in
299
+ # under a second instead of paying for the archive first. The stock artwork
300
+ # comes from the SDK this job already installed, so it tracks an upgrade.
301
+ - name: Check app artwork and icon coverage
302
+ if: ${{ inputs.archive == 'true' }}
303
+ shell: bash
304
+ run: |
305
+ set -o pipefail
306
+ python3 "$SWIFT_APP_ACTION/scripts/source_preflight.py" "$GITHUB_WORKSPACE"
307
+
293
308
  - name: Describe native dependency cache
294
309
  if: ${{ inputs.candidate-binary == '' }}
295
310
  id: native_cache_scope
@@ -789,6 +804,24 @@ runs:
789
804
  path: ${{ runner.temp }}/firebase-symbols/
790
805
  if-no-files-found: ignore
791
806
 
807
+ # App Store Connect validates a binary only AFTER the upload completes and
808
+ # answers hours later by email, so a refusal like ITMS-90023 (a device
809
+ # family with no icons) or ITMS-90362 (an extension key in the wrong place)
810
+ # costs a whole release cycle. Every one of those verdicts is a fact about
811
+ # the bundle already sitting in $RUNNER_TEMP/export. Reading it here is
812
+ # offline, reads only a handful of plists out of the archive, and turns
813
+ # "rejected by email tomorrow" into a named build failure with the exact
814
+ # remedy. It runs before the upload INTENT so the release ledger never
815
+ # records an attempt for a binary Apple was always going to refuse.
816
+ - name: Check the exported binary against Apple's delivery rules
817
+ if: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
818
+ shell: bash
819
+ run: |
820
+ set -o pipefail
821
+ python3 "$SWIFT_APP_ACTION/scripts/delivery_preflight.py" \
822
+ "$RUNNER_TEMP/export" \
823
+ --scratch "$RUNNER_TEMP/delivery-preflight"
824
+
792
825
  - name: Record iOS upload intent
793
826
  if: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
794
827
  shell: bash
@@ -0,0 +1,527 @@
1
+ #!/usr/bin/env python3
2
+ """Refuse an iOS binary Apple's delivery would refuse, before it is uploaded.
3
+
4
+ App Store Connect validates a binary AFTER the upload finishes and answers hours
5
+ later by email, so a refusal like ITMS-90023 (a device family with no icons) or
6
+ ITMS-90362 (an extension key in the wrong place) costs a whole release cycle.
7
+ Every one of those verdicts is a fact about bytes this job already has on disk:
8
+ the exported `.app`, its compiled asset catalog, and the Info.plist of every
9
+ nested bundle.
10
+
11
+ The checks are a TABLE (:data:`CHECKS`). A new ITMS code is one row: a `run` that
12
+ returns the exact refusals and stays silent where it does not apply. Nothing here
13
+ touches the network, and only a handful of small files are read out of the
14
+ archive, so the cost does not scale with the binary.
15
+
16
+ The asset catalog is read with ``/usr/bin/assetutil``, Apple's own inventory tool,
17
+ present on every macOS runner. It reports the renditions `actool` actually
18
+ emitted, which is what ITMS-90023 reads. The flattened ``AppIcon*.png`` files in
19
+ the bundle root are only a partial mirror of it — measured on shipped,
20
+ Apple-ACCEPTED IPAs, Xcode writes 120 and 152 as files and keeps 167 and 180 in
21
+ the catalog alone — so they are a fallback that reports what it could not judge.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ from fnmatch import fnmatch
27
+ import json
28
+ from pathlib import Path
29
+ import plistlib
30
+ import re
31
+ import struct
32
+ import subprocess
33
+ import sys
34
+ import zipfile
35
+
36
+ SCHEMA = "gowalk-cicd/apple-delivery-preflight.v1"
37
+
38
+ #: Apple's `assetutil` lives outside the selected Xcode and needs no developer dir.
39
+ ASSETUTIL = "/usr/bin/assetutil"
40
+
41
+ #: UIDeviceFamily member -> the primary app-icon renditions Apple requires of a
42
+ #: bundle that declares it, as the catalog idiom `assetutil` reports and the exact
43
+ #: pixel square Apple names in ITMS-90023.
44
+ REQUIRED_ICONS = {
45
+ 1: ("phone", ((120, "60x60@2x"), (180, "60x60@3x"))),
46
+ 2: ("pad", ((152, "76x76@2x"), (167, "83.5x83.5@2x"))),
47
+ }
48
+
49
+ #: The only icon squares Xcode ALSO writes as flat PNGs beside Assets.car. Without
50
+ #: the catalog, the absence of a flat 167 proves nothing and must never be reported
51
+ #: as a missing icon — that would refuse every correct build.
52
+ FLATTENED_ICON_SIZES = frozenset({120, 152})
53
+
54
+ #: Xcode 14+ lets one `universal` 1024 entry stand for every device family, and
55
+ #: `actool` then emits a single universal rendition instead of per-idiom ones.
56
+ #: Demanding `pad` renditions of such a catalog would refuse a correct app, so a
57
+ #: universal app-icon rendition satisfies every family.
58
+ UNIVERSAL_IDIOMS = frozenset({"universal"})
59
+
60
+ #: NSExtensionPointIdentifier -> the Info.plist keys Apple's delivery validator
61
+ #: reads for that extension point, WHERE it reads them, and the closed value set.
62
+ #: `path` is relative to the appex's NSExtension dictionary; () is the dictionary
63
+ #: itself. RPBroadcastProcessMode is the trap this table exists for: Apple's
64
+ #: runtime documentation nests it under NSExtensionAttributes and the delivery
65
+ #: validator reads it directly under NSExtension, so a bundle that satisfies only
66
+ #: the documented location is delivered and then refused as "not specified".
67
+ EXTENSION_KEYS = {
68
+ "com.apple.broadcast-services-upload": (
69
+ {"key": "RPBroadcastProcessMode", "path": (),
70
+ "values": ("RPBroadcastProcessModeSampleBuffer", "RPBroadcastProcessModeMP4Clip"),
71
+ "itms": "ITMS-90362"},
72
+ ),
73
+ "com.apple.broadcast-services-setupui": (
74
+ {"key": "NSExtensionPrincipalClass", "path": (), "values": (), "itms": "ITMS-90362"},
75
+ ),
76
+ "com.apple.widgetkit-extension": (
77
+ {"key": "NSExtensionPrincipalClass", "path": (), "values": (), "itms": "ITMS-90362"},
78
+ ),
79
+ }
80
+
81
+ #: Never shipped inside a Payload, and all of them are a directory listing away.
82
+ #: Scoped to what is genuinely disallowed: a vendored framework's own Modules/
83
+ #: directory is normal and must not be swept up here.
84
+ FORBIDDEN_PAYLOAD = (
85
+ {"glob": ".DS_Store", "itms": "ITMS-90049", "what": "a Finder metadata file"},
86
+ {"glob": "*.dSYM", "itms": "ITMS-90049", "what": "a debug symbol bundle"},
87
+ {"glob": "*.storekit", "itms": "ITMS-90049", "what": "a StoreKit test configuration"},
88
+ )
89
+
90
+
91
+ class Refused(Exception):
92
+ """The bundle could not be read well enough to judge it."""
93
+
94
+
95
+ # ── reading the bundle ──────────────────────────────────────────────────────
96
+
97
+ def _ihdr(data: bytes) -> tuple[int, int, int] | None:
98
+ """(width, height, colour type) from a PNG's IHDR chunk.
99
+
100
+ Xcode runs shipped icons through its own PNG optimizer, which inserts a
101
+ `CgBI` chunk BEFORE `IHDR`. Reading IHDR at a fixed offset therefore returns
102
+ garbage for exactly the builds that matter, so the chunk stream is walked.
103
+ """
104
+ if not data.startswith(b"\x89PNG\r\n\x1a\n"):
105
+ return None
106
+ offset = 8
107
+ while offset + 8 <= len(data):
108
+ length = struct.unpack(">I", data[offset:offset + 4])[0]
109
+ if data[offset + 4:offset + 8] == b"IHDR" and offset + 18 <= len(data):
110
+ width, height = struct.unpack(">II", data[offset + 8:offset + 16])
111
+ return width, height, data[offset + 17]
112
+ offset += 12 + length
113
+ return None
114
+
115
+
116
+ def png_size(data: bytes) -> tuple[int, int] | None:
117
+ header = _ihdr(data)
118
+ return (header[0], header[1]) if header else None
119
+
120
+
121
+ def png_has_alpha(data: bytes) -> bool | None:
122
+ header = _ihdr(data)
123
+ return header[2] in (4, 6) if header else None
124
+
125
+
126
+ #: The only bundle files whose BYTES a check reads. Everything else is judged from
127
+ #: the archive's own name list, so an .ipa costs the same whether it is 30 MB or
128
+ #: 300 MB — a preflight that scaled with the binary would tax every release for
129
+ #: the sake of a handful of plists.
130
+ def _wanted(relative: str) -> bool:
131
+ name = relative.rsplit("/", 1)[-1]
132
+ return (name in ("Info.plist", "Assets.car", "PrivacyInfo.xcprivacy")
133
+ or (name.startswith("AppIcon") and name.endswith(".png")
134
+ and relative.count("/") == 0))
135
+
136
+
137
+ def unpack_ipa(ipa: Path, into: Path) -> tuple[Path, set[str]]:
138
+ """Extract the few files the checks parse; return the app and EVERY path in it."""
139
+ with zipfile.ZipFile(ipa) as archive:
140
+ names = [name for name in archive.namelist()
141
+ if name.startswith("Payload/") and not name.startswith("/") and ".." not in name]
142
+ roots = {name.split("/")[1] for name in names
143
+ if name.count("/") >= 2 and name.split("/")[1].endswith(".app")}
144
+ if len(roots) != 1:
145
+ raise Refused("ipa_payload_unreadable")
146
+ prefix = f"Payload/{roots.pop()}/"
147
+ inside = {name[len(prefix):] for name in names
148
+ if name.startswith(prefix) and len(name) > len(prefix)}
149
+ archive.extractall(into, members=[prefix + item for item in inside
150
+ if _wanted(item) and not item.endswith("/")])
151
+ app = into / prefix.rstrip("/")
152
+ app.mkdir(parents=True, exist_ok=True)
153
+ return app, {item.rstrip("/") for item in inside if item}
154
+
155
+
156
+ def walk_names(app: Path) -> set[str]:
157
+ return {str(path.relative_to(app)) for path in app.rglob("*")}
158
+
159
+
160
+ def find_app(target: Path, scratch: Path | None) -> tuple[Path, set[str]]:
161
+ """The one `.app` to judge and every path inside it, from an .app, an
162
+ .xcarchive, an export directory or an .ipa."""
163
+ target = target.resolve()
164
+ if target.is_dir() and target.suffix == ".app":
165
+ return target, walk_names(target)
166
+ if target.is_dir() and target.suffix == ".xcarchive":
167
+ apps = sorted((target / "Products" / "Applications").glob("*.app"))
168
+ if len(apps) != 1:
169
+ raise Refused("xcarchive_application_unreadable")
170
+ return apps[0], walk_names(apps[0])
171
+ if target.is_file() and target.suffix == ".ipa":
172
+ if scratch is None:
173
+ raise Refused("ipa_scratch_required")
174
+ return unpack_ipa(target, scratch)
175
+ if target.is_dir():
176
+ candidates = sorted(target.glob("*.app")) or sorted(target.glob("*.ipa"))
177
+ if len(candidates) == 1:
178
+ return find_app(candidates[0], scratch)
179
+ raise Refused("bundle_not_found")
180
+
181
+
182
+ def read_plist(path: Path) -> dict:
183
+ try:
184
+ value = plistlib.loads(path.read_bytes())
185
+ except (OSError, plistlib.InvalidFileException, ValueError) as exc:
186
+ raise Refused("info_plist_unreadable") from exc
187
+ if not isinstance(value, dict):
188
+ raise Refused("info_plist_unreadable")
189
+ return value
190
+
191
+
192
+ def catalog_renditions(app: Path) -> list[dict] | None:
193
+ """Every rendition `actool` emitted, or None when the catalog cannot be read.
194
+
195
+ None is NOT a pass: the caller falls back to the flattened icons and says so,
196
+ so an unreadable catalog never silently certifies an icon set.
197
+ """
198
+ car = app / "Assets.car"
199
+ if not car.is_file() or not Path(ASSETUTIL).is_file():
200
+ return None
201
+ try:
202
+ done = subprocess.run([ASSETUTIL, "--info", str(car)], capture_output=True,
203
+ text=True, timeout=120, check=False)
204
+ except (OSError, subprocess.SubprocessError):
205
+ return None
206
+ if done.returncode != 0:
207
+ return None
208
+ try:
209
+ parsed = json.loads(done.stdout)
210
+ except ValueError:
211
+ return None
212
+ return [item for item in parsed if isinstance(item, dict)]
213
+
214
+
215
+ def flattened_icon_sizes(app: Path) -> set[int]:
216
+ sizes = set()
217
+ for png in app.glob("AppIcon*.png"):
218
+ try:
219
+ measured = png_size(png.read_bytes())
220
+ except OSError:
221
+ continue
222
+ if measured and measured[0] == measured[1]:
223
+ sizes.add(measured[0])
224
+ return sizes
225
+
226
+
227
+ def app_extensions(app: Path, names: set[str]) -> list[tuple[str, dict]]:
228
+ plugins = sorted({item.split("/")[1] for item in names
229
+ if item.startswith("PlugIns/") and item.count("/") >= 1
230
+ and item.split("/")[1].endswith(".appex")})
231
+ found = []
232
+ for appex in plugins:
233
+ info = app / "PlugIns" / appex / "Info.plist"
234
+ if not info.is_file():
235
+ found.append((appex, {}))
236
+ continue
237
+ try:
238
+ found.append((appex, read_plist(info)))
239
+ except Refused:
240
+ found.append((appex, {}))
241
+ return found
242
+
243
+
244
+ # ── the checks ──────────────────────────────────────────────────────────────
245
+
246
+ def _icon_renditions(renditions: list[dict]) -> list[dict]:
247
+ return [item for item in renditions if str(item.get("Name") or "").startswith("AppIcon")]
248
+
249
+
250
+ def check_app_icons(ctx: dict) -> list[dict]:
251
+ """Every primary icon the declared device families require.
252
+
253
+ The compiled catalog is the authority, because it is what ITMS-90023 reads.
254
+ Without it only the two squares Xcode also flattens can be judged, and the
255
+ rest are reported as unverified rather than as missing.
256
+ """
257
+ families, renditions, flattened = ctx["families"], ctx["renditions"], ctx["flattened"]
258
+ failures, unverified = [], []
259
+ if renditions is not None and any(
260
+ str(item.get("Idiom") or "") in UNIVERSAL_IDIOMS for item in _icon_renditions(renditions)):
261
+ # One universal icon covers every family; per-idiom renditions do not exist
262
+ # for it and demanding them would refuse a correct Xcode 14+ catalog.
263
+ return []
264
+ for family in sorted(families):
265
+ if family not in REQUIRED_ICONS:
266
+ continue
267
+ idiom, required = REQUIRED_ICONS[family]
268
+ device = "iPad" if family == 2 else "iPhone"
269
+ for pixels, label in required:
270
+ if renditions is not None:
271
+ present = any(item.get("Idiom") == idiom and item.get("PixelWidth") == pixels
272
+ and item.get("PixelHeight") == pixels
273
+ for item in _icon_renditions(renditions))
274
+ source = "the compiled asset catalog"
275
+ elif pixels in FLATTENED_ICON_SIZES:
276
+ present, source = pixels in flattened, "the bundle's flattened icons"
277
+ else:
278
+ unverified.append(f"{pixels}x{pixels} ({device})")
279
+ continue
280
+ if not present:
281
+ failures.append({
282
+ "itms": "ITMS-90023",
283
+ "message": f"no {pixels}x{pixels} app icon for {device} in {source}",
284
+ "next": (f"Add the {label} {device} entry to AppIcon.appiconset (or a "
285
+ f"`universal` entry with `platform: ios` that covers it), or drop "
286
+ f"{family} from TARGETED_DEVICE_FAMILY if the app does not ship "
287
+ f"for that device."),
288
+ })
289
+ if unverified:
290
+ ctx["unverified_icons"] = unverified
291
+ return failures
292
+
293
+
294
+ def check_marketing_icon(ctx: dict) -> list[dict]:
295
+ """The 1024 App Store icon, accepting either catalog style.
296
+
297
+ Apple takes it from `ios-marketing` or from the Xcode 14+ single-size
298
+ `universal` 1024 entry; treating the latter as absent would refuse shipping
299
+ apps, and treating it as not-applicable would exempt them silently.
300
+ """
301
+ renditions = ctx["renditions"]
302
+ if renditions is None:
303
+ return []
304
+ icons = _icon_renditions(renditions)
305
+ if not icons:
306
+ return []
307
+ present = any(item.get("PixelWidth") == 1024 and item.get("PixelHeight") == 1024
308
+ and str(item.get("Idiom") or "") in UNIVERSAL_IDIOMS | {"marketing"}
309
+ for item in icons)
310
+ if present:
311
+ return []
312
+ return [{"itms": "ITMS-90704",
313
+ "message": "no 1024x1024 App Store marketing icon in the compiled asset catalog",
314
+ "next": "Add the 1024x1024 `ios-marketing` entry to AppIcon.appiconset."}]
315
+
316
+
317
+ def check_extension_keys(ctx: dict) -> list[dict]:
318
+ failures = []
319
+ for name, info in ctx["extensions"]:
320
+ extension = info.get("NSExtension")
321
+ if not isinstance(extension, dict):
322
+ failures.append({"itms": "ITMS-90206",
323
+ "message": f"{name} has no NSExtension dictionary",
324
+ "next": "Give the extension a valid NSExtension Info.plist entry."})
325
+ continue
326
+ point = extension.get("NSExtensionPointIdentifier")
327
+ for rule in EXTENSION_KEYS.get(str(point), ()):
328
+ node = extension
329
+ for step in rule["path"]:
330
+ node = node.get(step) if isinstance(node, dict) else None
331
+ value = node.get(rule["key"]) if isinstance(node, dict) else None
332
+ legal = rule["values"]
333
+ if value is not None and (not legal or value in legal):
334
+ continue
335
+ where = "NSExtension" + "".join("." + step for step in rule["path"])
336
+ elsewhere = ""
337
+ attributes = extension.get("NSExtensionAttributes")
338
+ if isinstance(attributes, dict) and rule["key"] in attributes and not rule["path"]:
339
+ elsewhere = (" The key is present under NSExtension.NSExtensionAttributes, which "
340
+ "is where the runtime documentation puts it; Apple's delivery "
341
+ "validator does not read it there.")
342
+ failures.append({
343
+ "itms": rule["itms"],
344
+ "message": (f"{name}: {where}.{rule['key']} is "
345
+ f"{'absent' if value is None else 'not an accepted value'}." + elsewhere),
346
+ "next": (f"Set {where}.{rule['key']} in the extension's Info.plist"
347
+ + (f" to one of {', '.join(legal)}." if legal else ".")
348
+ + (" Keep the NSExtensionAttributes copy as well." if elsewhere else "")),
349
+ })
350
+ return failures
351
+
352
+
353
+ def check_bundle_identifiers(ctx: dict) -> list[dict]:
354
+ """Nested APP EXTENSION identity only.
355
+
356
+ Scoped to PlugIns on purpose: CocoaPods legitimately ships frameworks and
357
+ resource bundles whose identifiers repeat or sit outside the app's namespace,
358
+ so applying this to Frameworks/* would refuse ordinary builds.
359
+ """
360
+ app_id = str(ctx["info"].get("CFBundleIdentifier") or "")
361
+ failures, seen = [], {app_id}
362
+ for name, info in ctx["extensions"]:
363
+ nested = str(info.get("CFBundleIdentifier") or "")
364
+ if not nested or "$(" in nested:
365
+ failures.append({"itms": "ITMS-90007",
366
+ "message": f"{name} has no resolved CFBundleIdentifier",
367
+ "next": "Give the extension a literal bundle identifier."})
368
+ continue
369
+ if not nested.startswith(app_id + "."):
370
+ failures.append({
371
+ "itms": "ITMS-90046",
372
+ "message": f"{name} bundle id {nested!r} is not a child of the app id {app_id!r}",
373
+ "next": f"Rename the extension's bundle id to {app_id}.<suffix>."})
374
+ if nested in seen:
375
+ failures.append({"itms": "ITMS-90885",
376
+ "message": f"{name} repeats bundle id {nested!r}",
377
+ "next": "Give every nested bundle its own identifier."})
378
+ seen.add(nested)
379
+ return failures
380
+
381
+
382
+ def check_version_strings(ctx: dict) -> list[dict]:
383
+ """Version legality and app/extension agreement. Cheap and defensive: it did
384
+ not cause any observed refusal, and it costs one regex per bundle."""
385
+ info = ctx["info"]
386
+ short, build = info.get("CFBundleShortVersionString"), info.get("CFBundleVersion")
387
+ failures = []
388
+ if not isinstance(short, str) or not re.fullmatch(r"\d+(\.\d+){0,2}", short):
389
+ failures.append({"itms": "ITMS-90060",
390
+ "message": f"CFBundleShortVersionString {short!r} is not a legal version",
391
+ "next": "Use up to three dot-separated numbers, e.g. 2.0.0."})
392
+ if not isinstance(build, str) or not re.fullmatch(r"\d+(\.\d+){0,2}", build):
393
+ failures.append({"itms": "ITMS-90059",
394
+ "message": f"CFBundleVersion {build!r} is not a legal build number",
395
+ "next": "Use up to three dot-separated numbers, e.g. 190."})
396
+ for name, nested in ctx["extensions"]:
397
+ if nested.get("CFBundleShortVersionString") not in (None, short):
398
+ failures.append({
399
+ "itms": "ITMS-90473",
400
+ "message": (f"{name} CFBundleShortVersionString "
401
+ f"{nested.get('CFBundleShortVersionString')!r} != the app's {short!r}"),
402
+ "next": "Drive every nested bundle's MARKETING_VERSION from the app target."})
403
+ if nested.get("CFBundleVersion") not in (None, build):
404
+ failures.append({
405
+ "itms": "ITMS-90473",
406
+ "message": (f"{name} CFBundleVersion {nested.get('CFBundleVersion')!r} "
407
+ f"!= the app's {build!r}"),
408
+ "next": "Drive every nested bundle's CURRENT_PROJECT_VERSION from the app target."})
409
+ return failures
410
+
411
+
412
+ def check_payload_hygiene(ctx: dict) -> list[dict]:
413
+ names, failures = ctx["names"], []
414
+ for rule in FORBIDDEN_PAYLOAD:
415
+ hits = [item for item in sorted(names) if fnmatch(item.rsplit("/", 1)[-1], rule["glob"])]
416
+ for found in hits[:8]:
417
+ failures.append({
418
+ "itms": rule["itms"],
419
+ "message": f"the payload contains {rule['what']}: {found}",
420
+ "next": "Remove it from the bundle's copied resources."})
421
+ platforms = ctx["info"].get("CFBundleSupportedPlatforms")
422
+ if isinstance(platforms, list) and "iPhoneSimulator" in platforms:
423
+ failures.append({
424
+ "itms": "ITMS-90085",
425
+ "message": "the bundle was built for the simulator, not for a device",
426
+ "next": ("Archive with -destination 'generic/platform=iOS'; a simulator build is "
427
+ "never deliverable.")})
428
+ for name, info in ctx["extensions"]:
429
+ executable = info.get("CFBundleExecutable")
430
+ if not executable or f"PlugIns/{name}/{executable}" not in names:
431
+ failures.append({
432
+ "itms": "ITMS-90562",
433
+ "message": f"{name} declares no readable executable",
434
+ "next": "Ensure the extension target produces and embeds its binary."})
435
+ nested = info.get("CFBundleSupportedPlatforms")
436
+ if isinstance(nested, list) and "iPhoneSimulator" in nested:
437
+ failures.append({
438
+ "itms": "ITMS-90085",
439
+ "message": f"{name} was built for the simulator",
440
+ "next": "Rebuild the extension for a device destination."})
441
+ return failures
442
+
443
+
444
+ #: id -> (title, run). One row per delivery refusal.
445
+ CHECKS = (
446
+ {"id": "app_icons", "title": "required app icons", "run": check_app_icons},
447
+ {"id": "marketing_icon", "title": "App Store marketing icon", "run": check_marketing_icon},
448
+ {"id": "extension_keys", "title": "app extension Info.plist keys", "run": check_extension_keys},
449
+ {"id": "bundle_identifiers", "title": "nested bundle identifiers",
450
+ "run": check_bundle_identifiers},
451
+ {"id": "version_strings", "title": "version strings", "run": check_version_strings},
452
+ {"id": "payload_hygiene", "title": "payload contents", "run": check_payload_hygiene},
453
+ )
454
+
455
+
456
+ def context(app: Path, names: set[str] | None = None) -> dict:
457
+ if names is None:
458
+ names = walk_names(app)
459
+ info = read_plist(app / "Info.plist")
460
+ families = info.get("UIDeviceFamily")
461
+ families = ({int(item) for item in families if isinstance(item, int)}
462
+ if isinstance(families, list) else {1})
463
+ return {"app": app, "names": names, "info": info, "families": families,
464
+ "renditions": catalog_renditions(app),
465
+ "flattened": flattened_icon_sizes(app),
466
+ "extensions": app_extensions(app, names)}
467
+
468
+
469
+ def inspect(app: Path, *, only: set[str] | None = None, names: set[str] | None = None) -> dict:
470
+ ctx = context(app, names)
471
+ failures, advisories, ran = [], [], []
472
+ for check in CHECKS:
473
+ if only and check["id"] not in only:
474
+ continue
475
+ ran.append(check["id"])
476
+ for item in check["run"](ctx):
477
+ item["check"] = check["id"]
478
+ failures.append(item)
479
+ if ctx.get("unverified_icons"):
480
+ # Say what could not be judged. Silence here would read as "icons fine".
481
+ advisories.append({
482
+ "check": "app_icons", "itms": "ITMS-90023",
483
+ "message": (f"Assets.car could not be read, so {', '.join(ctx['unverified_icons'])} "
484
+ f"could not be verified (Xcode keeps those sizes in the catalog only)"),
485
+ "next": "Run this on a macOS runner where /usr/bin/assetutil can read the catalog."})
486
+ return {"schema": SCHEMA, "ok": not failures, "bundle": app.name,
487
+ "bundle_id": ctx["info"].get("CFBundleIdentifier"),
488
+ "version": ctx["info"].get("CFBundleShortVersionString"),
489
+ "build": ctx["info"].get("CFBundleVersion"),
490
+ "device_families": sorted(ctx["families"]),
491
+ "asset_catalog": "read" if ctx["renditions"] is not None else "unreadable",
492
+ "checks": ran, "failures": failures, "advisories": advisories}
493
+
494
+
495
+ def report(answer: dict) -> None:
496
+ for item in answer.get("advisories", []):
497
+ print(f"::warning title=apple_delivery_preflight::{item['itms']}: {item['message']}")
498
+ for item in answer.get("failures", []):
499
+ print(f"::error title=apple_delivery_preflight::{item['itms']}: {item['message']} "
500
+ f"-- {item['next']}")
501
+
502
+
503
+ def main(argv: list[str]) -> int:
504
+ parser = argparse.ArgumentParser(description=__doc__)
505
+ parser.add_argument("target", type=Path,
506
+ help="the exported .app, .ipa, .xcarchive, or the export directory")
507
+ parser.add_argument("--scratch", type=Path, default=None,
508
+ help="writable directory used to unpack an .ipa")
509
+ parser.add_argument("--only", default="", help="comma-separated check ids to run")
510
+ args = parser.parse_args(argv)
511
+ try:
512
+ app, names = find_app(args.target, args.scratch)
513
+ answer = inspect(app, only={item for item in args.only.split(",") if item} or None,
514
+ names=names)
515
+ except Refused as exc:
516
+ answer = {"schema": SCHEMA, "ok": False, "code": str(exc), "failures": [],
517
+ "next": "Point the preflight at the exported .app, .ipa or .xcarchive."}
518
+ print(f"::error title=apple_delivery_preflight::{exc}")
519
+ print(json.dumps(answer, indent=2, sort_keys=True))
520
+ if answer.get("ok"):
521
+ return 0
522
+ report(answer)
523
+ return 1
524
+
525
+
526
+ if __name__ == "__main__":
527
+ raise SystemExit(main(sys.argv[1:]))