gowalk-cicd 1.0.64 → 1.0.66

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
@@ -192,6 +192,9 @@ node /path/to/gowalk-cicd/bin/cli.mjs
192
192
  delivery request. This requires no human handoff. A green CI run alone is not
193
193
  evidence that the deferred symbols reached Firebase. Android retains its Dart
194
194
  symbols and uploads them before the store bundle, using the configured proxy.
195
+ - Play readiness retries only OAuth transport and cleanup of its known edit. Never
196
+ replay an ambiguous `edits.insert`; error annotations carry safe phase/category
197
+ evidence and the edit ID when cleanup remains, without raw provider exceptions.
195
198
  - The iOS action reads every script and prompt from the snapshot it takes of
196
199
  itself in its first step (`$SWIFT_APP_ACTION`, under `RUNNER_TEMP`), never
197
200
  from `${{ github.action_path }}` after that step. The plugin self-update
package/README.md CHANGED
@@ -298,6 +298,16 @@ and prompt from that copy.
298
298
 
299
299
  ### Google Play upload retry
300
300
 
301
+ The API readiness preflight retries transient OAuth transport failures and cleanup of
302
+ its own known edit up to three times, with one- and two-second delays. It never
303
+ replays an edit creation whose outcome is unknown. Every request keeps the assigned
304
+ Google proxy; an absent edit after cleanup is already closed. Exhausted recovery
305
+ emits the `play_preflight_failed` error annotation with schema
306
+ `gowalk-cicd/play-preflight-failure.v1`, phase (`oauth_refresh`, `edit_create` or
307
+ `edit_cleanup`), classified code and attempt count. Provider bodies, credential
308
+ values and raw network errors are omitted. A cleanup failure also records the known
309
+ edit ID and `cleanup_required: true` so recovery targets that edit.
310
+
301
311
  A Play edit is a short-lived server-side object, and an AAB upload that runs
302
312
  long enough outlives one:
303
313
 
@@ -1 +1 @@
1
- 1.0.64
1
+ 1.0.66
@@ -1 +1 @@
1
- 1.0.64
1
+ 1.0.66
@@ -13,6 +13,11 @@ 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.
18
+ IDLE_TIMEOUT = 900.0
19
+
20
+
16
21
  class Relay:
17
22
  def __init__(self, proxy: str):
18
23
  self.proxy = urlsplit(proxy)
@@ -56,13 +61,14 @@ class Relay:
56
61
  return reader, writer
57
62
 
58
63
  async def _pump(self, reader, writer):
59
- while data := await asyncio.wait_for(reader.read(65536), timeout=120):
64
+ while data := await asyncio.wait_for(reader.read(65536), timeout=IDLE_TIMEOUT):
60
65
  writer.write(data)
61
66
  await writer.drain()
62
67
 
63
68
  async def _handle(self, reader, writer):
64
69
  upstream = None
65
70
  pumps = []
71
+ established = False
66
72
  try:
67
73
  request = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=15)
68
74
  upstream_reader, upstream = await self._upstream(request)
@@ -72,12 +78,17 @@ class Relay:
72
78
  raise ConnectionError("upstream tunnel refused")
73
79
  writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
74
80
  await writer.drain()
81
+ established = True
75
82
  pumps = [asyncio.create_task(self._pump(reader, upstream)),
76
83
  asyncio.create_task(self._pump(upstream_reader, writer))]
77
84
  await asyncio.wait(pumps, return_when=asyncio.FIRST_COMPLETED)
78
85
  except (OSError, ValueError, IndexError, asyncio.TimeoutError,
79
86
  asyncio.IncompleteReadError, asyncio.LimitOverrunError):
80
- writer.write(b"HTTP/1.1 502 Proxy unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
87
+ # Once bytes flow, the stream is the client's protocol (usually TLS): a
88
+ # 502 written into it corrupts the transfer instead of failing it, so an
89
+ # established connection only closes.
90
+ if not established:
91
+ writer.write(b"HTTP/1.1 502 Proxy unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
81
92
  finally:
82
93
  for task in pumps:
83
94
  task.cancel()
@@ -4,15 +4,16 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
+ from functools import partial
7
8
  import json
8
9
  import os
9
10
  from pathlib import Path
11
+ from urllib.parse import quote
10
12
 
11
13
  import google.auth.transport.requests
12
- from google.auth.exceptions import TransportError
13
- import requests
14
14
  from google.oauth2 import service_account
15
15
 
16
+ from play_preflight_transport import call, fail
16
17
  from play_store_proxy import session
17
18
 
18
19
 
@@ -25,52 +26,47 @@ def write_ready(value: bool) -> None:
25
26
  stream.write(f"ready={'true' if value else 'false'}\n")
26
27
 
27
28
 
28
- def response_message(response: requests.Response) -> str:
29
- try:
30
- payload = response.json()
31
- return str(payload.get("error", {}).get("message", "unknown API error"))[:500]
32
- except (ValueError, AttributeError):
33
- return "unknown API error"
34
-
35
-
36
29
  def main() -> None:
37
30
  parser = argparse.ArgumentParser()
38
31
  parser.add_argument("--package", required=True)
39
32
  parser.add_argument("--service-account", required=True, type=Path)
40
33
  args = parser.parse_args()
41
34
 
42
- client = session()
35
+ with session() as client:
36
+ check_ready(client, args.package, args.service_account)
37
+
38
+
39
+ def check_ready(client, package: str, account: Path) -> None:
43
40
  credentials = service_account.Credentials.from_service_account_file(
44
- args.service_account, scopes=[SCOPE]
41
+ account, scopes=[SCOPE]
45
42
  )
46
- credentials.refresh(google.auth.transport.requests.Request(session=client))
43
+ request = partial(google.auth.transport.requests.Request(session=client), timeout=30)
44
+ call("oauth_refresh", lambda: credentials.refresh(request), retry=True)
47
45
  headers = {"Authorization": f"Bearer {credentials.token}"}
48
- url = f"{BASE_URL}/applications/{args.package}/edits"
49
- response = client.post(url, headers=headers, json={}, timeout=30)
46
+ url = f"{BASE_URL}/applications/{package}/edits"
47
+ # A timed-out insert may already have created an edit. Never blindly insert again.
48
+ response = call("edit_create", lambda: client.post(url, headers=headers, json={}, timeout=30),
49
+ accepted=(200, 404))
50
50
  if response.status_code == 404:
51
51
  write_ready(False)
52
52
  print(
53
- "::warning::Google Play package is not API-ready. Download the "
54
- "android-first-release artifact and upload it once in Play Console; "
55
- "later runs will deploy automatically."
53
+ "::warning::Google Play package is not API-ready. The app session must upload the "
54
+ "retained android-first-release artifact through its assigned Play Console and "
55
+ "verify the resulting release before retrying API delivery."
56
56
  )
57
57
  return
58
- if response.status_code != 200:
59
- message = response_message(response)
60
- raise SystemExit(f"Google Play preflight failed ({response.status_code}): {message}")
61
-
62
- edit_id = json.loads(response.text)["id"]
63
- delete_url = f"{url}/{edit_id}"
64
- deleted = client.delete(delete_url, headers=headers, timeout=30)
65
- if deleted.status_code not in (200, 204):
66
- message = response_message(deleted)
67
- raise SystemExit(f"cannot close Google Play preflight edit: {message}")
58
+ try:
59
+ edit_id = json.loads(response.text)["id"]
60
+ if not isinstance(edit_id, str) or not edit_id or len(edit_id) > 512:
61
+ raise ValueError("invalid edit identifier")
62
+ except (ValueError, TypeError, KeyError):
63
+ fail("edit_create", "invalid_response", 1)
64
+ delete_url = f"{url}/{quote(edit_id, safe='')}"
65
+ call("edit_cleanup", lambda: client.delete(delete_url, headers=headers, timeout=30),
66
+ retry=True, edit_id=edit_id, accepted=(200, 204, 404))
68
67
  write_ready(True)
69
68
  print("Google Play package is ready for automated uploads")
70
69
 
71
70
 
72
71
  if __name__ == "__main__":
73
- try:
74
- main()
75
- except (requests.RequestException, TransportError):
76
- raise SystemExit("Google Play preflight failed through the assigned proxy") from None
72
+ main()
@@ -0,0 +1,66 @@
1
+ """Bound safe preflight retries without replaying an ambiguous edit creation."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import time
6
+
7
+ from google.auth.exceptions import RefreshError, TransportError
8
+ import requests
9
+
10
+ RETRY_STATUSES = {429, 500, 502, 503, 504}
11
+ TRANSPORT_ERRORS = (requests.RequestException, TransportError)
12
+
13
+
14
+ def transport_code(error: Exception) -> str:
15
+ pending, seen = [error], set()
16
+ while pending and len(seen) < 12:
17
+ item = pending.pop(0)
18
+ if id(item) in seen:
19
+ continue
20
+ seen.add(id(item))
21
+ for kind, code in ((requests.exceptions.ProxyError, "proxy_connection_failed"),
22
+ (requests.exceptions.SSLError, "tls_failed"),
23
+ (requests.exceptions.Timeout, "timed_out"),
24
+ (requests.exceptions.ConnectionError, "connection_failed")):
25
+ if isinstance(item, kind):
26
+ return code
27
+ pending.extend(value for value in (item.__cause__, item.__context__, *item.args)
28
+ if isinstance(value, Exception))
29
+ return "transport_failed"
30
+
31
+
32
+ def fail(phase: str, code: str, attempt: int, *, status: int | None = None,
33
+ edit_id: str | None = None) -> None:
34
+ evidence = {"schema": "gowalk-cicd/play-preflight-failure.v1", "phase": phase,
35
+ "code": code, "attempts": attempt, "route": "assigned_proxy"}
36
+ if status is not None:
37
+ evidence["status"] = status
38
+ if edit_id is not None:
39
+ evidence["cleanup_required"] = True
40
+ evidence["edit_id"] = edit_id
41
+ encoded = json.dumps(evidence, sort_keys=True)
42
+ annotation = encoded.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
43
+ print("::error title=play_preflight_failed::" + annotation)
44
+ raise SystemExit("Google Play preflight failed: " + encoded) from None
45
+
46
+
47
+ def call(phase: str, operation, *, retry: bool = False, edit_id: str | None = None,
48
+ accepted: tuple[int, ...] | None = None):
49
+ limit = 3 if retry else 1
50
+ for attempt in range(1, limit + 1):
51
+ try:
52
+ result = operation()
53
+ except TRANSPORT_ERRORS as error:
54
+ if attempt == limit:
55
+ fail(phase, transport_code(error), attempt, edit_id=edit_id)
56
+ except RefreshError:
57
+ fail(phase, "oauth_refresh_failed", attempt)
58
+ else:
59
+ if not retry or getattr(result, "status_code", None) not in RETRY_STATUSES:
60
+ if accepted is not None and result.status_code not in accepted:
61
+ fail(phase, "provider_refused", attempt, status=result.status_code, edit_id=edit_id)
62
+ return result
63
+ if attempt == limit:
64
+ fail(phase, "provider_unavailable", attempt, status=result.status_code, edit_id=edit_id)
65
+ print(f"Google Play preflight retry: phase={phase} attempt={attempt + 1}/{limit}")
66
+ time.sleep(attempt)
@@ -1,6 +1,6 @@
1
1
  """Real socket and Java proofs: authenticated upstream only, no target fallback."""
2
2
  import base64
3
- from contextlib import closing, contextmanager
3
+ from contextlib import closing, contextmanager, suppress
4
4
  import http.client
5
5
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
6
  import os
@@ -10,7 +10,9 @@ import socket
10
10
  import subprocess
11
11
  import tempfile
12
12
  import threading
13
+ import time
13
14
  import unittest
15
+ from unittest import mock
14
16
 
15
17
  import jvm_proxy_relay as relay
16
18
 
@@ -117,5 +119,30 @@ class JvmProxyTests(unittest.TestCase):
117
119
  self.assertIn("-Dorg.gradle.daemon=false", env["GRADLE_OPTS"])
118
120
 
119
121
 
122
+ class TunnelLifetimeTests(unittest.TestCase):
123
+ def test_idle_tunnel_outlives_a_short_pause_and_never_receives_a_502(self):
124
+ # A CMake/NDK download reads a remote zip through one keep-alive tunnel with
125
+ # pauses between range requests; the relay must neither time the pause out
126
+ # nor inject an HTTP status into the established byte stream.
127
+ with upstream() as (proxy, _seen), mock.patch.object(relay, "IDLE_TIMEOUT", 0.3):
128
+ with relay.running(proxy) as port:
129
+ with socket.create_connection(("127.0.0.1", port), timeout=5) as client:
130
+ client.sendall(b"CONNECT unresolvable.invalid:443 HTTP/1.1\r\nHost: ignored\r\n\r\n")
131
+ self.assertIn(b"200", client.recv(4096))
132
+ client.sendall(b"abcd")
133
+ self.assertEqual(client.recv(4), b"abcd")
134
+ time.sleep(0.6) # longer than the (patched) idle timeout: the pumps have given up
135
+ client.settimeout(2)
136
+ received = b""
137
+ with suppress(OSError):
138
+ while chunk := client.recv(4096):
139
+ received += chunk
140
+ self.assertNotIn(b"502", received)
141
+ self.assertNotIn(b"HTTP/1.1", received)
142
+
143
+ def test_idle_timeout_is_long_enough_for_sdk_downloads(self):
144
+ self.assertGreaterEqual(relay.IDLE_TIMEOUT, 600)
145
+
146
+
120
147
  if __name__ == "__main__":
121
148
  unittest.main()
@@ -0,0 +1,108 @@
1
+ """Preflight retries safe operations and never duplicates an ambiguous edit insert."""
2
+ import io
3
+ import json
4
+ from pathlib import Path
5
+ import sys
6
+ import unittest
7
+ from unittest import mock
8
+
9
+ import requests
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
12
+ import play_preflight as preflight
13
+ import play_preflight_transport as transport
14
+
15
+
16
+ class PlayPreflightTests(unittest.TestCase):
17
+ def setUp(self):
18
+ self.client = mock.Mock()
19
+ self.client.post.return_value = mock.Mock(status_code=200, text='{"id":"edit-123"}')
20
+ self.client.delete.return_value = mock.Mock(status_code=204)
21
+ self.credentials = mock.Mock(token="private-token")
22
+ self.factory = self.enterContext(mock.patch.object(preflight.service_account.Credentials,
23
+ "from_service_account_file"))
24
+ self.factory.return_value = self.credentials
25
+ self.ready = self.enterContext(mock.patch.object(preflight, "write_ready"))
26
+ self.sleep = self.enterContext(mock.patch.object(transport.time, "sleep"))
27
+ self.output = self.enterContext(mock.patch("sys.stdout", new_callable=io.StringIO))
28
+
29
+ def check(self):
30
+ return preflight.check_ready(self.client, "com.example.app", Path("account.json"))
31
+
32
+ def test_oauth_transport_recovers_before_one_edit_is_created(self):
33
+ self.credentials.refresh.side_effect = [requests.exceptions.ProxyError("private-proxy"), None]
34
+ self.check()
35
+ self.assertEqual(self.credentials.refresh.call_count, 2)
36
+ self.client.post.assert_called_once()
37
+ self.client.delete.assert_called_once()
38
+ self.ready.assert_called_once_with(True)
39
+ request = self.credentials.refresh.call_args.args[0]
40
+ self.assertIs(request.func.session, self.client)
41
+ self.assertEqual(request.keywords, {"timeout": 30})
42
+ self.assertNotIn("private", self.output.getvalue())
43
+
44
+ def test_insert_timeout_is_not_replayed_or_reported_ready(self):
45
+ self.client.post.side_effect = requests.exceptions.ReadTimeout("private-token")
46
+ with self.assertRaises(SystemExit) as caught:
47
+ self.check()
48
+ self.assertIn('"phase": "edit_create"', str(caught.exception))
49
+ self.assertIn('"code": "timed_out"', str(caught.exception))
50
+ self.assertNotIn("private", str(caught.exception))
51
+ self.client.post.assert_called_once()
52
+ self.client.delete.assert_not_called()
53
+ self.ready.assert_not_called()
54
+ self.sleep.assert_not_called()
55
+
56
+ def test_delete_timeout_then_absence_closes_only_the_known_edit(self):
57
+ self.client.delete.side_effect = [requests.exceptions.ReadTimeout(), mock.Mock(status_code=404)]
58
+ self.check()
59
+ self.client.post.assert_called_once()
60
+ self.assertEqual(self.client.delete.call_count, 2)
61
+ self.assertEqual(self.client.delete.call_args_list[0], self.client.delete.call_args_list[1])
62
+ self.assertTrue(self.client.delete.call_args.args[0].endswith("/edit-123"))
63
+ self.ready.assert_called_once_with(True)
64
+
65
+ def test_cleanup_exhaustion_identifies_edit_without_ready_or_duplicate_insert(self):
66
+ self.client.delete.side_effect = requests.exceptions.ConnectionError("private-proxy")
67
+ with self.assertRaises(SystemExit) as caught:
68
+ self.check()
69
+ evidence = json.loads(str(caught.exception).split(": ", 1)[1])
70
+ self.assertEqual(evidence["edit_id"], "edit-123")
71
+ self.assertTrue(evidence["cleanup_required"])
72
+ self.assertEqual(evidence["attempts"], 3)
73
+ self.assertNotIn("private", str(caught.exception))
74
+ self.client.post.assert_called_once()
75
+ self.assertEqual(self.client.delete.call_count, 3)
76
+ self.ready.assert_not_called()
77
+
78
+ def test_provider_refusal_hides_untrusted_body_and_is_not_retried(self):
79
+ self.client.post.return_value = mock.Mock(status_code=403, text="private-access-token")
80
+ with self.assertRaises(SystemExit) as caught:
81
+ self.check()
82
+ self.assertIn('"status": 403', str(caught.exception))
83
+ self.assertNotIn("private", str(caught.exception))
84
+ self.client.post.assert_called_once()
85
+ self.ready.assert_not_called()
86
+
87
+ def test_unregistered_package_keeps_first_release_path(self):
88
+ self.client.post.return_value = mock.Mock(status_code=404)
89
+ self.check()
90
+ self.ready.assert_called_once_with(False)
91
+ self.client.delete.assert_not_called()
92
+
93
+ def test_opaque_edit_identifier_is_encoded_in_cleanup_path(self):
94
+ self.client.post.return_value.text = '{"id":"opaque/id?x=y"}'
95
+ self.check()
96
+ self.assertTrue(self.client.delete.call_args.args[0].endswith("/opaque%2Fid%3Fx%3Dy"))
97
+
98
+ def test_missing_proxy_stops_before_reading_credentials(self):
99
+ with mock.patch.dict("os.environ", {"GOOGLE_STORE_PROXY_URL": ""}), \
100
+ mock.patch.object(sys, "argv", ["preflight", "--package", "com.example.app",
101
+ "--service-account", "account.json"]):
102
+ with self.assertRaises(SystemExit):
103
+ preflight.main()
104
+ self.factory.assert_not_called()
105
+
106
+
107
+ if __name__ == "__main__":
108
+ unittest.main()
@@ -0,0 +1,58 @@
1
+ """Run OAuth's real requests transport against an owned rejecting loopback proxy."""
2
+ from contextlib import redirect_stdout
3
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
4
+ import io
5
+ import os
6
+ from pathlib import Path
7
+ import sys
8
+ import threading
9
+ from types import SimpleNamespace
10
+ import unittest
11
+ from unittest import mock
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
14
+ import play_preflight as preflight
15
+ import play_preflight_transport as transport
16
+
17
+
18
+ class RejectProxy(BaseHTTPRequestHandler):
19
+ def do_CONNECT(self):
20
+ self.server.targets.append(self.path)
21
+ self.send_error(502, "private-proxy-error")
22
+
23
+ def log_message(self, *args):
24
+ pass
25
+
26
+
27
+ class PreflightProxyRuntimeTests(unittest.TestCase):
28
+ def test_oauth_retries_only_through_assigned_proxy_without_secret_output(self):
29
+ server = ThreadingHTTPServer(("127.0.0.1", 0), RejectProxy)
30
+ server.targets = []
31
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
32
+ thread.start()
33
+ env = {"GOOGLE_STORE_PROXY_URL": f"http://user:private-password@127.0.0.1:{server.server_port}",
34
+ "HTTP_PROXY": "http://127.0.0.1:1", "HTTPS_PROXY": "http://127.0.0.1:1", "NO_PROXY": "*"}
35
+ credentials = SimpleNamespace(refresh=lambda request: request("https://oauth2.googleapis.com/token",
36
+ method="POST", body="private-body"))
37
+ output = io.StringIO()
38
+ try:
39
+ with mock.patch.dict(os.environ, env), redirect_stdout(output), \
40
+ mock.patch.object(preflight.service_account.Credentials, "from_service_account_file",
41
+ return_value=credentials), \
42
+ mock.patch.object(transport.time, "sleep"), \
43
+ mock.patch.object(sys, "argv", ["preflight", "--package", "com.example.app",
44
+ "--service-account", "account.json"]):
45
+ with self.assertRaises(SystemExit) as caught:
46
+ preflight.main()
47
+ self.assertEqual(server.targets, ["oauth2.googleapis.com:443"] * 3)
48
+ self.assertIn('"phase": "oauth_refresh"', str(caught.exception))
49
+ self.assertIn('"code": "proxy_connection_failed"', str(caught.exception))
50
+ self.assertNotIn("private", str(caught.exception) + output.getvalue())
51
+ finally:
52
+ server.shutdown()
53
+ server.server_close()
54
+ thread.join(timeout=2)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ unittest.main()
@@ -0,0 +1,76 @@
1
+ """Classified preflight diagnostics never include raw provider/credential exceptions."""
2
+ import io
3
+ from pathlib import Path
4
+ import sys
5
+ import unittest
6
+ from unittest import mock
7
+
8
+ from google.auth.exceptions import RefreshError, TransportError
9
+ import requests
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
12
+ import play_preflight_transport as transport
13
+
14
+
15
+ class PreflightTransportTests(unittest.TestCase):
16
+ def setUp(self):
17
+ self.sleep = self.enterContext(mock.patch.object(transport.time, "sleep"))
18
+ self.output = self.enterContext(mock.patch("sys.stdout", new_callable=io.StringIO))
19
+
20
+ def test_wrapped_oauth_transport_error_classification_does_not_read_raw_text(self):
21
+ for error, expected in ((requests.exceptions.ProxyError("private"), "proxy_connection_failed"),
22
+ (requests.exceptions.SSLError("private"), "tls_failed"),
23
+ (requests.exceptions.ReadTimeout("private"), "timed_out")):
24
+ wrapped = TransportError(error)
25
+ self.assertEqual(transport.transport_code(wrapped), expected)
26
+
27
+ def test_safe_operation_has_bounded_retry_and_sanitized_terminal_error(self):
28
+ operation = mock.Mock(side_effect=TransportError(requests.exceptions.ProxyError("private")))
29
+ with self.assertRaises(SystemExit) as caught:
30
+ transport.call("oauth_refresh", operation, retry=True)
31
+ self.assertEqual(operation.call_count, 3)
32
+ self.assertEqual(self.sleep.call_args_list, [mock.call(1), mock.call(2)])
33
+ self.assertIn('"code": "proxy_connection_failed"', str(caught.exception))
34
+ self.assertNotIn("private", str(caught.exception) + self.output.getvalue())
35
+
36
+ def test_oauth_provider_error_is_sanitized_without_an_extra_retry(self):
37
+ operation = mock.Mock(side_effect=RefreshError("private-provider-body"))
38
+ with self.assertRaises(SystemExit) as caught:
39
+ transport.call("oauth_refresh", operation, retry=True)
40
+ operation.assert_called_once()
41
+ self.assertIn("oauth_refresh_failed", str(caught.exception))
42
+ self.assertNotIn("private", str(caught.exception))
43
+
44
+ def test_cleanup_retries_server_failure_then_returns_the_success(self):
45
+ good = mock.Mock(status_code=204)
46
+ operation = mock.Mock(side_effect=[mock.Mock(status_code=503), good])
47
+ self.assertIs(transport.call("edit_cleanup", operation, retry=True, edit_id="123"), good)
48
+ self.assertEqual(operation.call_count, 2)
49
+
50
+ def test_ambiguous_server_error_on_insert_is_never_retried(self):
51
+ response = mock.Mock(status_code=503)
52
+ operation = mock.Mock(return_value=response)
53
+ self.assertIs(transport.call("edit_create", operation), response)
54
+ operation.assert_called_once()
55
+ self.sleep.assert_not_called()
56
+
57
+ def test_cleanup_server_failure_exhaustion_preserves_classified_evidence(self):
58
+ operation = mock.Mock(return_value=mock.Mock(status_code=503))
59
+ with self.assertRaises(SystemExit) as caught:
60
+ transport.call("edit_cleanup", operation, retry=True, edit_id="123")
61
+ self.assertIn('"code": "provider_unavailable"', str(caught.exception))
62
+ self.assertIn('"status": 503', str(caught.exception))
63
+ self.assertIn('"edit_id": "123"', str(caught.exception))
64
+ self.assertEqual(operation.call_count, 3)
65
+
66
+ def test_refusal_after_retry_records_the_actual_attempt_in_a_safe_annotation(self):
67
+ operation = mock.Mock(side_effect=[mock.Mock(status_code=503), mock.Mock(status_code=403)])
68
+ with self.assertRaises(SystemExit) as caught:
69
+ transport.call("edit_cleanup", operation, retry=True, edit_id="123", accepted=(200, 204, 404))
70
+ self.assertIn('"attempts": 2', str(caught.exception))
71
+ self.assertIn("::error title=play_preflight_failed::", self.output.getvalue())
72
+ self.assertIn('"cleanup_required": true', self.output.getvalue())
73
+
74
+
75
+ if __name__ == "__main__":
76
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.64
1
+ 1.0.66
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.64",
3
+ "version": "1.0.66",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {