gowalk-cicd 1.0.47 → 1.0.48

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
@@ -149,6 +149,31 @@ node /path/to/gowalk-cicd/bin/cli.mjs
149
149
  must not discard the symbols of a build that is already live. Symbol
150
150
  artifacts carry no `retention-days` so the repository's setting governs and
151
151
  can be raised to cover a release's life.
152
+ - Crashlytics symbol upload is gated on the `firebase-app-id` action input,
153
+ which `deploy.yml` feeds from the `FIREBASE_APP_ID` repository variable on
154
+ both actions. Actions never read `vars.*` themselves — an unavailable
155
+ context inside a composite action invalidates every consumer's workflow —
156
+ so the variable crosses the workflow/action boundary as an input and the
157
+ feature reaches consumers only after the one-off `npx gowalk-cicd` that
158
+ refreshes `deploy.yml`. The variable holds one id per platform
159
+ (`1:<n>:android:<hash>`, `1:<n>:ios:<hash>`, comma/whitespace-separated);
160
+ each action picks its own and fails on anything that is not an id. Android
161
+ runs `firebase crashlytics:symbols:upload` (the only Android path Firebase
162
+ documents; needs Node + Java, no credentials) on the `--split-debug-info`
163
+ directory — `android-action/scripts/crashlytics_symbols.py`. iOS runs the
164
+ FirebaseCrashlytics pod's `upload-symbols` on the archive's dSYMs —
165
+ `action/scripts/crashlytics_dsyms.py` — because Flutter's `.symbols` are
166
+ ELF debug info the Firebase CLI's generators reject for Apple targets, and
167
+ `App.framework.dSYM` is the documented Apple path. Both run before the
168
+ store upload and fail closed — except an iOS id on an app with no
169
+ FirebaseCrashlytics `upload-symbols` tool, which is a warn-and-skip: an app
170
+ that does not embed the SDK has nothing to symbolicate. Logic belongs in
171
+ those scripts, where it is unit-tested, not inline in YAML. Supporting
172
+ invariants: the workflow's disk-space step must never remove
173
+ `/usr/local/lib/node_modules` (npm and npx live there); `firebase-tools` is
174
+ pinned exactly because the Android job holds the keystore and the Play
175
+ service account; the CLI runs from a temp cwd so its `.crashlytics/` debris
176
+ never lands in the checkout.
152
177
  - The JDK is chosen by `scripts/select_jdk.py`, called from the workflow before
153
178
  `setup-java`. The `JAVA_VERSION` repo variable always wins; otherwise Flutter
154
179
  gets 17 and native Gradle gets 21, except that a positively-detected Kotlin
package/README.md CHANGED
@@ -254,9 +254,11 @@ zip I/O error: No space left on device
254
254
  ```
255
255
 
256
256
  The Android job removes the preinstalled toolchains a Flutter build never uses
257
- (.NET, the Android NDK, GHC, PowerShell, Swift, Chromium, global node modules)
258
- and prunes Docker images, reclaiming roughly 25 GB in a few seconds. It prints
259
- `df -h /` before and after. Linux only; skipped in Bitrise mode.
257
+ (.NET, the Android NDK, GHC, PowerShell, Swift, Chromium) and prunes Docker
258
+ images, reclaiming roughly 25 GB in a few seconds. It prints `df -h /` before
259
+ and after. Linux only; skipped in Bitrise mode. npm survives on purpose: the
260
+ [Crashlytics symbol upload](#crashlytics-symbol-upload-firebase_app_id) runs
261
+ the Firebase CLI through `npx`.
260
262
 
261
263
  ### Private git dependencies (Flutter)
262
264
 
@@ -493,14 +495,53 @@ flutter symbolize -i crash.txt -d app.android-arm64.symbols
493
495
  ```
494
496
 
495
497
  A crash reporter never sees the artifact, and from the first obfuscated build
496
- every Dart frame it shows is `***` until it has the symbols. If the app uses
497
- Crashlytics, Sentry or similar, upload the same directory to that service — for
498
- Crashlytics, `firebase crashlytics:symbols:upload --app=<FIREBASE_APP_ID>
499
- <dir>` with Firebase CLI 11.9 or newer, once per platform with that platform's
500
- app id (`mobilesdk_app_id` in `google-services.json`, `GOOGLE_APP_ID` in
501
- `GoogleService-Info.plist`). This package does not do that upload for you: the
502
- existing Crashlytics run-script phase on iOS uploads only the native dSYMs, and
503
- App Store Connect's own symbol upload covers only those as well.
498
+ every Dart frame it shows is `***` until it has the symbols. For Crashlytics
499
+ the workflow uploads them itself once the repository variable
500
+ **`FIREBASE_APP_ID`** is set — see below. Sentry or others: upload the same
501
+ directory to that service from your own pipeline.
502
+
503
+ #### Crashlytics symbol upload (`FIREBASE_APP_ID`)
504
+
505
+ Set the repository variable `FIREBASE_APP_ID` to the app's Firebase App ID —
506
+ `1:<project number>:<platform>:<hash>`. An app that ships both platforms has
507
+ two ids (they differ in the third segment); list both, separated by a comma:
508
+
509
+ ```
510
+ 1:123456789012:android:0a1b2c3d4e5f6a7b,1:123456789012:ios:7b6a5f4e3d2c1b0a
511
+ ```
512
+
513
+ Find them in `lib/firebase_options.dart` (`appId`), in
514
+ `android/app/google-services.json` (`mobilesdk_app_id`) and
515
+ `ios/Runner/GoogleService-Info.plist` (`GOOGLE_APP_ID`), or under *Project
516
+ settings → Your apps* in the Firebase console. No credentials are needed:
517
+ both upload tools authenticate by app id alone.
518
+
519
+ The variable reaches the actions through `deploy.yml`, which
520
+ [auto-update never touches](#deployyml-is-not-auto-updated) — an existing repo
521
+ must run `npx --yes gowalk-cicd` once before setting it does anything.
522
+
523
+ | | Android | iOS |
524
+ |---|---|---|
525
+ | What is uploaded | the `--split-debug-info` directory, with the command Firebase documents for Flutter: `firebase crashlytics:symbols:upload --app=<android id> <dir>` (Firebase CLI through `npx`, major-pinned) | every dSYM in the archive — `App.framework.dSYM` carries the Dart frames — with the `upload-symbols` tool from the FirebaseCrashlytics pod: `upload-symbols -ai <ios id> -p ios -- <archive>/dSYMs` |
526
+ | When | in the Android action, right after the build and before the bundle goes to Play | in the iOS action, right after the IPA export and before the TestFlight upload |
527
+ | If it fails | the deploy fails; nothing ships with crashes nobody can read. The symbols are still in the `android-symbols-*` artifact for a manual upload | same — the failure lands before the TestFlight upload, so nothing shipped; fix the cause and re-run the deploy |
528
+ | Variable unset | a `::warning::` when the app depends on `firebase_crashlytics` (every Dart frame will be `***`) | nothing — the run-script phase that `flutterfire configure` adds already uploads `App.framework.dSYM`, and the workflow installs the flutterfire CLI it needs |
529
+ | Variable set for the other platform only | a `::warning::`, no upload | a `::notice::`, no upload |
530
+
531
+ Why the two platforms differ: Flutter's `.symbols` files are ELF debug info,
532
+ which the Firebase CLI's symbol generators accept for Android and reject for
533
+ Apple targets; Firebase documents dSYMs as the Apple path, and Flutter 3.12+
534
+ puts the Dart debug info into `App.framework.dSYM`. On iOS the explicit
535
+ upload is a belt-and-braces over the run-script phase — it also covers the
536
+ native `Runner.app.dSYM` and every plugin framework — so a phase that is
537
+ missing or failing can never leave a build unreadable.
538
+
539
+ A value that is not a Firebase App ID fails the deploy rather than being
540
+ skipped: a typo must not become a fleet that silently stops uploading symbols.
541
+ An iOS id on an app that does not embed Crashlytics (no `upload-symbols` tool
542
+ anywhere under the project) is a warning, not a failure — an app without the
543
+ SDK has no Crashlytics crashes to symbolicate. Android apps delivered through
544
+ Bitrise are built by Bitrise and are not covered.
504
545
 
505
546
  Obfuscation renames identifiers, so code that depends on their spelling breaks
506
547
  at runtime, not at build time. Do not rely on `runtimeType.toString()`,
@@ -849,6 +890,9 @@ Changes that need that manual run:
849
890
  - **Flutter iOS obfuscation** — the `--obfuscate --split-debug-info` flags on
850
891
  the iOS `flutter build ios --config-only` call and the step that retains
851
892
  `ios-symbols-*`. See [Dart obfuscation](#dart-obfuscation-flutter-apps).
893
+ - **Crashlytics symbol upload** — `firebase-app-id: ${{ vars.FIREBASE_APP_ID }}`
894
+ passed to both actions. Until it is, setting the variable does nothing.
895
+ See [Crashlytics symbol upload](#crashlytics-symbol-upload-firebase_app_id).
852
896
 
853
897
  ### Opt out
854
898
 
@@ -1 +1 @@
1
- 1.0.47
1
+ 1.0.48
package/action/action.yml CHANGED
@@ -121,6 +121,17 @@ inputs:
121
121
  description: "GitHub Models model id for AI metadata generation."
122
122
  required: false
123
123
  default: "openai/gpt-4o"
124
+ firebase-app-id:
125
+ description: >-
126
+ Firebase App ID(s) for the Crashlytics dSYM upload — the
127
+ `1:<project number>:ios:<hash>` value (GOOGLE_APP_ID in
128
+ GoogleService-Info.plist, or Project settings → Your apps in the Firebase
129
+ console). Several ids may be listed, separated by commas or whitespace;
130
+ the iOS one is used. Empty leaves dSYM upload to the app's own
131
+ Crashlytics run-script phase. The package workflow passes the
132
+ FIREBASE_APP_ID repository variable.
133
+ required: false
134
+ default: ""
124
135
  # DESIGN DECISION: marketing-version-auto-bump trades unattended-CI
125
136
  # smoothness against silent-semver-decisions visibility. Default
126
137
  # 'rollover' lets CI keep the build moving when ASC's combined floor
@@ -705,6 +716,29 @@ runs:
705
716
  -exportPath "$RUNNER_TEMP/export"
706
717
  ls -la "$RUNNER_TEMP/export"
707
718
 
719
+ # Crashlytics symbolicates from the dSYMs in the archive — for a Flutter
720
+ # app that includes App.framework.dSYM, where the Dart frames of an
721
+ # obfuscated build come from, and the only Apple-platform path Firebase
722
+ # documents for such builds. The run-script phase `flutterfire configure`
723
+ # adds already uploads that dSYM during the archive; with firebase-app-id
724
+ # set (deploy.yml passes the FIREBASE_APP_ID repository variable) every
725
+ # dSYM in the archive is uploaded here as well, with the upload-symbols
726
+ # tool from the FirebaseCrashlytics pod, so a missing or broken phase
727
+ # cannot leave a build unreadable. Before the TestFlight upload on
728
+ # purpose: a failed upload fails the deploy instead of shipping crashes
729
+ # nobody can read. Logic and its tests live in scripts/crashlytics_dsyms.py.
730
+ - name: Upload dSYMs to Crashlytics
731
+ if: ${{ inputs.archive == 'true' && inputs.upload == 'true' && inputs.firebase-app-id != '' }}
732
+ shell: bash
733
+ env:
734
+ FIREBASE_APP_ID: ${{ inputs.firebase-app-id }}
735
+ PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
736
+ WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
737
+ run: |
738
+ python3 "${{ github.action_path }}/scripts/crashlytics_dsyms.py" \
739
+ --archive "$RUNNER_TEMP/app.xcarchive" \
740
+ --search-root "$(dirname "${WORKSPACE:-$PROJECT}")"
741
+
708
742
  - name: Upload to TestFlight
709
743
  if: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
710
744
  shell: bash
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ """Upload every dSYM in the archive to Firebase Crashlytics.
3
+
4
+ Crashlytics symbolicates Apple crashes from dSYMs. For a Flutter app that
5
+ includes App.framework.dSYM, which is where the Dart frames come from — and
6
+ the only Apple-platform path Firebase documents for Flutter's obfuscated,
7
+ --split-debug-info builds (the .symbols files those flags write are ELF
8
+ debug info that the Firebase CLI's symbol generators reject). The run-script
9
+ phase `flutterfire configure` adds already uploads that one dSYM during the
10
+ archive; this script uploads all of them (App.framework, the app binary,
11
+ plugin frameworks) with the `upload-symbols` tool that ships in the
12
+ FirebaseCrashlytics pod, so a missing or broken phase cannot leave a build
13
+ unreadable, and native frames get their symbols too.
14
+
15
+ upload-symbols -ai <FIREBASE_APP_ID> -p ios -- <archive>/dSYMs
16
+
17
+ No credentials: the tool authenticates by app id alone.
18
+
19
+ Gating: FIREBASE_APP_ID holds the app's Firebase App ID(s),
20
+ `1:<project number>:<platform>:<hash>`, comma- or whitespace-separated when an
21
+ app ships both platforms; the iOS one is used and its absence is a notice, not
22
+ a failure (the run-script phase is the documented default). With an iOS id
23
+ present, a missing tool or a failed upload fails the step — the action runs it
24
+ before the TestFlight upload, so a deploy never goes out with crashes nobody
25
+ can read.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import os
32
+ import re
33
+ import subprocess
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ APP_ID_RE = re.compile(r"^(\d+):(\d+):(android|ios|web):([0-9A-Fa-f]+)$")
38
+
39
+
40
+ class AppIdError(ValueError):
41
+ """FIREBASE_APP_ID contains something that is not a Firebase App ID."""
42
+
43
+
44
+ def parse_app_ids(raw: str) -> list[tuple[str, str]]:
45
+ """Split FIREBASE_APP_ID into (platform, app id) pairs; garbage is an error."""
46
+ ids: list[tuple[str, str]] = []
47
+ for token in re.split(r"[,\s]+", raw.strip()):
48
+ if not token:
49
+ continue
50
+ match = APP_ID_RE.match(token)
51
+ if match is None:
52
+ raise AppIdError(
53
+ f"{token!r} is not a Firebase App ID "
54
+ "(expected 1:<project number>:<android|ios|web>:<hash>)"
55
+ )
56
+ ids.append((match.group(3), token))
57
+ return ids
58
+
59
+
60
+ def _pick(ids: list[tuple[str, str]], platform: str) -> str | None:
61
+ return next((app_id for found_platform, app_id in ids if found_platform == platform), None)
62
+
63
+
64
+ def select_app_id(raw: str, platform: str) -> str | None:
65
+ return _pick(parse_app_ids(raw), platform)
66
+
67
+
68
+ def find_upload_symbols(search_root: Path, home: Path | None = None) -> Path | None:
69
+ """Locate the FirebaseCrashlytics `upload-symbols` tool.
70
+
71
+ CocoaPods puts it at <ios dir>/Pods/FirebaseCrashlytics/upload-symbols; a
72
+ Swift Package Manager checkout keeps it under Xcode's DerivedData. Only a
73
+ tool whose parent directory names Crashlytics counts — the name alone is
74
+ too generic.
75
+ """
76
+ direct = search_root / "Pods" / "FirebaseCrashlytics" / "upload-symbols"
77
+ if direct.is_file():
78
+ return direct
79
+ if search_root.is_dir():
80
+ # A project at the repo root makes search_root the whole checkout, so
81
+ # prune the trees that are big and can never hold the tool.
82
+ prune = {".git", "build", ".dart_tool", "node_modules"}
83
+ hits: list[Path] = []
84
+ for dirpath, dirnames, filenames in os.walk(search_root):
85
+ dirnames[:] = [d for d in dirnames if d not in prune]
86
+ if "upload-symbols" in filenames and Path(dirpath).name in ("FirebaseCrashlytics", "Crashlytics"):
87
+ candidate = Path(dirpath) / "upload-symbols"
88
+ if candidate.is_file():
89
+ hits.append(candidate)
90
+ if hits:
91
+ return sorted(hits)[0]
92
+ home = Path.home() if home is None else home
93
+ derived = home / "Library" / "Developer" / "Xcode" / "DerivedData"
94
+ for candidate in sorted(
95
+ derived.glob("*/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols")
96
+ ):
97
+ if candidate.is_file():
98
+ return candidate
99
+ return None
100
+
101
+
102
+ def upload_command(tool: Path, app_id: str, dsyms_dir: Path) -> list[str]:
103
+ return [str(tool), "-ai", app_id, "-p", "ios", "--", str(dsyms_dir)]
104
+
105
+
106
+ def _default_run(cmd: list[str]) -> int:
107
+ try:
108
+ return subprocess.run(cmd, check=False).returncode
109
+ except OSError as exc:
110
+ print(f"::error::could not run {cmd[0]}: {exc}")
111
+ return 126
112
+
113
+
114
+ def main(argv: list[str] | None = None, environ: dict[str, str] | None = None, run=None,
115
+ home: Path | None = None) -> int:
116
+ environ = os.environ if environ is None else environ
117
+ run = _default_run if run is None else run
118
+
119
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
120
+ parser.add_argument("--archive", required=True, type=Path, help="the .xcarchive that was exported")
121
+ parser.add_argument("--search-root", required=True, type=Path,
122
+ help="directory holding the Xcode project/workspace (where Pods/ lives)")
123
+ args = parser.parse_args(argv)
124
+
125
+ raw = environ.get("FIREBASE_APP_ID", "")
126
+ try:
127
+ ids = parse_app_ids(raw)
128
+ except AppIdError as exc:
129
+ print(f"::error::FIREBASE_APP_ID: {exc}")
130
+ return 1
131
+
132
+ app_id = _pick(ids, "ios")
133
+ if app_id is None:
134
+ if ids:
135
+ print(
136
+ "::notice::FIREBASE_APP_ID names no iOS app (1:<project number>:ios:<hash>); "
137
+ "relying on the Crashlytics run-script phase for dSYM upload."
138
+ )
139
+ else:
140
+ print("FIREBASE_APP_ID unset; relying on the Crashlytics run-script phase for dSYM upload.")
141
+ return 0
142
+
143
+ dsyms_dir = args.archive / "dSYMs"
144
+ dsyms = sorted(dsyms_dir.glob("*.dSYM")) if dsyms_dir.is_dir() else []
145
+ if not dsyms:
146
+ print(f"::error::no dSYMs in {dsyms_dir}; nothing to upload to Crashlytics")
147
+ return 1
148
+
149
+ tool = find_upload_symbols(args.search_root, home)
150
+ if tool is None:
151
+ # No tool means the app does not link the Crashlytics SDK, so there are
152
+ # no Crashlytics crash reports to symbolicate — skipping loses nothing,
153
+ # while failing would block every release of a non-Crashlytics app
154
+ # whose FIREBASE_APP_ID happens to carry an iOS id.
155
+ print(
156
+ "::warning::FIREBASE_APP_ID names an iOS app but no FirebaseCrashlytics "
157
+ f"upload-symbols tool exists under {args.search_root} or Xcode's "
158
+ "DerivedData, so the app does not appear to embed Crashlytics; "
159
+ "skipping the dSYM upload. (`flutterfire configure` adds the "
160
+ "dependency and the run-script phase.)"
161
+ )
162
+ return 0
163
+
164
+ cmd = upload_command(tool, app_id, dsyms_dir)
165
+ print(f"Uploading {len(dsyms)} dSYM(s) to Crashlytics app {app_id}:")
166
+ for dsym in dsyms:
167
+ print(f" {dsym.name}")
168
+ print(" " + " ".join(cmd))
169
+ rc = run(cmd)
170
+ if rc != 0:
171
+ print(
172
+ f"::error::Crashlytics dSYM upload failed (exit {rc}). This step runs "
173
+ "before the TestFlight upload, so nothing shipped — fix the cause and "
174
+ "re-run the deploy."
175
+ )
176
+ return 1
177
+ print(f"Uploaded dSYMs to Crashlytics app {app_id}.")
178
+ return 0
179
+
180
+
181
+ if __name__ == "__main__":
182
+ sys.exit(main())
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env python3
2
+ """Crashlytics dSYM upload: gating, tool discovery and failure semantics.
3
+
4
+ The upload itself cannot run here (it needs the FirebaseCrashlytics pod and
5
+ the network), so the command is captured through the injectable runner. The
6
+ wiring into action.yml is pinned at the bottom.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import re
13
+ import sys
14
+ import tempfile
15
+ import unittest
16
+ from contextlib import redirect_stdout
17
+ from pathlib import Path
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+
21
+ import crashlytics_dsyms as cd # noqa: E402
22
+
23
+ ANDROID = "1:123456789012:android:0123456789abcdef"
24
+ IOS = "1:123456789012:ios:fedcba9876543210"
25
+
26
+
27
+ class ParseAppIdsTest(unittest.TestCase):
28
+ def test_parse_and_select(self) -> None:
29
+ self.assertEqual(cd.parse_app_ids(f"{ANDROID}, {IOS}"), [("android", ANDROID), ("ios", IOS)])
30
+ self.assertEqual(cd.select_app_id(f"{ANDROID}\n{IOS}", "ios"), IOS)
31
+ self.assertIsNone(cd.select_app_id(ANDROID, "ios"))
32
+ self.assertEqual(cd.parse_app_ids(""), [])
33
+
34
+ def test_garbage_is_an_error(self) -> None:
35
+ with self.assertRaises(cd.AppIdError):
36
+ cd.parse_app_ids("1:123:tvos:abc")
37
+
38
+
39
+ class FindUploadSymbolsTest(unittest.TestCase):
40
+ def setUp(self) -> None:
41
+ self.tmp = tempfile.TemporaryDirectory()
42
+ self.root = Path(self.tmp.name)
43
+ self.ios = self.root / "ios"
44
+ self.ios.mkdir()
45
+ self.home = self.root / "home"
46
+ self.home.mkdir()
47
+
48
+ def tearDown(self) -> None:
49
+ self.tmp.cleanup()
50
+
51
+ def touch(self, path: Path) -> Path:
52
+ path.parent.mkdir(parents=True, exist_ok=True)
53
+ path.write_text("#!/bin/sh\n")
54
+ return path
55
+
56
+ def test_cocoapods_location_wins(self) -> None:
57
+ tool = self.touch(self.ios / "Pods" / "FirebaseCrashlytics" / "upload-symbols")
58
+ self.assertEqual(cd.find_upload_symbols(self.ios, self.home), tool)
59
+
60
+ def test_a_crashlytics_tool_anywhere_under_the_root_is_found(self) -> None:
61
+ tool = self.touch(self.ios / "Vendor" / "Crashlytics" / "upload-symbols")
62
+ self.assertEqual(cd.find_upload_symbols(self.ios, self.home), tool)
63
+
64
+ def test_an_unrelated_upload_symbols_is_ignored(self) -> None:
65
+ self.touch(self.ios / "Pods" / "Sentry" / "upload-symbols")
66
+ self.assertIsNone(cd.find_upload_symbols(self.ios, self.home))
67
+
68
+ def test_swiftpm_checkout_is_found(self) -> None:
69
+ tool = self.touch(
70
+ self.home / "Library" / "Developer" / "Xcode" / "DerivedData" / "Runner-abc"
71
+ / "SourcePackages" / "checkouts" / "firebase-ios-sdk" / "Crashlytics" / "upload-symbols"
72
+ )
73
+ self.assertEqual(cd.find_upload_symbols(self.ios, self.home), tool)
74
+
75
+ def test_missing_root_is_not_an_exception(self) -> None:
76
+ self.assertIsNone(cd.find_upload_symbols(self.root / "nope", self.home))
77
+
78
+ def test_pruned_trees_are_never_searched(self) -> None:
79
+ # A project at the repo root makes the search root the whole checkout.
80
+ for tree in (".git", "build", ".dart_tool", "node_modules"):
81
+ self.touch(self.ios / tree / "FirebaseCrashlytics" / "upload-symbols")
82
+ self.assertIsNone(cd.find_upload_symbols(self.ios, self.home))
83
+
84
+
85
+ class MainTest(unittest.TestCase):
86
+ def setUp(self) -> None:
87
+ self.tmp = tempfile.TemporaryDirectory()
88
+ self.root = Path(self.tmp.name)
89
+ self.ios = self.root / "ios"
90
+ self.tool = self.ios / "Pods" / "FirebaseCrashlytics" / "upload-symbols"
91
+ self.tool.parent.mkdir(parents=True)
92
+ self.tool.write_text("#!/bin/sh\n")
93
+ self.archive = self.root / "app.xcarchive"
94
+ self.dsyms = self.archive / "dSYMs"
95
+ for name in ("Runner.app.dSYM", "App.framework.dSYM", "Flutter.framework.dSYM"):
96
+ (self.dsyms / name / "Contents").mkdir(parents=True)
97
+ self.home = self.root / "home"
98
+ self.home.mkdir()
99
+ self.calls: list[list[str]] = []
100
+
101
+ def tearDown(self) -> None:
102
+ self.tmp.cleanup()
103
+
104
+ def run_main(self, env: dict[str, str], rc: int = 0, archive: Path | None = None,
105
+ search_root: Path | None = None) -> tuple[int, str]:
106
+ def runner(cmd: list[str]) -> int:
107
+ self.calls.append(cmd)
108
+ return rc
109
+
110
+ out = io.StringIO()
111
+ with redirect_stdout(out):
112
+ code = cd.main(
113
+ ["--archive", str(archive or self.archive), "--search-root", str(search_root or self.ios)],
114
+ environ=env, run=runner, home=self.home,
115
+ )
116
+ return code, out.getvalue()
117
+
118
+ def test_unset_is_a_quiet_no_op(self) -> None:
119
+ code, out = self.run_main({})
120
+ self.assertEqual(code, 0)
121
+ self.assertEqual(self.calls, [])
122
+ self.assertNotIn("::warning::", out)
123
+ self.assertNotIn("::error::", out)
124
+
125
+ def test_ids_without_an_ios_one_are_a_notice_not_a_failure(self) -> None:
126
+ code, out = self.run_main({"FIREBASE_APP_ID": ANDROID})
127
+ self.assertEqual(code, 0)
128
+ self.assertEqual(self.calls, [])
129
+ self.assertIn("::notice::", out)
130
+
131
+ def test_malformed_id_fails(self) -> None:
132
+ code, out = self.run_main({"FIREBASE_APP_ID": "nope"})
133
+ self.assertEqual(code, 1)
134
+ self.assertIn("::error::", out)
135
+
136
+ def test_ios_id_uploads_the_whole_dsyms_directory(self) -> None:
137
+ code, out = self.run_main({"FIREBASE_APP_ID": f"{ANDROID},{IOS}"})
138
+ self.assertEqual(code, 0)
139
+ self.assertEqual(self.calls, [[str(self.tool), "-ai", IOS, "-p", "ios", "--", str(self.dsyms)]])
140
+ self.assertIn("App.framework.dSYM", out)
141
+ self.assertIn("Uploaded", out)
142
+
143
+ def test_missing_tool_warns_and_skips(self) -> None:
144
+ # No tool => the app does not link the Crashlytics SDK, so there is
145
+ # nothing to symbolicate; blocking the release would be pure harm.
146
+ self.tool.unlink()
147
+ code, out = self.run_main({"FIREBASE_APP_ID": IOS})
148
+ self.assertEqual(code, 0)
149
+ self.assertEqual(self.calls, [])
150
+ self.assertIn("::warning::", out)
151
+ self.assertNotIn("::error::", out)
152
+ self.assertIn("upload-symbols", out)
153
+
154
+ def test_a_broken_tool_is_a_named_error_not_a_traceback(self) -> None:
155
+ out = io.StringIO()
156
+ with redirect_stdout(out):
157
+ rc = cd._default_run([str(self.root / "gowalk-cicd-no-such-binary")])
158
+ self.assertNotEqual(rc, 0)
159
+ self.assertIn("::error::", out.getvalue())
160
+
161
+ def test_missing_dsyms_fail_before_any_upload(self) -> None:
162
+ code, out = self.run_main({"FIREBASE_APP_ID": IOS}, archive=self.root / "missing.xcarchive")
163
+ self.assertEqual(code, 1)
164
+ self.assertEqual(self.calls, [])
165
+ self.assertIn("::error::", out)
166
+
167
+ def test_a_failed_upload_fails_the_step(self) -> None:
168
+ code, out = self.run_main({"FIREBASE_APP_ID": IOS}, rc=3)
169
+ self.assertEqual(code, 1)
170
+ self.assertIn("::error::", out)
171
+ self.assertIn(f"-ai {IOS}", out)
172
+
173
+
174
+ ACTION_YML = Path(__file__).resolve().parents[1] / "action.yml"
175
+
176
+
177
+ def step(name: str) -> str:
178
+ text = ACTION_YML.read_text()
179
+ pattern = rf" - name: {re.escape(name)}\n(?P<body>.*?)(?=\n - name:|\Z)"
180
+ match = re.search(pattern, text, re.DOTALL)
181
+ if match is None:
182
+ raise AssertionError(f"missing action step: {name}")
183
+ return match.group("body")
184
+
185
+
186
+ class CrashlyticsWiringTest(unittest.TestCase):
187
+ def test_the_action_takes_the_app_id_as_an_input(self) -> None:
188
+ # An input, not `vars.FIREBASE_APP_ID` read inside the action: the
189
+ # workflow passes the repository variable, and an unavailable context
190
+ # in a composite action would invalidate every consumer's workflow.
191
+ text = ACTION_YML.read_text()
192
+ self.assertRegex(text, r"(?m)^ firebase-app-id:\n(?: .*\n)*? default: \"\"$")
193
+ self.assertNotIn("vars.", text)
194
+
195
+ def test_dsyms_go_up_after_export_and_before_testflight(self) -> None:
196
+ upload = step("Upload dSYMs to Crashlytics")
197
+ self.assertIn("inputs.archive == 'true'", upload)
198
+ # Only builds that actually ship: an archive-only run (upload=false)
199
+ # must not be failed over a symbols upload.
200
+ self.assertIn("inputs.upload == 'true'", upload)
201
+ self.assertIn("inputs.firebase-app-id != ''", upload)
202
+ self.assertIn("FIREBASE_APP_ID: ${{ inputs.firebase-app-id }}", upload)
203
+ self.assertIn("scripts/crashlytics_dsyms.py", upload)
204
+ self.assertIn('--archive "$RUNNER_TEMP/app.xcarchive"', upload)
205
+ text = ACTION_YML.read_text()
206
+ self.assertLess(text.index("- name: Export IPA"), text.index("- name: Upload dSYMs to Crashlytics"))
207
+ self.assertLess(text.index("- name: Upload dSYMs to Crashlytics"), text.index("- name: Upload to TestFlight"))
208
+
209
+ def test_the_script_ships_with_the_action(self) -> None:
210
+ self.assertTrue((ACTION_YML.parent / "scripts" / "crashlytics_dsyms.py").is_file())
211
+
212
+
213
+ if __name__ == "__main__":
214
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.47
1
+ 1.0.48
@@ -30,6 +30,16 @@ inputs:
30
30
  (flutter analyze + flutter test, or the Gradle module's unit tests)
31
31
  required: false
32
32
  default: "true"
33
+ firebase-app-id:
34
+ description: >-
35
+ Firebase App ID(s) for the Crashlytics symbol upload — the
36
+ `1:<project number>:android:<hash>` value (mobilesdk_app_id in
37
+ google-services.json, or Project settings → Your apps in the Firebase
38
+ console). Several ids may be listed, separated by commas or whitespace;
39
+ the Android one is used. Empty disables the upload. The package workflow
40
+ passes the FIREBASE_APP_ID repository variable.
41
+ required: false
42
+ default: ""
33
43
 
34
44
  outputs:
35
45
  package-name:
@@ -149,6 +159,26 @@ runs:
149
159
  path: ${{ steps.flutter_build.outputs.symbols_path }}
150
160
  if-no-files-found: error
151
161
 
162
+ # Crashlytics shows *** for every Dart frame of an obfuscated build until
163
+ # it has the symbol files, and on Android nothing uploads them by itself
164
+ # (the Crashlytics Gradle plugin handles native NDK symbols only). With
165
+ # firebase-app-id set — deploy.yml passes the FIREBASE_APP_ID repository
166
+ # variable — they go up here, before the workflow ships the bundle to
167
+ # Play, so a failed upload fails the deploy instead of shipping crashes
168
+ # nobody can read. Unset, the step only warns when the app depends on
169
+ # firebase_crashlytics. The Firebase CLI needs Node and Java, both already
170
+ # on this job, and no credentials. Logic and its tests live in
171
+ # scripts/crashlytics_symbols.py.
172
+ - name: Upload Dart symbols to Crashlytics
173
+ if: ${{ steps.config.outputs.project_kind == 'flutter' }}
174
+ shell: bash
175
+ env:
176
+ FIREBASE_APP_ID: ${{ inputs.firebase-app-id }}
177
+ DART_SYMBOLS_DIR: ${{ steps.flutter_build.outputs.symbols_path }}
178
+ run: |
179
+ python3 "${{ github.action_path }}/scripts/crashlytics_symbols.py" \
180
+ --symbols-dir "$DART_SYMBOLS_DIR"
181
+
152
182
  # --- Native Gradle --------------------------------------------------
153
183
  # resolve_android.py rewrites versionCode/versionName in the module build
154
184
  # file when it can find a literal to rewrite, and the build step below
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ """Upload a Flutter app's Dart symbol files to Firebase Crashlytics.
3
+
4
+ Every Flutter release build is obfuscated (see action.yml), so Crashlytics
5
+ shows `***` for every Dart frame until it has the symbol files that
6
+ --split-debug-info wrote. On Android nothing uploads them automatically — the
7
+ Crashlytics Gradle plugin only handles native NDK symbols — so this script
8
+ does, with the command Firebase documents for Flutter:
9
+
10
+ firebase crashlytics:symbols:upload --app=<FIREBASE_APP_ID> <symbols dir>
11
+
12
+ It needs no credentials: that Firebase CLI command has no login requirement
13
+ and authenticates by app id alone. It does need Node (npx) and Java (the CLI
14
+ drives the Crashlytics buildtools jar), both of which the Android job has.
15
+
16
+ Gating: the FIREBASE_APP_ID environment variable holds the app's Firebase App
17
+ ID(s), `1:<project number>:<platform>:<hash>`, comma- or whitespace-separated
18
+ when an app ships both platforms; the Android one is used. Unset, nothing is
19
+ uploaded and the script only warns when the app depends on
20
+ firebase_crashlytics. Set, an upload failure is a failure: the action runs
21
+ before the workflow ships the bundle to Play, so a deploy never goes out with
22
+ crashes nobody can read.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import os
29
+ import re
30
+ import subprocess
31
+ import sys
32
+ import tempfile
33
+ from pathlib import Path
34
+
35
+ # Exact pin, not a range: this job holds the upload keystore and the Play
36
+ # service account, so no floating third-party code runs in it. Bump on purpose.
37
+ FIREBASE_TOOLS = "firebase-tools@15.28.2"
38
+
39
+ APP_ID_RE = re.compile(r"^(\d+):(\d+):(android|ios|web):([0-9A-Fa-f]+)$")
40
+
41
+
42
+ class AppIdError(ValueError):
43
+ """FIREBASE_APP_ID contains something that is not a Firebase App ID."""
44
+
45
+
46
+ def parse_app_ids(raw: str) -> list[tuple[str, str]]:
47
+ """Split FIREBASE_APP_ID into (platform, app id) pairs.
48
+
49
+ Accepts one id or several separated by commas and/or whitespace. Anything
50
+ that does not look like `1:<project number>:<android|ios|web>:<hash>` is
51
+ an error rather than a silent skip — a typo must not turn into a fleet
52
+ that quietly stops uploading symbols.
53
+ """
54
+ ids: list[tuple[str, str]] = []
55
+ for token in re.split(r"[,\s]+", raw.strip()):
56
+ if not token:
57
+ continue
58
+ match = APP_ID_RE.match(token)
59
+ if match is None:
60
+ raise AppIdError(
61
+ f"{token!r} is not a Firebase App ID "
62
+ "(expected 1:<project number>:<android|ios|web>:<hash>)"
63
+ )
64
+ ids.append((match.group(3), token))
65
+ return ids
66
+
67
+
68
+ def _pick(ids: list[tuple[str, str]], platform: str) -> str | None:
69
+ return next((app_id for found_platform, app_id in ids if found_platform == platform), None)
70
+
71
+
72
+ def select_app_id(raw: str, platform: str) -> str | None:
73
+ """The app id for *platform* out of FIREBASE_APP_ID, or None."""
74
+ return _pick(parse_app_ids(raw), platform)
75
+
76
+
77
+ def uses_crashlytics(pubspec: Path) -> bool:
78
+ try:
79
+ text = pubspec.read_text(encoding="utf-8")
80
+ except OSError:
81
+ return False
82
+ return re.search(r"^\s*firebase_crashlytics\s*:", text, re.MULTILINE) is not None
83
+
84
+
85
+ def upload_command(app_id: str, symbols_dir: Path, firebase_tools: str = FIREBASE_TOOLS) -> list[str]:
86
+ return [
87
+ "npx",
88
+ "--yes",
89
+ firebase_tools,
90
+ "crashlytics:symbols:upload",
91
+ f"--app={app_id}",
92
+ str(symbols_dir),
93
+ ]
94
+
95
+
96
+ def _default_run(cmd: list[str]) -> int:
97
+ # A scratch cwd: the Crashlytics buildtools drop a .crashlytics/ directory
98
+ # (dump_syms.bin, ~4 MB) into the working directory, which must not be the
99
+ # consumer's checkout.
100
+ workdir = tempfile.mkdtemp(prefix="crashlytics-upload-")
101
+ try:
102
+ return subprocess.run(cmd, check=False, cwd=workdir).returncode
103
+ except FileNotFoundError:
104
+ print(
105
+ "::error::npx is not on PATH, so the Firebase CLI cannot run. "
106
+ "If a disk-cleanup step removes /usr/local/lib/node_modules, npm "
107
+ "and npx go with it — deploy.yml deliberately leaves that path alone."
108
+ )
109
+ return 127
110
+
111
+
112
+ def main(argv: list[str] | None = None, environ: dict[str, str] | None = None, run=None) -> int:
113
+ environ = os.environ if environ is None else environ
114
+ run = _default_run if run is None else run
115
+
116
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
117
+ parser.add_argument("--symbols-dir", required=True, type=Path,
118
+ help="the directory passed to flutter build --split-debug-info")
119
+ parser.add_argument("--pubspec", type=Path, default=Path("pubspec.yaml"),
120
+ help="pubspec.yaml, used only to decide whether to warn when unset")
121
+ parser.add_argument("--firebase-tools", default=FIREBASE_TOOLS,
122
+ help="npm spec of the Firebase CLI to run through npx")
123
+ args = parser.parse_args(argv)
124
+
125
+ raw = environ.get("FIREBASE_APP_ID", "")
126
+ try:
127
+ ids = parse_app_ids(raw)
128
+ except AppIdError as exc:
129
+ print(f"::error::FIREBASE_APP_ID: {exc}")
130
+ return 1
131
+
132
+ app_id = _pick(ids, "android")
133
+ if app_id is None:
134
+ if ids:
135
+ print(
136
+ "::warning::FIREBASE_APP_ID names no Android app "
137
+ "(1:<project number>:android:<hash>); the Dart symbols were not "
138
+ "uploaded to Crashlytics."
139
+ )
140
+ elif uses_crashlytics(args.pubspec):
141
+ # Two causes look identical from here: the variable is unset, or a
142
+ # deploy.yml that predates the firebase-app-id input never passed
143
+ # it. Name both — the action can only ever see the input.
144
+ print(
145
+ "::warning::This app depends on firebase_crashlytics but no "
146
+ "Firebase App ID reached this build, so every Dart frame in "
147
+ "Crashlytics will read as ***. Either the FIREBASE_APP_ID "
148
+ "repository variable is unset, or this repo's deploy.yml predates "
149
+ "the firebase-app-id input — a one-off `npx --yes gowalk-cicd` "
150
+ "refreshes it."
151
+ )
152
+ else:
153
+ print("FIREBASE_APP_ID unset; not uploading Dart symbols to Crashlytics.")
154
+ return 0
155
+
156
+ symbols = sorted(args.symbols_dir.glob("*.symbols")) if args.symbols_dir.is_dir() else []
157
+ if not symbols:
158
+ print(f"::error::no Dart symbol files in {args.symbols_dir}; nothing to upload to Crashlytics")
159
+ return 1
160
+
161
+ cmd = upload_command(app_id, args.symbols_dir, args.firebase_tools)
162
+ print(f"Uploading {len(symbols)} Dart symbol file(s) to Crashlytics app {app_id}:")
163
+ print(" " + " ".join(cmd))
164
+ rc = run(cmd)
165
+ if rc != 0:
166
+ print(
167
+ f"::error::Crashlytics symbol upload failed (exit {rc}). The symbols are "
168
+ "retained as the android-symbols artifact; after fixing the cause, upload "
169
+ f"them by hand with: firebase crashlytics:symbols:upload --app={app_id} <dir>"
170
+ )
171
+ return 1
172
+ print(f"Uploaded Dart symbols to Crashlytics app {app_id}.")
173
+ return 0
174
+
175
+
176
+ if __name__ == "__main__":
177
+ sys.exit(main())
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env python3
2
+ """Crashlytics symbol upload: gating, id selection and failure semantics.
3
+
4
+ The upload itself cannot run here (it needs the Firebase CLI, Java and the
5
+ network), so the command is captured through the injectable runner and the
6
+ decision logic is exercised directly. The wiring into action.yml is pinned at
7
+ the bottom, next to the other Flutter obfuscation wiring tests.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import io
13
+ import re
14
+ import sys
15
+ import tempfile
16
+ import unittest
17
+ from contextlib import redirect_stdout
18
+ from pathlib import Path
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
21
+
22
+ import crashlytics_symbols as cs # noqa: E402
23
+
24
+ ANDROID = "1:123456789012:android:0123456789abcdef"
25
+ IOS = "1:123456789012:ios:fedcba9876543210"
26
+
27
+
28
+ class ParseAppIdsTest(unittest.TestCase):
29
+ def test_single_id(self) -> None:
30
+ self.assertEqual(cs.parse_app_ids(ANDROID), [("android", ANDROID)])
31
+
32
+ def test_several_ids_separated_by_commas_or_whitespace(self) -> None:
33
+ for raw in (f"{ANDROID},{IOS}", f"{ANDROID}, {IOS}", f"{ANDROID}\n{IOS}", f" {ANDROID} {IOS} "):
34
+ with self.subTest(raw=raw):
35
+ self.assertEqual(cs.parse_app_ids(raw), [("android", ANDROID), ("ios", IOS)])
36
+
37
+ def test_empty_is_no_ids(self) -> None:
38
+ self.assertEqual(cs.parse_app_ids(""), [])
39
+ self.assertEqual(cs.parse_app_ids(" \n "), [])
40
+
41
+ def test_web_ids_parse_but_are_never_selected_here(self) -> None:
42
+ web = "1:123456789012:web:abcdef0123456789"
43
+ self.assertEqual(cs.parse_app_ids(web), [("web", web)])
44
+ self.assertIsNone(cs.select_app_id(web, "android"))
45
+
46
+ def test_garbage_is_an_error_not_a_skip(self) -> None:
47
+ for raw in ("my-app", "1:123:android", "1:123:desktop:abc", f"{ANDROID},oops"):
48
+ with self.subTest(raw=raw):
49
+ with self.assertRaises(cs.AppIdError):
50
+ cs.parse_app_ids(raw)
51
+
52
+ def test_select_picks_the_platform(self) -> None:
53
+ self.assertEqual(cs.select_app_id(f"{IOS},{ANDROID}", "android"), ANDROID)
54
+ self.assertEqual(cs.select_app_id(f"{IOS},{ANDROID}", "ios"), IOS)
55
+ self.assertIsNone(cs.select_app_id(IOS, "android"))
56
+
57
+
58
+ class MainTest(unittest.TestCase):
59
+ def setUp(self) -> None:
60
+ self.tmp = tempfile.TemporaryDirectory()
61
+ self.root = Path(self.tmp.name)
62
+ self.symbols = self.root / "symbols"
63
+ self.symbols.mkdir()
64
+ for arch in ("arm", "arm64", "x64"):
65
+ (self.symbols / f"app.android-{arch}.symbols").write_bytes(b"\x7fELF")
66
+ self.pubspec = self.root / "pubspec.yaml"
67
+ self.pubspec.write_text("name: app\ndependencies:\n flutter:\n sdk: flutter\n")
68
+ self.calls: list[list[str]] = []
69
+
70
+ def tearDown(self) -> None:
71
+ self.tmp.cleanup()
72
+
73
+ def run_main(self, env: dict[str, str], rc: int = 0, symbols_dir: Path | None = None) -> tuple[int, str]:
74
+ def runner(cmd: list[str]) -> int:
75
+ self.calls.append(cmd)
76
+ return rc
77
+
78
+ out = io.StringIO()
79
+ with redirect_stdout(out):
80
+ code = cs.main(
81
+ ["--symbols-dir", str(symbols_dir or self.symbols), "--pubspec", str(self.pubspec)],
82
+ environ=env,
83
+ run=runner,
84
+ )
85
+ return code, out.getvalue()
86
+
87
+ def test_unset_without_crashlytics_is_a_quiet_no_op(self) -> None:
88
+ code, out = self.run_main({})
89
+ self.assertEqual(code, 0)
90
+ self.assertEqual(self.calls, [])
91
+ self.assertNotIn("::warning::", out)
92
+ self.assertNotIn("::error::", out)
93
+
94
+ def test_unset_with_crashlytics_warns_but_does_not_fail(self) -> None:
95
+ self.pubspec.write_text("dependencies:\n firebase_crashlytics: ^5.0.0\n")
96
+ code, out = self.run_main({"FIREBASE_APP_ID": ""})
97
+ self.assertEqual(code, 0)
98
+ self.assertEqual(self.calls, [])
99
+ self.assertIn("::warning::", out)
100
+ self.assertIn("FIREBASE_APP_ID", out)
101
+ self.assertIn("***", out)
102
+ # The action cannot tell an unset variable from a deploy.yml that never
103
+ # passes the input; the message must name both causes.
104
+ self.assertIn("gowalk-cicd", out)
105
+
106
+ def test_a_commented_out_dependency_does_not_count(self) -> None:
107
+ self.pubspec.write_text("dependencies:\n # firebase_crashlytics: ^5.0.0\n")
108
+ code, out = self.run_main({})
109
+ self.assertEqual(code, 0)
110
+ self.assertNotIn("::warning::", out)
111
+
112
+ def test_ids_without_an_android_one_warn_and_skip(self) -> None:
113
+ code, out = self.run_main({"FIREBASE_APP_ID": IOS})
114
+ self.assertEqual(code, 0)
115
+ self.assertEqual(self.calls, [])
116
+ self.assertIn("::warning::", out)
117
+ self.assertIn("no Android app", out)
118
+
119
+ def test_malformed_id_fails(self) -> None:
120
+ code, out = self.run_main({"FIREBASE_APP_ID": "not-an-id"})
121
+ self.assertEqual(code, 1)
122
+ self.assertEqual(self.calls, [])
123
+ self.assertIn("::error::", out)
124
+
125
+ def test_android_id_uploads_the_symbol_directory(self) -> None:
126
+ code, out = self.run_main({"FIREBASE_APP_ID": f"{IOS}, {ANDROID}"})
127
+ self.assertEqual(code, 0)
128
+ self.assertEqual(
129
+ self.calls,
130
+ [["npx", "--yes", cs.FIREBASE_TOOLS, "crashlytics:symbols:upload",
131
+ f"--app={ANDROID}", str(self.symbols)]],
132
+ )
133
+ self.assertIn("Uploaded", out)
134
+ self.assertNotIn("::error::", out)
135
+
136
+ def test_firebase_tools_is_pinned_exactly(self) -> None:
137
+ # This job holds the upload keystore and the Play service account;
138
+ # floating third-party code has no place in it.
139
+ self.assertRegex(cs.FIREBASE_TOOLS, r"^firebase-tools@\d+\.\d+\.\d+$")
140
+
141
+ def test_a_missing_npx_is_a_named_error_not_a_traceback(self) -> None:
142
+ out = io.StringIO()
143
+ with redirect_stdout(out):
144
+ rc = cs._default_run(["gowalk-cicd-no-such-binary-xyz"])
145
+ self.assertNotEqual(rc, 0)
146
+ self.assertIn("::error::", out.getvalue())
147
+
148
+ def test_a_failed_upload_fails_the_step(self) -> None:
149
+ code, out = self.run_main({"FIREBASE_APP_ID": ANDROID}, rc=2)
150
+ self.assertEqual(code, 1)
151
+ self.assertIn("::error::", out)
152
+ # The operator is told how to recover from the retained artifact.
153
+ self.assertIn("android-symbols", out)
154
+ self.assertIn(f"--app={ANDROID}", out)
155
+
156
+ def test_missing_symbols_fail_before_any_upload(self) -> None:
157
+ empty = self.root / "empty"
158
+ empty.mkdir()
159
+ code, out = self.run_main({"FIREBASE_APP_ID": ANDROID}, symbols_dir=empty)
160
+ self.assertEqual(code, 1)
161
+ self.assertEqual(self.calls, [])
162
+ self.assertIn("::error::", out)
163
+ code, out = self.run_main({"FIREBASE_APP_ID": ANDROID}, symbols_dir=self.root / "nope")
164
+ self.assertEqual(code, 1)
165
+ self.assertEqual(self.calls, [])
166
+
167
+
168
+ ACTION_YML = Path(__file__).resolve().parent.parent / "action.yml"
169
+
170
+
171
+ def step(name: str) -> str:
172
+ text = ACTION_YML.read_text()
173
+ pattern = rf" - name: {re.escape(name)}\n(?P<body>.*?)(?=\n - name:|\Z)"
174
+ match = re.search(pattern, text, re.DOTALL)
175
+ if match is None:
176
+ raise AssertionError(f"missing action step: {name}")
177
+ return match.group("body")
178
+
179
+
180
+ class CrashlyticsWiringTest(unittest.TestCase):
181
+ def test_the_action_takes_the_app_id_as_an_input(self) -> None:
182
+ # An input, not `vars.FIREBASE_APP_ID` read inside the action: the
183
+ # workflow passes the repository variable, and an unavailable context
184
+ # in a composite action would invalidate every consumer's workflow.
185
+ text = ACTION_YML.read_text()
186
+ self.assertRegex(text, r"(?m)^ firebase-app-id:\n(?: .*\n)*? default: \"\"$")
187
+ self.assertNotIn("vars.", text)
188
+
189
+ def test_flutter_apps_upload_after_retaining_and_before_the_gradle_path(self) -> None:
190
+ upload = step("Upload Dart symbols to Crashlytics")
191
+ self.assertIn("project_kind == 'flutter'", upload)
192
+ self.assertIn("FIREBASE_APP_ID: ${{ inputs.firebase-app-id }}", upload)
193
+ self.assertIn("DART_SYMBOLS_DIR: ${{ steps.flutter_build.outputs.symbols_path }}", upload)
194
+ self.assertIn("scripts/crashlytics_symbols.py", upload)
195
+ text = ACTION_YML.read_text()
196
+ self.assertLess(text.index("- name: Retain Dart symbol files"),
197
+ text.index("- name: Upload Dart symbols to Crashlytics"))
198
+ self.assertLess(text.index("- name: Upload Dart symbols to Crashlytics"),
199
+ text.index("# --- Native Gradle"))
200
+
201
+ def test_the_upload_runs_even_when_unset_so_it_can_warn(self) -> None:
202
+ upload = step("Upload Dart symbols to Crashlytics")
203
+ self.assertNotIn("firebase-app-id != ''", upload)
204
+
205
+ def test_the_script_ships_with_the_action(self) -> None:
206
+ self.assertTrue((ACTION_YML.parent / "scripts" / "crashlytics_symbols.py").is_file())
207
+
208
+
209
+ if __name__ == "__main__":
210
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.47
1
+ 1.0.48
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.47",
3
+ "version": "1.0.48",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -129,6 +129,10 @@ jobs:
129
129
  # is locked in review. Default-branch deploys retain metadata automation.
130
130
  manage-app-store-version: >-
131
131
  ${{ github.ref_name == github.event.repository.default_branch && 'true' || 'false' }}
132
+ # Repository variable FIREBASE_APP_ID: the app's Firebase App ID(s),
133
+ # comma-separated when it ships both platforms. With an iOS id the
134
+ # action uploads the archive's dSYMs to Crashlytics before TestFlight.
135
+ firebase-app-id: ${{ vars.FIREBASE_APP_ID }}
132
136
  # `flutter symbolize` with app.ios-arm64.symbols is the only way to read a
133
137
  # stack trace from the build that just went to TestFlight (App Store
134
138
  # Connect receives native dSYMs only), and this runner holds the only
@@ -244,10 +248,11 @@ jobs:
244
248
  shell: bash
245
249
  run: |
246
250
  echo "Before:"; df -h / | tail -1
251
+ # NOT /usr/local/lib/node_modules: npm and npx live there, and the
252
+ # Android action's Crashlytics symbol upload shells out to npx.
247
253
  sudo rm -rf /usr/share/dotnet /usr/local/lib/android/sdk/ndk \
248
254
  /opt/ghc /usr/local/.ghcup /usr/local/share/powershell \
249
- /usr/share/swift /usr/local/share/chromium \
250
- /usr/local/lib/node_modules 2>/dev/null || true
255
+ /usr/share/swift /usr/local/share/chromium 2>/dev/null || true
251
256
  sudo docker image prune --all --force >/dev/null 2>&1 || true
252
257
  echo "After:"; df -h / | tail -1
253
258
  # The JDK used to be hardcoded here — 17 for Flutter, 21 for everything
@@ -367,6 +372,10 @@ jobs:
367
372
  # the day a new stable Flutter adds a warning-level lint, even when
368
373
  # the app's own CI gate is green.
369
374
  run-tests: 'false'
375
+ # Same variable as the iOS job. With an Android id the action uploads
376
+ # the Dart symbol files to Crashlytics before the bundle goes to Play;
377
+ # unset, it warns when the app depends on firebase_crashlytics.
378
+ firebase-app-id: ${{ vars.FIREBASE_APP_ID }}
370
379
  - name: Retain signed Android App Bundle
371
380
  if: ${{ steps.mode.outputs.mode == 'local' }}
372
381
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5