gowalk-cicd 1.0.148 → 1.0.149

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
@@ -249,7 +249,9 @@ node /path/to/gowalk-cicd/bin/cli.mjs
249
249
  `backend-<app>-edge.conf`; preserve a legacy vhost's TLS/public access and update both managed
250
250
  ports. A missing token/settings cannot downgrade an already protected app. Keep tokens in private
251
251
  stdin/file transport, strip the ingress header before app forwarding, and validate real nginx in
252
- focused tests. Preserve legacy deployments that have not opted in and existing failure phases.
252
+ focused tests. Managed locations forward WebSocket upgrades with a domain-scoped Connection map
253
+ and disable response buffering; verify a real frame exchange and a first SSE event before upstream
254
+ completion. Preserve legacy deployments that have not opted in and existing failure phases.
253
255
  - The Android action serves two build systems. `android_config.project_kind()`
254
256
  is the only place that decides which; every downstream step branches on the
255
257
  `project_kind` output rather than re-sniffing the repo. Flutter wins the tie
@@ -1 +1 @@
1
- 1.0.148
1
+ 1.0.149
@@ -1 +1 @@
1
- 1.0.148
1
+ 1.0.149
@@ -1 +1 @@
1
- 1.0.148
1
+ 1.0.149
@@ -1,4 +1,5 @@
1
- """Update one managed nginx upstream without discarding Certbot's HTTPS configuration."""
1
+ """Update managed nginx transport without discarding Certbot's HTTPS configuration."""
2
+ import hashlib
2
3
  import os
3
4
  from pathlib import Path
4
5
  import re
@@ -7,6 +8,29 @@ import sys
7
8
  import tempfile
8
9
 
9
10
 
11
+ def transport(text, domain):
12
+ variable = "$gowalk_backend_connection_" + hashlib.sha256(domain.encode()).hexdigest()[:16]
13
+ mapping = f"map $http_upgrade {variable} {{\n default upgrade;\n '' close;\n}}\n\n"
14
+ result = text.removeprefix(mapping)
15
+ directives = (r"proxy_http_version", r"proxy_set_header[ \t]+Upgrade",
16
+ r"proxy_set_header[ \t]+Connection", r"proxy_buffering")
17
+ for directive in directives:
18
+ pattern = rf"(?mi)^[ \t]*{directive}[ \t]+[^;\n]+;[^\n]*\n"
19
+ result, count = re.subn(pattern, "", result)
20
+ if count > 1:
21
+ raise ValueError("managed transport directive is not unique")
22
+ if variable in result:
23
+ raise ValueError("managed connection map is not recognized")
24
+ settings = ("proxy_http_version 1.1;", "proxy_set_header Upgrade $http_upgrade;",
25
+ f"proxy_set_header Connection {variable};", "proxy_buffering off;")
26
+ pattern = r"(?m)^([ \t]*)proxy_pass[ \t]+http://127\.0\.0\.1:\d+;[^\n]*$"
27
+ result, count = re.subn(pattern, lambda match: match[0] + "\n"
28
+ + "\n".join(match[1] + setting for setting in settings), result)
29
+ if count != 1:
30
+ raise ValueError("managed upstream is not unique")
31
+ return mapping + result
32
+
33
+
10
34
  def content(previous, domain, port):
11
35
  if not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", domain) or not 0 < port < 65536:
12
36
  raise ValueError("invalid domain or port")
@@ -18,14 +42,13 @@ def content(previous, domain, port):
18
42
  result, count = re.subn(pattern, lambda match: f"{match[1]}{port}{match[2]}", previous)
19
43
  if count != 1:
20
44
  raise ValueError("managed upstream is not unique")
21
- return result
22
- return f"""server {{
45
+ return transport(result, domain)
46
+ return transport(f"""server {{
23
47
  listen 80;
24
48
  server_name {domain};
25
49
  client_max_body_size 100m;
26
50
  location / {{
27
51
  proxy_pass http://127.0.0.1:{port};
28
- proxy_http_version 1.1;
29
52
  proxy_set_header Host $host;
30
53
  proxy_set_header X-Real-IP $remote_addr;
31
54
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -33,7 +56,7 @@ def content(previous, domain, port):
33
56
  proxy_read_timeout 120s;
34
57
  }}
35
58
  }}
36
- """
59
+ """, domain)
37
60
 
38
61
 
39
62
  def configure(path, domain, port):
@@ -0,0 +1,98 @@
1
+ """A task-owned HTTP server for real nginx protocol and streaming checks."""
2
+ import base64
3
+ import hashlib
4
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5
+ import json
6
+ import subprocess
7
+ import threading
8
+
9
+ LEGACY_VHOST = """server {
10
+ listen 80;
11
+ server_name legacy.example;
12
+ location / {
13
+ proxy_pass http://127.0.0.1:31000;
14
+ proxy_http_version 1.1;
15
+ proxy_set_header Host $host;
16
+ proxy_set_header X-Real-IP $remote_addr;
17
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
18
+ proxy_set_header X-Forwarded-Proto $scheme;
19
+ proxy_read_timeout 120s;
20
+ }
21
+ }
22
+ """
23
+
24
+
25
+ class BackendServer(ThreadingHTTPServer):
26
+ def __init__(self):
27
+ super().__init__(("127.0.0.1", 0), Backend)
28
+ self.stream_release = threading.Event()
29
+ self.stream_finished = threading.Event()
30
+
31
+
32
+ class Backend(BaseHTTPRequestHandler):
33
+ protocol_version = "HTTP/1.1"
34
+
35
+ def do_GET(self):
36
+ if self.path == "/ws":
37
+ return self.websocket()
38
+ if self.path == "/stream":
39
+ return self.stream()
40
+ payload = json.dumps({"build_sha": "a" * 40, "path": self.path,
41
+ "upgrade": self.headers.get("Upgrade"),
42
+ "connection": self.headers.get("Connection"),
43
+ "ingress_received": "X-App-Robot-Backend-Token" in self.headers}).encode()
44
+ self.send_response(200)
45
+ self.send_header("Content-Type", "application/json")
46
+ self.send_header("Content-Length", str(len(payload)))
47
+ self.end_headers()
48
+ self.wfile.write(payload)
49
+
50
+ def websocket(self):
51
+ self.close_connection = True
52
+ if (self.headers.get("Upgrade", "").lower() != "websocket"
53
+ or self.headers.get("Connection", "").lower() != "upgrade"
54
+ or self.request_version != "HTTP/1.1"
55
+ or "X-App-Robot-Backend-Token" in self.headers):
56
+ self.send_error(400)
57
+ return
58
+ key = self.headers["Sec-WebSocket-Key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
59
+ self.send_response(101)
60
+ self.send_header("Upgrade", "websocket")
61
+ self.send_header("Connection", "Upgrade")
62
+ self.send_header("Sec-WebSocket-Accept", base64.b64encode(hashlib.sha1(key.encode()).digest()).decode())
63
+ self.end_headers()
64
+ self.connection.settimeout(2)
65
+ frame = self.rfile.read(2)
66
+ if len(frame) != 2 or frame[0] != 0x81 or frame[1] & 0x80 == 0 or frame[1] & 0x7F > 125:
67
+ return
68
+ mask = self.rfile.read(4)
69
+ payload = self.rfile.read(frame[1] & 0x7F)
70
+ decoded = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
71
+ self.wfile.write(bytes((0x81, len(decoded))) + decoded)
72
+ self.wfile.flush()
73
+
74
+ def stream(self):
75
+ self.close_connection = True
76
+ self.send_response(200)
77
+ self.send_header("Content-Type", "text/event-stream")
78
+ self.end_headers()
79
+ self.wfile.write(b"data: first\n\n")
80
+ self.wfile.flush()
81
+ try:
82
+ if self.server.stream_release.wait(5):
83
+ self.wfile.write(b"data: second\n\n")
84
+ self.wfile.flush()
85
+ finally:
86
+ self.server.stream_finished.set()
87
+
88
+ def log_message(self, *_args):
89
+ pass
90
+
91
+
92
+ def stop(process):
93
+ process.terminate()
94
+ try:
95
+ process.communicate(timeout=5)
96
+ except subprocess.TimeoutExpired:
97
+ process.kill()
98
+ process.communicate(timeout=5)
@@ -1,5 +1,5 @@
1
- """Exercise the generated ingress config with an owned nginx process and real HTTP."""
2
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
1
+ """Exercise generated ingress with an owned nginx process and real HTTP, WebSocket and SSE."""
2
+ from http.client import HTTPResponse
3
3
  import json
4
4
  from pathlib import Path
5
5
  import shutil
@@ -12,41 +12,18 @@ import unittest
12
12
  from urllib.error import HTTPError, URLError
13
13
  from urllib.request import Request, urlopen
14
14
 
15
- import configure_vhost
16
15
  import edge_vhost
16
+ from nginx_test_fixture import BackendServer, LEGACY_VHOST, stop
17
17
  from test_edge_inputs import configured
18
18
 
19
19
 
20
- class Backend(BaseHTTPRequestHandler):
21
- def do_GET(self):
22
- payload = json.dumps({"build_sha": "a" * 40, "path": self.path,
23
- "ingress_received": "X-App-Robot-Backend-Token" in self.headers}).encode()
24
- self.send_response(200)
25
- self.send_header("Content-Type", "application/json")
26
- self.send_header("Content-Length", str(len(payload)))
27
- self.end_headers()
28
- self.wfile.write(payload)
29
-
30
- def log_message(self, *_args):
31
- pass
32
-
33
-
34
- def stop(process):
35
- process.terminate()
36
- try:
37
- process.communicate(timeout=5)
38
- except subprocess.TimeoutExpired:
39
- process.kill()
40
- process.communicate(timeout=5)
41
-
42
-
43
20
  class Ingress(unittest.TestCase):
44
21
  def setUp(self):
45
22
  self.assertIsNotNone(shutil.which("nginx"), "nginx is required for the backend ingress contract")
46
23
  directory = tempfile.TemporaryDirectory()
47
24
  self.addCleanup(directory.cleanup)
48
25
  self.root = Path(directory.name)
49
- server = ThreadingHTTPServer(("127.0.0.1", 0), Backend)
26
+ server = self.server = BackendServer()
50
27
  self.addCleanup(server.server_close)
51
28
  thread = threading.Thread(target=server.serve_forever, daemon=True)
52
29
  thread.start()
@@ -57,7 +34,7 @@ class Ingress(unittest.TestCase):
57
34
  self.config = self.root / ".backend-edge.json"
58
35
  self.config.write_text(json.dumps(configured()))
59
36
  legacy = self.root / "backend-fixture.conf"
60
- legacy.write_text(configure_vhost.content("", "legacy.example", server.server_port))
37
+ legacy.write_text(LEGACY_VHOST.replace(":31000;", f":{server.server_port};"))
61
38
  files = edge_vhost.planned(self.config, "fixture", "edge.example", server.server_port, self.root)
62
39
  for path, (data, mode) in files.items():
63
40
  path.write_bytes(data.replace(b"listen 80;", f"listen 127.0.0.1:{self.port};".encode()))
@@ -101,3 +78,48 @@ class Ingress(unittest.TestCase):
101
78
  self.assertEqual(body["build_sha"], "a" * 40)
102
79
  self.assertEqual(body["path"], "/healthz?probe=owned")
103
80
  self.assertFalse(body["ingress_received"])
81
+ self.assertIsNone(body["upgrade"])
82
+ self.assertEqual(body["connection"], "close")
83
+
84
+ def test_real_nginx_exchanges_websocket_frames_and_enforces_ingress(self):
85
+ cases = (("edge.example", None, 403), ("edge.example", "wrong-token", 403),
86
+ ("edge.example", configured()["ingress_token"], 101), ("legacy.example", None, 101))
87
+ for host, token, status in cases:
88
+ with self.subTest(host=host, status=status), socket.create_connection(("127.0.0.1", self.port), 2) as peer:
89
+ request = (f"GET /ws HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\n"
90
+ "Connection: Upgrade\r\nSec-WebSocket-Version: 13\r\n"
91
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n")
92
+ if token is not None:
93
+ request += f"X-App-Robot-Backend-Token: {token}\r\n"
94
+ peer.sendall((request + "\r\n").encode())
95
+ with HTTPResponse(peer) as response:
96
+ response.begin()
97
+ self.assertEqual(response.status, status)
98
+ if status != 101:
99
+ continue
100
+ self.assertEqual(response.getheader("Upgrade").lower(), "websocket")
101
+ self.assertEqual(response.getheader("Sec-WebSocket-Accept"), "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=")
102
+ payload, mask = b"ping-through-nginx", b"\x01\x02\x03\x04"
103
+ masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
104
+ peer.sendall(bytes((0x81, 0x80 | len(payload))) + mask + masked)
105
+ self.assertEqual(response.fp.read(2), bytes((0x81, len(payload))))
106
+ self.assertEqual(response.fp.read(len(payload)), payload)
107
+
108
+ def test_real_nginx_delivers_sse_before_upstream_completion(self):
109
+ for host, token in (("edge.example", configured()["ingress_token"]), ("legacy.example", None)):
110
+ with self.subTest(host=host):
111
+ self.server.stream_release.clear()
112
+ self.server.stream_finished.clear()
113
+ headers = {"Host": host}
114
+ if token is not None:
115
+ headers["X-App-Robot-Backend-Token"] = token
116
+ request = Request(f"http://127.0.0.1:{self.port}/stream", headers=headers)
117
+ try:
118
+ with urlopen(request, timeout=2) as response:
119
+ self.assertEqual(response.readline(), b"data: first\n")
120
+ self.assertFalse(self.server.stream_finished.is_set())
121
+ self.server.stream_release.set()
122
+ self.assertEqual(response.read(), b"\ndata: second\n\n")
123
+ finally:
124
+ self.server.stream_release.set()
125
+ self.assertTrue(self.server.stream_finished.wait(2))
@@ -34,6 +34,12 @@ The old public hostname is health-checked without authentication so released cli
34
34
  An edge alias matching the existing legacy hostname is refused; provision a new alias instead.
35
35
  Missing ingress settings on an already protected app cannot reopen its origin.
36
36
 
37
+ Managed backend locations forward WebSocket upgrades and disable response buffering so SSE and
38
+ streaming responses arrive incrementally. The Connection header becomes `upgrade` only when the
39
+ request carries an Upgrade header; ordinary HTTP requests use `close`. Each vhost owns a distinct
40
+ connection map, so protected and legacy domains coexist. Deployment updates existing locations
41
+ while preserving their domains, TLS settings and ingress checks.
42
+
37
43
  These options are also named backend settings in `.gowalk-cicd.yml`: `public-base-url`,
38
44
  `public-health-url`, `expected-build-sha`, `ingress-required` and `ingress-token`. Reference secrets
39
- by name. Deployments without these options retain their existing nginx/certificate/health behavior.
45
+ by name. Deployments without these options retain public origin access, certificate handling and health checks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.148",
3
+ "version": "1.0.149",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {