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 +25 -0
- package/README.md +55 -11
- package/action/.daemux-version +1 -1
- package/action/action.yml +34 -0
- package/action/scripts/crashlytics_dsyms.py +182 -0
- package/action/scripts/test_crashlytics_dsyms.py +214 -0
- package/android-action/.daemux-version +1 -1
- package/android-action/action.yml +30 -0
- package/android-action/scripts/crashlytics_symbols.py +177 -0
- package/android-action/scripts/test_crashlytics_symbols.py +210 -0
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
- package/templates/deploy.yml +11 -2
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
|
|
258
|
-
|
|
259
|
-
|
|
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.
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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.
|
|
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.
|
|
1
|
+
1.0.48
|
package/package.json
CHANGED
package/templates/deploy.yml
CHANGED
|
@@ -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
|