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 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: generates a throwaway Apple Distribution
180
- cert + a per-target App Store provisioning profile named `CI-<bundle_id>`.
181
- Patches the `.pbxproj` to use Manual signing against those profiles.
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 &lt;X&gt; 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`,
@@ -1 +1 @@
1
- 1.0.5
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: ``{"name": str, "bundle_id": str, "config_ids": [str,...]}``
79
- where ``config_ids`` is the list of XCBuildConfiguration UUIDs whose
80
- ``buildSettings`` dict we need to patch (typically Debug + Release).
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
- {"name": name, "bundle_id": bundle_id, "config_ids": config_ids}
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.5
1
+ 1.0.7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {