gowalk-cicd 1.0.92 → 1.0.94
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 -0
- package/README.md +11 -1
- package/action/.daemux-version +1 -1
- package/android-action/.daemux-version +1 -1
- package/android-action/action.yml +7 -0
- package/android-action/flutter-setup/action.yml +15 -1
- package/android-action/scripts/crashlytics_bootstrap.py +97 -0
- package/android-action/scripts/crashlytics_bootstrap_test_support.py +33 -0
- package/android-action/scripts/crashlytics_symbols.py +33 -23
- package/android-action/scripts/test_crashlytics_bootstrap.py +119 -0
- package/android-action/scripts/test_crashlytics_prebuild.py +40 -0
- package/android-action/scripts/test_crashlytics_symbols_proxy.py +3 -1
- package/android-action/scripts/test_google_bootstrap_wiring.py +21 -2
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -172,6 +172,11 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
172
172
|
errors still fail the job. Never use `continue-on-error` or a timeout around all SDK setup
|
|
173
173
|
to hide an optional cache failure. Do not alter cache keys or disable caches as part of
|
|
174
174
|
this timeout handling.
|
|
175
|
+
- Flutter SDK caching restores first and saves immediately after successful setup, before
|
|
176
|
+
consumer tests/builds can fail and suppress a combined cache action's success-only post hook.
|
|
177
|
+
Save only on a cache miss with caching enabled, a real successful install and verified cache
|
|
178
|
+
scope. Preserve resolved keys/paths and the official pinned restore/save programs. Failed or
|
|
179
|
+
dry-run setup never publishes an SDK cache; pub dependency caching retains its job-end behavior.
|
|
175
180
|
- Gradle caches use explicit checksum-pinned restore/save actions, each optional and bounded
|
|
176
181
|
to two minutes. Preserve setup-java's key inputs and absolute cache paths to reuse existing
|
|
177
182
|
caches. Java setup, builds and store uploads remain mandatory; save runs after delivery.
|
|
@@ -280,6 +285,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
280
285
|
delivery request. This requires no human handoff. A green CI run alone is not
|
|
281
286
|
evidence that the deferred symbols reached Firebase. Android retains its Dart
|
|
282
287
|
symbols and uploads them before the store bundle, using the configured proxy.
|
|
288
|
+
- Crashlytics buildtools bootstrap runs before Flutter compilation. `crashlytics_bootstrap.py`
|
|
289
|
+
pins the official JAR version, byte size and SHA-256, admits only verified dependency-cache
|
|
290
|
+
bytes, and bounds proxied GET recovery to three attempts within three minutes. Java receives
|
|
291
|
+
that verified file through `CRASHLYTICS_LOCAL_JAR`; changing the Firebase CLI pin must check
|
|
292
|
+
its JAR version/override contract. TLS, authorization and checksum errors never retry.
|
|
293
|
+
`firebase_symbols_bootstrap_failed` carries fixed typed evidence, never private curl output.
|
|
283
294
|
- Play readiness and AAB/APK version inventory share one prebuild edit. Retry OAuth
|
|
284
295
|
transport, inventory reads and cleanup of that known edit; never replay an ambiguous
|
|
285
296
|
`edits.insert`. Edit creation retries at most three times only for typed connection-timeout
|
package/README.md
CHANGED
|
@@ -30,7 +30,9 @@ Flutter's optional SDK and pub cache restores have a one-minute timeout per
|
|
|
30
30
|
download segment. A slow segment becomes a cache miss and normal SDK setup
|
|
31
31
|
continues through the assigned Google proxy; SDK setup errors still fail the
|
|
32
32
|
job. This bounds individual cache segments, not the total cache or SDK download,
|
|
33
|
-
and keeps existing cache keys
|
|
33
|
+
and keeps existing cache keys. A successfully installed SDK is saved before app
|
|
34
|
+
checks run, so a later test/build failure does not force another cold SDK download.
|
|
35
|
+
Failed setup never saves an SDK cache; pub dependencies retain their job-end save.
|
|
34
36
|
|
|
35
37
|
## Flutter web release
|
|
36
38
|
|
|
@@ -649,6 +651,14 @@ Crashlytics mapping/native-symbol uploads. Crashlytics Gradle 2.9.2+ supports pr
|
|
|
649
651
|
environment variables; older versions must be updated before use. Dart symbols are
|
|
650
652
|
retained as artifacts and uploaded through that transport before the bundle ships.
|
|
651
653
|
|
|
654
|
+
Before Flutter compilation, the action downloads Crashlytics buildtools 3.0.3 through
|
|
655
|
+
the assigned account proxy and verifies its exact size and SHA-256. Verified bytes are
|
|
656
|
+
reused from the runner dependency cache and supplied through the Firebase CLI's
|
|
657
|
+
`CRASHLYTICS_LOCAL_JAR` override. GET recovery allows three attempts within three minutes;
|
|
658
|
+
TLS, authorization and checksum refusals stop immediately. A bootstrap failure emits
|
|
659
|
+
`firebase_symbols_bootstrap_failed` with schema `gowalk-cicd/firebase-symbols-bootstrap.v1`,
|
|
660
|
+
`phase`, `reason`, `attempts`, `exit_code` and `http_status`. Raw curl output is never published.
|
|
661
|
+
|
|
652
662
|
Native iOS `upload-symbols` is refused because it does not use this environment
|
|
653
663
|
transport. CI defers dedicated native upload phases in its ephemeral projects and
|
|
654
664
|
retains `ios-crashlytics-symbols-<run>-<attempt>` with `ios-dsyms.zip` and a manifest
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.94
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.94
|
|
@@ -100,6 +100,13 @@ runs:
|
|
|
100
100
|
run: node "${{ github.action_path }}/play-upload/owner_run.cjs" resolve
|
|
101
101
|
|
|
102
102
|
# --- Flutter --------------------------------------------------------
|
|
103
|
+
- name: Prepare Crashlytics symbol tools
|
|
104
|
+
if: ${{ steps.config.outputs.project_kind == 'flutter' }}
|
|
105
|
+
shell: bash
|
|
106
|
+
env:
|
|
107
|
+
FIREBASE_APP_ID: ${{ inputs.firebase-app-id }}
|
|
108
|
+
run: python3 "${{ github.action_path }}/scripts/crashlytics_symbols.py" --prepare-only
|
|
109
|
+
|
|
103
110
|
- name: Get Flutter dependencies
|
|
104
111
|
if: ${{ steps.config.outputs.project_kind == 'flutter' }}
|
|
105
112
|
shell: >-
|
|
@@ -119,7 +119,7 @@ runs:
|
|
|
119
119
|
- name: Cache Flutter
|
|
120
120
|
id: cache-flutter
|
|
121
121
|
if: ${{ inputs.cache == 'true' }}
|
|
122
|
-
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25
|
|
122
|
+
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25
|
|
123
123
|
continue-on-error: true
|
|
124
124
|
env:
|
|
125
125
|
NODE_OPTIONS: --require "${{ steps.scope.outputs.preloader }}"
|
|
@@ -139,6 +139,7 @@ runs:
|
|
|
139
139
|
path: ${{ steps.flutter-action.outputs.PUB-CACHE-PATH }}
|
|
140
140
|
key: ${{ steps.flutter-action.outputs.PUB-CACHE-KEY }}-${{ hashFiles('**/pubspec.lock') }}
|
|
141
141
|
- name: Install or reuse the resolved Flutter SDK
|
|
142
|
+
id: install
|
|
142
143
|
if: ${{ inputs.dry-run != 'true' }}
|
|
143
144
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2
|
|
144
145
|
env:
|
|
@@ -157,3 +158,16 @@ runs:
|
|
|
157
158
|
pub-cache-path: ${{ steps.flutter-action.outputs.PUB-CACHE-PATH }}
|
|
158
159
|
dry-run: 'false'
|
|
159
160
|
git-source: ${{ inputs.git-source }}
|
|
161
|
+
# A later app failure must not discard a successfully installed, expensive SDK.
|
|
162
|
+
- name: Save installed Flutter SDK
|
|
163
|
+
if: >-
|
|
164
|
+
${{ inputs.cache == 'true' && inputs.dry-run != 'true'
|
|
165
|
+
&& steps.install.outcome == 'success' && steps.scope.outcome == 'success'
|
|
166
|
+
&& steps.cache-flutter.outputs.cache-hit != 'true' }}
|
|
167
|
+
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25
|
|
168
|
+
continue-on-error: true
|
|
169
|
+
env:
|
|
170
|
+
NODE_OPTIONS: --require "${{ steps.scope.outputs.preloader }}"
|
|
171
|
+
with:
|
|
172
|
+
path: ${{ steps.flutter-action.outputs.CACHE-PATH }}
|
|
173
|
+
key: ${{ steps.flutter-action.outputs.CACHE-KEY }}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Prepare the pinned Crashlytics JAR before compiling, with bounded proxied GETs.
|
|
2
|
+
|
|
3
|
+
Firebase CLI 15.28.2 accepts CRASHLYTICS_LOCAL_JAR. Its own streaming download
|
|
4
|
+
has no total deadline; an idle relay can consume fifteen minutes after a build.
|
|
5
|
+
Only size/checksum-verified bytes enter this dependency cache or reach Java.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from play_store_proxy import environment
|
|
17
|
+
|
|
18
|
+
VERSION = "3.0.3"
|
|
19
|
+
URL = ("https://dl.google.com/android/maven2/com/google/firebase/"
|
|
20
|
+
f"firebase-crashlytics-buildtools/{VERSION}/firebase-crashlytics-buildtools-{VERSION}.jar")
|
|
21
|
+
SIZE = 8437846
|
|
22
|
+
SHA256 = "953b6f3942283f8332cc17a59bca2649ea49d14e0072f2c1f01762ebaebdf357"
|
|
23
|
+
TOTAL_SECONDS = 180
|
|
24
|
+
ATTEMPT_SECONDS = 60
|
|
25
|
+
ATTEMPTS = 3
|
|
26
|
+
RETRY_EXITS = {5, 6, 7, 18, 28, 52, 55, 56}
|
|
27
|
+
RETRY_STATUS = {408, 429, 500, 502, 503, 504}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BootstrapError(RuntimeError):
|
|
31
|
+
def __init__(self, reason: str, attempt: int = 0, exit_code: int = 0, http_status: int = 0):
|
|
32
|
+
super().__init__(reason)
|
|
33
|
+
self.receipt = {"schema": "gowalk-cicd/firebase-symbols-bootstrap.v1",
|
|
34
|
+
"phase": "buildtools_download", "reason": reason,
|
|
35
|
+
"attempts": attempt, "exit_code": exit_code, "http_status": http_status}
|
|
36
|
+
|
|
37
|
+
def annotate(self) -> None:
|
|
38
|
+
print("::error title=firebase_symbols_bootstrap_failed::" + json.dumps(self.receipt, separators=(",", ":")))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def verified(path: Path) -> bool:
|
|
42
|
+
return (path.is_file() and path.stat().st_size == SIZE
|
|
43
|
+
and hashlib.sha256(path.read_bytes()).hexdigest() == SHA256)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def transfer(path: Path, env: dict, seconds: float, attempt: int) -> None:
|
|
47
|
+
command = ["curl", "--disable", "--silent", "--show-error", "--fail", "--proto", "=https",
|
|
48
|
+
"--max-redirs", "0", "--connect-timeout", "15", "--max-time", str(seconds),
|
|
49
|
+
"--max-filesize", str(SIZE), "--output", str(path), "--write-out", "%{http_code} %{http_connect}", URL]
|
|
50
|
+
try:
|
|
51
|
+
result = subprocess.run(command, env=env, capture_output=True, timeout=seconds + 5, check=False)
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
raise BootstrapError("curl_missing", attempt) from None
|
|
54
|
+
except subprocess.TimeoutExpired:
|
|
55
|
+
raise BootstrapError("download_timeout", attempt, 28) from None
|
|
56
|
+
codes = result.stdout.split()
|
|
57
|
+
http, connect = (map(int, codes) if len(codes) == 2 and all(code.isdigit() for code in codes) else (0, 0))
|
|
58
|
+
status = http or (connect if connect >= 400 else 0)
|
|
59
|
+
if result.returncode == 0 and status == 200:
|
|
60
|
+
if not verified(path):
|
|
61
|
+
raise BootstrapError("checksum_or_size_mismatch", attempt, 0, status)
|
|
62
|
+
return
|
|
63
|
+
reason = ("download_timeout" if result.returncode == 28 else
|
|
64
|
+
"tls_refused" if result.returncode in {35, 51, 60, 77, 83, 90, 91} else
|
|
65
|
+
"size_limit" if result.returncode == 63 else
|
|
66
|
+
"http_refused" if status >= 300 else "transport_failed")
|
|
67
|
+
raise BootstrapError(reason, attempt, result.returncode, status)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def prepare() -> Path:
|
|
71
|
+
env = environment() # Validate the assigned account exit even when the cache is warm.
|
|
72
|
+
root = Path(env.get("RUNNER_TOOL_CACHE") or Path.home() / ".cache") / "gowalk-cicd" / "crashlytics"
|
|
73
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
target = root / f"{VERSION}-{SHA256}.jar"
|
|
75
|
+
if verified(target):
|
|
76
|
+
return target
|
|
77
|
+
deadline = time.monotonic() + TOTAL_SECONDS
|
|
78
|
+
for attempt in range(1, ATTEMPTS + 1):
|
|
79
|
+
with tempfile.TemporaryDirectory(prefix="download-", dir=root) as scratch:
|
|
80
|
+
partial = Path(scratch) / "buildtools.jar"
|
|
81
|
+
seconds = min(ATTEMPT_SECONDS, deadline - time.monotonic())
|
|
82
|
+
if seconds <= 0:
|
|
83
|
+
raise BootstrapError("download_timeout", attempt - 1, 28)
|
|
84
|
+
try:
|
|
85
|
+
transfer(partial, env, seconds, attempt)
|
|
86
|
+
except BootstrapError as error:
|
|
87
|
+
status = error.receipt["http_status"]
|
|
88
|
+
retryable = (status in RETRY_STATUS if status >= 300
|
|
89
|
+
else error.receipt["exit_code"] in RETRY_EXITS)
|
|
90
|
+
if not retryable or attempt == ATTEMPTS:
|
|
91
|
+
raise
|
|
92
|
+
print(f"Crashlytics buildtools GET attempt {attempt} failed ({error.receipt['reason']}); retrying.")
|
|
93
|
+
time.sleep(min(attempt, max(0, deadline - time.monotonic())))
|
|
94
|
+
continue
|
|
95
|
+
partial.replace(target)
|
|
96
|
+
return target
|
|
97
|
+
raise AssertionError("download attempts exhausted without a result")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Serve an owned dependency fixture through authenticated HTTPS CONNECT only."""
|
|
2
|
+
import ssl
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from play_proxy_test_support import Proxy
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DownloadProxy(Proxy):
|
|
9
|
+
def do_CONNECT(self):
|
|
10
|
+
self.server.connections.append(self.path)
|
|
11
|
+
self.server.authenticated.append(bool(self.headers.get("Proxy-Authorization")))
|
|
12
|
+
if getattr(self.server, "connect_status", 200) != 200:
|
|
13
|
+
self.send_error(self.server.connect_status, "private fixture proxy refusal")
|
|
14
|
+
return
|
|
15
|
+
self.send_response(200)
|
|
16
|
+
self.end_headers()
|
|
17
|
+
try:
|
|
18
|
+
with self.server.context.wrap_socket(self.connection, server_side=True) as stream:
|
|
19
|
+
stream.settimeout(2)
|
|
20
|
+
request = b""
|
|
21
|
+
while b"\r\n\r\n" not in request:
|
|
22
|
+
data = stream.recv(4096)
|
|
23
|
+
if not data:
|
|
24
|
+
return
|
|
25
|
+
request += data
|
|
26
|
+
self.server.requests.append(request.split(b"\r\n", 1)[0])
|
|
27
|
+
status, body, delay = self.server.responses.pop(0)
|
|
28
|
+
time.sleep(delay)
|
|
29
|
+
headers = (f"HTTP/1.1 {status} Fixture\r\nConnection: close\r\n"
|
|
30
|
+
f"Content-Length: {self.server.expected_size}\r\n\r\n").encode()
|
|
31
|
+
stream.sendall(headers + body)
|
|
32
|
+
except (OSError, ssl.SSLError):
|
|
33
|
+
pass
|
|
@@ -27,11 +27,13 @@ from __future__ import annotations
|
|
|
27
27
|
import argparse
|
|
28
28
|
import os
|
|
29
29
|
import re
|
|
30
|
+
import shutil
|
|
30
31
|
import sys
|
|
31
32
|
import tempfile
|
|
32
33
|
from pathlib import Path
|
|
33
34
|
|
|
34
35
|
from play_store_proxy import run as run_proxied
|
|
36
|
+
from crashlytics_bootstrap import BootstrapError, prepare as prepare_buildtools
|
|
35
37
|
|
|
36
38
|
# Exact pin, not a range: this job holds the upload keystore and the Play
|
|
37
39
|
# service account, so no floating third-party code runs in it. Bump on purpose.
|
|
@@ -99,8 +101,17 @@ def _default_run(cmd: list[str]) -> int:
|
|
|
99
101
|
# (dump_syms.bin, ~4 MB) into the working directory, which must not be the
|
|
100
102
|
# consumer's checkout.
|
|
101
103
|
try:
|
|
104
|
+
if cmd and shutil.which(cmd[0]) is None:
|
|
105
|
+
raise FileNotFoundError(cmd[0])
|
|
106
|
+
jar = prepare_buildtools()
|
|
107
|
+
if not cmd:
|
|
108
|
+
print("Verified Crashlytics buildtools are ready before compilation.")
|
|
109
|
+
return 0
|
|
102
110
|
with tempfile.TemporaryDirectory(prefix="crashlytics-upload-") as workdir:
|
|
103
|
-
return run_proxied(cmd, cwd=workdir)
|
|
111
|
+
return run_proxied(["env", f"CRASHLYTICS_LOCAL_JAR={jar}", *cmd], cwd=workdir)
|
|
112
|
+
except BootstrapError as error:
|
|
113
|
+
error.annotate()
|
|
114
|
+
return 1
|
|
104
115
|
except FileNotFoundError:
|
|
105
116
|
print(
|
|
106
117
|
"::error::npx is not on PATH, so the Firebase CLI cannot run. "
|
|
@@ -110,13 +121,27 @@ def _default_run(cmd: list[str]) -> int:
|
|
|
110
121
|
return 127
|
|
111
122
|
|
|
112
123
|
|
|
124
|
+
def _missing_app(ids: list[tuple[str, str]], pubspec: Path) -> None:
|
|
125
|
+
if ids:
|
|
126
|
+
print("::warning::FIREBASE_APP_ID names no Android app "
|
|
127
|
+
"(1:<project number>:android:<hash>); the Dart symbols were not uploaded to Crashlytics.")
|
|
128
|
+
elif uses_crashlytics(pubspec):
|
|
129
|
+
print("::warning::This app depends on firebase_crashlytics but no Firebase App ID reached this build, "
|
|
130
|
+
"so every Dart frame in Crashlytics will read as ***. Either the FIREBASE_APP_ID repository "
|
|
131
|
+
"variable is unset, or this repo's deploy.yml predates the firebase-app-id input — a one-off "
|
|
132
|
+
"`npx --yes gowalk-cicd` refreshes it.")
|
|
133
|
+
else:
|
|
134
|
+
print("FIREBASE_APP_ID unset; not uploading Dart symbols to Crashlytics.")
|
|
135
|
+
|
|
136
|
+
|
|
113
137
|
def main(argv: list[str] | None = None, environ: dict[str, str] | None = None, run=None) -> int:
|
|
114
138
|
environ = os.environ if environ is None else environ
|
|
115
139
|
run = _default_run if run is None else run
|
|
116
140
|
|
|
117
141
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
118
|
-
parser.add_argument("--symbols-dir",
|
|
142
|
+
parser.add_argument("--symbols-dir", type=Path,
|
|
119
143
|
help="the directory passed to flutter build --split-debug-info")
|
|
144
|
+
parser.add_argument("--prepare-only", action="store_true", help="verify the buildtools before compilation")
|
|
120
145
|
parser.add_argument("--pubspec", type=Path, default=Path("pubspec.yaml"),
|
|
121
146
|
help="pubspec.yaml, used only to decide whether to warn when unset")
|
|
122
147
|
parser.add_argument("--firebase-tools", default=FIREBASE_TOOLS,
|
|
@@ -132,28 +157,13 @@ def main(argv: list[str] | None = None, environ: dict[str, str] | None = None, r
|
|
|
132
157
|
|
|
133
158
|
app_id = _pick(ids, "android")
|
|
134
159
|
if app_id is None:
|
|
135
|
-
|
|
136
|
-
print(
|
|
137
|
-
"::warning::FIREBASE_APP_ID names no Android app "
|
|
138
|
-
"(1:<project number>:android:<hash>); the Dart symbols were not "
|
|
139
|
-
"uploaded to Crashlytics."
|
|
140
|
-
)
|
|
141
|
-
elif uses_crashlytics(args.pubspec):
|
|
142
|
-
# Two causes look identical from here: the variable is unset, or a
|
|
143
|
-
# deploy.yml that predates the firebase-app-id input never passed
|
|
144
|
-
# it. Name both — the action can only ever see the input.
|
|
145
|
-
print(
|
|
146
|
-
"::warning::This app depends on firebase_crashlytics but no "
|
|
147
|
-
"Firebase App ID reached this build, so every Dart frame in "
|
|
148
|
-
"Crashlytics will read as ***. Either the FIREBASE_APP_ID "
|
|
149
|
-
"repository variable is unset, or this repo's deploy.yml predates "
|
|
150
|
-
"the firebase-app-id input — a one-off `npx --yes gowalk-cicd` "
|
|
151
|
-
"refreshes it."
|
|
152
|
-
)
|
|
153
|
-
else:
|
|
154
|
-
print("FIREBASE_APP_ID unset; not uploading Dart symbols to Crashlytics.")
|
|
160
|
+
_missing_app(ids, args.pubspec)
|
|
155
161
|
return 0
|
|
156
162
|
|
|
163
|
+
if args.prepare_only:
|
|
164
|
+
return 0 if run([]) == 0 else 1
|
|
165
|
+
if args.symbols_dir is None:
|
|
166
|
+
parser.error("--symbols-dir is required unless --prepare-only is used")
|
|
157
167
|
symbols = sorted(args.symbols_dir.glob("*.symbols")) if args.symbols_dir.is_dir() else []
|
|
158
168
|
if not symbols:
|
|
159
169
|
print(f"::error::no Dart symbol files in {args.symbols_dir}; nothing to upload to Crashlytics")
|
|
@@ -167,7 +177,7 @@ def main(argv: list[str] | None = None, environ: dict[str, str] | None = None, r
|
|
|
167
177
|
print(
|
|
168
178
|
f"::error::Crashlytics symbol upload failed (exit {rc}). The symbols are "
|
|
169
179
|
"retained as the android-symbols artifact; after fixing the cause, upload "
|
|
170
|
-
|
|
180
|
+
"them through the configured proxy after diagnosing the failed phase."
|
|
171
181
|
)
|
|
172
182
|
return 1
|
|
173
183
|
print(f"Uploaded Dart symbols to Crashlytics app {app_id}.")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Real curl/TLS reads: recover partial GETs, enforce deadlines and preserve the pin."""
|
|
2
|
+
from contextlib import ExitStack, redirect_stdout
|
|
3
|
+
import hashlib
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
import unittest
|
|
11
|
+
from unittest import mock
|
|
12
|
+
|
|
13
|
+
import crashlytics_bootstrap as bootstrap
|
|
14
|
+
from crashlytics_bootstrap_test_support import DownloadProxy
|
|
15
|
+
from play_proxy_test_support import running
|
|
16
|
+
|
|
17
|
+
BODY = b"verified fixture dependency"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BootstrapTests(unittest.TestCase):
|
|
21
|
+
def setUp(self):
|
|
22
|
+
self.stack = ExitStack()
|
|
23
|
+
self.addCleanup(self.stack.close)
|
|
24
|
+
self.root = Path(self.stack.enter_context(tempfile.TemporaryDirectory()))
|
|
25
|
+
self.server, self.certificate = self.stack.enter_context(running())
|
|
26
|
+
self.server.RequestHandlerClass = DownloadProxy
|
|
27
|
+
self.server.expected_size = len(BODY)
|
|
28
|
+
self.server.responses = [(200, BODY, 0)]
|
|
29
|
+
proxy = f"http://fixture-user:fixture-pass@127.0.0.1:{self.server.server_port}"
|
|
30
|
+
self.stack.enter_context(mock.patch.dict(os.environ, {
|
|
31
|
+
"GOOGLE_STORE_PROXY_URL": proxy, "RUNNER_TOOL_CACHE": str(self.root),
|
|
32
|
+
"CURL_CA_BUNDLE": self.certificate, "NO_PROXY": "*", "no_proxy": "*"}))
|
|
33
|
+
for name, value in {"URL": "https://play.test/buildtools.jar", "SIZE": len(BODY),
|
|
34
|
+
"SHA256": hashlib.sha256(BODY).hexdigest(),
|
|
35
|
+
"ATTEMPT_SECONDS": 1, "TOTAL_SECONDS": 5}.items():
|
|
36
|
+
self.stack.enter_context(mock.patch.object(bootstrap, name, value))
|
|
37
|
+
|
|
38
|
+
def test_verified_cache_reuses_bytes_without_another_request(self):
|
|
39
|
+
first = bootstrap.prepare()
|
|
40
|
+
self.assertEqual(first.read_bytes(), BODY)
|
|
41
|
+
self.assertEqual(bootstrap.prepare(), first)
|
|
42
|
+
self.assertEqual(self.server.requests, [b"GET /buildtools.jar HTTP/1.1"])
|
|
43
|
+
self.assertEqual(self.server.authenticated, [True])
|
|
44
|
+
self.assertEqual(self.server.connections, ["play.test:443"])
|
|
45
|
+
|
|
46
|
+
def test_partial_transfer_recovers_only_the_get_and_cleans_partial_files(self):
|
|
47
|
+
self.server.responses = [(200, BODY[:3], 0), (200, BODY, 0)]
|
|
48
|
+
target = bootstrap.prepare()
|
|
49
|
+
self.assertEqual(target.read_bytes(), BODY)
|
|
50
|
+
self.assertEqual(self.server.requests, [b"GET /buildtools.jar HTTP/1.1"] * 2)
|
|
51
|
+
self.assertEqual(list(target.parent.iterdir()), [target])
|
|
52
|
+
|
|
53
|
+
def test_corrupt_cache_is_replaced_only_after_verification(self):
|
|
54
|
+
target = bootstrap.prepare()
|
|
55
|
+
target.write_bytes(b"corrupted")
|
|
56
|
+
self.server.responses = [(200, BODY, 0)]
|
|
57
|
+
self.assertEqual(bootstrap.prepare().read_bytes(), BODY)
|
|
58
|
+
self.assertEqual(len(self.server.requests), 2)
|
|
59
|
+
|
|
60
|
+
def test_wrong_checksum_refuses_without_retry_or_cache_admission(self):
|
|
61
|
+
self.server.responses = [(200, b"x" * len(BODY), 0)]
|
|
62
|
+
with self.assertRaises(bootstrap.BootstrapError) as caught:
|
|
63
|
+
bootstrap.prepare()
|
|
64
|
+
self.assertEqual(caught.exception.receipt["reason"], "checksum_or_size_mismatch")
|
|
65
|
+
self.assertEqual(len(self.server.requests), 1)
|
|
66
|
+
self.assertFalse(list(self.root.rglob("*.jar")))
|
|
67
|
+
|
|
68
|
+
def test_stalled_download_has_a_total_deadline_and_no_partial_cache(self):
|
|
69
|
+
self.server.responses = [(200, BODY, 0.5)] * 3
|
|
70
|
+
started = time.monotonic()
|
|
71
|
+
with mock.patch.object(bootstrap, "ATTEMPT_SECONDS", 0.1), \
|
|
72
|
+
mock.patch.object(bootstrap, "TOTAL_SECONDS", 0.3):
|
|
73
|
+
with self.assertRaises(bootstrap.BootstrapError) as caught:
|
|
74
|
+
bootstrap.prepare()
|
|
75
|
+
self.assertEqual(caught.exception.receipt["reason"], "download_timeout")
|
|
76
|
+
self.assertLess(time.monotonic() - started, 1.5)
|
|
77
|
+
self.assertFalse(list(self.root.rglob("*.jar")))
|
|
78
|
+
|
|
79
|
+
def test_http_refusal_is_terminal_and_receipt_never_contains_provider_body(self):
|
|
80
|
+
self.server.responses = [(403, b"fixture-pass private body", 0)]
|
|
81
|
+
with self.assertRaises(bootstrap.BootstrapError) as caught:
|
|
82
|
+
bootstrap.prepare()
|
|
83
|
+
output = io.StringIO()
|
|
84
|
+
with redirect_stdout(output):
|
|
85
|
+
caught.exception.annotate()
|
|
86
|
+
text = output.getvalue()
|
|
87
|
+
receipt = json.loads(text.split("::", 2)[2])
|
|
88
|
+
self.assertEqual(receipt["http_status"], 403)
|
|
89
|
+
self.assertEqual(receipt["attempts"], 1)
|
|
90
|
+
self.assertNotIn("fixture-pass", text)
|
|
91
|
+
self.assertNotIn("private body", text)
|
|
92
|
+
|
|
93
|
+
def test_tls_refusal_does_not_retry_or_fall_back_to_direct(self):
|
|
94
|
+
with mock.patch.dict(os.environ, {"CURL_CA_BUNDLE": ""}):
|
|
95
|
+
with self.assertRaises(bootstrap.BootstrapError) as caught:
|
|
96
|
+
bootstrap.prepare()
|
|
97
|
+
self.assertEqual(caught.exception.receipt["reason"], "tls_refused")
|
|
98
|
+
self.assertEqual(self.server.connections, ["play.test:443"])
|
|
99
|
+
self.assertEqual(self.server.requests, [])
|
|
100
|
+
|
|
101
|
+
def test_missing_account_pin_refuses_even_with_a_verified_cache(self):
|
|
102
|
+
bootstrap.prepare()
|
|
103
|
+
with mock.patch.dict(os.environ, {"GOOGLE_STORE_PROXY_URL": ""}):
|
|
104
|
+
with self.assertRaises(SystemExit):
|
|
105
|
+
bootstrap.prepare()
|
|
106
|
+
self.assertEqual(len(self.server.requests), 1)
|
|
107
|
+
|
|
108
|
+
def test_proxy_auth_refusal_is_not_retried_as_a_connection_failure(self):
|
|
109
|
+
self.server.connect_status = 407
|
|
110
|
+
with self.assertRaises(bootstrap.BootstrapError) as caught:
|
|
111
|
+
bootstrap.prepare()
|
|
112
|
+
self.assertEqual(caught.exception.receipt["http_status"], 407)
|
|
113
|
+
self.assertEqual(caught.exception.receipt["attempts"], 1)
|
|
114
|
+
self.assertEqual(self.server.connections, ["play.test:443"])
|
|
115
|
+
self.assertEqual(self.server.requests, [])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
unittest.main()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The prebuild check shares upload gating and preserves the release dependency."""
|
|
2
|
+
import io
|
|
3
|
+
from contextlib import redirect_stdout
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
import crashlytics_symbols as symbols
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PrebuildTests(unittest.TestCase):
|
|
12
|
+
def test_prepare_requires_no_symbols_but_propagates_bootstrap_failure(self):
|
|
13
|
+
for result in (0, 1):
|
|
14
|
+
with self.subTest(result=result), redirect_stdout(io.StringIO()):
|
|
15
|
+
run = mock.Mock(return_value=result)
|
|
16
|
+
status = symbols.main(["--prepare-only"], {"FIREBASE_APP_ID": "1:123:android:abc"}, run)
|
|
17
|
+
self.assertEqual(status, result)
|
|
18
|
+
run.assert_called_once_with([])
|
|
19
|
+
|
|
20
|
+
def test_no_android_app_skips_bootstrap_without_touching_dependencies(self):
|
|
21
|
+
for value in ("", "1:123:ios:abc"):
|
|
22
|
+
with self.subTest(value=value), redirect_stdout(io.StringIO()):
|
|
23
|
+
run = mock.Mock()
|
|
24
|
+
self.assertEqual(symbols.main(["--prepare-only"], {"FIREBASE_APP_ID": value}, run), 0)
|
|
25
|
+
run.assert_not_called()
|
|
26
|
+
|
|
27
|
+
def test_bootstrap_precedes_flutter_compilation_and_keeps_upload_after_retention(self):
|
|
28
|
+
action = (Path(__file__).resolve().parents[1] / "action.yml").read_text()
|
|
29
|
+
self.assertLess(action.index("- name: Prepare Crashlytics symbol tools"),
|
|
30
|
+
action.index("- name: Get Flutter dependencies"))
|
|
31
|
+
self.assertLess(action.index("- name: Retain Dart symbol files"),
|
|
32
|
+
action.index("- name: Upload Dart symbols to Crashlytics"))
|
|
33
|
+
prepare = action.split("- name: Prepare Crashlytics symbol tools", 1)[1].split("- name:", 1)[0]
|
|
34
|
+
self.assertIn("project_kind == 'flutter'", prepare)
|
|
35
|
+
self.assertIn("FIREBASE_APP_ID: ${{ inputs.firebase-app-id }}", prepare)
|
|
36
|
+
self.assertIn("--prepare-only", prepare)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
unittest.main()
|
|
@@ -22,6 +22,7 @@ from urllib.parse import urlsplit
|
|
|
22
22
|
proxy = urlsplit(os.environ['HTTPS_PROXY'])
|
|
23
23
|
assert proxy.hostname == '127.0.0.1' and proxy.username is None
|
|
24
24
|
assert os.environ['NO_PROXY'] == os.environ['no_proxy'] == ''
|
|
25
|
+
assert os.environ['CRASHLYTICS_LOCAL_JAR'] == '/verified-fixture.jar'
|
|
25
26
|
reply = urllib.request.urlopen('http://unresolvable.invalid/symbols', timeout=5).read()
|
|
26
27
|
assert reply == b'through pinned proxy'
|
|
27
28
|
Path(sys.argv[1]).write_text(json.dumps({'cwd': os.getcwd(), 'port': proxy.port}))
|
|
@@ -29,7 +30,8 @@ sys.exit(7)
|
|
|
29
30
|
"""
|
|
30
31
|
with tempfile.TemporaryDirectory() as directory, upstream() as (proxy, seen):
|
|
31
32
|
result_path = Path(directory) / "result.json"
|
|
32
|
-
with mock.patch.dict(os.environ, {"GOOGLE_STORE_PROXY_URL": proxy, "NO_PROXY": "*"})
|
|
33
|
+
with mock.patch.dict(os.environ, {"GOOGLE_STORE_PROXY_URL": proxy, "NO_PROXY": "*"}), \
|
|
34
|
+
mock.patch.object(symbols, "prepare_buildtools", return_value=Path("/verified-fixture.jar")):
|
|
33
35
|
rc = symbols._default_run([sys.executable, "-c", probe, str(result_path)])
|
|
34
36
|
result = json.loads(result_path.read_text())
|
|
35
37
|
self.assertEqual(rc, 7)
|
|
@@ -24,7 +24,7 @@ class BootstrapWiringTests(unittest.TestCase):
|
|
|
24
24
|
self.assertNotIn("continue-on-error", step)
|
|
25
25
|
self.assertEqual(checked, 3)
|
|
26
26
|
setup = yaml.safe_load((ROOT / "android-action/flutter-setup/action.yml").read_text())
|
|
27
|
-
guard, scope, resolve, sdk_cache, pub_cache, install = setup["runs"]["steps"]
|
|
27
|
+
guard, scope, resolve, sdk_cache, pub_cache, install, save = setup["runs"]["steps"]
|
|
28
28
|
self.assertIn('play_store_proxy.py" --bootstrap-env', guard["run"])
|
|
29
29
|
self.assertNotIn("continue-on-error", guard)
|
|
30
30
|
self.assertTrue(scope["continue-on-error"])
|
|
@@ -39,7 +39,7 @@ class BootstrapWiringTests(unittest.TestCase):
|
|
|
39
39
|
self.assertEqual(step["with"]["pub-cache"], "false")
|
|
40
40
|
self.assertNotIn("continue-on-error", step)
|
|
41
41
|
self.assertNotIn("BASH_ENV", setup.get("env", {}))
|
|
42
|
-
for step in (guard, scope, sdk_cache, pub_cache):
|
|
42
|
+
for step in (guard, scope, sdk_cache, pub_cache, save):
|
|
43
43
|
self.assertNotIn("BASH_ENV", step.get("env", {}))
|
|
44
44
|
self.assertEqual(guard["id"], "transport")
|
|
45
45
|
self.assertIn('>> "$GITHUB_OUTPUT"', guard["run"])
|
|
@@ -59,6 +59,25 @@ class BootstrapWiringTests(unittest.TestCase):
|
|
|
59
59
|
self.assertEqual(setup["outputs"]["CACHE-HIT"]["value"], "${{ steps.cache-flutter.outputs.cache-hit }}")
|
|
60
60
|
self.assertEqual(setup["outputs"]["PUB-CACHE-HIT"]["value"], "${{ steps.cache-pub.outputs.cache-hit }}")
|
|
61
61
|
|
|
62
|
+
def test_successful_sdk_is_saved_before_later_consumer_failure_can_discard_it(self):
|
|
63
|
+
setup = yaml.safe_load((ROOT / "android-action/flutter-setup/action.yml").read_text())
|
|
64
|
+
steps = setup["runs"]["steps"]
|
|
65
|
+
restore = next(step for step in steps if step.get("id") == "cache-flutter")
|
|
66
|
+
self.assertEqual(restore["uses"],
|
|
67
|
+
"actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25")
|
|
68
|
+
install = next(step for step in steps if step.get("id") == "install")
|
|
69
|
+
save = steps[steps.index(install) + 1]
|
|
70
|
+
self.assertEqual(save["uses"], "actions/cache/save@caa296126883cff596d87d8935842f9db880ef25")
|
|
71
|
+
self.assertEqual(save["with"], restore["with"])
|
|
72
|
+
self.assertTrue(save["continue-on-error"])
|
|
73
|
+
self.assertEqual(save["env"], {"NODE_OPTIONS": '--require "${{ steps.scope.outputs.preloader }}"'})
|
|
74
|
+
condition = " ".join(save["if"].split())
|
|
75
|
+
self.assertEqual(condition, "${{ inputs.cache == 'true' && inputs.dry-run != 'true' "
|
|
76
|
+
"&& steps.install.outcome == 'success' && steps.scope.outcome == 'success' "
|
|
77
|
+
"&& steps.cache-flutter.outputs.cache-hit != 'true' }}")
|
|
78
|
+
self.assertNotIn("continue-on-error", install)
|
|
79
|
+
self.assertNotIn("timeout-minutes", save) # Unsupported in composite action steps.
|
|
80
|
+
|
|
62
81
|
def test_pods_cache_keeps_identity_and_uses_the_same_optional_scope(self):
|
|
63
82
|
workflow = yaml.safe_load((ROOT / "templates/deploy.yml").read_text())
|
|
64
83
|
steps = workflow["jobs"]["ios"]["steps"]
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.94
|