gowalk-cicd 1.0.78 → 1.0.80

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.
Files changed (36) hide show
  1. package/CLAUDE.md +33 -3
  2. package/README.md +22 -18
  3. package/action/.daemux-version +1 -1
  4. package/action/action.yml +48 -38
  5. package/action/scripts/auto_detect.py +8 -8
  6. package/action/scripts/commit_bot_changes.sh +3 -29
  7. package/action/scripts/native_dependency_guard.py +59 -0
  8. package/action/scripts/native_pods.py +97 -0
  9. package/action/scripts/native_pods_diagnostics.py +53 -0
  10. package/action/scripts/native_pods_process.py +93 -0
  11. package/action/scripts/native_project_graph.py +148 -0
  12. package/action/scripts/source_maintenance.py +93 -0
  13. package/action/scripts/source_maintenance_fixture.py +58 -0
  14. package/action/scripts/test_auto_detect.py +11 -0
  15. package/action/scripts/test_native_dependency_guard.py +116 -0
  16. package/action/scripts/test_native_dependency_wiring.py +59 -0
  17. package/action/scripts/test_native_pods.py +106 -0
  18. package/action/scripts/test_native_pods_diagnostics.py +39 -0
  19. package/action/scripts/test_native_pods_process.py +41 -0
  20. package/action/scripts/test_source_maintenance.py +122 -0
  21. package/android-action/.daemux-version +1 -1
  22. package/android-action/action.yml +3 -1
  23. package/android-action/play-upload/action.yml +1 -1
  24. package/android-action/play-upload/owner.cjs +61 -0
  25. package/android-action/play-upload/owner_http.cjs +63 -0
  26. package/android-action/play-upload/owner_principal.cjs +15 -0
  27. package/android-action/play-upload/owner_record.cjs +63 -0
  28. package/android-action/play-upload/owner_run.cjs +62 -0
  29. package/android-action/play-upload/run.cjs +14 -3
  30. package/android-action/scripts/resolve_android.py +19 -1
  31. package/android-action/scripts/test_early_play_workflow.py +2 -2
  32. package/android-action/scripts/test_resolve_play_readiness.py +26 -0
  33. package/backend-action/.daemux-version +1 -1
  34. package/backend-action/remote_deploy.sh +5 -1
  35. package/package.json +1 -1
  36. package/templates/deploy.yml +8 -3
@@ -0,0 +1,106 @@
1
+ """Run the real native preparation CLI, Git lock fence and canonical relay offline."""
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_pods
11
+ from test_native_dependency_guard import project
12
+
13
+ PROXY = Path(__file__).resolve().parents[2] / "android-action/scripts/play_store_proxy.py"
14
+
15
+
16
+ class PodsTests(unittest.TestCase):
17
+ def setUp(self):
18
+ self.temp = tempfile.TemporaryDirectory(prefix="app-robot-native-pods-test-")
19
+ self.addCleanup(self.temp.cleanup)
20
+ self.root = Path(self.temp.name).resolve()
21
+ project(self.root)
22
+ self.env = {**os.environ, "GOOGLE_STORE_PROXY_URL": "http://fixture:PRIVATE_SENTINEL@127.0.0.1:9"}
23
+
24
+ def locked(self):
25
+ (self.root / "Podfile").write_text("fixture")
26
+ (self.root / "Podfile.lock").write_text("fixture-lock")
27
+ commands = [["git", "init", "-q"], ["git", "add", "Podfile", "Podfile.lock"],
28
+ ["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid",
29
+ "commit", "-qm", "Own fixture lock"]]
30
+ for command in commands:
31
+ subprocess.run(command, cwd=self.root, check=True, capture_output=True)
32
+
33
+ def run_cli(self):
34
+ result = subprocess.run([sys.executable, native_pods.__file__, str(self.root),
35
+ "--container", "App.xcodeproj", "--proxy-helper", str(PROXY)],
36
+ capture_output=True, text=True, env=self.env, timeout=20)
37
+ self.assertNotIn("PRIVATE_SENTINEL", result.stdout + result.stderr)
38
+ return result.returncode, json.loads(result.stdout)
39
+
40
+ def test_without_podfile_does_not_require_a_proxy(self):
41
+ self.env.pop("GOOGLE_STORE_PROXY_URL")
42
+ code, result = self.run_cli()
43
+ self.assertEqual((code, result["pods"]), (0, "not_configured"))
44
+
45
+ def test_missing_or_uncommitted_or_modified_lock_refuses(self):
46
+ (self.root / "Podfile").write_text("fixture")
47
+ self.assertEqual(self.run_cli()[1]["code"], "native_podfile_lock_required")
48
+ (self.root / "Podfile.lock").write_text("uncommitted")
49
+ self.assertEqual(self.run_cli()[1]["code"], "native_podfile_lock_commit_required")
50
+ self.locked()
51
+ (self.root / "Podfile.lock").write_text("changed")
52
+ self.assertEqual(self.run_cli()[1]["code"], "native_podfile_lock_commit_required")
53
+
54
+ def test_missing_proxy_fails_before_the_pod_process(self):
55
+ self.locked()
56
+ self.env.pop("GOOGLE_STORE_PROXY_URL")
57
+ self.assertEqual(self.run_cli()[1]["code"], "native_google_proxy_required")
58
+
59
+ def test_real_child_has_relay_and_deployment_flags_and_preserves_failure(self):
60
+ self.locked()
61
+ script = self.root / "pod"
62
+ script.write_text('#!' + sys.executable + '\nimport os,sys,json\n'
63
+ 'from pathlib import Path\n'
64
+ 'Path("child.json").write_text(json.dumps({"argv":sys.argv[1:],'
65
+ '"proxy":os.environ["HTTPS_PROXY"],"bypass":os.environ["NO_PROXY"]}))\n'
66
+ 'print("PRIVATE_SENTINEL", file=sys.stderr)\nraise SystemExit(7)\n')
67
+ script.chmod(0o700)
68
+ self.env["PATH"] = str(self.root) + os.pathsep + self.env["PATH"]
69
+ self.env["NO_PROXY"] = "*"
70
+ code, result = self.run_cli()
71
+ self.assertEqual((code, result["code"]), (1, "native_pods_install_failed"))
72
+ child = json.loads((self.root / "child.json").read_text())
73
+ self.assertEqual(child["argv"], ["install", "--deployment", "--no-repo-update"])
74
+ self.assertTrue(child["proxy"].startswith("http://127.0.0.1:"))
75
+ self.assertNotIn("PRIVATE_SENTINEL", child["proxy"])
76
+ self.assertEqual(child["bypass"], "")
77
+
78
+ @unittest.skipUnless(os.name == "posix", "owned POSIX process groups")
79
+ def test_termination_reaps_the_owned_pod_child(self):
80
+ import time
81
+ self.locked()
82
+ script = self.root / "pod"
83
+ script.write_text('#!' + sys.executable + '\nimport os,time\nfrom pathlib import Path\n'
84
+ 'Path("owned-child").write_text(str(os.getpid()))\ntime.sleep(30)\n')
85
+ script.chmod(0o700)
86
+ self.env["PATH"] = str(self.root) + os.pathsep + self.env["PATH"]
87
+ argv = [sys.executable, native_pods.__file__, str(self.root), "--container", "App.xcodeproj",
88
+ "--proxy-helper", str(PROXY)]
89
+ with subprocess.Popen(argv, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) as child:
90
+ deadline = time.monotonic() + 5
91
+ while not (self.root / "owned-child").exists() and time.monotonic() < deadline:
92
+ time.sleep(0.02)
93
+ child.terminate()
94
+ output, errors = child.communicate(timeout=10)
95
+ self.assertTrue((self.root / "owned-child").exists())
96
+ self.assertEqual(json.loads(output)["code"], "native_pods_operation_interrupted")
97
+ self.assertEqual(errors, "")
98
+ owned_pid = int((self.root / "owned-child").read_text())
99
+ deadline = time.monotonic() + 5
100
+ while time.monotonic() < deadline:
101
+ try:
102
+ os.kill(owned_pid, 0)
103
+ except ProcessLookupError:
104
+ return
105
+ time.sleep(0.02)
106
+ self.fail("the owned pod child survived helper termination")
@@ -0,0 +1,39 @@
1
+ """Retain useful dependency conflicts without leaking child credentials or a truncated tail."""
2
+ import io
3
+ import unittest
4
+ from native_pods_diagnostics import diagnostic
5
+
6
+
7
+ class DiagnosticsTests(unittest.TestCase):
8
+ def test_versions_survive_while_current_env_and_generic_credentials_are_removed(self):
9
+ environment = {"GOOGLE_STORE_PROXY_URL": "http://owned-user:private-password@proxy.invalid:8000",
10
+ "GITHUB_TOKEN": "private-github-token", "PUBLIC_SETTING": "allowed"}
11
+ body = ('[!] CocoaPods could not find compatible versions for pod "Firebase/CoreOnly":\n'
12
+ 'In snapshot (Podfile.lock): Firebase/CoreOnly (= 11.9.0)\n'
13
+ 'In Podfile: Firebase/CoreOnly (= 11.8.0)\n'
14
+ 'owned-user private-password private-github-token\n'
15
+ 'https://owned-user:private-password@proxy.invalid:8000\n'
16
+ 'Proxy-Authorization: Basic cHJpdmF0ZTpwYXNzd29yZA==\n'
17
+ '{"private_key":"PRIVATE_JSON_MARKER", "access_token":"PRIVATE_ACCESS_MARKER"}\n'
18
+ 'abcDef0123456789_abcDef0123456789_SECRET\n')
19
+ result = diagnostic(io.BytesIO(body.encode()), environment)
20
+ self.assertIn('Firebase/CoreOnly (= 11.9.0)', result)
21
+ self.assertIn('Firebase/CoreOnly (= 11.8.0)', result)
22
+ for private in ('owned-user', 'private-password', 'private-github-token', 'https://',
23
+ 'cHJpdmF0ZTpwYXNzd29yZA==', 'PRIVATE_JSON_MARKER', 'PRIVATE_ACCESS_MARKER', 'SECRET'):
24
+ self.assertNotIn(private, result)
25
+
26
+ def test_truncated_first_line_and_small_proxy_credentials_cannot_escape(self):
27
+ body = b"x" * 17000 + b"TAIL_SECRET\n[!] Unable to satisfy dependency version 12.3.\n"
28
+ self.assertNotIn("TAIL_SECRET", diagnostic(io.BytesIO(body), {}))
29
+ result = diagnostic(io.BytesIO(b"username=Q password=Z\n"), {"HTTPS_PROXY": "http://Q:Z@fixture.invalid:8000"})
30
+ self.assertNotIn("Q", result)
31
+ self.assertNotIn("Z", result)
32
+
33
+ def test_bounds_and_pem_text_are_private(self):
34
+ body = b"[!] conflict 1.0 versus 2.0\n" * 1000
35
+ result = diagnostic(io.BytesIO(body), {})
36
+ self.assertLessEqual(len(result), 4000)
37
+ self.assertLessEqual(len(result.splitlines()), 30)
38
+ pem = b"-----BEGIN PRIVATE KEY-----\nPRIVATE_PEM_VALUE\n-----END PRIVATE KEY-----\n"
39
+ self.assertNotIn("PRIVATE_PEM_VALUE", diagnostic(io.BytesIO(pem), {}))
@@ -0,0 +1,41 @@
1
+ """Actual retained-leader cleanup, including a stubborn surviving descendant and unrelated sibling."""
2
+ import os
3
+ from pathlib import Path
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+
9
+ import native_pods_process as process
10
+
11
+
12
+ class ProcessTests(unittest.TestCase):
13
+ def test_exited_leader_does_not_leave_its_term_resistant_child(self):
14
+ with tempfile.TemporaryDirectory(prefix="app-robot-native-process-") as temporary:
15
+ root = Path(temporary)
16
+ worker = root / "worker.py"
17
+ worker.write_text('import os,signal,time\nfrom pathlib import Path\n'
18
+ 'signal.signal(signal.SIGTERM, signal.SIG_IGN)\n'
19
+ 'Path("pid").write_text(str(os.getpid()))\ntime.sleep(30)\n')
20
+ leader = ('import subprocess,sys,time\nfrom pathlib import Path\n'
21
+ 'subprocess.Popen([sys.executable,"worker.py"])\n'
22
+ 'while not Path("pid").exists(): time.sleep(.01)\nraise SystemExit(7)\n')
23
+ sibling = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(30)"], start_new_session=True)
24
+ try:
25
+ with tempfile.TemporaryFile() as log:
26
+ code = process.command([sys.executable, "-c", leader], root, log, timeout=5)
27
+ self.assertEqual(code, 7)
28
+ self.assertIsNone(sibling.poll())
29
+ child_pid = int((root / "pid").read_text())
30
+ found = subprocess.run(["ps", "-p", str(child_pid), "-o", "stat="], capture_output=True, text=True)
31
+ self.assertTrue(found.returncode or found.stdout.strip().startswith("Z"))
32
+ finally:
33
+ sibling.terminate()
34
+ sibling.wait(timeout=5)
35
+
36
+ def test_timeout_has_no_success_verdict(self):
37
+ with tempfile.TemporaryDirectory(prefix="app-robot-native-timeout-") as temporary:
38
+ with tempfile.TemporaryFile() as log:
39
+ with self.assertRaises(process.Interrupted):
40
+ process.command([sys.executable, "-c", "import time;time.sleep(30)"],
41
+ Path(temporary), log, timeout=0.05)
@@ -0,0 +1,122 @@
1
+ """Exercise maintenance delivery against an actual protected bare Git remote."""
2
+ import os
3
+ from pathlib import Path
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import textwrap
8
+ import unittest
9
+
10
+ from source_maintenance_fixture import MaintenanceFixture
11
+
12
+
13
+ class MaintenanceTests(MaintenanceFixture):
14
+ def test_protected_main_dirty_checkout_and_replay(self):
15
+ self.stage()
16
+ first = self.receipt(self.run_script())
17
+ self.assertEqual(self.git("rev-parse", "HEAD").strip(), self.base)
18
+ self.assertEqual(self.git("ls-remote", "origin", "refs/heads/main").split()[0], self.base)
19
+ self.assertEqual((self.repo / "unrelated.txt").read_text(), "unfinished build change\n")
20
+ self.assertEqual((self.repo / "project.yml").read_text(), "version: 1.1\n")
21
+ self.assertEqual(self.git("diff", "--cached", "--name-only"), "")
22
+ self.assertEqual(self.git("show", first["head_sha"] + ":project.yml"), "version: 1.1\n")
23
+ self.git("add", "project.yml")
24
+ self.assertEqual(self.receipt(self.run_script()), first)
25
+
26
+ def test_remote_refusal_is_failure_and_preserves_index(self):
27
+ self.stage()
28
+ self.hook.write_text("#!/bin/sh\nexit 1\n")
29
+ result = self.run_script()
30
+ self.assertNotEqual(result.returncode, 0)
31
+ self.assertIn("source_maintenance_push_unconfirmed", result.stderr)
32
+ self.assertEqual(self.git("diff", "--cached", "--name-only"), "project.yml\n")
33
+ self.assertEqual(self.git("rev-parse", "HEAD").strip(), self.base)
34
+
35
+ def test_changed_remote_candidate_is_not_overwritten(self):
36
+ self.stage()
37
+ receipt = self.receipt(self.run_script())
38
+ head = self.git("commit-tree", self.base + "^{tree}", "-p", receipt["head_sha"], "-m", "Other").strip()
39
+ self.git("push", "origin", head + ":refs/heads/" + receipt["branch"])
40
+ self.git("add", "project.yml")
41
+ result = self.run_script()
42
+ self.assertNotEqual(result.returncode, 0)
43
+ self.assertIn("source_maintenance_candidate_mismatch", result.stderr)
44
+ self.assertEqual(self.git("ls-remote", "origin", "refs/heads/" + receipt["branch"]).split()[0], head)
45
+
46
+ def test_secret_and_unexpected_staging_refuse_before_push(self):
47
+ for name in ("creds/private.p8", "arbitrary.txt"):
48
+ with self.subTest(name=name):
49
+ path = self.repo / name
50
+ path.parent.mkdir(exist_ok=True)
51
+ path.write_text("synthetic-secret-not-for-output")
52
+ self.git("add", name)
53
+ result = self.run_script()
54
+ self.assertNotEqual(result.returncode, 0)
55
+ self.assertNotIn(path.read_text(), result.stdout + result.stderr)
56
+ self.assertEqual(len(self.git("ls-remote", "--heads", "origin").splitlines()), 1)
57
+ self.git("reset", "--quiet", "HEAD", "--", name)
58
+
59
+ def test_no_staging_needs_no_ci_identity(self):
60
+ self.env.pop("GITHUB_REPOSITORY")
61
+ self.assertEqual(self.run_script().returncode, 0)
62
+
63
+ def test_lost_push_acknowledgment_uses_remote_readback(self):
64
+ self.stage()
65
+ real_git = shutil.which("git")
66
+ binary = self.root / "bin"
67
+ binary.mkdir()
68
+ shim = binary / "git"
69
+ shim.write_text(f"#!/bin/bash\n{real_git} \"$@\"\nstatus=$?\n"
70
+ "if [ \"$1\" = push ] && [ \"$status\" = 0 ]; then exit 128; fi\nexit $status\n")
71
+ shim.chmod(0o755)
72
+ self.env["PATH"] = str(binary) + os.pathsep + self.env["PATH"]
73
+ receipt = self.receipt(self.run_script())
74
+ self.assertEqual(self.git("ls-remote", "origin", "refs/heads/" + receipt["branch"]).split()[0],
75
+ receipt["head_sha"])
76
+
77
+ def test_wrong_repository_or_build_head_refuses_without_push(self):
78
+ self.stage()
79
+ for key, wrong in (("GITHUB_REPOSITORY", "other/app"), ("GITHUB_SHA", "f" * 40)):
80
+ original = self.env[key]
81
+ self.env[key] = wrong
82
+ self.assertNotEqual(self.run_script().returncode, 0)
83
+ self.assertEqual(len(self.git("ls-remote", "--heads", "origin").splitlines()), 1)
84
+ self.env[key] = original
85
+
86
+ def test_preserved_index_excludes_later_signing_edits_in_same_file(self):
87
+ self.stage()
88
+ (self.repo / "project.yml").write_text("version: 1.1\nsigning: ephemeral\n")
89
+ receipt = self.receipt(self.run_script())
90
+ self.assertEqual(self.git("show", receipt["head_sha"] + ":project.yml"), "version: 1.1\n")
91
+ self.assertEqual((self.repo / "project.yml").read_text(), "version: 1.1\nsigning: ephemeral\n")
92
+
93
+ def test_custom_plist_and_separate_update_candidates(self):
94
+ path = self.repo / ".github/actions/swift-app/.daemux-version"
95
+ path.parent.mkdir(parents=True)
96
+ path.write_text("1.0.79\n")
97
+ self.git("add", str(path.relative_to(self.repo)))
98
+ first = self.receipt(self.run_script())
99
+ (self.repo / "Custom-App-Info.plist").write_text("<plist>version</plist>\n")
100
+ self.git("add", "Custom-App-Info.plist")
101
+ second = self.receipt(self.run_script())
102
+ self.assertNotEqual(first["branch"], second["branch"])
103
+ self.assertEqual(self.git("diff", "--name-only", self.base, second["head_sha"]), "Custom-App-Info.plist\n")
104
+ self.assertEqual(path.read_text(), "1.0.79\n")
105
+
106
+ def test_actual_staging_step_delivers_the_helper_to_immutable_bot_snapshot(self):
107
+ action = Path(__file__).resolve().parents[1]
108
+ source = (action / "action.yml").read_text()
109
+ block = re.search(r" - name: Stage bot-side scripts to runner temp\n(.*?)(?=\n - name:)",
110
+ source, re.S)[1]
111
+ script = textwrap.dedent(block.split(" run: |\n", 1)[1])
112
+ self.env.update({"SWIFT_APP_ACTION": str(action), "RUNNER_TEMP": str(self.root),
113
+ "GITHUB_ENV": str(self.root / "environment")})
114
+ subprocess.run(["bash", "-e", "-c", script], env=self.env, cwd=self.repo, check=True,
115
+ text=True, capture_output=True, timeout=10)
116
+ self.script = self.root / "swift-app-bot-scripts/commit_bot_changes.sh"
117
+ self.stage()
118
+ self.assertEqual(self.receipt(self.run_script())["base_sha"], self.base)
119
+
120
+
121
+ if __name__ == "__main__":
122
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.78
1
+ 1.0.80
@@ -95,7 +95,9 @@ runs:
95
95
  INPUT_PACKAGE_NAME: ${{ inputs.package-name }}
96
96
  INPUT_BUILD_NUMBER: ${{ inputs.build-number }}
97
97
  INPUT_BUILD_NAME: ${{ inputs.build-name }}
98
- run: python3 "${{ github.action_path }}/scripts/resolve_android.py"
98
+ PLAY_EDIT_BINDING: ${{ env.PLAY_EDIT_BINDING }}
99
+ GITHUB_TOKEN: ${{ github.token }}
100
+ run: node "${{ github.action_path }}/play-upload/owner_run.cjs" resolve
99
101
 
100
102
  # --- Flutter --------------------------------------------------------
101
103
  - name: Get Flutter dependencies
@@ -1,5 +1,5 @@
1
1
  name: Publish the signed Play bundle with classified recovery
2
- description: Run the unchanged pinned official uploader; retry only definitive supported refusals.
2
+ description: Hold the package edit owner around the unchanged pinned official uploader and classified retries.
3
3
  inputs:
4
4
  serviceAccountJson:
5
5
  description: Path to the provisioned service account file.
@@ -0,0 +1,61 @@
1
+ const http = require('./owner_http.cjs');
2
+ const record = require('./owner_record.cjs');
3
+
4
+ async function seal(client, selected) {
5
+ for (let attempt = 0; attempt < 3; attempt++) {
6
+ const row = record.validate(await client.read(selected.path), selected);
7
+ if (row.value.owner !== null && !record.same(row.value.owner, selected.owner)) return;
8
+ const idle = record.replacement(row, null);
9
+ try { await client.write(selected.path, row, idle); } catch { /* Reconcile a possibly applied write. */ }
10
+ const after = record.validate(await client.read(selected.path), selected);
11
+ if (after.value.revision === idle.revision && after.value.owner === null) return;
12
+ if (after.value.owner !== null && !record.same(after.value.owner, selected.owner)) return;
13
+ }
14
+ throw new Error('play_owner_release_unverified');
15
+ }
16
+
17
+ async function acquire(client, selected, attempted = () => {}) {
18
+ const protocol = (await client.read('protocol.json')).value;
19
+ if (protocol?.schema !== record.schema || protocol.repository !== selected.value.repo
20
+ || Object.keys(protocol).length !== 2) throw new Error('play_owner_unverified');
21
+ for (let attempt = 0; attempt < 3; attempt++) {
22
+ const row = record.validate(await client.read(selected.path), selected);
23
+ if (row.value.owner !== null) {
24
+ const owner = row.value.owner;
25
+ if (owner.kind !== 'ci' || !record.terminal(await client.run(owner), owner)) throw new Error('play_owner_busy');
26
+ }
27
+ const desired = record.replacement(row, selected.owner);
28
+ attempted();
29
+ try { await client.write(selected.path, row, desired); } catch { /* Only exact readback may adopt it. */ }
30
+ const after = record.validate(await client.read(selected.path), selected);
31
+ if (after.value.revision === desired.revision && record.same(after.value.owner, selected.owner)) return;
32
+ }
33
+ throw new Error('play_owner_busy');
34
+ }
35
+
36
+ async function withOwner(env, packageName, operation, transport) {
37
+ const selected = record.binding(env, packageName);
38
+ const client = http.client(selected.value.repo, env.GITHUB_TOKEN, transport);
39
+ let cancelled = false, dirty = false, result, failure;
40
+ const stop = () => { cancelled = true; };
41
+ process.on('SIGTERM', stop); process.on('SIGINT', stop);
42
+ try {
43
+ await acquire(client, selected, () => { dirty = true; });
44
+ if (cancelled) throw new Error('play_owner_cancelled');
45
+ result = await operation(() => cancelled);
46
+ } catch (error) { failure = error; }
47
+ finally {
48
+ try { if (dirty) await seal(client, selected); } catch { failure = new Error('play_owner_release_unverified'); }
49
+ process.off('SIGTERM', stop); process.off('SIGINT', stop);
50
+ }
51
+ if (cancelled && !failure) failure = new Error('play_owner_cancelled');
52
+ if (failure) {
53
+ if (result !== undefined) failure.operationResult = result;
54
+ throw failure;
55
+ }
56
+ return result;
57
+ }
58
+
59
+ const codes = ['play_owner_binding_required', 'play_owner_unverified', 'play_owner_busy',
60
+ 'play_owner_release_unverified', 'play_owner_cancelled', 'play_owner_principal_unverified'];
61
+ module.exports = {acquire, seal, withOwner, codes};
@@ -0,0 +1,63 @@
1
+ // GitHub coordination only; never send repository credentials to a configurable origin.
2
+ const https = require('node:https');
3
+ const {createHash} = require('node:crypto');
4
+ const branch = 'app-robot-play-coordination';
5
+
6
+ function request(token, method, path, body) {
7
+ return new Promise((resolve, reject) => {
8
+ const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
9
+ const call = https.request({hostname: 'api.github.com', port: 443, path, method,
10
+ agent: new https.Agent({keepAlive: false, proxyEnv: {}}),
11
+ headers: {Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json',
12
+ 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'gowalk-cicd-play-owner',
13
+ ...(data ? {'Content-Type': 'application/json', 'Content-Length': data.length} : {})}}, response => {
14
+ const chunks = []; let size = 0;
15
+ response.on('data', chunk => {
16
+ size += chunk.length;
17
+ if (size > 131072) call.destroy(new Error('play_owner_unverified'));
18
+ else chunks.push(chunk);
19
+ });
20
+ response.on('end', () => {
21
+ try { resolve({status: response.statusCode, body: JSON.parse(Buffer.concat(chunks))}); }
22
+ catch { reject(new Error('play_owner_unverified')); }
23
+ });
24
+ response.on('error', () => reject(new Error('play_owner_unverified')));
25
+ });
26
+ call.setTimeout(10000, () => call.destroy(new Error('play_owner_unverified')));
27
+ call.on('error', () => reject(new Error('play_owner_unverified')));
28
+ call.end(data);
29
+ });
30
+ }
31
+
32
+ function decode(response) {
33
+ const item = response.body;
34
+ if (response.status !== 200 || !item || item.type !== 'file' || item.encoding !== 'base64'
35
+ || !Number.isSafeInteger(item.size) || item.size < 1 || item.size > 8192
36
+ || typeof item.content !== 'string' || item.content.length > 16384) throw new Error('play_owner_unverified');
37
+ const text = item.content.replace(/\n/g, '');
38
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(text)) {
39
+ throw new Error('play_owner_unverified');
40
+ }
41
+ const bytes = Buffer.from(text, 'base64');
42
+ const digest = createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
43
+ if (bytes.length !== item.size || digest !== item.sha) throw new Error('play_owner_unverified');
44
+ try { return {sha: item.sha, value: JSON.parse(bytes)}; }
45
+ catch { throw new Error('play_owner_unverified'); }
46
+ }
47
+
48
+ function client(repo, token, transport = request) {
49
+ if (typeof token !== 'string' || !token || /[\r\n]/.test(token)) throw new Error('play_owner_binding_required');
50
+ const prefix = `/repos/${repo}`;
51
+ const send = (method, path, body) => transport(token, method, prefix + path, body);
52
+ return {
53
+ read: async path => decode(await send('GET', `/contents/${path}?ref=${branch}`)),
54
+ write: async (path, old, value) => {
55
+ const response = await send('PUT', `/contents/${path}`, {branch, sha: old.sha,
56
+ message: 'Update scoped Play edit ownership', content: Buffer.from(JSON.stringify(value)).toString('base64')});
57
+ if (![200, 201, 409, 422].includes(response.status)) throw new Error('play_owner_unverified');
58
+ },
59
+ run: owner => send('GET', `/actions/runs/${owner.run_id}/attempts/${owner.run_attempt}`),
60
+ };
61
+ }
62
+
63
+ module.exports = {branch, client, decode, request};
@@ -0,0 +1,15 @@
1
+ // Compare a nonsecret API-principal fingerprint; never emit credential fields or file contents.
2
+ const {readFileSync, statSync} = require('node:fs');
3
+ const {createHash} = require('node:crypto');
4
+
5
+ function read(path) {
6
+ try {
7
+ const stat = statSync(path);
8
+ if (!stat.isFile() || stat.size < 1 || stat.size > 262144) throw new Error();
9
+ const value = JSON.parse(readFileSync(path, 'utf8'));
10
+ if (typeof value.client_email !== 'string' || !/^[^\s@]+@[^\s@]+$/.test(value.client_email)) throw new Error();
11
+ return createHash('sha256').update(value.client_email.toLowerCase()).digest('hex');
12
+ } catch { throw new Error('play_owner_principal_unverified'); }
13
+ }
14
+
15
+ module.exports = {read};
@@ -0,0 +1,63 @@
1
+ const {createHash, randomUUID} = require('node:crypto');
2
+ const {isDeepStrictEqual: same} = require('node:util');
3
+ const schema = 'app-robot.play-edit-owner.v1';
4
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
5
+ const sha = /^[0-9a-f]{40}$/;
6
+ const repository = /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/;
7
+ const positive = value => Number.isSafeInteger(value) && value > 0;
8
+
9
+ function binding(env, packageName) {
10
+ let value;
11
+ try { value = JSON.parse(env.PLAY_EDIT_BINDING); } catch { throw new Error('play_owner_binding_required'); }
12
+ const prefix = `${env.GITHUB_REPOSITORY}/`;
13
+ const workflow = String(env.GITHUB_WORKFLOW_REF || '');
14
+ const path = workflow.startsWith(prefix) ? workflow.slice(prefix.length).split('@')[0] : '';
15
+ if (!value || !repository.test(value.repo) || value.repo !== env.GITHUB_REPOSITORY
16
+ || value.package !== packageName || !/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/.test(packageName)
17
+ || !positive(value.account_id) || !uuid.test(value.generation)
18
+ || !/^[0-9a-f]{64}$/.test(value.principal_sha256)
19
+ || value.principal_sha256 !== env.PLAY_EDIT_PRINCIPAL_SHA256
20
+ || !sha.test(env.GITHUB_SHA) || !positive(Number(env.GITHUB_RUN_ID))
21
+ || !positive(Number(env.GITHUB_RUN_ATTEMPT)) || !workflow.includes('@')
22
+ || !/^[1-9][0-9]*$/.test(env.GITHUB_RUN_ID) || !/^[1-9][0-9]*$/.test(env.GITHUB_RUN_ATTEMPT)
23
+ || !/^\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml$/.test(path)) {
24
+ throw new Error('play_owner_binding_required');
25
+ }
26
+ return {value, path: `owners/${createHash('sha256').update(packageName).digest('hex')}.json`,
27
+ owner: {kind: 'ci', id: randomUUID(), repo: value.repo, run_id: Number(env.GITHUB_RUN_ID),
28
+ run_attempt: Number(env.GITHUB_RUN_ATTEMPT), sha: env.GITHUB_SHA, workflow: path}};
29
+ }
30
+
31
+ function holder(value) {
32
+ if (value === null) return true;
33
+ if (!value || !uuid.test(value.id)) return false;
34
+ if (value.kind === 'panel') return value.process && /^[0-9a-f]{64}$/.test(value.process.host)
35
+ && /^[0-9a-f]{64}$/.test(value.process.boot) && positive(value.process.pid)
36
+ && Number.isFinite(value.process.birth) && value.process.birth > 0;
37
+ return value.kind === 'ci' && repository.test(value.repo) && positive(value.run_id)
38
+ && positive(value.run_attempt) && sha.test(value.sha)
39
+ && /^\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml$/.test(value.workflow);
40
+ }
41
+
42
+ function validate(row, selected) {
43
+ const value = row.value, key = selected.value;
44
+ if (!value || value.schema !== schema || value.active !== true || value.repo !== key.repo
45
+ || value.package !== key.package
46
+ || value.account_id !== key.account_id || value.generation !== key.generation
47
+ || value.principal_sha256 !== key.principal_sha256
48
+ || !uuid.test(value.revision) || !holder(value.owner)
49
+ || (value.owner?.kind === 'ci' && value.owner.repo !== key.repo)) throw new Error('play_owner_unverified');
50
+ return row;
51
+ }
52
+
53
+ function replacement(row, owner) { return {...row.value, revision: randomUUID(), owner}; }
54
+
55
+ function terminal(response, owner) {
56
+ const run = response.body;
57
+ return response.status === 200 && run?.id === owner.run_id && run.run_attempt === owner.run_attempt
58
+ && run.repository?.full_name === owner.repo && run.head_sha === owner.sha && run.path === owner.workflow
59
+ && run.status === 'completed' && ['success', 'failure', 'cancelled', 'timed_out', 'action_required',
60
+ 'neutral', 'skipped', 'startup_failure', 'stale'].includes(run.conclusion);
61
+ }
62
+
63
+ module.exports = {schema, binding, validate, replacement, same, terminal};
@@ -0,0 +1,62 @@
1
+ // Hold exactly one package while its existing Python preflight/inventory child runs.
2
+ const {spawn} = require('node:child_process');
3
+ const {join} = require('node:path');
4
+ const owner = require('./owner.cjs');
5
+
6
+ function child(argv, env, capture = false, cancelled = () => false) {
7
+ return new Promise((resolve, reject) => {
8
+ if (cancelled()) { reject(new Error('play_owner_cancelled')); return; }
9
+ const processChild = spawn('python3', argv, {env, stdio: ['ignore', 'pipe', 'pipe']});
10
+ let text = '', overflow = false, force;
11
+ const stop = () => {
12
+ processChild.kill('SIGTERM');
13
+ if (!force) force = setTimeout(() => processChild.kill('SIGKILL'), 2000);
14
+ };
15
+ process.on('SIGTERM', stop); process.on('SIGINT', stop);
16
+ const timer = setTimeout(stop, 3600000);
17
+ processChild.stdout.on('data', chunk => {
18
+ if (!capture) process.stdout.write(chunk);
19
+ else if (text.length + chunk.length <= 1024) text += chunk;
20
+ else { overflow = true; stop(); }
21
+ });
22
+ processChild.stderr.resume();
23
+ processChild.on('error', () => { overflow = true; });
24
+ processChild.on('close', (code, signal) => {
25
+ clearTimeout(timer); clearTimeout(force); process.off('SIGTERM', stop); process.off('SIGINT', stop);
26
+ if (signal || overflow) reject(new Error('play_owner_child_failed'));
27
+ else resolve({code, text: text.trim()});
28
+ });
29
+ });
30
+ }
31
+
32
+ async function main(argv = process.argv.slice(2), env = process.env) {
33
+ try {
34
+ let packageName, args, principal;
35
+ const directory = join(__dirname, '../scripts');
36
+ if (argv[0] === 'resolve' && argv.length === 1) {
37
+ const script = join(directory, 'resolve_android.py');
38
+ const local = await child([script, '--owner-package'], env, true);
39
+ if (local.code !== 0) throw new Error('play_owner_package_unverified');
40
+ const identity = JSON.parse(local.text);
41
+ packageName = identity.package; principal = identity.principal_sha256; args = [script];
42
+ } else if (argv[0] === 'preflight' && argv.length === 3) {
43
+ packageName = argv[1];
44
+ principal = require('./owner_principal.cjs').read(argv[2]);
45
+ args = [join(directory, 'play_preflight.py'), '--package', packageName, '--service-account', argv[2]];
46
+ } else throw new Error('play_owner_package_unverified');
47
+ const bound = {...env, PLAY_EDIT_PRINCIPAL_SHA256: principal};
48
+ const result = await owner.withOwner(bound, packageName, cancelled =>
49
+ child(args, {...bound, INPUT_PACKAGE_NAME: packageName}, false, cancelled));
50
+ process.exitCode = result.code;
51
+ } catch (error) {
52
+ const code = [...owner.codes, 'play_owner_child_failed', 'play_owner_package_unverified'].includes(error.message)
53
+ ? error.message : 'play_owner_unverified';
54
+ const receipt = {schema: 'gowalk-cicd/play-owner.v1', code};
55
+ if (Number.isInteger(error.operationResult?.code)) receipt.child_exit_code = error.operationResult.code;
56
+ console.log(`::error title=play_edit_owner::${JSON.stringify(receipt)}`);
57
+ process.exitCode = 1;
58
+ }
59
+ }
60
+
61
+ module.exports = {child, main};
62
+ if (require.main === module) void main();
@@ -3,11 +3,14 @@ const {appendFileSync, mkdtempSync, mkdirSync, rmSync} = require('node:fs');
3
3
  const {join} = require('node:path');
4
4
  const source = require('./source.cjs');
5
5
  const processRunner = require('./process.cjs');
6
+ const owner = require('./owner.cjs');
6
7
 
7
- async function upload(program, env, directory, execute = processRunner.execute, timeout = 3600000) {
8
+ async function upload(program, env, directory, execute = processRunner.execute, timeout = 3600000,
9
+ cancelled = () => false) {
8
10
  const deadline = Date.now() + timeout;
9
11
  let mode = 'false';
10
12
  for (let attempt = 1; attempt <= 2; attempt++) {
13
+ if (cancelled()) return {ok: false, code: 'upload_cancelled', attempts: attempt - 1, review_pending: false};
11
14
  const target = join(directory, String(attempt)); mkdirSync(target, {mode: 0o700});
12
15
  const result = await execute(program, {...env, INPUT_CHANGESNOTSENTFORREVIEW: mode}, target,
13
16
  Math.max(1, deadline - Date.now()));
@@ -30,14 +33,22 @@ async function main() {
30
33
  let directory, receipt;
31
34
  try {
32
35
  const env = processRunner.environment(process.env);
36
+ if (!env.PLAY_EDIT_BINDING) throw new Error('play_owner_binding_required');
37
+ env.PLAY_EDIT_PRINCIPAL_SHA256 = require('./owner_principal.cjs').read(env.INPUT_SERVICEACCOUNTJSON);
33
38
  if (!env.RUNNER_TEMP || !env.GITHUB_OUTPUT) throw new Error('runner_context_required');
34
39
  directory = mkdtempSync(join(env.RUNNER_TEMP, 'gowalk-play-upload-'));
35
- receipt = await upload(await source.prepare(directory), env, directory);
40
+ const program = await source.prepare(directory);
41
+ receipt = await owner.withOwner(env, env.INPUT_PACKAGENAME, cancelled =>
42
+ upload(program, env, directory, processRunner.execute, 3600000, cancelled));
36
43
  } catch (error) {
37
44
  const allowed = ['assigned_proxy_required', 'runner_context_required', 'uploader_integrity_refused',
38
- 'uploader_source_unavailable'];
45
+ 'uploader_source_unavailable', ...owner.codes];
39
46
  receipt = {ok: false, code: allowed.includes(error.message) ? error.message : 'uploader_failed',
40
47
  attempts: 0, review_pending: false};
48
+ if (owner.codes.includes(error.message) && error.operationResult) {
49
+ receipt = {...error.operationResult, ok: false, code: error.message,
50
+ owner_error: error.message, provider_result: error.operationResult};
51
+ }
41
52
  } finally { if (directory) rmSync(directory, {recursive: true, force: true}); }
42
53
  receipt = {schema: 'gowalk-cicd/play-upload.v1', ...receipt, upstream_revision: source.revision,
43
54
  upstream_sha256: source.digest};