gowalk-cicd 1.0.4 → 1.0.6

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
@@ -147,7 +147,7 @@ configuration for either:
147
147
  |---|---|---|
148
148
  | Detected by | `pubspec.yaml` at the repo root | `settings.gradle(.kts)` + `gradlew` at the root or under `android/` |
149
149
  | App module | `android/app` | the one module applying `com.android.application` (version-catalog aliases are resolved; a module named `app` wins a tie against a wear/automotive sibling) |
150
- | Tests (`run-tests`) | `flutter analyze` + `flutter test` | `<module>:testReleaseUnitTest` |
150
+ | Tests (`run-tests`) | `flutter analyze` + `flutter test` | `gradlew test` (all variants, all modules) |
151
151
  | Build | `flutter build appbundle --release` | `<module>:bundleRelease` |
152
152
  | Toolchain installed | Flutter + JDK 17 | JDK 21 only |
153
153
 
@@ -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.4
1
+ 1.0.6
@@ -52,8 +52,15 @@ from cfg_io import log, notice
52
52
  APP_PRODUCT_TYPE = "com.apple.product-type.application"
53
53
  # Timeouts (seconds) — xcodebuild can hang on missing toolchains or
54
54
  # package resolution. CI should fail fast rather than spin forever.
55
- LIST_TIMEOUT = 60
56
- SETTINGS_TIMEOUT = 120
55
+ #
56
+ # The first xcodebuild invocation on a cold runner also resolves the Swift
57
+ # Package Manager graph, and a project that depends on firebase-ios-sdk spends
58
+ # minutes cloning before it prints a single scheme. That is legitimate work,
59
+ # not a hang, so `-list` gets a budget that survives it and a one-shot
60
+ # pre-resolution retry (see _list_schemes) rather than a tighter deadline.
61
+ LIST_TIMEOUT = 180
62
+ SETTINGS_TIMEOUT = 300
63
+ RESOLVE_TIMEOUT = 900
57
64
 
58
65
  # Process-local caches keyed by (project, workspace_file) or
59
66
  # (project, workspace_file, scheme, configuration). Cleared between test
@@ -221,6 +228,39 @@ def _pick_by_basename(matches: list[Path], workspace: Path, label: str) -> Path:
221
228
  return matches[0]
222
229
 
223
230
 
231
+ def _resolve_package_dependencies(
232
+ workspace: Path, project: str, workspace_file: str
233
+ ) -> bool:
234
+ """Resolve the SPM graph once so later xcodebuild calls are cheap.
235
+
236
+ Returns True when resolution finished (regardless of exit status — a
237
+ project with no packages still exits cleanly and a partial resolve may
238
+ be enough for ``-list``), False when it could not be run at all.
239
+ """
240
+ cmd = ["xcodebuild", "-resolvePackageDependencies"]
241
+ if workspace_file:
242
+ cmd += ["-workspace", workspace_file]
243
+ elif project:
244
+ cmd += ["-project", project]
245
+ else:
246
+ return False
247
+ log("auto-detect: pre-resolving Swift package dependencies")
248
+ try:
249
+ result = subprocess.run(
250
+ cmd, cwd=str(workspace), capture_output=True, text=True,
251
+ timeout=RESOLVE_TIMEOUT,
252
+ )
253
+ except (OSError, subprocess.TimeoutExpired) as exc:
254
+ log(f"auto-detect: xcodebuild -resolvePackageDependencies failed: {exc!r}")
255
+ return False
256
+ if result.returncode != 0:
257
+ log(
258
+ f"auto-detect: xcodebuild -resolvePackageDependencies returned "
259
+ f"{result.returncode}: {result.stderr.strip()[:500]}"
260
+ )
261
+ return True
262
+
263
+
224
264
  def _list_schemes(
225
265
  workspace: Path, project: str, workspace_file: str
226
266
  ) -> Optional[list[str]]:
@@ -243,7 +283,27 @@ def _list_schemes(
243
283
  cmd, cwd=str(workspace), capture_output=True, text=True,
244
284
  timeout=LIST_TIMEOUT,
245
285
  )
246
- except (OSError, subprocess.TimeoutExpired) as exc:
286
+ except subprocess.TimeoutExpired as exc:
287
+ # Almost always SPM resolution rather than a hang: `-list` implicitly
288
+ # resolves the package graph, and a cold Firebase/GoogleSignIn clone
289
+ # outlasts any deadline short enough to still catch a real hang. Pay
290
+ # for the resolve once, explicitly, then retry. Swallowing this is what
291
+ # produced the downstream "MARKETING_VERSION for scheme=" failure --
292
+ # an empty scheme reads as "project has none", not "we never asked".
293
+ log(f"auto-detect: xcodebuild -list timed out: {exc!r}")
294
+ if not _resolve_package_dependencies(workspace, project, workspace_file):
295
+ _list_cache[key] = None
296
+ return None
297
+ try:
298
+ result = subprocess.run(
299
+ cmd, cwd=str(workspace), capture_output=True, text=True,
300
+ timeout=LIST_TIMEOUT,
301
+ )
302
+ except (OSError, subprocess.TimeoutExpired) as retry_exc:
303
+ log(f"auto-detect: xcodebuild -list failed after resolve: {retry_exc!r}")
304
+ _list_cache[key] = None
305
+ return None
306
+ except OSError as exc:
247
307
  log(f"auto-detect: xcodebuild -list failed: {exc!r}")
248
308
  _list_cache[key] = None
249
309
  return None
@@ -0,0 +1,166 @@
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
+ # Entitlement key -> App Store Connect capabilityType, for capabilities that
32
+ # are a bare toggle. An entitlement absent from this map is not an error: most
33
+ # entitlements (keychain-access-groups, get-task-allow, ...) need no App ID
34
+ # capability at all.
35
+ CAPABILITY_BY_ENTITLEMENT = {
36
+ "aps-environment": "PUSH_NOTIFICATIONS",
37
+ "com.apple.developer.devicecheck.appattest-environment": "APP_ATTEST",
38
+ "com.apple.developer.associated-domains": "ASSOCIATED_DOMAINS",
39
+ "com.apple.developer.applesignin": "APPLE_ID_AUTH",
40
+ "com.apple.developer.networking.wifi-info": "ACCESS_WIFI_INFORMATION",
41
+ "com.apple.developer.networking.networkextension": "NETWORK_EXTENSIONS",
42
+ "com.apple.developer.networking.vpn.api": "PERSONAL_VPN",
43
+ "com.apple.developer.networking.multipath": "MULTIPATH",
44
+ "com.apple.developer.networking.HotspotConfiguration": "HOT_SPOT",
45
+ "com.apple.developer.nfc.readersession.formats": "NFC_TAG_READING",
46
+ "com.apple.developer.homekit": "HOMEKIT",
47
+ "com.apple.developer.healthkit": "HEALTHKIT",
48
+ "com.apple.developer.siri": "SIRIKIT",
49
+ "com.apple.developer.ClassKit-environment": "CLASSKIT",
50
+ "com.apple.developer.authentication-services.autofill-credential-provider":
51
+ "AUTOFILL_CREDENTIAL_PROVIDER",
52
+ "com.apple.external-accessory.wireless-configuration":
53
+ "WIRELESS_ACCESSORY_CONFIGURATION",
54
+ "inter-app-audio": "INTER_APP_AUDIO",
55
+ }
56
+
57
+ # Capabilities whose App ID configuration carries values (container ids,
58
+ # merchant ids, protection level) that an entitlements file cannot supply
59
+ # unambiguously. Enabling these blind would create a half-configured App ID,
60
+ # so we say what is missing and stop.
61
+ MANUAL_ENTITLEMENTS = {
62
+ "com.apple.security.application-groups": "App Groups",
63
+ "com.apple.developer.icloud-container-identifiers": "iCloud",
64
+ "com.apple.developer.icloud-services": "iCloud",
65
+ "com.apple.developer.ubiquity-kvstore-identifier": "iCloud",
66
+ "com.apple.developer.in-app-payments": "Apple Pay",
67
+ "com.apple.developer.pass-type-identifiers": "Wallet",
68
+ "com.apple.developer.default-data-protection": "Data Protection",
69
+ }
70
+
71
+
72
+ def read_entitlement_keys(path: Path) -> set[str]:
73
+ """Top-level keys of an entitlements plist; empty when unreadable."""
74
+ try:
75
+ with path.open("rb") as handle:
76
+ data = plistlib.load(handle)
77
+ except (OSError, plistlib.InvalidFileException, ValueError) as exc:
78
+ print(f"::warning::could not read entitlements {path}: {exc!r}")
79
+ return set()
80
+ return set(data) if isinstance(data, dict) else set()
81
+
82
+
83
+ def required_capabilities(entitlement_keys: set[str]) -> set[str]:
84
+ return {
85
+ CAPABILITY_BY_ENTITLEMENT[key]
86
+ for key in entitlement_keys
87
+ if key in CAPABILITY_BY_ENTITLEMENT
88
+ }
89
+
90
+
91
+ def warn_about_manual_entitlements(entitlement_keys: set[str], bundle_id: str) -> None:
92
+ for key in sorted(entitlement_keys & set(MANUAL_ENTITLEMENTS)):
93
+ print(
94
+ f"::warning::{bundle_id} declares {key}, which needs "
95
+ f"{MANUAL_ENTITLEMENTS[key]} configured on the App ID with values "
96
+ f"this action cannot infer. Enable it in the Apple Developer "
97
+ f"portal if the archive complains."
98
+ )
99
+
100
+
101
+ def enabled_capabilities(token: str, bundle_pk: str) -> set[str]:
102
+ try:
103
+ data = get_json(
104
+ f"/bundleIds/{bundle_pk}/bundleIdCapabilities", token,
105
+ params={"limit": "200"},
106
+ )
107
+ except Exception as exc: # noqa: BLE001 - never fail signing over a read
108
+ print(f"::warning::could not list capabilities for {bundle_pk}: {exc!r}")
109
+ return set()
110
+ found = set()
111
+ for item in data.get("data", []):
112
+ capability = (item.get("attributes") or {}).get("capabilityType")
113
+ if capability:
114
+ found.add(capability)
115
+ return found
116
+
117
+
118
+ def enable_capability(token: str, bundle_pk: str, capability: str) -> bool:
119
+ body = {
120
+ "data": {
121
+ "type": "bundleIdCapabilities",
122
+ "attributes": {"capabilityType": capability},
123
+ "relationships": {
124
+ "bundleId": {"data": {"type": "bundleIds", "id": bundle_pk}}
125
+ },
126
+ }
127
+ }
128
+ try:
129
+ # 409 means another writer got there first — the desired end state.
130
+ response = request(
131
+ "POST", "/bundleIdCapabilities", token,
132
+ json_body=body, allow_status={200, 201, 409},
133
+ )
134
+ except Exception as exc: # noqa: BLE001 - Apple's archive error is clearer
135
+ print(f"::warning::could not enable {capability}: {exc!r}")
136
+ return False
137
+ if response.status_code == 409:
138
+ return False
139
+ print(f"Enabled {capability} on the App ID")
140
+ return True
141
+
142
+
143
+ def reconcile(token: str, bundle_pk: str, bundle_id: str, entitlement_keys: set[str]) -> bool:
144
+ """Enable every simple capability the entitlements imply.
145
+
146
+ Returns True when something was actually turned on — the caller must then
147
+ regenerate the profile, because a cached one predates the change and still
148
+ lacks the capability.
149
+ """
150
+ warn_about_manual_entitlements(entitlement_keys, bundle_id)
151
+ wanted = required_capabilities(entitlement_keys)
152
+ if not wanted:
153
+ return False
154
+ missing = wanted - enabled_capabilities(token, bundle_pk)
155
+ if not missing:
156
+ return False
157
+ print(
158
+ f"{bundle_id}: entitlements require {', '.join(sorted(missing))} "
159
+ f"but the App ID does not have them; enabling"
160
+ )
161
+ # List, not a generator: `any` short-circuits, and every missing capability
162
+ # must be attempted, not just up to the first success.
163
+ return any([
164
+ enable_capability(token, bundle_pk, capability)
165
+ for capability in sorted(missing)
166
+ ])
@@ -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
@@ -9,6 +9,7 @@ All tests run offline. xcodebuild invocations are stubbed through
9
9
  from __future__ import annotations
10
10
 
11
11
  import json
12
+ import subprocess
12
13
  import sys
13
14
  import tempfile
14
15
  import unittest
@@ -318,6 +319,54 @@ class AutoDetectSchemeTests(unittest.TestCase):
318
319
  scheme = auto_detect.auto_detect_scheme(root, "Proj.xcodeproj", "")
319
320
  self.assertIsNone(scheme)
320
321
 
322
+ def test_resolves_packages_and_retries_when_list_times_out(self):
323
+ # `-list` implicitly resolves the SPM graph, so a cold runner cloning
324
+ # firebase-ios-sdk blows the deadline on the first call. Treating that
325
+ # as "no schemes" is what left SCHEME empty and failed the build much
326
+ # later, on an unrelated-looking MARKETING_VERSION error.
327
+ calls: list[str] = []
328
+
329
+ def fake_run(cmd, **kw):
330
+ if "-resolvePackageDependencies" in cmd:
331
+ calls.append("resolve")
332
+ result = mock.MagicMock()
333
+ result.returncode = 0
334
+ result.stdout = ""
335
+ return result
336
+ if "-list" in cmd:
337
+ calls.append("list")
338
+ if calls.count("list") == 1:
339
+ raise subprocess.TimeoutExpired(cmd, auto_detect.LIST_TIMEOUT)
340
+ result = mock.MagicMock()
341
+ result.returncode = 0
342
+ result.stdout = self._list_output(["MyApp"])
343
+ return result
344
+ result = mock.MagicMock()
345
+ result.returncode = 0
346
+ result.stdout = self._show_settings(APP_TYPE)
347
+ return result
348
+
349
+ root = Path("/tmp/repo")
350
+ with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
351
+ scheme = auto_detect.auto_detect_scheme(root, "Proj.xcodeproj", "")
352
+
353
+ self.assertEqual(scheme, "MyApp")
354
+ self.assertEqual(calls[:3], ["list", "resolve", "list"])
355
+
356
+ def test_gives_up_when_list_times_out_again_after_resolving(self):
357
+ def fake_run(cmd, **kw):
358
+ if "-resolvePackageDependencies" in cmd:
359
+ result = mock.MagicMock()
360
+ result.returncode = 0
361
+ result.stdout = ""
362
+ return result
363
+ raise subprocess.TimeoutExpired(cmd, auto_detect.LIST_TIMEOUT)
364
+
365
+ root = Path("/tmp/repo")
366
+ with mock.patch.object(auto_detect.subprocess, "run", side_effect=fake_run):
367
+ scheme = auto_detect.auto_detect_scheme(root, "Proj.xcodeproj", "")
368
+ self.assertIsNone(scheme)
369
+
321
370
 
322
371
  class AutoDetectBundleIdTests(unittest.TestCase):
323
372
  """auto_detect_bundle_id: extract PRODUCT_BUNDLE_IDENTIFIER from showBuildSettings."""
@@ -0,0 +1,145 @@
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_warns_about_capabilities_it_will_not_guess(self) -> None:
133
+ with mock.patch.object(capabilities, "get_json", return_value={"data": []}), \
134
+ mock.patch("builtins.print") as printed:
135
+ capabilities.reconcile(
136
+ "tok", "PK", "com.gowalk.form",
137
+ {"com.apple.security.application-groups"},
138
+ )
139
+
140
+ warnings = [c.args[0] for c in printed.call_args_list if c.args]
141
+ self.assertTrue(any("App Groups" in w for w in warnings), warnings)
142
+
143
+
144
+ if __name__ == "__main__":
145
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.4
1
+ 1.0.6
@@ -109,13 +109,19 @@ runs:
109
109
  # resolve_android.py has already rewritten versionCode/versionName in the
110
110
  # module build file, so these are plain Gradle invocations with no extra
111
111
  # properties for the project to have to opt into reading.
112
+ # `test` is the aggregate task ("Run unit tests for all variants") and is the
113
+ # only one guaranteed to exist: AGP only creates testXUnitTest tasks for the
114
+ # variants it enables, and AGP 9 disables the release unit test variant by
115
+ # default, so `:app:testReleaseUnitTest` is simply absent in many projects.
116
+ # Running it unqualified also mirrors `flutter test`, which covers the whole
117
+ # package rather than one module.
112
118
  - name: Test Android app
113
119
  if: ${{ steps.config.outputs.project_kind == 'gradle' && inputs.run-tests == 'true' }}
114
120
  shell: bash
115
121
  working-directory: ${{ steps.config.outputs.gradle_root || '.' }}
116
122
  run: |
117
123
  chmod +x ./gradlew
118
- ./gradlew "${ANDROID_GRADLE_MODULE}:testReleaseUnitTest" --console=plain --stacktrace
124
+ ./gradlew test --console=plain --stacktrace
119
125
 
120
126
  - name: Build release Android App Bundle with Gradle
121
127
  if: ${{ steps.config.outputs.project_kind == 'gradle' }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {