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 +11 -4
- package/README.md +18 -5
- package/action/.daemux-version +1 -1
- package/action/scripts/app_groups.py +82 -0
- package/action/scripts/capabilities.py +5 -1
- package/action/scripts/prepare_signing.py +3 -0
- package/action/scripts/profile_manager.py +21 -25
- package/action/scripts/test_app_group_requirements.py +83 -0
- package/action/scripts/test_profile_app_groups.py +103 -0
- package/android-action/.daemux-version +1 -1
- package/android-action/scripts/flutter_download.py +60 -0
- package/android-action/scripts/flutter_manifest.py +25 -30
- package/android-action/scripts/flutter_manifest_env.sh +6 -2
- package/android-action/scripts/flutter_manifest_fixture.py +26 -8
- package/android-action/scripts/test_flutter_archive.py +52 -5
- package/android-action/scripts/test_flutter_archive_setup.py +68 -0
- package/android-action/scripts/test_flutter_manifest_scope.py +2 -1
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
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
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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** —
|
|
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
|
|
1064
|
-
|
|
1065
|
-
|
|
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.
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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
|
-
#
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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 =
|
|
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)``
|
|
339
|
-
|
|
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.
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
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", "
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1
|
+
1.0.89
|