gowalk-cicd 1.0.47 → 1.0.49

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.
@@ -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.49
@@ -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.49
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.47",
3
+ "version": "1.0.49",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {