gowalk-cicd 1.0.8 → 1.0.10
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 +22 -9
- package/action/.daemux-version +1 -1
- package/action/scripts/capabilities.py +38 -2
- package/action/scripts/profile_manager.py +21 -1
- package/action/scripts/test_capabilities.py +24 -9
- package/action/scripts/test_profile_reuse.py +113 -0
- package/android-action/.daemux-version +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -206,15 +206,28 @@ Provisioning profile "CI-com.example.app" doesn't include the App Attest capabil
|
|
|
206
206
|
|
|
207
207
|
Before creating a profile, the action reads the target's
|
|
208
208
|
`CODE_SIGN_ENTITLEMENTS` plist and enables the matching capabilities on the App
|
|
209
|
-
ID — Push Notifications,
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
209
|
+
ID — Push Notifications, Associated Domains, HealthKit, SiriKit, HomeKit,
|
|
210
|
+
network extensions and the other plain on/off toggles.
|
|
211
|
+
|
|
212
|
+
The cached profile under `creds/profiles/` is checked against those same
|
|
213
|
+
entitlements and regenerated when it does not carry them. The cache manifest
|
|
214
|
+
records only a UUID and an expiry, so without that check a profile issued
|
|
215
|
+
before a capability was turned on — whether by this action or by you in the
|
|
216
|
+
developer portal — would be reused indefinitely, and the archive would keep
|
|
217
|
+
failing on a capability the App ID already has.
|
|
218
|
+
|
|
219
|
+
Two classes of capability are **not** enabled automatically, and each gets a
|
|
220
|
+
`::warning::` naming what was seen:
|
|
221
|
+
|
|
222
|
+
- **Values the action cannot infer** — App Groups, iCloud containers, Apple Pay
|
|
223
|
+
merchant IDs, Wallet pass types, Data Protection. Configure these on the App
|
|
224
|
+
ID yourself.
|
|
225
|
+
- **App Attest** — a real App ID capability in the developer portal, but absent
|
|
226
|
+
from the ASC API's `capabilityType` enum, so *no* API call can turn it on.
|
|
227
|
+
Any app using Firebase App Check's `AppAttestProvider` must have App Attest
|
|
228
|
+
ticked once by hand under **Certificates, Identifiers & Profiles → Identifiers
|
|
229
|
+
→ <your App ID>**. Until then the archive fails with `Provisioning profile
|
|
230
|
+
"CI-<bundle>" doesn't include the App Attest capability`.
|
|
218
231
|
|
|
219
232
|
## AI metadata auto-fill
|
|
220
233
|
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.10
|
|
@@ -41,7 +41,6 @@ _API_FAILURES = (Exception, SystemExit)
|
|
|
41
41
|
# capability at all.
|
|
42
42
|
CAPABILITY_BY_ENTITLEMENT = {
|
|
43
43
|
"aps-environment": "PUSH_NOTIFICATIONS",
|
|
44
|
-
"com.apple.developer.devicecheck.appattest-environment": "APP_ATTEST",
|
|
45
44
|
"com.apple.developer.associated-domains": "ASSOCIATED_DOMAINS",
|
|
46
45
|
"com.apple.developer.applesignin": "APPLE_ID_AUTH",
|
|
47
46
|
"com.apple.developer.networking.wifi-info": "ACCESS_WIFI_INFORMATION",
|
|
@@ -65,7 +64,17 @@ CAPABILITY_BY_ENTITLEMENT = {
|
|
|
65
64
|
# merchant ids, protection level) that an entitlements file cannot supply
|
|
66
65
|
# unambiguously. Enabling these blind would create a half-configured App ID,
|
|
67
66
|
# so we say what is missing and stop.
|
|
67
|
+
APP_ATTEST_ENTITLEMENT = "com.apple.developer.devicecheck.appattest-environment"
|
|
68
|
+
|
|
68
69
|
MANUAL_ENTITLEMENTS = {
|
|
70
|
+
# App Attest is a real App ID capability in the developer portal, but it is
|
|
71
|
+
# absent from the ASC API's capabilityType enum entirely — POSTing it
|
|
72
|
+
# returns 409 ENTITY_ERROR.ATTRIBUTE.TYPE listing the valid values, which
|
|
73
|
+
# do not include it. There is no API to turn this one on.
|
|
74
|
+
APP_ATTEST_ENTITLEMENT: (
|
|
75
|
+
"App Attest, which the App Store Connect API cannot enable — tick it "
|
|
76
|
+
"on the App ID under Certificates, Identifiers & Profiles"
|
|
77
|
+
),
|
|
69
78
|
"com.apple.security.application-groups": "App Groups",
|
|
70
79
|
"com.apple.developer.icloud-container-identifiers": "iCloud",
|
|
71
80
|
"com.apple.developer.icloud-services": "iCloud",
|
|
@@ -76,6 +85,30 @@ MANUAL_ENTITLEMENTS = {
|
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
|
|
88
|
+
# Entitlement keys Apple mirrors into an issued profile's Entitlements dict
|
|
89
|
+
# when the capability is on. Checking a cached profile against these is what
|
|
90
|
+
# makes the cache self-correcting: the manifest records a UUID and an expiry
|
|
91
|
+
# but nothing about which capabilities the profile was issued with, so a
|
|
92
|
+
# profile minted before a capability was added stays "valid" forever.
|
|
93
|
+
#
|
|
94
|
+
# Deliberately excludes the settings-bearing capabilities (App Groups, iCloud,
|
|
95
|
+
# Apple Pay, Wallet, Data Protection): those need App ID configuration this
|
|
96
|
+
# action does not perform, so treating them as missing would regenerate the
|
|
97
|
+
# profile on every single run without ever fixing anything.
|
|
98
|
+
PROFILE_ENTITLEMENT_KEYS = set(CAPABILITY_BY_ENTITLEMENT) | {APP_ATTEST_ENTITLEMENT}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def missing_profile_entitlements(
|
|
102
|
+
profile_entitlements: dict, required_keys: set[str]
|
|
103
|
+
) -> set[str]:
|
|
104
|
+
"""Required entitlement keys an already-issued profile does not carry."""
|
|
105
|
+
return {
|
|
106
|
+
key
|
|
107
|
+
for key in required_keys & PROFILE_ENTITLEMENT_KEYS
|
|
108
|
+
if key not in profile_entitlements
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
79
112
|
def read_entitlement_keys(path: Path) -> set[str]:
|
|
80
113
|
"""Top-level keys of an entitlements plist; empty when unreadable."""
|
|
81
114
|
try:
|
|
@@ -145,9 +178,12 @@ def enable_capability(token: str, bundle_pk: str, capability: str) -> bool:
|
|
|
145
178
|
# identically from the status code alone. Surface the body: without it
|
|
146
179
|
# the run just reports a profile missing a capability we claimed to
|
|
147
180
|
# have enabled, and there is nothing in the log to explain why.
|
|
181
|
+
# Untruncated enough to show Apple's full list of valid capabilityType
|
|
182
|
+
# values, which is the only way to learn that a name is simply not
|
|
183
|
+
# API-manageable (see App Attest in MANUAL_ENTITLEMENTS).
|
|
148
184
|
print(
|
|
149
185
|
f"::warning::App Store Connect returned 409 for {capability}: "
|
|
150
|
-
f"{response.text[:
|
|
186
|
+
f"{response.text[:2000]}"
|
|
151
187
|
)
|
|
152
188
|
return False
|
|
153
189
|
print(f"Enabled {capability} on the App ID")
|
|
@@ -181,18 +181,36 @@ def _try_reuse_cached(
|
|
|
181
181
|
cert_id: str,
|
|
182
182
|
creds_dir: Path,
|
|
183
183
|
manifest: dict,
|
|
184
|
+
entitlement_keys: set[str] | None = None,
|
|
184
185
|
) -> tuple[str, str, dict] | None:
|
|
185
186
|
"""Return ``(uuid, team, entry)`` if a cached profile reinstalls cleanly.
|
|
186
187
|
|
|
187
188
|
A corrupt .mobileprovision (truncated, manually edited, ``security
|
|
188
189
|
cms`` decode failure) must NEVER abort the build — log and return
|
|
189
190
|
``None`` so the caller falls through to the fresh-create path.
|
|
191
|
+
|
|
192
|
+
The profile is also checked against the app's entitlements. The manifest
|
|
193
|
+
records a UUID and an expiry but nothing about the capabilities the
|
|
194
|
+
profile carries, so one issued before a capability was enabled — by this
|
|
195
|
+
action or by a human in the developer portal — would otherwise be reused
|
|
196
|
+
forever, failing the archive on a capability the App ID now has.
|
|
190
197
|
"""
|
|
191
198
|
cached = creds_store.find_reusable_profile(manifest, bid, cert_id, creds_dir)
|
|
192
199
|
if cached is None:
|
|
193
200
|
return None
|
|
194
201
|
try:
|
|
195
202
|
der = creds_store.profile_path(creds_dir, cached.uuid).read_bytes()
|
|
203
|
+
if entitlement_keys:
|
|
204
|
+
plist = decode_profile_plist(der, _PROFILE_DIRS[0])
|
|
205
|
+
missing = capabilities.missing_profile_entitlements(
|
|
206
|
+
plist.get("Entitlements") or {}, entitlement_keys
|
|
207
|
+
)
|
|
208
|
+
if missing:
|
|
209
|
+
print(
|
|
210
|
+
f"cached profile {cached.uuid} predates "
|
|
211
|
+
f"{', '.join(sorted(missing))}; regenerating"
|
|
212
|
+
)
|
|
213
|
+
return None
|
|
196
214
|
uuid, team_id, _ = install_profile(der)
|
|
197
215
|
except (OSError, subprocess.CalledProcessError, ValueError, KeyError) as exc:
|
|
198
216
|
print(
|
|
@@ -234,7 +252,9 @@ def _provision_bundle(
|
|
|
234
252
|
if capabilities.reconcile(token, bundle_pk, bid, entitlement_keys):
|
|
235
253
|
can_reuse = False
|
|
236
254
|
if can_reuse:
|
|
237
|
-
reused = _try_reuse_cached(
|
|
255
|
+
reused = _try_reuse_cached(
|
|
256
|
+
bid, name, cert_id, creds_dir, manifest, entitlement_keys
|
|
257
|
+
)
|
|
238
258
|
if reused is not None:
|
|
239
259
|
uuid, team_id, entry = reused
|
|
240
260
|
return name, uuid, team_id, entry
|
|
@@ -19,6 +19,7 @@ import capabilities
|
|
|
19
19
|
|
|
20
20
|
ENTITLEMENTS = {
|
|
21
21
|
"aps-environment": "production",
|
|
22
|
+
"com.apple.developer.associated-domains": ["applinks:gowalk.example"],
|
|
22
23
|
"com.apple.developer.devicecheck.appattest-environment": "production",
|
|
23
24
|
"keychain-access-groups": ["$(AppIdentifierPrefix)com.gowalk.form"],
|
|
24
25
|
}
|
|
@@ -46,12 +47,26 @@ class ReadEntitlementsTest(unittest.TestCase):
|
|
|
46
47
|
|
|
47
48
|
class RequiredCapabilitiesTest(unittest.TestCase):
|
|
48
49
|
def test_maps_only_known_toggles(self) -> None:
|
|
49
|
-
# keychain-access-groups needs no App ID capability at all
|
|
50
|
+
# keychain-access-groups needs no App ID capability at all, and App
|
|
51
|
+
# Attest is not in the ASC API's capabilityType enum, so neither can
|
|
52
|
+
# produce a POST.
|
|
50
53
|
self.assertEqual(
|
|
51
54
|
capabilities.required_capabilities(set(ENTITLEMENTS)),
|
|
52
|
-
{"PUSH_NOTIFICATIONS", "
|
|
55
|
+
{"PUSH_NOTIFICATIONS", "ASSOCIATED_DOMAINS"},
|
|
53
56
|
)
|
|
54
57
|
|
|
58
|
+
def test_app_attest_is_reported_not_attempted(self) -> None:
|
|
59
|
+
key = "com.apple.developer.devicecheck.appattest-environment"
|
|
60
|
+
|
|
61
|
+
self.assertNotIn(key, capabilities.CAPABILITY_BY_ENTITLEMENT)
|
|
62
|
+
self.assertIn(key, capabilities.MANUAL_ENTITLEMENTS)
|
|
63
|
+
|
|
64
|
+
with mock.patch("builtins.print") as printed:
|
|
65
|
+
capabilities.warn_about_manual_entitlements({key}, "com.gowalk.form")
|
|
66
|
+
|
|
67
|
+
messages = [c.args[0] for c in printed.call_args_list if c.args]
|
|
68
|
+
self.assertTrue(any("App Attest" in m for m in messages), messages)
|
|
69
|
+
|
|
55
70
|
def test_ignores_entitlements_with_no_capability(self) -> None:
|
|
56
71
|
self.assertEqual(
|
|
57
72
|
capabilities.required_capabilities({"get-task-allow", "com.apple.security.get-task-allow"}),
|
|
@@ -78,13 +93,13 @@ class ReconcileTest(unittest.TestCase):
|
|
|
78
93
|
changed = capabilities.reconcile("tok", "PK", "com.gowalk.form", set(ENTITLEMENTS))
|
|
79
94
|
|
|
80
95
|
self.assertTrue(changed)
|
|
81
|
-
self.assertEqual(posted, ["
|
|
96
|
+
self.assertEqual(posted, ["ASSOCIATED_DOMAINS"])
|
|
82
97
|
|
|
83
98
|
def test_no_op_when_the_app_id_already_has_everything(self) -> None:
|
|
84
99
|
listed = {
|
|
85
100
|
"data": [
|
|
86
101
|
{"attributes": {"capabilityType": "PUSH_NOTIFICATIONS"}},
|
|
87
|
-
{"attributes": {"capabilityType": "
|
|
102
|
+
{"attributes": {"capabilityType": "ASSOCIATED_DOMAINS"}},
|
|
88
103
|
]
|
|
89
104
|
}
|
|
90
105
|
with mock.patch.object(capabilities, "get_json", return_value=listed), \
|
|
@@ -107,7 +122,7 @@ class ReconcileTest(unittest.TestCase):
|
|
|
107
122
|
mock.patch.object(capabilities, "request", side_effect=fake_request):
|
|
108
123
|
capabilities.reconcile("tok", "PK", "com.gowalk.form", set(ENTITLEMENTS))
|
|
109
124
|
|
|
110
|
-
self.assertEqual(sorted(posted), ["
|
|
125
|
+
self.assertEqual(sorted(posted), ["ASSOCIATED_DOMAINS", "PUSH_NOTIFICATIONS"])
|
|
111
126
|
|
|
112
127
|
def test_regenerates_even_when_the_capability_could_not_be_enabled(self) -> None:
|
|
113
128
|
# A 409 (or any refusal) still means the cached profile was issued
|
|
@@ -119,7 +134,7 @@ class ReconcileTest(unittest.TestCase):
|
|
|
119
134
|
mock.patch.object(capabilities, "request", return_value=response):
|
|
120
135
|
changed = capabilities.reconcile(
|
|
121
136
|
"tok", "PK", "com.gowalk.form",
|
|
122
|
-
{"com.apple.developer.
|
|
137
|
+
{"com.apple.developer.associated-domains"},
|
|
123
138
|
)
|
|
124
139
|
|
|
125
140
|
self.assertTrue(changed)
|
|
@@ -132,7 +147,7 @@ class ReconcileTest(unittest.TestCase):
|
|
|
132
147
|
mock.patch("builtins.print") as printed:
|
|
133
148
|
capabilities.reconcile(
|
|
134
149
|
"tok", "PK", "com.gowalk.form",
|
|
135
|
-
{"com.apple.developer.
|
|
150
|
+
{"com.apple.developer.associated-domains"},
|
|
136
151
|
)
|
|
137
152
|
|
|
138
153
|
messages = [c.args[0] for c in printed.call_args_list if c.args]
|
|
@@ -145,7 +160,7 @@ class ReconcileTest(unittest.TestCase):
|
|
|
145
160
|
mock.patch.object(capabilities, "request", side_effect=RuntimeError("boom")):
|
|
146
161
|
changed = capabilities.reconcile(
|
|
147
162
|
"tok", "PK", "com.gowalk.form",
|
|
148
|
-
{"com.apple.developer.
|
|
163
|
+
{"com.apple.developer.associated-domains"},
|
|
149
164
|
)
|
|
150
165
|
|
|
151
166
|
self.assertTrue(changed)
|
|
@@ -159,7 +174,7 @@ class ReconcileTest(unittest.TestCase):
|
|
|
159
174
|
mock.patch.object(capabilities, "request", side_effect=SystemExit("400")):
|
|
160
175
|
changed = capabilities.reconcile(
|
|
161
176
|
"tok", "PK", "com.gowalk.form",
|
|
162
|
-
{"com.apple.developer.
|
|
177
|
+
{"com.apple.developer.associated-domains"},
|
|
163
178
|
)
|
|
164
179
|
|
|
165
180
|
self.assertTrue(changed)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cached-profile reuse must account for the app's entitlements.
|
|
3
|
+
|
|
4
|
+
The manifest records a profile's UUID and expiry but nothing about the
|
|
5
|
+
capabilities Apple baked into it. Without checking, a profile issued before a
|
|
6
|
+
capability was enabled — by this action or by hand in the developer portal —
|
|
7
|
+
is reused forever and the archive keeps failing on a capability the App ID
|
|
8
|
+
already has.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import tempfile
|
|
14
|
+
import unittest
|
|
15
|
+
from datetime import datetime, timedelta, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from unittest import mock
|
|
18
|
+
|
|
19
|
+
import capabilities
|
|
20
|
+
import profile_manager
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
APP_ATTEST = capabilities.APP_ATTEST_ENTITLEMENT
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CachedProfileEntitlementsTest(unittest.TestCase):
|
|
27
|
+
def setUp(self) -> None:
|
|
28
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
29
|
+
self.creds = Path(self.temp.name)
|
|
30
|
+
self.uuid = "c981d698-f941-44ff-8ff2-5823b7851ac6"
|
|
31
|
+
cached = mock.MagicMock()
|
|
32
|
+
cached.uuid = self.uuid
|
|
33
|
+
cached.name = "CI-com.gowalk.form"
|
|
34
|
+
cached.filename = f"{self.uuid}.mobileprovision"
|
|
35
|
+
cached.expiration = datetime.now(timezone.utc) + timedelta(days=300)
|
|
36
|
+
self.cached = cached
|
|
37
|
+
|
|
38
|
+
def tearDown(self) -> None:
|
|
39
|
+
self.temp.cleanup()
|
|
40
|
+
|
|
41
|
+
def _reuse(self, profile_entitlements: dict, required: set[str]):
|
|
42
|
+
path = mock.MagicMock()
|
|
43
|
+
path.read_bytes.return_value = b"der"
|
|
44
|
+
with mock.patch.object(
|
|
45
|
+
profile_manager.creds_store, "find_reusable_profile",
|
|
46
|
+
return_value=self.cached), \
|
|
47
|
+
mock.patch.object(
|
|
48
|
+
profile_manager.creds_store, "profile_path", return_value=path), \
|
|
49
|
+
mock.patch.object(
|
|
50
|
+
profile_manager, "decode_profile_plist",
|
|
51
|
+
return_value={"Entitlements": profile_entitlements}), \
|
|
52
|
+
mock.patch.object(
|
|
53
|
+
profile_manager, "install_profile",
|
|
54
|
+
return_value=(self.uuid, "TEAM123", None)):
|
|
55
|
+
return profile_manager._try_reuse_cached(
|
|
56
|
+
"com.gowalk.form", "CI-com.gowalk.form", "CERT",
|
|
57
|
+
self.creds, {}, required,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def test_rejects_a_profile_issued_before_the_capability(self) -> None:
|
|
61
|
+
result = self._reuse({"aps-environment": "production"}, {APP_ATTEST})
|
|
62
|
+
|
|
63
|
+
self.assertIsNone(result)
|
|
64
|
+
|
|
65
|
+
def test_reuses_a_profile_that_carries_the_entitlement(self) -> None:
|
|
66
|
+
result = self._reuse(
|
|
67
|
+
{"aps-environment": "production", APP_ATTEST: "production"},
|
|
68
|
+
{APP_ATTEST, "aps-environment"},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
self.assertIsNotNone(result)
|
|
72
|
+
self.assertEqual(result[0], self.uuid)
|
|
73
|
+
|
|
74
|
+
def test_ignores_entitlements_a_profile_never_mirrors(self) -> None:
|
|
75
|
+
# keychain-access-groups needs no capability, and App Groups needs App
|
|
76
|
+
# ID configuration this action does not perform — treating either as
|
|
77
|
+
# missing would regenerate the profile on every run and fix nothing.
|
|
78
|
+
result = self._reuse(
|
|
79
|
+
{APP_ATTEST: "production"},
|
|
80
|
+
{APP_ATTEST, "keychain-access-groups",
|
|
81
|
+
"com.apple.security.application-groups"},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
self.assertIsNotNone(result)
|
|
85
|
+
|
|
86
|
+
def test_no_entitlements_declared_keeps_the_old_fast_path(self) -> None:
|
|
87
|
+
result = self._reuse({}, set())
|
|
88
|
+
|
|
89
|
+
self.assertIsNotNone(result)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class MissingProfileEntitlementsTest(unittest.TestCase):
|
|
93
|
+
def test_reports_only_checkable_keys(self) -> None:
|
|
94
|
+
self.assertEqual(
|
|
95
|
+
capabilities.missing_profile_entitlements(
|
|
96
|
+
{"aps-environment": "production"},
|
|
97
|
+
{APP_ATTEST, "aps-environment", "keychain-access-groups"},
|
|
98
|
+
),
|
|
99
|
+
{APP_ATTEST},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def test_empty_when_the_profile_covers_everything(self) -> None:
|
|
103
|
+
self.assertEqual(
|
|
104
|
+
capabilities.missing_profile_entitlements(
|
|
105
|
+
{"aps-environment": "x", APP_ATTEST: "production"},
|
|
106
|
+
{APP_ATTEST, "aps-environment"},
|
|
107
|
+
),
|
|
108
|
+
set(),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.10
|