gowalk-cicd 1.0.97 → 1.0.99
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 +11 -0
- package/README.md +16 -0
- package/action/.daemux-version +1 -1
- package/action/scripts/native_bundle.py +112 -0
- package/action/scripts/native_pods.py +6 -11
- package/action/scripts/test_native_bundle.py +97 -0
- package/action/scripts/test_native_dependency_wiring.py +9 -0
- package/action/scripts/test_native_pods.py +29 -0
- package/android-action/.daemux-version +1 -1
- package/android-action/action.yml +4 -1
- package/android-action/scripts/android_build_diagnostics.py +80 -0
- package/android-action/scripts/test_android_build_diagnostics.py +108 -0
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
- package/templates/deploy.yml +2 -1
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
|
|
@@ -243,6 +249,11 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
243
249
|
dependency download or Google proxy requirement. Initial local lock resolution may use the
|
|
244
250
|
explicit `--before-pods --initial-pods-lock` guard; CI never uses that development exception.
|
|
245
251
|
These checks cover the declared native dependency graph, not arbitrary custom build-script traffic.
|
|
252
|
+
`native_bundle.py` honors the selected project's committed Gemfile/lock, falling back to
|
|
253
|
+
the repository root; missing gems install through the same relay with a frozen bundle.
|
|
254
|
+
Both Flutter configuration and the Pods helper use that bundle in their child process,
|
|
255
|
+
preserving Podfile.lock and the parent job environment. Deliver the workflow wrapper and
|
|
256
|
+
action helper together; action-only updates cannot repair earlier custom configuration steps.
|
|
246
257
|
- Version stamping is per-build-system: Flutter takes `--build-number`, Gradle
|
|
247
258
|
has no equivalent, so `set_gradle_version()` rewrites the literal in the
|
|
248
259
|
checkout the way the iOS action patches the pbxproj. Never commit that edit.
|
package/README.md
CHANGED
|
@@ -48,6 +48,16 @@ and keeps existing cache keys. A successfully installed SDK is saved before app
|
|
|
48
48
|
checks run, so a later test/build failure does not force another cold SDK download.
|
|
49
49
|
Failed setup never saves an SDK cache; pub dependencies retain their job-end save.
|
|
50
50
|
|
|
51
|
+
Native dependency preparation uses a committed `Gemfile` and `Gemfile.lock` beside
|
|
52
|
+
the selected iOS project, falling back to the repository root. It installs missing
|
|
53
|
+
bundle gems through the assigned Google relay and runs Pods with `bundle exec`.
|
|
54
|
+
The Flutter workflow uses that same bundle before configuration can invoke Pods;
|
|
55
|
+
Ruby settings remain scoped to those commands. Provide a Ruby/Bundler runtime
|
|
56
|
+
compatible with the app's lockfile. Changed or uncommitted locks fail before delivery.
|
|
57
|
+
When adopting this update, retain the `native_bundle.py` configuration wrapper in
|
|
58
|
+
custom workflows as well as updating the action files. Apps without a Gemfile keep
|
|
59
|
+
their existing CocoaPods runtime.
|
|
60
|
+
|
|
51
61
|
## Flutter web release
|
|
52
62
|
|
|
53
63
|
The conditional `deploy-web.yml` workflow runs on changes to Flutter source,
|
|
@@ -314,6 +324,12 @@ the situation and whose **message** is one line of JSON carrying a versioned
|
|
|
314
324
|
| `source_maintenance_pending` | `gowalk-cicd/source-maintenance-pending.v1` | `repo`, `base_sha`, `head_sha`, `branch`, `run_id` |
|
|
315
325
|
| `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
326
|
| `flutter_download_failed` (error) | `gowalk-cicd/flutter-download-failed.v1` | `phase`, `curl_exit`, `http_status`, `proxy_status`, `attempts`, `retained_bytes` |
|
|
327
|
+
| `android_build_failed` (error) | `gowalk-cicd/android-build-failed.v1` | `phase`, `exit_code`, `signals` (fixed observed patterns; empty means unclassified) |
|
|
328
|
+
|
|
329
|
+
Android release compilers retain only a bounded private output window. Their failure signals
|
|
330
|
+
describe observed error patterns, not retry permission or a unique root cause. Silent builds
|
|
331
|
+
emit `android_build_progress` notices (`gowalk-cicd/android-build-progress.v1`) every minute
|
|
332
|
+
with `phase` and `elapsed_seconds`; successful compiler output is not republished.
|
|
317
333
|
|
|
318
334
|
SDK transfers also emit `flutter_download_progress` notices with schema
|
|
319
335
|
`gowalk-cicd/flutter-download-progress.v1`. Each carries `phase`, `event`, `elapsed_ms`
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.99
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Use the app's committed Ruby bundle for native preparation without changing the job environment."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
import native_dependency_guard as guard
|
|
16
|
+
from native_pods_diagnostics import refuse
|
|
17
|
+
import native_pods_process
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def command(argv: list[str], cwd: Path, log) -> int:
|
|
21
|
+
try:
|
|
22
|
+
return native_pods_process.command(argv, cwd, log)
|
|
23
|
+
except native_pods_process.Interrupted:
|
|
24
|
+
raise guard.Refused("native_pods_operation_interrupted") from None
|
|
25
|
+
except (OSError, RuntimeError, subprocess.SubprocessError):
|
|
26
|
+
raise guard.Refused("native_pods_process_unverified") from None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def committed(root: Path, path: Path, kind: str) -> str:
|
|
30
|
+
if not path.is_file() or path.is_symlink() or path.stat().st_size > 2 * 1024 * 1024:
|
|
31
|
+
raise guard.Refused(f"native_{kind}_required")
|
|
32
|
+
if not path.resolve().is_relative_to(root):
|
|
33
|
+
raise guard.Refused("native_bundle_path_unverified")
|
|
34
|
+
result = subprocess.run(["git", "show", "HEAD:" + path.relative_to(root).as_posix()],
|
|
35
|
+
cwd=root, capture_output=True, check=False, timeout=15)
|
|
36
|
+
if result.returncode or result.stdout != path.read_bytes():
|
|
37
|
+
raise guard.Refused(f"native_{kind}_commit_required")
|
|
38
|
+
return hashlib.sha256(result.stdout).hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def unchanged(snapshot: dict[Path, str]) -> None:
|
|
42
|
+
for path, digest in snapshot.items():
|
|
43
|
+
if not path.is_file() or path.is_symlink() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
|
|
44
|
+
raise guard.Refused("native_bundle_inputs_changed")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def prepare(root: Path, folder: Path, proxy_helper: Path, log) -> tuple[list[str], dict[Path, str]]:
|
|
48
|
+
gemfile = next((p for p in (folder / "Gemfile", root / "Gemfile") if p.exists() or p.is_symlink()), None)
|
|
49
|
+
if gemfile is None:
|
|
50
|
+
return [], {}
|
|
51
|
+
snapshot = {gemfile: committed(root, gemfile, "gemfile"),
|
|
52
|
+
gemfile.with_name("Gemfile.lock"): committed(root, gemfile.with_name("Gemfile.lock"), "gemfile_lock")}
|
|
53
|
+
prefix = ["/usr/bin/env", "BUNDLE_GEMFILE=" + str(gemfile), "BUNDLE_FROZEN=true",
|
|
54
|
+
"BUNDLE_PATH=" + (os.environ.get("BUNDLE_PATH") or str(root / ".factory/toolchain/gems"))]
|
|
55
|
+
relay = [sys.executable, str(proxy_helper), "--", *prefix, "bundle"]
|
|
56
|
+
if command([*relay, "check"], gemfile.parent, log):
|
|
57
|
+
if command([*relay, "install", "--jobs", "2", "--retry", "1"], gemfile.parent, log):
|
|
58
|
+
refuse("native_bundle_install_failed", log, guard.Refused)
|
|
59
|
+
unchanged(snapshot)
|
|
60
|
+
return [*prefix, "bundle", "exec"], snapshot
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run(root: Path, folder: Path, proxy_helper: Path, argv: list[str]) -> dict:
|
|
64
|
+
root = root.resolve(strict=True)
|
|
65
|
+
folder = (root / folder).resolve(strict=True)
|
|
66
|
+
proxy_helper = proxy_helper.resolve(strict=True)
|
|
67
|
+
if not folder.is_relative_to(root) or not argv:
|
|
68
|
+
raise guard.Refused("native_bundle_path_unverified")
|
|
69
|
+
if (folder / "Podfile").is_symlink():
|
|
70
|
+
raise guard.Refused("native_workspace_reference_unverified")
|
|
71
|
+
pod_lock = folder / "Podfile.lock"
|
|
72
|
+
pod_snapshot = {pod_lock: committed(root, pod_lock, "podfile_lock")} if (folder / "Podfile").exists() else {}
|
|
73
|
+
with tempfile.TemporaryFile() as log:
|
|
74
|
+
if command([sys.executable, str(proxy_helper)], root, log):
|
|
75
|
+
raise guard.Refused("native_google_proxy_required")
|
|
76
|
+
prefix, snapshot = prepare(root, folder, proxy_helper, log)
|
|
77
|
+
if command([sys.executable, str(proxy_helper), "--", *prefix, *argv], root, log):
|
|
78
|
+
refuse("native_configuration_failed", log, guard.Refused)
|
|
79
|
+
unchanged(snapshot)
|
|
80
|
+
for path, digest in pod_snapshot.items():
|
|
81
|
+
if not path.is_file() or path.is_symlink() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
|
|
82
|
+
raise guard.Refused("native_podfile_lock_changed")
|
|
83
|
+
return {"ok": True, "ruby_bundle": "locked" if snapshot else "not_configured"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def interrupted(*_args) -> None:
|
|
87
|
+
raise KeyboardInterrupt
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(argv: list[str]) -> int:
|
|
91
|
+
signal.signal(signal.SIGTERM, interrupted)
|
|
92
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
93
|
+
parser.add_argument("--root", type=Path, default=Path.cwd())
|
|
94
|
+
parser.add_argument("--folder", type=Path, default=Path("ios"))
|
|
95
|
+
parser.add_argument("--proxy-helper", type=Path, required=True)
|
|
96
|
+
parser.add_argument("command", nargs=argparse.REMAINDER)
|
|
97
|
+
args = parser.parse_args(argv)
|
|
98
|
+
command_args = args.command[1:] if args.command[:1] == ["--"] else args.command
|
|
99
|
+
try:
|
|
100
|
+
answer = run(args.root, args.folder, args.proxy_helper, command_args)
|
|
101
|
+
except (guard.Refused, OSError, subprocess.SubprocessError) as exc:
|
|
102
|
+
code = str(exc) if isinstance(exc, guard.Refused) else "native_bundle_operation_unverified"
|
|
103
|
+
answer = {"ok": False, "code": code, "next": "Use the app's committed Gemfile and Gemfile.lock with a "
|
|
104
|
+
"compatible Ruby/Bundler runtime; reconcile dependency locks locally and retain deployment guards."}
|
|
105
|
+
if getattr(exc, "diagnostic", ""):
|
|
106
|
+
answer["diagnostic"] = exc.diagnostic
|
|
107
|
+
print(json.dumps(answer, sort_keys=True))
|
|
108
|
+
return 0 if answer["ok"] else 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -13,8 +13,9 @@ import sys
|
|
|
13
13
|
import tempfile
|
|
14
14
|
|
|
15
15
|
import native_dependency_guard as guard
|
|
16
|
+
import native_bundle
|
|
17
|
+
from native_bundle import command
|
|
16
18
|
from native_pods_diagnostics import refuse
|
|
17
|
-
import native_pods_process
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def committed(root: Path, lock: Path) -> str:
|
|
@@ -27,15 +28,6 @@ def committed(root: Path, lock: Path) -> str:
|
|
|
27
28
|
return hashlib.sha256(result.stdout).hexdigest()
|
|
28
29
|
|
|
29
30
|
|
|
30
|
-
def command(argv: list[str], cwd: Path, log) -> int:
|
|
31
|
-
try:
|
|
32
|
-
return native_pods_process.command(argv, cwd, log)
|
|
33
|
-
except native_pods_process.Interrupted:
|
|
34
|
-
raise guard.Refused("native_pods_operation_interrupted") from None
|
|
35
|
-
except (OSError, RuntimeError, subprocess.SubprocessError):
|
|
36
|
-
raise guard.Refused("native_pods_process_unverified") from None
|
|
37
|
-
|
|
38
|
-
|
|
39
31
|
def prepare(root: Path, container: str, proxy_helper: Path) -> dict:
|
|
40
32
|
root = root.resolve(strict=True)
|
|
41
33
|
folder = (root / container).parent.resolve(strict=True) if container else root
|
|
@@ -56,9 +48,12 @@ def prepare(root: Path, container: str, proxy_helper: Path) -> dict:
|
|
|
56
48
|
refuse("native_project_generation_failed", log, guard.Refused)
|
|
57
49
|
initial = container or str(guard.selected(root, "", True).relative_to(root))
|
|
58
50
|
guard.check(root, initial, before_pods=True)
|
|
59
|
-
|
|
51
|
+
prefix, bundle_snapshot = native_bundle.prepare(root, folder, proxy_helper, log)
|
|
52
|
+
if command([sys.executable, str(proxy_helper), "--", *prefix, "pod", "install",
|
|
53
|
+
"--deployment", "--no-repo-update"],
|
|
60
54
|
folder, log):
|
|
61
55
|
refuse("native_pods_install_failed", log, guard.Refused)
|
|
56
|
+
native_bundle.unchanged(bundle_snapshot)
|
|
62
57
|
if digest != hashlib.sha256((folder / "Podfile.lock").read_bytes()).hexdigest():
|
|
63
58
|
raise guard.Refused("native_podfile_lock_changed")
|
|
64
59
|
workspaces = sorted(folder.glob("*.xcworkspace"))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Exercise real child scoping and committed bundle failures without contacting a provider."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
import native_bundle
|
|
11
|
+
|
|
12
|
+
PROXY = Path(__file__).resolve().parents[2] / "android-action/scripts/play_store_proxy.py"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BundleTests(unittest.TestCase):
|
|
16
|
+
def setUp(self):
|
|
17
|
+
temporary = tempfile.TemporaryDirectory(prefix="native-bundle-test-")
|
|
18
|
+
self.addCleanup(temporary.cleanup)
|
|
19
|
+
self.root = Path(temporary.name).resolve()
|
|
20
|
+
(self.root / "ios").mkdir()
|
|
21
|
+
self.env = {**os.environ, "GOOGLE_STORE_PROXY_URL": "http://fixture:PRIVATE_SENTINEL@127.0.0.1:9",
|
|
22
|
+
"PATH": str(self.root) + os.pathsep + os.environ["PATH"]}
|
|
23
|
+
self.env.pop("BUNDLE_PATH", None)
|
|
24
|
+
subprocess.run(["git", "init", "-q"], cwd=self.root, check=True, capture_output=True)
|
|
25
|
+
bundle = self.root / "bundle"
|
|
26
|
+
bundle.write_text('#!' + sys.executable + '\nimport os,sys\nfrom pathlib import Path\n'
|
|
27
|
+
'if sys.argv[1] == "check": raise SystemExit(int(os.environ.get("COLD_BUNDLE", "0")))\n'
|
|
28
|
+
'if sys.argv[1] == "install":\n'
|
|
29
|
+
' print("PRIVATE_SENTINEL")\n'
|
|
30
|
+
' if os.environ.get("CHANGE_LOCK"): Path("Gemfile.lock").write_text("changed")\n'
|
|
31
|
+
' raise SystemExit(int(os.environ.get("INSTALL_EXIT", "0")))\n'
|
|
32
|
+
'assert sys.argv[1] == "exec"\n'
|
|
33
|
+
'os.environ["FIXTURE_BUNDLE_ACTIVE"] = "yes"\n'
|
|
34
|
+
'os.execvp(sys.argv[2], sys.argv[2:])\n')
|
|
35
|
+
bundle.chmod(0o700)
|
|
36
|
+
|
|
37
|
+
def commit(self, *files):
|
|
38
|
+
for name in files:
|
|
39
|
+
path = self.root / name
|
|
40
|
+
path.write_text("fixture " + name + "\n")
|
|
41
|
+
for argv in (["git", "add", *files],
|
|
42
|
+
["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid",
|
|
43
|
+
"commit", "-qm", "Freeze fixture inputs"]):
|
|
44
|
+
subprocess.run(argv, cwd=self.root, check=True, capture_output=True)
|
|
45
|
+
|
|
46
|
+
def run_cli(self, script=None):
|
|
47
|
+
script = script or ('import json,os; from pathlib import Path; '
|
|
48
|
+
'Path("child.json").write_text(json.dumps({k:os.environ.get(k) for k in '
|
|
49
|
+
'["FIXTURE_BUNDLE_ACTIVE","BUNDLE_GEMFILE","BUNDLE_FROZEN","HTTPS_PROXY"]}))')
|
|
50
|
+
result = subprocess.run([sys.executable, native_bundle.__file__, "--root", str(self.root),
|
|
51
|
+
"--proxy-helper", str(PROXY), "--", sys.executable, "-c", script],
|
|
52
|
+
cwd=self.root, env=self.env, capture_output=True, text=True, timeout=20)
|
|
53
|
+
self.assertNotIn("PRIVATE_SENTINEL", result.stdout + result.stderr)
|
|
54
|
+
return result.returncode, json.loads(result.stdout)
|
|
55
|
+
|
|
56
|
+
def test_unconfigured_app_keeps_the_proxied_command(self):
|
|
57
|
+
code, value = self.run_cli()
|
|
58
|
+
self.assertEqual((code, value["ruby_bundle"]), (0, "not_configured"))
|
|
59
|
+
child = json.loads((self.root / "child.json").read_text())
|
|
60
|
+
self.assertIsNone(child["FIXTURE_BUNDLE_ACTIVE"])
|
|
61
|
+
self.assertTrue(child["HTTPS_PROXY"].startswith("http://127.0.0.1:"))
|
|
62
|
+
|
|
63
|
+
def test_nearest_bundle_scopes_configuration_and_preserves_locks(self):
|
|
64
|
+
self.commit("Gemfile", "Gemfile.lock", "ios/Gemfile", "ios/Gemfile.lock", "ios/Podfile", "ios/Podfile.lock")
|
|
65
|
+
self.env["COLD_BUNDLE"] = "1"
|
|
66
|
+
code, value = self.run_cli()
|
|
67
|
+
self.assertEqual((code, value["ruby_bundle"]), (0, "locked"))
|
|
68
|
+
child = json.loads((self.root / "child.json").read_text())
|
|
69
|
+
self.assertEqual(child["FIXTURE_BUNDLE_ACTIVE"], "yes")
|
|
70
|
+
self.assertEqual(child["BUNDLE_GEMFILE"], str(self.root / "ios/Gemfile"))
|
|
71
|
+
self.assertEqual(child["BUNDLE_FROZEN"], "true")
|
|
72
|
+
self.assertTrue(child["HTTPS_PROXY"].startswith("http://127.0.0.1:"))
|
|
73
|
+
|
|
74
|
+
def test_uncommitted_or_modified_bundle_refuses_before_configuration(self):
|
|
75
|
+
(self.root / "Gemfile").write_text("uncommitted")
|
|
76
|
+
self.assertEqual(self.run_cli()[1]["code"], "native_gemfile_commit_required")
|
|
77
|
+
self.commit("Gemfile")
|
|
78
|
+
self.assertEqual(self.run_cli()[1]["code"], "native_gemfile_lock_required")
|
|
79
|
+
self.commit("Gemfile.lock")
|
|
80
|
+
(self.root / "Gemfile.lock").write_text("changed")
|
|
81
|
+
self.assertEqual(self.run_cli()[1]["code"], "native_gemfile_lock_commit_required")
|
|
82
|
+
self.assertFalse((self.root / "child.json").exists())
|
|
83
|
+
|
|
84
|
+
def test_install_failure_and_mutation_do_not_launch_configuration(self):
|
|
85
|
+
self.commit("Gemfile", "Gemfile.lock")
|
|
86
|
+
self.env.update(COLD_BUNDLE="1", INSTALL_EXIT="7")
|
|
87
|
+
self.assertEqual(self.run_cli()[1]["code"], "native_bundle_install_failed")
|
|
88
|
+
self.env.update(INSTALL_EXIT="0", CHANGE_LOCK="1")
|
|
89
|
+
self.assertEqual(self.run_cli()[1]["code"], "native_bundle_inputs_changed")
|
|
90
|
+
self.assertFalse((self.root / "child.json").exists())
|
|
91
|
+
|
|
92
|
+
def test_failed_configuration_and_generated_lock_drift_cannot_pass(self):
|
|
93
|
+
self.commit("Gemfile", "Gemfile.lock", "ios/Podfile", "ios/Podfile.lock")
|
|
94
|
+
code, value = self.run_cli('print("PRIVATE_SENTINEL"); raise SystemExit(7)')
|
|
95
|
+
self.assertEqual((code, value["code"]), (1, "native_configuration_failed"))
|
|
96
|
+
script = 'from pathlib import Path; Path("ios/Podfile.lock").write_text("new serializer")'
|
|
97
|
+
self.assertEqual(self.run_cli(script)[1]["code"], "native_podfile_lock_changed")
|
|
@@ -8,6 +8,15 @@ ACTION = Path(__file__).resolve().parents[1] / "action.yml"
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class WiringTests(unittest.TestCase):
|
|
11
|
+
def test_flutter_configuration_uses_the_same_bundle_before_pods(self):
|
|
12
|
+
template = ACTION.parents[1] / "templates/deploy.yml"
|
|
13
|
+
body = template.read_text().split("- name: Prepare Flutter iOS project\n", 1)[1].split("\n - ", 1)[0]
|
|
14
|
+
shell = body.split("shell: >-\n", 1)[1].split("\n env:", 1)[0]
|
|
15
|
+
self.assertIn(".github/actions/swift-app/scripts/native_bundle.py", shell)
|
|
16
|
+
self.assertIn("--folder ios --proxy-helper .github/actions/android-app/scripts/play_store_proxy.py", shell)
|
|
17
|
+
self.assertIn("-- bash --noprofile --norc -e -o pipefail {0}", shell)
|
|
18
|
+
self.assertIn("flutter build ios", body)
|
|
19
|
+
|
|
11
20
|
def test_every_selected_xcode_step_has_its_own_preflight(self):
|
|
12
21
|
source = ACTION.read_text()
|
|
13
22
|
names = {name: step_block(source, name) for name in step_names(source)}
|
|
@@ -56,6 +56,35 @@ class PodsTests(unittest.TestCase):
|
|
|
56
56
|
self.env.pop("GOOGLE_STORE_PROXY_URL")
|
|
57
57
|
self.assertEqual(self.run_cli()[1]["code"], "native_google_proxy_required")
|
|
58
58
|
|
|
59
|
+
def test_declared_bundle_controls_the_actual_pod_child(self):
|
|
60
|
+
self.locked()
|
|
61
|
+
(self.root / "Gemfile").write_text('source "https://rubygems.org"\ngem "cocoapods", "1.16.2"\n')
|
|
62
|
+
(self.root / "Gemfile.lock").write_text("fixture gem lock\n")
|
|
63
|
+
for argv in (["git", "add", "Gemfile", "Gemfile.lock"],
|
|
64
|
+
["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid",
|
|
65
|
+
"commit", "-qm", "Declare the app bundle"]):
|
|
66
|
+
subprocess.run(argv, cwd=self.root, check=True, capture_output=True)
|
|
67
|
+
bundle = self.root / "bundle"
|
|
68
|
+
bundle.write_text('#!' + sys.executable + '\nimport os,sys\n'
|
|
69
|
+
'if sys.argv[1] == "install": raise SystemExit(0)\n'
|
|
70
|
+
'assert sys.argv[1] == "exec"\n'
|
|
71
|
+
'os.environ["FIXTURE_BUNDLE_ACTIVE"] = "yes"\n'
|
|
72
|
+
'os.execvp(sys.argv[2], sys.argv[2:])\n')
|
|
73
|
+
bundle.chmod(0o700)
|
|
74
|
+
pod = self.root / "pod"
|
|
75
|
+
pod.write_text('#!' + sys.executable + '\nimport os,json\nfrom pathlib import Path\n'
|
|
76
|
+
'Path("bundle-child.json").write_text(json.dumps({k:os.environ.get(k) for k in '
|
|
77
|
+
'["FIXTURE_BUNDLE_ACTIVE","BUNDLE_GEMFILE","BUNDLE_FROZEN","HTTPS_PROXY"]}))\n'
|
|
78
|
+
'raise SystemExit(7)\n')
|
|
79
|
+
pod.chmod(0o700)
|
|
80
|
+
self.env["PATH"] = str(self.root) + os.pathsep + self.env["PATH"]
|
|
81
|
+
self.assertEqual(self.run_cli()[1]["code"], "native_pods_install_failed")
|
|
82
|
+
child = json.loads((self.root / "bundle-child.json").read_text())
|
|
83
|
+
self.assertEqual(child["FIXTURE_BUNDLE_ACTIVE"], "yes")
|
|
84
|
+
self.assertEqual(child["BUNDLE_GEMFILE"], str(self.root / "Gemfile"))
|
|
85
|
+
self.assertEqual(child["BUNDLE_FROZEN"], "true")
|
|
86
|
+
self.assertTrue(child["HTTPS_PROXY"].startswith("http://127.0.0.1:"))
|
|
87
|
+
|
|
59
88
|
def test_real_child_has_relay_and_deployment_flags_and_preserves_failure(self):
|
|
60
89
|
self.locked()
|
|
61
90
|
script = self.root / "pod"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.99
|
|
@@ -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" --
|
|
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.
|
|
1
|
+
1.0.99
|
package/package.json
CHANGED
package/templates/deploy.yml
CHANGED
|
@@ -142,7 +142,8 @@ jobs:
|
|
|
142
142
|
- name: Prepare Flutter iOS project
|
|
143
143
|
if: ${{ steps.flutter.outputs.enabled == 'true' }}
|
|
144
144
|
shell: >-
|
|
145
|
-
python3 .github/actions/
|
|
145
|
+
python3 .github/actions/swift-app/scripts/native_bundle.py
|
|
146
|
+
--folder ios --proxy-helper .github/actions/android-app/scripts/play_store_proxy.py
|
|
146
147
|
-- bash --noprofile --norc -e -o pipefail {0}
|
|
147
148
|
env:
|
|
148
149
|
ONBOARDING_PUBLIC_ORIGIN: ${{ vars.ONBOARDING_PUBLIC_ORIGIN }}
|