gowalk-cicd 1.0.75 → 1.0.77
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 +4 -0
- package/action/.daemux-version +1 -1
- package/action/scripts/asc_common.py +18 -2
- package/action/scripts/test_asc_network_diagnostics.py +103 -0
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
- package/templates/deploy.yml +7 -0
package/CLAUDE.md
CHANGED
|
@@ -172,6 +172,10 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
172
172
|
- The mobile workflow's feature-branch-only `backend-preview` job bootstraps a
|
|
173
173
|
new app before GitHub registers `deploy-backend.yml` on the default branch.
|
|
174
174
|
Default-branch deploys continue through the separate backend workflow.
|
|
175
|
+
- Mobile pushes exclude durable `.factory/progress` records and the separately deployed
|
|
176
|
+
backend/server/Compose paths. A mixed commit with any native/shared input still builds;
|
|
177
|
+
explicit workflow dispatch remains available. Keep backend deployment's own path filters
|
|
178
|
+
intact, and do not broadly exclude assets, root build settings or unknown app layouts.
|
|
175
179
|
- Domain-backed backend deploys fail closed on certificate issuance and the
|
|
176
180
|
public HTTPS health probe. `select_certbot_account.sh` chooses one existing
|
|
177
181
|
Let's Encrypt ACME v2 account deterministically so multi-account hosts stay
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.77
|
|
@@ -155,21 +155,37 @@ def _request_timeouts() -> tuple[float, float]:
|
|
|
155
155
|
)
|
|
156
156
|
|
|
157
157
|
|
|
158
|
+
def _network_error_category(exc: BaseException) -> str:
|
|
159
|
+
"""Closed diagnostic labels only; exception text can contain private proxy URLs."""
|
|
160
|
+
for error_type, category in (
|
|
161
|
+
(requests.exceptions.ProxyError, "proxy"),
|
|
162
|
+
(requests.exceptions.SSLError, "tls"),
|
|
163
|
+
(requests.exceptions.ConnectTimeout, "connect_timeout"),
|
|
164
|
+
(requests.exceptions.ReadTimeout, "read_timeout"),
|
|
165
|
+
(requests.exceptions.Timeout, "timeout"),
|
|
166
|
+
(requests.exceptions.ConnectionError, "connection"),
|
|
167
|
+
):
|
|
168
|
+
if isinstance(exc, error_type):
|
|
169
|
+
return category
|
|
170
|
+
return "request"
|
|
171
|
+
|
|
172
|
+
|
|
158
173
|
def _retry_network_error(
|
|
159
174
|
method: str, path: str, attempt: int, total: int,
|
|
160
175
|
backoffs: tuple[int, ...], exc: BaseException,
|
|
161
176
|
) -> None:
|
|
162
177
|
"""Network error path: sleep and return when retries remain, else
|
|
163
178
|
raise SystemExit (terminal). Caller ``continue``s after return."""
|
|
179
|
+
category = _network_error_category(exc)
|
|
164
180
|
if attempt >= total - 1:
|
|
165
181
|
raise SystemExit(
|
|
166
182
|
f"ASC {method} {path} network error after {total} "
|
|
167
|
-
"attempts through the configured proxy"
|
|
183
|
+
f"attempts through the configured proxy (category={category})"
|
|
168
184
|
)
|
|
169
185
|
delay = backoffs[attempt]
|
|
170
186
|
print(
|
|
171
187
|
f"ASC {method} {path} network error through the configured proxy; "
|
|
172
|
-
f"retrying in {delay}s ({attempt + 1}/{total - 1})",
|
|
188
|
+
f"retrying in {delay}s ({attempt + 1}/{total - 1}; category={category})",
|
|
173
189
|
file=sys.stderr,
|
|
174
190
|
)
|
|
175
191
|
time.sleep(delay)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Real ASC helper and Requests preparation; only socket dispatch and sleeps are replaced."""
|
|
2
|
+
import contextlib
|
|
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 asc_common
|
|
12
|
+
|
|
13
|
+
PRIVATE = "SYNTHETIC_PRIVATE_MARKER"
|
|
14
|
+
PROXY = f"http://account:{PRIVATE}@assigned.proxy.test:1234"
|
|
15
|
+
ENV = {"APPLE_STORE_PROXY_URL": PROXY, "NO_PROXY": "*", "no_proxy": "*",
|
|
16
|
+
"HTTPS_PROXY": "http://unrelated.proxy.test:9876", "REQUESTS_CA_BUNDLE": "",
|
|
17
|
+
"CURL_CA_BUNDLE": "", "ASC_REQUEST_TIMEOUT_CONNECT_SEC": "", "ASC_REQUEST_TIMEOUT_READ_SEC": ""}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PrivateFailure(asc_common.requests.RequestException):
|
|
21
|
+
def __str__(self):
|
|
22
|
+
raise AssertionError("Exception text must never be inspected")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@mock.patch.dict(os.environ, ENV)
|
|
26
|
+
class NetworkDiagnosticTests(unittest.TestCase):
|
|
27
|
+
def test_terminal_categories_preserve_transport_and_privacy(self):
|
|
28
|
+
errors = asc_common.requests.exceptions
|
|
29
|
+
cases = [(errors.ProxyError, "proxy"), (errors.SSLError, "tls"),
|
|
30
|
+
(errors.ConnectTimeout, "connect_timeout"), (errors.ReadTimeout, "read_timeout"),
|
|
31
|
+
(errors.Timeout, "timeout"), (errors.ConnectionError, "connection"),
|
|
32
|
+
(errors.RequestException, "request"), (PrivateFailure, "request")]
|
|
33
|
+
for error_type, category in cases:
|
|
34
|
+
with self.subTest(category=category, error_type=error_type.__name__):
|
|
35
|
+
stderr, stdout = io.StringIO(), io.StringIO()
|
|
36
|
+
with mock.patch.object(asc_common.requests.Session, "send", autospec=True,
|
|
37
|
+
side_effect=error_type(f"{PRIVATE} {PROXY}")) as sender, \
|
|
38
|
+
mock.patch.object(asc_common.time, "sleep") as sleep, \
|
|
39
|
+
contextlib.redirect_stderr(stderr), contextlib.redirect_stdout(stdout):
|
|
40
|
+
with self.assertRaises(SystemExit) as caught:
|
|
41
|
+
asc_common.request("GET", "/builds", PRIVATE, params={"limit": "200"})
|
|
42
|
+
self.assertEqual(sender.call_count, 3)
|
|
43
|
+
self.assertEqual(sleep.call_args_list, [mock.call(2), mock.call(8)])
|
|
44
|
+
self.assertEqual(stderr.getvalue().count(f"category={category}"), 2)
|
|
45
|
+
self.assertIn(f"after 3 attempts through the configured proxy (category={category})",
|
|
46
|
+
str(caught.exception))
|
|
47
|
+
self.assertNotIn(PRIVATE, stdout.getvalue() + stderr.getvalue() + str(caught.exception))
|
|
48
|
+
self.assertEqual(stdout.getvalue(), "")
|
|
49
|
+
for call in sender.call_args_list:
|
|
50
|
+
self.assertEqual(call.args[1].url, asc_common.ASC_BASE + "/builds?limit=200")
|
|
51
|
+
self.assertEqual(call.args[1].headers["Authorization"], f"Bearer {PRIVATE}")
|
|
52
|
+
self.assertEqual(call.kwargs["proxies"]["https"], PROXY)
|
|
53
|
+
self.assertEqual(call.kwargs["proxies"]["http"], PROXY)
|
|
54
|
+
self.assertEqual(call.kwargs["timeout"], (10.0, 30.0))
|
|
55
|
+
self.assertIs(call.kwargs["verify"], True)
|
|
56
|
+
|
|
57
|
+
def test_transient_failure_returns_success_without_extra_sender_calls(self):
|
|
58
|
+
response = asc_common.requests.Response()
|
|
59
|
+
response.status_code = 200
|
|
60
|
+
stderr = io.StringIO()
|
|
61
|
+
with mock.patch.object(asc_common.requests.Session, "send", autospec=True,
|
|
62
|
+
side_effect=[asc_common.requests.exceptions.ProxyError(PRIVATE), response]) as sender, \
|
|
63
|
+
mock.patch.object(asc_common.time, "sleep") as sleep, contextlib.redirect_stderr(stderr):
|
|
64
|
+
self.assertIs(asc_common.request("GET", "/builds", PRIVATE), response)
|
|
65
|
+
self.assertEqual(sender.call_count, 2)
|
|
66
|
+
sleep.assert_called_once_with(2)
|
|
67
|
+
self.assertIn("category=proxy", stderr.getvalue())
|
|
68
|
+
self.assertNotIn(PRIVATE, stderr.getvalue())
|
|
69
|
+
|
|
70
|
+
def test_single_attempt_does_not_sleep(self):
|
|
71
|
+
with mock.patch.object(asc_common.requests.Session, "send", autospec=True,
|
|
72
|
+
side_effect=asc_common.requests.exceptions.ReadTimeout(PRIVATE)) as sender, \
|
|
73
|
+
mock.patch.object(asc_common.time, "sleep") as sleep:
|
|
74
|
+
with self.assertRaisesRegex(SystemExit, "after 1 attempts.*category=read_timeout"):
|
|
75
|
+
asc_common.request("GET", "/builds", PRIVATE, max_attempts=1)
|
|
76
|
+
self.assertEqual(sender.call_count, 1)
|
|
77
|
+
sleep.assert_not_called()
|
|
78
|
+
|
|
79
|
+
def test_missing_or_invalid_proxy_refuses_before_sender(self):
|
|
80
|
+
for proxy in ["", "not-a-proxy"]:
|
|
81
|
+
with self.subTest(proxy=proxy), mock.patch.dict(os.environ, {"APPLE_STORE_PROXY_URL": proxy}), \
|
|
82
|
+
mock.patch.object(asc_common.requests.Session, "send") as sender, \
|
|
83
|
+
mock.patch.object(asc_common.time, "sleep") as sleep:
|
|
84
|
+
with self.assertRaisesRegex(SystemExit, "direct requests are refused"):
|
|
85
|
+
asc_common.request("GET", "/builds", PRIVATE)
|
|
86
|
+
sender.assert_not_called()
|
|
87
|
+
sleep.assert_not_called()
|
|
88
|
+
|
|
89
|
+
def test_http_refusal_is_not_reclassified_as_network_failure(self):
|
|
90
|
+
response = asc_common.requests.Response()
|
|
91
|
+
response.status_code = 401
|
|
92
|
+
response._content = b"unauthorized"
|
|
93
|
+
with mock.patch.object(asc_common.requests.Session, "send", return_value=response) as sender, \
|
|
94
|
+
mock.patch.object(asc_common.time, "sleep") as sleep:
|
|
95
|
+
with self.assertRaises(SystemExit) as caught:
|
|
96
|
+
asc_common.request("GET", "/builds", PRIVATE)
|
|
97
|
+
self.assertEqual(sender.call_count, 1)
|
|
98
|
+
sleep.assert_not_called()
|
|
99
|
+
self.assertEqual(str(caught.exception), "ASC GET /builds failed: 401\nunauthorized")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.77
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.77
|
package/package.json
CHANGED
package/templates/deploy.yml
CHANGED
|
@@ -6,6 +6,13 @@ on:
|
|
|
6
6
|
- '**/*.md'
|
|
7
7
|
- '.gitignore'
|
|
8
8
|
- 'creds/**'
|
|
9
|
+
# Durable task records and independently deployed backend sources do not
|
|
10
|
+
# change a native binary. Mixed commits still run when any mobile input changes.
|
|
11
|
+
- '.factory/progress/**'
|
|
12
|
+
- 'backend/**'
|
|
13
|
+
- 'server/**'
|
|
14
|
+
- 'docker-compose.yml'
|
|
15
|
+
- '.github/workflows/deploy-backend.yml'
|
|
9
16
|
- '.github/actions/swift-app/**'
|
|
10
17
|
- '.github/actions/android-app/**'
|
|
11
18
|
workflow_dispatch:
|