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.
- package/action/.daemux-version +1 -1
- package/action/action.yml +33 -0
- package/action/scripts/delivery_preflight.py +527 -0
- package/action/scripts/source_preflight.py +370 -0
- package/action/scripts/test_delivery_preflight.py +333 -0
- package/action/scripts/test_source_preflight.py +226 -0
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/docs/CONFIGURATION.md +6 -3
- package/package.json +1 -1
- package/scripts/lib/checks.mjs +1 -1
- package/src/config/merge.mjs +15 -3
- package/src/config/validation-events.mjs +56 -0
- package/src/config/validation.mjs +9 -20
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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:]))
|