gowalk-cicd 1.0.112 → 1.0.114
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 +8 -2
- package/README.md +13 -0
- package/action/.daemux-version +1 -1
- package/action/scripts/native_bundle.py +1 -1
- package/action/scripts/native_pods_retry.py +2 -2
- package/action/scripts/test_native_pods.py +4 -0
- package/action/scripts/test_native_pods_retry.py +2 -1
- package/action/scripts/test_native_pods_timeout.py +34 -0
- package/android-action/.daemux-version +1 -1
- package/android-action/play-upload/owner.cjs +28 -8
- package/android-action/play-upload/owner_run.cjs +1 -3
- package/android-action/play-upload/run.cjs +1 -0
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -214,6 +214,8 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
214
214
|
that multiline secret masking can erase. Never reconstruct masked fields or include arbitrary text.
|
|
215
215
|
Native bundle/Pods failures emit `native_prepare_failed` with fixed refusal codes and observed
|
|
216
216
|
signals. Unknown signals remain unclassified; never copy child output or commands into annotations.
|
|
217
|
+
Timeout and cancellation retain the existing bounded sanitized diagnostic after owned-child cleanup;
|
|
218
|
+
preserve the failure code, install deadline and retry fences.
|
|
217
219
|
- The mobile workflow's feature-branch-only `backend-preview` job bootstraps a
|
|
218
220
|
new app before GitHub registers `deploy-backend.yml` on the default branch.
|
|
219
221
|
Default-branch deploys continue through the separate backend workflow.
|
|
@@ -395,8 +397,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
395
397
|
`APP_ROBOT_PLAY_EDIT_BINDING` is panel-provisioned repository data; workflows pass it as
|
|
396
398
|
`PLAY_EDIT_BINDING` with the repo-local GitHub token and `contents:write`/`actions:read`.
|
|
397
399
|
`play-upload/owner_run.cjs` protects Python entrypoints; `run.cjs` holds the same owner across
|
|
398
|
-
its permitted upload retry.
|
|
399
|
-
|
|
400
|
+
its permitted upload retry. An active owner gets a single 60-second acquisition wait budget;
|
|
401
|
+
each retry revalidates the record, cancellation interrupts the wait, and an expired budget
|
|
402
|
+
never clears an owner. Missing/retired bindings and unknown write outcomes still refuse;
|
|
403
|
+
only exact terminal run/attempt/source proof permits CI-owner recovery.
|
|
404
|
+
Both entrypoints emit brace-free `play_edit_owner` errors with fixed codes and optional child
|
|
405
|
+
exit evidence, preserving the existing upload receipt and refusal. Never reconstruct masked text.
|
|
400
406
|
Other packages stay parallel. The GitHub coordination request is fixed-origin and does not
|
|
401
407
|
change the Google account proxy. Update workflow wiring as well as action directories.
|
|
402
408
|
|
package/README.md
CHANGED
|
@@ -361,6 +361,7 @@ the situation and whose **message** is one line of JSON carrying a versioned
|
|
|
361
361
|
| `play_review_pending` | `gowalk-cicd/play-review-pending.v1` | `package`, `track`, `version_code` |
|
|
362
362
|
| `play_upload_result` | `gowalk-cicd/play-upload.v1` | `ok`, `code`, `attempts`, `review_pending`, upstream identity |
|
|
363
363
|
| `play_upload_diagnostic` | `gowalk-cicd/play-upload-diagnostic.v1` | Fixed process evidence; see below |
|
|
364
|
+
| `play_edit_owner` | `gowalk-cicd/play-owner.v1` | Ownership refusal; no implied upload retry permission |
|
|
364
365
|
| `store_version_locked` | `gowalk-cicd/store-version-locked.v1` | `version`, `state`, `build_number` |
|
|
365
366
|
| `firebase_symbols_pending` | `gowalk-cicd/firebase-symbols-pending.v1` | `platform`, `app_id`, `source_sha`, `artifact`, `file`, `sha256`, `status` |
|
|
366
367
|
| `mobile_upload_reused` | `gowalk-cicd/mobile-upload-reused.v1` | `platform`, `fingerprint`, `run_id`, `run_attempt`, `source_sha`, `deployment_id`, `next_action` |
|
|
@@ -379,6 +380,8 @@ Native bundle and Pods failures emit `native_prepare_failed` with schema
|
|
|
379
380
|
Backend failures emit `backend_deploy_failed` with schema `gowalk-cicd/backend-deploy-failed.v1`,
|
|
380
381
|
the runner/host `phase` and `exit_code`. These receipts preserve the failed result and exclude
|
|
381
382
|
private command output, commands, hosts and runtime credentials. Empty signals are unclassified.
|
|
383
|
+
Timeout and cancellation preserve the bounded, sanitized child diagnostic after owned-process cleanup;
|
|
384
|
+
observed signals do not authorize another attempt or change the failed result.
|
|
382
385
|
The backend message is `gowalk-cicd/backend-deploy-failed.v1 phase=<phase> exit_code=<code>`:
|
|
383
386
|
fixed fields without JSON braces, which GitHub can mask when a multiline secret has brace-only lines.
|
|
384
387
|
Consumers validate the complete envelope, the known phase and exit code 1–255; older JSON messages
|
|
@@ -475,6 +478,16 @@ bounded to 16 KiB. No provider message, path, URL, track or credential is republ
|
|
|
475
478
|
These observations never authorize a retry. Missing commit output does not prove no mutation,
|
|
476
479
|
and a reported commit does not prove review submission. Unknown failures remain unclassified.
|
|
477
480
|
|
|
481
|
+
Package-owner acquisition rechecks an occupied record within one 60-second wait budget before
|
|
482
|
+
provider work. Every read retains the binding and identity checks; cancellation interrupts the wait.
|
|
483
|
+
Time passing never authorizes clearing an owner. A known CI owner still requires exact terminal
|
|
484
|
+
run, attempt and source proof before recovery; the panel releases its own requests.
|
|
485
|
+
Both entrypoints emit `play_edit_owner` as `gowalk-cicd/play-owner.v1 code=<code> child_exit_code=<0-255|unknown>`.
|
|
486
|
+
Codes are `play_owner_binding_required`, `play_owner_unverified`, `play_owner_busy`,
|
|
487
|
+
`play_owner_release_unverified`, `play_owner_cancelled`, `play_owner_principal_unverified`,
|
|
488
|
+
`play_owner_child_failed` and `play_owner_package_unverified`. An unknown child exit proves no outcome.
|
|
489
|
+
These errors preserve the failed job and existing upload receipt; they do not authorize replay.
|
|
490
|
+
|
|
478
491
|
An expired/deleted edit does not identify its cause. Another edit opened with the
|
|
479
492
|
same API user/package can invalidate it; avoid competing read edits while uploading.
|
|
480
493
|
An external actor is not proven by an expired-edit error alone. Ambiguous outcomes
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.114
|
|
@@ -22,7 +22,7 @@ def command(argv: list[str], cwd: Path, log, *, timeout: float = 900) -> int:
|
|
|
22
22
|
try:
|
|
23
23
|
return native_pods_process.command(argv, cwd, log, timeout=timeout)
|
|
24
24
|
except native_pods_process.Interrupted:
|
|
25
|
-
|
|
25
|
+
refuse("native_pods_operation_interrupted", log, guard.Refused)
|
|
26
26
|
except (OSError, RuntimeError, subprocess.SubprocessError):
|
|
27
27
|
raise guard.Refused("native_pods_process_unverified") from None
|
|
28
28
|
|
|
@@ -7,7 +7,7 @@ import time
|
|
|
7
7
|
|
|
8
8
|
import native_bundle
|
|
9
9
|
import native_dependency_guard as guard
|
|
10
|
-
from native_pods_diagnostics import diagnostic
|
|
10
|
+
from native_pods_diagnostics import diagnostic, refuse
|
|
11
11
|
|
|
12
12
|
TERMINAL = re.compile(
|
|
13
13
|
r"certificate|SSL peer|TLS|authentication|unauthorized|forbidden|permission denied|"
|
|
@@ -27,7 +27,7 @@ def install(argv: list[str], folder, log, snapshot: dict, *, timeout: float = 90
|
|
|
27
27
|
native_bundle.unchanged(snapshot)
|
|
28
28
|
remaining = deadline - time.monotonic()
|
|
29
29
|
if remaining <= 0:
|
|
30
|
-
|
|
30
|
+
refuse("native_pods_operation_interrupted", log, guard.Refused)
|
|
31
31
|
with tempfile.TemporaryFile() as child_log:
|
|
32
32
|
code = native_bundle.command(argv, folder, child_log, timeout=remaining)
|
|
33
33
|
body = diagnostic(child_log)
|
|
@@ -142,6 +142,7 @@ class PodsTests(unittest.TestCase):
|
|
|
142
142
|
self.locked()
|
|
143
143
|
script = self.root / "pod"
|
|
144
144
|
script.write_text('#!' + sys.executable + '\nimport os,time\nfrom pathlib import Path\n'
|
|
145
|
+
'print("Downloading dependencies\\nRead timed out\\nPRIVATE_SENTINEL", flush=True)\n'
|
|
145
146
|
'Path("owned-child").write_text(str(os.getpid()))\ntime.sleep(30)\n')
|
|
146
147
|
script.chmod(0o700)
|
|
147
148
|
self.env["PATH"] = str(self.root) + os.pathsep + self.env["PATH"]
|
|
@@ -155,8 +156,11 @@ class PodsTests(unittest.TestCase):
|
|
|
155
156
|
output, errors = child.communicate(timeout=10)
|
|
156
157
|
self.assertTrue((self.root / "owned-child").exists())
|
|
157
158
|
self.assertEqual(json.loads(output)["code"], "native_pods_operation_interrupted")
|
|
159
|
+
self.assertIn("Downloading dependencies", json.loads(output).get("diagnostic", ""))
|
|
160
|
+
self.assertNotIn("PRIVATE_SENTINEL", output + errors)
|
|
158
161
|
self.assertTrue(errors.startswith("::error title=native_prepare_failed::"))
|
|
159
162
|
self.assertEqual(json.loads(errors.strip().split("::", 2)[2])["code"], "native_pods_operation_interrupted")
|
|
163
|
+
self.assertEqual(json.loads(errors.strip().split("::", 2)[2])["signals"], ["connection_timeout"])
|
|
160
164
|
owned_pid = int((self.root / "owned-child").read_text())
|
|
161
165
|
deadline = time.monotonic() + 5
|
|
162
166
|
while time.monotonic() < deadline:
|
|
@@ -95,6 +95,7 @@ class RetryTests(unittest.TestCase):
|
|
|
95
95
|
self.assertEqual([c.kwargs["timeout"] for c in command.call_args_list], [899, 200])
|
|
96
96
|
command.reset_mock()
|
|
97
97
|
with patch.object(native_pods_retry.time, "monotonic", side_effect=[0, 1, 901]):
|
|
98
|
-
with self.assertRaisesRegex(guard.Refused, "native_pods_operation_interrupted"):
|
|
98
|
+
with self.assertRaisesRegex(guard.Refused, "native_pods_operation_interrupted") as caught:
|
|
99
99
|
native_pods_retry.install(["fixture"], self.root, log, {})
|
|
100
100
|
self.assertEqual(command.call_count, 1)
|
|
101
|
+
self.assertEqual(caught.exception.diagnostic, PARTIAL)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Keep a real timed-out Pod child's sanitized evidence after its private log closes."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import native_dependency_guard as guard
|
|
10
|
+
import native_pods_retry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@unittest.skipUnless(os.name == "posix", "owned POSIX process groups")
|
|
14
|
+
class TimeoutTests(unittest.TestCase):
|
|
15
|
+
def test_timeout_retains_bounded_diagnostic_without_retrying_or_leaving_child(self):
|
|
16
|
+
with tempfile.TemporaryDirectory(prefix="native-pods-timeout-") as folder:
|
|
17
|
+
root = Path(folder)
|
|
18
|
+
script = ('import os,time; from pathlib import Path; '
|
|
19
|
+
'Path("attempts").open("a").write("attempt\\n"); '
|
|
20
|
+
'print("x" * 20000, flush=True); '
|
|
21
|
+
'print("Downloading dependencies\\nRead timed out\\nPRIVATE_SENTINEL", flush=True); '
|
|
22
|
+
'Path("child").write_text(str(os.getpid())); time.sleep(30)')
|
|
23
|
+
with tempfile.TemporaryFile() as log, patch.dict(os.environ, {"TEST_TOKEN": "PRIVATE_SENTINEL"}):
|
|
24
|
+
with self.assertRaisesRegex(guard.Refused, "native_pods_operation_interrupted") as caught:
|
|
25
|
+
native_pods_retry.install([sys.executable, "-c", script], root, log, {}, timeout=1)
|
|
26
|
+
self.assertEqual((root / "attempts").read_text(), "attempt\n")
|
|
27
|
+
with self.assertRaises(ProcessLookupError):
|
|
28
|
+
os.kill(int((root / "child").read_text()), 0)
|
|
29
|
+
body = getattr(caught.exception, "diagnostic", "")
|
|
30
|
+
self.assertIn("Downloading dependencies", body)
|
|
31
|
+
self.assertIn("Read timed out", body)
|
|
32
|
+
self.assertNotIn("PRIVATE_SENTINEL", body)
|
|
33
|
+
self.assertLessEqual(len(body), 4000)
|
|
34
|
+
self.assertLessEqual(len(body.splitlines()), 30)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.114
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const http = require('./owner_http.cjs');
|
|
2
2
|
const record = require('./owner_record.cjs');
|
|
3
|
+
const {performance} = require('node:perf_hooks');
|
|
4
|
+
const {setTimeout: delay} = require('node:timers/promises');
|
|
3
5
|
|
|
4
6
|
async function seal(client, selected) {
|
|
5
7
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
@@ -14,16 +16,27 @@ async function seal(client, selected) {
|
|
|
14
16
|
throw new Error('play_owner_release_unverified');
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
async function acquire(client, selected, attempted = () => {}) {
|
|
19
|
+
async function acquire(client, selected, attempted = () => {}, waiting = {}) {
|
|
20
|
+
const now = waiting.now || (() => performance.now());
|
|
21
|
+
const sleep = waiting.sleep || ((ms, signal) => delay(ms, undefined, {signal}));
|
|
22
|
+
const deadline = now() + 60000;
|
|
18
23
|
const protocol = (await client.read('protocol.json')).value;
|
|
19
24
|
if (protocol?.schema !== record.schema || protocol.repository !== selected.value.repo
|
|
20
25
|
|| Object.keys(protocol).length !== 2) throw new Error('play_owner_unverified');
|
|
21
|
-
for (let attempt = 0; attempt < 3;
|
|
26
|
+
for (let attempt = 0; attempt < 3;) {
|
|
27
|
+
if (waiting.signal?.aborted) throw new Error('play_owner_cancelled');
|
|
22
28
|
const row = record.validate(await client.read(selected.path), selected);
|
|
23
29
|
if (row.value.owner !== null) {
|
|
24
30
|
const owner = row.value.owner;
|
|
25
|
-
if (owner.kind !== 'ci' || !record.terminal(await client.run(owner), owner))
|
|
31
|
+
if (owner.kind !== 'ci' || !record.terminal(await client.run(owner), owner)) {
|
|
32
|
+
const remaining = deadline - now();
|
|
33
|
+
if (remaining <= 0) throw new Error('play_owner_busy');
|
|
34
|
+
await sleep(Math.min(5000, remaining), waiting.signal);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
26
37
|
}
|
|
38
|
+
if (waiting.signal?.aborted) throw new Error('play_owner_cancelled');
|
|
39
|
+
attempt++;
|
|
27
40
|
const desired = record.replacement(row, selected.owner);
|
|
28
41
|
attempted();
|
|
29
42
|
try { await client.write(selected.path, row, desired); } catch { /* Only exact readback may adopt it. */ }
|
|
@@ -33,14 +46,15 @@ async function acquire(client, selected, attempted = () => {}) {
|
|
|
33
46
|
throw new Error('play_owner_busy');
|
|
34
47
|
}
|
|
35
48
|
|
|
36
|
-
async function withOwner(env, packageName, operation, transport) {
|
|
49
|
+
async function withOwner(env, packageName, operation, transport, timing = {}) {
|
|
37
50
|
const selected = record.binding(env, packageName);
|
|
38
51
|
const client = http.client(selected.value.repo, env.GITHUB_TOKEN, transport);
|
|
52
|
+
const controller = new AbortController();
|
|
39
53
|
let cancelled = false, dirty = false, result, failure;
|
|
40
|
-
const stop = () => { cancelled = true; };
|
|
54
|
+
const stop = () => { cancelled = true; controller.abort(); };
|
|
41
55
|
process.on('SIGTERM', stop); process.on('SIGINT', stop);
|
|
42
56
|
try {
|
|
43
|
-
await acquire(client, selected, () => { dirty = true; });
|
|
57
|
+
await acquire(client, selected, () => { dirty = true; }, {...timing, signal: controller.signal});
|
|
44
58
|
if (cancelled) throw new Error('play_owner_cancelled');
|
|
45
59
|
result = await operation(() => cancelled);
|
|
46
60
|
} catch (error) { failure = error; }
|
|
@@ -48,7 +62,7 @@ async function withOwner(env, packageName, operation, transport) {
|
|
|
48
62
|
try { if (dirty) await seal(client, selected); } catch { failure = new Error('play_owner_release_unverified'); }
|
|
49
63
|
process.off('SIGTERM', stop); process.off('SIGINT', stop);
|
|
50
64
|
}
|
|
51
|
-
if (cancelled && !failure) failure = new Error('play_owner_cancelled');
|
|
65
|
+
if (cancelled && (!failure || failure.name === 'AbortError')) failure = new Error('play_owner_cancelled');
|
|
52
66
|
if (failure) {
|
|
53
67
|
if (result !== undefined) failure.operationResult = result;
|
|
54
68
|
throw failure;
|
|
@@ -58,4 +72,10 @@ async function withOwner(env, packageName, operation, transport) {
|
|
|
58
72
|
|
|
59
73
|
const codes = ['play_owner_binding_required', 'play_owner_unverified', 'play_owner_busy',
|
|
60
74
|
'play_owner_release_unverified', 'play_owner_cancelled', 'play_owner_principal_unverified'];
|
|
61
|
-
|
|
75
|
+
function annotation(reason, childExit) {
|
|
76
|
+
const allowed = [...codes, 'play_owner_child_failed', 'play_owner_package_unverified'];
|
|
77
|
+
const code = allowed.includes(reason) ? reason : 'play_owner_unverified';
|
|
78
|
+
const exit = Number.isInteger(childExit) && childExit >= 0 && childExit <= 255 ? childExit : 'unknown';
|
|
79
|
+
return `::error title=play_edit_owner::gowalk-cicd/play-owner.v1 code=${code} child_exit_code=${exit}`;
|
|
80
|
+
}
|
|
81
|
+
module.exports = {acquire, seal, withOwner, codes, annotation};
|
|
@@ -51,9 +51,7 @@ async function main(argv = process.argv.slice(2), env = process.env) {
|
|
|
51
51
|
} catch (error) {
|
|
52
52
|
const code = [...owner.codes, 'play_owner_child_failed', 'play_owner_package_unverified'].includes(error.message)
|
|
53
53
|
? error.message : 'play_owner_unverified';
|
|
54
|
-
|
|
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)}`);
|
|
54
|
+
console.log(owner.annotation(code, error.operationResult?.code));
|
|
57
55
|
process.exitCode = 1;
|
|
58
56
|
}
|
|
59
57
|
}
|
|
@@ -44,6 +44,7 @@ async function main() {
|
|
|
44
44
|
upload(program, env, directory, processRunner.execute, 3600000, cancelled,
|
|
45
45
|
(attempt, result) => console.log(diagnostics.annotation(attempt, result))));
|
|
46
46
|
} catch (error) {
|
|
47
|
+
if (owner.codes.includes(error.message)) console.log(owner.annotation(error.message));
|
|
47
48
|
const allowed = ['assigned_proxy_required', 'runner_context_required', 'uploader_integrity_refused',
|
|
48
49
|
'uploader_source_unavailable', ...owner.codes];
|
|
49
50
|
receipt = {ok: false, code: allowed.includes(error.message) ? error.message : 'uploader_failed',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.114
|