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 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
@@ -1 +1 @@
1
- 1.0.74
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.74
1
+ 1.0.76
@@ -1 +1 @@
1
- 1.0.74
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. Both are package
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 (idempotent; certbot skips if current)
97
- if ! certbot certificates 2>/dev/null | grep -q "Domains: $DOMAIN\b"; then
98
- log "requesting LE cert for $DOMAIN"
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
- log "cert for $DOMAIN already present"
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.74",
3
+ "version": "1.0.76",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {