gowalk-cicd 1.0.97 → 1.0.98

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
@@ -227,6 +227,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
227
227
  is the only place that decides which; every downstream step branches on the
228
228
  `project_kind` output rather than re-sniffing the repo. Flutter wins the tie
229
229
  because a Flutter app also carries `android/settings.gradle`.
230
+ - Both Android release compilers run through `android_build_diagnostics.py` inside the
231
+ existing proxy wrapper. It retains at most 64 KiB of private child output and publishes
232
+ only fixed signal names, phase and exit status in `android_build_failed`. An empty signal
233
+ list is unclassified, never a guessed cause; matching signals are observations, not a retry
234
+ decision. Minute progress notices replace raw compiler output. Preserve the actual exit,
235
+ command arguments, proxy environment, signing and symbol checks.
230
236
  - iOS plist stamping reads complete JSON build settings through `resolve_info_plist.py`,
231
237
  preserving Xcode's exit status and bounded sanitized stderr. Select exactly the configured
232
238
  bundle's application target and resolve paths from that target's source root; never take
package/README.md CHANGED
@@ -314,6 +314,12 @@ the situation and whose **message** is one line of JSON carrying a versioned
314
314
  | `source_maintenance_pending` | `gowalk-cicd/source-maintenance-pending.v1` | `repo`, `base_sha`, `head_sha`, `branch`, `run_id` |
315
315
  | `apple_build_upload_failed` (error) | `gowalk-cicd/apple-build-upload-failed.v2` | `category`, `stage`, `receipt_available`, receipt identifiers (`cfBundleVersion`, `upload_id`, `file_id`, `state`, `provider_status`, `provider_code`, `provider_pointer`), `action` |
316
316
  | `flutter_download_failed` (error) | `gowalk-cicd/flutter-download-failed.v1` | `phase`, `curl_exit`, `http_status`, `proxy_status`, `attempts`, `retained_bytes` |
317
+ | `android_build_failed` (error) | `gowalk-cicd/android-build-failed.v1` | `phase`, `exit_code`, `signals` (fixed observed patterns; empty means unclassified) |
318
+
319
+ Android release compilers retain only a bounded private output window. Their failure signals
320
+ describe observed error patterns, not retry permission or a unique root cause. Silent builds
321
+ emit `android_build_progress` notices (`gowalk-cicd/android-build-progress.v1`) every minute
322
+ with `phase` and `elapsed_seconds`; successful compiler output is not republished.
317
323
 
318
324
  SDK transfers also emit `flutter_download_progress` notices with schema
319
325
  `gowalk-cicd/flutter-download-progress.v1`. Each carries `phase`, `event`, `elapsed_ms`
@@ -1 +1 @@
1
- 1.0.97
1
+ 1.0.98
@@ -1 +1 @@
1
- 1.0.97
1
+ 1.0.98
@@ -178,7 +178,9 @@ runs:
178
178
  *) echo "::error::dart-defines entry '$define' is not KEY=VALUE"; exit 1 ;;
179
179
  esac
180
180
  done
181
- python3 "${{ github.action_path }}/scripts/play_store_proxy.py" -- flutter "${args[@]}"
181
+ python3 "${{ github.action_path }}/scripts/play_store_proxy.py" -- \
182
+ python3 "${{ github.action_path }}/scripts/android_build_diagnostics.py" --phase flutter_bundle -- \
183
+ flutter "${args[@]}"
182
184
  if ! ls "$DART_SYMBOLS_DIR"/*.symbols >/dev/null 2>&1; then
183
185
  echo "::error::flutter build wrote no Dart symbol files to $DART_SYMBOLS_DIR;" \
184
186
  "a crash from this obfuscated build could never be read. Refusing to ship it."
@@ -267,6 +269,7 @@ runs:
267
269
  run: |
268
270
  chmod +x ./gradlew
269
271
  python3 "${{ github.action_path }}/scripts/play_store_proxy.py" -- \
272
+ python3 "${{ github.action_path }}/scripts/android_build_diagnostics.py" --phase gradle_bundle -- \
270
273
  ./gradlew "${ANDROID_GRADLE_MODULE}:bundleRelease" \
271
274
  --init-script "${{ github.action_path }}/scripts/version_override.init.gradle" \
272
275
  --console=plain --stacktrace
@@ -0,0 +1,80 @@
1
+ """Run one Android compiler with bounded private output and fixed failure signals."""
2
+ import argparse
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ import threading
7
+ import time
8
+
9
+ LIMIT = 65536
10
+ SIGNALS = {
11
+ "tls_verification": ("PKIX path building failed", "CERTIFICATE_VERIFY_FAILED", "certificate verify failed"),
12
+ "dependency_resolution": ("Could not resolve all files", "Could not resolve all artifacts", "Could not GET '"),
13
+ "connection_timeout": ("Connect timed out", "Read timed out", "Connection timed out"),
14
+ "kotlin_compilation": ("Compilation error. See log for more details", "Kotlin compilation failed"),
15
+ "dart_compilation": ("Target kernel_snapshot_program failed", "Target kernel_snapshot failed"),
16
+ "android_resources": ("Android resource linking failed", "Android resource compilation failed"),
17
+ "r8_missing_classes": ("Missing classes detected while running R8",),
18
+ "duplicate_classes": ("Duplicate class ",),
19
+ "manifest_merge": ("Manifest merger failed",),
20
+ "signing_configuration": ("Keystore was tampered with", "Keystore file ", "Failed to read key "),
21
+ "out_of_memory": ("java.lang.OutOfMemoryError", "Java heap space", "GC overhead limit exceeded"),
22
+ "gradle_daemon_lost": ("Gradle build daemon disappeared unexpectedly",),
23
+ "disk_full": ("No space left on device",),
24
+ }
25
+
26
+
27
+ def signals(output: bytes) -> list[str]:
28
+ text = output.decode("utf-8", errors="replace")
29
+ return sorted(name for name, patterns in SIGNALS.items() if any(pattern in text for pattern in patterns))
30
+
31
+
32
+ def emit(title: str, level: str, **fields) -> None:
33
+ print(f"::{level} title={title}::" + json.dumps(fields, separators=(",", ":")), flush=True)
34
+
35
+
36
+ def drain(stream, tail: bytearray) -> None:
37
+ while chunk := stream.read1(8192):
38
+ tail[:] = (tail + chunk)[-LIMIT:]
39
+
40
+
41
+ def run(phase: str, command: list[str], *, interval: float = 60) -> int:
42
+ started, tail = time.monotonic(), bytearray()
43
+ try:
44
+ child = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
45
+ except OSError:
46
+ emit("android_build_failed", "error", schema="gowalk-cicd/android-build-failed.v1",
47
+ phase=phase, exit_code=127, signals=["command_start_failed"])
48
+ return 127
49
+ reader = threading.Thread(target=drain, args=(child.stdout, tail), daemon=True)
50
+ reader.start()
51
+ while True:
52
+ try:
53
+ code = child.wait(timeout=interval)
54
+ break
55
+ except subprocess.TimeoutExpired:
56
+ emit("android_build_progress", "notice", schema="gowalk-cicd/android-build-progress.v1",
57
+ phase=phase, elapsed_seconds=int(time.monotonic() - started))
58
+ # A descendant retaining stdout must not hold a finished compiler's result indefinitely.
59
+ reader.join(timeout=1)
60
+ if not reader.is_alive():
61
+ child.stdout.close()
62
+ if code:
63
+ emit("android_build_failed", "error", schema="gowalk-cicd/android-build-failed.v1",
64
+ phase=phase, exit_code=code, signals=signals(bytes(tail)))
65
+ return code if code >= 0 else 128 - code
66
+
67
+
68
+ def main(argv: list[str]) -> int:
69
+ parser = argparse.ArgumentParser(description=__doc__)
70
+ parser.add_argument("--phase", required=True, choices=("flutter_bundle", "gradle_bundle"))
71
+ parser.add_argument("command", nargs=argparse.REMAINDER)
72
+ args = parser.parse_args(argv)
73
+ command = args.command[1:] if args.command[:1] == ["--"] else args.command
74
+ if not command:
75
+ parser.error("a build command is required")
76
+ return run(args.phase, command)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,108 @@
1
+ """Exercise real child failures without publishing child output or altering its environment."""
2
+ import contextlib
3
+ import io
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import unittest
11
+ from unittest import mock
12
+
13
+ import android_build_diagnostics as diagnostics
14
+ import yaml
15
+ from test_jvm_proxy_relay import upstream
16
+
17
+
18
+ class BuildDiagnosticsTests(unittest.TestCase):
19
+ def invoke(self, script, **kwargs):
20
+ output = io.StringIO()
21
+ with contextlib.redirect_stdout(output):
22
+ code = diagnostics.run("flutter_bundle", [sys.executable, "-c", script], **kwargs)
23
+ rows = [json.loads(line.split("::", 2)[2]) for line in output.getvalue().splitlines()]
24
+ return code, rows, output.getvalue()
25
+
26
+ def test_child_failure_keeps_exit_and_only_fixed_signals(self):
27
+ code, rows, output = self.invoke(
28
+ "import sys; print('secret=do-not-publish https://user:password@example.test');"
29
+ "print('Missing classes detected while running R8'); sys.exit(42)"
30
+ )
31
+ self.assertEqual(code, 42)
32
+ self.assertEqual(rows, [{"schema": "gowalk-cicd/android-build-failed.v1", "phase": "flutter_bundle",
33
+ "exit_code": 42, "signals": ["r8_missing_classes"]}])
34
+ self.assertNotIn("password", output)
35
+ self.assertNotIn("do-not-publish", output)
36
+
37
+ def test_unknown_failure_never_guesses_or_echoes_output(self):
38
+ code, rows, output = self.invoke("import sys; print('private provider exception'); sys.exit(1)")
39
+ self.assertEqual(code, 1)
40
+ self.assertEqual(rows[0]["signals"], [])
41
+ self.assertNotIn("private provider", output)
42
+
43
+ def test_success_preserves_environment_and_arguments_without_echoing_them(self):
44
+ with mock.patch.dict(os.environ, {"HTTPS_PROXY": "http://private.invalid:9", "NO_PROXY": ""}):
45
+ code, rows, output = self.invoke(
46
+ "import os; assert os.environ['HTTPS_PROXY']=='http://private.invalid:9';"
47
+ "assert os.environ['NO_PROXY']==''; print('secret successful compiler output')"
48
+ )
49
+ self.assertEqual((code, rows, output), (0, [], ""))
50
+
51
+ def test_large_output_stays_bounded_and_recent_failures_are_retained(self):
52
+ tail = bytearray()
53
+ content = b"secret" * diagnostics.LIMIT + b"Android resource linking failed"
54
+ with io.BufferedReader(io.BytesIO(content)) as stream:
55
+ diagnostics.drain(stream, tail)
56
+ self.assertEqual(len(tail), diagnostics.LIMIT)
57
+ self.assertEqual(diagnostics.signals(tail), ["android_resources"])
58
+
59
+ def test_long_silent_build_emits_only_safe_progress(self):
60
+ code, rows, _ = self.invoke("import time; time.sleep(.12)", interval=.03)
61
+ self.assertEqual(code, 0)
62
+ self.assertTrue(rows)
63
+ for row in rows:
64
+ self.assertEqual(row["schema"], "gowalk-cicd/android-build-progress.v1")
65
+ self.assertEqual(row["phase"], "flutter_bundle")
66
+
67
+ def test_missing_program_has_typed_failure_without_argv(self):
68
+ output = io.StringIO()
69
+ with contextlib.redirect_stdout(output):
70
+ code = diagnostics.run("gradle_bundle", ["/missing/private-program"])
71
+ self.assertEqual(code, 127)
72
+ self.assertNotIn("private-program", output.getvalue())
73
+ self.assertIn('"signals":["command_start_failed"]', output.getvalue())
74
+
75
+ def test_cli_forwards_exact_argument_vector(self):
76
+ with mock.patch.object(diagnostics, "run", return_value=7) as run:
77
+ self.assertEqual(diagnostics.main(["--phase", "gradle_bundle", "--", "./gradlew", "a b"]), 7)
78
+ run.assert_called_once_with("gradle_bundle", ["./gradlew", "a b"])
79
+
80
+ def test_real_release_step_keeps_proxy_and_compiler_arguments_on_failure(self):
81
+ root = Path(__file__).resolve().parents[1]
82
+ action = yaml.safe_load((root / "action.yml").read_text())
83
+ step = next(row for row in action["runs"]["steps"] if row["name"] == "Build release Android App Bundle")
84
+ with tempfile.TemporaryDirectory() as directory, upstream() as (proxy, _seen):
85
+ cwd = Path(directory)
86
+ flutter = cwd / "flutter"
87
+ flutter.write_text(f"#!{sys.executable}\nimport os,sys,json\nfrom pathlib import Path\n"
88
+ "assert os.environ['HTTPS_PROXY'].startswith('http://127.0.0.1:')\n"
89
+ "assert os.environ['NO_PROXY']==''\n"
90
+ "Path('arguments.json').write_text(json.dumps(sys.argv[1:]))\n"
91
+ "print('Missing classes detected while running R8: private-class')\nsys.exit(41)\n")
92
+ flutter.chmod(0o700)
93
+ env = {**os.environ, "GOOGLE_STORE_PROXY_URL": proxy, "PATH": directory + os.pathsep + os.environ["PATH"],
94
+ "ANDROID_BUILD_NUMBER": "17", "BUILD_NAME": "", "DART_DEFINES": "",
95
+ "DART_SYMBOLS_DIR": str(cwd / "symbols"), "NO_PROXY": "*", "HTTPS_PROXY": "http://wrong.invalid"}
96
+ script = step["run"].replace("${{ github.action_path }}", str(root))
97
+ result = subprocess.run(["bash", "-eo", "pipefail", "-c", script], cwd=cwd, env=env,
98
+ capture_output=True, text=True, timeout=20)
99
+ args = json.loads((cwd / "arguments.json").read_text())
100
+ self.assertEqual(result.returncode, 41, result.stdout + result.stderr)
101
+ self.assertIn("--obfuscate", args)
102
+ self.assertIn("--split-debug-info", args)
103
+ self.assertIn('"signals":["r8_missing_classes"]', result.stdout)
104
+ self.assertNotIn("private-class", result.stdout + result.stderr)
105
+
106
+
107
+ if __name__ == "__main__":
108
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.97
1
+ 1.0.98
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.97",
3
+ "version": "1.0.98",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {