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 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, 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.
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
 
@@ -1 +1 @@
1
- 1.0.8
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[:600]}"
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(bid, name, cert_id, creds_dir, manifest)
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", "APP_ATTEST"},
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, ["APP_ATTEST"])
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": "APP_ATTEST"}},
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), ["APP_ATTEST", "PUSH_NOTIFICATIONS"])
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.devicecheck.appattest-environment"},
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.devicecheck.appattest-environment"},
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.devicecheck.appattest-environment"},
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.devicecheck.appattest-environment"},
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.8
1
+ 1.0.10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {