gowalk-cicd 1.0.87 → 1.0.88

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/CLAUDE.md CHANGED
@@ -121,6 +121,11 @@ node /path/to/gowalk-cicd/bin/cli.mjs
121
121
  Tracked `creds/` files remain a backward-compatible read fallback. Refreshed signing
122
122
  files stay ephemeral and are never staged; `persist-signing-cache` is ignored.
123
123
  Version source staged before signing is preserved separately with its original index bytes.
124
+ - App Group identifiers come from each archive target's entitlements. `app_groups.py`
125
+ checks exact values in cached and fresh profiles; a fresh mismatch fails before profile
126
+ installation with `apple_app_groups_required`. Register/assign groups through the scoped
127
+ Apple account console before retrying CI. Never invent an ASC group operation, infer an
128
+ identifier from the bundle name, or accept a wildcard/unrelated group as a matching grant.
124
129
  - Never revoke a distribution certificate automatically, including legacy caches.
125
130
  `certificate-cap-policy` accepts only `fail`; a full cap requires registry reconciliation.
126
131
  A certificate POST's HTTP 409 alone does not prove a full cap. `certificate_failure.py`
package/README.md CHANGED
@@ -737,10 +737,19 @@ before a capability was turned on — whether by this action or by you in the
737
737
  developer portal — would be reused indefinitely, and the archive would keep
738
738
  failing on a capability the App ID already has.
739
739
 
740
- Two classes of capability are **not** enabled automatically, and each gets a
740
+ App Groups require registration and assignment to each participating App ID through
741
+ the account's Apple Developer console. The action reads the literal identifiers from
742
+ each archive target's entitlements and verifies their exact membership in both cached
743
+ and fresh profiles. A stale cache regenerates; a fresh profile missing a required group
744
+ fails before installation with `apple_app_groups_required`, schema
745
+ `gowalk-cicd/apple-app-groups-required.v1`, containing the bundle ID and missing groups.
746
+ Complete those assignments through the scoped console and rerun CI. This check creates
747
+ no group or certificate and does not replace the account's capability provisioning.
748
+
749
+ Two other classes of capability are **not** enabled automatically, and each gets a
741
750
  `::warning::` naming what was seen:
742
751
 
743
- - **Values the action cannot infer** — App Groups, iCloud containers, Apple Pay
752
+ - **Values the action cannot infer** — iCloud containers, Apple Pay
744
753
  merchant IDs, Wallet pass types, Data Protection. Configure these on the App
745
754
  ID yourself.
746
755
  - **App Attest** — a real App ID capability in the developer portal, but absent
@@ -1 +1 @@
1
- 1.0.87
1
+ 1.0.88
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env python3
2
+ """Verify declared App Group identifiers against Apple's issued profiles.
3
+
4
+ Registration and assignment belong to the account's Developer console. This
5
+ module never guesses groups, posts an unsupported ASC operation or changes a
6
+ certificate. It rejects profiles that would break an app/extension shared container.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import plistlib
12
+ import re
13
+ from pathlib import Path
14
+
15
+ from profile_io import decode_profile_plist
16
+
17
+ ENTITLEMENT = "com.apple.security.application-groups"
18
+ _IDENTIFIER = re.compile(r"group\.[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*")
19
+
20
+
21
+ def declared_by_bundle(project: str, targets: list[dict]) -> dict[str, set[str]]:
22
+ """Read literal group values from the selected archive target entitlements."""
23
+ result: dict[str, set[str]] = {}
24
+ for target in targets:
25
+ relative = target.get("entitlements")
26
+ if not relative:
27
+ continue
28
+ path = Path(project).parent / relative
29
+ try:
30
+ data = plistlib.loads(path.read_bytes())
31
+ except (OSError, ValueError, plistlib.InvalidFileException):
32
+ raise SystemExit("::error::Cannot verify App Groups: target entitlements are unreadable") from None
33
+ if not isinstance(data, dict):
34
+ raise SystemExit("::error::Cannot verify App Groups: target entitlements must be a dictionary")
35
+ if ENTITLEMENT not in data:
36
+ continue
37
+ values = data[ENTITLEMENT]
38
+ if not isinstance(values, list) or any(
39
+ not isinstance(value, str) or not _IDENTIFIER.fullmatch(value) for value in values
40
+ ):
41
+ raise SystemExit("::error::App Groups must contain literal group identifiers, without build variables")
42
+ result.setdefault(target["bundle_id"], set()).update(values)
43
+ return result
44
+
45
+
46
+ def missing(profile_entitlements: dict, required: set[str]) -> set[str]:
47
+ """An unrelated group, malformed value or wildcard never grants a required group."""
48
+ if not isinstance(profile_entitlements, dict):
49
+ return set(required)
50
+ granted = profile_entitlements.get(ENTITLEMENT)
51
+ if not isinstance(granted, list) or any(not isinstance(value, str) for value in granted):
52
+ return set(required)
53
+ return required - set(granted)
54
+
55
+
56
+ def validate_requirements(keys_by_bundle: dict, groups_by_bundle: dict) -> None:
57
+ """Refuse legacy key-only inputs rather than silently ignoring group values."""
58
+ for bundle, keys in keys_by_bundle.items():
59
+ if ENTITLEMENT in keys and bundle not in groups_by_bundle:
60
+ raise SystemExit("::error::App Group profile verification requires declared identifier values")
61
+
62
+
63
+ def assert_profile(profile_der: bytes, required: set[str], bundle: str, scratch: Path) -> None:
64
+ """A fresh profile must contain every declared group before installation."""
65
+ if not required:
66
+ return
67
+ try:
68
+ profile = decode_profile_plist(profile_der, scratch)
69
+ except Exception:
70
+ raise SystemExit("::error::Cannot verify App Groups in the issued profile") from None
71
+ if not isinstance(profile, dict):
72
+ raise SystemExit("::error::Cannot verify App Groups in the issued profile")
73
+ absent = missing(profile.get("Entitlements") or {}, required)
74
+ if not absent:
75
+ return
76
+ receipt = {
77
+ "schema": "gowalk-cicd/apple-app-groups-required.v1",
78
+ "bundle_id": bundle,
79
+ "missing_group_identifiers": sorted(absent),
80
+ "recovery": "Assign the declared groups to this App ID through the account console, then rerun CI",
81
+ }
82
+ raise SystemExit("::error title=apple_app_groups_required::" + json.dumps(receipt, separators=(",", ":")))
@@ -114,7 +114,8 @@ MANUAL_ENTITLEMENTS = {
114
114
  # but nothing about which capabilities the profile was issued with, so a
115
115
  # profile minted before a capability was added stays "valid" forever.
116
116
  #
117
- # Deliberately excludes the settings-bearing capabilities (App Groups, iCloud,
117
+ # App Groups are checked by exact identifier values in app_groups.py.
118
+ # Deliberately excludes the other settings-bearing capabilities (iCloud,
118
119
  # Apple Pay, Wallet, Data Protection): those need App ID configuration this
119
120
  # action does not perform, so treating them as missing would regenerate the
120
121
  # profile on every single run without ever fixing anything.
@@ -178,6 +179,9 @@ def required_capabilities(entitlement_keys: set[str]) -> set[str]:
178
179
 
179
180
  def warn_about_manual_entitlements(entitlement_keys: set[str], bundle_id: str) -> None:
180
181
  for key in sorted(entitlement_keys & set(MANUAL_ENTITLEMENTS)):
182
+ if key == "com.apple.security.application-groups":
183
+ print(f"App Groups for {bundle_id}: issued profiles must cover every declared group identifier")
184
+ continue
181
185
  print(
182
186
  f"::warning::{bundle_id} declares {key}, which needs "
183
187
  f"{MANUAL_ENTITLEMENTS[key]} configured on the App ID with values "
@@ -39,6 +39,7 @@ import os
39
39
  from datetime import datetime, timedelta, timezone
40
40
  from pathlib import Path
41
41
 
42
+ import app_groups
42
43
  import capabilities
43
44
  import cert_factory
44
45
  import creds_store
@@ -230,12 +231,14 @@ def main() -> None:
230
231
  f"({len(t['config_ids'])} configs)"
231
232
  )
232
233
 
234
+ groups_by_bundle = app_groups.declared_by_bundle(project, targets)
233
235
  cert_id, p12_bytes, p12_pass, cache_hit = _load_or_create_cert(
234
236
  token, creds_dir
235
237
  )
236
238
  mappings, effective_team = provision_all_bundles(
237
239
  token, bundle_ids, cert_id, creds_dir=creds_dir, cache_hit=cache_hit,
238
240
  entitlements_by_bundle=_entitlement_keys_by_bundle(project, targets),
241
+ app_groups_by_bundle=groups_by_bundle,
239
242
  )
240
243
  print("Profile map:")
241
244
  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 app_groups
46
47
  import capabilities
47
48
  import creds_store
48
49
  from asc_common import get_json, request
@@ -97,10 +98,6 @@ def _register_bundle_id(token: str, identifier: str) -> str:
97
98
  # Profile lifecycle #
98
99
  # --------------------------------------------------------------------------- #
99
100
 
100
- def profile_name_for(bundle_id: str) -> str:
101
- return f"{PROFILE_PREFIX}{bundle_id}"
102
-
103
-
104
101
  def delete_profile_by_name(token: str, name: str) -> None:
105
102
  """Delete every existing profile with the given name (paginated scan)."""
106
103
  deleted_any = False
@@ -182,29 +179,26 @@ def _try_reuse_cached(
182
179
  creds_dir: Path,
183
180
  manifest: dict,
184
181
  entitlement_keys: set[str] | None = None,
182
+ required_groups: set[str] | None = None,
185
183
  ) -> tuple[str, str, dict] | None:
186
184
  """Return ``(uuid, team, entry)`` if a cached profile reinstalls cleanly.
187
185
 
188
- A corrupt .mobileprovision (truncated, manually edited, ``security
189
- cms`` decode failure) must NEVER abort the build — log and return
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.
186
+ Decode failure or missing entitlement keys/group values falls through to
187
+ fresh creation. The cache manifest's UUID and expiry alone cannot prove
188
+ that a profile reflects the App ID's current capability assignments.
197
189
  """
198
190
  cached = creds_store.find_reusable_profile(manifest, bid, cert_id, creds_dir)
199
191
  if cached is None:
200
192
  return None
201
193
  try:
202
194
  der = creds_store.profile_path(creds_dir, cached.uuid).read_bytes()
203
- if entitlement_keys:
195
+ if entitlement_keys or required_groups:
204
196
  plist = decode_profile_plist(der, _PROFILE_DIRS[0])
205
197
  missing = capabilities.missing_profile_entitlements(
206
- plist.get("Entitlements") or {}, entitlement_keys
198
+ plist.get("Entitlements") or {}, entitlement_keys or set()
207
199
  )
200
+ if app_groups.missing(plist.get("Entitlements") or {}, required_groups or set()):
201
+ missing.add(app_groups.ENTITLEMENT)
208
202
  if missing:
209
203
  print(
210
204
  f"cached profile {cached.uuid} predates "
@@ -218,8 +212,7 @@ def _try_reuse_cached(
218
212
  f"({exc!r}); regenerating"
219
213
  )
220
214
  return None
221
- # cached.expiration is already UTC-aware (load_profile_manifest routes
222
- # it through _parse_iso); no astimezone needed.
215
+ # load_profile_manifest already normalizes cached.expiration to UTC.
223
216
  entry = {
224
217
  "bundle_id": bid,
225
218
  "name": cached.name or name,
@@ -239,9 +232,10 @@ def _provision_bundle(
239
232
  manifest: dict,
240
233
  can_reuse: bool,
241
234
  entitlement_keys: set[str] | None = None,
235
+ required_groups: set[str] | None = None,
242
236
  ) -> tuple[str, str, str, dict]:
243
237
  """Provision one bundle; return ``(name, uuid, team, manifest_entry)``."""
244
- name = profile_name_for(bid)
238
+ name = f"{PROFILE_PREFIX}{bid}"
245
239
  # Reconcile capabilities BEFORE deciding to reuse. A profile carries only
246
240
  # the capabilities its App ID had when it was issued, so a cached profile
247
241
  # that predates an entitlement being added stays broken forever otherwise
@@ -253,7 +247,7 @@ def _provision_bundle(
253
247
  can_reuse = False
254
248
  if can_reuse:
255
249
  reused = _try_reuse_cached(
256
- bid, name, cert_id, creds_dir, manifest, entitlement_keys
250
+ bid, name, cert_id, creds_dir, manifest, entitlement_keys, required_groups
257
251
  )
258
252
  if reused is not None:
259
253
  uuid, team_id, entry = reused
@@ -262,6 +256,7 @@ def _provision_bundle(
262
256
  bundle_pk = bundle_pk or ensure_bundle_id(token, bid)
263
257
  delete_profile_by_name(token, name)
264
258
  profile_der = create_profile(token, name, bundle_pk, cert_id)
259
+ app_groups.assert_profile(profile_der, required_groups or set(), bid, _PROFILE_DIRS[0])
265
260
  if entitlement_keys:
266
261
  _assert_carplay_entitlements(profile_der, entitlement_keys, bid, name)
267
262
  uuid, team_id, expiration = install_profile(profile_der)
@@ -332,20 +327,21 @@ def provision_all_bundles(
332
327
  creds_dir: Path,
333
328
  cache_hit: bool,
334
329
  entitlements_by_bundle: dict[str, set[str]] | None = None,
330
+ app_groups_by_bundle: dict[str, set[str]] | None = None,
335
331
  ) -> tuple[list[tuple[str, str, str]], str]:
336
332
  """Create + install a CI profile for each bundle id, with caching.
337
333
 
338
- Returns ``(mappings, team_id)`` where mappings is a list of
339
- ``(bundle_id, profile_name, uuid)`` tuples and ``team_id`` is the
340
- team Apple assigned to the profiles (shared — the ASC API key binds
341
- every profile it issues to a single team).
342
-
334
+ Returns ``(mappings, team_id)`` with ``(bundle_id, profile_name, uuid)``
335
+ tuples and the team Apple assigned to the profiles.
343
336
  ``cache_hit`` says whether the cert came from cache. ``creds_dir``
344
337
  is the workspace ``creds/`` root. When the manifest's cert_id
345
338
  differs from ``cert_id`` we force a full regen — Apple invalidates
346
339
  every profile bound to the prior cert the moment that cert is
347
340
  revoked, so reusing on-disk profiles would archive unsigned bits.
348
341
  """
342
+ entitlements_by_bundle = entitlements_by_bundle or {}
343
+ app_groups_by_bundle = app_groups_by_bundle or {}
344
+ app_groups.validate_requirements(entitlements_by_bundle, app_groups_by_bundle)
349
345
  manifest = creds_store.load_profile_manifest(creds_dir)
350
346
  can_reuse = cache_hit and manifest.get("cert_id") == cert_id
351
347
  if cache_hit and not can_reuse:
@@ -357,11 +353,11 @@ def provision_all_bundles(
357
353
  results: list[tuple[str, str, str]] = []
358
354
  new_entries: list[dict] = []
359
355
  effective_team = ""
360
- entitlements_by_bundle = entitlements_by_bundle or {}
361
356
  for bid in bundle_ids:
362
357
  name, uuid, team_id, entry = _provision_bundle(
363
358
  token, bid, cert_id, creds_dir, manifest, can_reuse,
364
359
  entitlements_by_bundle.get(bid),
360
+ app_groups_by_bundle.get(bid),
365
361
  )
366
362
  if team_id:
367
363
  effective_team = team_id
@@ -0,0 +1,83 @@
1
+ """Archive target declarations retain actual App Group values without guessing."""
2
+ import plistlib
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest import mock
7
+
8
+ import app_groups
9
+ import prepare_signing
10
+
11
+
12
+ class GroupRequirementsTest(unittest.TestCase):
13
+ def test_parent_and_extension_receive_their_declared_groups(self):
14
+ with tempfile.TemporaryDirectory() as directory:
15
+ root = Path(directory)
16
+ targets = []
17
+ for name, groups in [("App", ["group.example.shared", "group.example.app"]),
18
+ ("Broadcast", ["group.example.shared"])]:
19
+ filename = name + ".entitlements"
20
+ (root / filename).write_bytes(plistlib.dumps({app_groups.ENTITLEMENT: groups}))
21
+ targets.append({"name": name, "bundle_id": name, "entitlements": filename})
22
+ self.assertEqual(app_groups.declared_by_bundle(str(root / "App.xcodeproj"), targets), {
23
+ "App": {"group.example.shared", "group.example.app"}, "Broadcast": {"group.example.shared"},
24
+ })
25
+
26
+ def test_unresolved_or_malformed_values_fail_before_signing(self):
27
+ values = ["group.example", ["$(GROUP_ID)"], ["group.example.*"], [1], ["group..example"]]
28
+ with tempfile.TemporaryDirectory() as directory:
29
+ root = Path(directory)
30
+ targets = [{"bundle_id": "App", "entitlements": "App.entitlements"}]
31
+ for value in values:
32
+ with self.subTest(value=value):
33
+ (root / "App.entitlements").write_bytes(plistlib.dumps({app_groups.ENTITLEMENT: value}))
34
+ with self.assertRaisesRegex(SystemExit, "literal group identifiers"):
35
+ app_groups.declared_by_bundle(str(root / "App.xcodeproj"), targets)
36
+
37
+ def test_unreadable_declaration_is_not_an_empty_requirement(self):
38
+ with tempfile.TemporaryDirectory() as directory:
39
+ with self.assertRaisesRegex(SystemExit, "unreadable"):
40
+ app_groups.declared_by_bundle(str(Path(directory) / "App.xcodeproj"), [
41
+ {"bundle_id": "App", "entitlements": "missing.entitlements"},
42
+ ])
43
+
44
+ def test_unrelated_targets_and_empty_groups_need_no_assignment(self):
45
+ self.assertEqual(app_groups.declared_by_bundle("App.xcodeproj", [{"bundle_id": "App"}]), {})
46
+ app_groups.validate_requirements({"App": {app_groups.ENTITLEMENT}}, {"App": set()})
47
+
48
+ def test_key_only_legacy_requirement_is_refused(self):
49
+ with self.assertRaisesRegex(SystemExit, "declared identifier values"):
50
+ app_groups.validate_requirements({"App": {app_groups.ENTITLEMENT}}, {})
51
+
52
+ def test_malformed_or_wildcard_profile_does_not_grant_group(self):
53
+ required = {"group.example.shared"}
54
+ for value in [None, "group.example.shared", ["group.*"], [1], ["group.example.other"]]:
55
+ with self.subTest(value=value):
56
+ self.assertEqual(app_groups.missing({app_groups.ENTITLEMENT: value}, required), required)
57
+
58
+ def test_prepare_signing_forwards_values_to_profile_provisioning(self):
59
+ with tempfile.TemporaryDirectory() as directory:
60
+ root = Path(directory)
61
+ (root / "App.entitlements").write_bytes(plistlib.dumps({
62
+ app_groups.ENTITLEMENT: ["group.example.shared"],
63
+ }))
64
+ targets = [{"name": "App", "bundle_id": "App", "config_ids": ["R"],
65
+ "entitlements": "App.entitlements"}]
66
+ env = {"RUNNER_TEMP": directory, "PROJECT": str(root / "App.xcodeproj"),
67
+ "ASC_KEY_ID": "K", "ASC_ISSUER_ID": "I", "ASC_KEY_PATH": "unused"}
68
+ with mock.patch.dict(prepare_signing.os.environ, env, clear=True), \
69
+ mock.patch.object(prepare_signing, "make_jwt", return_value="test"), \
70
+ mock.patch.object(prepare_signing, "discover_signable_targets", return_value=targets), \
71
+ mock.patch.object(prepare_signing, "_load_or_create_cert", return_value=("C", b"p12", "", True)), \
72
+ mock.patch.object(
73
+ prepare_signing, "provision_all_bundles", return_value=([], "TEAM")) as provision, \
74
+ mock.patch.object(prepare_signing, "patch_project_signing"), \
75
+ mock.patch.object(prepare_signing, "setup_keychain"):
76
+ prepare_signing.main()
77
+ self.assertEqual(provision.call_args.kwargs["app_groups_by_bundle"], {
78
+ "App": {"group.example.shared"},
79
+ })
80
+
81
+
82
+ if __name__ == "__main__":
83
+ unittest.main()
@@ -0,0 +1,103 @@
1
+ """A stale/misconfigured group profile cannot be installed or cached as usable."""
2
+ import json
3
+ import tempfile
4
+ import unittest
5
+ from datetime import datetime, timedelta, timezone
6
+ from pathlib import Path
7
+ from unittest import mock
8
+
9
+ import app_groups
10
+ import profile_manager
11
+
12
+
13
+ class GroupProfileTest(unittest.TestCase):
14
+ def test_cached_profile_requires_exact_group_values(self):
15
+ required = {"group.example.shared"}
16
+ cached = mock.MagicMock(uuid="UUID", name="CI-App", filename="UUID.mobileprovision")
17
+ cached.expiration = datetime.now(timezone.utc) + timedelta(days=100)
18
+ for granted, reusable in [([], False), (["group.example.other"], False),
19
+ (["group.example.shared", "group.example.extra"], True)]:
20
+ with self.subTest(granted=granted), tempfile.TemporaryDirectory() as directory:
21
+ with mock.patch.object(profile_manager.creds_store, "find_reusable_profile", return_value=cached), \
22
+ mock.patch.object(profile_manager.creds_store, "profile_path") as path, \
23
+ mock.patch.object(profile_manager, "decode_profile_plist", return_value={
24
+ "Entitlements": {app_groups.ENTITLEMENT: granted},
25
+ }), \
26
+ mock.patch.object(
27
+ profile_manager, "install_profile", return_value=("UUID", "T", None)) as install:
28
+ path.return_value.read_bytes.return_value = b"profile"
29
+ result = profile_manager._try_reuse_cached(
30
+ "App", "CI-App", "C", Path(directory), {}, {app_groups.ENTITLEMENT}, required,
31
+ )
32
+ self.assertEqual(result is not None, reusable)
33
+ self.assertEqual(install.called, reusable)
34
+
35
+ def test_fresh_profile_missing_group_is_rejected_before_install_or_cache(self):
36
+ with tempfile.TemporaryDirectory() as directory:
37
+ with mock.patch.object(profile_manager, "ensure_bundle_id", return_value="B"), \
38
+ mock.patch.object(profile_manager.capabilities, "reconcile", return_value=False), \
39
+ mock.patch.object(profile_manager, "delete_profile_by_name"), \
40
+ mock.patch.object(profile_manager, "create_profile", return_value=b"profile"), \
41
+ mock.patch.object(app_groups, "decode_profile_plist", return_value={"Entitlements": {}}), \
42
+ mock.patch.object(profile_manager, "install_profile") as install, \
43
+ mock.patch.object(profile_manager.creds_store, "write_cached_profile") as write:
44
+ with self.assertRaises(SystemExit) as caught:
45
+ profile_manager._provision_bundle(
46
+ "test", "App", "C", Path(directory), {}, False,
47
+ {app_groups.ENTITLEMENT}, {"group.example.shared"},
48
+ )
49
+ install.assert_not_called()
50
+ write.assert_not_called()
51
+ receipt = json.loads(str(caught.exception).split("::", 2)[2])
52
+ self.assertEqual(receipt["bundle_id"], "App")
53
+ self.assertEqual(receipt["missing_group_identifiers"], ["group.example.shared"])
54
+
55
+ def test_fresh_matching_profile_is_accepted(self):
56
+ with mock.patch.object(app_groups, "decode_profile_plist", return_value={
57
+ "Entitlements": {app_groups.ENTITLEMENT: ["group.example.shared"]},
58
+ }):
59
+ app_groups.assert_profile(b"profile", {"group.example.shared"}, "App", Path("unused"))
60
+
61
+ def test_each_bundle_is_checked_against_its_own_group_requirements(self):
62
+ expires = datetime.now(timezone.utc) + timedelta(days=100)
63
+ with tempfile.TemporaryDirectory() as directory:
64
+ with mock.patch.object(profile_manager, "ensure_bundle_id", side_effect=["A", "B"]), \
65
+ mock.patch.object(profile_manager.capabilities, "reconcile", return_value=False), \
66
+ mock.patch.object(profile_manager, "delete_profile_by_name"), \
67
+ mock.patch.object(profile_manager, "create_profile", return_value=b"profile"), \
68
+ mock.patch.object(app_groups, "decode_profile_plist", return_value={
69
+ "Entitlements": {app_groups.ENTITLEMENT: ["group.example.parent"]},
70
+ }), \
71
+ mock.patch.object(profile_manager, "_assert_carplay_entitlements"), \
72
+ mock.patch.object(
73
+ profile_manager, "install_profile", return_value=("U", "T", expires)) as install, \
74
+ mock.patch.object(profile_manager.creds_store, "write_cached_profile"), \
75
+ mock.patch.object(profile_manager.creds_store, "write_profile_manifest") as manifest:
76
+ with self.assertRaises(SystemExit) as caught:
77
+ profile_manager.provision_all_bundles(
78
+ "test", ["App", "Broadcast"], "C", creds_dir=Path(directory), cache_hit=False,
79
+ entitlements_by_bundle={bid: {app_groups.ENTITLEMENT} for bid in ["App", "Broadcast"]},
80
+ app_groups_by_bundle={
81
+ "App": {"group.example.parent"}, "Broadcast": {"group.example.broadcast"},
82
+ },
83
+ )
84
+ receipt = json.loads(str(caught.exception).split("::", 2)[2])
85
+ self.assertEqual(receipt["bundle_id"], "Broadcast")
86
+ self.assertEqual(receipt["missing_group_identifiers"], ["group.example.broadcast"])
87
+ self.assertEqual(install.call_count, 1)
88
+ manifest.assert_not_called()
89
+
90
+ def test_profile_decode_failure_is_private_and_fails_closed(self):
91
+ with mock.patch.object(app_groups, "decode_profile_plist", side_effect=ValueError("private raw bytes")):
92
+ with self.assertRaises(SystemExit) as caught:
93
+ app_groups.assert_profile(b"profile", {"group.example.shared"}, "App", Path("unused"))
94
+ self.assertNotIn("private raw bytes", str(caught.exception))
95
+
96
+ def test_no_group_declaration_keeps_existing_profile_path(self):
97
+ with mock.patch.object(app_groups, "decode_profile_plist") as decode:
98
+ app_groups.assert_profile(b"profile", set(), "App", Path("unused"))
99
+ decode.assert_not_called()
100
+
101
+
102
+ if __name__ == "__main__":
103
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.87
1
+ 1.0.88
@@ -1 +1 @@
1
- 1.0.87
1
+ 1.0.88
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.87",
3
+ "version": "1.0.88",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {