@lifeaitools/rdc-skills 0.25.10 → 0.26.1

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.
Files changed (77) hide show
  1. package/.claude-plugin/plugin.json +1608 -1551
  2. package/.github/workflows/self-test.yml +34 -34
  3. package/CHANGELOG.md +332 -319
  4. package/MANIFEST.md +224 -224
  5. package/README.md +368 -367
  6. package/RELEASE.md +50 -45
  7. package/bin/rdc-skills-mcp.mjs +1 -1
  8. package/commands/build.md +181 -181
  9. package/commands/collab.md +180 -180
  10. package/commands/deploy.md +173 -148
  11. package/commands/fixit.md +150 -150
  12. package/commands/handoff.md +173 -173
  13. package/commands/overnight.md +220 -220
  14. package/commands/plan.md +158 -158
  15. package/commands/preplan.md +131 -131
  16. package/commands/prototype.md +145 -145
  17. package/commands/report.md +99 -99
  18. package/commands/review.md +120 -120
  19. package/commands/status.md +86 -86
  20. package/commands/workitems.md +127 -127
  21. package/deploy/install-systemd.sh +16 -0
  22. package/deploy/systemd/rdc-skills-mcp.service +19 -0
  23. package/git-sha.json +1 -1
  24. package/guides/agent-bootstrap.md +195 -195
  25. package/guides/agents/backend.md +102 -102
  26. package/guides/agents/content.md +94 -94
  27. package/guides/agents/cs2.md +56 -56
  28. package/guides/agents/data.md +86 -86
  29. package/guides/agents/design.md +77 -77
  30. package/guides/agents/frontend.md +91 -91
  31. package/guides/agents/infrastructure.md +81 -81
  32. package/guides/agents/setup.md +272 -272
  33. package/guides/agents/verify.md +119 -119
  34. package/guides/agents/viz.md +106 -106
  35. package/guides/orchestration-epic.md +17 -0
  36. package/hooks/check-rdc-environment.js +172 -378
  37. package/hooks/lib/box-lock.js +4 -5
  38. package/package.json +1 -2
  39. package/scripts/install-rdc-skills.js +19 -264
  40. package/scripts/probe-installed-hooks.mjs +2 -2
  41. package/scripts/self-test.mjs +1459 -1459
  42. package/scripts/validate-publish-manifests.js +502 -502
  43. package/skills/build/SKILL.md +662 -578
  44. package/skills/channel-formatter/SKILL.md +538 -538
  45. package/skills/collab/SKILL.md +239 -239
  46. package/skills/convert/SKILL.md +138 -138
  47. package/skills/deploy/SKILL.md +593 -541
  48. package/skills/design/SKILL.md +205 -205
  49. package/skills/env/SKILL.md +146 -139
  50. package/skills/fixit/SKILL.md +203 -203
  51. package/skills/handoff/SKILL.md +236 -236
  52. package/skills/housekeeping/SKILL.md +189 -189
  53. package/skills/onramp/SKILL.md +1459 -1459
  54. package/skills/overnight/SKILL.md +251 -251
  55. package/skills/plan/SKILL.md +345 -345
  56. package/skills/preplan/SKILL.md +90 -90
  57. package/skills/prototype/SKILL.md +150 -150
  58. package/skills/refactor/SKILL.md +51 -0
  59. package/skills/regen-media/SKILL.md +94 -94
  60. package/skills/release/SKILL.md +140 -140
  61. package/skills/report/SKILL.md +100 -100
  62. package/skills/review/SKILL.md +151 -151
  63. package/skills/self-test/SKILL.md +108 -108
  64. package/skills/status/SKILL.md +99 -99
  65. package/skills/tests/MATRIX.md +59 -55
  66. package/skills/tests/README.md +1 -1
  67. package/skills/tests/onramp.test.json +101 -87
  68. package/skills/tests/rdc-env.test.json +12 -0
  69. package/skills/tests/rdc-new-model.test.json +12 -0
  70. package/skills/tests/rdc-refactor.test.json +29 -0
  71. package/skills/tests/rdc-regen-media.test.json +29 -29
  72. package/skills/watch/SKILL.md +84 -84
  73. package/skills/workitems/SKILL.md +151 -151
  74. package/tests/no-local-pm2.test.mjs +45 -0
  75. package/scripts/local-install-with-stop.sh +0 -41
  76. package/scripts/probe-lock-holders.mjs +0 -36
  77. package/scripts/rebuild-mcp.mjs +0 -107
@@ -1,541 +1,593 @@
1
- ---
2
- name: rdc:deploy
3
- description: "Usage `rdc:deploy <slug> [new|diagnose|audit|promote|convert] [--fix|--hotfix <sha>]` — Deploy an app to Coolify (production) or PM2 (staging), promote a verified dev change to production (one-command cherry-pick → PR → admin-merge → explicit Coolify trigger → verify), convert a prod app's runtime in place static→Next, add a new Coolify app, diagnose a failed deploy, or audit watch paths. Handles DNS, branch protection, health checks, and post-deploy verification."
4
- ---
5
-
6
- > **⚠️ OUTPUT CONTRACT (READ FIRST):** `guides/output-contract.md`
7
- > Checklist-only output. No tool-call narration. No raw MCP/JSON/log dumps.
8
- > One checklist upfront, updated in place, shown again at end with a 1-line verdict.
9
-
10
-
11
- # rdc:deploy — Coolify Operations
12
-
13
- **READ FIRST:** `guides/output-contract.md`. Checklist-only output. No narration.
14
- No raw MCP dumps. No UUIDs unless asked.
15
-
16
- > **Sandbox contract:** This skill honors `RDC_TEST=1` per `guides/agent-bootstrap.md` § RDC_TEST Sandbox Contract. Destructive external calls short-circuit under the flag.
17
- >
18
- > *Under `$RDC_TEST=1`:* Modes 1 (deploy), 2 (new), and 5 (promote) are **entirely skipped** — echo `[RDC_TEST] skipping Coolify deploy/create/promote` and mark every `[ ]` line in those checklists as `[~]`. Modes 3 (diagnose) and 4 (audit without `--fix`) are **read-only and run normally**. Mode 4 with `--fix` skips all remediation — echo `[RDC_TEST] skipping audit --fix remediation` and report findings only. Registry SELECTs, Coolify status reads, HTTP gate probes, TLS checks, and DNS lookups are NOT destructive and run normally. Anything that writes (create app, set watch_paths, deploy trigger, **PR/admin-merge to main**, env var write, DNS write, CF cache purge, registry UPDATE/INSERT) is gated.
19
-
20
- ## When to Use
21
- - Project lead says "deploy", "ship it", "push to production", "update the server"
22
- - Project lead says "promote", "patch prod", "hotfix production", "push this fix live" — use `rdc:deploy <slug> promote`
23
- - Dev is a Next.js app but prod is still a static site for the same slug ("dev is XJS, prod is static", "make prod Next") — use `rdc:deploy <slug> convert` (Mode 6)
24
- - A new app needs to be registered and deployed for the first time (`rdc:deploy new`)
25
- - A deployed app is behaving unexpectedly and needs diagnosis (`rdc:deploy diagnose`)
26
- - Running a compliance/health audit of all deployed apps (`rdc:deploy audit`)
27
-
28
- ## Arguments
29
-
30
- - `rdc:deploy <slug>` — deploy existing app (latest commit on its watched branch)
31
- - `rdc:deploy <slug> <build-id>` — deploy specific commit/tag
32
- - `rdc:deploy <slug> promote` — promote the verified `develop` change for this app to production (Mode 5)
33
- - `rdc:deploy <slug> promote --hotfix <sha>` — promote a specific commit (cherry-pick just that sha to `main`)
34
- - `rdc:deploy <slug> convert` — convert a prod app's runtime in place from static→Next (Mode 6); use when dev is Next but prod is still a static Coolify build
35
- - `rdc:deploy new <slug>` — create a new Coolify app from registry
36
- - `rdc:deploy diagnose <slug>` — debug why an app is broken
37
- - `rdc:deploy audit` — fleet-wide scan for missed failures
38
- - `rdc:deploy audit --fix` — fleet scan + auto-remediate safe issues
39
- - `rdc:deploy` (no args) — print mode menu, ask which
40
-
41
- ## Modes
42
-
43
- ### Mode 1 — deploy <slug> [build-id]
44
-
45
- ```
46
- rdc:deploy: <slug> → <domain>
47
- [ ] Registry lookup (slug, uuid, branch, type, env_vars_needed)
48
- [ ] Runtime-source guard: if the source being deployed is static (`sites/<name>`, nixpacks/static) but the registry `runtime` for <slug> is `next`, BLOCK — never deploy a flat/static source over a Next app. A static prototype deploys only under its OWN slug (never an existing Next app's slug), dev only. Changing a slug's runtime is architectural. (`.claude/rules/production-stack-nextjs.md`)
49
- [ ] Git state verified (branch matches Coolify, commit pushed)
50
- [ ] Build-id resolved (default: HEAD of watched branch)
51
- [ ] Env vars present in Coolify (compare to registry)
52
- [ ] Type-specific preflight (see docs/runbooks/coolify-deploy-checklist.md)
53
- [ ] Mandatory pre-deploy code-review (pr-review-toolkit:code-reviewer on `git diff <last-deployed-sha>..HEAD` for this app's paths). Block deploy on `critical`/`high` findings; record `medium`/`low` and proceed.
54
- [ ] PUBLISH.md read from app root (warn if absent; fail if present but invalid)
55
- [ ] watch_paths derived from PUBLISH.md surfaces (union of all surface watch_paths arrays) and updated in app_deployments
56
- [ ] Deploy triggered
57
- [ ] Deployment reached "finished" state
58
- [ ] Gate: HTTP 200
59
- [ ] Gate: TLS valid (no SSL cipher mismatch)
60
- [ ] Gate: cache headers correct on HTML
61
- [ ] Gate: container running on declared port
62
- [ ] Gate: metadata audit (see § Metadata Audit below) — warn on gaps, do not block deploy
63
- [ ] Cloudflare cache purged (if proxied)
64
- [ ] artifact_registry INSERT per PUBLISH.md surface (if PUBLISH.md present)
65
- [ ] deployment_registry updated (last_deploy_at, status)
66
- ✅ rdc:deploy: <slug> deployed in Nm Ns
67
- ```
68
-
69
- #### Static PM2 dev sites — do NOT trust the push webhook (SSH-reset + served-hash gate)
70
-
71
- For a **static** PM2 dev site, `git push` succeeding does NOT mean the live site
72
- updated: the push webhook skips/instruments static apps unreliably, so the host
73
- working tree (and the committed `dist/` it serves) can stay STUCK across several
74
- pushes while HTTP stays 200 and `origin/develop` looks shipped (lesson
75
- 2026-06-11-deploy-static-host-stuck: issho served the v1.10.0 bundle across three
76
- pushes because `/srv/regen/regen-root` never pulled). The only signal is the
77
- SERVED bundle hash vs the local `dist/` hash.
78
-
79
- After pushing committed `dist/`, SSH-reset the host explicitly, then verify the
80
- served hash:
81
- ```bash
82
- _K=$(mktemp); curl -s http://127.0.0.1:52437/v/vultr-dev-ssh > "$_K"; printf '\n' >> "$_K"; chmod 600 "$_K"
83
- ssh -i "$_K" root@64.237.54.189 'cd /srv/regen/regen-root && git fetch -q origin develop && git reset -q --hard origin/develop && pm2 restart <app> --update-env'
84
- rm -f "$_K"
85
- # Then verify SERVED hash == local build hash (HTTP 200 is NOT proof):
86
- curl -s https://<app>.dev.place.fund/ | grep -oE 'index-[A-Za-z0-9_-]+\.js' # served
87
- grep -oE 'index-[A-Za-z0-9_-]+\.js' sites/<app>/dist/index.html # local
88
- ```
89
- Only when the two hashes match do the content/screenshot gates mean anything.
90
-
91
- ### Mode 2 — new <slug>
92
-
93
- **MANDATORY:** All new apps are created from `docs/runbooks/coolify-app-templates.json`. Read that file first — pick the right template, substitute the required vars, POST the exact payload. No manual field configuration. No improvisation. The template encodes all learned lessons (base_directory, build_pack, watch_paths, health_check, ports). Deviating from it breaks things.
94
-
95
- Template selection:
96
- - `nextjs-app` — for `apps/<name>/` (Dockerfile, turbo filter, port 3000)
97
- - `static-site` — for `sites/<name>/` (nixpacks, publish_directory=out, no packages)
98
- - `mcp-server` — for `mcp-servers/<name>/` (Dockerfile, health check enabled, custom port)
99
-
100
- ```
101
- rdc:deploy new: <slug>
102
- [ ] .dockerignore present at project root (`ls {PROJECT_ROOT}/.dockerignore` — STOP if missing)
103
- [ ] Template loaded from docs/runbooks/coolify-app-templates.json (pick nextjs-app / static-site / mcp-server)
104
- [ ] Required vars substituted: NAME, APP_PATH, DOMAIN, BRANCH, PROJECT_UUID, ENVIRONMENT_UUID [+ TURBO_FILTER / PORT]
105
- [ ] DNS path chosen (A: staging wildcard B: apex C: other zone)
106
- [ ] DNS record verified or wildcard confirmed
107
- [ ] Cloudflare proxy setting correct for DNS path
108
- [ ] Application created via POST /applications/private-github-app (template payload)
109
- [ ] UUID recorded from response
110
- [ ] watch_paths verified via GET /api/v1/applications/<uuid> — must match template
111
- [ ] Env vars set in Coolify (from deployment_registry.env_vars_needed)
112
- [ ] First deploy triggered
113
- [ ] Deployment reached "finished" state
114
- [ ] Gate: HTTP 200 on <domain>
115
- [ ] Gate: TLS valid
116
- [ ] deployment_registry row inserted
117
- ✅ rdc:deploy new: <slug> live at <domain>
118
- ```
119
-
120
- ### Mode 3 — diagnose <slug>
121
-
122
- ```
123
- rdc:deploy diagnose: <slug>
124
- [ ] App located (uuid, domain, last deploy)
125
- [ ] Container state (running / restarting / stopped)
126
- [ ] Last 100 log lines scanned for known error patterns
127
- [ ] Port mismatch check (declared vs actual)
128
- [ ] Env var drift check (registry vs Coolify)
129
- [ ] watch_paths sanity check
130
- [ ] HTTP / TLS reachability
131
- [ ] Cloudflare proxy state check
132
- [ ] Disk space on server
133
- [ ] Branch mismatch check (Coolify git_branch vs expected)
134
- ⚠️ rdc:deploy diagnose: <root cause in one sentence> — fix: <one command>
135
- ```
136
-
137
- #### `next start` crash-loop — check `.next/BUILD_ID` FIRST
138
-
139
- When a PM2 `next start` app is crash-looping (`pm2 jlist` shows high `restart_time`
140
- / "waiting restart"), check **`.next/BUILD_ID`** before chasing env/port theories —
141
- it is the single gate `next start` enforces (`Could not find a production build in
142
- the '.next' directory` repeats for every restart when it is missing). A `.next`
143
- tree can exist as a PARTIAL build (manifests + server artifacts but no `BUILD_ID`)
144
- — the signature of a `next build` that started but was interrupted/OOM-killed; the
145
- crash loop then churns CPU and can re-trigger the OOM (lesson
146
- 2026-06-13-deploy-nextstart-missing-buildid: ~7300 restarts, all `BUILD_ID`-missing,
147
- while the documented prior lead was the NEXT_PUBLIC env issue — which was NOT the
148
- cause here). Fix sequence:
149
- ```bash
150
- pm2 stop <app> # halt the loop so it stops competing for resources
151
- rm -rf apps/<name>/.next # clear the partial tree
152
- pnpm --filter @regen/<name> build # ONE clean scoped build (LOCAL BUILD SAFETY)
153
- pm2 restart <app> --update-env
154
- ```
155
- Guard: assert `apps/<name>/.next/BUILD_ID` exists before (re)starting any
156
- `next start` process.
157
-
158
- #### ⛔ On the 2nd IDENTICAL failed probe, STOP polling and diagnose full-state
159
-
160
- A repeated identical failure (same 404, same 502, same non-200) is a STRUCTURAL
161
- signal, not eventual-consistency — polling it will never clear it and reads to the
162
- user as stalling/throttling (lesson 2026-06-10-deploy-stop-polling-diagnose: a
163
- 12×-404 poll loop on a remote-managed tunnel that was structurally ignoring the
164
- local config — it could never clear). On the **2nd identical failed check**, stop
165
- polling and pull GROUND TRUTH instead: list ALL processes / the whole effective
166
- remote config / the full event log (`debugging-protocol.md` Rule 6 — full-state, not
167
- a narrow filter). Poll ONLY when something is genuinely in-progress with a known
168
- finish (a deploy build, a DNS first-create) — never to hope a structural error
169
- resolves itself.
170
-
171
- ### Mode 4 — audit
172
-
173
- ```
174
- rdc:deploy audit: fleet scan
175
- [ ] Inventory join: Coolify apps ⋈ deployment_registry
176
- [ ] Orphans (in one but not the other)
177
- [ ] Monorepo apps missing watch_paths
178
- [ ] Stale deploys (>14 days since last success)
179
- [ ] Registry rows with status='broken'
180
- [ ] Failed deployments in last 7 days
181
- [ ] HTTP gate sweep (non-200 per domain)
182
- [ ] TLS cert expiry <30 days
183
- [ ] Port mismatches (ports_exposes vs actual container port)
184
- [ ] Env var drift (registry.env_vars_needed vs Coolify env)
185
- [ ] Branch mismatches (Coolify git_branch ≠ expected)
186
- [ ] Disk space on 64.237.54.189
187
- [ ] DNS/proxy misconfigs on configured staging wildcard
188
- [ ] Duplicate apps (same repo, multiple UUIDs)
189
-
190
- Findings:
191
- | Severity | App | Issue | Fix |
192
- |----------|-----|-------|-----|
193
- | HIGH | ... | ... | ... |
194
- ⚠️ rdc:deploy audit: N HIGH · M MED · K LOW — run `rdc:deploy audit --fix` to auto-remediate safe issues
195
- ```
196
-
197
- Severity rules:
198
- - **HIGH** — user-facing down (HTTP non-200, TLS invalid, container not running)
199
- - **MED** — degraded or drifting (watch_paths missing, env var drift, stale deploy, branch mismatch)
200
- - **LOW** — cleanup (orphans, duplicates, registry status stale)
201
-
202
- `--fix` auto-remediates only: missing watch_paths, registry row updates, CF cache purges. Never touches env vars, DNS, or container config without explicit confirmation.
203
-
204
- ### Mode 5 — promote <slug> [--hotfix <sha>]
205
-
206
- Promote a **verified `develop` change** for one app to production. This is the sanctioned production-patch fast path — one command instead of fighting branch protection, the main-push hook, and a flaky Coolify webhook by hand.
207
-
208
- **Authorization:** production promote requires explicit user go-ahead ("promote", "patch prod", "push live", "go"). A dev deploy does NOT. If the user has not given it for THIS promote, stop and ask first.
209
-
210
- ```
211
- rdc:deploy promote: <slug> → <prod-domain>
212
- [ ] Registry lookup: PROD row (uuid, prod branch=main, watch_paths, url)
213
- [ ] PUBLISH.md status gate: status=active AND prod in environments (block if not)
214
- [ ] Runtime-source guard: BLOCK promoting a static source to a prod row whose intended `runtime` is `next` — production apps are Next.js (DB-backed). (`.claude/rules/production-stack-nextjs.md`)
215
- [ ] Scope resolved: --hotfix <sha> → that commit; else the app-path commits on develop not on main
216
- [ ] Scope guard: promote ONLY this app's paths. NEVER merge develop→main wholesale (drags unrelated WIP to prod)
217
- [ ] Clean worktree off origin/main (never switch the dirty working tree)
218
- [ ] Export Supabase creds in the worktree BEFORE commit (the pre-commit `sync:docs` hook needs them, and a fresh worktree does NOT inherit the main checkout's `.env*`/shell env — without the key sync:docs silently regenerates a GUTTED `app-deployments.md` ("Registry: unavailable") and `git add`s it into the surgical promote; `--no-verify` is forbidden):
219
- `export NEXT_PUBLIC_SUPABASE_URL=https://uvojezuorjgqzmhhgluu.supabase.co`
220
- `export NEXT_PUBLIC_SUPABASE_ANON_KEY=$(curl -s http://127.0.0.1:52437/v/supabase-anon)`
221
- Then ALWAYS `git show --stat HEAD` before pushing and confirm the file set is scoped — never ship the no-key gutted app-deployments.md. (lesson 2026-06-13-deploy-worktree-syncdocs-guts-app-deployments)
222
- [ ] Apply change: cherry-pick <sha> (or `git checkout <develop-sha> -- <app-paths>`); confirm diff = expected files only
223
- [ ] Mandatory pre-promote code-review (pr-review-toolkit:code-reviewer on the promote diff). Block on critical/high.
224
- [ ] Commit on promote branch; push branch (NOT main directly — the main-push hook blocks raw main pushes)
225
- [ ] Open PR base=main; merge with admin override (`gh pr merge --squash --admin --delete-branch`) — branch protection needs --admin
226
- [ ] EXPLICITLY trigger Coolify deploy — NEVER rely on the GitHub→Coolify webhook (it silently no-ops on some merges even with auto-deploy ON)
227
- [ ] Deployment reached "finished" state (poll coolify_events / deployment status)
228
- [ ] Gate: HTTP 200 on prod domain
229
- [ ] Gate: content-level check — assert the actual changed string is live (200 alone is NOT proof; origin may serve stale)
230
- [ ] Gate: TLS valid
231
- [ ] Gate: metadata audit (see § Metadata Audit below) — BLOCK promote on any missing required item
232
- [ ] Cloudflare cache purged (if proxied)
233
- [ ] deployment_registry updated (last_deploy_at, last_deploy_commit, status)
234
- ✅ rdc:deploy promote: <slug> live in prod — <changed-string> verified
235
- ```
236
-
237
- **The explicit Coolify trigger (the whole point — do not skip):**
238
- ```bash
239
- _COOLIFY=$(curl -s http://127.0.0.1:52437/v/coolify-api)
240
- # Correct endpoint is GET /api/v1/deploy?uuid= — NOT POST /applications/<uuid>/deploy (that 404s)
241
- curl -s -H "Authorization: Bearer $_COOLIFY" \
242
- "$DEPLOY_API_BASE/api/v1/deploy?uuid=<PROD_UUID>&force=true"
243
- # → {"deployments":[{"deployment_uuid":"...","message":"...deployment queued."}]}
244
- ```
245
-
246
- **Why each guard exists (lessons from 2026-06-05 EF Hooper promote):**
247
- - `main` branch protection rejects PR merge without `--admin`; a raw `git push …:main` is blocked by the main-push hook → must go branch → PR → admin-merge.
248
- - Coolify auto-deploy was **ON** for the app yet the merge did **not** auto-deploy — a webhook-delivery flake. A promote must ALWAYS trigger the deploy explicitly and verify; never hope the webhook fired.
249
- - `develop` was 87 commits ahead of `main` (unrelated apps' WIP). Promoting must be surgical (this app's paths / one sha), never a develop→main merge.
250
- - HTTP 200 was returned by the stale origin the whole time — only a content-level assertion (`curl … | grep '<new string>'`) proves the promote landed.
251
-
252
- ### Mode 6 — convert <slug> (prod runtime static→Next, in place)
253
-
254
- One-time conversion of an existing **production** Coolify app from a static/nixpacks build to the Next.js (dockerfile) runtime, **in place** — same UUID, same fqdn, same DNS. After this, the slug is a normal Next app and every future ship is just `rdc:deploy <slug> promote`. This is the mode for "dev is Next, prod is still static" (the `vlas.earth` / `life.ai` class). `promote` alone CANNOT do this — it re-triggers the prod app's existing (static) build; only `convert` changes the build config.
255
-
256
- **Authorization:** this changes a production app's runtime — **architectural** per `.claude/rules/production-stack-nextjs.md` + `.claude/rules/architectural-change-approval.md`. Requires explicit user go-ahead for THIS slug. Stop and ask if not given.
257
-
258
- **Why in-place PATCH, not recreate:** keeps the application UUID, fqdn (apex + `www`), DNS binding, and project — zero DNS cutover, zero window where the domain is unbound. PATCH switches the build pack; the next deploy builds the Next app on the same application object.
259
-
260
- ```
261
- rdc:deploy convert: <slug> → <prod-domain> (static→Next, in place)
262
- [ ] Go-ahead confirmed for THIS slug (production runtime change — architectural)
263
- [ ] Registry lookup: PROD coolify_uuid, fqdn, project/env, current build_pack
264
- [ ] Confirm it IS a convert: dev runtime=next AND prod build_pack ∈ {static, nixpacks}. If prod build_pack already `dockerfile` → already converted, fall through to Mode 5 promote.
265
- [ ] Source Next-prod-readiness preflight (BLOCK on any miss):
266
- - apps/<name>/Dockerfile present (prod build) — BLOCK "source not Next-prod-ready: add Dockerfile" if missing
267
- - start/listen port reconciled with intended ports_exposes (no dev-only port like :3214 leaking to prod)
268
- - PUBLISH.md present, build_type=nextjs, status=active, prod in environments
269
- [ ] Mandatory pre-convert code-review (pr-review-toolkit:code-reviewer on the apps/<name> diff being promoted). Block on critical/high.
270
- [ ] Promote app code to main (Mode 5 path): clean worktree off origin/main → checkout apps/<name> paths (or cherry-pick) → confirm diff = expected files only → branch → PR base=main → `gh pr merge --squash --admin --delete-branch`
271
- [ ] LOCKFILE IMPORTER (mandatory for an app NEW to main): if `git show origin/main:pnpm-lock.yaml | grep -c "apps/<name>:"` is 0, the Docker `pnpm install --frozen-lockfile` WILL fail. In the clean main worktree run `pnpm install --lockfile-only` (adds only the apps/<name> importer; verify the diff is minimal — do NOT promote develop's whole lockfile, it carries unrelated drift) and include `pnpm-lock.yaml` in the SAME promote PR.
272
- [ ] In-place PATCH prod Coolify app <uuid> to nextjs-app template fields:
273
- build_pack=dockerfile · dockerfile_location=/apps/<name>/Dockerfile · base_directory=/ ·
274
- build_command="pnpm turbo run build --filter=<pkg>" · start_command="pnpm --filter=<pkg> start" ·
275
- install_command="pnpm install --frozen-lockfile" · ports_exposes=<port> ·
276
- watch_paths="apps/<name>/**\npackages/**"
277
- (clear publish_directory; static-only field)
278
- [ ] PROXY LABELS (mandatory — the static-app `custom_labels` are read-only and do NOT auto-update on a build_pack PATCH): fetch `custom_labels`, base64-decode, rewrite `loadbalancer.server.port=80`→`=<port>` (all routers) AND `upstreams 80}}`→`upstreams <port>}}`, and STRIP the static `caddy_*.try_files=…/index.html /index.php` lines (wrong for a Next app), re-base64, PATCH `custom_labels`. Skipping this = container serves on :<port> but the proxy still routes :80 → **502** even though the build "finished" and the app logs "Ready". (Note: the API rejects `is_container_label_readonly_enabled` and a non-base64 `custom_labels` — you must hand-rewrite the base64.)
279
- [ ] Env vars present in Coolify (compare registry.env_vars_needed); set any missing
280
- [ ] EXPLICITLY trigger deploy: GET /api/v1/deploy?uuid=<PROD_UUID>&force=true — never rely on the webhook
281
- [ ] Deployment reached "finished" state (poll coolify_events)
282
- [ ] Gate: SSR proof — prod HTML now contains `/_next/static` (it is the Next app, not the old static build)
283
- [ ] Gate: HTTP 200 + content-level assertion of a known string from the Next render
284
- [ ] Gate: TLS valid; cache headers correct on HTML
285
- [ ] Gate: metadata audit (see § Metadata Audit) — BLOCK on any required gap
286
- [ ] Cloudflare cache purged (apex + www)
287
- [ ] app_deployments updated: runtime intent → next, notes (converted static→Next <date>), last_deploy_at, last_deploy_commit, status=active
288
- [ ] `apps` row runtime=next confirmed
289
- ✅ rdc:deploy convert: <slug> now Next.js in prod — SSR verified at <prod-domain>
290
- ```
291
-
292
- **Rollback:** PATCH the app back to static (`build_pack=static`, `base_directory=/sites/<name>`, `publish_directory=/sites/<name>`, `ports_exposes=80`, `watch_paths=sites/<name>/**`) and redeploy. `sites/<name>` stays in the repo precisely so this rollback is always available.
293
-
294
- **PATCH command (in-place build-pack switch):**
295
- ```bash
296
- _COOLIFY=$(curl -s http://127.0.0.1:52437/v/coolify-api)
297
- curl -s -X PATCH -H "Authorization: Bearer $_COOLIFY" -H "Content-Type: application/json" \
298
- -d '{"build_pack":"dockerfile","dockerfile_location":"/apps/<name>/Dockerfile","base_directory":"/","build_command":"pnpm turbo run build --filter=<pkg>","start_command":"pnpm --filter=<pkg> start","install_command":"pnpm install --frozen-lockfile","ports_exposes":"<port>","watch_paths":"apps/<name>/**\npackages/**"}' \
299
- "$DEPLOY_API_BASE/api/v1/applications/<PROD_UUID>"
300
- ```
301
-
302
- ## Metadata Audit
303
-
304
- After a successful deploy or promote, audit the live site's `<head>` metadata. Run this against the deployed URL (not a local file).
305
-
306
- **Check list** (curl the deployed URL, parse the HTML `<head>`):
307
-
308
- | # | Item | How to check | Required | Severity |
309
- |---|------|-------------|----------|----------|
310
- | 1 | `<title>` | grep `<title>` — non-empty, not default/placeholder | yes | block |
311
- | 2 | `<meta name="description">` | content attr ≥ 50 chars, ≤ 160 chars | yes | block on promote; warn on deploy |
312
- | 3 | `<link rel="canonical">` | href present, starts with `https://`, matches the deployed domain | yes | block on promote; warn on deploy |
313
- | 4 | `og:title` | `<meta property="og:title">` present | yes | block on promote; warn on deploy |
314
- | 5 | `og:description` | `<meta property="og:description">` present, ≥ 50 chars | yes | block on promote; warn on deploy |
315
- | 6 | `og:image` | `<meta property="og:image">` present, URL returns HTTP 200 | yes | block on promote; warn on deploy |
316
- | 7 | `og:url` | present, matches canonical | recommended | warn |
317
- | 8 | `twitter:card` | `summary_large_image` or `summary` | recommended | warn |
318
- | 9 | `twitter:image` | present, URL returns HTTP 200 | recommended | warn |
319
- | 10 | Favicon | `<link rel="icon">` present, href returns HTTP 200 | yes | block on promote; warn on deploy |
320
- | 11 | `sitemap.xml` | `<domain>/sitemap.xml` returns HTTP 200 | recommended | warn |
321
- | 12 | `robots.txt` | `<domain>/robots.txt` returns HTTP 200 | recommended | warn |
322
- | 13 | Version | `<meta name="version">` or visible version string in page | yes (project rule) | warn |
323
-
324
- **Behavior by mode:**
325
- - **Mode 1 (deploy):** run the audit after HTTP/TLS gates. Print a table of results. **Warn** on gaps but do NOT block the deploy — dev deploys are iterative.
326
- - **Mode 5 (promote):** run the audit after content-level check. **Block the promote** if any item marked "block on promote" is missing. Print the table and require the metadata to be fixed before re-attempting.
327
- - **Mode 4 (audit):** include the metadata sweep in the fleet scan (one row per app per missing item in the findings table).
328
-
329
- **Implementation — one-liner per check:**
330
- ```bash
331
- URL="https://<domain>"
332
- HEAD=$(curl -s "$URL" | sed -n '/<head/,/<\/head>/p')
333
- echo "$HEAD" | grep -ioE '<title>[^<]+</title>'
334
- echo "$HEAD" | grep -ioE 'name="description"[^>]*content="[^"]*"'
335
- echo "$HEAD" | grep -ioE 'rel="canonical"[^>]*href="[^"]*"'
336
- echo "$HEAD" | grep -ioE 'property="og:title"[^>]*content="[^"]*"'
337
- echo "$HEAD" | grep -ioE 'property="og:image"[^>]*content="[^"]*"'
338
- echo "$HEAD" | grep -ioE 'name="twitter:card"[^>]*content="[^"]*"'
339
- echo "$HEAD" | grep -ioE 'rel="icon"[^>]*href="[^"]*"'
340
- echo "$HEAD" | grep -ioE 'name="version"[^>]*content="[^"]*"'
341
- curl -s -o /dev/null -w "%{http_code}" "$URL/sitemap.xml"
342
- curl -s -o /dev/null -w "%{http_code}" "$URL/robots.txt"
343
- OG_IMG=$(echo "$HEAD" | grep -ioE 'property="og:image"[^>]*content="([^"]*)"' | grep -ioE 'https://[^"]*')
344
- [ -n "$OG_IMG" ] && curl -s -o /dev/null -w "%{http_code}" "$OG_IMG"
345
- ```
346
-
347
- **Why this exists (2026-06-05):** life.ai deployed to production with zero social/SEO metadata — no description, no OG, no favicon, no sitemap. HTTP 200 + TLS + content passed; metadata was invisible to the existing gates. This audit catches that class of defect.
348
-
349
- ## Hotlink referer allowlist — new media.place.fund consumer domains
350
-
351
- When onboarding a NEW brand domain that loads assets from `media.place.fund`, add
352
- that domain to the hotlink-protection Worker's referer allowlist **before
353
- go-live** (lesson 2026-06-14-deploy-issholiving-referer-hotlink-403: a new brand
354
- domain served its own HTML fine but every `media.place.fund` image returned 403
355
- because the hotlink Worker rejected the unknown `Referer`). The image 403 is
356
- invisible to the HTTP-200 / TLS / metadata gates — they probe the HTML document,
357
- not the cross-origin asset.
358
-
359
- - Add the new origin (apex + `www`, dev + prod) to the Worker's referer allowlist
360
- and redeploy the Worker before the brand domain goes live.
361
- - Verify with an explicit `Referer` probe (NOT a bare curl — a missing Referer can
362
- pass while the real browser Referer fails):
363
- ```bash
364
- curl -s -o /dev/null -w "%{http_code}" -e "https://<new-brand-domain>/" \
365
- "https://media.place.fund/<a-known-asset-path>" # expect 200, not 403
366
- ```
367
-
368
- ## PUBLISH.md Integration
369
-
370
- Every deploy reads `PUBLISH.md` from the app's source root to derive `watch_paths` and to register surfaces in Studio `artifact_registry`.
371
-
372
- ### Step 6 — Read PUBLISH.md from the app root
373
-
374
- ```bash
375
- MONOREPO_PATH=$(get_app_deployments_monorepo_path "$SLUG")
376
- PUBLISH_MD="$MONOREPO_PATH/PUBLISH.md"
377
-
378
- if [ ! -f "$PUBLISH_MD" ]; then
379
- echo "WARN: PUBLISH.md missing for $SLUG — using app_deployments.watch_paths only"
380
- # Deploy continues; watch_paths derivation and artifact_registry INSERT are skipped
381
- fi
382
- ```
383
-
384
- PUBLISH.md format: see `C:/Dev/rdc-skills/guides/publish-md-spec.md` (authoritative).
385
-
386
- Required frontmatter fields: `schema_version`, `entity_slug`, `artifact_type`, `environments`, `status`.
387
- One or more `<!-- SURFACE:<id> -->` … `<!-- /SURFACE:<id> -->` blocks per surface (each with `path`, `source_dir`, `build_type`, `visibility`, `cache`, `watch_paths`).
388
-
389
- If PUBLISH.md is **present but invalid** (missing required field, bad enum, no surface blocks): abort deploy with `BLOCKED: PUBLISH.md parse error for <slug> — <reason>`.
390
-
391
- ### Step 7 — Derive watch_paths from PUBLISH.md surfaces
392
-
393
- Union all `watch_paths` arrays across every surface section in PUBLISH.md. Update `app_deployments.watch_paths` for the app slug to this derived union before triggering the Coolify deploy.
394
-
395
- ```sql
396
- UPDATE app_deployments
397
- SET watch_paths = '<union-of-surface-watch_paths>'
398
- WHERE app_slug = '<slug>';
399
- ```
400
-
401
- Also PATCH the Coolify application's `watch_paths` field:
402
-
403
- ```bash
404
- _COOLIFY=$(curl -s http://127.0.0.1:52437/v/coolify-api)
405
- WATCH_PATHS_JSON=$(derive_watch_paths_union "$PUBLISH_MD")
406
- curl -s -X PATCH -H "Authorization: Bearer $_COOLIFY" \
407
- -H "Content-Type: application/json" \
408
- -d "{\"watch_paths\":\"$WATCH_PATHS_JSON\"}" \
409
- "$DEPLOY_API_BASE/api/v1/applications/<uuid>"
410
- ```
411
-
412
- ### Step 15 — storeArtifact per surface (after successful deploy)
413
-
414
- After the deployment reaches "finished" state, INSERT one row into Studio `artifact_registry` for each surface declared in PUBLISH.md:
415
-
416
- | Column | Value |
417
- |--------|-------|
418
- | `entity_slug` | from PUBLISH.md frontmatter `entity_slug` |
419
- | `artifact_type` | from PUBLISH.md frontmatter `artifact_type` |
420
- | `canonical_url` | `https://<app_deployments.url><surface.path>` |
421
- | `surface_id` | surface name from `<!-- SURFACE:<id> -->` marker |
422
- | `commit_sha` | HEAD SHA of the deploy |
423
- | `published_at` | `now()` |
424
-
425
- Use the Supabase MCP (`mcp__claude_ai_Supabase__execute_sql`) from the supervisor session:
426
-
427
- ```sql
428
- INSERT INTO artifact_registry (entity_slug, artifact_type, canonical_url, surface_id, commit_sha, published_at)
429
- VALUES ('<entity_slug>', '<artifact_type>', 'https://<url><path>', '<surface_id>', '<commit_sha>', now())
430
- ON CONFLICT (entity_slug, surface_id) DO UPDATE SET
431
- canonical_url = EXCLUDED.canonical_url,
432
- commit_sha = EXCLUDED.commit_sha,
433
- published_at = EXCLUDED.published_at;
434
- ```
435
-
436
- If the INSERT fails, surface the failure in the deploy output but **do NOT roll back the deploy**. The artifact registry is a post-deploy record, not a deploy gate.
437
-
438
- ## Coolify Access — clauth + REST API
439
-
440
- All Coolify operations use the clauth daemon and the Coolify REST API directly.
441
- There is no Coolify MCP server. Do not reference `@masonator/coolify-mcp`.
442
-
443
- ```bash
444
- # Get token (plain text — no JSON parsing needed)
445
- _COOLIFY=$(curl -s http://127.0.0.1:52437/v/coolify-api)
446
-
447
- # List applications
448
- curl -s -H "Authorization: Bearer $_COOLIFY" \
449
- "$DEPLOY_API_BASE/api/v1/applications"
450
-
451
- # Get application details
452
- curl -s -H "Authorization: Bearer $_COOLIFY" \
453
- "$DEPLOY_API_BASE/api/v1/applications/<uuid>"
454
-
455
- # Deploy (trigger) — correct endpoint is GET /api/v1/deploy?uuid=
456
- # (POST /applications/<uuid>/deploy returns {"message":"Not found."})
457
- curl -s -H "Authorization: Bearer $_COOLIFY" \
458
- "$DEPLOY_API_BASE/api/v1/deploy?uuid=<uuid>&force=true"
459
-
460
- # Get deployment logs
461
- curl -s -H "Authorization: Bearer $_COOLIFY" \
462
- "$DEPLOY_API_BASE/api/v1/deployments/<deployment-id>"
463
-
464
- # Set env var
465
- curl -s -X POST -H "Authorization: Bearer $_COOLIFY" \
466
- -H "Content-Type: application/json" \
467
- -d '{"key":"<KEY>","value":"<VALUE>"}' \
468
- "$DEPLOY_API_BASE/api/v1/applications/<uuid>/envs"
469
-
470
- # Set watch_paths
471
- curl -s -X PATCH -H "Authorization: Bearer $_COOLIFY" \
472
- -H "Content-Type: application/json" \
473
- -d '{"watch_paths":"apps/<name>/**\npackages/**"}' \
474
- "$DEPLOY_API_BASE/api/v1/applications/<uuid>"
475
-
476
- # Change the app's domain — the writable field is "domains", NOT "fqdn"
477
- # (Coolify v4 PATCH rejects {"fqdn":...} with "This field is not allowed"; fqdn is read-only/derived)
478
- curl -s -X PATCH -H "Authorization: Bearer $_COOLIFY" \
479
- -H "Content-Type: application/json" \
480
- -d '{"domains":"https://<host>"}' \
481
- "$DEPLOY_API_BASE/api/v1/applications/<uuid>"
482
- ```
483
-
484
- **Domain change / namespace migration** (lesson 2026-06-13-deploy-media-manager-namespace-migration):
485
- PATCH `applications/<uuid>` with `{"domains":"https://<host>"}` — never `fqdn`.
486
- For an app NEW to main (first prod deploy) also bring the app + a lockfile importer
487
- to main (clean worktree off origin/main, `pnpm install --lockfile-only`, verify the
488
- diff = additions for `apps/<name>:` only); resolve the lockfile-guard vs scope-guard
489
- collision by committing the app+lockfile as a SINGLE `chore(infra):`-subject commit
490
- (the scope-guard's documented escape hatch) so both pre-commit guards pass.
491
-
492
- **Never print `$_COOLIFY` to stdout.** Inline from clauth only — do not assign raw strings.
493
-
494
- If clauth daemon is not responding (`curl -s http://127.0.0.1:52437/ping` fails):
495
- ```
496
- BLOCKED: credential provider is not responding.
497
- Fix: start the project's credential provider or configure deployment credentials through env vars, then retry.
498
- I cannot proceed until this is resolved.
499
- ```
500
-
501
- ## Deployment Event Log — `coolify_events`
502
-
503
- Every Coolify deploy emits a webhook → `coolify_events` row. Use this for last-N-deploys queries, debugging failed deploys, and reconciling local state with Coolify state.
504
-
505
- Query the last 5 events for an app:
506
- ```sql
507
- SELECT created_at, event_type, status, branch, commit_hash, duration_seconds
508
- FROM coolify_events
509
- WHERE app_uuid = '<uuid>' OR app_name = '<slug>'
510
- ORDER BY created_at DESC LIMIT 5;
511
- ```
512
-
513
- Fields:
514
- - `app_uuid` — Coolify application UUID (matches `app_deployments.coolify_uuid`)
515
- - `event_type` — `started | succeeded | failed | cancelled` (canonical values; consult webhook receiver for full enum)
516
- - `status` — overall deploy status
517
- - `branch`, `commit_hash`, `commit_message` — git context
518
- - `duration_seconds` — total deploy time
519
- - `payload` — full webhook payload (jsonb) for forensic debugging
520
-
521
- When diagnosing a broken deploy in Mode 3: query `coolify_events` FIRST before re-running the deploy — the most recent event row tells you whether the previous deploy actually triggered, whether it failed, and how long it ran. Faster than checking the Coolify UI.
522
-
523
- ## Capture lessons (exit step)
524
-
525
- Before the final verdict line, follow `.rdc/guides/lessons-learned-spec.md` § Capture procedure. If this run taught something non-obvious — a first root-cause theory that turned out wrong, the documented/standard path not working, a missing gate or check that cost a round, or a surprising tool/infra behavior — write one `.rdc/lessons/<YYYY-MM-DD>-deploy-<short-slug>.md` per lesson using the schema in that spec. Set `scope` (`simple` | `architectural`) and `status` (`open`, or `applied` if you shipped the fix in this same run, with the commit linked). Commit the lesson file(s) on `develop` alongside the run's other commits, and note "N lessons captured" in your verdict/summary. A run that taught nothing writes nothing — absence is the default.
526
-
527
- ## References
528
-
529
- - Type-specific checklists + DNS tree + gate commands: `docs/runbooks/coolify-deploy-checklist.md`
530
- - Rules / registry RPCs / hard limits: `.claude/context/coolify-deployment.md`
531
- - Infrastructure constants:
532
- ```
533
- Server UUID: ih386anenvvvn6fy1umtyow0
534
- Server IP: 64.237.54.189
535
- Dashboard: <deployment-dashboard-url>
536
- GitHub App UUID: xdmcy60putp5h9j7k4kwg9c3
537
- ```
538
-
539
- ## Supersedes
540
-
541
- `coolify-deploy` standalone skill (kept for back-compat; new work uses `rdc:deploy`).
1
+ ---
2
+ name: rdc:deploy
3
+ description: "Usage `rdc:deploy <slug> [new|diagnose|audit|promote|convert] [--fix|--hotfix <sha>]` or `rdc:deploy maintenance <service>` — Deploy applications or operate a private Coolify infrastructure service with guarded, internal-only checks."
4
+ ---
5
+
6
+ > **⚠️ OUTPUT CONTRACT (READ FIRST):** `guides/output-contract.md`
7
+ > Checklist-only output. No tool-call narration. No raw MCP/JSON/log dumps.
8
+ > One checklist upfront, updated in place, shown again at end with a 1-line verdict.
9
+
10
+
11
+ # rdc:deploy — Coolify Operations
12
+
13
+ **READ FIRST:** `guides/output-contract.md`. Checklist-only output. No narration.
14
+ No raw MCP dumps. No UUIDs unless asked.
15
+
16
+ > **Sandbox contract:** This skill honors `RDC_TEST=1` per `guides/agent-bootstrap.md` § RDC_TEST Sandbox Contract. Destructive external calls short-circuit under the flag.
17
+ >
18
+ > *Under `$RDC_TEST=1`:* Modes 1 (deploy), 2 (new), and 5 (promote) are **entirely skipped** — echo `[RDC_TEST] skipping Coolify deploy/create/promote` and mark every `[ ]` line in those checklists as `[~]`. Modes 3 (diagnose) and 4 (audit without `--fix`) are **read-only and run normally**. Mode 4 with `--fix` skips all remediation — echo `[RDC_TEST] skipping audit --fix remediation` and report findings only. Registry SELECTs, Coolify status reads, HTTP gate probes, TLS checks, and DNS lookups are NOT destructive and run normally. Anything that writes (create app, set watch_paths, deploy trigger, **PR/admin-merge to main**, env var write, DNS write, CF cache purge, registry UPDATE/INSERT) is gated.
19
+
20
+ ## When to Use
21
+ - Project lead says "deploy", "ship it", "push to production", "update the server"
22
+ - Project lead says "promote", "patch prod", "hotfix production", "push this fix live" — use `rdc:deploy <slug> promote`
23
+ - Dev is a Next.js app but prod is still a static site for the same slug ("dev is XJS, prod is static", "make prod Next") — use `rdc:deploy <slug> convert` (Mode 6)
24
+ - A new app needs to be registered and deployed for the first time (`rdc:deploy new`)
25
+ - A deployed app is behaving unexpectedly and needs diagnosis (`rdc:deploy diagnose`)
26
+ - Running a compliance/health audit of all deployed apps (`rdc:deploy audit`)
27
+ - A platform service needs to run only on the Coolify network, with no public domain or host port (`rdc:deploy maintenance <service>`)
28
+
29
+ ## Arguments
30
+
31
+ - `rdc:deploy <slug>` — deploy existing app (latest commit on its watched branch)
32
+ - `rdc:deploy <slug> <build-id>` — deploy specific commit/tag
33
+ - `rdc:deploy <slug> promote` — promote the verified `develop` change for this app to production (Mode 5)
34
+ - `rdc:deploy <slug> promote --hotfix <sha>` — promote a specific commit (cherry-pick just that sha to `main`)
35
+ - `rdc:deploy <slug> convert` — convert a prod app's runtime in place from static→Next (Mode 6); use when dev is Next but prod is still a static Coolify build
36
+ - `rdc:deploy new <slug>` — create a new Coolify app from registry
37
+ - `rdc:deploy diagnose <slug>` — debug why an app is broken
38
+ - `rdc:deploy audit` — fleet-wide scan for missed failures
39
+ - `rdc:deploy audit --fix` — fleet scan + auto-remediate safe issues
40
+ - `rdc:deploy maintenance <service>` — create, update, or verify one allowlisted private infrastructure service (Mode 7)
41
+ - `rdc:deploy` (no args) — print mode menu, ask which
42
+
43
+ ## Modes
44
+
45
+ ### Mode 1 — deploy <slug> [build-id]
46
+
47
+ ```
48
+ rdc:deploy: <slug> → <domain>
49
+ [ ] Registry lookup (slug, uuid, branch, type, env_vars_needed)
50
+ [ ] Runtime-source guard: if the source being deployed is static (`sites/<name>`, nixpacks/static) but the registry `runtime` for <slug> is `next`, BLOCK — never deploy a flat/static source over a Next app. A static prototype deploys only under its OWN slug (never an existing Next app's slug), dev only. Changing a slug's runtime is architectural. (`.claude/rules/production-stack-nextjs.md`)
51
+ [ ] Git state verified (branch matches Coolify, commit pushed)
52
+ [ ] Build-id resolved (default: HEAD of watched branch)
53
+ [ ] Env vars present in Coolify (compare to registry)
54
+ [ ] Type-specific preflight (see docs/runbooks/coolify-deploy-checklist.md)
55
+ [ ] Mandatory pre-deploy code-review (pr-review-toolkit:code-reviewer on `git diff <last-deployed-sha>..HEAD` for this app's paths). Block deploy on `critical`/`high` findings; record `medium`/`low` and proceed.
56
+ [ ] PUBLISH.md read from app root (warn if absent; fail if present but invalid)
57
+ [ ] watch_paths derived from PUBLISH.md surfaces (union of all surface watch_paths arrays) and updated in app_deployments
58
+ [ ] Deploy triggered
59
+ [ ] Deployment reached "finished" state
60
+ [ ] Gate: HTTP 200
61
+ [ ] Gate: TLS valid (no SSL cipher mismatch)
62
+ [ ] Gate: cache headers correct on HTML
63
+ [ ] Gate: container running on declared port
64
+ [ ] Gate: metadata audit (see § Metadata Audit below) — warn on gaps, do not block deploy
65
+ [ ] Cloudflare cache purged (if proxied)
66
+ [ ] artifact_registry INSERT per PUBLISH.md surface (if PUBLISH.md present)
67
+ [ ] deployment_registry updated (last_deploy_at, status)
68
+ ✅ rdc:deploy: <slug> deployed in Nm Ns
69
+ ```
70
+
71
+ #### Static PM2 dev sites — do NOT trust the push webhook (SSH-reset + served-hash gate)
72
+
73
+ For a **static** PM2 dev site, `git push` succeeding does NOT mean the live site
74
+ updated: the push webhook skips/instruments static apps unreliably, so the host
75
+ working tree (and the committed `dist/` it serves) can stay STUCK across several
76
+ pushes while HTTP stays 200 and `origin/develop` looks shipped (lesson
77
+ 2026-06-11-deploy-static-host-stuck: issho served the v1.10.0 bundle across three
78
+ pushes because `/srv/regen/regen-root` never pulled). The only signal is the
79
+ SERVED bundle hash vs the local `dist/` hash.
80
+
81
+ After pushing committed `dist/`, SSH-reset the host explicitly, then verify the
82
+ served hash:
83
+ ```bash
84
+ _K=$(mktemp); curl -s http://127.0.0.1:52437/v/vultr-dev-ssh > "$_K"; printf '\n' >> "$_K"; chmod 600 "$_K"
85
+ ssh -i "$_K" root@64.237.54.189 'cd /srv/regen/regen-root && git fetch -q origin develop && git reset -q --hard origin/develop && pm2 restart <app> --update-env'
86
+ rm -f "$_K"
87
+ # Then verify SERVED hash == local build hash (HTTP 200 is NOT proof):
88
+ curl -s https://<app>.dev.place.fund/ | grep -oE 'index-[A-Za-z0-9_-]+\.js' # served
89
+ grep -oE 'index-[A-Za-z0-9_-]+\.js' sites/<app>/dist/index.html # local
90
+ ```
91
+ Only when the two hashes match do the content/screenshot gates mean anything.
92
+
93
+ ### Mode 2 — new <slug>
94
+
95
+ **MANDATORY:** All new apps are created from `docs/runbooks/coolify-app-templates.json`. Read that file first — pick the right template, substitute the required vars, POST the exact payload. No manual field configuration. No improvisation. The template encodes all learned lessons (base_directory, build_pack, watch_paths, health_check, ports). Deviating from it breaks things.
96
+
97
+ Template selection:
98
+ - `nextjs-app` — for `apps/<name>/` (Dockerfile, turbo filter, port 3000)
99
+ - `static-site` — for `sites/<name>/` (nixpacks, publish_directory=out, no packages)
100
+ - `mcp-server` — for `mcp-servers/<name>/` (Dockerfile, health check enabled, custom port)
101
+
102
+ ```
103
+ rdc:deploy new: <slug>
104
+ [ ] .dockerignore present at project root (`ls {PROJECT_ROOT}/.dockerignore` — STOP if missing)
105
+ [ ] Template loaded from docs/runbooks/coolify-app-templates.json (pick nextjs-app / static-site / mcp-server)
106
+ [ ] Required vars substituted: NAME, APP_PATH, DOMAIN, BRANCH, PROJECT_UUID, ENVIRONMENT_UUID [+ TURBO_FILTER / PORT]
107
+ [ ] DNS path chosen (A: staging wildcard B: apex C: other zone)
108
+ [ ] DNS record verified or wildcard confirmed
109
+ [ ] Cloudflare proxy setting correct for DNS path
110
+ [ ] Application created via POST /applications/private-github-app (template payload)
111
+ [ ] UUID recorded from response
112
+ [ ] watch_paths verified via GET /api/v1/applications/<uuid> — must match template
113
+ [ ] Env vars set in Coolify (from deployment_registry.env_vars_needed)
114
+ [ ] First deploy triggered
115
+ [ ] Deployment reached "finished" state
116
+ [ ] Gate: HTTP 200 on <domain>
117
+ [ ] Gate: TLS valid
118
+ [ ] deployment_registry row inserted
119
+ ✅ rdc:deploy new: <slug> live at <domain>
120
+ ```
121
+
122
+ ### Mode 3 — diagnose <slug>
123
+
124
+ ```
125
+ rdc:deploy diagnose: <slug>
126
+ [ ] App located (uuid, domain, last deploy)
127
+ [ ] Container state (running / restarting / stopped)
128
+ [ ] Last 100 log lines scanned for known error patterns
129
+ [ ] Port mismatch check (declared vs actual)
130
+ [ ] Env var drift check (registry vs Coolify)
131
+ [ ] watch_paths sanity check
132
+ [ ] HTTP / TLS reachability
133
+ [ ] Cloudflare proxy state check
134
+ [ ] Disk space on server
135
+ [ ] Branch mismatch check (Coolify git_branch vs expected)
136
+ ⚠️ rdc:deploy diagnose: <root cause in one sentence> — fix: <one command>
137
+ ```
138
+
139
+ #### `next start` crash-loop — check `.next/BUILD_ID` FIRST
140
+
141
+ When a PM2 `next start` app is crash-looping (`pm2 jlist` shows high `restart_time`
142
+ / "waiting restart"), check **`.next/BUILD_ID`** before chasing env/port theories —
143
+ it is the single gate `next start` enforces (`Could not find a production build in
144
+ the '.next' directory` repeats for every restart when it is missing). A `.next`
145
+ tree can exist as a PARTIAL build (manifests + server artifacts but no `BUILD_ID`)
146
+ — the signature of a `next build` that started but was interrupted/OOM-killed; the
147
+ crash loop then churns CPU and can re-trigger the OOM (lesson
148
+ 2026-06-13-deploy-nextstart-missing-buildid: ~7300 restarts, all `BUILD_ID`-missing,
149
+ while the documented prior lead was the NEXT_PUBLIC env issue — which was NOT the
150
+ cause here). Fix sequence:
151
+ ```bash
152
+ pm2 stop <app> # halt the loop so it stops competing for resources
153
+ rm -rf apps/<name>/.next # clear the partial tree
154
+ pnpm --filter @regen/<name> build # ONE clean scoped build (LOCAL BUILD SAFETY)
155
+ pm2 restart <app> --update-env
156
+ ```
157
+ Guard: assert `apps/<name>/.next/BUILD_ID` exists before (re)starting any
158
+ `next start` process.
159
+
160
+ #### ⛔ On the 2nd IDENTICAL failed probe, STOP polling and diagnose full-state
161
+
162
+ A repeated identical failure (same 404, same 502, same non-200) is a STRUCTURAL
163
+ signal, not eventual-consistency — polling it will never clear it and reads to the
164
+ user as stalling/throttling (lesson 2026-06-10-deploy-stop-polling-diagnose: a
165
+ 12×-404 poll loop on a remote-managed tunnel that was structurally ignoring the
166
+ local config — it could never clear). On the **2nd identical failed check**, stop
167
+ polling and pull GROUND TRUTH instead: list ALL processes / the whole effective
168
+ remote config / the full event log (`debugging-protocol.md` Rule 6 — full-state, not
169
+ a narrow filter). Poll ONLY when something is genuinely in-progress with a known
170
+ finish (a deploy build, a DNS first-create) — never to hope a structural error
171
+ resolves itself.
172
+
173
+ ### Mode 4 — audit
174
+
175
+ ```
176
+ rdc:deploy audit: fleet scan
177
+ [ ] Inventory join: Coolify apps ⋈ deployment_registry
178
+ [ ] Orphans (in one but not the other)
179
+ [ ] Monorepo apps missing watch_paths
180
+ [ ] Stale deploys (>14 days since last success)
181
+ [ ] Registry rows with status='broken'
182
+ [ ] Failed deployments in last 7 days
183
+ [ ] HTTP gate sweep (non-200 per domain)
184
+ [ ] TLS cert expiry <30 days
185
+ [ ] Port mismatches (ports_exposes vs actual container port)
186
+ [ ] Env var drift (registry.env_vars_needed vs Coolify env)
187
+ [ ] Branch mismatches (Coolify git_branch ≠ expected)
188
+ [ ] Disk space on 64.237.54.189
189
+ [ ] DNS/proxy misconfigs on configured staging wildcard
190
+ [ ] Duplicate apps (same repo, multiple UUIDs)
191
+
192
+ Findings:
193
+ | Severity | App | Issue | Fix |
194
+ |----------|-----|-------|-----|
195
+ | HIGH | ... | ... | ... |
196
+ ⚠️ rdc:deploy audit: N HIGH · M MED · K LOW — run `rdc:deploy audit --fix` to auto-remediate safe issues
197
+ ```
198
+
199
+ Severity rules:
200
+ - **HIGH** — user-facing down (HTTP non-200, TLS invalid, container not running)
201
+ - **MED** — degraded or drifting (watch_paths missing, env var drift, stale deploy, branch mismatch)
202
+ - **LOW** — cleanup (orphans, duplicates, registry status stale)
203
+
204
+ `--fix` auto-remediates only: missing watch_paths, registry row updates, CF cache purges. Never touches env vars, DNS, or container config without explicit confirmation.
205
+
206
+ ### Mode 5 — promote <slug> [--hotfix <sha>]
207
+
208
+ Promote a **verified `develop` change** for one app to production. This is the sanctioned production-patch fast path — one command instead of fighting branch protection, the main-push hook, and a flaky Coolify webhook by hand.
209
+
210
+ **Authorization:** production promote requires explicit user go-ahead, in either form:
211
+ - **Per-promote:** a direct go word for THIS call ("promote", "patch prod", "push live", "go").
212
+ - **Standing opt-in:** the user names one slug and grants it for the current session only ("auto-promote <slug> whenever dev is green", "standing go-ahead for <slug> this session"). Scope rules:
213
+ - One named slug per opt-in — never blanket ("auto-promote everything").
214
+ - Expires with the session. A new session/conversation needs a fresh opt-in; never infer one from a prior session's transcript or memory.
215
+ - Does not skip any other gate below — metadata-audit block, content-level verify, mandatory code review block on critical/high, scope guard, and back-merge all still run in full. The opt-in only removes the "stop and ask" step.
216
+ - Before EACH promote under a standing opt-in, re-verify dev is green with a **fresh** check (the content-level check further down this checklist) — "dev is green" means checked now for this call, not remembered from earlier in the session.
217
+
218
+ A dev deploy requires neither form. If the user has not given either form of go-ahead for THIS promote, stop and ask first.
219
+
220
+ Approved: option-2 — standing per-slug/session opt-in. Interview: 2026-07-31 (Dave), regen-root session.
221
+
222
+ ```
223
+ rdc:deploy promote: <slug> → <prod-domain>
224
+ [ ] Registry lookup: PROD row (uuid, prod branch=main, watch_paths, url)
225
+ [ ] PUBLISH.md status gate: status=active AND prod in environments (block if not)
226
+ [ ] Runtime-source guard: BLOCK promoting a static source to a prod row whose intended `runtime` is `next` — production apps are Next.js (DB-backed). (`.claude/rules/production-stack-nextjs.md`)
227
+ [ ] Scope resolved: --hotfix <sha> → that commit; else the app-path commits on develop not on main
228
+ [ ] Scope guard: promote ONLY this app's paths. NEVER merge develop→main wholesale (drags unrelated WIP to prod)
229
+ [ ] Clean worktree off origin/main (never switch the dirty working tree)
230
+ [ ] Export Supabase creds in the worktree BEFORE commit (the pre-commit `sync:docs` hook needs them, and a fresh worktree does NOT inherit the main checkout's `.env*`/shell env — without the key sync:docs silently regenerates a GUTTED `app-deployments.md` ("Registry: unavailable") and `git add`s it into the surgical promote; `--no-verify` is forbidden):
231
+ `export NEXT_PUBLIC_SUPABASE_URL=https://uvojezuorjgqzmhhgluu.supabase.co`
232
+ `export NEXT_PUBLIC_SUPABASE_ANON_KEY=$(curl -s http://127.0.0.1:52437/v/supabase-anon)`
233
+ Then ALWAYS `git show --stat HEAD` before pushing and confirm the file set is scoped — never ship the no-key gutted app-deployments.md. (lesson 2026-06-13-deploy-worktree-syncdocs-guts-app-deployments)
234
+ [ ] Apply change: cherry-pick <sha> (or `git checkout <develop-sha> -- <app-paths>`); confirm diff = expected files only
235
+ [ ] Mandatory pre-promote code-review (pr-review-toolkit:code-reviewer on the promote diff). Block on critical/high.
236
+ [ ] Commit on promote branch; push branch (NOT main directly — the main-push hook blocks raw main pushes)
237
+ [ ] Open PR base=main; merge with admin override (`gh pr merge --squash --admin --delete-branch`) — branch protection needs --admin
238
+ [ ] EXPLICITLY trigger Coolify deploy — NEVER rely on the GitHub→Coolify webhook (it silently no-ops on some merges even with auto-deploy ON)
239
+ [ ] Deployment reached "finished" state (poll coolify_events / deployment status)
240
+ [ ] Gate: HTTP 200 on prod domain
241
+ [ ] Gate: content-level check — assert the actual changed string is live (200 alone is NOT proof; origin may serve stale)
242
+ [ ] Gate: TLS valid
243
+ [ ] Gate: metadata audit (see § Metadata Audit below) — BLOCK promote on any missing required item
244
+ [ ] Cloudflare cache purged (if proxied)
245
+ [ ] deployment_registry updated (last_deploy_at, last_deploy_commit, status)
246
+ ✅ rdc:deploy promote: <slug> live in prod — <changed-string> verified
247
+ ```
248
+
249
+ **The explicit Coolify trigger (the whole point — do not skip):**
250
+ ```bash
251
+ _COOLIFY=$(curl -s http://127.0.0.1:52437/v/coolify-api)
252
+ # Correct endpoint is GET /api/v1/deploy?uuid= — NOT POST /applications/<uuid>/deploy (that 404s)
253
+ curl -s -H "Authorization: Bearer $_COOLIFY" \
254
+ "$DEPLOY_API_BASE/api/v1/deploy?uuid=<PROD_UUID>&force=true"
255
+ # → {"deployments":[{"deployment_uuid":"...","message":"...deployment queued."}]}
256
+ ```
257
+
258
+ **Why each guard exists (lessons from 2026-06-05 EF Hooper promote):**
259
+ - `main` branch protection rejects PR merge without `--admin`; a raw `git push …:main` is blocked by the main-push hook → must go branch → PR → admin-merge.
260
+ - Coolify auto-deploy was **ON** for the app yet the merge did **not** auto-deploy — a webhook-delivery flake. A promote must ALWAYS trigger the deploy explicitly and verify; never hope the webhook fired.
261
+ - `develop` was 87 commits ahead of `main` (unrelated apps' WIP). Promoting must be surgical (this app's paths / one sha), never a develop→main merge.
262
+ - HTTP 200 was returned by the stale origin the whole time — only a content-level assertion (`curl … | grep '<new string>'`) proves the promote landed.
263
+
264
+ ### Mode 6 — convert <slug> (prod runtime static→Next, in place)
265
+
266
+ One-time conversion of an existing **production** Coolify app from a static/nixpacks build to the Next.js (dockerfile) runtime, **in place** — same UUID, same fqdn, same DNS. After this, the slug is a normal Next app and every future ship is just `rdc:deploy <slug> promote`. This is the mode for "dev is Next, prod is still static" (the `vlas.earth` / `life.ai` class). `promote` alone CANNOT do this — it re-triggers the prod app's existing (static) build; only `convert` changes the build config.
267
+
268
+ **Authorization:** this changes a production app's runtime — **architectural** per `.claude/rules/production-stack-nextjs.md` + `.claude/rules/architectural-change-approval.md`. Requires explicit user go-ahead for THIS slug. Stop and ask if not given.
269
+
270
+ **Why in-place PATCH, not recreate:** keeps the application UUID, fqdn (apex + `www`), DNS binding, and project — zero DNS cutover, zero window where the domain is unbound. PATCH switches the build pack; the next deploy builds the Next app on the same application object.
271
+
272
+ ```
273
+ rdc:deploy convert: <slug> → <prod-domain> (static→Next, in place)
274
+ [ ] Go-ahead confirmed for THIS slug (production runtime change — architectural)
275
+ [ ] Registry lookup: PROD coolify_uuid, fqdn, project/env, current build_pack
276
+ [ ] Confirm it IS a convert: dev runtime=next AND prod build_pack ∈ {static, nixpacks}. If prod build_pack already `dockerfile` → already converted, fall through to Mode 5 promote.
277
+ [ ] Source Next-prod-readiness preflight (BLOCK on any miss):
278
+ - apps/<name>/Dockerfile present (prod build) — BLOCK "source not Next-prod-ready: add Dockerfile" if missing
279
+ - start/listen port reconciled with intended ports_exposes (no dev-only port like :3214 leaking to prod)
280
+ - PUBLISH.md present, build_type=nextjs, status=active, prod in environments
281
+ [ ] Mandatory pre-convert code-review (pr-review-toolkit:code-reviewer on the apps/<name> diff being promoted). Block on critical/high.
282
+ [ ] Promote app code to main (Mode 5 path): clean worktree off origin/main → checkout apps/<name> paths (or cherry-pick) → confirm diff = expected files only → branch → PR base=main → `gh pr merge --squash --admin --delete-branch`
283
+ [ ] LOCKFILE IMPORTER (mandatory for an app NEW to main): if `git show origin/main:pnpm-lock.yaml | grep -c "apps/<name>:"` is 0, the Docker `pnpm install --frozen-lockfile` WILL fail. In the clean main worktree run `pnpm install --lockfile-only` (adds only the apps/<name> importer; verify the diff is minimal — do NOT promote develop's whole lockfile, it carries unrelated drift) and include `pnpm-lock.yaml` in the SAME promote PR.
284
+ [ ] In-place PATCH prod Coolify app <uuid> to nextjs-app template fields:
285
+ build_pack=dockerfile · dockerfile_location=/apps/<name>/Dockerfile · base_directory=/ ·
286
+ build_command="pnpm turbo run build --filter=<pkg>" · start_command="pnpm --filter=<pkg> start" ·
287
+ install_command="pnpm install --frozen-lockfile" · ports_exposes=<port> ·
288
+ watch_paths="apps/<name>/**\npackages/**"
289
+ (clear publish_directory; static-only field)
290
+ [ ] PROXY LABELS (mandatory — the static-app `custom_labels` are read-only and do NOT auto-update on a build_pack PATCH): fetch `custom_labels`, base64-decode, rewrite `loadbalancer.server.port=80`→`=<port>` (all routers) AND `upstreams 80}}`→`upstreams <port>}}`, and STRIP the static `caddy_*.try_files=…/index.html /index.php` lines (wrong for a Next app), re-base64, PATCH `custom_labels`. Skipping this = container serves on :<port> but the proxy still routes :80 → **502** even though the build "finished" and the app logs "Ready". (Note: the API rejects `is_container_label_readonly_enabled` and a non-base64 `custom_labels` — you must hand-rewrite the base64.)
291
+ [ ] Env vars present in Coolify (compare registry.env_vars_needed); set any missing
292
+ [ ] EXPLICITLY trigger deploy: GET /api/v1/deploy?uuid=<PROD_UUID>&force=true — never rely on the webhook
293
+ [ ] Deployment reached "finished" state (poll coolify_events)
294
+ [ ] Gate: SSR proof — prod HTML now contains `/_next/static` (it is the Next app, not the old static build)
295
+ [ ] Gate: HTTP 200 + content-level assertion of a known string from the Next render
296
+ [ ] Gate: TLS valid; cache headers correct on HTML
297
+ [ ] Gate: metadata audit (see § Metadata Audit) — BLOCK on any required gap
298
+ [ ] Cloudflare cache purged (apex + www)
299
+ [ ] app_deployments updated: runtime intent → next, notes (converted static→Next <date>), last_deploy_at, last_deploy_commit, status=active
300
+ [ ] `apps` row runtime=next confirmed
301
+ ✅ rdc:deploy convert: <slug> now Next.js in prod — SSR verified at <prod-domain>
302
+ ```
303
+
304
+ **Rollback:** PATCH the app back to static (`build_pack=static`, `base_directory=/sites/<name>`, `publish_directory=/sites/<name>`, `ports_exposes=80`, `watch_paths=sites/<name>/**`) and redeploy. `sites/<name>` stays in the repo precisely so this rollback is always available.
305
+
306
+ ### Mode 7 — maintenance <service> (private Coolify infrastructure)
307
+
308
+ Use this mode for a **private service**, not a website: its consumers run on the
309
+ same Coolify Docker network and reach it by internal service alias. It is the
310
+ only sanctioned path for creating or changing private infrastructure such as
311
+ the Coolify clauth runtime.
312
+
313
+ **Authorization and scope:** maintenance may create, deploy, or update only a
314
+ service declared in the `private-service` template. It must never add a public
315
+ domain, host-port publication, DNS record, Cloudflare route, or public probe.
316
+ Destructive retirement remains separately approval-gated and is not part of
317
+ this command. Secrets are checked by **key name only**; their values must never
318
+ be retrieved, logged, or copied into source control.
319
+
320
+ ```
321
+ rdc:deploy maintenance: <service>
322
+ [ ] Service allowlisted by the `private-service` template; source repository, branch, Dockerfile, port, and health path resolved
323
+ [ ] Private-only contract checked: domains empty, no host port mapping, and service alias declared for the Coolify network
324
+ [ ] Required secret keys present by name only; values neither read nor emitted
325
+ [ ] Dockerfile preflight: service binds its container interface and exposes only its declared internal port
326
+ [ ] Create or update performed through the private-service template; no manual Coolify field configuration
327
+ [ ] Deploy explicitly triggered and reaches finished state
328
+ [ ] Container health gate passes at the declared health path
329
+ [ ] In-network probe from an existing Coolify workload reaches the declared service alias and health path
330
+ [ ] Public exposure gate passes: no domain, DNS, TLS, or host-port endpoint exists for the service
331
+ [ ] Service record and maintenance receipt updated without secret values
332
+ ✅ rdc:deploy maintenance: <service> private service healthy on the Coolify network
333
+ ```
334
+
335
+ **Current allowlisted service — `clauth`:** source `LIFEAI/clauth` on `main`;
336
+ Dockerfile `/Dockerfile`; internal port `52437`; health path `/ping`; required
337
+ secret key names `CLAUTH_MACHINE_ID` and `CLAUTH_MASTER_PASSWORD`. Its Docker
338
+ runtime must bind `0.0.0.0` *inside the container only*. Consumers use the
339
+ resolved Coolify network alias, never `localhost` and never a published URL.
340
+
341
+ **Verification boundary:** a host-side request to this service is not evidence
342
+ of correctness. Verify it from a running workload on the same Coolify network;
343
+ the expected result is a successful internal health response while public
344
+ domain, TLS, and host-port checks remain absent.
345
+
346
+ **PATCH command (in-place build-pack switch):**
347
+ ```bash
348
+ _COOLIFY=$(curl -s http://127.0.0.1:52437/v/coolify-api)
349
+ curl -s -X PATCH -H "Authorization: Bearer $_COOLIFY" -H "Content-Type: application/json" \
350
+ -d '{"build_pack":"dockerfile","dockerfile_location":"/apps/<name>/Dockerfile","base_directory":"/","build_command":"pnpm turbo run build --filter=<pkg>","start_command":"pnpm --filter=<pkg> start","install_command":"pnpm install --frozen-lockfile","ports_exposes":"<port>","watch_paths":"apps/<name>/**\npackages/**"}' \
351
+ "$DEPLOY_API_BASE/api/v1/applications/<PROD_UUID>"
352
+ ```
353
+
354
+ ## Metadata Audit
355
+
356
+ After a successful deploy or promote, audit the live site's `<head>` metadata. Run this against the deployed URL (not a local file).
357
+
358
+ **Check list** (curl the deployed URL, parse the HTML `<head>`):
359
+
360
+ | # | Item | How to check | Required | Severity |
361
+ |---|------|-------------|----------|----------|
362
+ | 1 | `<title>` | grep `<title>` — non-empty, not default/placeholder | yes | block |
363
+ | 2 | `<meta name="description">` | content attr ≥ 50 chars, ≤ 160 chars | yes | block on promote; warn on deploy |
364
+ | 3 | `<link rel="canonical">` | href present, starts with `https://`, matches the deployed domain | yes | block on promote; warn on deploy |
365
+ | 4 | `og:title` | `<meta property="og:title">` present | yes | block on promote; warn on deploy |
366
+ | 5 | `og:description` | `<meta property="og:description">` present, ≥ 50 chars | yes | block on promote; warn on deploy |
367
+ | 6 | `og:image` | `<meta property="og:image">` present, URL returns HTTP 200 | yes | block on promote; warn on deploy |
368
+ | 7 | `og:url` | present, matches canonical | recommended | warn |
369
+ | 8 | `twitter:card` | `summary_large_image` or `summary` | recommended | warn |
370
+ | 9 | `twitter:image` | present, URL returns HTTP 200 | recommended | warn |
371
+ | 10 | Favicon | `<link rel="icon">` present, href returns HTTP 200 | yes | block on promote; warn on deploy |
372
+ | 11 | `sitemap.xml` | `<domain>/sitemap.xml` returns HTTP 200 | recommended | warn |
373
+ | 12 | `robots.txt` | `<domain>/robots.txt` returns HTTP 200 | recommended | warn |
374
+ | 13 | Version | `<meta name="version">` or visible version string in page | yes (project rule) | warn |
375
+
376
+ **Behavior by mode:**
377
+ - **Mode 1 (deploy):** run the audit after HTTP/TLS gates. Print a table of results. **Warn** on gaps but do NOT block the deploy — dev deploys are iterative.
378
+ - **Mode 5 (promote):** run the audit after content-level check. **Block the promote** if any item marked "block on promote" is missing. Print the table and require the metadata to be fixed before re-attempting.
379
+ - **Mode 4 (audit):** include the metadata sweep in the fleet scan (one row per app per missing item in the findings table).
380
+
381
+ **Implementation — one-liner per check:**
382
+ ```bash
383
+ URL="https://<domain>"
384
+ HEAD=$(curl -s "$URL" | sed -n '/<head/,/<\/head>/p')
385
+ echo "$HEAD" | grep -ioE '<title>[^<]+</title>'
386
+ echo "$HEAD" | grep -ioE 'name="description"[^>]*content="[^"]*"'
387
+ echo "$HEAD" | grep -ioE 'rel="canonical"[^>]*href="[^"]*"'
388
+ echo "$HEAD" | grep -ioE 'property="og:title"[^>]*content="[^"]*"'
389
+ echo "$HEAD" | grep -ioE 'property="og:image"[^>]*content="[^"]*"'
390
+ echo "$HEAD" | grep -ioE 'name="twitter:card"[^>]*content="[^"]*"'
391
+ echo "$HEAD" | grep -ioE 'rel="icon"[^>]*href="[^"]*"'
392
+ echo "$HEAD" | grep -ioE 'name="version"[^>]*content="[^"]*"'
393
+ curl -s -o /dev/null -w "%{http_code}" "$URL/sitemap.xml"
394
+ curl -s -o /dev/null -w "%{http_code}" "$URL/robots.txt"
395
+ OG_IMG=$(echo "$HEAD" | grep -ioE 'property="og:image"[^>]*content="([^"]*)"' | grep -ioE 'https://[^"]*')
396
+ [ -n "$OG_IMG" ] && curl -s -o /dev/null -w "%{http_code}" "$OG_IMG"
397
+ ```
398
+
399
+ **Why this exists (2026-06-05):** life.ai deployed to production with zero social/SEO metadata — no description, no OG, no favicon, no sitemap. HTTP 200 + TLS + content passed; metadata was invisible to the existing gates. This audit catches that class of defect.
400
+
401
+ ## Hotlink referer allowlist — new media.place.fund consumer domains
402
+
403
+ When onboarding a NEW brand domain that loads assets from `media.place.fund`, add
404
+ that domain to the hotlink-protection Worker's referer allowlist **before
405
+ go-live** (lesson 2026-06-14-deploy-issholiving-referer-hotlink-403: a new brand
406
+ domain served its own HTML fine but every `media.place.fund` image returned 403
407
+ because the hotlink Worker rejected the unknown `Referer`). The image 403 is
408
+ invisible to the HTTP-200 / TLS / metadata gates — they probe the HTML document,
409
+ not the cross-origin asset.
410
+
411
+ - Add the new origin (apex + `www`, dev + prod) to the Worker's referer allowlist
412
+ and redeploy the Worker before the brand domain goes live.
413
+ - Verify with an explicit `Referer` probe (NOT a bare curl — a missing Referer can
414
+ pass while the real browser Referer fails):
415
+ ```bash
416
+ curl -s -o /dev/null -w "%{http_code}" -e "https://<new-brand-domain>/" \
417
+ "https://media.place.fund/<a-known-asset-path>" # expect 200, not 403
418
+ ```
419
+
420
+ ## PUBLISH.md Integration
421
+
422
+ Every deploy reads `PUBLISH.md` from the app's source root to derive `watch_paths` and to register surfaces in Studio `artifact_registry`.
423
+
424
+ ### Step 6 — Read PUBLISH.md from the app root
425
+
426
+ ```bash
427
+ MONOREPO_PATH=$(get_app_deployments_monorepo_path "$SLUG")
428
+ PUBLISH_MD="$MONOREPO_PATH/PUBLISH.md"
429
+
430
+ if [ ! -f "$PUBLISH_MD" ]; then
431
+ echo "WARN: PUBLISH.md missing for $SLUG — using app_deployments.watch_paths only"
432
+ # Deploy continues; watch_paths derivation and artifact_registry INSERT are skipped
433
+ fi
434
+ ```
435
+
436
+ PUBLISH.md format: see `C:/Dev/rdc-skills/guides/publish-md-spec.md` (authoritative).
437
+
438
+ Required frontmatter fields: `schema_version`, `entity_slug`, `artifact_type`, `environments`, `status`.
439
+ One or more `<!-- SURFACE:<id> -->` … `<!-- /SURFACE:<id> -->` blocks per surface (each with `path`, `source_dir`, `build_type`, `visibility`, `cache`, `watch_paths`).
440
+
441
+ If PUBLISH.md is **present but invalid** (missing required field, bad enum, no surface blocks): abort deploy with `BLOCKED: PUBLISH.md parse error for <slug> — <reason>`.
442
+
443
+ ### Step 7 — Derive watch_paths from PUBLISH.md surfaces
444
+
445
+ Union all `watch_paths` arrays across every surface section in PUBLISH.md. Update `app_deployments.watch_paths` for the app slug to this derived union before triggering the Coolify deploy.
446
+
447
+ ```sql
448
+ UPDATE app_deployments
449
+ SET watch_paths = '<union-of-surface-watch_paths>'
450
+ WHERE app_slug = '<slug>';
451
+ ```
452
+
453
+ Also PATCH the Coolify application's `watch_paths` field:
454
+
455
+ ```bash
456
+ _COOLIFY=$(curl -s http://127.0.0.1:52437/v/coolify-api)
457
+ WATCH_PATHS_JSON=$(derive_watch_paths_union "$PUBLISH_MD")
458
+ curl -s -X PATCH -H "Authorization: Bearer $_COOLIFY" \
459
+ -H "Content-Type: application/json" \
460
+ -d "{\"watch_paths\":\"$WATCH_PATHS_JSON\"}" \
461
+ "$DEPLOY_API_BASE/api/v1/applications/<uuid>"
462
+ ```
463
+
464
+ ### Step 15 — storeArtifact per surface (after successful deploy)
465
+
466
+ After the deployment reaches "finished" state, INSERT one row into Studio `artifact_registry` for each surface declared in PUBLISH.md:
467
+
468
+ | Column | Value |
469
+ |--------|-------|
470
+ | `entity_slug` | from PUBLISH.md frontmatter `entity_slug` |
471
+ | `artifact_type` | from PUBLISH.md frontmatter `artifact_type` |
472
+ | `canonical_url` | `https://<app_deployments.url><surface.path>` |
473
+ | `surface_id` | surface name from `<!-- SURFACE:<id> -->` marker |
474
+ | `commit_sha` | HEAD SHA of the deploy |
475
+ | `published_at` | `now()` |
476
+
477
+ Use the Supabase MCP (`mcp__claude_ai_Supabase__execute_sql`) from the supervisor session:
478
+
479
+ ```sql
480
+ INSERT INTO artifact_registry (entity_slug, artifact_type, canonical_url, surface_id, commit_sha, published_at)
481
+ VALUES ('<entity_slug>', '<artifact_type>', 'https://<url><path>', '<surface_id>', '<commit_sha>', now())
482
+ ON CONFLICT (entity_slug, surface_id) DO UPDATE SET
483
+ canonical_url = EXCLUDED.canonical_url,
484
+ commit_sha = EXCLUDED.commit_sha,
485
+ published_at = EXCLUDED.published_at;
486
+ ```
487
+
488
+ If the INSERT fails, surface the failure in the deploy output but **do NOT roll back the deploy**. The artifact registry is a post-deploy record, not a deploy gate.
489
+
490
+ ## Coolify Access — clauth + REST API
491
+
492
+ All Coolify operations use the clauth daemon and the Coolify REST API directly.
493
+ There is no Coolify MCP server. Do not reference `@masonator/coolify-mcp`.
494
+
495
+ ```bash
496
+ # Get token (plain text — no JSON parsing needed)
497
+ _COOLIFY=$(curl -s http://127.0.0.1:52437/v/coolify-api)
498
+
499
+ # List applications
500
+ curl -s -H "Authorization: Bearer $_COOLIFY" \
501
+ "$DEPLOY_API_BASE/api/v1/applications"
502
+
503
+ # Get application details
504
+ curl -s -H "Authorization: Bearer $_COOLIFY" \
505
+ "$DEPLOY_API_BASE/api/v1/applications/<uuid>"
506
+
507
+ # Deploy (trigger) — correct endpoint is GET /api/v1/deploy?uuid=
508
+ # (POST /applications/<uuid>/deploy returns {"message":"Not found."})
509
+ curl -s -H "Authorization: Bearer $_COOLIFY" \
510
+ "$DEPLOY_API_BASE/api/v1/deploy?uuid=<uuid>&force=true"
511
+
512
+ # Get deployment logs
513
+ curl -s -H "Authorization: Bearer $_COOLIFY" \
514
+ "$DEPLOY_API_BASE/api/v1/deployments/<deployment-id>"
515
+
516
+ # Set env var
517
+ curl -s -X POST -H "Authorization: Bearer $_COOLIFY" \
518
+ -H "Content-Type: application/json" \
519
+ -d '{"key":"<KEY>","value":"<VALUE>"}' \
520
+ "$DEPLOY_API_BASE/api/v1/applications/<uuid>/envs"
521
+
522
+ # Set watch_paths
523
+ curl -s -X PATCH -H "Authorization: Bearer $_COOLIFY" \
524
+ -H "Content-Type: application/json" \
525
+ -d '{"watch_paths":"apps/<name>/**\npackages/**"}' \
526
+ "$DEPLOY_API_BASE/api/v1/applications/<uuid>"
527
+
528
+ # Change the app's domain — the writable field is "domains", NOT "fqdn"
529
+ # (Coolify v4 PATCH rejects {"fqdn":...} with "This field is not allowed"; fqdn is read-only/derived)
530
+ curl -s -X PATCH -H "Authorization: Bearer $_COOLIFY" \
531
+ -H "Content-Type: application/json" \
532
+ -d '{"domains":"https://<host>"}' \
533
+ "$DEPLOY_API_BASE/api/v1/applications/<uuid>"
534
+ ```
535
+
536
+ **Domain change / namespace migration** (lesson 2026-06-13-deploy-media-manager-namespace-migration):
537
+ PATCH `applications/<uuid>` with `{"domains":"https://<host>"}` — never `fqdn`.
538
+ For an app NEW to main (first prod deploy) also bring the app + a lockfile importer
539
+ to main (clean worktree off origin/main, `pnpm install --lockfile-only`, verify the
540
+ diff = additions for `apps/<name>:` only); resolve the lockfile-guard vs scope-guard
541
+ collision by committing the app+lockfile as a SINGLE `chore(infra):`-subject commit
542
+ (the scope-guard's documented escape hatch) so both pre-commit guards pass.
543
+
544
+ **Never print `$_COOLIFY` to stdout.** Inline from clauth only — do not assign raw strings.
545
+
546
+ If clauth daemon is not responding (`curl -s http://127.0.0.1:52437/ping` fails):
547
+ ```
548
+ BLOCKED: credential provider is not responding.
549
+ Fix: start the project's credential provider or configure deployment credentials through env vars, then retry.
550
+ I cannot proceed until this is resolved.
551
+ ```
552
+
553
+ ## Deployment Event Log — `coolify_events`
554
+
555
+ Every Coolify deploy emits a webhook → `coolify_events` row. Use this for last-N-deploys queries, debugging failed deploys, and reconciling local state with Coolify state.
556
+
557
+ Query the last 5 events for an app:
558
+ ```sql
559
+ SELECT created_at, event_type, status, branch, commit_hash, duration_seconds
560
+ FROM coolify_events
561
+ WHERE app_uuid = '<uuid>' OR app_name = '<slug>'
562
+ ORDER BY created_at DESC LIMIT 5;
563
+ ```
564
+
565
+ Fields:
566
+ - `app_uuid` — Coolify application UUID (matches `app_deployments.coolify_uuid`)
567
+ - `event_type` — `started | succeeded | failed | cancelled` (canonical values; consult webhook receiver for full enum)
568
+ - `status` — overall deploy status
569
+ - `branch`, `commit_hash`, `commit_message` — git context
570
+ - `duration_seconds` — total deploy time
571
+ - `payload` — full webhook payload (jsonb) for forensic debugging
572
+
573
+ When diagnosing a broken deploy in Mode 3: query `coolify_events` FIRST before re-running the deploy — the most recent event row tells you whether the previous deploy actually triggered, whether it failed, and how long it ran. Faster than checking the Coolify UI.
574
+
575
+ ## Capture lessons (exit step)
576
+
577
+ Before the final verdict line, follow `.rdc/guides/lessons-learned-spec.md` § Capture procedure. If this run taught something non-obvious — a first root-cause theory that turned out wrong, the documented/standard path not working, a missing gate or check that cost a round, or a surprising tool/infra behavior — write one `.rdc/lessons/<YYYY-MM-DD>-deploy-<short-slug>.md` per lesson using the schema in that spec. Set `scope` (`simple` | `architectural`) and `status` (`open`, or `applied` if you shipped the fix in this same run, with the commit linked). Commit the lesson file(s) on `develop` alongside the run's other commits, and note "N lessons captured" in your verdict/summary. A run that taught nothing writes nothing — absence is the default.
578
+
579
+ ## References
580
+
581
+ - Type-specific checklists + DNS tree + gate commands: `docs/runbooks/coolify-deploy-checklist.md`
582
+ - Rules / registry RPCs / hard limits: `.claude/context/coolify-deployment.md`
583
+ - Infrastructure constants:
584
+ ```
585
+ Server UUID: ih386anenvvvn6fy1umtyow0
586
+ Server IP: 64.237.54.189
587
+ Dashboard: <deployment-dashboard-url>
588
+ GitHub App UUID: xdmcy60putp5h9j7k4kwg9c3
589
+ ```
590
+
591
+ ## Supersedes
592
+
593
+ `coolify-deploy` standalone skill (kept for back-compat; new work uses `rdc:deploy`).