gowalk-cicd 1.0.74 → 1.0.76
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 +6 -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/backend-action/action.yml +2 -1
- package/backend-action/ensure_certificate.sh +123 -0
- package/backend-action/remote_deploy.sh +7 -15
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -176,6 +176,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
176
176
|
public HTTPS health probe. `select_certbot_account.sh` chooses one existing
|
|
177
177
|
Let's Encrypt ACME v2 account deterministically so multi-account hosts stay
|
|
178
178
|
non-interactive; legacy v1 account directories are not valid candidates.
|
|
179
|
+
`ensure_certificate.sh` preserves failed inventory status and retries only Certbot's
|
|
180
|
+
explicit lock-busy response (six attempts, 75 seconds total backoff). Every retry reads
|
|
181
|
+
inventory before issuance; absence needs recognized complete SAN records or Certbot's explicit
|
|
182
|
+
no-certificate sentinel. Empty/malformed output and arbitrary errors never mean absence. Raw command
|
|
183
|
+
output stays in an owned private temporary directory removed on exit. Do not remove
|
|
184
|
+
Certbot locks, stop other processes, force renewal or bypass the public HTTPS health gate.
|
|
179
185
|
- The Android action serves two build systems. `android_config.project_kind()`
|
|
180
186
|
is the only place that decides which; every downstream step branches on the
|
|
181
187
|
`project_kind` output rather than re-sniffing the repo. Flutter wins the tie
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.76
|
|
@@ -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.76
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.76
|
|
@@ -122,11 +122,12 @@ runs:
|
|
|
122
122
|
rsync -az -e "$SSH" "$COMPOSE_FILE" \
|
|
123
123
|
"${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/docker-compose.yml"
|
|
124
124
|
fi
|
|
125
|
-
# Ship remote helpers after the application mirror.
|
|
125
|
+
# Ship remote helpers after the application mirror. They are package
|
|
126
126
|
# owned and kept out of the consumer's source tree.
|
|
127
127
|
rsync -az -e "$SSH" \
|
|
128
128
|
"${{ github.action_path }}/remote_deploy.sh" \
|
|
129
129
|
"${{ github.action_path }}/select_certbot_account.sh" \
|
|
130
|
+
"${{ github.action_path }}/ensure_certificate.sh" \
|
|
130
131
|
"${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
|
|
131
132
|
|
|
132
133
|
- name: Deploy on the host
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Sourced by remote_deploy.sh. Only a proven Certbot lock conflict may be retried.
|
|
3
|
+
|
|
4
|
+
certbot_lock_busy() {
|
|
5
|
+
grep -Fxq 'Another instance of Certbot is already running.' \
|
|
6
|
+
"$CERTBOT_WORK/stdout" "$CERTBOT_WORK/stderr"
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
certbot_attempt() {
|
|
10
|
+
local phase="$1" attempt="$2" status
|
|
11
|
+
shift 2
|
|
12
|
+
if certbot "$@" > "$CERTBOT_WORK/stdout" 2> "$CERTBOT_WORK/stderr"; then
|
|
13
|
+
log "certificate phase=$phase attempt=$attempt state=ok exit=0"
|
|
14
|
+
return 0
|
|
15
|
+
else
|
|
16
|
+
status=$?
|
|
17
|
+
fi
|
|
18
|
+
if certbot_lock_busy; then
|
|
19
|
+
log "certificate phase=$phase attempt=$attempt state=lock_busy exit=$status"
|
|
20
|
+
else
|
|
21
|
+
log "certificate phase=$phase attempt=$attempt state=error exit=$status"
|
|
22
|
+
fi
|
|
23
|
+
return "$status"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
certificate_present() {
|
|
27
|
+
# 0=present, 1=confirmed absent, 2=unrecognized/partial inventory; preserve other awk failures.
|
|
28
|
+
awk -v domain="$1" '
|
|
29
|
+
function valid_san(value, labels, count, i, compressed, groups) {
|
|
30
|
+
if (index(value, ":")) {
|
|
31
|
+
count = split(value, labels, "::"); if (count > 2) return 0
|
|
32
|
+
compressed = count == 2
|
|
33
|
+
if (value ~ /^:[^:]/ || value ~ /[^:]:$/ || value ~ /:::/) return 0
|
|
34
|
+
count = split(value, labels, ":"); groups = 0
|
|
35
|
+
for (i = 1; i <= count; i++) if (labels[i] != "") {
|
|
36
|
+
if (labels[i] !~ /^[0-9A-Fa-f]+$/ || length(labels[i]) > 4) return 0
|
|
37
|
+
groups++
|
|
38
|
+
}
|
|
39
|
+
return compressed ? groups < 8 : groups == 8
|
|
40
|
+
}
|
|
41
|
+
sub(/^[*][.]/, "", value); count = split(value, labels, ".")
|
|
42
|
+
if (count < 2 || length(value) > 253) return 0
|
|
43
|
+
for (i = 1; i <= count; i++)
|
|
44
|
+
if (labels[i] !~ /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$/ || length(labels[i]) > 63) return 0
|
|
45
|
+
return 1
|
|
46
|
+
}
|
|
47
|
+
/^[[:space:]-]*$/ { next }
|
|
48
|
+
/^No certificates found[.]$/ { if (mode) bad = 1; mode = 2; next }
|
|
49
|
+
/^Found the following certs:$/ { if (mode) bad = 1; mode = 1; next }
|
|
50
|
+
$1 == "Certificate" && $2 == "Name:" {
|
|
51
|
+
if (mode != 1 || NF < 3 || (records && sans != 1)) bad = 1
|
|
52
|
+
records++; sans = 0; next
|
|
53
|
+
}
|
|
54
|
+
$1 == "Domains:" || $1 == "Identifiers:" {
|
|
55
|
+
if (mode != 1 || !records || sans || NF < 2) bad = 1
|
|
56
|
+
sans++
|
|
57
|
+
for (i = 2; i <= NF; i++) { if (!valid_san($i)) bad = 1; if ($i == domain) found = 1 }
|
|
58
|
+
next
|
|
59
|
+
}
|
|
60
|
+
/^[[:space:]]+(Serial Number|Key Type|Expiry Date|Certificate Path|Private Key Path):/ {
|
|
61
|
+
if (mode != 1 || !records) bad = 1
|
|
62
|
+
next
|
|
63
|
+
}
|
|
64
|
+
{ bad = 1 }
|
|
65
|
+
END {
|
|
66
|
+
if (bad || !mode || (mode == 1 && (!records || sans != 1))) exit 2
|
|
67
|
+
exit !found
|
|
68
|
+
}
|
|
69
|
+
' "$CERTBOT_WORK/stdout"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
request_certificate() {
|
|
73
|
+
local domain="$1" email="$2" attempt="$3" account
|
|
74
|
+
set -- --nginx -d "$domain" --non-interactive --agree-tos --email "$email" --redirect
|
|
75
|
+
account="$(select_certbot_account)" || return $?
|
|
76
|
+
if [ -n "$account" ]; then
|
|
77
|
+
log "using an existing Let's Encrypt account"
|
|
78
|
+
set -- "$@" --account "$account"
|
|
79
|
+
fi
|
|
80
|
+
certbot_attempt issuance "$attempt" "$@"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ensure_certificate() (
|
|
84
|
+
local domain="$1" email="$2" delay attempt=0 status=1 CERTBOT_WORK
|
|
85
|
+
CERTBOT_WORK="$(mktemp -d "${TMPDIR:-/tmp}/gowalk-certbot.XXXXXXXX")" || return $?
|
|
86
|
+
trap 'rm -rf -- "$CERTBOT_WORK"' EXIT
|
|
87
|
+
trap 'exit 130' INT
|
|
88
|
+
trap 'exit 143' TERM
|
|
89
|
+
# Six attempts, 75 seconds total backoff. Commands retain the deployment job's bound.
|
|
90
|
+
for delay in 0 5 10 15 20 25; do
|
|
91
|
+
attempt=$((attempt + 1))
|
|
92
|
+
if [ "$delay" -gt 0 ]; then
|
|
93
|
+
log "certificate state=lock_retry attempt=$attempt backoff_seconds=$delay"
|
|
94
|
+
sleep "$delay" || return $?
|
|
95
|
+
fi
|
|
96
|
+
if certbot_attempt inventory "$attempt" certificates; then
|
|
97
|
+
if certificate_present "$domain"; then
|
|
98
|
+
log "cert for $domain already present"
|
|
99
|
+
return 0
|
|
100
|
+
else
|
|
101
|
+
status=$?
|
|
102
|
+
if [ "$status" -ne 1 ]; then
|
|
103
|
+
log "certificate phase=inventory_parse state=error exit=$status"
|
|
104
|
+
return "$status"
|
|
105
|
+
fi
|
|
106
|
+
fi
|
|
107
|
+
else
|
|
108
|
+
status=$?
|
|
109
|
+
certbot_lock_busy && continue
|
|
110
|
+
return "$status"
|
|
111
|
+
fi
|
|
112
|
+
log "requesting LE cert for $domain"
|
|
113
|
+
if request_certificate "$domain" "$email" "$attempt"; then
|
|
114
|
+
return 0
|
|
115
|
+
else
|
|
116
|
+
status=$?
|
|
117
|
+
certbot_lock_busy || return "$status"
|
|
118
|
+
fi
|
|
119
|
+
# A contender may have created this certificate. Re-read before another issuance.
|
|
120
|
+
done
|
|
121
|
+
log "certificate state=lock_retry_exhausted attempts=$attempt exit=$status"
|
|
122
|
+
return "$status"
|
|
123
|
+
)
|
|
@@ -11,6 +11,7 @@ CERT_EMAIL="${4:-admin@gowalk.com}"
|
|
|
11
11
|
DIR="/opt/gowalk-backends/$APP"
|
|
12
12
|
cd "$DIR"
|
|
13
13
|
source "$DIR/select_certbot_account.sh"
|
|
14
|
+
source "$DIR/ensure_certificate.sh"
|
|
14
15
|
|
|
15
16
|
log() { echo "[deploy $APP] $*"; }
|
|
16
17
|
|
|
@@ -93,22 +94,13 @@ NGINX
|
|
|
93
94
|
nginx -t && systemctl reload nginx
|
|
94
95
|
fi
|
|
95
96
|
|
|
96
|
-
# ── Let's Encrypt cert
|
|
97
|
-
if
|
|
98
|
-
log "
|
|
99
|
-
CERTBOT_ACCOUNT="$(select_certbot_account)"
|
|
100
|
-
CERTBOT_ARGS=()
|
|
101
|
-
if [ -n "$CERTBOT_ACCOUNT" ]; then
|
|
102
|
-
log "using an existing Let's Encrypt account"
|
|
103
|
-
CERTBOT_ARGS+=(--account "$CERTBOT_ACCOUNT")
|
|
104
|
-
fi
|
|
105
|
-
if ! certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos \
|
|
106
|
-
--email "$CERT_EMAIL" --redirect "${CERTBOT_ARGS[@]}"; then
|
|
107
|
-
log "ERROR: certbot failed for $DOMAIN"
|
|
108
|
-
exit 1
|
|
109
|
-
fi
|
|
97
|
+
# ── Let's Encrypt cert: failed inventory is never absence; only lock contention retries.
|
|
98
|
+
if ensure_certificate "$DOMAIN" "$CERT_EMAIL"; then
|
|
99
|
+
log "certificate ready for $DOMAIN"
|
|
110
100
|
else
|
|
111
|
-
|
|
101
|
+
status=$?
|
|
102
|
+
log "ERROR: certbot failed for $DOMAIN (exit=$status)"
|
|
103
|
+
exit "$status"
|
|
112
104
|
fi
|
|
113
105
|
|
|
114
106
|
# ── public health-check. This validates DNS, the certificate, nginx routing,
|