gowalk-cicd 1.0.4 → 1.0.6
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/README.md +35 -4
- package/action/.daemux-version +1 -1
- package/action/scripts/auto_detect.py +63 -3
- package/action/scripts/capabilities.py +166 -0
- package/action/scripts/pbxproj_editor.py +30 -4
- package/action/scripts/prepare_signing.py +26 -1
- package/action/scripts/profile_manager.py +16 -2
- package/action/scripts/test_auto_detect.py +49 -0
- package/action/scripts/test_capabilities.py +145 -0
- package/android-action/.daemux-version +1 -1
- package/android-action/action.yml +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -147,7 +147,7 @@ configuration for either:
|
|
|
147
147
|
|---|---|---|
|
|
148
148
|
| Detected by | `pubspec.yaml` at the repo root | `settings.gradle(.kts)` + `gradlew` at the root or under `android/` |
|
|
149
149
|
| App module | `android/app` | the one module applying `com.android.application` (version-catalog aliases are resolved; a module named `app` wins a tie against a wear/automotive sibling) |
|
|
150
|
-
| Tests (`run-tests`) | `flutter analyze` + `flutter test` |
|
|
150
|
+
| Tests (`run-tests`) | `flutter analyze` + `flutter test` | `gradlew test` (all variants, all modules) |
|
|
151
151
|
| Build | `flutter build appbundle --release` | `<module>:bundleRelease` |
|
|
152
152
|
| Toolchain installed | Flutter + JDK 17 | JDK 21 only |
|
|
153
153
|
|
|
@@ -176,9 +176,11 @@ The iOS composite action runs on `macos-15` and:
|
|
|
176
176
|
declared version is already live.
|
|
177
177
|
4. **Computes the next build number** by querying ASC for the latest uploaded
|
|
178
178
|
build and incrementing.
|
|
179
|
-
5. **Provisions signing** at runtime:
|
|
180
|
-
|
|
181
|
-
|
|
179
|
+
5. **Provisions signing** at runtime: reconciles the App ID's capabilities with
|
|
180
|
+
the target's entitlements (see below), then generates a throwaway Apple
|
|
181
|
+
Distribution cert + a per-target App Store provisioning profile named
|
|
182
|
+
`CI-<bundle_id>`. Patches the `.pbxproj` to use Manual signing against those
|
|
183
|
+
profiles.
|
|
182
184
|
6. **Archives** with `xcodebuild archive`, exports the IPA, and uploads via
|
|
183
185
|
`xcrun altool`.
|
|
184
186
|
7. **Sets "What's New"** on every declared localization (reads
|
|
@@ -191,6 +193,29 @@ The iOS composite action runs on `macos-15` and:
|
|
|
191
193
|
|
|
192
194
|
The iOS action requires only the p8. Everything else is derived.
|
|
193
195
|
|
|
196
|
+
### App ID capabilities
|
|
197
|
+
|
|
198
|
+
A provisioning profile carries only the capabilities enabled on its App ID.
|
|
199
|
+
Xcode's automatic signing hides this by turning them on as you edit
|
|
200
|
+
entitlements; the ASC API does not, so a CI-issued profile omits them and the
|
|
201
|
+
archive fails with
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
Provisioning profile "CI-com.example.app" doesn't include the App Attest capability.
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Before creating a profile, the action reads the target's
|
|
208
|
+
`CODE_SIGN_ENTITLEMENTS` plist and enables the matching capabilities on the App
|
|
209
|
+
ID — Push Notifications, App Attest, Associated Domains, Sign in with Apple,
|
|
210
|
+
HealthKit, SiriKit, NFC, HomeKit, network extensions and the other plain
|
|
211
|
+
on/off toggles. If it had to turn anything on, it regenerates the profile
|
|
212
|
+
rather than reusing the cached one, which predates the change.
|
|
213
|
+
|
|
214
|
+
Capabilities that carry values an entitlements file cannot supply — App Groups,
|
|
215
|
+
iCloud containers, Apple Pay merchant IDs, Wallet pass types, Data Protection —
|
|
216
|
+
are **not** enabled automatically. The action emits a `::warning::` naming what
|
|
217
|
+
it saw and leaves those for you to configure in the Apple Developer portal.
|
|
218
|
+
|
|
194
219
|
## AI metadata auto-fill
|
|
195
220
|
|
|
196
221
|
On every run, after the TestFlight upload succeeds, the action:
|
|
@@ -319,6 +344,12 @@ pending agreements, retry.
|
|
|
319
344
|
**Upload fails with provisioning errors** — delete any stale profiles named
|
|
320
345
|
`CI-<bundle_id>` on developer.apple.com and re-run; the action will regenerate.
|
|
321
346
|
|
|
347
|
+
**"Provisioning profile doesn't include the <X> capability"** — the App ID
|
|
348
|
+
lacks a capability the entitlements declare. The action enables the simple
|
|
349
|
+
toggles itself (see [App ID capabilities](#app-id-capabilities)); if the
|
|
350
|
+
warning names App Groups, iCloud, Apple Pay, Wallet or Data Protection, enable
|
|
351
|
+
and configure that one in the Apple Developer portal, then re-run.
|
|
352
|
+
|
|
322
353
|
## Auto-bumping MARKETING_VERSION
|
|
323
354
|
|
|
324
355
|
When the ASC combined floor (max of pending review, `preReleaseVersions`,
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.6
|
|
@@ -52,8 +52,15 @@ from cfg_io import log, notice
|
|
|
52
52
|
APP_PRODUCT_TYPE = "com.apple.product-type.application"
|
|
53
53
|
# Timeouts (seconds) — xcodebuild can hang on missing toolchains or
|
|
54
54
|
# package resolution. CI should fail fast rather than spin forever.
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
#
|
|
56
|
+
# The first xcodebuild invocation on a cold runner also resolves the Swift
|
|
57
|
+
# Package Manager graph, and a project that depends on firebase-ios-sdk spends
|
|
58
|
+
# minutes cloning before it prints a single scheme. That is legitimate work,
|
|
59
|
+
# not a hang, so `-list` gets a budget that survives it and a one-shot
|
|
60
|
+
# pre-resolution retry (see _list_schemes) rather than a tighter deadline.
|
|
61
|
+
LIST_TIMEOUT = 180
|
|
62
|
+
SETTINGS_TIMEOUT = 300
|
|
63
|
+
RESOLVE_TIMEOUT = 900
|
|
57
64
|
|
|
58
65
|
# Process-local caches keyed by (project, workspace_file) or
|
|
59
66
|
# (project, workspace_file, scheme, configuration). Cleared between test
|
|
@@ -221,6 +228,39 @@ def _pick_by_basename(matches: list[Path], workspace: Path, label: str) -> Path:
|
|
|
221
228
|
return matches[0]
|
|
222
229
|
|
|
223
230
|
|
|
231
|
+
def _resolve_package_dependencies(
|
|
232
|
+
workspace: Path, project: str, workspace_file: str
|
|
233
|
+
) -> bool:
|
|
234
|
+
"""Resolve the SPM graph once so later xcodebuild calls are cheap.
|
|
235
|
+
|
|
236
|
+
Returns True when resolution finished (regardless of exit status — a
|
|
237
|
+
project with no packages still exits cleanly and a partial resolve may
|
|
238
|
+
be enough for ``-list``), False when it could not be run at all.
|
|
239
|
+
"""
|
|
240
|
+
cmd = ["xcodebuild", "-resolvePackageDependencies"]
|
|
241
|
+
if workspace_file:
|
|
242
|
+
cmd += ["-workspace", workspace_file]
|
|
243
|
+
elif project:
|
|
244
|
+
cmd += ["-project", project]
|
|
245
|
+
else:
|
|
246
|
+
return False
|
|
247
|
+
log("auto-detect: pre-resolving Swift package dependencies")
|
|
248
|
+
try:
|
|
249
|
+
result = subprocess.run(
|
|
250
|
+
cmd, cwd=str(workspace), capture_output=True, text=True,
|
|
251
|
+
timeout=RESOLVE_TIMEOUT,
|
|
252
|
+
)
|
|
253
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
254
|
+
log(f"auto-detect: xcodebuild -resolvePackageDependencies failed: {exc!r}")
|
|
255
|
+
return False
|
|
256
|
+
if result.returncode != 0:
|
|
257
|
+
log(
|
|
258
|
+
f"auto-detect: xcodebuild -resolvePackageDependencies returned "
|
|
259
|
+
f"{result.returncode}: {result.stderr.strip()[:500]}"
|
|
260
|
+
)
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
|
|
224
264
|
def _list_schemes(
|
|
225
265
|
workspace: Path, project: str, workspace_file: str
|
|
226
266
|
) -> Optional[list[str]]:
|
|
@@ -243,7 +283,27 @@ def _list_schemes(
|
|
|
243
283
|
cmd, cwd=str(workspace), capture_output=True, text=True,
|
|
244
284
|
timeout=LIST_TIMEOUT,
|
|
245
285
|
)
|
|
246
|
-
except
|
|
286
|
+
except subprocess.TimeoutExpired as exc:
|
|
287
|
+
# Almost always SPM resolution rather than a hang: `-list` implicitly
|
|
288
|
+
# resolves the package graph, and a cold Firebase/GoogleSignIn clone
|
|
289
|
+
# outlasts any deadline short enough to still catch a real hang. Pay
|
|
290
|
+
# for the resolve once, explicitly, then retry. Swallowing this is what
|
|
291
|
+
# produced the downstream "MARKETING_VERSION for scheme=" failure --
|
|
292
|
+
# an empty scheme reads as "project has none", not "we never asked".
|
|
293
|
+
log(f"auto-detect: xcodebuild -list timed out: {exc!r}")
|
|
294
|
+
if not _resolve_package_dependencies(workspace, project, workspace_file):
|
|
295
|
+
_list_cache[key] = None
|
|
296
|
+
return None
|
|
297
|
+
try:
|
|
298
|
+
result = subprocess.run(
|
|
299
|
+
cmd, cwd=str(workspace), capture_output=True, text=True,
|
|
300
|
+
timeout=LIST_TIMEOUT,
|
|
301
|
+
)
|
|
302
|
+
except (OSError, subprocess.TimeoutExpired) as retry_exc:
|
|
303
|
+
log(f"auto-detect: xcodebuild -list failed after resolve: {retry_exc!r}")
|
|
304
|
+
_list_cache[key] = None
|
|
305
|
+
return None
|
|
306
|
+
except OSError as exc:
|
|
247
307
|
log(f"auto-detect: xcodebuild -list failed: {exc!r}")
|
|
248
308
|
_list_cache[key] = None
|
|
249
309
|
return None
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reconcile an App ID's capabilities with what the app's entitlements need.
|
|
3
|
+
|
|
4
|
+
A provisioning profile only carries the capabilities enabled on its App ID.
|
|
5
|
+
Xcode's automatic signing hides this by enabling them for you as you edit
|
|
6
|
+
entitlements; the ASC API does not, so a CI-generated profile silently omits
|
|
7
|
+
them and `xcodebuild archive` fails with
|
|
8
|
+
|
|
9
|
+
Provisioning profile "CI-<bundle>" doesn't include the App Attest
|
|
10
|
+
capability.
|
|
11
|
+
|
|
12
|
+
This module reads the entitlement keys the project actually declares and turns
|
|
13
|
+
on the matching capabilities before the profile is created.
|
|
14
|
+
|
|
15
|
+
Deliberately narrow: only capabilities that are a plain on/off switch are
|
|
16
|
+
enabled automatically. App Groups, iCloud containers, Apple Pay merchant IDs
|
|
17
|
+
and friends carry settings that cannot be guessed from an entitlements file, so
|
|
18
|
+
they are reported and left to a human. Everything here is best-effort — a
|
|
19
|
+
capability we cannot enable produces a ``::warning::`` and lets the archive
|
|
20
|
+
deliver Apple's own (more specific) error rather than failing the build here.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import plistlib
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from asc_common import get_json, request
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Entitlement key -> App Store Connect capabilityType, for capabilities that
|
|
32
|
+
# are a bare toggle. An entitlement absent from this map is not an error: most
|
|
33
|
+
# entitlements (keychain-access-groups, get-task-allow, ...) need no App ID
|
|
34
|
+
# capability at all.
|
|
35
|
+
CAPABILITY_BY_ENTITLEMENT = {
|
|
36
|
+
"aps-environment": "PUSH_NOTIFICATIONS",
|
|
37
|
+
"com.apple.developer.devicecheck.appattest-environment": "APP_ATTEST",
|
|
38
|
+
"com.apple.developer.associated-domains": "ASSOCIATED_DOMAINS",
|
|
39
|
+
"com.apple.developer.applesignin": "APPLE_ID_AUTH",
|
|
40
|
+
"com.apple.developer.networking.wifi-info": "ACCESS_WIFI_INFORMATION",
|
|
41
|
+
"com.apple.developer.networking.networkextension": "NETWORK_EXTENSIONS",
|
|
42
|
+
"com.apple.developer.networking.vpn.api": "PERSONAL_VPN",
|
|
43
|
+
"com.apple.developer.networking.multipath": "MULTIPATH",
|
|
44
|
+
"com.apple.developer.networking.HotspotConfiguration": "HOT_SPOT",
|
|
45
|
+
"com.apple.developer.nfc.readersession.formats": "NFC_TAG_READING",
|
|
46
|
+
"com.apple.developer.homekit": "HOMEKIT",
|
|
47
|
+
"com.apple.developer.healthkit": "HEALTHKIT",
|
|
48
|
+
"com.apple.developer.siri": "SIRIKIT",
|
|
49
|
+
"com.apple.developer.ClassKit-environment": "CLASSKIT",
|
|
50
|
+
"com.apple.developer.authentication-services.autofill-credential-provider":
|
|
51
|
+
"AUTOFILL_CREDENTIAL_PROVIDER",
|
|
52
|
+
"com.apple.external-accessory.wireless-configuration":
|
|
53
|
+
"WIRELESS_ACCESSORY_CONFIGURATION",
|
|
54
|
+
"inter-app-audio": "INTER_APP_AUDIO",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Capabilities whose App ID configuration carries values (container ids,
|
|
58
|
+
# merchant ids, protection level) that an entitlements file cannot supply
|
|
59
|
+
# unambiguously. Enabling these blind would create a half-configured App ID,
|
|
60
|
+
# so we say what is missing and stop.
|
|
61
|
+
MANUAL_ENTITLEMENTS = {
|
|
62
|
+
"com.apple.security.application-groups": "App Groups",
|
|
63
|
+
"com.apple.developer.icloud-container-identifiers": "iCloud",
|
|
64
|
+
"com.apple.developer.icloud-services": "iCloud",
|
|
65
|
+
"com.apple.developer.ubiquity-kvstore-identifier": "iCloud",
|
|
66
|
+
"com.apple.developer.in-app-payments": "Apple Pay",
|
|
67
|
+
"com.apple.developer.pass-type-identifiers": "Wallet",
|
|
68
|
+
"com.apple.developer.default-data-protection": "Data Protection",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_entitlement_keys(path: Path) -> set[str]:
|
|
73
|
+
"""Top-level keys of an entitlements plist; empty when unreadable."""
|
|
74
|
+
try:
|
|
75
|
+
with path.open("rb") as handle:
|
|
76
|
+
data = plistlib.load(handle)
|
|
77
|
+
except (OSError, plistlib.InvalidFileException, ValueError) as exc:
|
|
78
|
+
print(f"::warning::could not read entitlements {path}: {exc!r}")
|
|
79
|
+
return set()
|
|
80
|
+
return set(data) if isinstance(data, dict) else set()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def required_capabilities(entitlement_keys: set[str]) -> set[str]:
|
|
84
|
+
return {
|
|
85
|
+
CAPABILITY_BY_ENTITLEMENT[key]
|
|
86
|
+
for key in entitlement_keys
|
|
87
|
+
if key in CAPABILITY_BY_ENTITLEMENT
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def warn_about_manual_entitlements(entitlement_keys: set[str], bundle_id: str) -> None:
|
|
92
|
+
for key in sorted(entitlement_keys & set(MANUAL_ENTITLEMENTS)):
|
|
93
|
+
print(
|
|
94
|
+
f"::warning::{bundle_id} declares {key}, which needs "
|
|
95
|
+
f"{MANUAL_ENTITLEMENTS[key]} configured on the App ID with values "
|
|
96
|
+
f"this action cannot infer. Enable it in the Apple Developer "
|
|
97
|
+
f"portal if the archive complains."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def enabled_capabilities(token: str, bundle_pk: str) -> set[str]:
|
|
102
|
+
try:
|
|
103
|
+
data = get_json(
|
|
104
|
+
f"/bundleIds/{bundle_pk}/bundleIdCapabilities", token,
|
|
105
|
+
params={"limit": "200"},
|
|
106
|
+
)
|
|
107
|
+
except Exception as exc: # noqa: BLE001 - never fail signing over a read
|
|
108
|
+
print(f"::warning::could not list capabilities for {bundle_pk}: {exc!r}")
|
|
109
|
+
return set()
|
|
110
|
+
found = set()
|
|
111
|
+
for item in data.get("data", []):
|
|
112
|
+
capability = (item.get("attributes") or {}).get("capabilityType")
|
|
113
|
+
if capability:
|
|
114
|
+
found.add(capability)
|
|
115
|
+
return found
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def enable_capability(token: str, bundle_pk: str, capability: str) -> bool:
|
|
119
|
+
body = {
|
|
120
|
+
"data": {
|
|
121
|
+
"type": "bundleIdCapabilities",
|
|
122
|
+
"attributes": {"capabilityType": capability},
|
|
123
|
+
"relationships": {
|
|
124
|
+
"bundleId": {"data": {"type": "bundleIds", "id": bundle_pk}}
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
try:
|
|
129
|
+
# 409 means another writer got there first — the desired end state.
|
|
130
|
+
response = request(
|
|
131
|
+
"POST", "/bundleIdCapabilities", token,
|
|
132
|
+
json_body=body, allow_status={200, 201, 409},
|
|
133
|
+
)
|
|
134
|
+
except Exception as exc: # noqa: BLE001 - Apple's archive error is clearer
|
|
135
|
+
print(f"::warning::could not enable {capability}: {exc!r}")
|
|
136
|
+
return False
|
|
137
|
+
if response.status_code == 409:
|
|
138
|
+
return False
|
|
139
|
+
print(f"Enabled {capability} on the App ID")
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def reconcile(token: str, bundle_pk: str, bundle_id: str, entitlement_keys: set[str]) -> bool:
|
|
144
|
+
"""Enable every simple capability the entitlements imply.
|
|
145
|
+
|
|
146
|
+
Returns True when something was actually turned on — the caller must then
|
|
147
|
+
regenerate the profile, because a cached one predates the change and still
|
|
148
|
+
lacks the capability.
|
|
149
|
+
"""
|
|
150
|
+
warn_about_manual_entitlements(entitlement_keys, bundle_id)
|
|
151
|
+
wanted = required_capabilities(entitlement_keys)
|
|
152
|
+
if not wanted:
|
|
153
|
+
return False
|
|
154
|
+
missing = wanted - enabled_capabilities(token, bundle_pk)
|
|
155
|
+
if not missing:
|
|
156
|
+
return False
|
|
157
|
+
print(
|
|
158
|
+
f"{bundle_id}: entitlements require {', '.join(sorted(missing))} "
|
|
159
|
+
f"but the App ID does not have them; enabling"
|
|
160
|
+
)
|
|
161
|
+
# List, not a generator: `any` short-circuits, and every missing capability
|
|
162
|
+
# must be attempted, not just up to the first success.
|
|
163
|
+
return any([
|
|
164
|
+
enable_capability(token, bundle_pk, capability)
|
|
165
|
+
for capability in sorted(missing)
|
|
166
|
+
])
|
|
@@ -72,12 +72,33 @@ def _bundle_id_from_configs(objects: dict, config_ids: list[str]) -> str:
|
|
|
72
72
|
return ""
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def _entitlements_from_configs(objects: dict, config_ids: list[str]) -> str:
|
|
76
|
+
"""First CODE_SIGN_ENTITLEMENTS path across the given configs.
|
|
77
|
+
|
|
78
|
+
Needed to reconcile the App ID's capabilities with what the app declares —
|
|
79
|
+
a profile only carries capabilities enabled on its App ID. Build-setting
|
|
80
|
+
references are skipped rather than half-expanded; a missed entitlements
|
|
81
|
+
file costs a clearer error, a wrong one costs a wrong App ID edit.
|
|
82
|
+
"""
|
|
83
|
+
for cid in config_ids:
|
|
84
|
+
cfg = objects.get(cid) or {}
|
|
85
|
+
settings = cfg.get("buildSettings") or {}
|
|
86
|
+
value = (settings.get("CODE_SIGN_ENTITLEMENTS") or "").strip().strip('"')
|
|
87
|
+
if value and "$(" not in value and "${" not in value:
|
|
88
|
+
return value
|
|
89
|
+
return ""
|
|
90
|
+
|
|
91
|
+
|
|
75
92
|
def discover_signable_targets(project_path: str) -> list[dict]:
|
|
76
93
|
"""Return one entry per signable native target.
|
|
77
94
|
|
|
78
|
-
Each entry:
|
|
79
|
-
|
|
80
|
-
``
|
|
95
|
+
Each entry:
|
|
96
|
+
``{"name": str, "bundle_id": str, "config_ids": [str,...],
|
|
97
|
+
"entitlements": str}`` where ``config_ids`` is the list of
|
|
98
|
+
XCBuildConfiguration UUIDs whose ``buildSettings`` dict we need to patch
|
|
99
|
+
(typically Debug + Release) and ``entitlements`` is the target's
|
|
100
|
+
CODE_SIGN_ENTITLEMENTS path relative to the project directory ("" when the
|
|
101
|
+
target declares none).
|
|
81
102
|
"""
|
|
82
103
|
pbx = _load_pbxproj(project_path)
|
|
83
104
|
objects = pbx["objects"]
|
|
@@ -95,7 +116,12 @@ def discover_signable_targets(project_path: str) -> list[dict]:
|
|
|
95
116
|
print(f"skip target {name!r}: no PRODUCT_BUNDLE_IDENTIFIER")
|
|
96
117
|
continue
|
|
97
118
|
targets.append(
|
|
98
|
-
{
|
|
119
|
+
{
|
|
120
|
+
"name": name,
|
|
121
|
+
"bundle_id": bundle_id,
|
|
122
|
+
"config_ids": config_ids,
|
|
123
|
+
"entitlements": _entitlements_from_configs(objects, config_ids),
|
|
124
|
+
}
|
|
99
125
|
)
|
|
100
126
|
if not targets:
|
|
101
127
|
raise SystemExit(
|
|
@@ -39,6 +39,7 @@ import os
|
|
|
39
39
|
from datetime import datetime, timedelta, timezone
|
|
40
40
|
from pathlib import Path
|
|
41
41
|
|
|
42
|
+
import capabilities
|
|
42
43
|
import cert_factory
|
|
43
44
|
import creds_store
|
|
44
45
|
from asc_common import make_jwt
|
|
@@ -180,6 +181,29 @@ def _resolve_project_path() -> str:
|
|
|
180
181
|
return project
|
|
181
182
|
|
|
182
183
|
|
|
184
|
+
def _entitlement_keys_by_bundle(project: str, targets: list[dict]) -> dict[str, set[str]]:
|
|
185
|
+
"""bundle_id -> entitlement keys declared by the targets that ship it.
|
|
186
|
+
|
|
187
|
+
CODE_SIGN_ENTITLEMENTS is relative to the project directory. Targets
|
|
188
|
+
sharing a bundle id (an app and its test host, say) contribute a union —
|
|
189
|
+
a capability any of them needs must be on the App ID.
|
|
190
|
+
"""
|
|
191
|
+
base = Path(project).parent
|
|
192
|
+
by_bundle: dict[str, set[str]] = {}
|
|
193
|
+
for target in targets:
|
|
194
|
+
relative = target.get("entitlements") or ""
|
|
195
|
+
if not relative:
|
|
196
|
+
continue
|
|
197
|
+
path = base / relative
|
|
198
|
+
if not path.is_file():
|
|
199
|
+
print(f"::warning::{target['name']}: entitlements not found at {path}")
|
|
200
|
+
continue
|
|
201
|
+
keys = capabilities.read_entitlement_keys(path)
|
|
202
|
+
if keys:
|
|
203
|
+
by_bundle.setdefault(target["bundle_id"], set()).update(keys)
|
|
204
|
+
return by_bundle
|
|
205
|
+
|
|
206
|
+
|
|
183
207
|
def main() -> None:
|
|
184
208
|
runner_temp = env("RUNNER_TEMP")
|
|
185
209
|
# TEAM_ID is optional — provision_all_bundles returns the effective
|
|
@@ -202,7 +226,8 @@ def main() -> None:
|
|
|
202
226
|
token, creds_dir
|
|
203
227
|
)
|
|
204
228
|
mappings, effective_team = provision_all_bundles(
|
|
205
|
-
token, bundle_ids, cert_id, creds_dir=creds_dir, cache_hit=cache_hit
|
|
229
|
+
token, bundle_ids, cert_id, creds_dir=creds_dir, cache_hit=cache_hit,
|
|
230
|
+
entitlements_by_bundle=_entitlement_keys_by_bundle(project, targets),
|
|
206
231
|
)
|
|
207
232
|
print("Profile map:")
|
|
208
233
|
for bid, pname, uuid in mappings:
|
|
@@ -43,6 +43,7 @@ import subprocess
|
|
|
43
43
|
from datetime import datetime
|
|
44
44
|
from pathlib import Path
|
|
45
45
|
|
|
46
|
+
import capabilities
|
|
46
47
|
import creds_store
|
|
47
48
|
from asc_common import get_json, request
|
|
48
49
|
from pbxproj_editor import PROFILE_PREFIX
|
|
@@ -219,16 +220,26 @@ def _provision_bundle(
|
|
|
219
220
|
creds_dir: Path,
|
|
220
221
|
manifest: dict,
|
|
221
222
|
can_reuse: bool,
|
|
223
|
+
entitlement_keys: set[str] | None = None,
|
|
222
224
|
) -> tuple[str, str, str, dict]:
|
|
223
225
|
"""Provision one bundle; return ``(name, uuid, team, manifest_entry)``."""
|
|
224
226
|
name = profile_name_for(bid)
|
|
227
|
+
# Reconcile capabilities BEFORE deciding to reuse. A profile carries only
|
|
228
|
+
# the capabilities its App ID had when it was issued, so a cached profile
|
|
229
|
+
# that predates an entitlement being added stays broken forever otherwise
|
|
230
|
+
# — the archive keeps failing on a capability the App ID now has.
|
|
231
|
+
bundle_pk = ""
|
|
232
|
+
if entitlement_keys:
|
|
233
|
+
bundle_pk = ensure_bundle_id(token, bid)
|
|
234
|
+
if capabilities.reconcile(token, bundle_pk, bid, entitlement_keys):
|
|
235
|
+
can_reuse = False
|
|
225
236
|
if can_reuse:
|
|
226
237
|
reused = _try_reuse_cached(bid, name, cert_id, creds_dir, manifest)
|
|
227
238
|
if reused is not None:
|
|
228
239
|
uuid, team_id, entry = reused
|
|
229
240
|
return name, uuid, team_id, entry
|
|
230
241
|
|
|
231
|
-
bundle_pk = ensure_bundle_id(token, bid)
|
|
242
|
+
bundle_pk = bundle_pk or ensure_bundle_id(token, bid)
|
|
232
243
|
delete_profile_by_name(token, name)
|
|
233
244
|
profile_der = create_profile(token, name, bundle_pk, cert_id)
|
|
234
245
|
uuid, team_id, expiration = install_profile(profile_der)
|
|
@@ -266,6 +277,7 @@ def provision_all_bundles(
|
|
|
266
277
|
*,
|
|
267
278
|
creds_dir: Path,
|
|
268
279
|
cache_hit: bool,
|
|
280
|
+
entitlements_by_bundle: dict[str, set[str]] | None = None,
|
|
269
281
|
) -> tuple[list[tuple[str, str, str]], str]:
|
|
270
282
|
"""Create + install a CI profile for each bundle id, with caching.
|
|
271
283
|
|
|
@@ -291,9 +303,11 @@ def provision_all_bundles(
|
|
|
291
303
|
results: list[tuple[str, str, str]] = []
|
|
292
304
|
new_entries: list[dict] = []
|
|
293
305
|
effective_team = ""
|
|
306
|
+
entitlements_by_bundle = entitlements_by_bundle or {}
|
|
294
307
|
for bid in bundle_ids:
|
|
295
308
|
name, uuid, team_id, entry = _provision_bundle(
|
|
296
|
-
token, bid, cert_id, creds_dir, manifest, can_reuse
|
|
309
|
+
token, bid, cert_id, creds_dir, manifest, can_reuse,
|
|
310
|
+
entitlements_by_bundle.get(bid),
|
|
297
311
|
)
|
|
298
312
|
if team_id:
|
|
299
313
|
effective_team = team_id
|
|
@@ -9,6 +9,7 @@ All tests run offline. xcodebuild invocations are stubbed through
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
|
+
import subprocess
|
|
12
13
|
import sys
|
|
13
14
|
import tempfile
|
|
14
15
|
import unittest
|
|
@@ -318,6 +319,54 @@ class AutoDetectSchemeTests(unittest.TestCase):
|
|
|
318
319
|
scheme = auto_detect.auto_detect_scheme(root, "Proj.xcodeproj", "")
|
|
319
320
|
self.assertIsNone(scheme)
|
|
320
321
|
|
|
322
|
+
def test_resolves_packages_and_retries_when_list_times_out(self):
|
|
323
|
+
# `-list` implicitly resolves the SPM graph, so a cold runner cloning
|
|
324
|
+
# firebase-ios-sdk blows the deadline on the first call. Treating that
|
|
325
|
+
# as "no schemes" is what left SCHEME empty and failed the build much
|
|
326
|
+
# later, on an unrelated-looking MARKETING_VERSION error.
|
|
327
|
+
calls: list[str] = []
|
|
328
|
+
|
|
329
|
+
def fake_run(cmd, **kw):
|
|
330
|
+
if "-resolvePackageDependencies" in cmd:
|
|
331
|
+
calls.append("resolve")
|
|
332
|
+
result = mock.MagicMock()
|
|
333
|
+
result.returncode = 0
|
|
334
|
+
result.stdout = ""
|
|
335
|
+
return result
|
|
336
|
+
if "-list" in cmd:
|
|
337
|
+
calls.append("list")
|
|
338
|
+
if calls.count("list") == 1:
|
|
339
|
+
raise subprocess.TimeoutExpired(cmd, auto_detect.LIST_TIMEOUT)
|
|
340
|
+
result = mock.MagicMock()
|
|
341
|
+
result.returncode = 0
|
|
342
|
+
result.stdout = self._list_output(["MyApp"])
|
|
343
|
+
return result
|
|
344
|
+
result = mock.MagicMock()
|
|
345
|
+
result.returncode = 0
|
|
346
|
+
result.stdout = self._show_settings(APP_TYPE)
|
|
347
|
+
return result
|
|
348
|
+
|
|
349
|
+
root = Path("/tmp/repo")
|
|
350
|
+
with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
|
|
351
|
+
scheme = auto_detect.auto_detect_scheme(root, "Proj.xcodeproj", "")
|
|
352
|
+
|
|
353
|
+
self.assertEqual(scheme, "MyApp")
|
|
354
|
+
self.assertEqual(calls[:3], ["list", "resolve", "list"])
|
|
355
|
+
|
|
356
|
+
def test_gives_up_when_list_times_out_again_after_resolving(self):
|
|
357
|
+
def fake_run(cmd, **kw):
|
|
358
|
+
if "-resolvePackageDependencies" in cmd:
|
|
359
|
+
result = mock.MagicMock()
|
|
360
|
+
result.returncode = 0
|
|
361
|
+
result.stdout = ""
|
|
362
|
+
return result
|
|
363
|
+
raise subprocess.TimeoutExpired(cmd, auto_detect.LIST_TIMEOUT)
|
|
364
|
+
|
|
365
|
+
root = Path("/tmp/repo")
|
|
366
|
+
with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
|
|
367
|
+
scheme = auto_detect.auto_detect_scheme(root, "Proj.xcodeproj", "")
|
|
368
|
+
self.assertIsNone(scheme)
|
|
369
|
+
|
|
321
370
|
|
|
322
371
|
class AutoDetectBundleIdTests(unittest.TestCase):
|
|
323
372
|
"""auto_detect_bundle_id: extract PRODUCT_BUNDLE_IDENTIFIER from showBuildSettings."""
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""App ID capability reconciliation.
|
|
3
|
+
|
|
4
|
+
A profile only carries the capabilities its App ID had when Apple issued it,
|
|
5
|
+
so an entitlement the App ID does not know about fails the archive with
|
|
6
|
+
"Provisioning profile ... doesn't include the App Attest capability".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import plistlib
|
|
12
|
+
import tempfile
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from unittest import mock
|
|
16
|
+
|
|
17
|
+
import capabilities
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ENTITLEMENTS = {
|
|
21
|
+
"aps-environment": "production",
|
|
22
|
+
"com.apple.developer.devicecheck.appattest-environment": "production",
|
|
23
|
+
"keychain-access-groups": ["$(AppIdentifierPrefix)com.gowalk.form"],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ReadEntitlementsTest(unittest.TestCase):
|
|
28
|
+
def setUp(self) -> None:
|
|
29
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
30
|
+
self.path = Path(self.temp.name) / "App.entitlements"
|
|
31
|
+
|
|
32
|
+
def tearDown(self) -> None:
|
|
33
|
+
self.temp.cleanup()
|
|
34
|
+
|
|
35
|
+
def test_reads_top_level_keys(self) -> None:
|
|
36
|
+
with self.path.open("wb") as handle:
|
|
37
|
+
plistlib.dump(ENTITLEMENTS, handle)
|
|
38
|
+
|
|
39
|
+
self.assertEqual(capabilities.read_entitlement_keys(self.path), set(ENTITLEMENTS))
|
|
40
|
+
|
|
41
|
+
def test_unreadable_file_is_not_fatal(self) -> None:
|
|
42
|
+
self.path.write_text("not a plist")
|
|
43
|
+
|
|
44
|
+
self.assertEqual(capabilities.read_entitlement_keys(self.path), set())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RequiredCapabilitiesTest(unittest.TestCase):
|
|
48
|
+
def test_maps_only_known_toggles(self) -> None:
|
|
49
|
+
# keychain-access-groups needs no App ID capability at all.
|
|
50
|
+
self.assertEqual(
|
|
51
|
+
capabilities.required_capabilities(set(ENTITLEMENTS)),
|
|
52
|
+
{"PUSH_NOTIFICATIONS", "APP_ATTEST"},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def test_ignores_entitlements_with_no_capability(self) -> None:
|
|
56
|
+
self.assertEqual(
|
|
57
|
+
capabilities.required_capabilities({"get-task-allow", "com.apple.security.get-task-allow"}),
|
|
58
|
+
set(),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ReconcileTest(unittest.TestCase):
|
|
63
|
+
def _response(self, status: int):
|
|
64
|
+
response = mock.MagicMock()
|
|
65
|
+
response.status_code = status
|
|
66
|
+
return response
|
|
67
|
+
|
|
68
|
+
def test_enables_only_the_missing_capability(self) -> None:
|
|
69
|
+
posted: list[str] = []
|
|
70
|
+
|
|
71
|
+
def fake_request(method, path, token, **kw):
|
|
72
|
+
posted.append(kw["json_body"]["data"]["attributes"]["capabilityType"])
|
|
73
|
+
return self._response(201)
|
|
74
|
+
|
|
75
|
+
listed = {"data": [{"attributes": {"capabilityType": "PUSH_NOTIFICATIONS"}}]}
|
|
76
|
+
with mock.patch.object(capabilities, "get_json", return_value=listed), \
|
|
77
|
+
mock.patch.object(capabilities, "request", side_effect=fake_request):
|
|
78
|
+
changed = capabilities.reconcile("tok", "PK", "com.gowalk.form", set(ENTITLEMENTS))
|
|
79
|
+
|
|
80
|
+
self.assertTrue(changed)
|
|
81
|
+
self.assertEqual(posted, ["APP_ATTEST"])
|
|
82
|
+
|
|
83
|
+
def test_no_op_when_the_app_id_already_has_everything(self) -> None:
|
|
84
|
+
listed = {
|
|
85
|
+
"data": [
|
|
86
|
+
{"attributes": {"capabilityType": "PUSH_NOTIFICATIONS"}},
|
|
87
|
+
{"attributes": {"capabilityType": "APP_ATTEST"}},
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
with mock.patch.object(capabilities, "get_json", return_value=listed), \
|
|
91
|
+
mock.patch.object(capabilities, "request") as post:
|
|
92
|
+
changed = capabilities.reconcile("tok", "PK", "com.gowalk.form", set(ENTITLEMENTS))
|
|
93
|
+
|
|
94
|
+
self.assertFalse(changed)
|
|
95
|
+
post.assert_not_called()
|
|
96
|
+
|
|
97
|
+
def test_attempts_every_missing_capability(self) -> None:
|
|
98
|
+
# `any()` over a generator would stop at the first success and leave
|
|
99
|
+
# the rest of the capabilities off the App ID.
|
|
100
|
+
posted: list[str] = []
|
|
101
|
+
|
|
102
|
+
def fake_request(method, path, token, **kw):
|
|
103
|
+
posted.append(kw["json_body"]["data"]["attributes"]["capabilityType"])
|
|
104
|
+
return self._response(201)
|
|
105
|
+
|
|
106
|
+
with mock.patch.object(capabilities, "get_json", return_value={"data": []}), \
|
|
107
|
+
mock.patch.object(capabilities, "request", side_effect=fake_request):
|
|
108
|
+
capabilities.reconcile("tok", "PK", "com.gowalk.form", set(ENTITLEMENTS))
|
|
109
|
+
|
|
110
|
+
self.assertEqual(sorted(posted), ["APP_ATTEST", "PUSH_NOTIFICATIONS"])
|
|
111
|
+
|
|
112
|
+
def test_existing_capability_race_is_not_a_change(self) -> None:
|
|
113
|
+
with mock.patch.object(capabilities, "get_json", return_value={"data": []}), \
|
|
114
|
+
mock.patch.object(capabilities, "request", return_value=self._response(409)):
|
|
115
|
+
changed = capabilities.reconcile(
|
|
116
|
+
"tok", "PK", "com.gowalk.form",
|
|
117
|
+
{"com.apple.developer.devicecheck.appattest-environment"},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
self.assertFalse(changed)
|
|
121
|
+
|
|
122
|
+
def test_api_failure_does_not_raise(self) -> None:
|
|
123
|
+
with mock.patch.object(capabilities, "get_json", return_value={"data": []}), \
|
|
124
|
+
mock.patch.object(capabilities, "request", side_effect=RuntimeError("boom")):
|
|
125
|
+
changed = capabilities.reconcile(
|
|
126
|
+
"tok", "PK", "com.gowalk.form",
|
|
127
|
+
{"com.apple.developer.devicecheck.appattest-environment"},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
self.assertFalse(changed)
|
|
131
|
+
|
|
132
|
+
def test_warns_about_capabilities_it_will_not_guess(self) -> None:
|
|
133
|
+
with mock.patch.object(capabilities, "get_json", return_value={"data": []}), \
|
|
134
|
+
mock.patch("builtins.print") as printed:
|
|
135
|
+
capabilities.reconcile(
|
|
136
|
+
"tok", "PK", "com.gowalk.form",
|
|
137
|
+
{"com.apple.security.application-groups"},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
warnings = [c.args[0] for c in printed.call_args_list if c.args]
|
|
141
|
+
self.assertTrue(any("App Groups" in w for w in warnings), warnings)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.6
|
|
@@ -109,13 +109,19 @@ runs:
|
|
|
109
109
|
# resolve_android.py has already rewritten versionCode/versionName in the
|
|
110
110
|
# module build file, so these are plain Gradle invocations with no extra
|
|
111
111
|
# properties for the project to have to opt into reading.
|
|
112
|
+
# `test` is the aggregate task ("Run unit tests for all variants") and is the
|
|
113
|
+
# only one guaranteed to exist: AGP only creates testXUnitTest tasks for the
|
|
114
|
+
# variants it enables, and AGP 9 disables the release unit test variant by
|
|
115
|
+
# default, so `:app:testReleaseUnitTest` is simply absent in many projects.
|
|
116
|
+
# Running it unqualified also mirrors `flutter test`, which covers the whole
|
|
117
|
+
# package rather than one module.
|
|
112
118
|
- name: Test Android app
|
|
113
119
|
if: ${{ steps.config.outputs.project_kind == 'gradle' && inputs.run-tests == 'true' }}
|
|
114
120
|
shell: bash
|
|
115
121
|
working-directory: ${{ steps.config.outputs.gradle_root || '.' }}
|
|
116
122
|
run: |
|
|
117
123
|
chmod +x ./gradlew
|
|
118
|
-
./gradlew
|
|
124
|
+
./gradlew test --console=plain --stacktrace
|
|
119
125
|
|
|
120
126
|
- name: Build release Android App Bundle with Gradle
|
|
121
127
|
if: ${{ steps.config.outputs.project_kind == 'gradle' }}
|