gowalk-cicd 1.0.128 → 1.0.130

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -434,3 +434,9 @@ workflow after a protected package release/adoption; never run hand-SSH or expos
434
434
  The diagnostic reader accepts the product-independent standard WARNING logger envelope, discards its
435
435
  logger name and rejects trailing fields. Structured remote failure codes survive exit1; SSH transport,
436
436
  read bounds and scope refusals have a closed vocabulary. Unknown exceptions remain unclassified.
437
+
438
+ The same validated envelope is retained in the dedicated `backend-identity-diagnostics-<job>-<attempt>`
439
+ artifact on success and classified reader failure. Download `result.json` from that exact run;
440
+ GitHub secret masking can alter structural braces in logs. The artifact contains only schema,
441
+ ok, fixed failure and fixed stage/reason records. Raw host output, configuration and identities
442
+ never enter it. Its pinned upload uses the same process-scoped artifact transport as mobile actions.
package/README.md CHANGED
@@ -1229,6 +1229,12 @@ it does not prove authentication succeeded. Match the bounded observation window
1229
1229
 
1230
1230
  Reader failures retain only: `invalid_scope`, `invalid_window`, `invalid_destination`,
1231
1231
  `container_ambiguous_or_absent`, `container_scope_mismatch`, `command_timeout`, `output_limit`,
1232
- `command_failed`, `transport_failure`, `remote_invalid`, or `unclassified`. Structured remote refusals
1232
+ `command_failed`, `transport_failure`, `remote_invalid`, `artifact_write_failed`, or `unclassified`. Structured remote refusals
1233
1233
  survive a remote exit1. SSH transport failures remain distinct. Unknown exceptions never contribute text.
1234
1234
  The optional standard `WARNING:<logger>:` prefix is discarded; logger names and trailing fields are never returned.
1235
+
1236
+ Download the dedicated `backend-identity-diagnostics-<job>-<attempt>` artifact from that exact run.
1237
+ Its `result.json` contains the validated envelope on success and classified failure, without raw logs,
1238
+ headers, environment values, provider exceptions or identities. Normal GitHub artifact integrity checks
1239
+ apply. The console copy is supplementary: secret masking can replace JSON punctuation in logs.
1240
+ Each invocation uses a fresh local directory so a failed reader cannot publish a prior result.
@@ -1 +1 @@
1
- 1.0.128
1
+ 1.0.130
@@ -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 dedicated(script: str) -> bool:
18
- lines = [line.strip() for line in script.replace("\\\n", " ").splitlines()
19
- if line.strip() and not line.lstrip().startswith("#")]
20
- commands = [line for line in lines if not re.fullmatch(r'(?:export )?PATH=[^;&|<>`]+', line)]
21
- if len(commands) != 1 or any(token in script for token in ("$(", "`")):
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
- lexer = shlex.shlex(commands[0], posix=True, punctuation_chars=";&|<>")
24
- lexer.whitespace_split = True
25
- tokens = list(lexer)
26
- if not tokens or any(set(token) <= set(";&|<>") for token in tokens):
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[0].endswith(("upload-symbols", "Crashlytics/run"))
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 "crashlytics" not in str(phase.get("name", "")).lower() or not dedicated(script):
42
- raise ValueError("Crashlytics upload is in a mixed build phase; separate it before archiving")
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, select_app_id
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
- raise SystemExit("The app embeds Crashlytics but FIREBASE_APP_ID names no iOS app")
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": name, "shellScript": "upload-symbols"}}}).encode()
57
- for name in ("Crashlytics", "Mixed")]
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.128
1
+ 1.0.130
@@ -1 +1 @@
1
- 1.0.128
1
+ 1.0.130
@@ -182,6 +182,7 @@ runs:
182
182
  '${{ inputs.health-path }}' '${{ inputs.cert-email }}'"
183
183
 
184
184
  - name: Read fixed backend identity diagnostics
185
+ id: identity_diagnostics
185
186
  if: inputs.operation == 'identity-diagnostics'
186
187
  shell: bash
187
188
  env:
@@ -191,9 +192,22 @@ runs:
191
192
  DIAGNOSTIC_HOST: ${{ inputs.host }}
192
193
  DIAGNOSTIC_USER: ${{ inputs.ssh-user }}
193
194
  run: |
195
+ RESULT_DIR="$(mktemp -d "$RUNNER_TEMP/backend-identity-diagnostics.XXXXXX")"
196
+ echo "result=$RESULT_DIR/result.json" >> "$GITHUB_OUTPUT"
194
197
  python3 "${{ github.action_path }}/identity_diagnostics.py" \
195
198
  --app "$DIAGNOSTIC_APP" --service "$DIAGNOSTIC_SERVICE" --since "$DIAGNOSTIC_SINCE" \
196
- --host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER"
199
+ --host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER" \
200
+ --output "$RESULT_DIR/result.json"
201
+
202
+ - name: Retain fixed backend identity result
203
+ if: always() && inputs.operation == 'identity-diagnostics'
204
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
205
+ env:
206
+ NODE_OPTIONS: --require "${{ github.action_path }}/scripts/artifact_env.cjs"
207
+ with:
208
+ name: backend-identity-diagnostics-${{ github.job }}-${{ github.run_attempt }}
209
+ path: ${{ steps.identity_diagnostics.outputs.result }}
210
+ if-no-files-found: error
197
211
 
198
212
  - name: Cleanup key
199
213
  if: always()
@@ -30,6 +30,7 @@ FAILURES = frozenset({
30
30
  'unclassified', 'invalid_scope', 'invalid_window', 'invalid_destination',
31
31
  'container_ambiguous_or_absent', 'container_scope_mismatch', 'command_timeout',
32
32
  'output_limit', 'command_failed', 'transport_failure', 'remote_invalid',
33
+ 'artifact_write_failed',
33
34
  })
34
35
 
35
36
 
@@ -178,6 +179,17 @@ def decode_remote(status, raw):
178
179
  return result
179
180
 
180
181
 
182
+ def retain_result(path, result):
183
+ """Retain only the already validated public envelope, never captured host output."""
184
+ try:
185
+ target = Path(path)
186
+ target.parent.mkdir(parents=True, exist_ok=True)
187
+ with target.open('x', encoding='utf-8') as output:
188
+ output.write(json.dumps(result, separators=(',', ':')) + '\n')
189
+ except OSError:
190
+ raise ReaderFailure('artifact_write_failed') from None
191
+
192
+
181
193
  def main():
182
194
  parser = argparse.ArgumentParser(description=__doc__)
183
195
  parser.add_argument('--remote', action='store_true')
@@ -186,16 +198,27 @@ def main():
186
198
  parser.add_argument('--since', required=True)
187
199
  parser.add_argument('--host')
188
200
  parser.add_argument('--user', default='root')
201
+ parser.add_argument('--output', help='Exclusive local file for the closed diagnostic envelope')
189
202
  args = parser.parse_args()
203
+ status = 0
190
204
  try:
205
+ if args.remote and args.output:
206
+ raise ReaderFailure('invalid_destination')
191
207
  result = remote(args.app, args.service, args.since) if args.remote else local(args)
208
+ result = decode_remote(0, json.dumps(result).encode())
192
209
  except Exception as error:
193
210
  # Only our closed vocabulary survives; provider and parser exceptions remain unclassified.
194
211
  code = error.code if isinstance(error, ReaderFailure) else 'unclassified'
195
- print(json.dumps({'schema': SCHEMA, 'ok': False, 'failure': code, 'records': []}))
196
- return 1
212
+ result = {'schema': SCHEMA, 'ok': False, 'failure': code, 'records': []}
213
+ status = 1
214
+ if args.output and not args.remote:
215
+ try:
216
+ retain_result(args.output, result)
217
+ except ReaderFailure as error:
218
+ result = {'schema': SCHEMA, 'ok': False, 'failure': error.code, 'records': []}
219
+ status = 1
197
220
  print(json.dumps(result, separators=(',', ':')))
198
- return 0
221
+ return status
199
222
 
200
223
 
201
224
  if __name__ == '__main__':
@@ -0,0 +1,10 @@
1
+ // Only the exact pinned upload-artifact process receives this GitHub-specific transport.
2
+ try {
3
+ const scope = require('./cache_scope.cjs');
4
+ scope.authorize(process.argv[1], process.env, 'artifact');
5
+ scope.guardRequests();
6
+ for (const key of scope.proxyKeys) delete process.env[key];
7
+ } catch {
8
+ process.stderr.write('github_artifact_transport_scope_refused\n');
9
+ process.exit(1);
10
+ }
@@ -0,0 +1,83 @@
1
+ // Direct routing belongs only to pinned first-party Actions programs and approved HTTPS origins.
2
+ const {readFileSync, copyFileSync, mkdtempSync, appendFileSync} = require('node:fs');
3
+ const {createHash} = require('node:crypto');
4
+ const {basename, dirname, join} = require('node:path');
5
+ const hashes = new Map([
6
+ ['restore', 'a49301428b4c5477522c00f6464566457ed327d64ae5f80f8c12e762fb2ea9a2'],
7
+ ['save', 'b969065795aaf5c6d20ba158e910be5d353eefb02eb2785263eb32e13090c066'],
8
+ ['restore-only', '6255afaa3956351b8cfefc1e82f026b4db418a10678a8eaddf3d6c55f81744de'],
9
+ ['save-only', '7fb63f90f06ce6a10f39d40a113f99791bffdedad5394cdad5b4dfaa644559cb'],
10
+ ]);
11
+ const artifactHash = '168c44946cba03564808c19b83a72601e6d1d6f57081dbd2d6e2ea1cedc522f5';
12
+ const proxyKeys = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy'];
13
+ const refusal = () => new Error('github_cache_transport_scope_refused');
14
+
15
+ function allowedHost(host) {
16
+ return host === 'api.github.com'
17
+ || /^[a-z0-9.-]+\.actions\.githubusercontent\.com$/.test(host)
18
+ || /^[a-z0-9]{3,24}\.blob\.core\.windows\.net$/.test(host);
19
+ }
20
+
21
+ function requireOrigin(value, service = false) {
22
+ let url;
23
+ try { url = new URL(value); } catch { throw refusal(); }
24
+ if (url.protocol !== 'https:' || url.port || url.username || url.password || !allowedHost(url.hostname)
25
+ || (service && !url.hostname.endsWith('.actions.githubusercontent.com'))) throw refusal();
26
+ return url;
27
+ }
28
+
29
+ function authorize(entrypoint, env, kind = 'cache') {
30
+ if (!entrypoint || basename(entrypoint) !== 'index.js' || basename(dirname(dirname(entrypoint))) !== 'dist') {
31
+ throw refusal();
32
+ }
33
+ if (!['cache', 'artifact'].includes(kind)) throw refusal();
34
+ const program = basename(dirname(entrypoint));
35
+ const expected = kind === 'artifact' ? (program === 'upload' && artifactHash) : hashes.get(program);
36
+ if (!expected || createHash('sha256').update(readFileSync(entrypoint)).digest('hex') !== expected) throw refusal();
37
+ const origins = (kind === 'artifact' ? [env.ACTIONS_RESULTS_URL]
38
+ : [env.ACTIONS_RESULTS_URL, env.ACTIONS_CACHE_URL]).filter(Boolean);
39
+ if (!origins.length) throw refusal();
40
+ for (const origin of origins) requireOrigin(origin, true);
41
+ }
42
+
43
+ function requireRequest(args, protocol) {
44
+ const [input, extra] = args;
45
+ const options = typeof input === 'string' || input instanceof URL
46
+ ? {protocol, ...require('node:url').urlToHttpOptions(requireOrigin(input)),
47
+ ...(typeof extra === 'object' ? extra : {})}
48
+ : {...input};
49
+ if (!options || options.socketPath || (options.protocol || protocol) !== 'https:') throw refusal();
50
+ for (const host of [options.hostname, options.host].filter(Boolean)) {
51
+ requireOrigin(`https://${host}${options.port ? ':' + options.port : ''}`);
52
+ }
53
+ if (!options.hostname && !options.host) throw refusal();
54
+ }
55
+
56
+ function guardRequests() {
57
+ for (const [name, protocol] of [['node:http', 'http:'], ['node:https', 'https:']]) {
58
+ const client = require(name);
59
+ for (const verb of ['request', 'get']) {
60
+ const original = client[verb];
61
+ client[verb] = function (...args) { requireRequest(args, protocol); return original.apply(this, args); };
62
+ }
63
+ }
64
+ if (globalThis.fetch) {
65
+ const original = globalThis.fetch;
66
+ globalThis.fetch = function (input, init) {
67
+ requireOrigin(typeof input === 'string' || input instanceof URL ? input : input.url);
68
+ return original.call(this, input, {...init, redirect: 'error'});
69
+ };
70
+ }
71
+ }
72
+
73
+ function snapshot() {
74
+ const target = mkdtempSync(join(process.env.RUNNER_TEMP, 'gowalk-cache-scope-'));
75
+ for (const file of ['cache_env.cjs', 'cache_scope.cjs']) copyFileSync(join(__dirname, file), join(target, file));
76
+ appendFileSync(process.env.GITHUB_OUTPUT, `preloader=${join(target, 'cache_env.cjs')}\n`);
77
+ }
78
+
79
+ module.exports = {authorize, guardRequests, proxyKeys, requireOrigin};
80
+ if (require.main === module) {
81
+ try { if (process.argv[2] !== '--snapshot') throw refusal(); snapshot(); }
82
+ catch { process.stderr.write('github_cache_scope_snapshot_failed\n'); process.exitCode = 1; }
83
+ }
@@ -0,0 +1,77 @@
1
+ """The dedicated artifact carries the complete closed result independently of log masking."""
2
+ from contextlib import redirect_stdout
3
+ import hashlib
4
+ import io
5
+ import json
6
+ from pathlib import Path
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from unittest.mock import patch
11
+ from test_identity_diagnostics import READER, SINCE
12
+
13
+
14
+ class IdentityArtifact(unittest.TestCase):
15
+ def invoke(self, path, *, result=None, error=None, remote=False):
16
+ argv = ['reader', '--app', 'fixture', '--service', 'api', '--since', SINCE, '--output', str(path)]
17
+ if remote:
18
+ argv.append('--remote')
19
+ output = io.StringIO()
20
+ with patch.object(sys, 'argv', argv), patch.object(READER, 'local', return_value=result,
21
+ side_effect=error), redirect_stdout(output):
22
+ status = READER.main()
23
+ return status, output.getvalue()
24
+
25
+ def test_success_file_survives_structural_log_redaction_byte_for_byte(self):
26
+ result = {'schema': READER.SCHEMA, 'ok': True, 'failure': '',
27
+ 'records': [{'stage': 'auth_signature', 'reason': 'token_invalid'}]}
28
+ with tempfile.TemporaryDirectory() as folder:
29
+ path = Path(folder) / 'result.json'
30
+ status, log = self.invoke(path, result=result)
31
+ data = path.read_bytes()
32
+ self.assertEqual(status, 0)
33
+ self.assertEqual(json.loads(data), result)
34
+ self.assertNotEqual(log.replace('{', '***').replace('}', '***'), data.decode())
35
+ self.assertEqual(hashlib.sha256(data).digest(), hashlib.sha256(log.encode()).digest())
36
+
37
+ def test_classified_and_unknown_failures_are_retained_without_exception_data(self):
38
+ for failure, expected in [(READER.ReaderFailure('container_scope_mismatch'), 'container_scope_mismatch'),
39
+ (RuntimeError('PRIVATE_SENTINEL'), 'unclassified')]:
40
+ with self.subTest(expected=expected), tempfile.TemporaryDirectory() as folder:
41
+ path = Path(folder) / 'result.json'
42
+ status, log = self.invoke(path, error=failure)
43
+ self.assertEqual(status, 1)
44
+ self.assertEqual(json.loads(path.read_bytes()), {
45
+ 'schema': READER.SCHEMA, 'ok': False, 'failure': expected, 'records': []})
46
+ self.assertNotIn('PRIVATE_SENTINEL', log + path.read_text())
47
+
48
+ def test_unvalidated_fields_never_reach_artifact(self):
49
+ invalid = {'schema': READER.SCHEMA, 'ok': True, 'failure': '', 'records': [], 'raw': 'PRIVATE_SENTINEL'}
50
+ with tempfile.TemporaryDirectory() as folder:
51
+ path = Path(folder) / 'result.json'
52
+ status, log = self.invoke(path, result=invalid)
53
+ self.assertEqual(status, 1)
54
+ self.assertEqual(json.loads(path.read_text())['failure'], 'remote_invalid')
55
+ self.assertNotIn('PRIVATE_SENTINEL', log + path.read_text())
56
+
57
+ def test_existing_file_is_preserved_and_write_failure_is_closed(self):
58
+ with tempfile.TemporaryDirectory() as folder:
59
+ path = Path(folder) / 'result.json'; path.write_text('existing')
60
+ status, log = self.invoke(path, error=READER.ReaderFailure('invalid_scope'))
61
+ self.assertEqual(status, 1)
62
+ self.assertEqual(json.loads(log)['failure'], 'artifact_write_failed')
63
+ self.assertEqual(path.read_text(), 'existing')
64
+
65
+ def test_remote_mode_cannot_create_output_files(self):
66
+ with tempfile.TemporaryDirectory() as folder:
67
+ path = Path(folder) / 'result.json'
68
+ with patch.object(READER, 'remote') as remote:
69
+ status, log = self.invoke(path, remote=True)
70
+ self.assertEqual(status, 1)
71
+ self.assertEqual(json.loads(log)['failure'], 'invalid_destination')
72
+ self.assertFalse(path.exists())
73
+ remote.assert_not_called()
74
+
75
+
76
+ if __name__ == '__main__':
77
+ unittest.main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.128",
3
+ "version": "1.0.130",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {