gowalk-cicd 1.0.123 → 1.0.125

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.
@@ -1 +1 @@
1
- 1.0.123
1
+ 1.0.125
@@ -1,4 +1,4 @@
1
- """Retry one positively identified interrupted Pod fetch within the existing install deadline."""
1
+ """Retry one positively identified transient Pod fetch fault within the existing install deadline."""
2
2
  import json
3
3
  import re
4
4
  import sys
@@ -14,6 +14,11 @@ TERMINAL = re.compile(
14
14
  r"repository not found|checksum|compatible versions|deployment mode|"
15
15
  r"returned error:\s*(?:401|403|407)\b|\bcurl(?:\s+(?:51|60)\b|:\s*\((?:51|60)\))", re.I)
16
16
  PARTIAL = re.compile(r"\bcurl(?:\s+18\b|:\s*\(18\))", re.I)
17
+ # CocoaPods reports a timed-out CDN spec fetch as its own sentence and never as a curl
18
+ # code, so the partial-transfer evidence above cannot see it: "CDN: trunk URL couldn't be
19
+ # downloaded: <url> Response: Timeout was reached". A timeout leaves the successfully
20
+ # fetched specs cached, so the second attempt resumes rather than starting over.
21
+ TIMED_OUT = re.compile(r"Response:\s*Timeout was reached|\bcurl(?:\s+28\b|:\s*\(28\))", re.I)
17
22
 
18
23
 
19
24
  def interrupted_transfer(body: str) -> bool:
@@ -21,6 +26,17 @@ def interrupted_transfer(body: str) -> bool:
21
26
  text in body.lower() for text in ("early eof", "transfer closed", "bytes of body are still expected")))
22
27
 
23
28
 
29
+ def timed_out(body: str) -> bool:
30
+ return bool(TIMED_OUT.search(body) and not TERMINAL.search(body))
31
+
32
+
33
+ def retryable(body: str) -> str:
34
+ """Name the transient class this failure belongs to, or "" when it is not one."""
35
+ if interrupted_transfer(body):
36
+ return "partial_transfer"
37
+ return "transient_timeout" if timed_out(body) else ""
38
+
39
+
24
40
  def install(argv: list[str], folder, log, snapshot: dict, *, timeout: float = 900) -> tuple[int, int]:
25
41
  deadline = time.monotonic() + timeout
26
42
  for attempt in (1, 2):
@@ -35,9 +51,10 @@ def install(argv: list[str], folder, log, snapshot: dict, *, timeout: float = 90
35
51
  log.seek(0)
36
52
  log.truncate()
37
53
  log.write(body.encode())
38
- if code == 0 or attempt == 2 or not interrupted_transfer(body):
54
+ reason = retryable(body)
55
+ if code == 0 or attempt == 2 or not reason:
39
56
  return code, attempt
40
- payload = {"schema": "gowalk-cicd/native-pods-retry.v1", "attempt": 2, "reason": "partial_transfer"}
57
+ payload = {"schema": "gowalk-cicd/native-pods-retry.v1", "attempt": 2, "reason": reason}
41
58
  print("::notice title=native_pods_retry::" + json.dumps(payload, separators=(",", ":")),
42
59
  file=sys.stderr, flush=True)
43
60
  raise AssertionError("unreachable")
@@ -12,6 +12,10 @@ import native_pods_retry
12
12
  import test_native_pods
13
13
 
14
14
  PARTIAL = "error: RPC failed; curl 18 transfer closed with outstanding read data remaining\nfatal: early EOF"
15
+ # Verbatim from ios-zuper-sms Mobile Deploy 34346560202, after the diagnostic's own URL
16
+ # redaction — the form `retryable` actually sees.
17
+ TIMEOUT = "\n".join(["CDN: trunk URL couldn't be downloaded: [URL redacted] "
18
+ "Response: Timeout was reached"] * 30)
15
19
 
16
20
 
17
21
  class RetryTests(unittest.TestCase):
@@ -81,6 +85,42 @@ class RetryTests(unittest.TestCase):
81
85
  self.assertEqual(self.fixture.run_cli()[0], 1)
82
86
  self.assertEqual(len(json.loads((self.root / "attempts.json").read_text())), 1)
83
87
 
88
+ def test_timed_out_cdn_fetch_recovers_within_the_same_deadline(self):
89
+ self.script([TIMEOUT])
90
+ code, result = self.fixture.run_cli()
91
+ self.assertEqual((code, result["pod_attempts"]), (0, 2))
92
+ attempts = json.loads((self.root / "attempts.json").read_text())
93
+ self.assertEqual(len(attempts), 2)
94
+ for attempt in attempts:
95
+ self.assertEqual(attempt["argv"], ["install", "--deployment", "--no-repo-update"])
96
+ self.assertTrue(attempt["proxy"].startswith("http://127.0.0.1:"))
97
+ self.assertEqual((self.root / "Podfile.lock").read_text(), "fixture-lock")
98
+
99
+ def test_repeat_timeout_stops_after_second_attempt(self):
100
+ self.script([TIMEOUT, TIMEOUT, TIMEOUT])
101
+ code, result = self.fixture.run_cli()
102
+ self.assertEqual((code, result["code"]), (1, "native_pods_install_failed"))
103
+ self.assertEqual(len(json.loads((self.root / "attempts.json").read_text())), 2)
104
+
105
+ def test_terminal_evidence_dominates_a_timeout(self):
106
+ for failure in ("certificate verify failed", "The requested URL returned error: 403",
107
+ "checksum mismatch", "curl: (60) unknown trust failure"):
108
+ with self.subTest(failure=failure):
109
+ (self.root / "attempts.json").unlink(missing_ok=True)
110
+ self.script([TIMEOUT + "\n" + failure])
111
+ self.assertEqual(self.fixture.run_cli()[0], 1)
112
+ self.assertEqual(len(json.loads((self.root / "attempts.json").read_text())), 1)
113
+
114
+ def test_each_transient_class_is_named_and_unrelated_waits_are_not_retried(self):
115
+ self.assertEqual(native_pods_retry.retryable(TIMEOUT), "transient_timeout")
116
+ self.assertEqual(native_pods_retry.retryable(PARTIAL), "partial_transfer")
117
+ self.assertEqual(native_pods_retry.retryable("curl: (28) Operation timed out"),
118
+ "transient_timeout")
119
+ for body in ("the timeout was generous", "Response: 404 Not Found",
120
+ "waiting for the lock", "unknown network error"):
121
+ with self.subTest(body=body):
122
+ self.assertEqual(native_pods_retry.retryable(body), "")
123
+
84
124
  def test_one_deadline_and_no_guess_from_generic_eof(self):
85
125
  for body in ("fatal: early EOF", "curl 180 failure", "unknown network error"):
86
126
  self.assertFalse(native_pods_retry.interrupted_transfer(body))
@@ -1 +1 @@
1
- 1.0.123
1
+ 1.0.125
@@ -3,17 +3,31 @@ const record = require('./owner_record.cjs');
3
3
  const {performance} = require('node:perf_hooks');
4
4
  const {setTimeout: delay} = require('node:timers/promises');
5
5
 
6
+ // The last ownership write's outcome, as one allow-listed token. A release that
7
+ // cannot be verified used to name nothing at all, so a 403 (token identity), a
8
+ // 409 (CAS race) and a silent transport fault were one indistinguishable code
9
+ // and no session could act on it (Foundy, 2026-09-09, run 34345638488).
10
+ function evidence(status) {
11
+ if (Number.isInteger(status) && status >= 100 && status <= 599) return `release_write_status=${status}`;
12
+ return 'release_write_status=error';
13
+ }
14
+
6
15
  async function seal(client, selected) {
16
+ let detail = 'release_write_status=none';
7
17
  for (let attempt = 0; attempt < 3; attempt++) {
8
18
  const row = record.validate(await client.read(selected.path), selected);
9
19
  if (row.value.owner !== null && !record.same(row.value.owner, selected.owner)) return;
10
20
  const idle = record.replacement(row, null);
11
- try { await client.write(selected.path, row, idle); } catch { /* Reconcile a possibly applied write. */ }
21
+ // Reconcile a possibly applied write; keep only its status as evidence.
22
+ try { detail = evidence(await client.write(selected.path, row, idle)); }
23
+ catch { detail = evidence(undefined); }
12
24
  const after = record.validate(await client.read(selected.path), selected);
13
25
  if (after.value.revision === idle.revision && after.value.owner === null) return;
14
26
  if (after.value.owner !== null && !record.same(after.value.owner, selected.owner)) return;
15
27
  }
16
- throw new Error('play_owner_release_unverified');
28
+ const failure = new Error('play_owner_release_unverified');
29
+ failure.detail = detail;
30
+ throw failure;
17
31
  }
18
32
 
19
33
  async function acquire(client, selected, attempted = () => {}, waiting = {}) {
@@ -59,7 +73,22 @@ async function withOwner(env, packageName, operation, transport, timing = {}) {
59
73
  result = await operation(() => cancelled);
60
74
  } catch (error) { failure = error; }
61
75
  finally {
62
- try { if (dirty) await seal(client, selected); } catch { failure = new Error('play_owner_release_unverified'); }
76
+ // An unverified release must not erase why the operation itself failed: the
77
+ // build's real error is the actionable one, and overwriting it left a
78
+ // session with a fence code and no cause. Keep the first failure and carry
79
+ // the release problem alongside it.
80
+ try { if (dirty) await seal(client, selected); }
81
+ catch (error) {
82
+ // The classified code is always what leaves this function: a raw read or
83
+ // transport error carries provider text that must never reach a receipt
84
+ // or an annotation. Only this file's own allow-listed token travels.
85
+ const detail = typeof error?.detail === 'string' ? error.detail : undefined;
86
+ if (failure) { if (detail) failure.releaseUnverified = detail; }
87
+ else {
88
+ failure = new Error('play_owner_release_unverified');
89
+ if (detail) failure.detail = detail;
90
+ }
91
+ }
63
92
  process.off('SIGTERM', stop); process.off('SIGINT', stop);
64
93
  }
65
94
  if (cancelled && (!failure || failure.name === 'AbortError')) failure = new Error('play_owner_cancelled');
@@ -72,10 +101,14 @@ async function withOwner(env, packageName, operation, transport, timing = {}) {
72
101
 
73
102
  const codes = ['play_owner_binding_required', 'play_owner_unverified', 'play_owner_busy',
74
103
  'play_owner_release_unverified', 'play_owner_cancelled', 'play_owner_principal_unverified'];
75
- function annotation(reason, childExit) {
104
+ function annotation(reason, childExit, detail) {
76
105
  const allowed = [...codes, 'play_owner_child_failed', 'play_owner_package_unverified'];
77
106
  const code = allowed.includes(reason) ? reason : 'play_owner_unverified';
78
107
  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}`;
108
+ // An optional trailing token, admitted only in the shape this file mints, so
109
+ // a provider message can never reach the annotation through it.
110
+ const extra = typeof detail === 'string' && /^release_write_status=(?:[1-5][0-9]{2}|error|none)$/.test(detail)
111
+ ? ` ${detail}` : '';
112
+ return `::error title=play_edit_owner::gowalk-cicd/play-owner.v1 code=${code} child_exit_code=${exit}${extra}`;
80
113
  }
81
114
  module.exports = {acquire, seal, withOwner, codes, annotation};
@@ -55,6 +55,10 @@ function client(repo, token, transport = request) {
55
55
  const response = await send('PUT', `/contents/${path}`, {branch, sha: old.sha,
56
56
  message: 'Update scoped Play edit ownership', content: Buffer.from(JSON.stringify(value)).toString('base64')});
57
57
  if (![200, 201, 409, 422].includes(response.status)) throw new Error('play_owner_unverified');
58
+ // The status is the only safe evidence a failed ownership write leaves:
59
+ // 409/422 is a CAS race, 403 a token identity. Callers reconcile through
60
+ // a readback and would otherwise discard it (`owner.cjs`).
61
+ return response.status;
58
62
  },
59
63
  run: owner => send('GET', `/actions/runs/${owner.run_id}/attempts/${owner.run_attempt}`),
60
64
  };
@@ -51,7 +51,8 @@ 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
- console.log(owner.annotation(code, error.operationResult?.code));
54
+ console.log(owner.annotation(code, error.operationResult?.code,
55
+ error.detail || (typeof error.releaseUnverified === 'string' ? error.releaseUnverified : undefined)));
55
56
  process.exitCode = 1;
56
57
  }
57
58
  }
@@ -44,7 +44,8 @@ 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
+ if (owner.codes.includes(error.message)) console.log(owner.annotation(error.message, undefined,
48
+ error.detail || (typeof error.releaseUnverified === 'string' ? error.releaseUnverified : undefined)));
48
49
  const allowed = ['assigned_proxy_required', 'runner_context_required', 'uploader_integrity_refused',
49
50
  'uploader_source_unavailable', ...owner.codes];
50
51
  receipt = {ok: false, code: allowed.includes(error.message) ? error.message : 'uploader_failed',
@@ -1 +1 @@
1
- 1.0.123
1
+ 1.0.125
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.123",
3
+ "version": "1.0.125",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {