gowalk-cicd 1.0.100 → 1.0.102
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 +3 -0
- package/README.md +6 -0
- package/action/.daemux-version +1 -1
- package/action/scripts/native_bundle.py +2 -0
- package/action/scripts/native_pods.py +3 -0
- package/action/scripts/native_prepare_evidence.py +41 -0
- package/action/scripts/test_native_bundle.py +7 -0
- package/action/scripts/test_native_pods.py +34 -1
- package/action/scripts/test_native_prepare_evidence.py +50 -0
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/backend-action/action.yml +14 -1
- package/backend-action/deploy_evidence.sh +16 -0
- package/backend-action/remote_deploy.sh +9 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -203,6 +203,9 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
203
203
|
- Backend runtime credentials use the single encrypted `BACKEND_RUNTIME_ENV`
|
|
204
204
|
secret. The action writes it to host-side `.runtime.env` with mode 0600 and
|
|
205
205
|
supplies it to Compose after the generated `.env`; never print its content.
|
|
206
|
+
Backend failures emit `backend_deploy_failed` with the fixed runner/host phase and exit code.
|
|
207
|
+
Native bundle/Pods failures emit `native_prepare_failed` with fixed refusal codes and observed
|
|
208
|
+
signals. Unknown signals remain unclassified; never copy child output or commands into annotations.
|
|
206
209
|
- The mobile workflow's feature-branch-only `backend-preview` job bootstraps a
|
|
207
210
|
new app before GitHub registers `deploy-backend.yml` on the default branch.
|
|
208
211
|
Default-branch deploys continue through the separate backend workflow.
|
package/README.md
CHANGED
|
@@ -331,6 +331,12 @@ describe observed error patterns, not retry permission or a unique root cause. S
|
|
|
331
331
|
emit `android_build_progress` notices (`gowalk-cicd/android-build-progress.v1`) every minute
|
|
332
332
|
with `phase` and `elapsed_seconds`; successful compiler output is not republished.
|
|
333
333
|
|
|
334
|
+
Native bundle and Pods failures emit `native_prepare_failed` with schema
|
|
335
|
+
`gowalk-cicd/native-prepare-failed.v1`, `phase`, a fixed refusal `code` and fixed observed `signals`.
|
|
336
|
+
Backend failures emit `backend_deploy_failed` with schema `gowalk-cicd/backend-deploy-failed.v1`,
|
|
337
|
+
the runner/host `phase` and `exit_code`. These receipts preserve the failed result and exclude
|
|
338
|
+
private command output, commands, hosts and runtime credentials. Empty signals are unclassified.
|
|
339
|
+
|
|
334
340
|
SDK transfers also emit `flutter_download_progress` notices with schema
|
|
335
341
|
`gowalk-cicd/flutter-download-progress.v1`. Each carries `phase`, `event`, `elapsed_ms`
|
|
336
342
|
and `retained_bytes`; `attempt_finished` adds `attempt`, `curl_exit`, `http_status`
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.102
|
|
@@ -15,6 +15,7 @@ import tempfile
|
|
|
15
15
|
import native_dependency_guard as guard
|
|
16
16
|
from native_pods_diagnostics import refuse
|
|
17
17
|
import native_pods_process
|
|
18
|
+
from native_prepare_evidence import report
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def command(argv: list[str], cwd: Path, log) -> int:
|
|
@@ -104,6 +105,7 @@ def main(argv: list[str]) -> int:
|
|
|
104
105
|
"compatible Ruby/Bundler runtime; reconcile dependency locks locally and retain deployment guards."}
|
|
105
106
|
if getattr(exc, "diagnostic", ""):
|
|
106
107
|
answer["diagnostic"] = exc.diagnostic
|
|
108
|
+
report(answer, "bundle")
|
|
107
109
|
print(json.dumps(answer, sort_keys=True))
|
|
108
110
|
return 0 if answer["ok"] else 1
|
|
109
111
|
|
|
@@ -16,6 +16,7 @@ import native_dependency_guard as guard
|
|
|
16
16
|
import native_bundle
|
|
17
17
|
from native_bundle import command
|
|
18
18
|
from native_pods_diagnostics import refuse
|
|
19
|
+
from native_prepare_evidence import report
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def committed(root: Path, lock: Path) -> str:
|
|
@@ -40,6 +41,7 @@ def prepare(root: Path, container: str, proxy_helper: Path) -> dict:
|
|
|
40
41
|
digest = committed(root, folder / "Podfile.lock")
|
|
41
42
|
if not proxy_helper.is_file():
|
|
42
43
|
raise guard.Refused("native_google_transport_helper_required")
|
|
44
|
+
proxy_helper = proxy_helper.resolve(strict=True)
|
|
43
45
|
with tempfile.TemporaryFile() as log:
|
|
44
46
|
if command([sys.executable, str(proxy_helper)], root, log):
|
|
45
47
|
raise guard.Refused("native_google_proxy_required")
|
|
@@ -84,6 +86,7 @@ def main(argv: list[str]) -> int:
|
|
|
84
86
|
answer = {"ok": False, "code": code, "next": guard.REMEDIATION}
|
|
85
87
|
if getattr(exc, "diagnostic", ""):
|
|
86
88
|
answer["diagnostic"] = exc.diagnostic
|
|
89
|
+
report(answer, "pods")
|
|
87
90
|
print(json.dumps(answer, sort_keys=True))
|
|
88
91
|
return 0 if answer["ok"] else 1
|
|
89
92
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Publish fixed native failure evidence without moving private child output into annotations."""
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
SIGNALS = {
|
|
6
|
+
"pod_version_conflict": ("could not find compatible versions for pod", "Unable to satisfy the following requirements"),
|
|
7
|
+
"pod_lock_drift": ("Podfile.lock", "deployment mode"),
|
|
8
|
+
"ruby_version_incompatible": ("requires Ruby version", "Your Ruby version is"),
|
|
9
|
+
"gem_missing": ("Could not find", "in locally installed gems"),
|
|
10
|
+
"bundler_version_missing": ("Could not find 'bundler'",),
|
|
11
|
+
"tls_verification": ("certificate verify failed", "CERTIFICATE_VERIFY_FAILED"),
|
|
12
|
+
"connection_timeout": ("Connection timed out", "execution expired", "Read timed out"),
|
|
13
|
+
"dns_resolution": ("Could not resolve host", "getaddrinfo"),
|
|
14
|
+
"disk_full": ("No space left on device",),
|
|
15
|
+
}
|
|
16
|
+
ALL_REQUIRED = frozenset({"pod_lock_drift", "gem_missing"})
|
|
17
|
+
CODES = frozenset({
|
|
18
|
+
"native_bundle_install_failed", "native_bundle_inputs_changed", "native_bundle_path_unverified",
|
|
19
|
+
"native_bundle_operation_unverified", "native_configuration_failed", "native_google_proxy_required",
|
|
20
|
+
"native_google_transport_helper_required", "native_pods_install_failed", "native_pods_operation_unverified",
|
|
21
|
+
"native_pods_operation_interrupted", "native_pods_process_unverified", "native_podfile_lock_required",
|
|
22
|
+
"native_podfile_lock_commit_required", "native_podfile_lock_changed", "native_gemfile_required",
|
|
23
|
+
"native_gemfile_commit_required", "native_gemfile_lock_required", "native_gemfile_lock_commit_required",
|
|
24
|
+
"native_workspace_reference_unverified", "native_project_generation_failed", "native_project_selection_required",
|
|
25
|
+
"native_project_parse_unverified", "native_project_inventory_unverified", "native_project_inventory_missing",
|
|
26
|
+
"native_package_proxy_transport_required", "native_initial_lock_phase_required",
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def report(answer: dict, phase: str) -> None:
|
|
31
|
+
if answer.get("ok"):
|
|
32
|
+
return
|
|
33
|
+
body = str(answer.get("diagnostic", ""))[-4000:]
|
|
34
|
+
signals = sorted(name for name, patterns in SIGNALS.items()
|
|
35
|
+
if (all if name in ALL_REQUIRED else any)(part in body for part in patterns))
|
|
36
|
+
code = answer.get("code")
|
|
37
|
+
payload = {"schema": "gowalk-cicd/native-prepare-failed.v1",
|
|
38
|
+
"phase": phase if phase in {"bundle", "pods"} else "unclassified",
|
|
39
|
+
"code": code if code in CODES else "native_prepare_unclassified", "signals": signals}
|
|
40
|
+
print("::error title=native_prepare_failed::" + json.dumps(payload, separators=(",", ":")),
|
|
41
|
+
file=sys.stderr, flush=True)
|
|
@@ -51,6 +51,13 @@ class BundleTests(unittest.TestCase):
|
|
|
51
51
|
"--proxy-helper", str(PROXY), "--", sys.executable, "-c", script],
|
|
52
52
|
cwd=self.root, env=self.env, capture_output=True, text=True, timeout=20)
|
|
53
53
|
self.assertNotIn("PRIVATE_SENTINEL", result.stdout + result.stderr)
|
|
54
|
+
annotations = [line for line in result.stderr.splitlines()
|
|
55
|
+
if line.startswith("::error title=native_prepare_failed::")]
|
|
56
|
+
if result.returncode:
|
|
57
|
+
self.assertEqual(len(annotations), 1)
|
|
58
|
+
self.assertEqual(json.loads(annotations[0].split("::", 2)[2])["phase"], "bundle")
|
|
59
|
+
else:
|
|
60
|
+
self.assertEqual(annotations, [])
|
|
54
61
|
return result.returncode, json.loads(result.stdout)
|
|
55
62
|
|
|
56
63
|
def test_unconfigured_app_keeps_the_proxied_command(self):
|
|
@@ -56,6 +56,38 @@ class PodsTests(unittest.TestCase):
|
|
|
56
56
|
self.env.pop("GOOGLE_STORE_PROXY_URL")
|
|
57
57
|
self.assertEqual(self.run_cli()[1]["code"], "native_google_proxy_required")
|
|
58
58
|
|
|
59
|
+
def test_relative_relay_survives_the_nested_pod_working_directory(self):
|
|
60
|
+
self.locked()
|
|
61
|
+
folder = self.root / "mobile app/ios"
|
|
62
|
+
folder.mkdir(parents=True)
|
|
63
|
+
for name in ("App.xcodeproj", "Podfile", "Podfile.lock"):
|
|
64
|
+
(self.root / name).rename(folder / name)
|
|
65
|
+
for argv in (["git", "add", "-A"],
|
|
66
|
+
["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid",
|
|
67
|
+
"commit", "-qm", "Move the native project into its app folder"]):
|
|
68
|
+
subprocess.run(argv, cwd=self.root, check=True, capture_output=True)
|
|
69
|
+
pod = self.root / "pod"
|
|
70
|
+
pod.write_text('#!' + sys.executable + '\nimport json,os,sys\nfrom pathlib import Path\n'
|
|
71
|
+
'Path("child.json").write_text(json.dumps({"cwd":str(Path.cwd()),'
|
|
72
|
+
'"argv":sys.argv[1:],"proxy":os.environ["HTTPS_PROXY"]}))\n'
|
|
73
|
+
'workspace=Path("App.xcworkspace");workspace.mkdir()\n'
|
|
74
|
+
'(workspace/"contents.xcworkspacedata").write_text('
|
|
75
|
+
'\'<Workspace><FileRef location="group:App.xcodeproj"/></Workspace>\')\n')
|
|
76
|
+
pod.chmod(0o700)
|
|
77
|
+
self.env["PATH"] = str(self.root) + os.pathsep + self.env["PATH"]
|
|
78
|
+
result = subprocess.run([sys.executable, native_pods.__file__, str(self.root),
|
|
79
|
+
"--container", "mobile app/ios/App.xcodeproj", "--proxy-helper",
|
|
80
|
+
os.path.relpath(PROXY, self.root)], cwd=self.root,
|
|
81
|
+
capture_output=True, text=True, env=self.env, timeout=20)
|
|
82
|
+
self.assertNotIn("PRIVATE_SENTINEL", result.stdout + result.stderr)
|
|
83
|
+
self.assertEqual(result.returncode, 0, result.stdout)
|
|
84
|
+
self.assertTrue(json.loads(result.stdout)["ok"])
|
|
85
|
+
child = json.loads((folder / "child.json").read_text())
|
|
86
|
+
self.assertEqual(child["cwd"], str(folder))
|
|
87
|
+
self.assertEqual(child["argv"], ["install", "--deployment", "--no-repo-update"])
|
|
88
|
+
self.assertTrue(child["proxy"].startswith("http://127.0.0.1:"))
|
|
89
|
+
self.assertEqual((folder / "Podfile.lock").read_text(), "fixture-lock")
|
|
90
|
+
|
|
59
91
|
def test_declared_bundle_controls_the_actual_pod_child(self):
|
|
60
92
|
self.locked()
|
|
61
93
|
(self.root / "Gemfile").write_text('source "https://rubygems.org"\ngem "cocoapods", "1.16.2"\n')
|
|
@@ -123,7 +155,8 @@ class PodsTests(unittest.TestCase):
|
|
|
123
155
|
output, errors = child.communicate(timeout=10)
|
|
124
156
|
self.assertTrue((self.root / "owned-child").exists())
|
|
125
157
|
self.assertEqual(json.loads(output)["code"], "native_pods_operation_interrupted")
|
|
126
|
-
self.
|
|
158
|
+
self.assertTrue(errors.startswith("::error title=native_prepare_failed::"))
|
|
159
|
+
self.assertEqual(json.loads(errors.strip().split("::", 2)[2])["code"], "native_pods_operation_interrupted")
|
|
127
160
|
owned_pid = int((self.root / "owned-child").read_text())
|
|
128
161
|
deadline = time.monotonic() + 5
|
|
129
162
|
while time.monotonic() < deadline:
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Native entrypoints preserve JSON callers while emitting only fixed public failure fields."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import native_bundle
|
|
9
|
+
import native_pods
|
|
10
|
+
from native_prepare_evidence import report
|
|
11
|
+
from native_project_graph import Refused
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EvidenceTests(unittest.TestCase):
|
|
15
|
+
def test_both_entrypoints_emit_failures_without_copying_private_diagnostic_text(self):
|
|
16
|
+
for module, operation, argv, phase in [
|
|
17
|
+
(native_bundle, "run", ["--proxy-helper", ".", "--", "true"], "bundle"),
|
|
18
|
+
(native_pods, "prepare", [".", "--proxy-helper", "."], "pods"),
|
|
19
|
+
]:
|
|
20
|
+
error = Refused("native_configuration_failed")
|
|
21
|
+
error.diagnostic = 'PRIVATE_SENTINEL could not find compatible versions for pod "Fixture"'
|
|
22
|
+
out, err = io.StringIO(), io.StringIO()
|
|
23
|
+
with self.subTest(phase=phase), patch.object(module, operation, side_effect=error):
|
|
24
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
25
|
+
code = module.main(argv)
|
|
26
|
+
self.assertEqual(code, 1)
|
|
27
|
+
self.assertFalse(json.loads(out.getvalue())["ok"])
|
|
28
|
+
self.assertNotIn("PRIVATE_SENTINEL", err.getvalue())
|
|
29
|
+
receipt = json.loads(err.getvalue().strip().split("::", 2)[2])
|
|
30
|
+
self.assertEqual(receipt["phase"], phase)
|
|
31
|
+
self.assertEqual(receipt["signals"], ["pod_version_conflict"])
|
|
32
|
+
|
|
33
|
+
def test_success_is_silent_and_unknown_failures_cannot_inject_annotations(self):
|
|
34
|
+
err = io.StringIO()
|
|
35
|
+
with contextlib.redirect_stderr(err):
|
|
36
|
+
report({"ok": True}, "bundle")
|
|
37
|
+
self.assertEqual(err.getvalue(), "")
|
|
38
|
+
with contextlib.redirect_stderr(err):
|
|
39
|
+
report({"ok": False, "code": "PRIVATE_SENTINEL\n::error::injected",
|
|
40
|
+
"diagnostic": "PRIVATE_SENTINEL"}, "PRIVATE_SENTINEL")
|
|
41
|
+
self.assertNotIn("PRIVATE_SENTINEL", err.getvalue())
|
|
42
|
+
receipt = json.loads(err.getvalue().strip().split("::", 2)[2])
|
|
43
|
+
self.assertEqual(receipt["code"], "native_prepare_unclassified")
|
|
44
|
+
self.assertEqual(receipt["signals"], [])
|
|
45
|
+
|
|
46
|
+
def test_a_lockfile_mention_alone_does_not_claim_a_deployment_lock_conflict(self):
|
|
47
|
+
err = io.StringIO()
|
|
48
|
+
with contextlib.redirect_stderr(err):
|
|
49
|
+
report({"ok": False, "code": "native_configuration_failed", "diagnostic": "Using Podfile.lock"}, "bundle")
|
|
50
|
+
self.assertEqual(json.loads(err.getvalue().strip().split("::", 2)[2])["signals"], [])
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.102
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.102
|
|
@@ -64,6 +64,8 @@ runs:
|
|
|
64
64
|
COMPOSE_FILE_INPUT: ${{ inputs.compose-file }}
|
|
65
65
|
run: |
|
|
66
66
|
set -euo pipefail
|
|
67
|
+
source "${{ github.action_path }}/deploy_evidence.sh"
|
|
68
|
+
BACKEND_DEPLOY_PHASE=validate_inputs
|
|
67
69
|
COMPOSE_FILE="${COMPOSE_FILE_INPUT:-$BACKEND_DIR/docker-compose.yml}"
|
|
68
70
|
test -d "$BACKEND_DIR" \
|
|
69
71
|
|| { echo "::error::no backend directory $BACKEND_DIR"; exit 1; }
|
|
@@ -91,6 +93,8 @@ runs:
|
|
|
91
93
|
DEPLOY_SSH_KEY: ${{ inputs.ssh-key }}
|
|
92
94
|
run: |
|
|
93
95
|
set -euo pipefail
|
|
96
|
+
source "${{ github.action_path }}/deploy_evidence.sh"
|
|
97
|
+
BACKEND_DEPLOY_PHASE=configure_ssh
|
|
94
98
|
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
|
95
99
|
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/backend_deploy
|
|
96
100
|
chmod 600 ~/.ssh/backend_deploy
|
|
@@ -104,6 +108,8 @@ runs:
|
|
|
104
108
|
COMPOSE_FILE: ${{ steps.layout.outputs.compose-file }}
|
|
105
109
|
run: |
|
|
106
110
|
set -euo pipefail
|
|
111
|
+
source "${{ github.action_path }}/deploy_evidence.sh"
|
|
112
|
+
BACKEND_DEPLOY_PHASE=sync_backend
|
|
107
113
|
SSH="ssh -i ~/.ssh/backend_deploy -o IdentitiesOnly=yes"
|
|
108
114
|
DEST="/opt/gowalk-backends/${{ inputs.app-name }}"
|
|
109
115
|
$SSH "${{ inputs.ssh-user }}@${{ inputs.host }}" "mkdir -p '$DEST'"
|
|
@@ -129,6 +135,7 @@ runs:
|
|
|
129
135
|
"${{ github.action_path }}/select_certbot_account.sh" \
|
|
130
136
|
"${{ github.action_path }}/ensure_certificate.sh" \
|
|
131
137
|
"${{ github.action_path }}/configure_vhost.py" \
|
|
138
|
+
"${{ github.action_path }}/deploy_evidence.sh" \
|
|
132
139
|
"${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
|
|
133
140
|
|
|
134
141
|
- name: Deploy on the host
|
|
@@ -137,6 +144,8 @@ runs:
|
|
|
137
144
|
RUNTIME_ENV: ${{ inputs.runtime-env }}
|
|
138
145
|
run: |
|
|
139
146
|
set -euo pipefail
|
|
147
|
+
source "${{ github.action_path }}/deploy_evidence.sh"
|
|
148
|
+
BACKEND_DEPLOY_PHASE=write_runtime_env
|
|
140
149
|
SSH="ssh -i ~/.ssh/backend_deploy -o IdentitiesOnly=yes"
|
|
141
150
|
DEST="/opt/gowalk-backends/${{ inputs.app-name }}"
|
|
142
151
|
if [ -n "${RUNTIME_ENV:-}" ]; then
|
|
@@ -144,6 +153,7 @@ runs:
|
|
|
144
153
|
"${{ inputs.ssh-user }}@${{ inputs.host }}" \
|
|
145
154
|
"umask 077; cat > '$DEST/.runtime.env'"
|
|
146
155
|
fi
|
|
156
|
+
BACKEND_DEPLOY_PHASE=remote_deploy
|
|
147
157
|
$SSH "${{ inputs.ssh-user }}@${{ inputs.host }}" \
|
|
148
158
|
"bash '$DEST/remote_deploy.sh' \
|
|
149
159
|
'${{ inputs.app-name }}' '${{ inputs.api-domain }}' \
|
|
@@ -152,4 +162,7 @@ runs:
|
|
|
152
162
|
- name: Cleanup key
|
|
153
163
|
if: always()
|
|
154
164
|
shell: bash
|
|
155
|
-
run:
|
|
165
|
+
run: |
|
|
166
|
+
source "${{ github.action_path }}/deploy_evidence.sh"
|
|
167
|
+
BACKEND_DEPLOY_PHASE=cleanup_key
|
|
168
|
+
rm -f ~/.ssh/backend_deploy
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Source inside the owning deploy shell. Never print the failed command or environment.
|
|
3
|
+
backend_report_failure() {
|
|
4
|
+
local status="$1" phase="${BACKEND_DEPLOY_PHASE:-unclassified}"
|
|
5
|
+
[ "$status" -ne 0 ] || return 0
|
|
6
|
+
case "$phase" in
|
|
7
|
+
validate_inputs|configure_ssh|sync_backend|write_runtime_env|remote_deploy|cleanup_key|\
|
|
8
|
+
remote_setup|initialize_environment|compose_up|discover_port|local_health|configure_vhost|certificate|public_health) ;;
|
|
9
|
+
*) phase=unclassified ;;
|
|
10
|
+
esac
|
|
11
|
+
printf '::error title=backend_deploy_failed::{"schema":"gowalk-cicd/backend-deploy-failed.v1",'\
|
|
12
|
+
'"phase":"%s","exit_code":%s}\n' "$phase" "$status"
|
|
13
|
+
return "$status"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
trap 'backend_report_failure "$?"' EXIT
|
|
@@ -9,6 +9,8 @@ DOMAIN="${2:-}" # api.<app>.gowalk.com, or empty to skip nginx/cert
|
|
|
9
9
|
HEALTH="${3:-/health}"
|
|
10
10
|
CERT_EMAIL="${4:-admin@gowalk.com}"
|
|
11
11
|
DIR="/opt/gowalk-backends/$APP"
|
|
12
|
+
source "$(dirname "${BASH_SOURCE[0]}")/deploy_evidence.sh"
|
|
13
|
+
BACKEND_DEPLOY_PHASE=remote_setup
|
|
12
14
|
cd "$DIR"
|
|
13
15
|
source "$DIR/select_certbot_account.sh"
|
|
14
16
|
source "$DIR/ensure_certificate.sh"
|
|
@@ -16,6 +18,7 @@ source "$DIR/ensure_certificate.sh"
|
|
|
16
18
|
log() { echo "[deploy $APP] $*"; }
|
|
17
19
|
|
|
18
20
|
# ── secrets: generate a per-app .env once, then preserve it (rsync excludes it)
|
|
21
|
+
BACKEND_DEPLOY_PHASE=initialize_environment
|
|
19
22
|
if [ ! -f .env ]; then
|
|
20
23
|
log "first deploy — generating .env (POSTGRES_PASSWORD)"
|
|
21
24
|
{
|
|
@@ -32,6 +35,7 @@ if [ -s .runtime.env ]; then
|
|
|
32
35
|
fi
|
|
33
36
|
|
|
34
37
|
# ── bring the stack up
|
|
38
|
+
BACKEND_DEPLOY_PHASE=compose_up
|
|
35
39
|
log "docker compose up -d --build"
|
|
36
40
|
docker compose "${COMPOSE_ENV[@]}" --project-name "$APP" \
|
|
37
41
|
up -d --build --remove-orphans
|
|
@@ -43,6 +47,7 @@ detect_port() {
|
|
|
43
47
|
docker compose "${COMPOSE_ENV[@]}" --project-name "$APP" ps 2>/dev/null \
|
|
44
48
|
| sed -nE 's/.*127\.0\.0\.1:([0-9]+)->.*/\1/p' | head -1
|
|
45
49
|
}
|
|
50
|
+
BACKEND_DEPLOY_PHASE=discover_port
|
|
46
51
|
PORT="$(detect_port || true)"
|
|
47
52
|
[ -n "${PORT:-}" ] || { log "ERROR: no 127.0.0.1:<port> publish found in compose ps"; \
|
|
48
53
|
docker compose --project-name "$APP" ps; exit 1; }
|
|
@@ -50,6 +55,7 @@ log "service published on 127.0.0.1:$PORT"
|
|
|
50
55
|
|
|
51
56
|
# ── local health-check first (fast failure, no DNS/cert dependency).
|
|
52
57
|
# Generous: a first-ever deploy cold-inits Postgres and pulls base images.
|
|
58
|
+
BACKEND_DEPLOY_PHASE=local_health
|
|
53
59
|
HEALTHY=""
|
|
54
60
|
for i in $(seq 1 90); do
|
|
55
61
|
if curl -fsS "http://127.0.0.1:$PORT$HEALTH" >/dev/null 2>&1; then
|
|
@@ -72,6 +78,7 @@ if [ -z "$DOMAIN" ]; then
|
|
|
72
78
|
fi
|
|
73
79
|
|
|
74
80
|
# ── nginx vhost → proxy the domain to the published port
|
|
81
|
+
BACKEND_DEPLOY_PHASE=configure_vhost
|
|
75
82
|
VHOST="/etc/nginx/conf.d/backend-$APP.conf"
|
|
76
83
|
VHOST_STATE="$(python3 "$DIR/configure_vhost.py" "$VHOST" "$DOMAIN" "$PORT")"
|
|
77
84
|
if [ "$VHOST_STATE" = changed ]; then
|
|
@@ -80,6 +87,7 @@ if [ "$VHOST_STATE" = changed ]; then
|
|
|
80
87
|
fi
|
|
81
88
|
|
|
82
89
|
# ── Let's Encrypt cert: failed inventory is never absence; only lock contention retries.
|
|
90
|
+
BACKEND_DEPLOY_PHASE=certificate
|
|
83
91
|
if ensure_certificate "$DOMAIN" "$CERT_EMAIL"; then
|
|
84
92
|
log "certificate ready for $DOMAIN"
|
|
85
93
|
else
|
|
@@ -90,6 +98,7 @@ fi
|
|
|
90
98
|
|
|
91
99
|
# ── public health-check. This validates DNS, the certificate, nginx routing,
|
|
92
100
|
# and the application together; a green deploy must mean the public API works.
|
|
101
|
+
BACKEND_DEPLOY_PHASE=public_health
|
|
93
102
|
PUBLIC_HEALTHY=""
|
|
94
103
|
for i in $(seq 1 15); do
|
|
95
104
|
CURL_EXIT=0
|