gowalk-cicd 1.0.22 → 1.0.24

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/README.md CHANGED
@@ -39,6 +39,36 @@ Re-run the same command anytime to pull the latest version.
39
39
  5. The existing metadata-rich form
40
40
  `*(key_id_<KEY_ID>_issuer_<UUID>_vendor_id_<ID>).p8` is also accepted.
41
41
 
42
+ ## Backend deploy (Python + Postgres, Docker)
43
+
44
+ When a repo carries a Dockerized backend — a `backend/` (or `server/`) directory
45
+ with a `docker-compose.yml` — the installer also vendors a backend deploy path:
46
+ `.github/actions/backend-app/` and `.github/workflows/deploy-backend.yml`. A
47
+ mobile-only repo is unaffected (the backend path is not installed).
48
+
49
+ The backend workflow runs on a GitHub-hosted runner and, on a push touching
50
+ `backend/**`, deploys to the gowalk host (`138.197.36.107`, publicly reachable):
51
+ it rsyncs the backend dir to `/opt/gowalk-backends/<app>/`, runs
52
+ `docker compose up -d --build`, auto-detects the published `127.0.0.1:<port>`,
53
+ wires an nginx vhost + Let's Encrypt cert for the API domain, and health-checks.
54
+
55
+ Requirements on the consumer repo:
56
+
57
+ - **`backend/docker-compose.yml`** that publishes the API on a **loopback host
58
+ port** (`127.0.0.1:<port>:<container-port>`) so nginx can proxy it. Include a
59
+ Postgres service (or use a per-app database) and a `/health` endpoint.
60
+ - A **`Dockerfile`** the compose file builds; pin the base image, run non-root,
61
+ never bake secrets in. A generated `.env` (with `POSTGRES_PASSWORD`) is created
62
+ on the host on first deploy and preserved across deploys (rsync excludes it).
63
+ - Repo **secret** `BACKEND_DEPLOY_SSH_KEY` (a private key with access to the host).
64
+ - Repo **variables** (optional): `BACKEND_API_DOMAIN` (its DNS A record must point
65
+ at the host for the cert; empty = deploy the container only, skip nginx/cert),
66
+ `BACKEND_APP_NAME` (defaults to the repo name), `BACKEND_HEALTH_PATH`
67
+ (default `/health`), `BACKEND_DEPLOY_HOST` (default `138.197.36.107`).
68
+
69
+ The action directory is canonical here (like the iOS/Android actions): edit it in
70
+ `backend-action/`, never in a consumer's vendored copy.
71
+
42
72
  ## Android credentials
43
73
 
44
74
  Place these files under `creds/`:
@@ -1 +1 @@
1
- 1.0.22
1
+ 1.0.24
@@ -66,6 +66,28 @@ CAPABILITY_BY_ENTITLEMENT = {
66
66
  # so we say what is missing and stop.
67
67
  APP_ATTEST_ENTITLEMENT = "com.apple.developer.devicecheck.appattest-environment"
68
68
 
69
+ # CarPlay capabilities are Apple-GRANTED per App ID (the CarPlay entitlement
70
+ # request at https://developer.apple.com/contact/carplay/); the ASC API's
71
+ # capabilityType enum has no CarPlay member, so a POST would 409 exactly like
72
+ # App Attest. They live in MANUAL_ENTITLEMENTS (message, no POST) and in
73
+ # PROFILE_ENTITLEMENT_KEYS (cache invalidation) — see the notes on each.
74
+ CARPLAY_ENTITLEMENTS = {
75
+ "com.apple.developer.carplay-charging",
76
+ "com.apple.developer.carplay-fueling",
77
+ "com.apple.developer.carplay-parking",
78
+ "com.apple.developer.carplay-quick-ordering",
79
+ "com.apple.developer.carplay-maps",
80
+ "com.apple.developer.carplay-audio",
81
+ "com.apple.developer.carplay-communication",
82
+ }
83
+
84
+ _CARPLAY_MESSAGE = (
85
+ "a CarPlay entitlement, which Apple grants per App ID through the CarPlay "
86
+ "entitlement request form (developer.apple.com/contact/carplay/) — the "
87
+ "App Store Connect API cannot enable it. Request it from Apple, wait for "
88
+ "the grant, then re-run"
89
+ )
90
+
69
91
  MANUAL_ENTITLEMENTS = {
70
92
  # App Attest is a real App ID capability in the developer portal, but it is
71
93
  # absent from the ASC API's capabilityType enum entirely — POSTing it
@@ -82,6 +104,7 @@ MANUAL_ENTITLEMENTS = {
82
104
  "com.apple.developer.in-app-payments": "Apple Pay",
83
105
  "com.apple.developer.pass-type-identifiers": "Wallet",
84
106
  "com.apple.developer.default-data-protection": "Data Protection",
107
+ **{key: _CARPLAY_MESSAGE for key in CARPLAY_ENTITLEMENTS},
85
108
  }
86
109
 
87
110
 
@@ -95,7 +118,16 @@ MANUAL_ENTITLEMENTS = {
95
118
  # Apple Pay, Wallet, Data Protection): those need App ID configuration this
96
119
  # action does not perform, so treating them as missing would regenerate the
97
120
  # profile on every single run without ever fixing anything.
98
- PROFILE_ENTITLEMENT_KEYS = set(CAPABILITY_BY_ENTITLEMENT) | {APP_ATTEST_ENTITLEMENT}
121
+ #
122
+ # CarPlay IS included, deliberately: a profile cached before Apple granted
123
+ # the capability would otherwise be reinstalled until it expires, failing
124
+ # every archive with no hint. Before the grant this forces a regenerate on
125
+ # every run — which still fails, but loudly, with Apple's own message (and
126
+ # the fail-fast in profile_manager); after the grant the fresh profile
127
+ # carries the key and the check goes quiet.
128
+ PROFILE_ENTITLEMENT_KEYS = (
129
+ set(CAPABILITY_BY_ENTITLEMENT) | {APP_ATTEST_ENTITLEMENT} | CARPLAY_ENTITLEMENTS
130
+ )
99
131
 
100
132
 
101
133
  def missing_profile_entitlements(
@@ -109,6 +141,22 @@ def missing_profile_entitlements(
109
141
  }
110
142
 
111
143
 
144
+ def missing_carplay_entitlements(
145
+ profile_entitlements: dict, required_keys: set[str]
146
+ ) -> set[str]:
147
+ """CarPlay keys the app needs that an issued profile does not carry.
148
+
149
+ Used as a pre-archive assertion: a freshly minted profile without the
150
+ granted CarPlay capability means Apple has not (yet) granted it, and the
151
+ archive would die later with an opaque xcodebuild error.
152
+ """
153
+ return {
154
+ key
155
+ for key in required_keys & CARPLAY_ENTITLEMENTS
156
+ if key not in profile_entitlements
157
+ }
158
+
159
+
112
160
  def read_entitlement_keys(path: Path) -> set[str]:
113
161
  """Top-level keys of an entitlements plist; empty when unreadable."""
114
162
  try:
@@ -262,6 +262,8 @@ def _provision_bundle(
262
262
  bundle_pk = bundle_pk or ensure_bundle_id(token, bid)
263
263
  delete_profile_by_name(token, name)
264
264
  profile_der = create_profile(token, name, bundle_pk, cert_id)
265
+ if entitlement_keys:
266
+ _assert_carplay_entitlements(profile_der, entitlement_keys, bid, name)
265
267
  uuid, team_id, expiration = install_profile(profile_der)
266
268
  creds_store.write_cached_profile(creds_dir, uuid, profile_der)
267
269
  entry = {
@@ -275,6 +277,38 @@ def _provision_bundle(
275
277
  return name, uuid, team_id, entry
276
278
 
277
279
 
280
+ def _assert_carplay_entitlements(
281
+ profile_der: bytes, entitlement_keys: set[str], bid: str, name: str
282
+ ) -> None:
283
+ """Fail fast when a fresh profile lacks a required CarPlay capability.
284
+
285
+ CarPlay capabilities are Apple-granted per App ID and cannot be enabled
286
+ through the ASC API. Without this check the run dies much later inside
287
+ ``xcodebuild archive`` with an opaque "doesn't include the ... capability"
288
+ — here it dies immediately, saying exactly what to do.
289
+ """
290
+ try:
291
+ plist = decode_profile_plist(profile_der, _PROFILE_DIRS[0])
292
+ except (OSError, subprocess.CalledProcessError, ValueError) as exc:
293
+ print(f"::warning::could not inspect fresh profile for {bid}: {exc!r}")
294
+ return
295
+ missing = capabilities.missing_carplay_entitlements(
296
+ plist.get("Entitlements") or {}, entitlement_keys
297
+ )
298
+ if not missing:
299
+ return
300
+ keys = ", ".join(sorted(missing))
301
+ raise SystemExit(
302
+ f"::error::Profile {name} for {bid} was issued WITHOUT {keys}. "
303
+ f"CarPlay capabilities are granted by Apple per App ID via the "
304
+ f"CarPlay entitlement request (developer.apple.com/contact/carplay/) "
305
+ f"and cannot be enabled through the App Store Connect API. Until the "
306
+ f"grant lands, keep the CarPlay key out of the entitlements file "
307
+ f"used by this configuration; once Apple confirms it on the App ID, "
308
+ f"re-run — the profile is regenerated automatically."
309
+ )
310
+
311
+
278
312
  def _gc_orphan_profiles(
279
313
  creds_dir: Path, old_manifest: dict, new_entries: list[dict]
280
314
  ) -> None:
@@ -199,5 +199,63 @@ class ReconcileTest(unittest.TestCase):
199
199
  self.assertTrue(any("App Groups" in w for w in warnings), warnings)
200
200
 
201
201
 
202
+ class CarPlayEntitlementsTest(unittest.TestCase):
203
+ CHARGING = "com.apple.developer.carplay-charging"
204
+
205
+ def test_every_carplay_key_is_manual_with_a_request_form_message(self) -> None:
206
+ for key in capabilities.CARPLAY_ENTITLEMENTS:
207
+ self.assertIn(key, capabilities.MANUAL_ENTITLEMENTS, key)
208
+ self.assertIn("carplay", capabilities.MANUAL_ENTITLEMENTS[key].lower())
209
+
210
+ def test_carplay_never_maps_to_an_asc_capability_post(self) -> None:
211
+ # The ASC capabilityType enum has no CarPlay member; a POST would 409.
212
+ self.assertEqual(
213
+ capabilities.required_capabilities(capabilities.CARPLAY_ENTITLEMENTS),
214
+ set(),
215
+ )
216
+
217
+ def test_cached_profile_without_carplay_is_detected_as_stale(self) -> None:
218
+ # The self-correcting-cache check: a profile minted pre-grant must be
219
+ # regenerated once the entitlements file declares CarPlay.
220
+ self.assertEqual(
221
+ capabilities.missing_profile_entitlements(
222
+ {"application-identifier": "T.com.gowalk.ev"}, {self.CHARGING}
223
+ ),
224
+ {self.CHARGING},
225
+ )
226
+
227
+ def test_profile_carrying_carplay_passes_both_checks(self) -> None:
228
+ entitlements = {self.CHARGING: True}
229
+ self.assertEqual(
230
+ capabilities.missing_profile_entitlements(entitlements, {self.CHARGING}),
231
+ set(),
232
+ )
233
+ self.assertEqual(
234
+ capabilities.missing_carplay_entitlements(entitlements, {self.CHARGING}),
235
+ set(),
236
+ )
237
+
238
+ def test_missing_carplay_ignores_non_carplay_keys(self) -> None:
239
+ self.assertEqual(
240
+ capabilities.missing_carplay_entitlements(
241
+ {}, {"aps-environment", "keychain-access-groups"}
242
+ ),
243
+ set(),
244
+ )
245
+
246
+ def test_reconcile_warns_but_does_not_post_for_carplay(self) -> None:
247
+ with mock.patch.object(capabilities, "get_json", return_value={"data": []}), \
248
+ mock.patch.object(capabilities, "request") as posted, \
249
+ mock.patch("builtins.print") as printed:
250
+ regenerate = capabilities.reconcile(
251
+ "tok", "PK", "com.gowalk.ev", {self.CHARGING}
252
+ )
253
+
254
+ posted.assert_not_called()
255
+ self.assertFalse(regenerate)
256
+ warnings = [c.args[0] for c in printed.call_args_list if c.args]
257
+ self.assertTrue(any("carplay" in w.lower() for w in warnings), warnings)
258
+
259
+
202
260
  if __name__ == "__main__":
203
261
  unittest.main()
@@ -1 +1 @@
1
- 1.0.22
1
+ 1.0.24
@@ -0,0 +1 @@
1
+ 1.0.24
@@ -0,0 +1,98 @@
1
+ name: gowalk-cicd backend deploy
2
+ description: >
3
+ Build and deploy a Dockerized Python+Postgres backend to the gowalk deploy
4
+ host (138.197.36.107) — rsync the backend dir, `docker compose up -d --build`,
5
+ wire an nginx vhost + Let's Encrypt cert for the API domain, and health-check.
6
+ The deploy host is publicly reachable, so this runs on a GitHub-hosted runner.
7
+
8
+ inputs:
9
+ host:
10
+ description: SSH host of the deploy server.
11
+ required: false
12
+ default: "138.197.36.107"
13
+ ssh-user:
14
+ description: SSH user on the deploy server.
15
+ required: false
16
+ default: "root"
17
+ ssh-key:
18
+ description: Private SSH key with access to the deploy host (from a secret).
19
+ required: true
20
+ app-name:
21
+ description: Stable slug — the compose project and /opt/gowalk-backends/<app-name> dir.
22
+ required: true
23
+ api-domain:
24
+ description: >
25
+ Public domain for the API (e.g. api.myapp.gowalk.com). Its DNS A record
26
+ must already point at the deploy host. Empty = deploy the container only,
27
+ skip nginx + cert.
28
+ required: false
29
+ default: ""
30
+ backend-dir:
31
+ description: Path to the backend (holds Dockerfile + docker-compose.yml).
32
+ required: false
33
+ default: "backend"
34
+ health-path:
35
+ description: HTTP path the service answers 2xx on once healthy.
36
+ required: false
37
+ default: "/health"
38
+ cert-email:
39
+ description: Email for the Let's Encrypt registration.
40
+ required: false
41
+ default: "admin@gowalk.com"
42
+
43
+ runs:
44
+ using: composite
45
+ steps:
46
+ - name: Validate inputs
47
+ shell: bash
48
+ run: |
49
+ set -euo pipefail
50
+ test -f "${{ inputs.backend-dir }}/docker-compose.yml" \
51
+ || { echo "::error::no ${{ inputs.backend-dir }}/docker-compose.yml"; exit 1; }
52
+ case "${{ inputs.app-name }}" in
53
+ *[!a-z0-9-]*|"") echo "::error::app-name must be [a-z0-9-]"; exit 1;;
54
+ esac
55
+
56
+ - name: Configure SSH
57
+ shell: bash
58
+ env:
59
+ DEPLOY_SSH_KEY: ${{ inputs.ssh-key }}
60
+ run: |
61
+ set -euo pipefail
62
+ mkdir -p ~/.ssh && chmod 700 ~/.ssh
63
+ printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/backend_deploy
64
+ chmod 600 ~/.ssh/backend_deploy
65
+ ssh-keyscan -t ed25519,rsa,ecdsa "${{ inputs.host }}" >> ~/.ssh/known_hosts 2>/dev/null
66
+ chmod 644 ~/.ssh/known_hosts
67
+
68
+ - name: Sync backend to the host
69
+ shell: bash
70
+ run: |
71
+ set -euo pipefail
72
+ SSH="ssh -i ~/.ssh/backend_deploy -o IdentitiesOnly=yes"
73
+ DEST="/opt/gowalk-backends/${{ inputs.app-name }}"
74
+ $SSH "${{ inputs.ssh-user }}@${{ inputs.host }}" "mkdir -p '$DEST'"
75
+ # --delete keeps the mirror true; exclude .env so server-side secrets
76
+ # (generated DB password, etc.) survive across deploys.
77
+ rsync -az --delete --exclude '.env' --exclude '.git' \
78
+ -e "$SSH" "${{ inputs.backend-dir }}/" \
79
+ "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
80
+ # ship the remote deploy script (kept out of the app checkout)
81
+ rsync -az -e "$SSH" "${{ github.action_path }}/remote_deploy.sh" \
82
+ "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/.remote_deploy.sh"
83
+
84
+ - name: Deploy on the host
85
+ shell: bash
86
+ run: |
87
+ set -euo pipefail
88
+ SSH="ssh -i ~/.ssh/backend_deploy -o IdentitiesOnly=yes"
89
+ DEST="/opt/gowalk-backends/${{ inputs.app-name }}"
90
+ $SSH "${{ inputs.ssh-user }}@${{ inputs.host }}" \
91
+ "bash '$DEST/.remote_deploy.sh' \
92
+ '${{ inputs.app-name }}' '${{ inputs.api-domain }}' \
93
+ '${{ inputs.health-path }}' '${{ inputs.cert-email }}'"
94
+
95
+ - name: Cleanup key
96
+ if: always()
97
+ shell: bash
98
+ run: rm -f ~/.ssh/backend_deploy
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # Runs ON the deploy host, inside /opt/gowalk-backends/<app>. Brings up the
3
+ # compose stack, then (when a domain is given) wires an nginx vhost + LE cert
4
+ # and health-checks the public URL. Idempotent; safe to re-run.
5
+ set -euo pipefail
6
+
7
+ APP="$1" # slug — compose project + dir name
8
+ DOMAIN="${2:-}" # api.<app>.gowalk.com, or empty to skip nginx/cert
9
+ HEALTH="${3:-/health}"
10
+ CERT_EMAIL="${4:-admin@gowalk.com}"
11
+ DIR="/opt/gowalk-backends/$APP"
12
+ cd "$DIR"
13
+
14
+ log() { echo "[deploy $APP] $*"; }
15
+
16
+ # ── secrets: generate a per-app .env once, then preserve it (rsync excludes it)
17
+ if [ ! -f .env ]; then
18
+ log "first deploy — generating .env (POSTGRES_PASSWORD)"
19
+ {
20
+ echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)"
21
+ echo "APP_NAME=$APP"
22
+ } > .env
23
+ chmod 600 .env
24
+ fi
25
+
26
+ # ── bring the stack up
27
+ log "docker compose up -d --build"
28
+ docker compose --project-name "$APP" up -d --build --remove-orphans
29
+
30
+ # ── discover the host port the service published on 127.0.0.1.
31
+ # The text `ps` PORTS column reliably shows `127.0.0.1:<port>-><target>/tcp`.
32
+ detect_port() {
33
+ # Capture ONLY the published port after 127.0.0.1: (not the IP's own digits).
34
+ docker compose --project-name "$APP" ps 2>/dev/null \
35
+ | sed -nE 's/.*127\.0\.0\.1:([0-9]+)->.*/\1/p' | head -1
36
+ }
37
+ PORT="$(detect_port || true)"
38
+ [ -n "${PORT:-}" ] || { log "ERROR: no 127.0.0.1:<port> publish found in compose ps"; \
39
+ docker compose --project-name "$APP" ps; exit 1; }
40
+ log "service published on 127.0.0.1:$PORT"
41
+
42
+ # ── local health-check first (fast failure, no DNS/cert dependency).
43
+ # Generous: a first-ever deploy cold-inits Postgres and pulls base images.
44
+ HEALTHY=""
45
+ for i in $(seq 1 90); do
46
+ if curl -fsS "http://127.0.0.1:$PORT$HEALTH" >/dev/null 2>&1; then
47
+ log "container healthy on :$PORT$HEALTH (after ${i}x2s)"; HEALTHY=1; break
48
+ fi
49
+ [ $((i % 15)) -eq 0 ] && log "waiting for health… (${i}x2s)"
50
+ sleep 2
51
+ done
52
+ if [ -z "$HEALTHY" ]; then
53
+ log "ERROR: container never became healthy on :$PORT$HEALTH"
54
+ docker compose --project-name "$APP" ps
55
+ docker compose --project-name "$APP" logs --tail 60
56
+ exit 1
57
+ fi
58
+
59
+ # ── container-only deploy (no domain): stop here
60
+ if [ -z "$DOMAIN" ]; then
61
+ log "no domain given — container deployed, skipping nginx/cert"
62
+ exit 0
63
+ fi
64
+
65
+ # ── nginx vhost → proxy the domain to the published port
66
+ VHOST="/etc/nginx/conf.d/backend-$APP.conf"
67
+ if [ ! -f "$VHOST" ] || ! grep -q "127.0.0.1:$PORT" "$VHOST"; then
68
+ log "writing nginx vhost $VHOST → 127.0.0.1:$PORT"
69
+ cat > "$VHOST" <<NGINX
70
+ server {
71
+ listen 80;
72
+ server_name $DOMAIN;
73
+ client_max_body_size 100m;
74
+ location / {
75
+ proxy_pass http://127.0.0.1:$PORT;
76
+ proxy_http_version 1.1;
77
+ proxy_set_header Host \$host;
78
+ proxy_set_header X-Real-IP \$remote_addr;
79
+ proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
80
+ proxy_set_header X-Forwarded-Proto \$scheme;
81
+ proxy_read_timeout 120s;
82
+ }
83
+ }
84
+ NGINX
85
+ nginx -t && systemctl reload nginx
86
+ fi
87
+
88
+ # ── Let's Encrypt cert (idempotent; certbot skips if current)
89
+ if ! certbot certificates 2>/dev/null | grep -q "Domains: $DOMAIN\b"; then
90
+ log "requesting LE cert for $DOMAIN"
91
+ certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos \
92
+ --email "$CERT_EMAIL" --redirect || log "warn: certbot failed (DNS not pointed yet?)"
93
+ else
94
+ log "cert for $DOMAIN already present"
95
+ fi
96
+
97
+ # ── public health-check (best effort — cert/DNS may still be propagating)
98
+ if curl -fsSk "https://$DOMAIN$HEALTH" >/dev/null 2>&1; then
99
+ log "public https://$DOMAIN$HEALTH healthy"
100
+ else
101
+ log "warn: public URL not answering yet (DNS/cert propagation) — container is up on :$PORT"
102
+ fi
103
+ log "done"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,9 @@
15
15
  "android-action/",
16
16
  "!android-action/**/__pycache__/**",
17
17
  "!android-action/**/*.py[cod]",
18
+ "backend-action/",
19
+ "!backend-action/**/__pycache__/**",
20
+ "!backend-action/**/*.py[cod]",
18
21
  "templates/",
19
22
  "README.md",
20
23
  "CLAUDE.md"
package/src/install.mjs CHANGED
@@ -26,16 +26,29 @@ const PACKAGE_DIR = resolve(__dirname, '..');
26
26
 
27
27
  const ACTION_SRC = join(PACKAGE_DIR, 'action');
28
28
  const ANDROID_ACTION_SRC = join(PACKAGE_DIR, 'android-action');
29
+ const BACKEND_ACTION_SRC = join(PACKAGE_DIR, 'backend-action');
29
30
  const WORKFLOW_TEMPLATE = join(PACKAGE_DIR, 'templates', 'deploy.yml');
31
+ const BACKEND_WORKFLOW_TEMPLATE = join(PACKAGE_DIR, 'templates', 'deploy-backend.yml');
30
32
  const PACKAGE_JSON = join(PACKAGE_DIR, 'package.json');
31
33
 
32
34
  // Target paths inside the consumer repo. These are relative to the repo
33
35
  // root we discover below.
34
36
  const ACTION_DEST_REL = join('.github', 'actions', 'swift-app');
35
37
  const ANDROID_ACTION_DEST_REL = join('.github', 'actions', 'android-app');
38
+ const BACKEND_ACTION_DEST_REL = join('.github', 'actions', 'backend-app');
36
39
  const WORKFLOW_DEST_REL = join('.github', 'workflows', 'deploy.yml');
40
+ const BACKEND_WORKFLOW_DEST_REL = join('.github', 'workflows', 'deploy-backend.yml');
37
41
  const VERSION_MARKER_REL = join('.github', 'actions', 'swift-app', '.daemux-version');
38
42
  const ANDROID_VERSION_MARKER_REL = join('.github', 'actions', 'android-app', '.daemux-version');
43
+ const BACKEND_VERSION_MARKER_REL = join('.github', 'actions', 'backend-app', '.daemux-version');
44
+
45
+ // A repo has a backend when a backend/ or server/ dir carries a compose file.
46
+ function backendDir(repoRoot) {
47
+ for (const d of ['backend', 'server']) {
48
+ if (existsSync(join(repoRoot, d, 'docker-compose.yml'))) return d;
49
+ }
50
+ return null;
51
+ }
39
52
 
40
53
  // Lines we treat as ignoring credentials. If any appears in the consumer's
41
54
  // .gitignore, CI will not see one or both platform keys. We warn instead of
@@ -113,6 +126,18 @@ function copyWorkflow(repoRoot, dryRun) {
113
126
  return { dest, existed };
114
127
  }
115
128
 
129
+ function copyBackendWorkflow(repoRoot, dryRun) {
130
+ const dest = join(repoRoot, BACKEND_WORKFLOW_DEST_REL);
131
+ const existed = existsSync(dest);
132
+ if (dryRun) {
133
+ console.log(` [dry-run] ${existed ? 'overwrite' : 'create'} ${BACKEND_WORKFLOW_DEST_REL}`);
134
+ return;
135
+ }
136
+ ensureDir(dirname(dest));
137
+ cpSync(BACKEND_WORKFLOW_TEMPLATE, dest, { force: true });
138
+ console.log(` ${existed ? 'Overwrote' : 'Created'} ${BACKEND_WORKFLOW_DEST_REL}`);
139
+ }
140
+
116
141
  function writeVersionMarkers(repoRoot, dryRun) {
117
142
  // Reads this package's own version from package.json and stamps it
118
143
  // into the consumer repo as the source of truth for autoupdate's
@@ -195,6 +220,19 @@ export async function runInstall({ dryRun = false } = {}) {
195
220
  writeVersionMarkers(repoRoot, dryRun);
196
221
  copyWorkflow(repoRoot, dryRun);
197
222
 
223
+ // Backend deploy path — installed only when the repo has a Dockerized
224
+ // backend (backend/ or server/ with a docker-compose.yml). Mobile-only
225
+ // repos are unaffected, so this is backward compatible.
226
+ const bdir = backendDir(repoRoot);
227
+ if (bdir) {
228
+ console.log(` Backend detected (${bdir}/docker-compose.yml) — installing backend deploy`);
229
+ copyAction(BACKEND_ACTION_SRC, BACKEND_ACTION_DEST_REL, repoRoot, dryRun);
230
+ if (!dryRun) writeFileSync(
231
+ join(repoRoot, BACKEND_VERSION_MARKER_REL),
232
+ String(JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')).version || '').trim());
233
+ copyBackendWorkflow(repoRoot, dryRun);
234
+ }
235
+
198
236
  const gi = checkGitignore(repoRoot);
199
237
  printGitignoreWarning(gi);
200
238
 
@@ -0,0 +1,41 @@
1
+ name: Backend Deploy
2
+
3
+ # Deploys the Dockerized Python+Postgres backend to the gowalk host. Installed
4
+ # by gowalk-cicd only when a backend/ (or server/) dir with a docker-compose.yml
5
+ # is present. Mobile deploy stays in deploy.yml — this is the backend's own path.
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ paths:
10
+ - "backend/**"
11
+ - "server/**"
12
+ - ".github/workflows/deploy-backend.yml"
13
+ workflow_dispatch:
14
+
15
+ concurrency:
16
+ group: backend-deploy-${{ github.ref }}
17
+ cancel-in-progress: true
18
+
19
+ jobs:
20
+ deploy:
21
+ runs-on: ubuntu-latest
22
+ timeout-minutes: 30
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+
26
+ # Pick backend/ or server/, whichever holds the compose file.
27
+ - id: dir
28
+ run: |
29
+ if [ -f backend/docker-compose.yml ]; then echo "path=backend" >> "$GITHUB_OUTPUT"
30
+ elif [ -f server/docker-compose.yml ]; then echo "path=server" >> "$GITHUB_OUTPUT"
31
+ else echo "::error::no backend/ or server/ docker-compose.yml"; exit 1; fi
32
+
33
+ - name: Deploy backend
34
+ uses: ./.github/actions/backend-app
35
+ with:
36
+ ssh-key: ${{ secrets.BACKEND_DEPLOY_SSH_KEY }}
37
+ host: ${{ vars.BACKEND_DEPLOY_HOST || '138.197.36.107' }}
38
+ app-name: ${{ vars.BACKEND_APP_NAME || github.event.repository.name }}
39
+ api-domain: ${{ vars.BACKEND_API_DOMAIN }}
40
+ backend-dir: ${{ steps.dir.outputs.path }}
41
+ health-path: ${{ vars.BACKEND_HEALTH_PATH || '/health' }}