gowalk-cicd 1.0.9 → 1.0.11

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. Whenever the App ID was
211
- missing something it regenerates the profile rather than reusing the cached
212
- one, which was issued before the capability existed.
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:
@@ -1 +1 @@
1
- 1.0.9
1
+ 1.0.11
@@ -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
- "com.apple.developer.devicecheck.appattest-environment": (
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(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
@@ -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.9
1
+ 1.0.11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -149,6 +149,12 @@ jobs:
149
149
  id: android
150
150
  if: ${{ steps.mode.outputs.mode == 'local' }}
151
151
  uses: ./.github/actions/android-app
152
+ with:
153
+ # Tests belong to the CI gate, not the deploy path — same policy as
154
+ # the iOS job above. Running the analyzer here breaks every deploy
155
+ # the day a new stable Flutter adds a warning-level lint, even when
156
+ # the app's own CI gate is green.
157
+ run-tests: 'false'
152
158
  - name: Retain signed Android App Bundle
153
159
  if: ${{ steps.mode.outputs.mode == 'local' }}
154
160
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5