gowalk-cicd 1.0.111 → 1.0.113
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 +11 -0
- package/action/.daemux-version +1 -1
- 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/android-action/scripts/jvm_proxy_relay.py +13 -6
- package/android-action/scripts/test_jvm_proxy_idle.py +106 -0
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -143,6 +143,8 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
143
143
|
Crashlytics Buildtools reads them first and rejects authenticated proxy URLs. HTTPS is tunneled
|
|
144
144
|
without TLS interception; no upstream failure may fall back to the destination.
|
|
145
145
|
The wrapper disables Gradle daemons and closes its relay when the command ends.
|
|
146
|
+
The relay's idle deadline covers the whole tunnel: traffic in either direction
|
|
147
|
+
keeps downloads/uploads alive. An idle direction alone must never end a transfer.
|
|
146
148
|
- Flutter bootstrap is part of that boundary: before the Flutter setup action,
|
|
147
149
|
`play_store_proxy.py --bootstrap-env` validates the Google account pin and writes
|
|
148
150
|
both proxy/bypass spellings to the private job environment without console output.
|
|
@@ -393,8 +395,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
393
395
|
`APP_ROBOT_PLAY_EDIT_BINDING` is panel-provisioned repository data; workflows pass it as
|
|
394
396
|
`PLAY_EDIT_BINDING` with the repo-local GitHub token and `contents:write`/`actions:read`.
|
|
395
397
|
`play-upload/owner_run.cjs` protects Python entrypoints; `run.cjs` holds the same owner across
|
|
396
|
-
its permitted upload retry.
|
|
397
|
-
|
|
398
|
+
its permitted upload retry. An active owner gets a single 60-second acquisition wait budget;
|
|
399
|
+
each retry revalidates the record, cancellation interrupts the wait, and an expired budget
|
|
400
|
+
never clears an owner. Missing/retired bindings and unknown write outcomes still refuse;
|
|
401
|
+
only exact terminal run/attempt/source proof permits CI-owner recovery.
|
|
402
|
+
Both entrypoints emit brace-free `play_edit_owner` errors with fixed codes and optional child
|
|
403
|
+
exit evidence, preserving the existing upload receipt and refusal. Never reconstruct masked text.
|
|
398
404
|
Other packages stay parallel. The GitHub coordination request is fixed-origin and does not
|
|
399
405
|
change the Google account proxy. Update workflow wiring as well as action directories.
|
|
400
406
|
|
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` |
|
|
@@ -475,6 +476,16 @@ bounded to 16 KiB. No provider message, path, URL, track or credential is republ
|
|
|
475
476
|
These observations never authorize a retry. Missing commit output does not prove no mutation,
|
|
476
477
|
and a reported commit does not prove review submission. Unknown failures remain unclassified.
|
|
477
478
|
|
|
479
|
+
Package-owner acquisition rechecks an occupied record within one 60-second wait budget before
|
|
480
|
+
provider work. Every read retains the binding and identity checks; cancellation interrupts the wait.
|
|
481
|
+
Time passing never authorizes clearing an owner. A known CI owner still requires exact terminal
|
|
482
|
+
run, attempt and source proof before recovery; the panel releases its own requests.
|
|
483
|
+
Both entrypoints emit `play_edit_owner` as `gowalk-cicd/play-owner.v1 code=<code> child_exit_code=<0-255|unknown>`.
|
|
484
|
+
Codes are `play_owner_binding_required`, `play_owner_unverified`, `play_owner_busy`,
|
|
485
|
+
`play_owner_release_unverified`, `play_owner_cancelled`, `play_owner_principal_unverified`,
|
|
486
|
+
`play_owner_child_failed` and `play_owner_package_unverified`. An unknown child exit proves no outcome.
|
|
487
|
+
These errors preserve the failed job and existing upload receipt; they do not authorize replay.
|
|
488
|
+
|
|
478
489
|
An expired/deleted edit does not identify its cause. Another edit opened with the
|
|
479
490
|
same API user/package can invalidate it; avoid competing read edits while uploading.
|
|
480
491
|
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.113
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.113
|
|
@@ -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',
|
|
@@ -13,8 +13,8 @@ import threading
|
|
|
13
13
|
from urllib.parse import unquote, urlsplit
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
# A tunnel carries whole SDK/NDK/CMake downloads and Gradle's keep-alive pools
|
|
17
|
-
#
|
|
16
|
+
# A tunnel carries whole SDK/NDK/CMake downloads and Gradle's keep-alive pools.
|
|
17
|
+
# Only inactivity in both directions expires it; a download may send no requests.
|
|
18
18
|
IDLE_TIMEOUT = 900.0
|
|
19
19
|
|
|
20
20
|
|
|
@@ -63,11 +63,16 @@ class Relay:
|
|
|
63
63
|
await writer.drain()
|
|
64
64
|
return reader, writer
|
|
65
65
|
|
|
66
|
-
async def _pump(self, reader, writer):
|
|
67
|
-
while data := await
|
|
66
|
+
async def _pump(self, reader, writer, activity):
|
|
67
|
+
while data := await reader.read(65536):
|
|
68
|
+
activity[0] = self.loop.time()
|
|
68
69
|
writer.write(data)
|
|
69
70
|
await writer.drain()
|
|
70
71
|
|
|
72
|
+
async def _idle(self, activity):
|
|
73
|
+
while remaining := max(0.0, activity[0] + IDLE_TIMEOUT - self.loop.time()):
|
|
74
|
+
await asyncio.sleep(remaining)
|
|
75
|
+
|
|
71
76
|
async def _handle(self, reader, writer):
|
|
72
77
|
upstream = None
|
|
73
78
|
pumps = []
|
|
@@ -82,8 +87,10 @@ class Relay:
|
|
|
82
87
|
writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
|
|
83
88
|
await writer.drain()
|
|
84
89
|
established = True
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
activity = [self.loop.time()]
|
|
91
|
+
pumps = [asyncio.create_task(self._pump(reader, upstream, activity)),
|
|
92
|
+
asyncio.create_task(self._pump(upstream_reader, writer, activity)),
|
|
93
|
+
asyncio.create_task(self._idle(activity))]
|
|
87
94
|
await asyncio.wait(pumps, return_when=asyncio.FIRST_COMPLETED)
|
|
88
95
|
except (OSError, ValueError, IndexError, asyncio.TimeoutError,
|
|
89
96
|
asyncio.IncompleteReadError, asyncio.LimitOverrunError):
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""A live tunnel stays open for one-way traffic and closes on total inactivity."""
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
4
|
+
import socket
|
|
5
|
+
import threading
|
|
6
|
+
import unittest
|
|
7
|
+
from unittest import mock
|
|
8
|
+
|
|
9
|
+
import jvm_proxy_relay as relay
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
PAYLOAD = bytes(range(24))
|
|
13
|
+
INTERVAL = 0.075
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@contextmanager
|
|
17
|
+
def tunnel(mode):
|
|
18
|
+
stop = threading.Event()
|
|
19
|
+
finished = threading.Event()
|
|
20
|
+
received = []
|
|
21
|
+
|
|
22
|
+
class Proxy(BaseHTTPRequestHandler):
|
|
23
|
+
def do_CONNECT(self):
|
|
24
|
+
self.send_response(200)
|
|
25
|
+
self.end_headers()
|
|
26
|
+
try:
|
|
27
|
+
if mode == "download":
|
|
28
|
+
for value in PAYLOAD:
|
|
29
|
+
self.wfile.write(bytes([value]))
|
|
30
|
+
if stop.wait(INTERVAL):
|
|
31
|
+
break
|
|
32
|
+
else:
|
|
33
|
+
value = self.rfile.read(len(PAYLOAD))
|
|
34
|
+
received.append(value)
|
|
35
|
+
if mode == "upload":
|
|
36
|
+
self.wfile.write(value)
|
|
37
|
+
except OSError:
|
|
38
|
+
pass
|
|
39
|
+
finally:
|
|
40
|
+
finished.set()
|
|
41
|
+
|
|
42
|
+
def log_message(self, *_):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), Proxy)
|
|
46
|
+
server.daemon_threads = True
|
|
47
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
48
|
+
thread.start()
|
|
49
|
+
try:
|
|
50
|
+
yield f"http://127.0.0.1:{server.server_port}", finished, received
|
|
51
|
+
finally:
|
|
52
|
+
stop.set()
|
|
53
|
+
server.shutdown()
|
|
54
|
+
server.server_close()
|
|
55
|
+
thread.join(timeout=5)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def client(port):
|
|
60
|
+
with socket.create_connection(("127.0.0.1", port), timeout=5) as connection:
|
|
61
|
+
with connection.makefile("rb") as stream:
|
|
62
|
+
connection.sendall(b"CONNECT unresolvable.invalid:443 HTTP/1.1\r\nHost: ignored\r\n\r\n")
|
|
63
|
+
assert b"200" in stream.readline()
|
|
64
|
+
while stream.readline() != b"\r\n":
|
|
65
|
+
pass
|
|
66
|
+
yield connection, stream
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TunnelActivityTests(unittest.TestCase):
|
|
70
|
+
def test_download_outlives_idle_deadline_without_client_traffic(self):
|
|
71
|
+
with tunnel("download") as (proxy, finished, _), mock.patch.object(relay, "IDLE_TIMEOUT", 1.0):
|
|
72
|
+
with relay.running(proxy) as port, client(port) as (_, stream):
|
|
73
|
+
self.assertEqual(stream.read(len(PAYLOAD)), PAYLOAD)
|
|
74
|
+
self.assertTrue(finished.wait(5))
|
|
75
|
+
|
|
76
|
+
def test_upload_outlives_idle_deadline_without_server_traffic(self):
|
|
77
|
+
with tunnel("upload") as (proxy, finished, received), mock.patch.object(relay, "IDLE_TIMEOUT", 1.0):
|
|
78
|
+
with relay.running(proxy) as port, client(port) as (connection, stream):
|
|
79
|
+
for value in PAYLOAD:
|
|
80
|
+
connection.sendall(bytes([value]))
|
|
81
|
+
threading.Event().wait(INTERVAL)
|
|
82
|
+
self.assertEqual(stream.read(len(PAYLOAD)), PAYLOAD)
|
|
83
|
+
self.assertTrue(finished.wait(5))
|
|
84
|
+
self.assertEqual(received, [PAYLOAD])
|
|
85
|
+
|
|
86
|
+
def test_wholly_idle_tunnel_closes_without_http_bytes(self):
|
|
87
|
+
with tunnel("idle") as (proxy, finished, received), mock.patch.object(relay, "IDLE_TIMEOUT", 0.2):
|
|
88
|
+
with relay.running(proxy) as port, client(port) as (_, stream):
|
|
89
|
+
self.assertEqual(stream.read(), b"")
|
|
90
|
+
self.assertTrue(finished.wait(5))
|
|
91
|
+
self.assertEqual(received, [b""])
|
|
92
|
+
|
|
93
|
+
def test_owner_exit_closes_active_clients_and_upstream(self):
|
|
94
|
+
with tunnel("idle") as (proxy, finished, received):
|
|
95
|
+
with relay.running(proxy) as port:
|
|
96
|
+
connection = socket.create_connection(("127.0.0.1", port), timeout=5)
|
|
97
|
+
connection.sendall(b"CONNECT unresolvable.invalid:443 HTTP/1.1\r\n\r\n")
|
|
98
|
+
self.assertIn(b"200", connection.recv(4096))
|
|
99
|
+
with connection:
|
|
100
|
+
self.assertEqual(connection.recv(4096), b"")
|
|
101
|
+
self.assertTrue(finished.wait(5))
|
|
102
|
+
self.assertEqual(received, [b""])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.113
|