gowalk-cicd 1.0.93 → 1.0.95
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 +12 -2
- package/README.md +22 -0
- package/action/.daemux-version +1 -1
- package/android-action/.daemux-version +1 -1
- package/android-action/action.yml +7 -0
- 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/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
- package/templates/deploy-backend.yml +2 -0
- package/templates/deploy.yml +10 -0
package/CLAUDE.md
CHANGED
|
@@ -52,7 +52,9 @@ vendoring concept are gone.
|
|
|
52
52
|
## Publishing
|
|
53
53
|
|
|
54
54
|
Auto-publish on every push to `main` (the entire repo IS the package). The
|
|
55
|
-
workflow (`.github/workflows/publish.yml`)
|
|
55
|
+
workflow (`.github/workflows/publish.yml`) serializes version selection through
|
|
56
|
+
publication; PR checks use separate cancellable groups. This prevents concurrent
|
|
57
|
+
main runs from choosing the same npm patch version. The workflow:
|
|
56
58
|
|
|
57
59
|
1. Runs `scripts/auto-version.mjs .` which queries npm for the current
|
|
58
60
|
published version of `gowalk-cicd` and bumps the patch. A first publish
|
|
@@ -196,7 +198,9 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
196
198
|
new app before GitHub registers `deploy-backend.yml` on the default branch.
|
|
197
199
|
Default-branch deploys continue through the separate backend workflow.
|
|
198
200
|
- Mobile pushes exclude durable `.factory/progress` records and the separately deployed
|
|
199
|
-
backend/server/Compose paths
|
|
201
|
+
backend/server/Compose paths, backend action, docs, marketing, account-recovery scripts,
|
|
202
|
+
tests and the standard validation/web workflows. Backend action changes trigger only backend
|
|
203
|
+
deployment; its package version marker alone does not. A mixed commit with any native/shared input still builds;
|
|
200
204
|
explicit workflow dispatch remains available. Keep backend deployment's own path filters
|
|
201
205
|
intact, and do not broadly exclude assets, root build settings or unknown app layouts.
|
|
202
206
|
- Domain-backed backend deploys fail closed on certificate issuance and the
|
|
@@ -285,6 +289,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
285
289
|
delivery request. This requires no human handoff. A green CI run alone is not
|
|
286
290
|
evidence that the deferred symbols reached Firebase. Android retains its Dart
|
|
287
291
|
symbols and uploads them before the store bundle, using the configured proxy.
|
|
292
|
+
- Crashlytics buildtools bootstrap runs before Flutter compilation. `crashlytics_bootstrap.py`
|
|
293
|
+
pins the official JAR version, byte size and SHA-256, admits only verified dependency-cache
|
|
294
|
+
bytes, and bounds proxied GET recovery to three attempts within three minutes. Java receives
|
|
295
|
+
that verified file through `CRASHLYTICS_LOCAL_JAR`; changing the Firebase CLI pin must check
|
|
296
|
+
its JAR version/override contract. TLS, authorization and checksum errors never retry.
|
|
297
|
+
`firebase_symbols_bootstrap_failed` carries fixed typed evidence, never private curl output.
|
|
288
298
|
- Play readiness and AAB/APK version inventory share one prebuild edit. Retry OAuth
|
|
289
299
|
transport, inventory reads and cleanup of that known edit; never replay an ambiguous
|
|
290
300
|
`edits.insert`. Edit creation retries at most three times only for typed connection-timeout
|
package/README.md
CHANGED
|
@@ -26,6 +26,20 @@ are unaffected.
|
|
|
26
26
|
|
|
27
27
|
Re-run the same command anytime to pull the latest version.
|
|
28
28
|
|
|
29
|
+
Mobile releases run for changes to the app or its release inputs. Documentation,
|
|
30
|
+
marketing, `tools/recovery/`, tests, `.factory/progress/`, backend-only changes and
|
|
31
|
+
the standard validation/web workflows do not upload another mobile binary. Mixed
|
|
32
|
+
commits still release when they change any app input, including embedded editors,
|
|
33
|
+
assets and build scripts. Backend action changes deploy the backend; updating only
|
|
34
|
+
its `.daemux-version` marker does not. Keep runtime assets outside these excluded
|
|
35
|
+
directories, or customize the filters if your build deliberately includes them.
|
|
36
|
+
|
|
37
|
+
These filters take effect after updating and committing the consumer workflow
|
|
38
|
+
files with this installer; CI self-update refreshes action directories only.
|
|
39
|
+
Follow an existing push-triggered release before dispatching another one. Manual
|
|
40
|
+
dispatch remains available after an actual release-input or credential repair;
|
|
41
|
+
it bypasses push path filters and can repeat a successful release.
|
|
42
|
+
|
|
29
43
|
Flutter's optional SDK and pub cache restores have a one-minute timeout per
|
|
30
44
|
download segment. A slow segment becomes a cache miss and normal SDK setup
|
|
31
45
|
continues through the assigned Google proxy; SDK setup errors still fail the
|
|
@@ -651,6 +665,14 @@ Crashlytics mapping/native-symbol uploads. Crashlytics Gradle 2.9.2+ supports pr
|
|
|
651
665
|
environment variables; older versions must be updated before use. Dart symbols are
|
|
652
666
|
retained as artifacts and uploaded through that transport before the bundle ships.
|
|
653
667
|
|
|
668
|
+
Before Flutter compilation, the action downloads Crashlytics buildtools 3.0.3 through
|
|
669
|
+
the assigned account proxy and verifies its exact size and SHA-256. Verified bytes are
|
|
670
|
+
reused from the runner dependency cache and supplied through the Firebase CLI's
|
|
671
|
+
`CRASHLYTICS_LOCAL_JAR` override. GET recovery allows three attempts within three minutes;
|
|
672
|
+
TLS, authorization and checksum refusals stop immediately. A bootstrap failure emits
|
|
673
|
+
`firebase_symbols_bootstrap_failed` with schema `gowalk-cicd/firebase-symbols-bootstrap.v1`,
|
|
674
|
+
`phase`, `reason`, `attempts`, `exit_code` and `http_status`. Raw curl output is never published.
|
|
675
|
+
|
|
654
676
|
Native iOS `upload-symbols` is refused because it does not use this environment
|
|
655
677
|
transport. CI defers dedicated native upload phases in its ephemeral projects and
|
|
656
678
|
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.95
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.95
|
|
@@ -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: >-
|
|
@@ -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)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.95
|
package/package.json
CHANGED
package/templates/deploy.yml
CHANGED
|
@@ -9,12 +9,22 @@ on:
|
|
|
9
9
|
# Durable task records and independently deployed backend sources do not
|
|
10
10
|
# change a native binary. Mixed commits still run when any mobile input changes.
|
|
11
11
|
- '.factory/progress/**'
|
|
12
|
+
- 'docs/**'
|
|
13
|
+
- 'marketing/**'
|
|
14
|
+
- 'tools/recovery/**'
|
|
15
|
+
- 'test/**'
|
|
16
|
+
- 'tests/**'
|
|
17
|
+
- 'integration_test/**'
|
|
12
18
|
- 'backend/**'
|
|
13
19
|
- 'server/**'
|
|
14
20
|
- 'docker-compose.yml'
|
|
15
21
|
- '.github/workflows/deploy-backend.yml'
|
|
22
|
+
- '.github/workflows/deploy-web.yml'
|
|
23
|
+
- '.github/workflows/flutter-ci.yml'
|
|
24
|
+
- '.github/workflows/ios-ci.yml'
|
|
16
25
|
- '.github/actions/swift-app/**'
|
|
17
26
|
- '.github/actions/android-app/**'
|
|
27
|
+
- '.github/actions/backend-app/**'
|
|
18
28
|
workflow_dispatch:
|
|
19
29
|
|
|
20
30
|
permissions:
|