gowalk-cicd 1.0.93 → 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
@@ -285,6 +285,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
285
285
  delivery request. This requires no human handoff. A green CI run alone is not
286
286
  evidence that the deferred symbols reached Firebase. Android retains its Dart
287
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.
288
294
  - Play readiness and AAB/APK version inventory share one prebuild edit. Retry OAuth
289
295
  transport, inventory reads and cleanup of that known edit; never replay an ambiguous
290
296
  `edits.insert`. Edit creation retries at most three times only for typed connection-timeout
package/README.md CHANGED
@@ -651,6 +651,14 @@ Crashlytics mapping/native-symbol uploads. Crashlytics Gradle 2.9.2+ supports pr
651
651
  environment variables; older versions must be updated before use. Dart symbols are
652
652
  retained as artifacts and uploaded through that transport before the bundle ships.
653
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
+
654
662
  Native iOS `upload-symbols` is refused because it does not use this environment
655
663
  transport. CI defers dedicated native upload phases in its ephemeral projects and
656
664
  retains `ios-crashlytics-symbols-<run>-<attempt>` with `ios-dsyms.zip` and a manifest
@@ -1 +1 @@
1
- 1.0.93
1
+ 1.0.94
@@ -1 +1 @@
1
- 1.0.93
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: >-
@@ -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)
@@ -1 +1 @@
1
- 1.0.93
1
+ 1.0.94
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.93",
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": {