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 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 and the successful restore/save behavior.
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
@@ -1 +1 @@
1
- 1.0.92
1
+ 1.0.94
@@ -1 +1 @@
1
- 1.0.92
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", required=True, type=Path,
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
- if ids:
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
- f"them through the configured proxy after restoring its transport."
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.92
1
+ 1.0.94
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.92",
3
+ "version": "1.0.94",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {