gowalk-cicd 1.0.87 → 1.0.89

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`
@@ -150,10 +155,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
150
155
  `NO_PROXY`. The preloader snapshot under `RUNNER_TEMP` also covers official post/save after
151
156
  consumer autoupdate changes the action directory. The installer delivers this development
152
157
  tooling for native iOS repositories too; it does not require an Android app.
153
- - Flutter manifest GET recovery is scoped to the two pinned setup invocations through `BASH_ENV`.
154
- It retains the account proxy, retries transient TLS/transport errors within 60 seconds, and emits
155
- only complete JSON. Partial responses and private curl stderr never enter action outputs;
156
- authentication/certificate failures remain terminal. SDK archive calls retain upstream behavior.
158
+ - Flutter manifest/archive GET recovery is scoped to the two pinned setup invocations through `BASH_ENV`.
159
+ It retains the account proxy and allows four attempts within 60 seconds for manifests or 600 seconds
160
+ for archives. Interrupted archives resume private partial bytes with HTTP Range; an origin ignoring
161
+ Range permits a bounded full restart. Only complete JSON or an archive matching the selected official
162
+ manifest's SHA-256 enters action outputs. Authentication/certificate refusals remain terminal.
163
+ `flutter_download_failed` emits fixed transport codes and retained-byte counts, never curl stderr.
157
164
  - Web Release selects bundled renderer resources with `--no-web-resources-cdn` inside
158
165
  the existing proxied build command. This is not a font or integration offline guarantee;
159
166
  preserve app-authored font declarations, bootstrap configuration and other build inputs.
package/README.md CHANGED
@@ -297,6 +297,7 @@ the situation and whose **message** is one line of JSON carrying a versioned
297
297
  | `firebase_symbols_pending` | `gowalk-cicd/firebase-symbols-pending.v1` | `platform`, `app_id`, `source_sha`, `artifact`, `file`, `sha256`, `status` |
298
298
  | `source_maintenance_pending` | `gowalk-cicd/source-maintenance-pending.v1` | `repo`, `base_sha`, `head_sha`, `branch`, `run_id` |
299
299
  | `apple_build_upload_failed` (error) | `gowalk-cicd/apple-build-upload-failed.v2` | `category`, `stage`, `receipt_available`, receipt identifiers (`cfBundleVersion`, `upload_id`, `file_id`, `state`, `provider_status`, `provider_code`, `provider_pointer`), `action` |
300
+ | `flutter_download_failed` (error) | `gowalk-cicd/flutter-download-failed.v1` | `phase`, `curl_exit`, `http_status`, `proxy_status`, `attempts`, `retained_bytes` |
300
301
 
301
302
  Add a field by bumping the schema version; never change the meaning of an
302
303
  existing one.
@@ -737,10 +738,19 @@ before a capability was turned on — whether by this action or by you in the
737
738
  developer portal — would be reused indefinitely, and the archive would keep
738
739
  failing on a capability the App ID already has.
739
740
 
740
- Two classes of capability are **not** enabled automatically, and each gets a
741
+ App Groups require registration and assignment to each participating App ID through
742
+ the account's Apple Developer console. The action reads the literal identifiers from
743
+ each archive target's entitlements and verifies their exact membership in both cached
744
+ and fresh profiles. A stale cache regenerates; a fresh profile missing a required group
745
+ fails before installation with `apple_app_groups_required`, schema
746
+ `gowalk-cicd/apple-app-groups-required.v1`, containing the bundle ID and missing groups.
747
+ Complete those assignments through the scoped console and rerun CI. This check creates
748
+ no group or certificate and does not replace the account's capability provisioning.
749
+
750
+ Two other classes of capability are **not** enabled automatically, and each gets a
741
751
  `::warning::` naming what was seen:
742
752
 
743
- - **Values the action cannot infer** — App Groups, iCloud containers, Apple Pay
753
+ - **Values the action cannot infer** — iCloud containers, Apple Pay
744
754
  merchant IDs, Wallet pass types, Data Protection. Configure these on the App
745
755
  ID yourself.
746
756
  - **App Attest** — a real App ID capability in the developer portal, but absent
@@ -1060,6 +1070,9 @@ different bytes under the same number. Build numbers come from `/builds`,
1060
1070
  reservation is never reused; delete such a reservation only after reading it back.
1061
1071
  All requests and parts still use the Apple proxy.
1062
1072
 
1063
- Flutter SDK setup retries interrupted manifest and archive GETs on the assigned proxy. Archive attempts remain
1064
- bounded, expose only complete bytes to extraction, and never retry authentication or certificate refusals. Cache
1065
- identities remain those of the pinned upstream Flutter action.
1073
+ Flutter SDK setup retries interrupted manifest and archive GETs on the assigned proxy, with four attempts
1074
+ within 60 seconds for manifests or 600 seconds for archives. Archive retries resume retained private bytes
1075
+ with HTTP Range; if the origin ignores Range, a bounded full restart is permitted. The completed archive
1076
+ must match the selected official manifest's SHA-256 before extraction. Authentication and certificate
1077
+ refusals remain terminal. Exhausted transfers emit `flutter_download_failed` with fixed status codes and
1078
+ retained-byte counts, without URLs or private curl stderr. Cache identities remain those of the pinned action.
@@ -1 +1 @@
1
- 1.0.87
1
+ 1.0.89
@@ -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.89
@@ -0,0 +1,60 @@
1
+ """Private Flutter transfers publish only complete JSON or an archive matching the manifest digest."""
2
+ import hashlib
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+
11
+ MAX_ARCHIVE_BYTES = 2147483648
12
+
13
+
14
+ def archive_digest() -> str:
15
+ value = os.environ.get("GOWALK_FLUTTER_ARCHIVE_SHA256", "")
16
+ if not re.fullmatch(r"[a-fA-F0-9]{64}", value):
17
+ raise ValueError("flutter_archive_digest_required")
18
+ return value.lower()
19
+
20
+
21
+ def transfer(url: str, destination: Path, kind: str, remaining: float, env: dict) -> tuple[int, int, int]:
22
+ resume = kind == "archive" and destination.stat().st_size > 0
23
+ command = ["curl", "--disable", "--silent", "--show-error", "--fail", "--retry", "0",
24
+ "--connect-timeout", "15", "--max-time", str(min(20 if kind == "manifest" else 300, remaining)),
25
+ "--max-filesize", str(16777216 if kind == "manifest" else MAX_ARCHIVE_BYTES),
26
+ "--output", str(destination), "--write-out", "%{http_code} %{http_connect}"]
27
+ if resume:
28
+ command += ["--continue-at", "-"]
29
+ try:
30
+ result = subprocess.run([*command, url], capture_output=True, timeout=remaining, env=env)
31
+ status = result.stdout.split()
32
+ http, proxy = (map(int, status) if len(status) == 2 and all(part.isdigit() for part in status) else (0, 0))
33
+ return result.returncode, http, proxy
34
+ except subprocess.TimeoutExpired:
35
+ return 28, 0, 0
36
+
37
+
38
+ def publish(destination: Path, kind: str) -> None:
39
+ if kind == "manifest":
40
+ json.loads(destination.read_bytes())
41
+ else:
42
+ if destination.stat().st_size > MAX_ARCHIVE_BYTES:
43
+ raise ValueError("flutter_archive_size_exceeded")
44
+ digest = hashlib.sha256()
45
+ with destination.open("rb") as source:
46
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
47
+ digest.update(chunk)
48
+ if digest.hexdigest() != archive_digest():
49
+ raise ValueError("flutter_archive_checksum_mismatch")
50
+ with destination.open("rb") as source:
51
+ shutil.copyfileobj(source, sys.stdout.buffer, length=1024 * 1024)
52
+
53
+
54
+ def failure(kind: str, code: int, http: int, proxy: int, attempts: int, retained: int) -> None:
55
+ print(f"flutter_{kind}_transport_failed curl_exit={code} http_status={http} "
56
+ f"proxy_status={proxy} attempts={attempts} retained_bytes={retained}", file=sys.stderr)
57
+ note = {"schema": "gowalk-cicd/flutter-download-failed.v1", "phase": kind,
58
+ "curl_exit": code, "http_status": http, "proxy_status": proxy,
59
+ "attempts": attempts, "retained_bytes": retained}
60
+ print("::error title=flutter_download_failed::" + json.dumps(note, separators=(",", ":")), file=sys.stderr)
@@ -1,17 +1,16 @@
1
1
  """Retry the official Flutter manifest and SDK archive GETs without exposing partial JSON or proxy secrets."""
2
2
  from __future__ import annotations
3
3
 
4
- import json
5
4
  import os
6
5
  from pathlib import Path
7
6
  import re
8
- import shutil
9
- import subprocess
10
7
  import sys
11
8
  import tempfile
12
9
  import time
13
10
  from urllib.parse import urlsplit
14
11
 
12
+ import flutter_download
13
+
15
14
  RETRY_CODES = {5, 6, 7, 18, 28, 35, 52, 55, 56, 92, 95}
16
15
  RETRY_HTTP = {408, 429, 500, 502, 503, 504}
17
16
 
@@ -20,42 +19,36 @@ def download(arguments: list[str], directory: Path) -> int:
20
19
  kind = request_kind(arguments)
21
20
  if not (os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")):
22
21
  raise ValueError("flutter_download_proxy_required")
22
+ if kind == "archive":
23
+ flutter_download.archive_digest()
23
24
  child_env = {**os.environ, "NO_PROXY": "", "no_proxy": ""}
24
25
  deadline, code, http, proxy_http = time.monotonic() + (60 if kind == "manifest" else 600), 1, 0, 0
26
+ destination = directory / "download"
27
+ destination.touch(mode=0o600, exist_ok=False)
28
+ destination.chmod(0o600)
29
+ attempted = 0
25
30
  for attempt in range(1, 5):
26
31
  remaining = deadline - time.monotonic()
27
32
  if remaining <= 0:
28
33
  break
29
- destination = directory / f"download-{attempt}"
30
- destination.touch(mode=0o600, exist_ok=False)
31
- destination.chmod(0o600)
32
- try:
33
- result = subprocess.run([
34
- "curl", "--disable", "--silent", "--show-error", "--fail", "--retry", "0",
35
- "--connect-timeout", "15", "--max-time", str(min(20 if kind == "manifest" else 300, remaining)),
36
- "--max-filesize", str(16777216 if kind == "manifest" else 2147483648), "--output", str(destination),
37
- "--write-out", "%{http_code} %{http_connect}", arguments[-1],
38
- ], capture_output=True, timeout=remaining, env=child_env)
39
- code = result.returncode
40
- status = result.stdout.split()
41
- http, proxy_http = (map(int, status) if len(status) == 2 and all(part.isdigit() for part in status)
42
- else (0, 0))
43
- except subprocess.TimeoutExpired:
44
- code, http, proxy_http = 28, 0, 0
45
- if code == 0 and http == 200:
46
- if kind == "manifest":
47
- json.loads(destination.read_bytes())
48
- with destination.open("rb") as source:
49
- shutil.copyfileobj(source, sys.stdout.buffer, length=1024 * 1024)
34
+ attempted = attempt
35
+ code, http, proxy_http = flutter_download.transfer(arguments[-1], destination, kind, remaining, child_env)
36
+ if code == 0 and http in ({200, 206} if kind == "archive" else {200}):
37
+ flutter_download.publish(destination, kind)
50
38
  if attempt > 1:
51
39
  print(f"flutter_{kind}_recovered attempts={attempt}", file=sys.stderr)
52
40
  return 0
53
- destination.unlink(missing_ok=True)
54
- if attempt == 4 or proxy_http in {401, 403, 407} or (code not in RETRY_CODES and http not in RETRY_HTTP):
41
+ if attempt == 4 or proxy_http in {401, 403, 407} or http in {401, 403}:
42
+ break
43
+ restart = kind == "archive" and code == 33 and http == 200
44
+ if not restart and code not in RETRY_CODES and http not in RETRY_HTTP:
55
45
  break
46
+ if kind == "manifest" or restart:
47
+ destination.write_bytes(b"")
48
+ if destination.stat().st_size > flutter_download.MAX_ARCHIVE_BYTES:
49
+ raise ValueError("flutter_archive_size_exceeded")
56
50
  time.sleep(min(2 ** (attempt - 1), max(0, deadline - time.monotonic())))
57
- print(f"flutter_{kind}_transport_failed curl_exit={code} http_status={http} "
58
- f"proxy_status={proxy_http} attempts={attempt}", file=sys.stderr)
51
+ flutter_download.failure(kind, code, http, proxy_http, attempted, destination.stat().st_size)
59
52
  return code or 1
60
53
 
61
54
 
@@ -79,8 +72,10 @@ def main() -> int:
79
72
  try:
80
73
  with tempfile.TemporaryDirectory(prefix="flutter-manifest-", dir=os.environ["RUNNER_TEMP"]) as scratch:
81
74
  return download(sys.argv[1:], Path(scratch))
82
- except (ValueError, OSError, KeyError):
83
- print("flutter_manifest_invalid_request_or_response", file=sys.stderr)
75
+ except (ValueError, OSError, KeyError) as error:
76
+ safe = {"flutter_archive_digest_required", "flutter_archive_size_exceeded", "flutter_archive_checksum_mismatch"}
77
+ reason = str(error) if str(error) in safe else "flutter_manifest_invalid_request_or_response"
78
+ print("::error::" + reason, file=sys.stderr)
84
79
  return 1
85
80
 
86
81
 
@@ -1,10 +1,14 @@
1
1
  # Loaded only by the two pinned Flutter setup invocations, never the whole job.
2
2
  curl() {
3
3
  case "${!#}" in
4
- */flutter_infra_release/releases/releases_*.json|*/flutter_infra_release/releases/*.tar.xz|\
5
- */flutter_infra_release/releases/*.tar.gz|*/flutter_infra_release/releases/*.zip)
4
+ */flutter_infra_release/releases/releases_*.json)
6
5
  python3 "${GOWALK_FLUTTER_MANIFEST_HELPER:?}" "$@"
7
6
  ;;
7
+ */flutter_infra_release/releases/*.tar.xz|*/flutter_infra_release/releases/*.tar.gz|\
8
+ */flutter_infra_release/releases/*.zip)
9
+ GOWALK_FLUTTER_ARCHIVE_SHA256="$(printf '%s' "${VERSION_MANIFEST:-}" | jq -er '.sha256')" \
10
+ python3 "${GOWALK_FLUTTER_MANIFEST_HELPER:?}" "$@"
11
+ ;;
8
12
  *) command curl "$@" ;;
9
13
  esac
10
14
  }
@@ -30,6 +30,25 @@ def official_setup() -> bytes:
30
30
  return source
31
31
 
32
32
 
33
+ def response(server, mode, headers, path):
34
+ requested = headers.get(b"range", b"").decode()
35
+ server.ranges.append(requested)
36
+ complete = server.body[path] if isinstance(server.body, dict) else server.body
37
+ extra = b""
38
+ status = b"401 Unauthorized" if mode == "origin_auth" else b"200 OK"
39
+ if mode.startswith("range"):
40
+ if not requested.startswith("bytes=") or not requested.endswith("-"):
41
+ return b"HTTP/1.1 416 Range Not Satisfiable\r\nContent-Length: 0\r\n\r\n"
42
+ start = int(requested[6:-1])
43
+ extra = f"Content-Range: bytes {start}-{len(complete) - 1}/{len(complete)}\r\n".encode()
44
+ complete, status = complete[start:], b"206 Partial Content"
45
+ body = complete[:31] if mode in {"partial", "range_partial"} else complete
46
+ if mode == "range_corrupt":
47
+ body = bytes([body[0] ^ 1]) + body[1:]
48
+ return (b"HTTP/1.1 " + status + b"\r\n" + extra + b"Content-Length: "
49
+ + str(len(complete)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
50
+
51
+
33
52
  class Proxy(socketserver.StreamRequestHandler):
34
53
  def handle(self):
35
54
  first = self.rfile.readline(4096).split()
@@ -53,20 +72,18 @@ class Proxy(socketserver.StreamRequestHandler):
53
72
  stream.settimeout(3)
54
73
  with stream.makefile("rb") as reader:
55
74
  request = reader.readline(4096).split()
56
- while reader.readline(4096) not in (b"\r\n", b"\n", b""):
57
- pass
75
+ headers = {}
76
+ while (line := reader.readline(4096)) not in (b"\r\n", b"\n", b""):
77
+ name, _, value = line.partition(b":")
78
+ headers[name.lower()] = value.strip()
58
79
  self.server.requests.append(request[1].decode())
59
- status = b"401 Unauthorized" if mode == "origin_auth" else b"200 OK"
60
- complete = self.server.body
61
- body = complete[:31] if mode == "partial" else complete
62
- stream.sendall(b"HTTP/1.1 " + status + b"\r\nContent-Length: "
63
- + str(len(complete)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
80
+ stream.sendall(response(self.server, mode, headers, request[1].decode()))
64
81
  except (OSError, ssl.SSLError):
65
82
  pass # An untrusted fixture certificate must be rejected by the real client.
66
83
 
67
84
 
68
85
  @contextmanager
69
- def tls_proxy(root: Path, modes: list[str], body: bytes = MANIFEST):
86
+ def tls_proxy(root: Path, modes: list[str], body: bytes | dict[str, bytes] = MANIFEST):
70
87
  cert, key = root / "cert.pem", root / "key.pem"
71
88
  command = ["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
72
89
  "-keyout", str(key), "-out", str(cert), "-subj", "/CN=" + HOST,
@@ -78,6 +95,7 @@ def tls_proxy(root: Path, modes: list[str], body: bytes = MANIFEST):
78
95
  server.daemon_threads = True
79
96
  server.context, server.modes, server.body = context, list(modes), body
80
97
  server.connects, server.requests, server.lock = 0, [], threading.Lock()
98
+ server.ranges = []
81
99
  thread = threading.Thread(target=server.serve_forever, daemon=True)
82
100
  thread.start()
83
101
  try:
@@ -1,4 +1,6 @@
1
- """Real proxy/TLS transfers discard interrupted SDK bytes and preserve terminal refusals."""
1
+ """Real proxy/TLS transfers resume private SDK bytes and verify the official manifest digest."""
2
+ import hashlib
3
+ import json
2
4
  import os
3
5
  from pathlib import Path
4
6
  import subprocess
@@ -12,10 +14,12 @@ ARCHIVE = bytes(range(256)) * 512
12
14
 
13
15
 
14
16
  class ArchiveRecoveryTests(unittest.TestCase):
15
- def invoke(self, root, server, cert, hooked=True, trusted=True):
17
+ def invoke(self, root, server, cert, hooked=True, trusted=True, digest=None):
16
18
  proxy = f"http://fixture-user:fixture-secret@127.0.0.1:{server.server_address[1]}"
17
19
  env = {**os.environ, "HOME": str(root), "RUNNER_TEMP": str(root),
18
- "HTTPS_PROXY": proxy, "https_proxy": proxy, "NO_PROXY": "", "no_proxy": ""}
20
+ "HTTPS_PROXY": proxy, "https_proxy": proxy, "NO_PROXY": "", "no_proxy": "",
21
+ "VERSION_MANIFEST": json.dumps({"sha256": hashlib.sha256(ARCHIVE).hexdigest()
22
+ if digest is None else digest})}
19
23
  for key in ("BASH_ENV", "CURL_CA_BUNDLE", "SSL_CERT_FILE", "SSL_CERT_DIR"):
20
24
  env.pop(key, None)
21
25
  if trusted:
@@ -37,10 +41,11 @@ class ArchiveRecoveryTests(unittest.TestCase):
37
41
  with tls_proxy(root, ["partial"], ARCHIVE) as (server, cert):
38
42
  original = self.invoke(root, server, cert, hooked=False)
39
43
  self.assertEqual(original.returncode, 18)
40
- with tls_proxy(root, ["partial", "ok"], ARCHIVE) as (server, cert):
44
+ with tls_proxy(root, ["partial", "range"], ARCHIVE) as (server, cert):
41
45
  result = self.invoke(root, server, cert)
42
46
  self.assertEqual((result.returncode, result.stdout), (0, ARCHIVE))
43
47
  self.assertEqual(server.connects, 2)
48
+ self.assertEqual(server.ranges, ["", "bytes=31-"])
44
49
  self.assertIn(b"flutter_archive_recovered attempts=2", result.stderr)
45
50
 
46
51
  def test_archive_transport_never_retries_auth_or_untrusted_certificate(self):
@@ -52,12 +57,54 @@ class ArchiveRecoveryTests(unittest.TestCase):
52
57
  self.assertNotEqual(result.returncode, 0)
53
58
  self.assertEqual((result.stdout, server.connects), (b"", 1))
54
59
 
60
+ def test_authentication_refusal_after_partial_transfer_stops_without_publishing(self):
61
+ for mode in ("proxy_auth", "origin_auth"):
62
+ with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory:
63
+ root = Path(directory)
64
+ with tls_proxy(root, ["partial", mode], ARCHIVE) as (server, cert):
65
+ result = self.invoke(root, server, cert)
66
+ self.assertNotEqual(result.returncode, 0)
67
+ self.assertEqual((result.stdout, server.connects), (b"", 2))
68
+
55
69
  def test_exhausted_archive_retry_never_emits_partial_bytes(self):
56
70
  with tempfile.TemporaryDirectory() as directory:
57
71
  root = Path(directory)
58
- with tls_proxy(root, ["partial"] * 4, ARCHIVE) as (server, cert):
72
+ modes = ["partial", "range_partial", "range_partial", "range_partial"]
73
+ with tls_proxy(root, modes, ARCHIVE) as (server, cert):
59
74
  result = self.invoke(root, server, cert)
60
75
  self.assertEqual((result.returncode, result.stdout, server.connects), (18, b"", 4))
76
+ self.assertEqual(server.ranges, ["", "bytes=31-", "bytes=62-", "bytes=93-"])
77
+ self.assertIn(b'"retained_bytes":124', result.stderr)
78
+
79
+ def test_repeated_disconnects_resume_until_the_verified_archive_is_complete(self):
80
+ with tempfile.TemporaryDirectory() as directory:
81
+ root = Path(directory)
82
+ with tls_proxy(root, ["partial", "range_partial", "range"], ARCHIVE) as (server, cert):
83
+ result = self.invoke(root, server, cert)
84
+ self.assertEqual((result.returncode, result.stdout), (0, ARCHIVE))
85
+ self.assertEqual(server.ranges, ["", "bytes=31-", "bytes=62-"])
86
+
87
+ def test_origin_ignoring_range_restarts_without_appending_another_full_copy(self):
88
+ with tempfile.TemporaryDirectory() as directory:
89
+ root = Path(directory)
90
+ with tls_proxy(root, ["partial", "ok", "ok"], ARCHIVE) as (server, cert):
91
+ result = self.invoke(root, server, cert)
92
+ self.assertEqual((result.returncode, result.stdout), (0, ARCHIVE))
93
+ self.assertEqual(server.ranges, ["", "bytes=31-", ""])
94
+
95
+ def test_changed_resumed_bytes_fail_checksum_before_any_output(self):
96
+ with tempfile.TemporaryDirectory() as directory:
97
+ root = Path(directory)
98
+ with tls_proxy(root, ["partial", "range_corrupt"], ARCHIVE) as (server, cert):
99
+ result = self.invoke(root, server, cert)
100
+ self.assertEqual((result.returncode, result.stdout, server.connects), (1, b"", 2))
101
+
102
+ def test_missing_manifest_digest_refuses_before_download(self):
103
+ with tempfile.TemporaryDirectory() as directory:
104
+ root = Path(directory)
105
+ with tls_proxy(root, [], ARCHIVE) as (server, cert):
106
+ result = self.invoke(root, server, cert, digest="")
107
+ self.assertEqual((result.returncode, result.stdout, server.connects), (1, b"", 0))
61
108
 
62
109
 
63
110
  if __name__ == "__main__":
@@ -0,0 +1,68 @@
1
+ """The unchanged official setup supplies its selected digest and extracts only verified resumed bytes."""
2
+ import hashlib
3
+ import io
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import subprocess
8
+ import tarfile
9
+ import tempfile
10
+ import unittest
11
+
12
+ from flutter_manifest_fixture import HOST, official_setup, tls_proxy
13
+
14
+ ROOT = Path(__file__).parent
15
+ PREFIX = "/flutter_infra_release/releases/"
16
+
17
+
18
+ def fixture_archive():
19
+ buffer = io.BytesIO()
20
+ with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
21
+ entry = tarfile.TarInfo("flutter/bin/flutter")
22
+ content = b"#!/bin/sh\nexit 0\n"
23
+ entry.size, entry.mode = len(content), 0o755
24
+ archive.addfile(entry, io.BytesIO(content))
25
+ return buffer.getvalue()
26
+
27
+
28
+ class OfficialArchiveSetupTests(unittest.TestCase):
29
+ @classmethod
30
+ def setUpClass(cls):
31
+ cls.source = official_setup()
32
+
33
+ def test_cold_setup_resumes_and_extracts_using_its_actual_manifest_scope(self):
34
+ with tempfile.TemporaryDirectory() as directory:
35
+ root = Path(directory)
36
+ setup, sdk = root / "setup.sh", root / "sdk"
37
+ setup.write_bytes(self.source)
38
+ archive = fixture_archive()
39
+ manifest = json.dumps({"releases": [{"channel": "stable", "version": "3.44.0",
40
+ "dart_sdk_arch": "x64", "hash": "a" * 40, "sha256": hashlib.sha256(archive).hexdigest(),
41
+ "archive": "fixture-sdk.tar.gz"}]}).encode()
42
+ bodies = {PREFIX + "releases_linux.json": manifest, PREFIX + "fixture-sdk.tar.gz": archive}
43
+ with tls_proxy(root, ["ok", "partial", "range"], bodies) as (server, cert):
44
+ proxy = f"http://fixture-user:fixture-secret@127.0.0.1:{server.server_address[1]}"
45
+ env = {**os.environ, "HOME": str(root), "RUNNER_TEMP": str(root), "RUNNER_OS": "Linux",
46
+ "RUNNER_ARCH": "X64", "RUNNER_TOOL_CACHE": str(root / "tools"),
47
+ "PUB_CACHE": str(root / "pub"), "FLUTTER_STORAGE_BASE_URL": "https://" + HOST,
48
+ "HTTPS_PROXY": proxy, "https_proxy": proxy, "NO_PROXY": "*", "no_proxy": "*",
49
+ "CURL_CA_BUNDLE": str(cert), "BASH_ENV": str(ROOT / "flutter_manifest_env.sh"),
50
+ "GOWALK_FLUTTER_MANIFEST_HELPER": str(ROOT / "flutter_manifest.py")}
51
+ for name in ("VERSION_MANIFEST", "GOWALK_FLUTTER_ARCHIVE_SHA256"):
52
+ env.pop(name, None)
53
+ for name in ("GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_PATH"):
54
+ env[name] = str(root / name.lower())
55
+ result = subprocess.run(["bash", str(setup), "-n", "3.44.0", "-a", "x64",
56
+ "-c", str(sdk), "stable"], env=env, cwd=root,
57
+ capture_output=True, timeout=20)
58
+ self.assertEqual(result.returncode, 0, result.stderr)
59
+ self.assertEqual((sdk / "flutter/bin/flutter").read_bytes(), b"#!/bin/sh\nexit 0\n")
60
+ self.assertEqual(server.ranges, ["", "", "bytes=31-"])
61
+ self.assertEqual(server.requests, [PREFIX + "releases_linux.json"]
62
+ + [PREFIX + "fixture-sdk.tar.gz"] * 2)
63
+ self.assertNotIn(b"fixture-secret", result.stdout + result.stderr)
64
+ self.assertFalse(list(root.glob("flutter-manifest-*")))
65
+
66
+
67
+ if __name__ == "__main__":
68
+ unittest.main()
@@ -20,7 +20,8 @@ class ManifestPathScopeTests(unittest.TestCase):
20
20
  parent = root / "parent/flutter-setup"
21
21
  scripts = parent.parent / "scripts"
22
22
  parent.mkdir(parents=True); scripts.mkdir()
23
- for name in ("play_store_proxy.py", "flutter_manifest.py", "flutter_manifest_env.sh"):
23
+ names = ("play_store_proxy.py", "flutter_manifest.py", "flutter_download.py", "flutter_manifest_env.sh")
24
+ for name in names:
24
25
  shutil.copyfile(ROOT / "android-action/scripts" / name, scripts / name)
25
26
  env_file, output = root / "env", root / "output"
26
27
  env = {**os.environ, "GITHUB_ACTION_PATH": str(parent), "GITHUB_ENV": str(env_file),
@@ -1 +1 @@
1
- 1.0.87
1
+ 1.0.89
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.87",
3
+ "version": "1.0.89",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {