gowalk-cicd 1.0.147 → 1.0.148

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
@@ -242,6 +242,14 @@ node /path/to/gowalk-cicd/bin/cli.mjs
242
242
  Docker port changes update only the managed nginx upstream, preserving existing TLS directives.
243
243
  Existing certificate inventory is followed by domain-scoped `certbot install`, so a legacy
244
244
  HTTP-only vhost also recovers without reissuance. Installation shares the bounded lock-only retry.
245
+ - Optional backend edge delivery keeps `api-domain` as infrastructure and verifies the separate
246
+ `public-health-url` against exact health JSON `build_sha`. `public-base-url` supplies
247
+ `BACKEND_PUBLIC_BASE_URL` to Compose interpolation; app services consume it for generated URLs.
248
+ `ingress-required` requires a private `ingress-token` before deployment. Protect only the distinct
249
+ `backend-<app>-edge.conf`; preserve a legacy vhost's TLS/public access and update both managed
250
+ ports. A missing token/settings cannot downgrade an already protected app. Keep tokens in private
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.
245
253
  - The Android action serves two build systems. `android_config.project_kind()`
246
254
  is the only place that decides which; every downstream step branches on the
247
255
  `project_kind` output rather than re-sniffing the repo. Flutter wins the tie
package/README.md CHANGED
@@ -201,6 +201,8 @@ branch yet, manually dispatching **Mobile Deploy** on the feature branch runs
201
201
  the same backend action as a `backend-preview` job. The job is feature-branch
202
202
  only; normal default-branch backend deploys remain in `deploy-backend.yml`.
203
203
 
204
+ For optional app-owned Cloudflare ingress, follow [backend edge delivery](docs/BACKEND_EDGE.md).
205
+
204
206
  ## Android credentials
205
207
 
206
208
  Place these files under `creds/`:
@@ -1 +1 @@
1
- 1.0.147
1
+ 1.0.148
@@ -24,6 +24,7 @@ function input(name, platform) {
24
24
  // checked against the template, and share the projection with the freeze CLI.
25
25
  const BUILD_VARIABLES = [
26
26
  'BACKEND_API_DOMAIN', 'BACKEND_APP_NAME', 'BACKEND_DEPLOY_HOST', 'BACKEND_HEALTH_PATH',
27
+ 'BACKEND_INGRESS_REQUIRED', 'BACKEND_PUBLIC_BASE_URL', 'BACKEND_PUBLIC_HEALTH_URL',
27
28
  'DEPLOY_PLATFORMS', 'FIREBASE_APP_ID', 'GOOGLE_PLAY_STATUS', 'GOOGLE_PLAY_TRACK',
28
29
  'GOOGLE_PLAY_USER_FRACTION', 'JAVA_VERSION', 'ONBOARDING_PUBLIC_ORIGIN',
29
30
  ];
@@ -1 +1 @@
1
- 1.0.147
1
+ 1.0.148
@@ -1 +1 @@
1
- 1.0.147
1
+ 1.0.148
@@ -53,6 +53,26 @@ inputs:
53
53
  description: HTTP path the service answers 2xx on once healthy.
54
54
  required: false
55
55
  default: "/health"
56
+ public-health-url:
57
+ description: App-owned HTTPS edge health URL; empty preserves the infrastructure health probe.
58
+ required: false
59
+ default: ""
60
+ public-base-url:
61
+ description: Canonical client API base supplied as BACKEND_PUBLIC_BASE_URL to Compose interpolation.
62
+ required: false
63
+ default: ""
64
+ expected-build-sha:
65
+ description: Exact 40-character lowercase commit SHA required in health JSON build_sha.
66
+ required: false
67
+ default: ""
68
+ ingress-required:
69
+ description: Protect a distinct edge-only infrastructure vhost, retaining an existing legacy vhost.
70
+ required: false
71
+ default: "false"
72
+ ingress-token:
73
+ description: Backend-only per-app token sent by the edge in X-App-Robot-Backend-Token.
74
+ required: false
75
+ default: ""
56
76
  cert-email:
57
77
  description: Email for the Let's Encrypt registration.
58
78
  required: false
@@ -82,10 +102,16 @@ runs:
82
102
  env:
83
103
  BACKEND_DIR: ${{ inputs.backend-dir }}
84
104
  COMPOSE_FILE_INPUT: ${{ inputs.compose-file }}
105
+ BACKEND_PUBLIC_HEALTH_URL: ${{ inputs.public-health-url }}
106
+ BACKEND_PUBLIC_BASE_URL: ${{ inputs.public-base-url }}
107
+ BACKEND_EXPECTED_BUILD_SHA: ${{ inputs.expected-build-sha }}
108
+ BACKEND_INGRESS_REQUIRED: ${{ inputs.ingress-required }}
109
+ BACKEND_INGRESS_TOKEN: ${{ inputs.ingress-token }}
85
110
  run: |
86
111
  set -euo pipefail
87
112
  source "${{ github.action_path }}/deploy_evidence.sh"
88
113
  BACKEND_DEPLOY_PHASE=validate_inputs
114
+ python3 "${{ github.action_path }}/edge_inputs.py" check
89
115
  COMPOSE_FILE="${COMPOSE_FILE_INPUT:-$BACKEND_DIR/docker-compose.yml}"
90
116
  test -d "$BACKEND_DIR" \
91
117
  || { echo "::error::no backend directory $BACKEND_DIR"; exit 1; }
@@ -138,6 +164,7 @@ runs:
138
164
  # (generated DB password, etc.) survive across deploys.
139
165
  if [ "$COMPOSE_FILE" = "$BACKEND_DIR/docker-compose.yml" ]; then
140
166
  rsync -az --delete --exclude '.env' --exclude '.git' \
167
+ --exclude '.backend-edge.json' --exclude '.backend-ingress.conf' --exclude '.backend-public.env' \
141
168
  -e "$SSH" "$BACKEND_DIR/" \
142
169
  "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
143
170
  else
@@ -156,6 +183,9 @@ runs:
156
183
  "${{ github.action_path }}/select_certbot_account.sh" \
157
184
  "${{ github.action_path }}/ensure_certificate.sh" \
158
185
  "${{ github.action_path }}/configure_vhost.py" \
186
+ "${{ github.action_path }}/edge_inputs.py" \
187
+ "${{ github.action_path }}/edge_vhost.py" \
188
+ "${{ github.action_path }}/edge_health.py" \
159
189
  "${{ github.action_path }}/deploy_evidence.sh" \
160
190
  "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
161
191
 
@@ -164,12 +194,21 @@ runs:
164
194
  shell: bash
165
195
  env:
166
196
  RUNTIME_ENV: ${{ inputs.runtime-env }}
197
+ BACKEND_PUBLIC_HEALTH_URL: ${{ inputs.public-health-url }}
198
+ BACKEND_PUBLIC_BASE_URL: ${{ inputs.public-base-url }}
199
+ BACKEND_EXPECTED_BUILD_SHA: ${{ inputs.expected-build-sha }}
200
+ BACKEND_INGRESS_REQUIRED: ${{ inputs.ingress-required }}
201
+ BACKEND_INGRESS_TOKEN: ${{ inputs.ingress-token }}
167
202
  run: |
168
203
  set -euo pipefail
169
204
  source "${{ github.action_path }}/deploy_evidence.sh"
170
205
  BACKEND_DEPLOY_PHASE=write_runtime_env
171
206
  SSH="ssh -i ~/.ssh/backend_deploy -o IdentitiesOnly=yes"
172
207
  DEST="/opt/gowalk-backends/${{ inputs.app-name }}"
208
+ EDGE_DIR="/opt/gowalk-backend-ingress/${{ inputs.app-name }}"
209
+ python3 "${{ github.action_path }}/edge_inputs.py" export | $SSH \
210
+ "${{ inputs.ssh-user }}@${{ inputs.host }}" \
211
+ "umask 077; mkdir -p '$EDGE_DIR'; python3 '$DEST/edge_inputs.py' receive '$EDGE_DIR/.backend-edge.json'"
173
212
  if [ -n "${RUNTIME_ENV:-}" ]; then
174
213
  printf '%s\n' "$RUNTIME_ENV" | $SSH \
175
214
  "${{ inputs.ssh-user }}@${{ inputs.host }}" \
@@ -0,0 +1,72 @@
1
+ """Verify private-origin refusal, retained aliases and the public edge's exact build."""
2
+ import json
3
+ from pathlib import Path
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ import time
8
+
9
+ from edge_inputs import load, TOKEN_HEADER
10
+ from edge_vhost import NGINX_DIR, paths, recorded_domain
11
+
12
+
13
+ def request(url, *, headers=None, run=subprocess.run):
14
+ # curl retains its strict TLS/time bounds. Private headers and bounded response
15
+ # bytes live only in an owned mode-0700 directory, never argv or diagnostics.
16
+ with tempfile.TemporaryDirectory(prefix="backend-edge-health-") as directory:
17
+ root = Path(directory)
18
+ config, body = root / "curl.conf", root / "body"
19
+ config.write_text("".join(f'header = "{key}: {value}"\n' for key, value in (headers or {}).items()))
20
+ config.chmod(0o600)
21
+ result = run(["curl", "--disable", "--silent", "--config", str(config), "--output", str(body),
22
+ "--write-out", "%{http_code}", "--proto", "=https", "--connect-timeout", "5",
23
+ "--max-time", "10", "--max-filesize", "65536", url],
24
+ capture_output=True, timeout=15)
25
+ status = result.stdout.decode("ascii", errors="replace")
26
+ data = body.read_bytes() if body.exists() and body.stat().st_size <= 65536 else b""
27
+ return result.returncode, int(status) if status.isdigit() else 0, data
28
+
29
+
30
+ def healthy(url, sha="", *, headers=None, fetch=request, sleep=time.sleep):
31
+ for attempt in range(15):
32
+ try:
33
+ code, status, body = fetch(url, headers=headers)
34
+ valid = code == 0 and 200 <= status < 300
35
+ if valid and sha:
36
+ value = json.loads(body)
37
+ valid = isinstance(value, dict) and value.get("build_sha") == sha
38
+ if valid:
39
+ return
40
+ except (OSError, ValueError, subprocess.SubprocessError):
41
+ pass
42
+ if attempt != 14:
43
+ sleep(2)
44
+ raise ValueError("backend_public_health_failed")
45
+
46
+
47
+ def verify(config, app, domain, health, *, nginx_dir=NGINX_DIR, fetch=request, sleep=time.sleep):
48
+ value = load(config)
49
+ if not health.startswith("/") or any(character in health for character in "\r\n?#"):
50
+ raise ValueError("backend_health_path_invalid")
51
+ origin_url = f"https://{domain}{health}"
52
+ if value["ingress_required"] == "true":
53
+ for headers in (None, {TOKEN_HEADER: "invalid-backend-ingress-token"}):
54
+ code, status, _body = fetch(origin_url, headers=headers)
55
+ if code != 0 or status != 403:
56
+ raise ValueError("backend_origin_ingress_unverified")
57
+ healthy(origin_url, value["expected_build_sha"], headers={TOKEN_HEADER: value["ingress_token"]},
58
+ fetch=fetch, sleep=sleep)
59
+ legacy = recorded_domain(paths(config, app, nginx_dir)[0])
60
+ if legacy:
61
+ healthy(f"https://{legacy}{health}", fetch=fetch, sleep=sleep)
62
+ public = value["public_health_url"] or origin_url
63
+ healthy(public, value["expected_build_sha"], fetch=fetch, sleep=sleep)
64
+ return {"ok": True, "public_health_verified": True, "build_sha": value["expected_build_sha"],
65
+ "ingress_verified": value["ingress_required"] == "true"}
66
+
67
+
68
+ if __name__ == "__main__":
69
+ try:
70
+ print(json.dumps(verify(*sys.argv[1:5])))
71
+ except (OSError, ValueError, subprocess.SubprocessError):
72
+ sys.exit("backend_edge_health_failed")
@@ -0,0 +1,115 @@
1
+ """Validate optional edge delivery inputs without exposing credentials or response bodies."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import re
6
+ import sys
7
+ import tempfile
8
+ from urllib.parse import urlsplit
9
+
10
+ CONFIG_NAME = ".backend-edge.json"
11
+ TOKEN_HEADER = "X-App-Robot-Backend-Token"
12
+ FIELDS = ("public_health_url", "public_base_url", "expected_build_sha", "ingress_required", "ingress_token")
13
+
14
+
15
+ def validate(value):
16
+ if not isinstance(value, dict) or set(value) != set(FIELDS):
17
+ raise ValueError("backend_edge_inputs_invalid")
18
+ if any(not isinstance(value[key], str) for key in FIELDS):
19
+ raise ValueError("backend_edge_inputs_invalid")
20
+ public, base, sha, required, token = (value[key] for key in FIELDS)
21
+ if required not in {"true", "false"}:
22
+ raise ValueError("backend_ingress_required_invalid")
23
+ if sha and not re.fullmatch(r"[a-f0-9]{40}", sha):
24
+ raise ValueError("backend_build_sha_invalid")
25
+ if public:
26
+ parsed = urlsplit(public)
27
+ if (parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password
28
+ or parsed.query or parsed.fragment or re.search(r"[\s\\]", public) or not sha):
29
+ raise ValueError("backend_public_health_invalid")
30
+ if parsed.port not in {None, 443}:
31
+ raise ValueError("backend_public_health_invalid")
32
+ if base:
33
+ parsed = urlsplit(base)
34
+ if (parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password
35
+ or parsed.query or parsed.fragment or parsed.port not in {None, 443}
36
+ or not re.fullmatch(r"[a-zA-Z0-9._~:/%-]+", base) or base.endswith("/")):
37
+ raise ValueError("backend_public_base_invalid")
38
+ if public and not public.startswith(base + "/"):
39
+ raise ValueError("backend_public_health_base_mismatch")
40
+ if required == "true" and (not public or not base or not re.fullmatch(r"[a-zA-Z0-9_-]{43,128}", token)):
41
+ raise ValueError("backend_ingress_configuration_required")
42
+ return value
43
+
44
+
45
+ def environment():
46
+ return validate({key: os.environ.get("BACKEND_" + key.upper(), "false" if key == "ingress_required" else "")
47
+ for key in FIELDS})
48
+
49
+
50
+ def load(path):
51
+ path = Path(path)
52
+ if path.is_symlink():
53
+ raise ValueError("backend_edge_config_symlink")
54
+ if not path.exists():
55
+ return validate(dict(zip(FIELDS, ("", "", "", "false", ""))))
56
+ if path.stat().st_size > 8192:
57
+ raise ValueError("backend_edge_config_invalid")
58
+ return validate(json.loads(path.read_text()))
59
+
60
+
61
+ def mode(value):
62
+ if value["ingress_required"] == "true":
63
+ return "edge"
64
+ return "verify" if value["public_health_url"] or value["expected_build_sha"] else "legacy"
65
+
66
+
67
+ def runtime(path):
68
+ value = load(path)["public_base_url"]
69
+ if value:
70
+ target = Path(path).parent / ".backend-public.env"
71
+ if target.is_symlink():
72
+ raise ValueError("backend_public_env_symlink")
73
+ descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
74
+ with os.fdopen(descriptor, "w") as output:
75
+ os.fchmod(output.fileno(), 0o600)
76
+ output.write("BACKEND_PUBLIC_BASE_URL=" + json.dumps(value) + "\n")
77
+
78
+
79
+ def receive(path):
80
+ target = Path(path)
81
+ value = validate(json.loads(sys.stdin.buffer.read(8193)))
82
+ if target.is_symlink() or target.parent.is_symlink():
83
+ raise ValueError("backend_edge_config_symlink")
84
+ descriptor, temporary = tempfile.mkstemp(prefix=target.name + ".", dir=target.parent)
85
+ try:
86
+ with os.fdopen(descriptor, "w") as output:
87
+ os.fchmod(output.fileno(), 0o600)
88
+ json.dump(value, output)
89
+ output.flush()
90
+ os.fsync(output.fileno())
91
+ os.replace(temporary, target)
92
+ finally:
93
+ Path(temporary).unlink(missing_ok=True)
94
+
95
+
96
+ def main():
97
+ if sys.argv[1] == "check":
98
+ environment()
99
+ elif sys.argv[1] == "export":
100
+ print(json.dumps(environment()))
101
+ elif sys.argv[1] == "mode":
102
+ print(mode(load(sys.argv[2])))
103
+ elif sys.argv[1] == "runtime":
104
+ runtime(sys.argv[2])
105
+ elif sys.argv[1] == "receive":
106
+ receive(sys.argv[2])
107
+ else:
108
+ raise ValueError("backend_edge_operation_invalid")
109
+
110
+
111
+ if __name__ == "__main__":
112
+ try:
113
+ main()
114
+ except (OSError, ValueError):
115
+ sys.exit("backend_edge_configuration_invalid")
@@ -0,0 +1,140 @@
1
+ """Protect a distinct edge vhost while retaining an app's released direct-host address."""
2
+ import os
3
+ from pathlib import Path
4
+ import re
5
+ import stat
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+
10
+ from configure_vhost import content
11
+ from edge_inputs import load
12
+
13
+ NGINX_DIR = Path("/etc/nginx/conf.d")
14
+
15
+
16
+ def paths(config, app, nginx_dir=NGINX_DIR):
17
+ if not re.fullmatch(r"[a-z0-9-]+", app):
18
+ raise ValueError("backend_app_invalid")
19
+ return (nginx_dir / f"backend-{app}.conf", nginx_dir / f"backend-{app}-edge.conf",
20
+ Path(config).parent / ".backend-ingress.conf")
21
+
22
+
23
+ def previous(path):
24
+ if path.is_symlink():
25
+ raise ValueError("backend_vhost_symlink")
26
+ return path.read_text() if path.exists() else ""
27
+
28
+
29
+ def recorded_domain(path):
30
+ text = previous(path)
31
+ if not text:
32
+ return ""
33
+ names = set(re.findall(r"(?m)^\s*server_name\s+([^;]+);", text))
34
+ if len(names) != 1:
35
+ raise ValueError("backend_vhost_identity_invalid")
36
+ domain = names.pop().strip()
37
+ content(text, domain, 1)
38
+ return domain
39
+
40
+
41
+ def validate(config, app, domain, nginx_dir=NGINX_DIR):
42
+ value = load(config)
43
+ legacy, edge, include = paths(config, app, nginx_dir)
44
+ protected = bool(previous(edge) or previous(include))
45
+ if value["ingress_required"] != "true":
46
+ if protected:
47
+ raise ValueError("backend_ingress_downgrade_refused")
48
+ return value
49
+ content("", domain, 1)
50
+ old_domain = recorded_domain(legacy)
51
+ if old_domain == domain:
52
+ raise ValueError("backend_edge_domain_must_be_distinct")
53
+ if previous(edge) and recorded_domain(edge) != domain:
54
+ raise ValueError("backend_edge_domain_changed")
55
+ return value
56
+
57
+
58
+ def edge_content(text, domain, port, include):
59
+ updated = content(text, domain, port)
60
+ directive = f" include {include};\n"
61
+ if re.search(r"[\s;{}]", str(include)):
62
+ raise ValueError("backend_ingress_path_invalid")
63
+ if updated.count(directive) == 1:
64
+ return updated
65
+ if ".backend-ingress.conf" in updated or len(re.findall(r"\bproxy_pass\b", updated)) != 1:
66
+ raise ValueError("backend_edge_vhost_invalid")
67
+ pattern = r"(?m)^(\s*location / \{\n)"
68
+ updated, count = re.subn(pattern, lambda match: match[1] + directive, updated)
69
+ if count != 1:
70
+ raise ValueError("backend_edge_location_invalid")
71
+ return updated
72
+
73
+
74
+ def replace(path, data, mode):
75
+ fd, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
76
+ try:
77
+ with os.fdopen(fd, "wb") as output:
78
+ os.fchmod(output.fileno(), mode)
79
+ output.write(data)
80
+ output.flush()
81
+ os.fsync(output.fileno())
82
+ os.replace(temporary, path)
83
+ finally:
84
+ Path(temporary).unlink(missing_ok=True)
85
+
86
+
87
+ def planned(config, app, domain, port, nginx_dir=NGINX_DIR):
88
+ value = validate(config, app, domain, nginx_dir)
89
+ if value["ingress_required"] != "true":
90
+ raise ValueError("backend_ingress_configuration_required")
91
+ legacy, edge, include = paths(config, app, nginx_dir)
92
+ token = value["ingress_token"]
93
+ guard = (f'if ($http_x_app_robot_backend_token != "{token}") {{ return 403; }}\n'
94
+ 'proxy_set_header X-App-Robot-Backend-Token "";\n')
95
+ files = {include: (guard.encode(), 0o600),
96
+ edge: (edge_content(previous(edge), domain, port, include).encode(), 0o644)}
97
+ old_domain = recorded_domain(legacy)
98
+ if old_domain:
99
+ files[legacy] = (content(previous(legacy), old_domain, port).encode(),
100
+ stat.S_IMODE(legacy.stat().st_mode))
101
+ return files
102
+
103
+
104
+ def configure(config, app, domain, port, nginx_dir=NGINX_DIR, run=subprocess.run):
105
+ files = planned(config, app, domain, port, nginx_dir)
106
+ saved = {path: ((path.read_bytes(), stat.S_IMODE(path.stat().st_mode)) if path.exists() else None)
107
+ for path in files}
108
+ changes = {path: value for path, value in files.items() if saved[path] != value}
109
+ if not changes:
110
+ return "unchanged"
111
+ try:
112
+ for path, (data, mode) in changes.items():
113
+ replace(path, data, mode)
114
+ run(["nginx", "-t"], capture_output=True, timeout=30, check=True)
115
+ run(["systemctl", "reload", "nginx"], capture_output=True, timeout=30, check=True)
116
+ except (OSError, subprocess.SubprocessError):
117
+ for path in changes:
118
+ if saved[path] is None:
119
+ path.unlink(missing_ok=True)
120
+ else:
121
+ replace(path, *saved[path])
122
+ raise ValueError("backend_edge_nginx_failed") from None
123
+ return "changed"
124
+
125
+
126
+ def main():
127
+ operation, config, app, domain = sys.argv[1:5]
128
+ if operation == "validate":
129
+ validate(config, app, domain)
130
+ elif operation == "configure":
131
+ print(configure(config, app, domain, int(sys.argv[5])))
132
+ else:
133
+ raise ValueError("backend_edge_operation_invalid")
134
+
135
+
136
+ if __name__ == "__main__":
137
+ try:
138
+ main()
139
+ except (OSError, ValueError):
140
+ sys.exit("backend_edge_vhost_refused")
@@ -17,6 +17,15 @@ source "$DIR/ensure_certificate.sh"
17
17
 
18
18
  log() { echo "[deploy $APP] $*"; }
19
19
 
20
+ # Refuse a missing ingress token or an older unprotected workflow before Compose changes.
21
+ EDGE_CONFIG="/opt/gowalk-backend-ingress/$APP/.backend-edge.json"
22
+ EDGE_MODE=legacy
23
+ if [ -f "$EDGE_CONFIG" ] || [ -e "/etc/nginx/conf.d/backend-$APP-edge.conf" ]; then
24
+ python3 "$DIR/edge_vhost.py" validate "$EDGE_CONFIG" "$APP" "$DOMAIN"
25
+ EDGE_MODE="$(python3 "$DIR/edge_inputs.py" mode "$EDGE_CONFIG")"
26
+ python3 "$DIR/edge_inputs.py" runtime "$EDGE_CONFIG"
27
+ fi
28
+
20
29
  # ── secrets: generate a per-app .env once, then preserve it (rsync excludes it)
21
30
  BACKEND_DEPLOY_PHASE=initialize_environment
22
31
  if [ ! -f .env ]; then
@@ -33,6 +42,10 @@ if [ -s .runtime.env ]; then
33
42
  chmod 600 .runtime.env
34
43
  COMPOSE_ENV+=(--env-file .runtime.env)
35
44
  fi
45
+ EDGE_PUBLIC="$(dirname "$EDGE_CONFIG")/.backend-public.env"
46
+ if [ -s "$EDGE_PUBLIC" ]; then
47
+ COMPOSE_ENV+=(--env-file "$EDGE_PUBLIC")
48
+ fi
36
49
 
37
50
  # ── bring the stack up
38
51
  BACKEND_DEPLOY_PHASE=compose_up
@@ -80,10 +93,14 @@ fi
80
93
  # ── nginx vhost → proxy the domain to the published port
81
94
  BACKEND_DEPLOY_PHASE=configure_vhost
82
95
  VHOST="/etc/nginx/conf.d/backend-$APP.conf"
83
- VHOST_STATE="$(python3 "$DIR/configure_vhost.py" "$VHOST" "$DOMAIN" "$PORT")"
84
- if [ "$VHOST_STATE" = changed ]; then
85
- log "updated nginx upstream for $DOMAIN → 127.0.0.1:$PORT"
86
- nginx -t && systemctl reload nginx
96
+ if [ "${EDGE_MODE:-legacy}" = edge ]; then
97
+ python3 "$DIR/edge_vhost.py" configure "$EDGE_CONFIG" "$APP" "$DOMAIN" "$PORT"
98
+ else
99
+ VHOST_STATE="$(python3 "$DIR/configure_vhost.py" "$VHOST" "$DOMAIN" "$PORT")"
100
+ if [ "$VHOST_STATE" = changed ]; then
101
+ log "updated nginx upstream for $DOMAIN → 127.0.0.1:$PORT"
102
+ nginx -t && systemctl reload nginx
103
+ fi
87
104
  fi
88
105
 
89
106
  # ── Let's Encrypt cert: failed inventory is never absence; only lock contention retries.
@@ -99,6 +116,11 @@ fi
99
116
  # ── public health-check. This validates DNS, the certificate, nginx routing,
100
117
  # and the application together; a green deploy must mean the public API works.
101
118
  BACKEND_DEPLOY_PHASE=public_health
119
+ if [ "$EDGE_MODE" != legacy ]; then
120
+ python3 "$DIR/edge_health.py" "$EDGE_CONFIG" "$APP" "$DOMAIN" "$HEALTH"
121
+ log "done"
122
+ exit 0
123
+ fi
102
124
  PUBLIC_HEALTHY=""
103
125
  for i in $(seq 1 15); do
104
126
  CURL_EXIT=0
@@ -0,0 +1,75 @@
1
+ import json
2
+ from pathlib import Path
3
+ import subprocess
4
+ import tempfile
5
+ import unittest
6
+
7
+ import configure_vhost
8
+ import edge_health
9
+ from test_edge_inputs import configured
10
+
11
+
12
+ class Health(unittest.TestCase):
13
+ def test_exact_edge_build_and_legacy_access_with_no_client_ingress_token(self):
14
+ calls = []
15
+
16
+ def fetch(url, *, headers=None):
17
+ calls.append((url, headers))
18
+ if url.startswith("https://edge.example") and (headers or {}).get(
19
+ edge_health.TOKEN_HEADER) != configured()["ingress_token"]:
20
+ return 0, 403, b""
21
+ return 0, 200, json.dumps({"build_sha": "a" * 40}).encode()
22
+
23
+ with tempfile.TemporaryDirectory() as directory:
24
+ root = Path(directory)
25
+ config = root / ".backend-edge.json"
26
+ config.write_text(json.dumps(configured()))
27
+ (root / "backend-fixture.conf").write_text(configure_vhost.content("", "legacy.example", 32000))
28
+ result = edge_health.verify(config, "fixture", "edge.example", "/healthz",
29
+ nginx_dir=root, fetch=fetch)
30
+ self.assertTrue(result["ok"])
31
+ self.assertEqual(calls[-2:], [("https://legacy.example/healthz", None),
32
+ ("https://app.pages.dev/api/healthz", None)])
33
+ self.assertEqual(len(calls), 5)
34
+
35
+ def test_redirect_partial_response_missing_build_and_mismatch_fail(self):
36
+ cases = [(0, 302, b""), (28, 200, b'{"build_sha":"' + b"a" * 40 + b'"}'),
37
+ (0, 200, b"{}"), (0, 200, b'{"build_sha":"wrong"}'), (0, 200, b"invalid")]
38
+ for response in cases:
39
+ calls = []
40
+
41
+ def fetch(url, **kwargs):
42
+ calls.append(url)
43
+ return response
44
+
45
+ with self.subTest(response=response), self.assertRaises(ValueError):
46
+ edge_health.healthy("https://app.pages.dev/api/healthz", "a" * 40,
47
+ fetch=fetch, sleep=lambda _seconds: None)
48
+ self.assertEqual(len(calls), 15)
49
+
50
+ def test_origin_that_accepts_a_missing_token_fails_before_public_probe(self):
51
+ with tempfile.TemporaryDirectory() as directory:
52
+ config = Path(directory) / ".backend-edge.json"
53
+ config.write_text(json.dumps(configured()))
54
+ with self.assertRaisesRegex(ValueError, "ingress_unverified"):
55
+ edge_health.verify(config, "fixture", "edge.example", "/healthz",
56
+ fetch=lambda *args, **kwargs: (0, 200, b"{}"))
57
+
58
+ def test_curl_keeps_token_out_of_argv_and_deletes_private_temporary_files(self):
59
+ retained = []
60
+
61
+ def run(command, **kwargs):
62
+ self.assertNotIn(configured()["ingress_token"], str(command))
63
+ config = Path(command[command.index("--config") + 1])
64
+ retained.append(config.parent)
65
+ self.assertEqual(config.stat().st_mode & 0o777, 0o600)
66
+ self.assertIn(configured()["ingress_token"], config.read_text())
67
+ for unsafe in ("--insecure", "-k", "--location", "-L"):
68
+ self.assertNotIn(unsafe, command)
69
+ Path(command[command.index("--output") + 1]).write_text('{"build_sha":"' + "a" * 40 + '"}')
70
+ return subprocess.CompletedProcess(command, 0, stdout=b"200", stderr=b"")
71
+
72
+ result = edge_health.request("https://edge.example/healthz",
73
+ headers={edge_health.TOKEN_HEADER: configured()["ingress_token"]}, run=run)
74
+ self.assertEqual(result[:2], (0, 200))
75
+ self.assertFalse(retained[0].exists())
@@ -0,0 +1,79 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+
9
+ import edge_inputs
10
+
11
+
12
+ def configured(**changes):
13
+ return {"public_health_url": "https://app.pages.dev/api/healthz",
14
+ "public_base_url": "https://app.pages.dev/api", "expected_build_sha": "a" * 40,
15
+ "ingress_required": "true", "ingress_token": "synthetic-token-" + "a" * 43, **changes}
16
+
17
+
18
+ class Inputs(unittest.TestCase):
19
+ def test_edge_and_legacy_configurations(self):
20
+ self.assertEqual(edge_inputs.mode(edge_inputs.validate(configured())), "edge")
21
+ with tempfile.TemporaryDirectory() as directory:
22
+ value = edge_inputs.load(Path(directory) / "absent")
23
+ self.assertEqual(edge_inputs.mode(value), "legacy")
24
+ self.assertEqual(edge_inputs.mode(configured(ingress_required="false")), "verify")
25
+
26
+ def test_invalid_inputs_refuse_without_credential_output(self):
27
+ cases = [{"ingress_token": ""}, {"ingress_token": 'private\"; return 200;'},
28
+ {"ingress_required": "yes"}, {"expected_build_sha": "A" * 40},
29
+ {"public_health_url": "http://app.pages.dev/api/healthz"},
30
+ {"public_health_url": "https://other.pages.dev/api/healthz"},
31
+ {"public_base_url": "https://app.pages.dev/api/$TOKEN"},
32
+ {"public_base_url": ""}, {"expected_build_sha": ""},
33
+ {"public_health_url": "https://user:private@app.pages.dev/api/healthz"}]
34
+ for changes in cases:
35
+ with self.subTest(changes=changes), self.assertRaises(ValueError):
36
+ edge_inputs.validate(configured(**changes))
37
+ env = {"BACKEND_" + key.upper(): value for key, value in configured(ingress_token="private").items()}
38
+ result = subprocess.run([sys.executable, str(Path(edge_inputs.__file__)), "check"],
39
+ env={**os.environ, **env}, capture_output=True, text=True)
40
+ self.assertNotEqual(result.returncode, 0)
41
+ self.assertNotIn("private", result.stdout + result.stderr)
42
+
43
+ def test_runtime_input_is_private_and_separate_from_secret_dotenv(self):
44
+ with tempfile.TemporaryDirectory() as directory:
45
+ root = Path(directory)
46
+ config = root / edge_inputs.CONFIG_NAME
47
+ config.write_text(json.dumps(configured()))
48
+ secrets = root / ".runtime.env"
49
+ secrets.write_text("PASSWORD=synthetic-private\n")
50
+ edge_inputs.runtime(config)
51
+ output = root / ".backend-public.env"
52
+ self.assertEqual(output.read_text(), 'BACKEND_PUBLIC_BASE_URL="https://app.pages.dev/api"\n')
53
+ self.assertEqual(output.stat().st_mode & 0o777, 0o600)
54
+ self.assertEqual(secrets.read_text(), "PASSWORD=synthetic-private\n")
55
+ edge_inputs.runtime(config)
56
+ output.unlink()
57
+ output.symlink_to(secrets)
58
+ with self.assertRaises(ValueError):
59
+ edge_inputs.runtime(config)
60
+ self.assertEqual(secrets.read_text(), "PASSWORD=synthetic-private\n")
61
+
62
+ def test_private_input_receive_is_atomic_and_refuses_symlinks(self):
63
+ with tempfile.TemporaryDirectory() as directory:
64
+ path = Path(directory) / ".backend-edge.json"
65
+ command = [sys.executable, str(Path(edge_inputs.__file__)), "receive", str(path)]
66
+ value = json.dumps(configured())
67
+ result = subprocess.run(command, input=value, capture_output=True, text=True)
68
+ self.assertEqual(result.returncode, 0, result.stderr)
69
+ self.assertEqual(result.stdout, "")
70
+ self.assertEqual(path.stat().st_mode & 0o777, 0o600)
71
+ invalid = subprocess.run(command, input="broken", capture_output=True, text=True)
72
+ self.assertNotEqual(invalid.returncode, 0)
73
+ self.assertEqual(json.loads(path.read_text()), configured())
74
+ other = Path(directory) / "preserved"
75
+ path.rename(other)
76
+ path.symlink_to(other)
77
+ refused = subprocess.run(command, input=value, capture_output=True, text=True)
78
+ self.assertNotEqual(refused.returncode, 0)
79
+ self.assertEqual(json.loads(other.read_text()), configured())
@@ -0,0 +1,103 @@
1
+ """Exercise the generated ingress config with an owned nginx process and real HTTP."""
2
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
3
+ import json
4
+ from pathlib import Path
5
+ import shutil
6
+ import socket
7
+ import subprocess
8
+ import tempfile
9
+ import threading
10
+ import time
11
+ import unittest
12
+ from urllib.error import HTTPError, URLError
13
+ from urllib.request import Request, urlopen
14
+
15
+ import configure_vhost
16
+ import edge_vhost
17
+ from test_edge_inputs import configured
18
+
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
+ class Ingress(unittest.TestCase):
44
+ def setUp(self):
45
+ self.assertIsNotNone(shutil.which("nginx"), "nginx is required for the backend ingress contract")
46
+ directory = tempfile.TemporaryDirectory()
47
+ self.addCleanup(directory.cleanup)
48
+ self.root = Path(directory.name)
49
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Backend)
50
+ self.addCleanup(server.server_close)
51
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
52
+ thread.start()
53
+ self.addCleanup(server.shutdown)
54
+ with socket.socket() as listener:
55
+ listener.bind(("127.0.0.1", 0))
56
+ self.port = listener.getsockname()[1]
57
+ self.config = self.root / ".backend-edge.json"
58
+ self.config.write_text(json.dumps(configured()))
59
+ legacy = self.root / "backend-fixture.conf"
60
+ legacy.write_text(configure_vhost.content("", "legacy.example", server.server_port))
61
+ files = edge_vhost.planned(self.config, "fixture", "edge.example", server.server_port, self.root)
62
+ for path, (data, mode) in files.items():
63
+ path.write_bytes(data.replace(b"listen 80;", f"listen 127.0.0.1:{self.port};".encode()))
64
+ path.chmod(mode)
65
+ nginx = (f"pid {self.root}/nginx.pid;\nerror_log {self.root}/error.log;\n"
66
+ "events { worker_connections 64; }\nhttp { access_log off;\n"
67
+ f"include {self.root}/backend-fixture.conf;\n"
68
+ f"include {self.root}/backend-fixture-edge.conf;\n}}\n")
69
+ (self.root / "nginx.conf").write_text(nginx)
70
+ process = subprocess.Popen(["nginx", "-p", str(self.root) + "/", "-c", "nginx.conf",
71
+ "-g", "daemon off;"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
72
+ self.addCleanup(stop, process)
73
+ for _ in range(50):
74
+ try:
75
+ self.fetch("legacy.example")
76
+ break
77
+ except URLError:
78
+ self.assertIsNone(process.poll(), "owned nginx did not start")
79
+ time.sleep(0.05)
80
+ else:
81
+ self.fail("owned nginx did not become ready")
82
+
83
+ def fetch(self, host, token=None):
84
+ headers = {"Host": host}
85
+ if token is not None:
86
+ headers["X-App-Robot-Backend-Token"] = token
87
+ request = Request(f"http://127.0.0.1:{self.port}/healthz?probe=owned", headers=headers)
88
+ try:
89
+ with urlopen(request, timeout=2) as response:
90
+ return response.status, json.load(response)
91
+ except HTTPError as error:
92
+ with error:
93
+ return error.code, None
94
+
95
+ def test_real_nginx_rejects_missing_and_forged_tokens_and_retains_legacy_access(self):
96
+ self.assertEqual(self.fetch("edge.example"), (403, None))
97
+ self.assertEqual(self.fetch("edge.example", "wrong-token"), (403, None))
98
+ for host, token in (("edge.example", configured()["ingress_token"]), ("legacy.example", None)):
99
+ status, body = self.fetch(host, token)
100
+ self.assertEqual(status, 200)
101
+ self.assertEqual(body["build_sha"], "a" * 40)
102
+ self.assertEqual(body["path"], "/healthz?probe=owned")
103
+ self.assertFalse(body["ingress_received"])
@@ -0,0 +1,72 @@
1
+ import json
2
+ from pathlib import Path
3
+ import subprocess
4
+ import tempfile
5
+ import unittest
6
+
7
+ import configure_vhost
8
+ import edge_vhost
9
+ from test_edge_inputs import configured
10
+
11
+
12
+ class Vhosts(unittest.TestCase):
13
+ def setUp(self):
14
+ self.directory = tempfile.TemporaryDirectory()
15
+ self.addCleanup(self.directory.cleanup)
16
+ self.root = Path(self.directory.name)
17
+ self.config = self.root / ".backend-edge.json"
18
+ self.config.write_text(json.dumps(configured()))
19
+ self.legacy, self.edge, self.include = edge_vhost.paths(self.config, "fixture", self.root)
20
+ self.calls = []
21
+
22
+ def run_nginx(self, command, **kwargs):
23
+ self.calls.append(command)
24
+
25
+ def test_legacy_tls_port_changes_and_replay(self):
26
+ original = configure_vhost.content("", "legacy.example", 31000).replace(
27
+ "listen 80;", "listen 443 ssl;\n ssl_certificate /existing/cert;")
28
+ self.legacy.write_text(original)
29
+ self.legacy.chmod(0o640)
30
+ for port in (32000, 33000, 33000):
31
+ edge_vhost.configure(self.config, "fixture", "edge.example", port, self.root, self.run_nginx)
32
+ self.assertEqual(self.legacy.read_text(), original.replace(":31000;", f":{port};"))
33
+ self.assertEqual(self.legacy.stat().st_mode & 0o777, 0o640)
34
+ self.assertIn(f":{port};", self.edge.read_text())
35
+ self.assertNotIn("backend-ingress", self.legacy.read_text())
36
+ self.assertEqual(self.calls, [["nginx", "-t"], ["systemctl", "reload", "nginx"]] * 2)
37
+ self.assertEqual(self.include.stat().st_mode & 0o777, 0o600)
38
+ self.assertNotIn(configured()["ingress_token"], self.edge.read_text())
39
+
40
+ def test_new_app_has_no_public_legacy_vhost_and_cannot_downgrade(self):
41
+ edge_vhost.configure(self.config, "fixture", "edge.example", 32000, self.root, self.run_nginx)
42
+ self.assertFalse(self.legacy.exists())
43
+ self.config.write_text(json.dumps(configured(ingress_required="false", ingress_token="")))
44
+ with self.assertRaisesRegex(ValueError, "downgrade"):
45
+ edge_vhost.validate(self.config, "fixture", "edge.example", self.root)
46
+ self.config.unlink()
47
+ with self.assertRaisesRegex(ValueError, "downgrade"):
48
+ edge_vhost.validate(self.config, "fixture", "edge.example", self.root)
49
+
50
+ def test_domain_collision_and_ambiguous_legacy_refuse_without_changes(self):
51
+ for original in (configure_vhost.content("", "edge.example", 31000),
52
+ "server {\n server_name ambiguous.example another.example;\n}"):
53
+ self.legacy.write_text(original)
54
+ with self.assertRaises(ValueError):
55
+ edge_vhost.configure(self.config, "fixture", "edge.example", 32000, self.root, self.run_nginx)
56
+ self.assertEqual(self.legacy.read_text(), original)
57
+ self.assertFalse(self.edge.exists())
58
+ self.assertFalse(self.include.exists())
59
+ self.assertEqual(self.calls, [])
60
+
61
+ def test_failed_nginx_check_restores_only_the_owned_files(self):
62
+ original = configure_vhost.content("", "legacy.example", 31000)
63
+ self.legacy.write_text(original)
64
+
65
+ def failure(command, **kwargs):
66
+ raise subprocess.CalledProcessError(1, command, stderr=b"private-output")
67
+
68
+ with self.assertRaisesRegex(ValueError, "backend_edge_nginx_failed"):
69
+ edge_vhost.configure(self.config, "fixture", "edge.example", 32000, self.root, failure)
70
+ self.assertEqual(self.legacy.read_text(), original)
71
+ self.assertFalse(self.edge.exists())
72
+ self.assertFalse(self.include.exists())
@@ -0,0 +1,39 @@
1
+ # App-owned Cloudflare API ingress
2
+
3
+ Provision the app's Pages/Workers route and its per-app ingress token before deploying the backend.
4
+ `BACKEND_API_DOMAIN` remains a distinct infrastructure hostname pointing to the deploy host; never
5
+ set it to a Pages/Workers hostname. Set these repository values for edge delivery:
6
+
7
+ - `BACKEND_PUBLIC_BASE_URL`: the client API base, for example `https://app.pages.dev/api`.
8
+ - `BACKEND_PUBLIC_HEALTH_URL`: that base plus the backend health path, for example `/healthz`.
9
+ - `BACKEND_INGRESS_REQUIRED`: `true`.
10
+ - Secret `BACKEND_INGRESS_TOKEN`: the same 43–128 character URL-safe token the edge sends in
11
+ `X-App-Robot-Backend-Token`; never ship it in clients or app source.
12
+
13
+ The workflow supplies the exact commit SHA, and the public health endpoint must return JSON
14
+ with `build_sha` equal to that SHA. A completed HTTPS 2xx is required; redirects, partial transfers,
15
+ missing identity and another build fail deployment. The same check rejects missing/incorrect tokens
16
+ at the infrastructure hostname, verifies authenticated origin health and probes the public URL
17
+ without passing the token. The edge must add it itself.
18
+
19
+ The action adds `BACKEND_PUBLIC_BASE_URL` to Compose interpolation through a separate host-side
20
+ dotenv file, after `.env` and `.runtime.env`. The app's Compose service must explicitly consume it:
21
+
22
+ ```yaml
23
+ environment:
24
+ BACKEND_PUBLIC_BASE_URL: ${BACKEND_PUBLIC_BASE_URL:?required}
25
+ ```
26
+
27
+ Use that value for redirects and generated API links. Preserve the action's private runtime files;
28
+ the ingress token is transported on SSH stdin and used in a mode-0600 nginx include, not CLI arguments.
29
+ Ingress configuration and public dotenv live in `/opt/gowalk-backend-ingress/<app>/`, outside the
30
+ application's Docker build context; broad application COPY instructions cannot include the token.
31
+ The new protected vhost is `backend-<app>-edge.conf`. An existing `backend-<app>.conf` keeps its
32
+ legacy domain and TLS configuration, and both upstream ports follow Compose on every deployment.
33
+ The old public hostname is health-checked without authentication so released clients remain usable.
34
+ An edge alias matching the existing legacy hostname is refused; provision a new alias instead.
35
+ Missing ingress settings on an already protected app cannot reopen its origin.
36
+
37
+ These options are also named backend settings in `.gowalk-cicd.yml`: `public-base-url`,
38
+ `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.
@@ -19,6 +19,11 @@ mobile:
19
19
  backend:
20
20
  host: ${{ vars.BACKEND_DEPLOY_HOST }}
21
21
  runtime-env: ${{ secrets.BACKEND_RUNTIME_ENV }}
22
+ public-base-url: ${{ vars.BACKEND_PUBLIC_BASE_URL }}
23
+ public-health-url: ${{ vars.BACKEND_PUBLIC_HEALTH_URL }}
24
+ expected-build-sha: ${{ vars.BACKEND_PUBLIC_HEALTH_URL && github.sha || '' }}
25
+ ingress-required: ${{ vars.BACKEND_INGRESS_REQUIRED || 'false' }}
26
+ ingress-token: ${{ secrets.BACKEND_INGRESS_TOKEN }}
22
27
  workflows:
23
28
  deploy.yml:
24
29
  overrides: []
@@ -30,6 +35,12 @@ until the app explicitly replaces their references in this file. Keep passwords,
30
35
  signing material, tokens and private keys in GitHub Secrets. Reference their names;
31
36
  never put their values in this configuration.
32
37
 
38
+ Backend edge settings are optional and preserve legacy deployments when absent. When enabled,
39
+ the infrastructure `api-domain` must be a fresh alias distinct from the retained legacy hostname.
40
+ Provision its Cloudflare route/token first. Public health proves the exact `build_sha`; the app's
41
+ Compose service must pass `BACKEND_PUBLIC_BASE_URL` into its container and use it in generated URLs.
42
+ The same named repository inputs reach the feature-branch backend preview in `deploy.yml`.
43
+
33
44
  The supported managed files are `deploy.yml`, `mobile-candidate.yml`,
34
45
  `deploy-web.yml` and `deploy-backend.yml`. App-specific values outside the named
35
46
  mobile/backend inputs, and custom jobs or steps embedded in those workflows,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.147",
3
+ "version": "1.0.148",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "CLAUDE.md",
24
24
  "docs/GRADLE_CACHE.md",
25
25
  "docs/CANDIDATE_ARTIFACTS.md",
26
+ "docs/BACKEND_EDGE.md",
26
27
  "scripts/upgrade-org.mjs",
27
28
  "scripts/lib/",
28
29
  "docs/CONFIGURATION.md"
@@ -8,7 +8,8 @@ const backend = ['jobs', 'deploy', 'steps', 'items', 'name:Deploy backend', 'wit
8
8
  const inputs = {
9
9
  ios: ['project', 'workspace', 'scheme', 'configuration', 'bundle-id', 'team-id', 'firebase-app-id'],
10
10
  android: ['project-dir', 'module', 'variant', 'firebase-app-id', 'dart-defines'],
11
- backend: ['ssh-key', 'host', 'app-name', 'api-domain', 'backend-dir', 'compose-file', 'health-path', 'runtime-env'],
11
+ backend: ['ssh-key', 'host', 'app-name', 'api-domain', 'backend-dir', 'compose-file', 'health-path', 'runtime-env',
12
+ 'public-health-url', 'public-base-url', 'expected-build-sha', 'ingress-required', 'ingress-token'],
12
13
  };
13
14
 
14
15
  export function settingsPaths(file) {
@@ -72,4 +72,9 @@ jobs:
72
72
  backend-dir: ${{ steps.dir.outputs.path }}
73
73
  compose-file: ${{ steps.dir.outputs.compose }}
74
74
  health-path: ${{ vars.BACKEND_HEALTH_PATH || '/health' }}
75
+ public-health-url: ${{ vars.BACKEND_PUBLIC_HEALTH_URL }}
76
+ public-base-url: ${{ vars.BACKEND_PUBLIC_BASE_URL }}
77
+ expected-build-sha: ${{ vars.BACKEND_PUBLIC_HEALTH_URL && github.sha || '' }}
78
+ ingress-required: ${{ vars.BACKEND_INGRESS_REQUIRED || 'false' }}
79
+ ingress-token: ${{ secrets.BACKEND_INGRESS_TOKEN }}
75
80
  runtime-env: ${{ secrets.BACKEND_RUNTIME_ENV }}
@@ -714,4 +714,9 @@ jobs:
714
714
  backend-dir: ${{ steps.dir.outputs.path }}
715
715
  compose-file: ${{ steps.dir.outputs.compose }}
716
716
  health-path: ${{ vars.BACKEND_HEALTH_PATH || '/health' }}
717
+ public-health-url: ${{ vars.BACKEND_PUBLIC_HEALTH_URL }}
718
+ public-base-url: ${{ vars.BACKEND_PUBLIC_BASE_URL }}
719
+ expected-build-sha: ${{ vars.BACKEND_PUBLIC_HEALTH_URL && github.sha || '' }}
720
+ ingress-required: ${{ vars.BACKEND_INGRESS_REQUIRED || 'false' }}
721
+ ingress-token: ${{ secrets.BACKEND_INGRESS_TOKEN }}
717
722
  runtime-env: ${{ secrets.BACKEND_RUNTIME_ENV }}
Binary file