gowalk-cicd 1.0.9 → 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
CHANGED
|
@@ -207,9 +207,14 @@ Provisioning profile "CI-com.example.app" doesn't include the App Attest capabil
|
|
|
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
209
|
ID — Push Notifications, Associated Domains, HealthKit, SiriKit, HomeKit,
|
|
210
|
-
network extensions and the other plain on/off toggles.
|
|
211
|
-
|
|
212
|
-
|
|
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.
|
|
213
218
|
|
|
214
219
|
Two classes of capability are **not** enabled automatically, and each gets a
|
|
215
220
|
`::warning::` naming what was seen:
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.10
|
|
@@ -64,12 +64,14 @@ CAPABILITY_BY_ENTITLEMENT = {
|
|
|
64
64
|
# merchant ids, protection level) that an entitlements file cannot supply
|
|
65
65
|
# unambiguously. Enabling these blind would create a half-configured App ID,
|
|
66
66
|
# so we say what is missing and stop.
|
|
67
|
+
APP_ATTEST_ENTITLEMENT = "com.apple.developer.devicecheck.appattest-environment"
|
|
68
|
+
|
|
67
69
|
MANUAL_ENTITLEMENTS = {
|
|
68
70
|
# App Attest is a real App ID capability in the developer portal, but it is
|
|
69
71
|
# absent from the ASC API's capabilityType enum entirely — POSTing it
|
|
70
72
|
# returns 409 ENTITY_ERROR.ATTRIBUTE.TYPE listing the valid values, which
|
|
71
73
|
# do not include it. There is no API to turn this one on.
|
|
72
|
-
|
|
74
|
+
APP_ATTEST_ENTITLEMENT: (
|
|
73
75
|
"App Attest, which the App Store Connect API cannot enable — tick it "
|
|
74
76
|
"on the App ID under Certificates, Identifiers & Profiles"
|
|
75
77
|
),
|
|
@@ -83,6 +85,30 @@ MANUAL_ENTITLEMENTS = {
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
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
|
+
|
|
86
112
|
def read_entitlement_keys(path: Path) -> set[str]:
|
|
87
113
|
"""Top-level keys of an entitlements plist; empty when unreadable."""
|
|
88
114
|
try:
|
|
@@ -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
|
|
@@ -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
|