gowalk-cicd 1.0.110 → 1.0.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -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.
@@ -369,6 +371,10 @@ node /path/to/gowalk-cicd/bin/cli.mjs
369
371
  refusal permits a review hold. Unknown/transport/post-commit uncertainty does not replay.
370
372
  The existing signed AAB, proxy, track/status/rollout and notes survive one bounded retry;
371
373
  only a successful hold emits `play_review_pending`. Keep raw child output out of receipts.
374
+ Each attempt also emits brace-free `play_upload_diagnostic` with fixed observed progress,
375
+ process outcome and error signals from bounded private stdout/stderr line buffers.
376
+ Preserve the v1 upload result and retry rules; diagnostics never authorize replay or prove
377
+ an operation completed. See README for the independent diagnostic schema and field vocabulary.
372
378
  - The JDK is chosen by `scripts/select_jdk.py`, called from the workflow before
373
379
  `setup-java`. The `JAVA_VERSION` repo variable always wins; otherwise Flutter
374
380
  gets 17 and native Gradle gets 21, except that a positively-detected Kotlin
package/README.md CHANGED
@@ -360,6 +360,7 @@ the situation and whose **message** is one line of JSON carrying a versioned
360
360
  | --- | --- | --- |
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
+ | `play_upload_diagnostic` | `gowalk-cicd/play-upload-diagnostic.v1` | Fixed process evidence; see below |
363
364
  | `store_version_locked` | `gowalk-cicd/store-version-locked.v1` | `version`, `state`, `build_number` |
364
365
  | `firebase_symbols_pending` | `gowalk-cicd/firebase-symbols-pending.v1` | `platform`, `app_id`, `source_sha`, `artifact`, `file`, `sha256`, `status` |
365
366
  | `mobile_upload_reused` | `gowalk-cicd/mobile-upload-reused.v1` | `platform`, `fingerprint`, `run_id`, `run_attempt`, `source_sha`, `deployment_id`, `next_action` |
@@ -460,6 +461,20 @@ successful review-hold retry emits the existing `play_review_pending` receipt.
460
461
  Ordinary retry success never misreports an unsubmitted release. The wrapper does
461
462
  not depend on check-run annotations becoming visible while its job is running.
462
463
 
464
+ Every completed uploader attempt also emits `play_upload_diagnostic`, independently of the
465
+ unchanged v1 result. Its brace-free message starts with `gowalk-cicd/play-upload-diagnostic.v1`
466
+ and carries these space-separated fields in order: `attempt=<1|2>`, `phase=<phase>`,
467
+ `exit_code=<0-255|unknown>`, `signal=<signal>`, `error_count=<count|unknown>`,
468
+ `cancelled=<true|false>`, `commit_reported=<true|false>`, `signals=<signals>`.
469
+ The phase is the last observed official progress line: `startup`, `edit_create`, `tracks_read`,
470
+ `bundle_upload`, `track_update` or `edit_commit`. It does not prove that operation completed.
471
+ The signal is `none`, `SIGTERM`, `SIGINT`, `SIGKILL` or `other`; observed error signals are
472
+ comma-separated `connection_reset`, `connection_refused`, `connection_timeout`, `dns_failure`,
473
+ `tls_verification`, `output_truncated`, or `none`. Each private stdout/stderr line buffer is
474
+ bounded to 16 KiB. No provider message, path, URL, track or credential is republished.
475
+ These observations never authorize a retry. Missing commit output does not prove no mutation,
476
+ and a reported commit does not prove review submission. Unknown failures remain unclassified.
477
+
463
478
  An expired/deleted edit does not identify its cause. Another edit opened with the
464
479
  same API user/package can invalidate it; avoid competing read edits while uploading.
465
480
  An external actor is not proven by an expired-edit error alone. Ambiguous outcomes
@@ -1 +1 @@
1
- 1.0.110
1
+ 1.0.112
@@ -1 +1 @@
1
- 1.0.110
1
+ 1.0.112
@@ -0,0 +1,73 @@
1
+ // Observe only fixed progress/error patterns; never retain provider text in a receipt.
2
+ const phases = new Map([
3
+ ['Creating a new Edit for this release', 'edit_create'],
4
+ ['Committing the Edit', 'edit_commit'],
5
+ ]);
6
+ const prefixes = new Map([
7
+ ["Validating tracks: '", 'tracks_read'], ['Uploading ', 'bundle_upload'],
8
+ ['::debug::Creating release for:', 'track_update'],
9
+ ]);
10
+ const tlsCodes = ['CERT_HAS_EXPIRED', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN',
11
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'ERR_TLS_CERT_ALTNAME_INVALID'];
12
+ const patterns = new Map([
13
+ ['connection_reset', /\bECONNRESET\b|\bsocket hang up\b/i],
14
+ ['connection_refused', /\bECONNREFUSED\b/],
15
+ ['connection_timeout', /\b(?:ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED)\b/],
16
+ ['dns_failure', /\b(?:ENOTFOUND|EAI_AGAIN)\b/],
17
+ ['tls_verification', new RegExp(`\\b(?:${tlsCodes.join('|')})\\b`)],
18
+ ]);
19
+ const knownPhases = new Set(['startup', ...phases.values(), ...prefixes.values()]);
20
+ const knownSignals = new Set([...patterns.keys(), 'output_truncated']);
21
+ const limit = 16384;
22
+
23
+ function create() {
24
+ return {phase: 'startup', signals: new Set(), pending: {stdout: '', stderr: ''},
25
+ discard: {stdout: false, stderr: false}};
26
+ }
27
+
28
+ function line(state, stream, value) {
29
+ if (stream === 'stdout') {
30
+ if (phases.has(value)) state.phase = phases.get(value);
31
+ for (const [prefix, phase] of prefixes) if (value.startsWith(prefix)) state.phase = phase;
32
+ if (!value.startsWith('::error')) return;
33
+ value = value.replace(/%0D/g, '\r').replace(/%0A/g, '\n').replace(/%25/g, '%');
34
+ }
35
+ for (const [signal, pattern] of patterns) if (pattern.test(value)) state.signals.add(signal);
36
+ }
37
+
38
+ function consume(state, stream, chunk) {
39
+ state.pending[stream] += chunk;
40
+ let end;
41
+ while ((end = state.pending[stream].indexOf('\n')) !== -1) {
42
+ const value = state.pending[stream].slice(0, end).replace(/\r$/, '');
43
+ state.pending[stream] = state.pending[stream].slice(end + 1);
44
+ if (state.discard[stream]) state.discard[stream] = false;
45
+ else if (value.length <= limit) line(state, stream, value);
46
+ else state.signals.add('output_truncated');
47
+ }
48
+ if (state.pending[stream].length > limit) {
49
+ state.pending[stream] = ''; state.discard[stream] = true; state.signals.add('output_truncated');
50
+ }
51
+ }
52
+
53
+ function snapshot(state) {
54
+ return {phase: state.phase, signals: [...state.signals].sort()};
55
+ }
56
+
57
+ function annotation(attempt, result) {
58
+ const diagnostic = result.diagnostic || {};
59
+ const phase = knownPhases.has(diagnostic.phase) ? diagnostic.phase : 'startup';
60
+ const signals = Array.isArray(diagnostic.signals)
61
+ ? [...new Set(diagnostic.signals.filter(value => knownSignals.has(value)))].sort() : [];
62
+ const exit = Number.isInteger(result.status) && result.status >= 0 && result.status <= 255
63
+ ? result.status : 'unknown';
64
+ const signal = ['SIGTERM', 'SIGINT', 'SIGKILL'].includes(result.signal)
65
+ ? result.signal : result.signal ? 'other' : 'none';
66
+ const errors = Number.isSafeInteger(result.errors) && result.errors >= 0 ? result.errors : 'unknown';
67
+ return '::notice title=play_upload_diagnostic::gowalk-cicd/play-upload-diagnostic.v1'
68
+ + ` attempt=${attempt === 2 ? 2 : 1} phase=${phase} exit_code=${exit} signal=${signal}`
69
+ + ` error_count=${errors} cancelled=${result.cancelled === true} commit_reported=${result.committed === true}`
70
+ + ` signals=${signals.join(',') || 'none'}`;
71
+ }
72
+
73
+ module.exports = {create, consume, snapshot, annotation};
@@ -2,6 +2,7 @@
2
2
  const {spawn} = require('node:child_process');
3
3
  const {readFileSync, writeFileSync} = require('node:fs');
4
4
  const {join} = require('node:path');
5
+ const diagnostics = require('./diagnostics.cjs');
5
6
  const messages = new Map([
6
7
  ['This edit has expired, please create a new Edit.', 'edit_expired'],
7
8
  ['This Edit has been deleted.', 'edit_deleted'],
@@ -40,6 +41,7 @@ function execute(program, env, directory, timeout) {
40
41
  const output = join(directory, 'output'), exports = join(directory, 'environment');
41
42
  writeFileSync(output, '', {mode: 0o600}); writeFileSync(exports, '', {mode: 0o600});
42
43
  const state = {pending: '', errors: 0, code: 'unclassified_failure', ambiguous: false};
44
+ const diagnostic = diagnostics.create();
43
45
  const child = spawn(process.execPath, [program], {
44
46
  env: {...env, GITHUB_OUTPUT: output, GITHUB_ENV: exports}, stdio: ['ignore', 'pipe', 'pipe'],
45
47
  });
@@ -50,15 +52,19 @@ function execute(program, env, directory, timeout) {
50
52
  };
51
53
  const timer = setTimeout(stop, timeout);
52
54
  process.on('SIGTERM', stop); process.on('SIGINT', stop);
53
- child.stdout.setEncoding('utf8'); child.stdout.on('data', chunk => consume(state, chunk));
54
- child.stderr.resume();
55
+ child.stdout.setEncoding('utf8'); child.stdout.on('data', chunk => {
56
+ consume(state, chunk); diagnostics.consume(diagnostic, 'stdout', chunk);
57
+ });
58
+ child.stderr.setEncoding('utf8');
59
+ child.stderr.on('data', chunk => diagnostics.consume(diagnostic, 'stderr', chunk));
55
60
  child.on('error', () => { state.ambiguous = true; });
56
61
  child.on('close', (status, signal) => {
57
62
  clearTimeout(timer); clearTimeout(force); process.off('SIGTERM', stop); process.off('SIGINT', stop);
58
63
  consume(state, '\n');
64
+ diagnostics.consume(diagnostic, 'stdout', '\n'); diagnostics.consume(diagnostic, 'stderr', '\n');
59
65
  const committed = /(?:^|\n)committedEditId(?:=|<<)/.test(readFileSync(output, 'utf8'));
60
66
  resolve({status, signal, cancelled, committed, errors: state.errors,
61
- code: state.ambiguous ? 'unclassified_failure' : state.code});
67
+ code: state.ambiguous ? 'unclassified_failure' : state.code, diagnostic: diagnostics.snapshot(diagnostic)});
62
68
  });
63
69
  });
64
70
  }
@@ -4,9 +4,10 @@ const {join} = require('node:path');
4
4
  const source = require('./source.cjs');
5
5
  const processRunner = require('./process.cjs');
6
6
  const owner = require('./owner.cjs');
7
+ const diagnostics = require('./diagnostics.cjs');
7
8
 
8
9
  async function upload(program, env, directory, execute = processRunner.execute, timeout = 3600000,
9
- cancelled = () => false) {
10
+ cancelled = () => false, report = () => {}) {
10
11
  const deadline = Date.now() + timeout;
11
12
  let mode = 'false';
12
13
  for (let attempt = 1; attempt <= 2; attempt++) {
@@ -14,6 +15,7 @@ async function upload(program, env, directory, execute = processRunner.execute,
14
15
  const target = join(directory, String(attempt)); mkdirSync(target, {mode: 0o700});
15
16
  const result = await execute(program, {...env, INPUT_CHANGESNOTSENTFORREVIEW: mode}, target,
16
17
  Math.max(1, deadline - Date.now()));
18
+ report(attempt, result);
17
19
  const reviewPending = mode === 'true';
18
20
  if (result.status === 0 && !result.signal && !result.cancelled && result.errors === 0) {
19
21
  return {ok: true, code: reviewPending ? 'review_pending' : 'uploaded', attempts: attempt,
@@ -39,7 +41,8 @@ async function main() {
39
41
  directory = mkdtempSync(join(env.RUNNER_TEMP, 'gowalk-play-upload-'));
40
42
  const program = await source.prepare(directory);
41
43
  receipt = await owner.withOwner(env, env.INPUT_PACKAGENAME, cancelled =>
42
- upload(program, env, directory, processRunner.execute, 3600000, cancelled));
44
+ upload(program, env, directory, processRunner.execute, 3600000, cancelled,
45
+ (attempt, result) => console.log(diagnostics.annotation(attempt, result))));
43
46
  } catch (error) {
44
47
  const allowed = ['assigned_proxy_required', 'runner_context_required', 'uploader_integrity_refused',
45
48
  'uploader_source_unavailable', ...owner.codes];
@@ -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; an
17
- # idle read is not a failure, so pumps wait long and the tunnel closes cleanly.
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 asyncio.wait_for(reader.read(65536), timeout=IDLE_TIMEOUT):
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
- pumps = [asyncio.create_task(self._pump(reader, upstream)),
86
- asyncio.create_task(self._pump(upstream_reader, writer))]
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.110
1
+ 1.0.112
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.110",
3
+ "version": "1.0.112",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {