gowalk-cicd 1.0.101 → 1.0.103

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
@@ -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.
@@ -259,6 +262,11 @@ node /path/to/gowalk-cicd/bin/cli.mjs
259
262
  Both Flutter configuration and the Pods helper use that bundle in their child process,
260
263
  preserving Podfile.lock and the parent job environment. Deliver the workflow wrapper and
261
264
  action helper together; action-only updates cannot repair earlier custom configuration steps.
265
+ - Locked Pod installation retries once only when its own attempt reports curl 18 and a partial-transfer
266
+ symptom. Both attempts share the existing 900-second budget and recheck Podfile, Podfile.lock and
267
+ selected Ruby bundle inputs. TLS/auth/permission, checksum, version and deployment errors never retry.
268
+ The assigned relay, deployment flags and completed-workspace guard remain mandatory; caches are retained.
269
+ `native_pods_retry` publishes only the fixed attempt/reason, and success reports `pod_attempts`.
262
270
  - Version stamping is per-build-system: Flutter takes `--build-number`, Gradle
263
271
  has no equivalent, so `set_gradle_version()` rewrites the literal in the
264
272
  checkout the way the iOS action patches the pbxproj. Never commit that edit.
package/README.md CHANGED
@@ -331,6 +331,15 @@ 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
+ An interrupted locked Pod fetch can retry once within the existing 900-second install budget.
340
+ `native_pods_retry` (`gowalk-cicd/native-pods-retry.v1`) reports `attempt:2` and `reason:partial_transfer`;
341
+ successful preparation reports `pod_attempts`. Dependency locks, relay and TLS checks remain enforced.
342
+
334
343
  SDK transfers also emit `flutter_download_progress` notices with schema
335
344
  `gowalk-cicd/flutter-download-progress.v1`. Each carries `phase`, `event`, `elapsed_ms`
336
345
  and `retained_bytes`; `attempt_finished` adds `attempt`, `curl_exit`, `http_status`
@@ -1 +1 @@
1
- 1.0.101
1
+ 1.0.103
@@ -15,11 +15,12 @@ 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
- def command(argv: list[str], cwd: Path, log) -> int:
21
+ def command(argv: list[str], cwd: Path, log, *, timeout: float = 900) -> int:
21
22
  try:
22
- return native_pods_process.command(argv, cwd, log)
23
+ return native_pods_process.command(argv, cwd, log, timeout=timeout)
23
24
  except native_pods_process.Interrupted:
24
25
  raise guard.Refused("native_pods_operation_interrupted") from None
25
26
  except (OSError, RuntimeError, subprocess.SubprocessError):
@@ -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
 
@@ -14,8 +14,10 @@ import tempfile
14
14
 
15
15
  import native_dependency_guard as guard
16
16
  import native_bundle
17
+ import native_pods_retry
17
18
  from native_bundle import command
18
19
  from native_pods_diagnostics import refuse
20
+ from native_prepare_evidence import report
19
21
 
20
22
 
21
23
  def committed(root: Path, lock: Path) -> str:
@@ -50,9 +52,12 @@ def prepare(root: Path, container: str, proxy_helper: Path) -> dict:
50
52
  initial = container or str(guard.selected(root, "", True).relative_to(root))
51
53
  guard.check(root, initial, before_pods=True)
52
54
  prefix, bundle_snapshot = native_bundle.prepare(root, folder, proxy_helper, log)
53
- if command([sys.executable, str(proxy_helper), "--", *prefix, "pod", "install",
54
- "--deployment", "--no-repo-update"],
55
- folder, log):
55
+ snapshot = {**bundle_snapshot, folder / "Podfile.lock": digest,
56
+ folder / "Podfile": hashlib.sha256((folder / "Podfile").read_bytes()).hexdigest()}
57
+ code, attempts = native_pods_retry.install(
58
+ [sys.executable, str(proxy_helper), "--", *prefix, "pod", "install", "--deployment", "--no-repo-update"],
59
+ folder, log, snapshot)
60
+ if code:
56
61
  refuse("native_pods_install_failed", log, guard.Refused)
57
62
  native_bundle.unchanged(bundle_snapshot)
58
63
  if digest != hashlib.sha256((folder / "Podfile.lock").read_bytes()).hexdigest():
@@ -64,7 +69,7 @@ def prepare(root: Path, container: str, proxy_helper: Path) -> dict:
64
69
  raise guard.Refused("native_project_selection_required")
65
70
  final = str(workspaces[0].relative_to(root))
66
71
  result = guard.check(root, final)
67
- return {**result, "pods": "installed", "lock_sha256": digest, "workspace": final}
72
+ return {**result, "pods": "installed", "pod_attempts": attempts, "lock_sha256": digest, "workspace": final}
68
73
 
69
74
 
70
75
  def interrupted(*_args) -> None:
@@ -85,6 +90,7 @@ def main(argv: list[str]) -> int:
85
90
  answer = {"ok": False, "code": code, "next": guard.REMEDIATION}
86
91
  if getattr(exc, "diagnostic", ""):
87
92
  answer["diagnostic"] = exc.diagnostic
93
+ report(answer, "pods")
88
94
  print(json.dumps(answer, sort_keys=True))
89
95
  return 0 if answer["ok"] else 1
90
96
 
@@ -0,0 +1,43 @@
1
+ """Retry one positively identified interrupted Pod fetch within the existing install deadline."""
2
+ import json
3
+ import re
4
+ import sys
5
+ import tempfile
6
+ import time
7
+
8
+ import native_bundle
9
+ import native_dependency_guard as guard
10
+ from native_pods_diagnostics import diagnostic
11
+
12
+ TERMINAL = re.compile(
13
+ r"certificate|SSL peer|TLS|authentication|unauthorized|forbidden|permission denied|"
14
+ r"repository not found|checksum|compatible versions|deployment mode|"
15
+ r"returned error:\s*(?:401|403|407)\b|\bcurl(?:\s+(?:51|60)\b|:\s*\((?:51|60)\))", re.I)
16
+ PARTIAL = re.compile(r"\bcurl(?:\s+18\b|:\s*\(18\))", re.I)
17
+
18
+
19
+ def interrupted_transfer(body: str) -> bool:
20
+ return bool(PARTIAL.search(body) and not TERMINAL.search(body) and any(
21
+ text in body.lower() for text in ("early eof", "transfer closed", "bytes of body are still expected")))
22
+
23
+
24
+ def install(argv: list[str], folder, log, snapshot: dict, *, timeout: float = 900) -> tuple[int, int]:
25
+ deadline = time.monotonic() + timeout
26
+ for attempt in (1, 2):
27
+ native_bundle.unchanged(snapshot)
28
+ remaining = deadline - time.monotonic()
29
+ if remaining <= 0:
30
+ raise guard.Refused("native_pods_operation_interrupted")
31
+ with tempfile.TemporaryFile() as child_log:
32
+ code = native_bundle.command(argv, folder, child_log, timeout=remaining)
33
+ body = diagnostic(child_log)
34
+ native_bundle.unchanged(snapshot)
35
+ log.seek(0)
36
+ log.truncate()
37
+ log.write(body.encode())
38
+ if code == 0 or attempt == 2 or not interrupted_transfer(body):
39
+ return code, attempt
40
+ payload = {"schema": "gowalk-cicd/native-pods-retry.v1", "attempt": 2, "reason": "partial_transfer"}
41
+ print("::notice title=native_pods_retry::" + json.dumps(payload, separators=(",", ":")),
42
+ file=sys.stderr, flush=True)
43
+ raise AssertionError("unreachable")
@@ -0,0 +1,42 @@
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
+ "partial_transfer": ("curl 18", "curl: (18)"),
15
+ "disk_full": ("No space left on device",),
16
+ }
17
+ ALL_REQUIRED = frozenset({"pod_lock_drift", "gem_missing"})
18
+ CODES = frozenset({
19
+ "native_bundle_install_failed", "native_bundle_inputs_changed", "native_bundle_path_unverified",
20
+ "native_bundle_operation_unverified", "native_configuration_failed", "native_google_proxy_required",
21
+ "native_google_transport_helper_required", "native_pods_install_failed", "native_pods_operation_unverified",
22
+ "native_pods_operation_interrupted", "native_pods_process_unverified", "native_podfile_lock_required",
23
+ "native_podfile_lock_commit_required", "native_podfile_lock_changed", "native_gemfile_required",
24
+ "native_gemfile_commit_required", "native_gemfile_lock_required", "native_gemfile_lock_commit_required",
25
+ "native_workspace_reference_unverified", "native_project_generation_failed", "native_project_selection_required",
26
+ "native_project_parse_unverified", "native_project_inventory_unverified", "native_project_inventory_missing",
27
+ "native_package_proxy_transport_required", "native_initial_lock_phase_required",
28
+ })
29
+
30
+
31
+ def report(answer: dict, phase: str) -> None:
32
+ if answer.get("ok"):
33
+ return
34
+ body = str(answer.get("diagnostic", ""))[-4000:]
35
+ signals = sorted(name for name, patterns in SIGNALS.items()
36
+ if (all if name in ALL_REQUIRED else any)(part in body for part in patterns))
37
+ code = answer.get("code")
38
+ payload = {"schema": "gowalk-cicd/native-prepare-failed.v1",
39
+ "phase": phase if phase in {"bundle", "pods"} else "unclassified",
40
+ "code": code if code in CODES else "native_prepare_unclassified", "signals": signals}
41
+ print("::error title=native_prepare_failed::" + json.dumps(payload, separators=(",", ":")),
42
+ 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):
@@ -155,7 +155,8 @@ class PodsTests(unittest.TestCase):
155
155
  output, errors = child.communicate(timeout=10)
156
156
  self.assertTrue((self.root / "owned-child").exists())
157
157
  self.assertEqual(json.loads(output)["code"], "native_pods_operation_interrupted")
158
- self.assertEqual(errors, "")
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")
159
160
  owned_pid = int((self.root / "owned-child").read_text())
160
161
  deadline = time.monotonic() + 5
161
162
  while time.monotonic() < deadline:
@@ -0,0 +1,100 @@
1
+ """Exercise interrupted installs through the actual CLI and relay, plus the shared deadline."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+ from unittest.mock import patch
9
+
10
+ import native_dependency_guard as guard
11
+ import native_pods_retry
12
+ import test_native_pods
13
+
14
+ PARTIAL = "error: RPC failed; curl 18 transfer closed with outstanding read data remaining\nfatal: early EOF"
15
+
16
+
17
+ class RetryTests(unittest.TestCase):
18
+ def setUp(self):
19
+ self.fixture = test_native_pods.PodsTests()
20
+ self.fixture.setUp()
21
+ self.addCleanup(self.fixture.doCleanups)
22
+ self.fixture.locked()
23
+ self.root = self.fixture.root
24
+
25
+ def script(self, failures, *, change_lock=False):
26
+ configuration = json.dumps({"failures": failures, "change_lock": change_lock})
27
+ script = self.root / "pod"
28
+ script.write_text('#!' + sys.executable + '\nimport json,os,sys\nfrom pathlib import Path\n'
29
+ 'config=json.loads(' + repr(configuration) + ')\n'
30
+ 'p=Path("attempts.json");rows=json.loads(p.read_text()) if p.exists() else []\n'
31
+ 'rows.append({"argv":sys.argv[1:],"proxy":os.environ["HTTPS_PROXY"],'
32
+ '"bypass":os.environ["NO_PROXY"]});p.write_text(json.dumps(rows))\n'
33
+ 'if len(rows)<=len(config["failures"]):\n'
34
+ ' if config["change_lock"]: Path("Podfile.lock").write_text("changed")\n'
35
+ ' print(config["failures"][len(rows)-1],file=sys.stderr);raise SystemExit(7)\n'
36
+ 'p=Path("App.xcworkspace");p.mkdir(exist_ok=True)\n'
37
+ '(p/"contents.xcworkspacedata").write_text('
38
+ '\'<Workspace><FileRef location="group:App.xcodeproj"/></Workspace>\')\n')
39
+ script.chmod(0o700)
40
+ self.fixture.env["PATH"] = str(self.root) + os.pathsep + self.fixture.env["PATH"]
41
+ self.fixture.env["NO_PROXY"] = "*"
42
+
43
+ def test_partial_transfer_recovers_with_same_lock_flags_and_private_relay(self):
44
+ self.script([PARTIAL])
45
+ code, result = self.fixture.run_cli()
46
+ self.assertEqual((code, result["pod_attempts"]), (0, 2))
47
+ attempts = json.loads((self.root / "attempts.json").read_text())
48
+ self.assertEqual(len(attempts), 2)
49
+ for attempt in attempts:
50
+ self.assertEqual(attempt["argv"], ["install", "--deployment", "--no-repo-update"])
51
+ self.assertTrue(attempt["proxy"].startswith("http://127.0.0.1:"))
52
+ self.assertEqual(attempt["bypass"], "")
53
+ self.assertEqual((self.root / "Podfile.lock").read_text(), "fixture-lock")
54
+
55
+ def test_repeat_partial_failure_stops_after_second_attempt(self):
56
+ self.script([PARTIAL, PARTIAL, PARTIAL])
57
+ code, result = self.fixture.run_cli()
58
+ self.assertEqual((code, result["code"]), (1, "native_pods_install_failed"))
59
+ self.assertEqual(len(json.loads((self.root / "attempts.json").read_text())), 2)
60
+
61
+ def test_changed_lock_refuses_before_any_retry(self):
62
+ self.script([PARTIAL], change_lock=True)
63
+ code, result = self.fixture.run_cli()
64
+ self.assertEqual((code, result["code"]), (1, "native_bundle_inputs_changed"))
65
+ self.assertEqual(len(json.loads((self.root / "attempts.json").read_text())), 1)
66
+
67
+ def test_unknown_second_failure_does_not_inherit_first_attempt_classification(self):
68
+ self.script([PARTIAL, "unclassified failure", PARTIAL])
69
+ code, result = self.fixture.run_cli()
70
+ self.assertEqual(code, 1)
71
+ self.assertEqual(result["diagnostic"], "unclassified failure")
72
+ self.assertEqual(len(json.loads((self.root / "attempts.json").read_text())), 2)
73
+
74
+ def test_terminal_evidence_dominates_partial_transfer(self):
75
+ for failure in ("certificate verify failed", "authentication failed", "permission denied",
76
+ "checksum mismatch", "could not find compatible versions", "deployment mode",
77
+ "The requested URL returned error: 407", "curl: (60) unknown trust failure"):
78
+ with self.subTest(failure=failure):
79
+ (self.root / "attempts.json").unlink(missing_ok=True)
80
+ self.script([PARTIAL + "\n" + failure])
81
+ self.assertEqual(self.fixture.run_cli()[0], 1)
82
+ self.assertEqual(len(json.loads((self.root / "attempts.json").read_text())), 1)
83
+
84
+ def test_one_deadline_and_no_guess_from_generic_eof(self):
85
+ for body in ("fatal: early EOF", "curl 180 failure", "unknown network error"):
86
+ self.assertFalse(native_pods_retry.interrupted_transfer(body))
87
+ self.assertTrue(native_pods_retry.interrupted_transfer("curl: (18) transfer closed"))
88
+ def child(_argv, _folder, log, *, timeout):
89
+ log.write(PARTIAL.encode())
90
+ return 7
91
+ with tempfile.TemporaryFile() as log, patch.object(native_pods_retry.native_bundle, "command",
92
+ side_effect=child) as command:
93
+ with patch.object(native_pods_retry.time, "monotonic", side_effect=[0, 1, 700]):
94
+ self.assertEqual(native_pods_retry.install(["fixture"], self.root, log, {}), (7, 2))
95
+ self.assertEqual([c.kwargs["timeout"] for c in command.call_args_list], [899, 200])
96
+ command.reset_mock()
97
+ with patch.object(native_pods_retry.time, "monotonic", side_effect=[0, 1, 901]):
98
+ with self.assertRaisesRegex(guard.Refused, "native_pods_operation_interrupted"):
99
+ native_pods_retry.install(["fixture"], self.root, log, {})
100
+ self.assertEqual(command.call_count, 1)
@@ -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.101
1
+ 1.0.103
@@ -1 +1 @@
1
- 1.0.101
1
+ 1.0.103
@@ -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: rm -f ~/.ssh/backend_deploy
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.101",
3
+ "version": "1.0.103",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {