gowalk-cicd 1.0.5 → 1.0.7
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 +34 -3
- package/action/.daemux-version +1 -1
- package/action/scripts/capabilities.py +172 -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_capabilities.py +165 -0
- package/android-action/.daemux-version +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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.7
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
# asc_common.request signals a non-retryable failure with SystemExit, which is
|
|
32
|
+
# a BaseException — `except Exception` sails straight past it. Spelling both
|
|
33
|
+
# out is what makes the "best-effort" promise below actually hold; without it
|
|
34
|
+
# a capability call that Apple rejects kills the whole signing step.
|
|
35
|
+
_API_FAILURES = (Exception, SystemExit)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Entitlement key -> App Store Connect capabilityType, for capabilities that
|
|
39
|
+
# are a bare toggle. An entitlement absent from this map is not an error: most
|
|
40
|
+
# entitlements (keychain-access-groups, get-task-allow, ...) need no App ID
|
|
41
|
+
# capability at all.
|
|
42
|
+
CAPABILITY_BY_ENTITLEMENT = {
|
|
43
|
+
"aps-environment": "PUSH_NOTIFICATIONS",
|
|
44
|
+
"com.apple.developer.devicecheck.appattest-environment": "APP_ATTEST",
|
|
45
|
+
"com.apple.developer.associated-domains": "ASSOCIATED_DOMAINS",
|
|
46
|
+
"com.apple.developer.applesignin": "APPLE_ID_AUTH",
|
|
47
|
+
"com.apple.developer.networking.wifi-info": "ACCESS_WIFI_INFORMATION",
|
|
48
|
+
"com.apple.developer.networking.networkextension": "NETWORK_EXTENSIONS",
|
|
49
|
+
"com.apple.developer.networking.vpn.api": "PERSONAL_VPN",
|
|
50
|
+
"com.apple.developer.networking.multipath": "MULTIPATH",
|
|
51
|
+
"com.apple.developer.networking.HotspotConfiguration": "HOT_SPOT",
|
|
52
|
+
"com.apple.developer.nfc.readersession.formats": "NFC_TAG_READING",
|
|
53
|
+
"com.apple.developer.homekit": "HOMEKIT",
|
|
54
|
+
"com.apple.developer.healthkit": "HEALTHKIT",
|
|
55
|
+
"com.apple.developer.siri": "SIRIKIT",
|
|
56
|
+
"com.apple.developer.ClassKit-environment": "CLASSKIT",
|
|
57
|
+
"com.apple.developer.authentication-services.autofill-credential-provider":
|
|
58
|
+
"AUTOFILL_CREDENTIAL_PROVIDER",
|
|
59
|
+
"com.apple.external-accessory.wireless-configuration":
|
|
60
|
+
"WIRELESS_ACCESSORY_CONFIGURATION",
|
|
61
|
+
"inter-app-audio": "INTER_APP_AUDIO",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Capabilities whose App ID configuration carries values (container ids,
|
|
65
|
+
# merchant ids, protection level) that an entitlements file cannot supply
|
|
66
|
+
# unambiguously. Enabling these blind would create a half-configured App ID,
|
|
67
|
+
# so we say what is missing and stop.
|
|
68
|
+
MANUAL_ENTITLEMENTS = {
|
|
69
|
+
"com.apple.security.application-groups": "App Groups",
|
|
70
|
+
"com.apple.developer.icloud-container-identifiers": "iCloud",
|
|
71
|
+
"com.apple.developer.icloud-services": "iCloud",
|
|
72
|
+
"com.apple.developer.ubiquity-kvstore-identifier": "iCloud",
|
|
73
|
+
"com.apple.developer.in-app-payments": "Apple Pay",
|
|
74
|
+
"com.apple.developer.pass-type-identifiers": "Wallet",
|
|
75
|
+
"com.apple.developer.default-data-protection": "Data Protection",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def read_entitlement_keys(path: Path) -> set[str]:
|
|
80
|
+
"""Top-level keys of an entitlements plist; empty when unreadable."""
|
|
81
|
+
try:
|
|
82
|
+
with path.open("rb") as handle:
|
|
83
|
+
data = plistlib.load(handle)
|
|
84
|
+
except (OSError, plistlib.InvalidFileException, ValueError) as exc:
|
|
85
|
+
print(f"::warning::could not read entitlements {path}: {exc!r}")
|
|
86
|
+
return set()
|
|
87
|
+
return set(data) if isinstance(data, dict) else set()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def required_capabilities(entitlement_keys: set[str]) -> set[str]:
|
|
91
|
+
return {
|
|
92
|
+
CAPABILITY_BY_ENTITLEMENT[key]
|
|
93
|
+
for key in entitlement_keys
|
|
94
|
+
if key in CAPABILITY_BY_ENTITLEMENT
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def warn_about_manual_entitlements(entitlement_keys: set[str], bundle_id: str) -> None:
|
|
99
|
+
for key in sorted(entitlement_keys & set(MANUAL_ENTITLEMENTS)):
|
|
100
|
+
print(
|
|
101
|
+
f"::warning::{bundle_id} declares {key}, which needs "
|
|
102
|
+
f"{MANUAL_ENTITLEMENTS[key]} configured on the App ID with values "
|
|
103
|
+
f"this action cannot infer. Enable it in the Apple Developer "
|
|
104
|
+
f"portal if the archive complains."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def enabled_capabilities(token: str, bundle_pk: str) -> set[str]:
|
|
109
|
+
try:
|
|
110
|
+
# No `limit` here: this relationship rejects it outright with
|
|
111
|
+
# PARAMETER_ERROR.ILLEGAL. Apple returns the full capability set.
|
|
112
|
+
data = get_json(f"/bundleIds/{bundle_pk}/bundleIdCapabilities", token)
|
|
113
|
+
except _API_FAILURES as exc:
|
|
114
|
+
print(f"::warning::could not list capabilities for {bundle_pk}: {exc!r}")
|
|
115
|
+
return set()
|
|
116
|
+
found = set()
|
|
117
|
+
for item in data.get("data", []):
|
|
118
|
+
capability = (item.get("attributes") or {}).get("capabilityType")
|
|
119
|
+
if capability:
|
|
120
|
+
found.add(capability)
|
|
121
|
+
return found
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def enable_capability(token: str, bundle_pk: str, capability: str) -> bool:
|
|
125
|
+
body = {
|
|
126
|
+
"data": {
|
|
127
|
+
"type": "bundleIdCapabilities",
|
|
128
|
+
"attributes": {"capabilityType": capability},
|
|
129
|
+
"relationships": {
|
|
130
|
+
"bundleId": {"data": {"type": "bundleIds", "id": bundle_pk}}
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
try:
|
|
135
|
+
# 409 means another writer got there first — the desired end state.
|
|
136
|
+
response = request(
|
|
137
|
+
"POST", "/bundleIdCapabilities", token,
|
|
138
|
+
json_body=body, allow_status={200, 201, 409},
|
|
139
|
+
)
|
|
140
|
+
except _API_FAILURES as exc:
|
|
141
|
+
print(f"::warning::could not enable {capability}: {exc!r}")
|
|
142
|
+
return False
|
|
143
|
+
if response.status_code == 409:
|
|
144
|
+
return False
|
|
145
|
+
print(f"Enabled {capability} on the App ID")
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def reconcile(token: str, bundle_pk: str, bundle_id: str, entitlement_keys: set[str]) -> bool:
|
|
150
|
+
"""Enable every simple capability the entitlements imply.
|
|
151
|
+
|
|
152
|
+
Returns True when something was actually turned on — the caller must then
|
|
153
|
+
regenerate the profile, because a cached one predates the change and still
|
|
154
|
+
lacks the capability.
|
|
155
|
+
"""
|
|
156
|
+
warn_about_manual_entitlements(entitlement_keys, bundle_id)
|
|
157
|
+
wanted = required_capabilities(entitlement_keys)
|
|
158
|
+
if not wanted:
|
|
159
|
+
return False
|
|
160
|
+
missing = wanted - enabled_capabilities(token, bundle_pk)
|
|
161
|
+
if not missing:
|
|
162
|
+
return False
|
|
163
|
+
print(
|
|
164
|
+
f"{bundle_id}: entitlements require {', '.join(sorted(missing))} "
|
|
165
|
+
f"but the App ID does not have them; enabling"
|
|
166
|
+
)
|
|
167
|
+
# List, not a generator: `any` short-circuits, and every missing capability
|
|
168
|
+
# must be attempted, not just up to the first success.
|
|
169
|
+
return any([
|
|
170
|
+
enable_capability(token, bundle_pk, capability)
|
|
171
|
+
for capability in sorted(missing)
|
|
172
|
+
])
|
|
@@ -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
|
|
@@ -0,0 +1,165 @@
|
|
|
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_systemexit_from_asc_does_not_kill_signing(self) -> None:
|
|
133
|
+
# asc_common.request raises SystemExit, not Exception, so a bare
|
|
134
|
+
# `except Exception` would let an Apple 4xx abort the whole run.
|
|
135
|
+
with mock.patch.object(capabilities, "get_json", side_effect=SystemExit("400")), \
|
|
136
|
+
mock.patch.object(capabilities, "request", side_effect=SystemExit("400")):
|
|
137
|
+
changed = capabilities.reconcile(
|
|
138
|
+
"tok", "PK", "com.gowalk.form",
|
|
139
|
+
{"com.apple.developer.devicecheck.appattest-environment"},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
self.assertFalse(changed)
|
|
143
|
+
|
|
144
|
+
def test_capability_listing_sends_no_limit_parameter(self) -> None:
|
|
145
|
+
# /bundleIds/{id}/bundleIdCapabilities rejects `limit` with
|
|
146
|
+
# PARAMETER_ERROR.ILLEGAL rather than ignoring it.
|
|
147
|
+
with mock.patch.object(capabilities, "get_json", return_value={"data": []}) as get:
|
|
148
|
+
capabilities.enabled_capabilities("tok", "PK")
|
|
149
|
+
|
|
150
|
+
get.assert_called_once_with("/bundleIds/PK/bundleIdCapabilities", "tok")
|
|
151
|
+
|
|
152
|
+
def test_warns_about_capabilities_it_will_not_guess(self) -> None:
|
|
153
|
+
with mock.patch.object(capabilities, "get_json", return_value={"data": []}), \
|
|
154
|
+
mock.patch("builtins.print") as printed:
|
|
155
|
+
capabilities.reconcile(
|
|
156
|
+
"tok", "PK", "com.gowalk.form",
|
|
157
|
+
{"com.apple.security.application-groups"},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
warnings = [c.args[0] for c in printed.call_args_list if c.args]
|
|
161
|
+
self.assertTrue(any("App Groups" in w for w in warnings), warnings)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.7
|