gowalk-cicd 1.0.91 → 1.0.93

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
@@ -172,6 +172,11 @@ node /path/to/gowalk-cicd/bin/cli.mjs
172
172
  errors still fail the job. Never use `continue-on-error` or a timeout around all SDK setup
173
173
  to hide an optional cache failure. Do not alter cache keys or disable caches as part of
174
174
  this timeout handling.
175
+ - Flutter SDK caching restores first and saves immediately after successful setup, before
176
+ consumer tests/builds can fail and suppress a combined cache action's success-only post hook.
177
+ Save only on a cache miss with caching enabled, a real successful install and verified cache
178
+ scope. Preserve resolved keys/paths and the official pinned restore/save programs. Failed or
179
+ dry-run setup never publishes an SDK cache; pub dependency caching retains its job-end behavior.
175
180
  - Gradle caches use explicit checksum-pinned restore/save actions, each optional and bounded
176
181
  to two minutes. Preserve setup-java's key inputs and absolute cache paths to reuse existing
177
182
  caches. Java setup, builds and store uploads remain mandatory; save runs after delivery.
@@ -282,7 +287,10 @@ node /path/to/gowalk-cicd/bin/cli.mjs
282
287
  symbols and uploads them before the store bundle, using the configured proxy.
283
288
  - Play readiness and AAB/APK version inventory share one prebuild edit. Retry OAuth
284
289
  transport, inventory reads and cleanup of that known edit; never replay an ambiguous
285
- `edits.insert`. Only an explicit package-404 allows first-release defaults; failed
290
+ `edits.insert`. Edit creation retries at most three times only for typed connection-timeout
291
+ or typed connection failure / CONNECT 502,503,504 evidence that no provider request was sent. Bare proxy errors,
292
+ TLS verification failures, provider responses and lost origin responses do not authorize a retry.
293
+ Only an explicit package-404 allows first-release defaults; failed
286
294
  inventory is not an empty account. The action exposes `play-api-ready` so current
287
295
  workflows skip the redundant postbuild check. Error annotations carry safe
288
296
  phase/category evidence and any cleanup edit ID without raw provider exceptions.
package/README.md CHANGED
@@ -30,7 +30,9 @@ Flutter's optional SDK and pub cache restores have a one-minute timeout per
30
30
  download segment. A slow segment becomes a cache miss and normal SDK setup
31
31
  continues through the assigned Google proxy; SDK setup errors still fail the
32
32
  job. This bounds individual cache segments, not the total cache or SDK download,
33
- and keeps existing cache keys and the successful restore/save behavior.
33
+ and keeps existing cache keys. A successfully installed SDK is saved before app
34
+ checks run, so a later test/build failure does not force another cold SDK download.
35
+ Failed setup never saves an SDK cache; pub dependencies retain their job-end save.
34
36
 
35
37
  ## Flutter web release
36
38
 
@@ -1 +1 @@
1
- 1.0.91
1
+ 1.0.93
@@ -1 +1 @@
1
- 1.0.91
1
+ 1.0.93
@@ -119,7 +119,7 @@ runs:
119
119
  - name: Cache Flutter
120
120
  id: cache-flutter
121
121
  if: ${{ inputs.cache == 'true' }}
122
- uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25
122
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25
123
123
  continue-on-error: true
124
124
  env:
125
125
  NODE_OPTIONS: --require "${{ steps.scope.outputs.preloader }}"
@@ -139,6 +139,7 @@ runs:
139
139
  path: ${{ steps.flutter-action.outputs.PUB-CACHE-PATH }}
140
140
  key: ${{ steps.flutter-action.outputs.PUB-CACHE-KEY }}-${{ hashFiles('**/pubspec.lock') }}
141
141
  - name: Install or reuse the resolved Flutter SDK
142
+ id: install
142
143
  if: ${{ inputs.dry-run != 'true' }}
143
144
  uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2
144
145
  env:
@@ -157,3 +158,16 @@ runs:
157
158
  pub-cache-path: ${{ steps.flutter-action.outputs.PUB-CACHE-PATH }}
158
159
  dry-run: 'false'
159
160
  git-source: ${{ inputs.git-source }}
161
+ # A later app failure must not discard a successfully installed, expensive SDK.
162
+ - name: Save installed Flutter SDK
163
+ if: >-
164
+ ${{ inputs.cache == 'true' && inputs.dry-run != 'true'
165
+ && steps.install.outcome == 'success' && steps.scope.outcome == 'success'
166
+ && steps.cache-flutter.outputs.cache-hit != 'true' }}
167
+ uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25
168
+ continue-on-error: true
169
+ env:
170
+ NODE_OPTIONS: --require "${{ steps.scope.outputs.preloader }}"
171
+ with:
172
+ path: ${{ steps.flutter-action.outputs.CACHE-PATH }}
173
+ key: ${{ steps.flutter-action.outputs.CACHE-KEY }}
@@ -55,9 +55,9 @@ def opened_edit(client, package: str, account: Path):
55
55
  call("oauth_refresh", lambda: credentials.refresh(request), retry=True)
56
56
  headers = {"Authorization": f"Bearer {credentials.token}"}
57
57
  url = f"{BASE_URL}/applications/{package}/edits"
58
- # A timed-out insert may already have created an edit. Never blindly insert again.
58
+ # Retry only a proven failed connection; an ambiguous insert is never replayed.
59
59
  response = call("edit_create", lambda: client.post(url, headers=headers, json={}, timeout=30),
60
- accepted=(200, 404))
60
+ accepted=(200, 404), retry_connect=True)
61
61
  if response.status_code == 404:
62
62
  print(
63
63
  "::warning::Google Play package is not API-ready. The app session must upload the "
@@ -2,15 +2,45 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ import re
5
6
  import time
6
7
 
7
8
  from google.auth.exceptions import RefreshError, TransportError
8
9
  import requests
10
+ from urllib3 import exceptions as urllib_errors
9
11
 
10
12
  RETRY_STATUSES = {429, 500, 502, 503, 504}
11
13
  TRANSPORT_ERRORS = (requests.RequestException, TransportError)
12
14
 
13
15
 
16
+ def connection_not_sent(error: Exception) -> bool:
17
+ """Retry only typed connection failure or a transient HTTP CONNECT refusal.
18
+
19
+ Requests explicitly declares ConnectTimeout safe to retry. Other failures must
20
+ carry urllib3's structured reason. ProxyError alone is ambiguous: it can wrap
21
+ a dropped origin response. A plain OSError with http.client's exact tunnel
22
+ status framing identifies CONNECT 502/503/504 before any origin TLS/POST.
23
+ """
24
+ if isinstance(error, requests.exceptions.ConnectTimeout):
25
+ return error.response is None
26
+ if not isinstance(error, requests.exceptions.ConnectionError) or error.response is not None:
27
+ return False
28
+ if len(error.args) != 1:
29
+ return False
30
+ reason = error.args[0]
31
+ if isinstance(reason, urllib_errors.MaxRetryError):
32
+ reason = reason.reason
33
+ if isinstance(reason, urllib_errors.ConnectTimeoutError):
34
+ return True
35
+ if not isinstance(reason, urllib_errors.ProxyError):
36
+ return False
37
+ original = reason.original_error
38
+ if isinstance(original, urllib_errors.ConnectTimeoutError):
39
+ return True
40
+ return (type(original) is OSError and original.errno is None
41
+ and re.fullmatch(r"Tunnel connection failed: (?:502|503|504)(?: [^\r\n]*)?", str(original)) is not None)
42
+
43
+
14
44
  def transport_code(error: Exception) -> str:
15
45
  pending, seen = [error], set()
16
46
  while pending and len(seen) < 12:
@@ -45,13 +75,13 @@ def fail(phase: str, code: str, attempt: int, *, status: int | None = None,
45
75
 
46
76
 
47
77
  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
78
+ accepted: tuple[int, ...] | None = None, retry_connect: bool = False):
79
+ limit = 3 if retry or retry_connect else 1
50
80
  for attempt in range(1, limit + 1):
51
81
  try:
52
82
  result = operation()
53
83
  except TRANSPORT_ERRORS as error:
54
- if attempt == limit:
84
+ if attempt == limit or (not retry and not connection_not_sent(error)):
55
85
  fail(phase, transport_code(error), attempt, edit_id=edit_id)
56
86
  except RefreshError:
57
87
  fail(phase, "oauth_refresh_failed", attempt)
@@ -0,0 +1,82 @@
1
+ """An owned TLS origin inside a loopback CONNECT proxy; no external traffic."""
2
+ from contextlib import contextmanager
3
+ from datetime import datetime, timedelta, timezone
4
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5
+ from pathlib import Path
6
+ import ssl
7
+ import tempfile
8
+ import threading
9
+
10
+ from cryptography import x509
11
+ from cryptography.hazmat.primitives import hashes, serialization
12
+ from cryptography.hazmat.primitives.asymmetric import rsa
13
+ from cryptography.x509.oid import NameOID
14
+
15
+
16
+ def tls_material(root):
17
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
18
+ name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "play.test")])
19
+ now = datetime.now(timezone.utc)
20
+ cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
21
+ .serial_number(x509.random_serial_number()).not_valid_before(now - timedelta(days=1))
22
+ .not_valid_after(now + timedelta(days=1))
23
+ .add_extension(x509.SubjectAlternativeName([x509.DNSName("play.test")]), critical=False)
24
+ .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
25
+ .sign(key, hashes.SHA256()))
26
+ path = Path(root)
27
+ certificate, private = path / "certificate.pem", path / "private.pem"
28
+ certificate.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
29
+ private.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
30
+ serialization.NoEncryption()))
31
+ private.chmod(0o600)
32
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
33
+ context.load_cert_chain(certificate, private)
34
+ return context, str(certificate)
35
+
36
+
37
+ class Proxy(BaseHTTPRequestHandler):
38
+ def do_CONNECT(self):
39
+ self.server.connections.append(self.path)
40
+ self.server.authenticated.append(bool(self.headers.get("Proxy-Authorization")))
41
+ if len(self.server.connections) <= self.server.reject:
42
+ self.send_error(502, "private proxy rejection")
43
+ return
44
+ self.send_response(200)
45
+ self.end_headers()
46
+ try:
47
+ with self.server.context.wrap_socket(self.connection, server_side=True) as stream:
48
+ stream.settimeout(3)
49
+ request = b""
50
+ while b"\r\n\r\n" not in request:
51
+ data = stream.recv(4096)
52
+ if not data:
53
+ return
54
+ request += data
55
+ self.server.requests.append(request.split(b"\r\n", 1)[0])
56
+ if self.server.drop_response:
57
+ return
58
+ body = b'{"id":"edit-1"}'
59
+ response = b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: " + str(len(body)).encode()
60
+ stream.sendall(response + b"\r\nContent-Type: application/json\r\n\r\n" + body)
61
+ except ssl.SSLError:
62
+ pass
63
+
64
+ def log_message(self, *args):
65
+ pass
66
+
67
+
68
+ @contextmanager
69
+ def running(*, reject=0, drop_response=False):
70
+ with tempfile.TemporaryDirectory(prefix="play-connect-test-") as root:
71
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Proxy)
72
+ server.context, certificate = tls_material(root)
73
+ server.connections, server.requests, server.authenticated = [], [], []
74
+ server.reject, server.drop_response = reject, drop_response
75
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
76
+ thread.start()
77
+ try:
78
+ yield server, certificate
79
+ finally:
80
+ server.shutdown()
81
+ server.server_close()
82
+ thread.join(timeout=2)
@@ -24,7 +24,7 @@ class BootstrapWiringTests(unittest.TestCase):
24
24
  self.assertNotIn("continue-on-error", step)
25
25
  self.assertEqual(checked, 3)
26
26
  setup = yaml.safe_load((ROOT / "android-action/flutter-setup/action.yml").read_text())
27
- guard, scope, resolve, sdk_cache, pub_cache, install = setup["runs"]["steps"]
27
+ guard, scope, resolve, sdk_cache, pub_cache, install, save = setup["runs"]["steps"]
28
28
  self.assertIn('play_store_proxy.py" --bootstrap-env', guard["run"])
29
29
  self.assertNotIn("continue-on-error", guard)
30
30
  self.assertTrue(scope["continue-on-error"])
@@ -39,7 +39,7 @@ class BootstrapWiringTests(unittest.TestCase):
39
39
  self.assertEqual(step["with"]["pub-cache"], "false")
40
40
  self.assertNotIn("continue-on-error", step)
41
41
  self.assertNotIn("BASH_ENV", setup.get("env", {}))
42
- for step in (guard, scope, sdk_cache, pub_cache):
42
+ for step in (guard, scope, sdk_cache, pub_cache, save):
43
43
  self.assertNotIn("BASH_ENV", step.get("env", {}))
44
44
  self.assertEqual(guard["id"], "transport")
45
45
  self.assertIn('>> "$GITHUB_OUTPUT"', guard["run"])
@@ -59,6 +59,25 @@ class BootstrapWiringTests(unittest.TestCase):
59
59
  self.assertEqual(setup["outputs"]["CACHE-HIT"]["value"], "${{ steps.cache-flutter.outputs.cache-hit }}")
60
60
  self.assertEqual(setup["outputs"]["PUB-CACHE-HIT"]["value"], "${{ steps.cache-pub.outputs.cache-hit }}")
61
61
 
62
+ def test_successful_sdk_is_saved_before_later_consumer_failure_can_discard_it(self):
63
+ setup = yaml.safe_load((ROOT / "android-action/flutter-setup/action.yml").read_text())
64
+ steps = setup["runs"]["steps"]
65
+ restore = next(step for step in steps if step.get("id") == "cache-flutter")
66
+ self.assertEqual(restore["uses"],
67
+ "actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25")
68
+ install = next(step for step in steps if step.get("id") == "install")
69
+ save = steps[steps.index(install) + 1]
70
+ self.assertEqual(save["uses"], "actions/cache/save@caa296126883cff596d87d8935842f9db880ef25")
71
+ self.assertEqual(save["with"], restore["with"])
72
+ self.assertTrue(save["continue-on-error"])
73
+ self.assertEqual(save["env"], {"NODE_OPTIONS": '--require "${{ steps.scope.outputs.preloader }}"'})
74
+ condition = " ".join(save["if"].split())
75
+ self.assertEqual(condition, "${{ inputs.cache == 'true' && inputs.dry-run != 'true' "
76
+ "&& steps.install.outcome == 'success' && steps.scope.outcome == 'success' "
77
+ "&& steps.cache-flutter.outputs.cache-hit != 'true' }}")
78
+ self.assertNotIn("continue-on-error", install)
79
+ self.assertNotIn("timeout-minutes", save) # Unsupported in composite action steps.
80
+
62
81
  def test_pods_cache_keeps_identity_and_uses_the_same_optional_scope(self):
63
82
  workflow = yaml.safe_load((ROOT / "templates/deploy.yml").read_text())
64
83
  steps = workflow["jobs"]["ios"]["steps"]
@@ -0,0 +1,115 @@
1
+ """Edit inserts retry only transport evidence that excludes an origin request."""
2
+ import io
3
+ from pathlib import Path
4
+ import ssl
5
+ import sys
6
+ import unittest
7
+ from unittest import mock
8
+
9
+ import requests
10
+ from urllib3 import exceptions as errors
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
13
+ import play_preflight_transport as transport
14
+
15
+
16
+ def proxy_failure(cause=None):
17
+ cause = cause or OSError("Tunnel connection failed: 502 private proxy refusal")
18
+ reason = errors.ProxyError("private proxy", cause)
19
+ return requests.exceptions.ProxyError(errors.MaxRetryError(None, "private URL", reason))
20
+
21
+
22
+ class ConnectRetryTests(unittest.TestCase):
23
+ def setUp(self):
24
+ self.sleep = self.enterContext(mock.patch.object(transport.time, "sleep"))
25
+ self.output = self.enterContext(mock.patch("sys.stdout", new_callable=io.StringIO))
26
+
27
+ def test_proven_connection_failures_recover_with_one_successful_insert(self):
28
+ connection = errors.NewConnectionError(None, "private connect failure")
29
+ direct = requests.exceptions.ConnectionError(errors.MaxRetryError(None, "private URL", connection))
30
+ for error in (requests.exceptions.ConnectTimeout("private"), proxy_failure(),
31
+ proxy_failure(connection), direct):
32
+ with self.subTest(kind=type(error).__name__):
33
+ response = mock.Mock(status_code=200)
34
+ operation = mock.Mock(side_effect=[error, response])
35
+ self.assertIs(transport.call("edit_create", operation, retry_connect=True, accepted=(200,)), response)
36
+ self.assertEqual(operation.call_count, 2)
37
+ self.assertNotIn("private", self.output.getvalue())
38
+
39
+ def test_ambiguous_errors_never_authorize_an_insert_retry(self):
40
+ for error in (requests.exceptions.ReadTimeout("private"), requests.exceptions.ProxyError("private"),
41
+ requests.exceptions.ConnectionError(errors.ProtocolError("private")),
42
+ proxy_failure(OSError("unknown private refusal")),
43
+ proxy_failure(OSError("Tunnel connection failed: 407 private authentication")),
44
+ proxy_failure(errors.ProtocolError("Tunnel connection failed: 502 private"))):
45
+ with self.subTest(kind=type(error).__name__):
46
+ operation = mock.Mock(side_effect=error)
47
+ with self.assertRaises(SystemExit):
48
+ transport.call("edit_create", operation, retry_connect=True)
49
+ operation.assert_called_once()
50
+ self.sleep.assert_not_called()
51
+
52
+ def test_proxy_wrapped_tls_failure_remains_terminal(self):
53
+ error = proxy_failure(errors.SSLError(ssl.SSLCertVerificationError("private certificate")))
54
+ operation = mock.Mock(side_effect=error)
55
+ with self.assertRaises(SystemExit):
56
+ transport.call("edit_create", operation, retry_connect=True)
57
+ operation.assert_called_once()
58
+ self.sleep.assert_not_called()
59
+ self.assertNotIn("private", self.output.getvalue())
60
+
61
+ def test_origin_response_never_uses_connection_retry_permission(self):
62
+ operation = mock.Mock(return_value=mock.Mock(status_code=503))
63
+ with self.assertRaises(SystemExit) as caught:
64
+ transport.call("edit_create", operation, retry_connect=True, accepted=(200, 404))
65
+ operation.assert_called_once()
66
+ self.sleep.assert_not_called()
67
+ self.assertIn('"status": 503', str(caught.exception))
68
+
69
+ def test_lost_response_after_a_connection_retry_stops_at_that_attempt(self):
70
+ operation = mock.Mock(side_effect=[proxy_failure(), requests.exceptions.ReadTimeout("private")])
71
+ with self.assertRaises(SystemExit) as caught:
72
+ transport.call("edit_create", operation, retry_connect=True)
73
+ self.assertEqual(operation.call_count, 2)
74
+ self.assertIn('"attempts": 2', str(caught.exception))
75
+ self.sleep.assert_called_once_with(1)
76
+
77
+ def test_failed_connections_are_bounded_and_keep_safe_evidence(self):
78
+ operation = mock.Mock(side_effect=proxy_failure())
79
+ with self.assertRaises(SystemExit) as caught:
80
+ transport.call("edit_create", operation, retry_connect=True)
81
+ self.assertEqual(operation.call_count, 3)
82
+ self.assertEqual(self.sleep.call_args_list, [mock.call(1), mock.call(2)])
83
+ self.assertIn('"attempts": 3', str(caught.exception))
84
+ self.assertIn('"code": "proxy_connection_failed"', str(caught.exception))
85
+ self.assertNotIn("private", str(caught.exception) + self.output.getvalue())
86
+
87
+ def test_other_writes_have_no_implicit_retry_permission(self):
88
+ operation = mock.Mock(side_effect=proxy_failure())
89
+ with self.assertRaises(SystemExit):
90
+ transport.call("another_write", operation)
91
+ operation.assert_called_once()
92
+ self.sleep.assert_not_called()
93
+
94
+
95
+ class PreflightConnectWiringTests(unittest.TestCase):
96
+ def test_real_preflight_recovers_connection_before_one_known_edit_cleanup(self):
97
+ import play_preflight as preflight
98
+
99
+ client = mock.Mock()
100
+ client.post.side_effect = [proxy_failure(), mock.Mock(status_code=200, text='{"id":"known-edit"}')]
101
+ client.delete.return_value = mock.Mock(status_code=204)
102
+ credentials = mock.Mock(token="private token")
103
+ with mock.patch.object(preflight.service_account.Credentials, "from_service_account_file",
104
+ return_value=credentials), mock.patch.object(preflight, "write_ready") as ready, \
105
+ mock.patch.object(transport.time, "sleep"), mock.patch("sys.stdout", new_callable=io.StringIO):
106
+ preflight.check_ready(client, "com.example.app", Path("account.json"))
107
+ self.assertEqual(client.post.call_count, 2)
108
+ self.assertEqual(client.post.call_args_list[0], client.post.call_args_list[1])
109
+ client.delete.assert_called_once()
110
+ self.assertTrue(client.delete.call_args.args[0].endswith("/known-edit"))
111
+ ready.assert_called_once_with(True)
112
+
113
+
114
+ if __name__ == "__main__":
115
+ unittest.main()
@@ -0,0 +1,62 @@
1
+ """Real Requests traffic proves bounded proxy retry and no replay after an origin POST."""
2
+ from contextlib import redirect_stdout
3
+ import io
4
+ import os
5
+ from pathlib import Path
6
+ import sys
7
+ import unittest
8
+ from unittest import mock
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
11
+ import play_preflight_transport as transport
12
+ import play_store_proxy
13
+ from play_proxy_test_support import running
14
+
15
+
16
+ class ConnectRuntimeTests(unittest.TestCase):
17
+ def test_two_failed_tunnels_then_one_origin_post_on_the_same_assigned_proxy(self):
18
+ with running(reject=2) as (server, certificate):
19
+ env = {"GOOGLE_STORE_PROXY_URL": f"http://user:private-password@127.0.0.1:{server.server_port}",
20
+ "HTTPS_PROXY": "http://127.0.0.1:1", "NO_PROXY": "*"}
21
+ output = io.StringIO()
22
+ with mock.patch.dict(os.environ, env), play_store_proxy.session() as client, \
23
+ mock.patch.object(transport.time, "sleep"), redirect_stdout(output):
24
+ client.verify = certificate
25
+ result = transport.call("edit_create", lambda: client.post("https://play.test/edits", timeout=3),
26
+ retry_connect=True, accepted=(200,))
27
+ self.assertEqual(result.json(), {"id": "edit-1"})
28
+ self.assertEqual(server.connections, ["play.test:443"] * 3)
29
+ self.assertEqual(server.requests, [b"POST /edits HTTP/1.1"])
30
+ self.assertEqual(server.authenticated, [True] * 3)
31
+ self.assertNotIn("private", output.getvalue())
32
+
33
+ def test_dropped_response_after_received_post_is_never_replayed(self):
34
+ with running(drop_response=True) as (server, certificate):
35
+ env = {"GOOGLE_STORE_PROXY_URL": f"http://127.0.0.1:{server.server_port}"}
36
+ output = io.StringIO()
37
+ with mock.patch.dict(os.environ, env), play_store_proxy.session() as client, \
38
+ mock.patch.object(transport.time, "sleep") as sleep, redirect_stdout(output):
39
+ client.verify = certificate
40
+ with self.assertRaises(SystemExit) as caught:
41
+ transport.call("edit_create", lambda: client.post("https://play.test/edits", timeout=3),
42
+ retry_connect=True, accepted=(200,))
43
+ self.assertEqual(server.connections, ["play.test:443"])
44
+ self.assertEqual(server.requests, [b"POST /edits HTTP/1.1"])
45
+ self.assertIn('"attempts": 1', str(caught.exception))
46
+ sleep.assert_not_called()
47
+
48
+ def test_untrusted_tls_certificate_stops_before_origin_post_without_retry(self):
49
+ with running() as (server, _certificate):
50
+ env = {"GOOGLE_STORE_PROXY_URL": f"http://127.0.0.1:{server.server_port}"}
51
+ with mock.patch.dict(os.environ, env), play_store_proxy.session() as client, \
52
+ mock.patch.object(transport.time, "sleep") as sleep, redirect_stdout(io.StringIO()):
53
+ with self.assertRaises(SystemExit):
54
+ transport.call("edit_create", lambda: client.post("https://play.test/edits", timeout=3),
55
+ retry_connect=True, accepted=(200,))
56
+ self.assertEqual(server.connections, ["play.test:443"])
57
+ self.assertEqual(server.requests, [])
58
+ sleep.assert_not_called()
59
+
60
+
61
+ if __name__ == "__main__":
62
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.91
1
+ 1.0.93
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.91",
3
+ "version": "1.0.93",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {