gowalk-cicd 1.0.128 → 1.0.129
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/action/.daemux-version +1 -1
- package/action/scripts/crashlytics_dsyms.py +38 -0
- package/action/scripts/prepare_crashlytics_build.py +123 -13
- package/action/scripts/prepare_crashlytics_dsyms.py +8 -2
- package/action/scripts/test_prepare_crashlytics.py +189 -2
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.129
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import argparse
|
|
12
12
|
import os
|
|
13
|
+
import plistlib
|
|
13
14
|
import re
|
|
14
15
|
import subprocess
|
|
15
16
|
import sys
|
|
@@ -46,6 +47,43 @@ def select_app_id(raw: str, platform: str) -> str | None:
|
|
|
46
47
|
return _pick(parse_app_ids(raw), platform)
|
|
47
48
|
|
|
48
49
|
|
|
50
|
+
CONFIG_NAME = "GoogleService-Info.plist"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def app_id_from_config(search_root: Path, platform: str) -> str | None:
|
|
54
|
+
"""Read the Firebase app ID out of the app's own GoogleService-Info.plist.
|
|
55
|
+
|
|
56
|
+
Firebase reads this file at runtime, it is committed beside the Xcode project and it
|
|
57
|
+
is already inside the archive, so requiring the same value in a separate secret adds
|
|
58
|
+
a way to be wrong without adding a source of truth. An explicit FIREBASE_APP_ID still
|
|
59
|
+
wins; this is only consulted when it names no app for the platform.
|
|
60
|
+
"""
|
|
61
|
+
if not search_root.is_dir():
|
|
62
|
+
return None
|
|
63
|
+
root = search_root.resolve()
|
|
64
|
+
found: dict[str, Path] = {}
|
|
65
|
+
for path in sorted(root.rglob(CONFIG_NAME)):
|
|
66
|
+
# A pod ships its own fixtures; only the app's committed config speaks for it.
|
|
67
|
+
if path.is_symlink() or root not in path.resolve().parents:
|
|
68
|
+
continue
|
|
69
|
+
if "Pods" in path.relative_to(root).parts:
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
with path.open("rb") as stream:
|
|
73
|
+
raw = plistlib.load(stream).get("GOOGLE_APP_ID", "")
|
|
74
|
+
except (OSError, ValueError, plistlib.InvalidFileException):
|
|
75
|
+
continue
|
|
76
|
+
match = APP_ID_RE.match(str(raw).strip())
|
|
77
|
+
if match and match.group(3) == platform:
|
|
78
|
+
found.setdefault(match.group(0), path)
|
|
79
|
+
if len(found) > 1:
|
|
80
|
+
listing = ", ".join(f"{path.relative_to(root)} -> {app_id}"
|
|
81
|
+
for app_id, path in sorted(found.items()))
|
|
82
|
+
raise AppIdError(f"more than one {platform} Firebase app is configured ({listing}); "
|
|
83
|
+
"set FIREBASE_APP_ID to say which one owns these symbols")
|
|
84
|
+
return next(iter(found), None)
|
|
85
|
+
|
|
86
|
+
|
|
49
87
|
def find_upload_symbols(search_root: Path, home: Path | None = None) -> Path | None:
|
|
50
88
|
"""Locate the FirebaseCrashlytics `upload-symbols` tool.
|
|
51
89
|
|
|
@@ -12,21 +12,129 @@ import subprocess
|
|
|
12
12
|
|
|
13
13
|
MARKER = "echo 'Crashlytics dSYMs are retained by CI for the account-proxied console upload'"
|
|
14
14
|
UPLOADS = ("upload-crashlytics-symbols", "upload-symbols", "Crashlytics/run")
|
|
15
|
+
# Shell words a symbol-upload phase may legitimately run besides the upload itself.
|
|
16
|
+
# `dart pub global activate flutterfire_cli` is how the FlutterFire CLI installs the
|
|
17
|
+
# uploader it is about to call; the rest are the inert built-ins the generated guards use
|
|
18
|
+
# to skip the upload when the tool is absent (Firebase via SwiftPM, for instance).
|
|
19
|
+
INERT = frozenset({"echo", "exit", "true", "false", ":", "[", "[[", "test", "return"})
|
|
20
|
+
KEYWORDS = frozenset({"if", "then", "else", "elif", "fi", "do", "done", "while", "until"})
|
|
21
|
+
ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z_0-9]*=")
|
|
22
|
+
OPERATOR = set(";&|<>")
|
|
23
|
+
REDIRECT = set("<>")
|
|
15
24
|
|
|
16
25
|
|
|
17
|
-
def
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
def continued(script: str) -> list[str]:
|
|
27
|
+
"""Split into logical lines, joining only a REAL backslash-newline continuation.
|
|
28
|
+
|
|
29
|
+
`echo done\\` ends in an escaped backslash, so the newline still separates commands and
|
|
30
|
+
the next line runs. Joining unconditionally turned that next command into an argument
|
|
31
|
+
of the echo and hid it, so count the trailing backslashes: an odd number continues.
|
|
32
|
+
"""
|
|
33
|
+
lines: list[str] = []
|
|
34
|
+
pending = ""
|
|
35
|
+
for line in script.split("\n"):
|
|
36
|
+
line = pending + line
|
|
37
|
+
if (len(line) - len(line.rstrip("\\"))) % 2:
|
|
38
|
+
pending = line[:-1] + " "
|
|
39
|
+
continue
|
|
40
|
+
lines.append(line)
|
|
41
|
+
pending = ""
|
|
42
|
+
if pending:
|
|
43
|
+
lines.append(pending)
|
|
44
|
+
return lines
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def commands(script: str) -> list[list[str]] | None:
|
|
48
|
+
"""Every command in `script` as a token list, or None when it cannot be read safely.
|
|
49
|
+
|
|
50
|
+
A newline separates commands just as `;` does, and shlex treats it as plain
|
|
51
|
+
whitespace, so each line is tokenized on its own after continuations are joined.
|
|
52
|
+
"""
|
|
53
|
+
if "$(" in script or "`" in script:
|
|
54
|
+
return None
|
|
55
|
+
parsed = []
|
|
56
|
+
for line in continued(script):
|
|
57
|
+
lexer = shlex.shlex(line, posix=True, punctuation_chars=";&|<>")
|
|
58
|
+
lexer.whitespace_split = True
|
|
59
|
+
# shlex would treat a '#' ANYWHERE outside quotes as starting a comment, but the
|
|
60
|
+
# shell only does so at the start of a word: in `upload-symbols#; rm -rf /` the
|
|
61
|
+
# rm really runs. Read comments the way the shell does instead.
|
|
62
|
+
lexer.commenters = ""
|
|
63
|
+
try:
|
|
64
|
+
tokens = list(lexer)
|
|
65
|
+
except ValueError:
|
|
66
|
+
return None
|
|
67
|
+
current = []
|
|
68
|
+
target = False
|
|
69
|
+
for token in tokens:
|
|
70
|
+
if target: # the file a redirect writes, not a command
|
|
71
|
+
target = False
|
|
72
|
+
continue
|
|
73
|
+
if token.startswith("#"):
|
|
74
|
+
break
|
|
75
|
+
if token and set(token) <= OPERATOR:
|
|
76
|
+
if set(token) & REDIRECT: # a redirect belongs to the command it follows
|
|
77
|
+
target = True
|
|
78
|
+
continue
|
|
79
|
+
if current:
|
|
80
|
+
parsed.append(current)
|
|
81
|
+
current = []
|
|
82
|
+
elif token in KEYWORDS:
|
|
83
|
+
if current:
|
|
84
|
+
parsed.append(current)
|
|
85
|
+
current = []
|
|
86
|
+
else:
|
|
87
|
+
current.append(token)
|
|
88
|
+
if current:
|
|
89
|
+
parsed.append(current)
|
|
90
|
+
return parsed
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def invoked(tokens: list[str]) -> list[str]:
|
|
94
|
+
"""What the command actually runs, after any leading VAR=value prefixes.
|
|
95
|
+
|
|
96
|
+
`CONFIG=release rm -rf build` runs rm; treating the assignment as the whole command
|
|
97
|
+
would approve anything hiding behind one.
|
|
98
|
+
"""
|
|
99
|
+
index = 0
|
|
100
|
+
while index < len(tokens) and ASSIGNMENT.match(tokens[index]):
|
|
101
|
+
index += 1
|
|
102
|
+
return tokens[index:]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def uploads_symbols(tokens: list[str]) -> bool:
|
|
106
|
+
words = invoked(tokens)
|
|
107
|
+
if not words:
|
|
22
108
|
return False
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
109
|
+
return (words[0].endswith(("upload-symbols", "Crashlytics/run"))
|
|
110
|
+
or (words[0] == "flutterfire" and words[1:2] == ["upload-crashlytics-symbols"]))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def permitted(tokens: list[str]) -> bool:
|
|
114
|
+
words = invoked(tokens)
|
|
115
|
+
if not words:
|
|
116
|
+
return True
|
|
117
|
+
if words[0] == "export" and all(ASSIGNMENT.match(word) for word in words[1:]):
|
|
118
|
+
return True # `export PATH=...` is the same inert prologue
|
|
119
|
+
return (uploads_symbols(words)
|
|
120
|
+
or words[0] in INERT
|
|
121
|
+
or words[:5] == ["dart", "pub", "global", "activate", "flutterfire_cli"])
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def dedicated(script: str) -> bool:
|
|
125
|
+
"""True when the phase uploads symbols and does nothing else the archive needs.
|
|
126
|
+
|
|
127
|
+
The uploader is generated, not hand-written: `flutterfire configure` emits a guard
|
|
128
|
+
clause, a PATH export, a `dart pub global activate` and the upload, and the Firebase
|
|
129
|
+
documentation emits an if/else around `$PODS_ROOT/FirebaseCrashlytics/upload-symbols`.
|
|
130
|
+
Demanding a single command rejected both, so the phase every Flutter app actually has
|
|
131
|
+
could never be deferred. What matters is that nothing ELSE in the phase builds
|
|
132
|
+
anything, since the whole script is replaced.
|
|
133
|
+
"""
|
|
134
|
+
parsed = commands(script)
|
|
135
|
+
if parsed is None:
|
|
27
136
|
return False
|
|
28
|
-
return (tokens
|
|
29
|
-
or (tokens[0] == "flutterfire" and tokens[1:2] == ["upload-crashlytics-symbols"]))
|
|
137
|
+
return any(uploads_symbols(tokens) for tokens in parsed) and all(map(permitted, parsed))
|
|
30
138
|
|
|
31
139
|
|
|
32
140
|
def rewrite(document: dict) -> list[str]:
|
|
@@ -38,8 +146,10 @@ def rewrite(document: dict) -> list[str]:
|
|
|
38
146
|
active = "\n".join(line for line in script.splitlines() if not line.lstrip().startswith("#"))
|
|
39
147
|
if not any(token in active for token in UPLOADS):
|
|
40
148
|
continue
|
|
41
|
-
if
|
|
42
|
-
raise ValueError(
|
|
149
|
+
if not dedicated(script):
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"Crashlytics upload shares the build phase {phase.get('name') or identity!r} with other "
|
|
152
|
+
"work; move the upload into its own phase before archiving")
|
|
43
153
|
phase["shellScript"] = MARKER
|
|
44
154
|
changed.append(identity)
|
|
45
155
|
return changed
|
|
@@ -10,7 +10,8 @@ from pathlib import Path
|
|
|
10
10
|
import subprocess
|
|
11
11
|
import zipfile
|
|
12
12
|
|
|
13
|
-
from crashlytics_dsyms import find_upload_symbols,
|
|
13
|
+
from crashlytics_dsyms import (CONFIG_NAME, app_id_from_config, find_upload_symbols,
|
|
14
|
+
select_app_id)
|
|
14
15
|
|
|
15
16
|
SCHEMA = "gowalk-cicd/firebase-symbols-pending.v1"
|
|
16
17
|
|
|
@@ -50,7 +51,12 @@ def main() -> None:
|
|
|
50
51
|
print("No Crashlytics SDK upload tool found; no Firebase symbol delivery is required.")
|
|
51
52
|
return
|
|
52
53
|
if not app_id:
|
|
53
|
-
|
|
54
|
+
app_id = app_id_from_config(args.search_root, "ios")
|
|
55
|
+
if not app_id:
|
|
56
|
+
raise SystemExit(
|
|
57
|
+
"The app embeds Crashlytics but no iOS Firebase app is named: FIREBASE_APP_ID is "
|
|
58
|
+
f"unset or names another platform, and no {CONFIG_NAME} under "
|
|
59
|
+
f"{args.search_root} carries an iOS GOOGLE_APP_ID")
|
|
54
60
|
receipt = prepare(args.archive, args.output, app_id)
|
|
55
61
|
print("::notice title=firebase_symbols_pending::" + json.dumps(receipt, separators=(",", ":")))
|
|
56
62
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import hashlib
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import plistlib
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
import sys
|
|
7
8
|
import tempfile
|
|
@@ -39,6 +40,116 @@ class BuildPhaseTests(unittest.TestCase):
|
|
|
39
40
|
self.assertTrue(build.dedicated('#!/bin/sh\nPATH="$PATH:$HOME/bin"\n'
|
|
40
41
|
'flutterfire upload-crashlytics-symbols --platform=ios'))
|
|
41
42
|
|
|
43
|
+
# Verbatim from gowalk-public/obd_scanner_app @3aa60211, whose iOS leg this refused
|
|
44
|
+
# after pods, signing and the App Store version slot had all already succeeded.
|
|
45
|
+
FLUTTERFIRE = (
|
|
46
|
+
'\n#!/bin/bash\n'
|
|
47
|
+
'if [ ! -f "$PODS_ROOT/FirebaseCrashlytics/upload-symbols" ]; then '
|
|
48
|
+
'echo "warning: FirebaseCrashlytics upload-symbols not found at '
|
|
49
|
+
'$PODS_ROOT/FirebaseCrashlytics/upload-symbols; skipping Crashlytics symbol upload '
|
|
50
|
+
'(Firebase via SwiftPM)"; exit 0; fi\n'
|
|
51
|
+
'PATH="${PATH}:$FLUTTER_ROOT/bin:$HOME/.pub-cache/bin"\n'
|
|
52
|
+
'dart pub global activate flutterfire_cli\n'
|
|
53
|
+
'flutterfire upload-crashlytics-symbols '
|
|
54
|
+
'--upload-symbols-script-path="$PODS_ROOT/FirebaseCrashlytics/upload-symbols" '
|
|
55
|
+
'--platform=ios --apple-project-path="${SRCROOT}" '
|
|
56
|
+
'--env-platform-name="${PLATFORM_NAME}" --env-configuration="${CONFIGURATION}"\n')
|
|
57
|
+
FIREBASE_DOCS = (
|
|
58
|
+
'if [ -f "$PODS_ROOT/FirebaseCrashlytics/upload-symbols" ]; then '
|
|
59
|
+
'$PODS_ROOT/FirebaseCrashlytics/upload-symbols '
|
|
60
|
+
'-gsp $PROJECT_DIR/Runner/GoogleService-Info.plist -p ios '
|
|
61
|
+
'$DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME; '
|
|
62
|
+
'else echo "warning: FirebaseCrashlytics upload-symbols not found; '
|
|
63
|
+
'skipping dSYM upload (Firebase via SwiftPM)"; fi\n')
|
|
64
|
+
|
|
65
|
+
def test_the_phases_flutterfire_and_firebase_actually_generate_are_deferred(self):
|
|
66
|
+
doc = {"objects": {
|
|
67
|
+
"flutterfire": {"isa": "PBXShellScriptBuildPhase",
|
|
68
|
+
"name": 'FlutterFire: "flutterfire upload-crashlytics-symbols"',
|
|
69
|
+
"shellScript": self.FLUTTERFIRE},
|
|
70
|
+
"docs": {"isa": "PBXShellScriptBuildPhase", "name": "Upload dSYMs",
|
|
71
|
+
"shellScript": self.FIREBASE_DOCS}}}
|
|
72
|
+
self.assertEqual(sorted(build.rewrite(doc)), ["docs", "flutterfire"])
|
|
73
|
+
for identity in ("flutterfire", "docs"):
|
|
74
|
+
self.assertEqual(doc["objects"][identity]["shellScript"], build.MARKER)
|
|
75
|
+
|
|
76
|
+
def test_an_upload_phase_is_deferred_whatever_the_phase_is_called(self):
|
|
77
|
+
# "Upload dSYMs" is what the Firebase documentation tells you to name it, and a
|
|
78
|
+
# name has never been evidence about what a script does.
|
|
79
|
+
doc = {"objects": {"phase": {"isa": "PBXShellScriptBuildPhase", "name": "Upload dSYMs",
|
|
80
|
+
"shellScript": self.FIREBASE_DOCS}}}
|
|
81
|
+
self.assertEqual(build.rewrite(doc), ["phase"])
|
|
82
|
+
|
|
83
|
+
def test_build_work_beside_the_upload_still_refuses_and_names_the_phase(self):
|
|
84
|
+
for script in ('/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build\n'
|
|
85
|
+
'"$PODS_ROOT/FirebaseCrashlytics/run"',
|
|
86
|
+
'upload-symbols && rm -rf "$SRCROOT/build"',
|
|
87
|
+
'upload-symbols; curl https://example.invalid/x'):
|
|
88
|
+
with self.subTest(script=script):
|
|
89
|
+
doc = {"objects": {"p": {"isa": "PBXShellScriptBuildPhase",
|
|
90
|
+
"name": "Crashlytics", "shellScript": script}}}
|
|
91
|
+
with self.assertRaises(ValueError) as caught:
|
|
92
|
+
build.rewrite(doc)
|
|
93
|
+
self.assertIn("Crashlytics", str(caught.exception))
|
|
94
|
+
self.assertEqual(doc["objects"]["p"]["shellScript"], script)
|
|
95
|
+
|
|
96
|
+
def test_a_hidden_command_is_never_treated_as_an_upload_only_phase(self):
|
|
97
|
+
for script in ('upload-symbols "$(curl https://example.invalid/x)"',
|
|
98
|
+
'upload-symbols `id`',
|
|
99
|
+
'$RUNNER upload-symbols',
|
|
100
|
+
'echo hi; "unbalanced'):
|
|
101
|
+
with self.subTest(script=script):
|
|
102
|
+
self.assertFalse(build.dedicated(script))
|
|
103
|
+
|
|
104
|
+
def test_a_command_hiding_behind_an_assignment_prefix_is_still_a_command(self):
|
|
105
|
+
# `CONFIG=release rm -rf build` RUNS rm. Reading only the first token saw an
|
|
106
|
+
# assignment and approved the phase, which would have deleted the build tree.
|
|
107
|
+
for script in ('upload-symbols\nCONFIG=release rm -rf "$SRCROOT/build"',
|
|
108
|
+
'A=1 B=2 curl https://example.invalid/x\nupload-symbols'):
|
|
109
|
+
with self.subTest(script=script):
|
|
110
|
+
self.assertFalse(build.dedicated(script))
|
|
111
|
+
self.assertTrue(build.dedicated('FOO=bar upload-symbols -p ios'))
|
|
112
|
+
self.assertEqual(build.invoked(["A=1", "B=2", "rm", "-rf", "/"]), ["rm", "-rf", "/"])
|
|
113
|
+
self.assertEqual(build.invoked(["A=1"]), [])
|
|
114
|
+
|
|
115
|
+
def test_a_hash_only_starts_a_comment_at_the_start_of_a_word(self):
|
|
116
|
+
# shlex would treat the '#' as a comment and never see the rm; bash runs it.
|
|
117
|
+
self.assertFalse(build.dedicated('upload-symbols#; rm -rf /'))
|
|
118
|
+
self.assertTrue(build.dedicated('upload-symbols -p ios # upload the dSYMs'))
|
|
119
|
+
self.assertTrue(build.dedicated('#!/bin/bash\nupload-symbols'))
|
|
120
|
+
|
|
121
|
+
def test_an_escaped_backslash_does_not_swallow_the_next_command(self):
|
|
122
|
+
# `echo done\\` prints a backslash and ENDS; joining every backslash-newline made
|
|
123
|
+
# the rm an argument of the echo and hid it from the check.
|
|
124
|
+
self.assertFalse(build.dedicated('"$PODS_ROOT/FirebaseCrashlytics/run"\n'
|
|
125
|
+
'echo done\\\\\nrm -rf /'))
|
|
126
|
+
self.assertTrue(build.dedicated('upload-symbols \\\n -p ios'))
|
|
127
|
+
self.assertEqual(build.continued('a\\\nb'), ["a b"])
|
|
128
|
+
self.assertEqual(build.continued('a\\\\\nb'), ["a\\\\", "b"])
|
|
129
|
+
|
|
130
|
+
def test_inert_prologues_and_redirects_do_not_refuse_a_real_upload_phase(self):
|
|
131
|
+
for script in ('export PATH=/x\nupload-symbols',
|
|
132
|
+
'upload-symbols > /dev/null 2>&1',
|
|
133
|
+
'export FLUTTER_ROOT=/f\nflutterfire upload-crashlytics-symbols --platform=ios'):
|
|
134
|
+
with self.subTest(script=script):
|
|
135
|
+
self.assertTrue(build.dedicated(script))
|
|
136
|
+
|
|
137
|
+
def test_a_destructive_redirect_is_removed_rather_than_refused(self):
|
|
138
|
+
# The whole script is replaced, so a phase whose only extra effect is a redirect
|
|
139
|
+
# stops doing it. Refusing would fail the archive and LEAVE the write in place.
|
|
140
|
+
self.assertTrue(build.dedicated('upload-symbols > "$HOME/.ssh/authorized_keys"'))
|
|
141
|
+
|
|
142
|
+
def test_a_redirect_never_hides_a_following_command(self):
|
|
143
|
+
self.assertFalse(build.dedicated('upload-symbols > f; rm -rf /'))
|
|
144
|
+
self.assertFalse(build.dedicated('export EVIL=1 rm -rf /\nupload-symbols'))
|
|
145
|
+
|
|
146
|
+
def test_a_phase_that_only_mentions_the_uploader_is_left_alone(self):
|
|
147
|
+
# No upload actually happens, so there is nothing to defer and nothing to refuse.
|
|
148
|
+
self.assertFalse(build.dedicated('echo "run upload-symbols by hand"'))
|
|
149
|
+
doc = {"objects": {"p": {"isa": "PBXShellScriptBuildPhase", "name": "Notes",
|
|
150
|
+
"shellScript": "# upload-symbols runs in CI\necho done"}}}
|
|
151
|
+
self.assertEqual(build.rewrite(doc), [])
|
|
152
|
+
|
|
42
153
|
def test_legacy_native_entrypoint_never_starts_the_binary(self):
|
|
43
154
|
with mock.patch.object(crashlytics_dsyms.subprocess, "run") as run:
|
|
44
155
|
with self.assertRaises(SystemExit):
|
|
@@ -53,14 +164,90 @@ class BuildPhaseTests(unittest.TestCase):
|
|
|
53
164
|
for parent in (first, second):
|
|
54
165
|
(parent / "project.pbxproj").write_text("original")
|
|
55
166
|
documents = [json.dumps({"objects": {"phase": {"isa": "PBXShellScriptBuildPhase",
|
|
56
|
-
"name":
|
|
57
|
-
for
|
|
167
|
+
"name": "Crashlytics", "shellScript": script}}}).encode()
|
|
168
|
+
for script in ("upload-symbols", "upload-symbols; compile-app")]
|
|
58
169
|
with mock.patch.object(build.subprocess, "check_output", side_effect=documents):
|
|
59
170
|
with self.assertRaises(ValueError):
|
|
60
171
|
build.prepare(root)
|
|
61
172
|
self.assertEqual((first / "project.pbxproj").read_text(), "original")
|
|
62
173
|
|
|
63
174
|
|
|
175
|
+
|
|
176
|
+
class FirebaseAppIdTests(unittest.TestCase):
|
|
177
|
+
"""The iOS app ID is committed beside the Xcode project; CI need not be told it twice."""
|
|
178
|
+
|
|
179
|
+
# Verbatim from gowalk-public/obd_scanner_app, whose archive and IPA both succeeded
|
|
180
|
+
# and which then failed on "FIREBASE_APP_ID names no iOS app" with the value below
|
|
181
|
+
# sitting in ios/Runner/GoogleService-Info.plist.
|
|
182
|
+
IOS_ID = "1:361949697659:ios:493e38c05202a939d9f4de"
|
|
183
|
+
ANDROID_ID = "1:361949697659:android:0d9dbd2b1c1f4a2b9f4de1"
|
|
184
|
+
|
|
185
|
+
def config(self, directory: Path, app_id: str) -> Path:
|
|
186
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
target = directory / crashlytics_dsyms.CONFIG_NAME
|
|
188
|
+
with target.open("wb") as stream:
|
|
189
|
+
plistlib.dump({"BUNDLE_ID": "com.gowalk.obdscanner", "GOOGLE_APP_ID": app_id}, stream)
|
|
190
|
+
return target
|
|
191
|
+
|
|
192
|
+
def test_the_committed_config_names_the_app_when_the_secret_does_not(self):
|
|
193
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
194
|
+
root = Path(temporary)
|
|
195
|
+
self.config(root / "Runner", self.IOS_ID)
|
|
196
|
+
self.assertEqual(crashlytics_dsyms.app_id_from_config(root, "ios"), self.IOS_ID)
|
|
197
|
+
self.assertIsNone(crashlytics_dsyms.app_id_from_config(root, "android"))
|
|
198
|
+
|
|
199
|
+
def test_an_explicit_secret_still_wins_over_the_committed_config(self):
|
|
200
|
+
override = "1:999999999999:ios:aaaabbbbccccdddd"
|
|
201
|
+
self.assertEqual(crashlytics_dsyms.select_app_id(override, "ios"), override)
|
|
202
|
+
|
|
203
|
+
def test_a_pod_fixture_never_speaks_for_the_app(self):
|
|
204
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
205
|
+
root = Path(temporary)
|
|
206
|
+
self.config(root / "Pods" / "SomePod" / "Example", self.IOS_ID)
|
|
207
|
+
self.assertIsNone(crashlytics_dsyms.app_id_from_config(root, "ios"))
|
|
208
|
+
|
|
209
|
+
def test_two_ios_apps_refuse_rather_than_guess(self):
|
|
210
|
+
other = "1:361949697659:ios:ffffffffffffffffffffff"
|
|
211
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
212
|
+
root = Path(temporary)
|
|
213
|
+
self.config(root / "Runner", self.IOS_ID)
|
|
214
|
+
self.config(root / "Watch", other)
|
|
215
|
+
with self.assertRaises(crashlytics_dsyms.AppIdError) as caught:
|
|
216
|
+
crashlytics_dsyms.app_id_from_config(root, "ios")
|
|
217
|
+
for expected in (self.IOS_ID, other, "FIREBASE_APP_ID"):
|
|
218
|
+
self.assertIn(expected, str(caught.exception))
|
|
219
|
+
|
|
220
|
+
def test_the_same_app_configured_twice_is_not_a_conflict(self):
|
|
221
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
222
|
+
root = Path(temporary)
|
|
223
|
+
self.config(root / "Runner", self.IOS_ID)
|
|
224
|
+
self.config(root / "Runner" / "Resources", self.IOS_ID)
|
|
225
|
+
self.assertEqual(crashlytics_dsyms.app_id_from_config(root, "ios"), self.IOS_ID)
|
|
226
|
+
|
|
227
|
+
def test_an_android_only_config_never_answers_for_ios(self):
|
|
228
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
229
|
+
root = Path(temporary)
|
|
230
|
+
self.config(root / "Runner", self.ANDROID_ID)
|
|
231
|
+
self.assertIsNone(crashlytics_dsyms.app_id_from_config(root, "ios"))
|
|
232
|
+
|
|
233
|
+
def test_unreadable_or_escaping_configs_are_skipped_not_fatal(self):
|
|
234
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
235
|
+
root = Path(temporary)
|
|
236
|
+
(root / "Broken").mkdir()
|
|
237
|
+
(root / "Broken" / crashlytics_dsyms.CONFIG_NAME).write_text("not a plist")
|
|
238
|
+
outside = Path(temporary) / "outside.plist"
|
|
239
|
+
with outside.open("wb") as stream:
|
|
240
|
+
plistlib.dump({"GOOGLE_APP_ID": self.IOS_ID}, stream)
|
|
241
|
+
(root / "Linked").mkdir()
|
|
242
|
+
(root / "Linked" / crashlytics_dsyms.CONFIG_NAME).symlink_to(outside)
|
|
243
|
+
self.assertIsNone(crashlytics_dsyms.app_id_from_config(root, "ios"))
|
|
244
|
+
self.config(root / "Runner", self.IOS_ID)
|
|
245
|
+
self.assertEqual(crashlytics_dsyms.app_id_from_config(root, "ios"), self.IOS_ID)
|
|
246
|
+
|
|
247
|
+
def test_a_missing_directory_is_simply_no_answer(self):
|
|
248
|
+
self.assertIsNone(crashlytics_dsyms.app_id_from_config(Path("/nonexistent-xyz"), "ios"))
|
|
249
|
+
|
|
250
|
+
|
|
64
251
|
@mock.patch.dict(os.environ, {"GITHUB_RUN_ID": "123", "GITHUB_RUN_ATTEMPT": "2", "GITHUB_SHA": "source-sha"})
|
|
65
252
|
class ArchiveTests(unittest.TestCase):
|
|
66
253
|
def test_zip_manifest_and_notice_identify_the_same_build(self):
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.129
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.129
|