From 0f1ac3d29c73697fa4f5d06af3c1b0a35a084f4b Mon Sep 17 00:00:00 2001 From: Codevena Date: Tue, 11 Aug 2026 14:35:12 +0200 Subject: [PATCH 1/4] docs: specify CI-gated Coolify deployment --- ...26-08-11-ci-gated-coolify-deploy-design.md | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-ci-gated-coolify-deploy-design.md diff --git a/docs/superpowers/specs/2026-08-11-ci-gated-coolify-deploy-design.md b/docs/superpowers/specs/2026-08-11-ci-gated-coolify-deploy-design.md new file mode 100644 index 0000000..17175ab --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-ci-gated-coolify-deploy-design.md @@ -0,0 +1,261 @@ +# CI-Gated Coolify Deployment Design + +**Status:** Approved direction; written specification awaiting final review +**Date:** 2026-08-11 +**Scope:** AgoraHub continuous deployment only + +## Goal + +AgoraHub production deployments must have one authority: GitHub Actions. A +commit may reach Coolify only after the repository's `CI` workflow has completed +successfully for that exact `main` commit. Coolify's native GitHub auto-deploy +must be disabled only after the replacement path has completed a controlled, +end-to-end deployment successfully. + +This work does not redesign the application, publish the CLI or SDK, change the +database schema, or introduce the later scheduled MCP/A2A synthetic monitor. + +## Current State + +- GitHub `CI` runs on pushes and pull requests targeting `main`, plus manual + dispatches. Its two jobs cover quality/database/browser verification and the + production audit/secret scan. +- `.github/workflows/deploy.yml` listens for completed `CI` runs on `main`, but + treats missing secrets as a successful skip. No Coolify secrets or variables + currently exist in the GitHub repository. +- Coolify application `z5eij4n8c4ubvxsmtpb507p2` deploys + `Codevena/AgoraHub:main` and has `is_auto_deploy_enabled = true`. +- Coolify `4.0.0-beta.470` protects `POST /api/v1/deploy` with a Sanctum token + carrying the `deploy` ability. Reading deployment state needs `read`. +- The deployed Coolify API is additionally protected by Cloudflare Access at + `panel.codevena.dev`; an anonymous API request receives an Access redirect. +- Coolify's deploy controller accepts a resource UUID but no commit SHA. It + queues the configured branch's `HEAD`. Calling it directly after CI would + therefore contain a time-of-check/time-of-use race if `main` advanced between + the CI result and Coolify's clone. + +## Chosen Architecture + +### Exact-commit deployment ref + +Create the repository branch `coolify-production` at the currently deployed, +green `main` SHA. Configure the Coolify application to build this branch instead +of `main`. + +Only the deploy workflow may move `coolify-production`. For each eligible +successful CI run it: + +1. reads `workflow_run.head_sha` as the verified SHA; +2. confirms that the CI event was a `push` or `workflow_dispatch` on `main`; +3. confirms that GitHub's current `main` ref still equals that SHA, otherwise it + exits successfully as superseded without deploying; +4. updates `coolify-production` to that exact SHA; +5. calls Coolify for the AgoraHub resource; +6. waits for the returned deployment UUID to reach a terminal state; +7. requires terminal state `finished` and Coolify's recorded `commit` to equal + the verified SHA; +8. runs the existing `pnpm release:smoke` suite against + `https://agorahub.dev` from the verified checkout. + +This closes the branch-head race: Coolify never reads the moving development +branch. The production ref is not advanced again until the serialized workflow +has observed the previous deployment's terminal state. + +```mermaid +flowchart LR + A[main commit] --> B[GitHub CI] + B -->|failure| X[No deployment] + B -->|success for current main SHA| C[coolify-production ref] + C --> D[Cloudflare Access service auth] + D --> E[Coolify deploy + read token] + E --> F[Coolify builds pinned ref] + F --> G[Commit/status verification] + G --> H[11-check production smoke] +``` + +### Workflow admission and serialization + +The deploy workflow has no direct `workflow_dispatch` trigger. A manual +production redeploy must start the `CI` workflow on `main`; only its successful +`workflow_run` event can enter CD. Pull-request CI runs are rejected even if +their branch name or conclusion is otherwise suitable. + +Deployment concurrency uses one repository-wide group with +`cancel-in-progress: false`. The workflow remains active until Coolify finishes +and the production smoke completes. A later successful CI run cannot move the +deployment ref during that interval. Superseded pending runs may be omitted; +the latest green current `main` is the desired production target. + +The job receives only these GitHub permissions: + +- `contents: write` to move `refs/heads/coolify-production` to the verified SHA; +- `actions: read` to consume the trusted `workflow_run` context; +- all other permissions remain absent. + +The ref update uses the SHA delivered by GitHub's successful CI event, never a +shell-derived or user-provided ref. The implementation must not evaluate event +text or interpolate it into executable shell syntax without an environment +boundary. + +### Two-layer API protection + +Cloudflare Access gets a path-scoped self-hosted application for +`panel.codevena.dev/api/v1/deploy*`. Its only allow path for automation is a +Service Auth policy bound to a new service token named +`GitHub Actions - AgoraHub Coolify deploy`. The existing human login policy for +the rest of `panel.codevena.dev` stays unchanged. + +Coolify gets a new team-scoped API token named +`GitHub Actions - AgoraHub deploy`. It carries exactly `read` and `deploy`, not +`write` or `root`. `read` is required to poll the deployment record; `deploy` +is required to queue it. + +The GitHub repository stores: + +| Kind | Name | Value | +|---|---|---| +| Variable | `COOLIFY_CD_ENABLED` | `false` during cutover, then permanently `true` | +| Variable | `COOLIFY_API_BASE_URL` | `https://panel.codevena.dev/api/v1` | +| Variable | `COOLIFY_APP_UUID` | `z5eij4n8c4ubvxsmtpb507p2` | +| Variable | `COOLIFY_DEPLOY_BRANCH` | `coolify-production` | +| Secret | `COOLIFY_API_TOKEN` | new Coolify `read` + `deploy` token | +| Secret | `CF_ACCESS_CLIENT_ID` | new Access service-token client ID | +| Secret | `CF_ACCESS_CLIENT_SECRET` | new Access service-token secret | + +Every Coolify request supplies all three authentication headers: + +- `Authorization: Bearer $COOLIFY_API_TOKEN` +- `CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID` +- `CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET` + +`COOLIFY_CD_ENABLED` is the explicit cutover interlock. It accepts only `false` +or `true`; missing or any other value fails. `false` produces an intentional, +visible no-deploy result during the short cutover window without reading the API +credentials. When it is `true`, missing variables or secrets are fatal +configuration errors. The workflow must never turn missing production +credentials into a green skip. + +## Deployment Result Handling + +The queue request must return exactly one deployment for the configured AgoraHub +resource and expose a non-empty `deployment_uuid`. Any malformed response, +unexpected resource UUID, HTTP error, Cloudflare Access redirect, authentication +failure, or Coolify rate limit fails the workflow. + +The workflow polls `GET /api/v1/deployments/{deployment_uuid}` at a bounded +interval for at most 20 minutes. Coolify statuses are handled as follows: + +- `queued` and `in_progress`: continue polling; +- `finished`: require `commit == workflow_run.head_sha`, then continue to the + production smoke; +- `failed` or `cancelled-by-user`: fail immediately; +- unknown or missing status: fail closed; +- timeout: fail with the deployment UUID for operator diagnosis. + +The workflow logs no token values or response headers. GitHub secrets remain in +the secret store, and the one-time values shown by Coolify and Cloudflare are +transferred directly into GitHub without being written to the repository, Brain, +terminal history, or review artifacts. + +After Coolify reports the correct finished commit, the workflow checks out the +verified SHA with the repository's pinned Node and pnpm versions, installs from +the frozen lockfile, and executes: + +```bash +BASE_URL=https://agorahub.dev pnpm release:smoke +``` + +This keeps GitHub's deploy run red if health, public pages, MCP Echo, A2A v1, or +legacy compatibility regresses after an otherwise successful container rollout. + +## Controlled Cutover + +The cutover preserves a working rollback path at every step: + +1. Set repository variable `COOLIFY_CD_ENABLED=false` before merging the tested + workflow. Missing or invalid values are not accepted as disabled. +2. Merge the workflow while Coolify still watches `main` with native auto-deploy. + The merge SHA receives normal CI and becomes the final native deployment. The + new deploy workflow records its explicit disabled state and does not call the + API. +3. Verify that the final native deployment is healthy and then create + `coolify-production` at that exact green/deployed `main` SHA. +4. Create the path-scoped Cloudflare Access application and service token. +5. Create the Coolify `read` + `deploy` API token. +6. Store the three credential secrets and the remaining API/app/branch variables + in GitHub. +7. Point the Coolify application at `coolify-production` while leaving native + auto-deploy enabled. Because the branch already points at the running SHA, + this configuration change does not introduce new code or emit a branch push. +8. Set `COOLIFY_CD_ENABLED=true`, then manually dispatch `CI` on `main`. Its + successful completion triggers CD. The production ref is already on the same + SHA, so the workflow performs no ref write and native branch auto-deploy does + not race the API call. Require the single API deployment, recorded commit, and + full production smoke to succeed. +9. Disable Coolify native auto-deploy for AgoraHub. +10. Verify through Coolify's persisted settings that auto-deploy is false, CD is + enabled, and the application is healthy with the expected SHA and no paired + native/API deployment for the controlled CI event. + +If the controlled API deployment fails before step 9, native auto-deploy +remains active and no authority has been removed. If verification fails after +step 9, immediately re-enable native auto-deploy, set +`COOLIFY_CD_ENABLED=false`, leave +`coolify-production` at the last green SHA, and diagnose without moving the +production ref. + +## Testing and Review + +The repository already has `scripts/ci-workflow.test.ts` for structural workflow +contracts. Implementation adds focused tests that first fail against the current +workflow and then enforce at least these properties: + +- deployment only admits successful `CI` runs for `main`, with no direct deploy + dispatch bypass; +- current-`main` SHA equality is required before the deployment ref moves; +- `coolify-production` receives the verified event SHA; +- deployment concurrency is serialized rather than canceled; +- the mode variable is always required; when enabled, every API variable/secret + is required and missing credentials fail; +- Coolify requests contain both Cloudflare Access headers and the Coolify bearer + header; +- the queue response is validated and the returned deployment is polled; +- terminal status and deployed commit SHA are fail-closed; +- the existing release smoke runs only after the correct deployment finishes. + +The workflow must also pass `actionlint`, the focused workflow contract test, +the full repository suite (443 baseline tests plus the new workflow guards) with +`.worktrees/**` excluded in this local multi-worktree checkout, lint, typecheck, +and production build. Every new guard +test is mutation-checked in a copy by removing the mechanism it guards and +observing the targeted test fail. + +After implementation, the normal independent review pipeline applies before any +push. The external configuration is verified read-only after mutation: GitHub +reports the expected secret/variable names, Coolify reports the production branch +and `is_auto_deploy_enabled = false`, and the final deploy run provides the +deployment UUID, exact SHA, terminal status, and 11/11 smoke evidence. + +## Acceptance Criteria + +The work is complete only when all of the following are true: + +1. `v0.2.0-alpha.0` remains untouched on `63c4585`; the deployment change is a + later commit. +2. A failing, canceled, pull-request, or superseded CI run cannot move the + production ref or call Coolify. +3. A successful current-`main` CI run sets or retains `coolify-production` at + exactly its `head_sha` and no other SHA. +4. GitHub reaches Coolify through Cloudflare Service Auth and a least-privilege + Coolify token; neither token appears in files or logs. +5. GitHub waits for the matching Coolify deployment and fails unless status is + `finished` and the recorded commit is the verified SHA. +6. The deployed production origin passes the existing 11-check release smoke. +7. Coolify native auto-deploy is disabled only after the controlled replacement + deployment succeeds. +8. A read-only final audit shows one enabled deployment authority, the expected + running SHA, a healthy app/database, `COOLIFY_CD_ENABLED=true`, and no paired + native/API deployment for the controlled CI event. +9. The AgoraHub project note, infrastructure resource note, and daily note record + the final architecture, credential rotation responsibility, verification + evidence, and rollback procedure without recording secret values. From 9b31a330d32556610d7906525f7682b2ad1f2d45 Mon Sep 17 00:00:00 2001 From: Codevena Date: Tue, 11 Aug 2026 15:41:51 +0200 Subject: [PATCH 2/4] docs: plan CI-gated Coolify deployment --- .../2026-08-11-ci-gated-coolify-deploy.md | 1357 +++++++++++++++++ ...26-08-11-ci-gated-coolify-deploy-design.md | 112 +- 2 files changed, 1434 insertions(+), 35 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-11-ci-gated-coolify-deploy.md diff --git a/docs/superpowers/plans/2026-08-11-ci-gated-coolify-deploy.md b/docs/superpowers/plans/2026-08-11-ci-gated-coolify-deploy.md new file mode 100644 index 0000000..d920c87 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-ci-gated-coolify-deploy.md @@ -0,0 +1,1357 @@ +# CI-Gated Coolify Deployment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make GitHub Actions the sole AgoraHub deployment authority so only the exact current `main` SHA from a successful `CI` run can reach Coolify. + +**Architecture:** A testable TypeScript controller validates the CI/cutover context, uses a dedicated repository-scoped GitHub App to pin the ruleset-protected `coolify-production` ref to the verified SHA, re-verifies that ref and Coolify's configured branch, calls Coolify through Cloudflare Service Auth, polls the exact deployment against one absolute 20-minute deadline, and verifies its commit. The GitHub workflow only supplies trusted event data, installs the pinned toolchain, calls that controller, and runs the existing 11-check production smoke after a verified deployment. + +**Tech Stack:** GitHub Actions, TypeScript 5.9, Vitest 4, Node 22 native `fetch`, Coolify 4.0.0-beta.470 API, Cloudflare Access, `gh`, `actionlint`. + +## Global Constraints + +- The frozen tag `v0.2.0-alpha.0` remains on `63c4585114a01211b1767daed2ba99a1e78bad40`. +- No direct deploy `workflow_dispatch`; a manual redeploy starts `CI` on `main` and must pass first. +- Coolify application UUID is `z5eij4n8c4ubvxsmtpb507p2`; deploy branch is `coolify-production`. +- Enabled mode fails closed unless repository, API base URL, application UUID, + and deploy branch exactly match the approved AgoraHub constants. +- A dedicated GitHub App is the sole ruleset bypass actor allowed to update + `coolify-production`; the workflow's ordinary `GITHUB_TOKEN` remains read-only. +- Permanent CD mode is `COOLIFY_CD_ENABLED=true`; only the controlled cutover uses explicit `false`. +- Coolify credentials carry exactly `read` and `deploy`; never `write` or `root`. +- Every Coolify request uses the bearer token plus both Cloudflare Access service-token headers. +- Missing/invalid mode always fails. When enabled, every missing variable or secret fails. +- Deployments are serialized with `cancel-in-progress: false` until Coolify and the production smoke finish. +- The deployment queue must contain exactly one AgoraHub deployment; terminal status must be `finished` and `commit` must equal the successful CI `head_sha`. +- One injected monotonic deadline bounds the whole enabled controller to 20 + minutes; each request timeout and poll sleep is capped to the remaining time. +- Never write token values to Git, Brain, terminal history, review artifacts, or logs. +- No external configuration mutation until local TDD, static gates, mutation checks, and independent reviews pass. +- No push, PR, or merge without a fresh explicit Markus authorization at Task 4. +- If an implementation brief is contradictory or incomplete: ask before guessing. + +## File Structure + +- Create `scripts/deploy-coolify.ts`: pure configuration validation plus injected GitHub/Coolify deployment state machine and a thin CLI/output wrapper. +- Create `scripts/deploy-coolify.test.ts`: behavioral tests using injected `fetch` and `sleep`; no live GitHub/Coolify calls. +- Modify `scripts/ci-workflow.test.ts`: structural contracts for the small GitHub Actions orchestrator. +- Modify `.github/workflows/deploy.yml`: trusted-event admission, pinned checkout, controller invocation, conditional production smoke. +- Update `docs/superpowers/specs/2026-08-11-ci-gated-coolify-deploy-design.md` before implementation with the Round-1 security corrections: dedicated ref-writer identity/ruleset, fixed-target validation, absolute deadline, and full rollback after the branch-authority switch. +- Update Brain notes after the live cutover, outside the Git repository. + +## Automated Guard Quantities + +These numbers are the paper half of every new regression guard. Each mutation in +Task 3 must change the “with mechanism” value to the “without mechanism” value +and make its named test fail. + +| Guarded quantity | With mechanism | One-at-a-time mutation | +|---|---:|---:| +| Required strict CD-mode checks | 1 | 0 | +| Direct deploy triggers in `deploy.yml` | 0 | 1 (`workflow_dispatch`) | +| Successful-event predicates required before job admission | 4 | 3 | +| Deploy concurrency-group declarations | 1 | 0 | +| `cancel-in-progress: true` settings in the deploy workflow | 0 | 1 | +| Deploy job timeout in minutes | 40 | absent | +| Required Coolify/Access auth headers per API request | 3 | 2 | +| Approved fixed target values enforced before networking | 4 | 3 | +| Distinct Git ref SHAs checked before queueing (`main`, final deploy ref) | 2 | 1 | +| Coolify application-branch predicates before queueing | 1 | 0 | +| Coolify deployments accepted from one queue response | exactly 1 | 0 or 2 accepted by lax parsing | +| Successful terminal predicates (`status`, exact `commit`) | 2 | 1 (`status` only) | +| Coolify terminal statuses handled explicitly | 5 | 4 | +| Unknown-status rejection guards | 1 | 0 | +| Maximum controller deadline in enabled mode | 20 minutes | absent/unbounded | +| Remaining-time caps (`request`, `sleep`) | 2 | 1 | +| Failed requests labeled with their operation/deployment UUID | 1 | 0 | +| Central manual-redirect policies | 1 | 0 | +| Post-deploy smoke-chain bindings (`id`, condition, origin, command) | 4 | 3 | +| Missing enabled-mode controller configuration paths that fail | 9 | 8 | +| Network requests in explicit disabled mode | 0 | 1+ | +| Deploy-ref writes when it already equals the verified SHA | 0 | 1 with an unconditional update | +| GitHub ref-update calls using the plural `/git/refs/` contract | 1 | 0 | +| Ordinary `GITHUB_TOKEN` contents-write grants | 0 | 1 | +| Persisted contents-write checkout credentials | 0 | 1 | +| Dedicated-App token controls (pinned action, enabled-mode condition, permission, output source) | 4 | 3 | +| Verified-SHA bindings (checkout ref, controller input) | 2 | 1 | +| Protected configuration names passed by the workflow | 9 | 8 | +| Green skip fallbacks for missing production credentials | 0 | 1 | + +Operational cutover guards are verified from external state rather than by +source mutation: exactly one GitHub App may bypass the production-ref ruleset; +the ruleset targets exactly `refs/heads/coolify-production`; and every rollback +after the Coolify branch switch restores all three authority fields +(`COOLIFY_CD_ENABLED=false`, Coolify branch `main`, native auto-deploy enabled). + +--- + +### Task 1: Exact-SHA deployment controller + +**Files:** +- Create: `scripts/deploy-coolify.test.ts` +- Create: `scripts/deploy-coolify.ts` + +**Interfaces:** +- Consumes: `COOLIFY_CD_ENABLED`, `VERIFIED_SHA`, `GITHUB_REPOSITORY`, `GITHUB_TOKEN`, `COOLIFY_API_BASE_URL`, `COOLIFY_APP_UUID`, `COOLIFY_DEPLOY_BRANCH`, `COOLIFY_API_TOKEN`, `CF_ACCESS_CLIENT_ID`, `CF_ACCESS_CLIENT_SECRET`, and `GITHUB_OUTPUT`. +- Produces: `readDeployConfig(env)`, `runCoolifyDeployment(config, dependencies)`, and GitHub step outputs `deployed`, `outcome`, and optional `deployment_uuid`. + +- [ ] **Step 1: Write the failing configuration and state-machine tests** + +Create `scripts/deploy-coolify.test.ts` with these concrete cases: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { + readDeployConfig, + runCoolifyDeployment, + type EnabledDeployConfig, + type FetchLike, +} from "./deploy-coolify"; + +const SHA = "a".repeat(40); +const OLD_SHA = "b".repeat(40); + +function enabledConfig(overrides: Partial = {}): EnabledDeployConfig { + return { + enabled: true, + verifiedSha: SHA, + githubRepository: "Codevena/AgoraHub", + githubToken: "github-token", + coolifyApiBaseUrl: "https://panel.codevena.dev/api/v1", + coolifyAppUuid: "z5eij4n8c4ubvxsmtpb507p2", + coolifyDeployBranch: "coolify-production", + coolifyApiToken: "coolify-token", + cfAccessClientId: "access-id", + cfAccessClientSecret: "access-secret", + ...overrides, + }; +} + +function json(value: unknown, status = 200): Response { + return Response.json(value, { status }); +} + +describe("readDeployConfig", () => { + it.each([undefined, "", "yes", "TRUE"])("rejects invalid CD mode %s", (mode) => { + expect(() => readDeployConfig({ COOLIFY_CD_ENABLED: mode })).toThrow( + /COOLIFY_CD_ENABLED must be exactly false or true/ + ); + }); + + it("allows explicit cutover-disabled mode without credentials", () => { + expect(readDeployConfig({ COOLIFY_CD_ENABLED: "false" })).toEqual({ enabled: false }); + }); + + it.each([ + "VERIFIED_SHA", + "GITHUB_REPOSITORY", + "GITHUB_TOKEN", + "COOLIFY_API_BASE_URL", + "COOLIFY_APP_UUID", + "COOLIFY_DEPLOY_BRANCH", + "COOLIFY_API_TOKEN", + "CF_ACCESS_CLIENT_ID", + "CF_ACCESS_CLIENT_SECRET", + ])("rejects enabled mode when %s is missing", (name) => { + const env: Record = { + COOLIFY_CD_ENABLED: "true", + VERIFIED_SHA: SHA, + GITHUB_REPOSITORY: "Codevena/AgoraHub", + GITHUB_TOKEN: "github-token", + COOLIFY_API_BASE_URL: "https://panel.codevena.dev/api/v1", + COOLIFY_APP_UUID: "z5eij4n8c4ubvxsmtpb507p2", + COOLIFY_DEPLOY_BRANCH: "coolify-production", + COOLIFY_API_TOKEN: "coolify-token", + CF_ACCESS_CLIENT_ID: "access-id", + CF_ACCESS_CLIENT_SECRET: "access-secret", + }; + delete env[name]; + expect(() => readDeployConfig(env)).toThrow( + new RegExp(`^${name} is required when COOLIFY_CD_ENABLED=true$`) + ); + }); + + it.each([ + ["GITHUB_REPOSITORY", "someone/else"], + ["COOLIFY_API_BASE_URL", "https://evil.example/api/v1"], + ["COOLIFY_APP_UUID", "anotherapp"], + ["COOLIFY_DEPLOY_BRANCH", "main"], + ])("rejects drifted approved target %s before networking", (name, value) => { + const env: Record = { + COOLIFY_CD_ENABLED: "true", + VERIFIED_SHA: SHA, + GITHUB_REPOSITORY: "Codevena/AgoraHub", + GITHUB_TOKEN: "github-app-token", + COOLIFY_API_BASE_URL: "https://panel.codevena.dev/api/v1", + COOLIFY_APP_UUID: "z5eij4n8c4ubvxsmtpb507p2", + COOLIFY_DEPLOY_BRANCH: "coolify-production", + COOLIFY_API_TOKEN: "coolify-token", + CF_ACCESS_CLIENT_ID: "access-id", + CF_ACCESS_CLIENT_SECRET: "access-secret", + }; + env[name] = value; + expect(() => readDeployConfig(env)).toThrow(/approved AgoraHub target/); + }); +}); + +describe("runCoolifyDeployment", () => { + it("performs no network work while the cutover interlock is false", async () => { + const fetchImpl = vi.fn(); + await expect(runCoolifyDeployment({ enabled: false }, { fetchImpl })).resolves.toEqual({ + status: "disabled", + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("does not deploy a successful CI SHA superseded on main", async () => { + const fetchImpl = vi.fn(async () => json({ object: { sha: OLD_SHA } })); + await expect(runCoolifyDeployment(enabledConfig(), { fetchImpl })).resolves.toEqual({ + status: "superseded", + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("pins the deploy ref, authenticates every Coolify call, and verifies the finished SHA", async () => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: OLD_SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ git_branch: "coolify-production" }), + json({ deployments: [{ resource_uuid: "z5eij4n8c4ubvxsmtpb507p2", deployment_uuid: "deploy-1" }] }), + json({ status: "queued", commit: SHA }), + json({ status: "in_progress", commit: SHA }), + json({ status: "finished", commit: SHA }), + ]; + const fetchImpl = vi.fn(async () => responses.shift() ?? json({}, 500)); + const sleepImpl = vi.fn(async () => undefined); + + await expect( + runCoolifyDeployment(enabledConfig(), { fetchImpl, sleepImpl }) + ).resolves.toEqual({ status: "deployed", deploymentUuid: "deploy-1" }); + + expect(fetchImpl).toHaveBeenCalledTimes(9); + const patchRequest = new Request(fetchImpl.mock.calls[2][0], fetchImpl.mock.calls[2][1]); + expect(patchRequest.url).toBe( + "https://api.github.com/repos/Codevena/AgoraHub/git/refs/heads/coolify-production" + ); + expect(patchRequest.method).toBe("PATCH"); + expect(await patchRequest.json()).toEqual({ sha: SHA, force: true }); + + const finalRefRequest = new Request( + fetchImpl.mock.calls[3][0], + fetchImpl.mock.calls[3][1] + ); + expect(finalRefRequest.url).toBe( + "https://api.github.com/repos/Codevena/AgoraHub/git/ref/heads/coolify-production" + ); + + for (const call of fetchImpl.mock.calls.slice(4)) { + const request = new Request(call[0], call[1]); + if (request.url.startsWith("https://panel.codevena.dev/")) { + expect(request.headers.get("authorization")).toBe("Bearer coolify-token"); + expect(request.headers.get("cf-access-client-id")).toBe("access-id"); + expect(request.headers.get("cf-access-client-secret")).toBe("access-secret"); + } + expect(request.redirect).toBe("manual"); + } + expect(sleepImpl).toHaveBeenCalledTimes(2); + }); + + it("does not rewrite a deploy ref that already equals the verified SHA", async () => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ git_branch: "coolify-production" }), + json({ deployments: [{ resource_uuid: "z5eij4n8c4ubvxsmtpb507p2", deployment_uuid: "deploy-1" }] }), + json({ status: "finished", commit: SHA }), + ]; + const fetchImpl = vi.fn(async () => responses.shift() ?? json({}, 500)); + await expect(runCoolifyDeployment(enabledConfig(), { fetchImpl })).resolves.toEqual({ + status: "deployed", + deploymentUuid: "deploy-1", + }); + expect(fetchImpl).toHaveBeenCalledTimes(6); + expect(fetchImpl.mock.calls.map((call) => new Request(call[0], call[1]).method)) + .toEqual(["GET", "GET", "GET", "GET", "POST", "GET"]); + }); + + it("rejects Coolify branch drift before queueing", async () => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ git_branch: "main" }), + ]; + const fetchImpl = vi.fn(async () => responses.shift() ?? json({}, 500)); + await expect(runCoolifyDeployment(enabledConfig(), { fetchImpl })).rejects.toThrow( + /Coolify application branch/ + ); + expect(fetchImpl).toHaveBeenCalledTimes(4); + }); + + it("rejects a final deploy ref that changed after the initial check", async () => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: OLD_SHA } }), + ]; + const fetchImpl = vi.fn(async () => responses.shift() ?? json({}, 500)); + await expect(runCoolifyDeployment(enabledConfig(), { fetchImpl })).rejects.toThrow( + /final deploy ref does not match/ + ); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it.each(["failed", "cancelled-by-user", "unknown"])( + "fails closed for terminal/unknown status %s", + async (status) => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ git_branch: "coolify-production" }), + json({ deployments: [{ resource_uuid: "z5eij4n8c4ubvxsmtpb507p2", deployment_uuid: "deploy-1" }] }), + json({ status, commit: SHA }), + ]; + await expect( + runCoolifyDeployment(enabledConfig(), { + fetchImpl: vi.fn(async () => responses.shift()!), + sleepImpl: vi.fn(async () => undefined), + }) + ).rejects.toThrow(new RegExp(status)); + } + ); + + it("rejects a finished deployment for a different commit", async () => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ git_branch: "coolify-production" }), + json({ deployments: [{ resource_uuid: "z5eij4n8c4ubvxsmtpb507p2", deployment_uuid: "deploy-1" }] }), + json({ status: "finished", commit: OLD_SHA }), + ]; + await expect( + runCoolifyDeployment(enabledConfig(), { + fetchImpl: vi.fn(async () => responses.shift()!), + }) + ).rejects.toThrow(/does not match verified SHA/); + }); + + it("rejects zero, multiple, or wrong-resource queue entries", async () => { + for (const deployments of [ + [], + [ + { resource_uuid: "z5eij4n8c4ubvxsmtpb507p2", deployment_uuid: "deploy-1" }, + { resource_uuid: "z5eij4n8c4ubvxsmtpb507p2", deployment_uuid: "deploy-2" }, + ], + [{ resource_uuid: "wrong", deployment_uuid: "deploy-1" }], + [{ resource_uuid: "z5eij4n8c4ubvxsmtpb507p2", deployment_uuid: "deploy-1\ninjected=true" }], + ]) { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ git_branch: "coolify-production" }), + json({ deployments }), + ]; + await expect( + runCoolifyDeployment(enabledConfig(), { + fetchImpl: vi.fn(async () => responses.shift()!), + }) + ).rejects.toThrow(/exactly one AgoraHub deployment/); + } + }); + + it("enforces one absolute non-divisible deadline across requests and sleeps", async () => { + let now = 0; + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ git_branch: "coolify-production" }), + json({ deployments: [{ resource_uuid: "z5eij4n8c4ubvxsmtpb507p2", deployment_uuid: "deploy-1" }] }), + ]; + const fetchImpl = vi.fn(async () => + responses.shift() ?? json({ status: "in_progress", commit: SHA }) + ); + await expect( + runCoolifyDeployment(enabledConfig(), { + fetchImpl, + nowImpl: () => now, + sleepImpl: vi.fn(async (milliseconds) => { now += milliseconds; }), + pollIntervalMs: 10_000, + deadlineMs: 15_000, + }) + ).rejects.toThrow(/deadline.*deploy-1/); + expect(now).toBe(15_000); + }); + + it("caps every request timeout to the remaining absolute deadline", async () => { + let now = 0; + let call = 0; + const timeoutBudgets: number[] = []; + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ git_branch: "coolify-production" }), + json({ deployments: [{ resource_uuid: "z5eij4n8c4ubvxsmtpb507p2", deployment_uuid: "deploy-1" }] }), + json({ status: "finished", commit: SHA }), + ]; + const fetchImpl = vi.fn(async () => { + const response = responses.shift() ?? json({}, 500); + if (call === 0) now = 19_000; + call += 1; + return response; + }); + + await expect(runCoolifyDeployment(enabledConfig(), { + fetchImpl, + nowImpl: () => now, + deadlineMs: 20_000, + requestTimeoutMs: 15_000, + timeoutSignalImpl: (milliseconds) => { + timeoutBudgets.push(milliseconds); + return new AbortController().signal; + }, + })).resolves.toEqual({ status: "deployed", deploymentUuid: "deploy-1" }); + expect(timeoutBudgets).toEqual([15_000, 1_000, 1_000, 1_000, 1_000, 1_000]); + }); + + it("labels an aborted polling request with its deployment UUID", async () => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ git_branch: "coolify-production" }), + json({ deployments: [{ resource_uuid: "z5eij4n8c4ubvxsmtpb507p2", deployment_uuid: "deploy-1" }] }), + ]; + const fetchImpl = vi.fn(async () => { + const response = responses.shift(); + if (response) return response; + throw new DOMException("The operation timed out", "TimeoutError"); + }); + await expect(runCoolifyDeployment(enabledConfig(), { fetchImpl })).rejects.toThrow( + /Coolify deployment deploy-1 request failed.*timed out/i + ); + }); +}); +``` + +- [ ] **Step 2: Run the focused test and verify the correct red state** + +Run: + +```bash +pnpm exec vitest run scripts/deploy-coolify.test.ts --exclude '.worktrees/**' +``` + +Expected: FAIL before collection with `Cannot find module './deploy-coolify'`. +This is the intended red state: the production controller does not exist yet. + +- [ ] **Step 3: Implement the minimal controller** + +Create `scripts/deploy-coolify.ts` with these exact public types and control +flow. Internal helper names may differ only if the exported interface and tested +behavior remain identical. + +```ts +import { appendFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; +type SleepLike = (milliseconds: number) => Promise; +type NowLike = () => number; +type TimeoutSignalLike = (milliseconds: number) => AbortSignal; + +export interface DisabledDeployConfig { enabled: false } +export interface EnabledDeployConfig { + enabled: true; + verifiedSha: string; + githubRepository: string; + githubToken: string; + coolifyApiBaseUrl: string; + coolifyAppUuid: string; + coolifyDeployBranch: string; + coolifyApiToken: string; + cfAccessClientId: string; + cfAccessClientSecret: string; +} +export type DeployConfig = DisabledDeployConfig | EnabledDeployConfig; +export type DeployOutcome = + | { status: "disabled" } + | { status: "superseded" } + | { status: "deployed"; deploymentUuid: string }; + +interface DeployDependencies { + fetchImpl?: FetchLike; + sleepImpl?: SleepLike; + nowImpl?: NowLike; + pollIntervalMs?: number; + requestTimeoutMs?: number; + deadlineMs?: number; + timeoutSignalImpl?: TimeoutSignalLike; +} + +const SHA_PATTERN = /^[0-9a-f]{40}$/i; +const DEPLOYMENT_UUID_PATTERN = /^[a-z0-9_-]+$/i; +const APPROVED_REPOSITORY = "Codevena/AgoraHub"; +const APPROVED_API_BASE_URL = "https://panel.codevena.dev/api/v1"; +const APPROVED_APP_UUID = "z5eij4n8c4ubvxsmtpb507p2"; +const APPROVED_DEPLOY_BRANCH = "coolify-production"; +const DEFAULT_DEADLINE_MS = 20 * 60 * 1_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; + +function required(env: Record, name: string): string { + const value = env[name]?.trim(); + if (!value) throw new Error(`${name} is required when COOLIFY_CD_ENABLED=true`); + return value; +} + +export function readDeployConfig( + env: Record = process.env +): DeployConfig { + const mode = env.COOLIFY_CD_ENABLED?.trim(); + if (mode !== "false" && mode !== "true") { + throw new Error("COOLIFY_CD_ENABLED must be exactly false or true"); + } + if (mode === "false") return { enabled: false }; + + const config: EnabledDeployConfig = { + enabled: true, + verifiedSha: required(env, "VERIFIED_SHA"), + githubRepository: required(env, "GITHUB_REPOSITORY"), + githubToken: required(env, "GITHUB_TOKEN"), + coolifyApiBaseUrl: required(env, "COOLIFY_API_BASE_URL"), + coolifyAppUuid: required(env, "COOLIFY_APP_UUID"), + coolifyDeployBranch: required(env, "COOLIFY_DEPLOY_BRANCH"), + coolifyApiToken: required(env, "COOLIFY_API_TOKEN"), + cfAccessClientId: required(env, "CF_ACCESS_CLIENT_ID"), + cfAccessClientSecret: required(env, "CF_ACCESS_CLIENT_SECRET"), + }; + + if (!SHA_PATTERN.test(config.verifiedSha)) { + throw new Error("VERIFIED_SHA must be a 40-character Git SHA"); + } + for (const [actual, expected, name] of [ + [config.githubRepository, APPROVED_REPOSITORY, "GITHUB_REPOSITORY"], + [config.coolifyApiBaseUrl, APPROVED_API_BASE_URL, "COOLIFY_API_BASE_URL"], + [config.coolifyAppUuid, APPROVED_APP_UUID, "COOLIFY_APP_UUID"], + [config.coolifyDeployBranch, APPROVED_DEPLOY_BRANCH, "COOLIFY_DEPLOY_BRANCH"], + ] as const) { + if (actual !== expected) { + throw new Error(`${name} does not match the approved AgoraHub target`); + } + } + return config; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function requestJson( + fetchImpl: FetchLike, + input: string, + init: RequestInit, + label: string, + deadlineAt: number, + nowImpl: NowLike, + requestTimeoutMs: number, + timeoutSignalImpl: TimeoutSignalLike +): Promise { + const remainingMs = Math.floor(deadlineAt - nowImpl()); + if (remainingMs <= 0) throw new Error(`${label} exceeded the deployment deadline`); + let response: Response; + try { + response = await fetchImpl(input, { + ...init, + redirect: "manual", + signal: init.signal ?? timeoutSignalImpl( + Math.max(1, Math.min(requestTimeoutMs, remainingMs)) + ), + }); + } catch (error) { + const detail = error instanceof Error ? `: ${error.message}` : ""; + throw new Error(`${label} request failed${detail}`, { cause: error }); + } + if (response.status >= 300 && response.status < 400) { + throw new Error(`${label} returned an unexpected redirect (${response.status})`); + } + if (!response.ok) throw new Error(`${label} failed with HTTP ${response.status}`); + try { return await response.json(); } + catch { throw new Error(`${label} returned invalid JSON`); } +} + +function refSha(value: unknown, label: string): string { + const sha = isRecord(value) && isRecord(value.object) ? value.object.sha : undefined; + if (typeof sha !== "string" || !SHA_PATTERN.test(sha)) throw new Error(`${label} returned no valid SHA`); + return sha; +} + +export async function runCoolifyDeployment( + config: DeployConfig, + dependencies: DeployDependencies = {} +): Promise { + if (!config.enabled) return { status: "disabled" }; + const fetchImpl = dependencies.fetchImpl ?? fetch; + const sleepImpl = dependencies.sleepImpl ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const nowImpl = dependencies.nowImpl ?? performance.now.bind(performance); + const pollIntervalMs = dependencies.pollIntervalMs ?? 10_000; + const requestTimeoutMs = dependencies.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const deadlineMs = dependencies.deadlineMs ?? DEFAULT_DEADLINE_MS; + const timeoutSignalImpl = dependencies.timeoutSignalImpl ?? ((ms) => AbortSignal.timeout(ms)); + if (!Number.isInteger(pollIntervalMs) || pollIntervalMs < 1) throw new Error("pollIntervalMs must be positive"); + if (!Number.isInteger(requestTimeoutMs) || requestTimeoutMs < 1) throw new Error("requestTimeoutMs must be positive"); + if (!Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > DEFAULT_DEADLINE_MS) { + throw new Error("deadlineMs must be positive and at most 20 minutes"); + } + const deadlineAt = nowImpl() + deadlineMs; + const getJson = (input: string, init: RequestInit, label: string) => + requestJson( + fetchImpl, + input, + init, + label, + deadlineAt, + nowImpl, + requestTimeoutMs, + timeoutSignalImpl + ); + + const githubHeaders = { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${config.githubToken}`, + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }; + const githubBase = `https://api.github.com/repos/${config.githubRepository}`; + const main = await getJson(`${githubBase}/git/ref/heads/main`, { headers: githubHeaders }, "GitHub main ref"); + if (refSha(main, "GitHub main ref") !== config.verifiedSha) return { status: "superseded" }; + + const encodedBranch = encodeURIComponent(config.coolifyDeployBranch); + const deployRefReadUrl = `${githubBase}/git/ref/heads/${encodedBranch}`; + const deployRefUpdateUrl = `${githubBase}/git/refs/heads/${encodedBranch}`; + const deployRef = await getJson(deployRefReadUrl, { headers: githubHeaders }, "GitHub deploy ref"); + if (refSha(deployRef, "GitHub deploy ref") !== config.verifiedSha) { + await getJson(deployRefUpdateUrl, { + method: "PATCH", + headers: githubHeaders, + body: JSON.stringify({ sha: config.verifiedSha, force: true }), + }, "GitHub deploy ref update"); + } + + const finalDeployRef = await getJson( + deployRefReadUrl, + { headers: githubHeaders }, + "GitHub final deploy ref" + ); + if (refSha(finalDeployRef, "GitHub final deploy ref") !== config.verifiedSha) { + throw new Error("GitHub final deploy ref does not match the verified SHA"); + } + + const coolifyHeaders = { + Authorization: `Bearer ${config.coolifyApiToken}`, + "CF-Access-Client-Id": config.cfAccessClientId, + "CF-Access-Client-Secret": config.cfAccessClientSecret, + "Content-Type": "application/json", + }; + const application = await getJson( + `${config.coolifyApiBaseUrl}/applications/${config.coolifyAppUuid}`, + { headers: coolifyHeaders }, + "Coolify application" + ); + if (!isRecord(application) || application.git_branch !== config.coolifyDeployBranch) { + throw new Error("Coolify application branch does not match the approved deploy branch"); + } + + const queued = await getJson(`${config.coolifyApiBaseUrl}/deploy`, { + method: "POST", + headers: coolifyHeaders, + body: JSON.stringify({ uuid: config.coolifyAppUuid }), + }, "Coolify deploy queue"); + const deployments = isRecord(queued) && Array.isArray(queued.deployments) ? queued.deployments : null; + if (!deployments || deployments.length !== 1 || !isRecord(deployments[0]) || + deployments[0].resource_uuid !== config.coolifyAppUuid || + typeof deployments[0].deployment_uuid !== "string" || + !DEPLOYMENT_UUID_PATTERN.test(deployments[0].deployment_uuid)) { + throw new Error("Coolify must queue exactly one AgoraHub deployment"); + } + const deploymentUuid = deployments[0].deployment_uuid; + + while (true) { + if (nowImpl() >= deadlineAt) { + throw new Error(`Coolify deployment exceeded the deadline: ${deploymentUuid}`); + } + const deployment = await getJson( + `${config.coolifyApiBaseUrl}/deployments/${encodeURIComponent(deploymentUuid)}`, + { headers: coolifyHeaders }, + `Coolify deployment ${deploymentUuid}` + ); + const status = isRecord(deployment) ? deployment.status : undefined; + const commit = isRecord(deployment) ? deployment.commit : undefined; + if (status === "finished") { + if (commit !== config.verifiedSha) throw new Error(`Coolify finished commit ${String(commit)} does not match verified SHA`); + return { status: "deployed", deploymentUuid }; + } + if (status === "failed" || status === "cancelled-by-user") throw new Error(`Coolify deployment ${deploymentUuid} ended with ${status}`); + if (status !== "queued" && status !== "in_progress") throw new Error(`Coolify deployment ${deploymentUuid} returned unknown status ${String(status)}`); + const remainingMs = Math.floor(deadlineAt - nowImpl()); + if (remainingMs <= 0) { + throw new Error(`Coolify deployment exceeded the deadline: ${deploymentUuid}`); + } + await sleepImpl(Math.min(pollIntervalMs, remainingMs)); + } +} + +async function main(): Promise { + const outcome = await runCoolifyDeployment(readDeployConfig()); + const outputPath = process.env.GITHUB_OUTPUT?.trim(); + if (!outputPath) throw new Error("GITHUB_OUTPUT is required"); + appendFileSync(outputPath, `deployed=${outcome.status === "deployed"}\noutcome=${outcome.status}\n`); + if (outcome.status === "deployed") appendFileSync(outputPath, `deployment_uuid=${outcome.deploymentUuid}\n`); + console.log(`Coolify CD outcome: ${outcome.status}`); +} + +const invokedPath = process.argv[1]; +if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : "Coolify deployment failed"); + process.exitCode = 1; + }); +} +``` + +- [ ] **Step 4: Run the focused tests and make them green** + +Run: + +```bash +pnpm exec vitest run scripts/deploy-coolify.test.ts --exclude '.worktrees/**' +``` + +Expected: all controller tests PASS; fetch counts are 0 disabled, 1 +superseded, and 9 for the branch-update happy path. The plan's TypeScript blocks +must also compile under the repository `tsconfig.json` before implementation. + +- [ ] **Step 5: Run focused lint/type checking and checkpoint review** + +Run: + +```bash +pnpm exec eslint scripts/deploy-coolify.ts scripts/deploy-coolify.test.ts --max-warnings=0 +pnpm typecheck +git diff --check +``` + +Then run the fast inline-diff checkpoint reviewer from the global policy. Fix +every CRITICAL/WARN before Task 2. Do not commit yet; the workflow is the other +half of this contract. + +--- + +### Task 2: Trusted GitHub Actions orchestrator + +**Files:** +- Modify: `scripts/ci-workflow.test.ts` +- Modify: `.github/workflows/deploy.yml` +- Test: `scripts/ci-workflow.test.ts` + +**Interfaces:** +- Consumes: successful `workflow_run` metadata for `CI`, repository variables and secrets from the design. +- Produces: environment for `scripts/deploy-coolify.ts`; invokes `pnpm release:smoke` only when output `deployed=true`. + +- [ ] **Step 1: Add failing workflow contract tests** + +Extend `scripts/ci-workflow.test.ts` with a separate `describe("Deploy workflow")` +that reads `.github/workflows/deploy.yml` and asserts: + +```ts +describe("Deploy workflow", () => { + const workflow = readFileSync( + new URL("../.github/workflows/deploy.yml", import.meta.url), + "utf8" + ); + + it("admits only successful main CI workflow runs", () => { + expect(workflow).toContain('workflows: ["CI"]'); + expect(workflow).toContain("branches: [main]"); + expect(workflow).not.toMatch(/^ workflow_dispatch:/m); + expect(workflow).toContain("workflow_run.conclusion == 'success'"); + expect(workflow).toContain("workflow_run.head_branch == 'main'"); + expect(workflow).toContain("workflow_run.event == 'push'"); + expect(workflow).toContain("workflow_run.event == 'workflow_dispatch'"); + }); + + it("serializes the entire deploy and smoke lifecycle", () => { + expect(workflow).toContain("group: deploy-coolify"); + expect(workflow).toContain("cancel-in-progress: false"); + expect(workflow).toContain("timeout-minutes: 40"); + }); + + it("checks out and deploys the verified event SHA", () => { + expect(workflow).toContain("contents: read"); + expect(workflow).toContain("actions: read"); + expect(workflow).toContain("ref: ${{ github.event.workflow_run.head_sha }}"); + expect(workflow).toContain("persist-credentials: false"); + expect(workflow).toContain( + "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1" + ); + expect(workflow).toContain("if: vars.COOLIFY_CD_ENABLED == 'true'"); + expect(workflow).toContain("permission-contents: write"); + expect(workflow).toContain("GITHUB_TOKEN: ${{ steps.deploy-ref-token.outputs.token }}"); + expect(workflow).toContain("VERIFIED_SHA: ${{ github.event.workflow_run.head_sha }}"); + expect(workflow).toContain("pnpm exec tsx scripts/deploy-coolify.ts"); + }); + + it("passes every protected configuration value without fallback literals", () => { + for (const name of [ + "COOLIFY_CD_ENABLED", + "COOLIFY_API_BASE_URL", + "COOLIFY_APP_UUID", + "COOLIFY_DEPLOY_BRANCH", + "COOLIFY_API_TOKEN", + "CF_ACCESS_CLIENT_ID", + "CF_ACCESS_CLIENT_SECRET", + "DEPLOY_REF_APP_ID", + "DEPLOY_REF_APP_PRIVATE_KEY", + ]) expect(workflow).toContain(name); + expect(workflow).not.toContain("skipping deploy"); + }); + + it("runs the production smoke only after a verified deployment", () => { + expect(workflow).toContain("id: deployment"); + expect(workflow).toContain("if: steps.deployment.outputs.deployed == 'true'"); + expect(workflow).toContain("BASE_URL: https://agorahub.dev"); + expect(workflow).toContain("run: pnpm release:smoke"); + }); +}); +``` + +- [ ] **Step 2: Run the workflow tests and verify the guard numbers are red** + +Run: + +```bash +pnpm exec vitest run scripts/ci-workflow.test.ts --exclude '.worktrees/**' +``` + +Expected: the existing security-job test stays green; all five new deploy +contracts fail against the current workflow. Key recomputed values are direct +dispatch `1` (target `0`), protected configuration names `0` (target `9`), smoke +commands `0` (target `1`), and `cancel-in-progress=true` (target `false`). + +- [ ] **Step 3: Replace the deploy workflow with the minimal orchestrator** + +Replace `.github/workflows/deploy.yml` with: + +```yaml +name: Deploy to Coolify + +on: + workflow_run: + workflows: ["CI"] + types: [completed] + branches: [main] + +permissions: + actions: read + contents: read + +concurrency: + group: deploy-coolify + cancel-in-progress: false + +env: + NODE_VERSION: 22.19.0 + PNPM_VERSION: 9.15.9 + +jobs: + deploy: + name: Exact SHA · Coolify · Production smoke + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main' && + (github.event.workflow_run.event == 'push' || + github.event.workflow_run.event == 'workflow_dispatch') + runs-on: ubuntu-latest + timeout-minutes: 40 + + steps: + - name: Checkout verified CI revision + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + + - name: Mint dedicated production-ref token + id: deploy-ref-token + if: vars.COOLIFY_CD_ENABLED == 'true' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.DEPLOY_REF_APP_ID }} + private-key: ${{ secrets.DEPLOY_REF_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: AgoraHub + permission-contents: write + + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + with: + version: ${{ env.PNPM_VERSION }} + run_install: false + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install frozen dependencies + run: pnpm install --frozen-lockfile + + - name: Deploy exact verified revision + id: deployment + env: + VERIFIED_SHA: ${{ github.event.workflow_run.head_sha }} + GITHUB_TOKEN: ${{ steps.deploy-ref-token.outputs.token }} + COOLIFY_CD_ENABLED: ${{ vars.COOLIFY_CD_ENABLED }} + COOLIFY_API_BASE_URL: ${{ vars.COOLIFY_API_BASE_URL }} + COOLIFY_APP_UUID: ${{ vars.COOLIFY_APP_UUID }} + COOLIFY_DEPLOY_BRANCH: ${{ vars.COOLIFY_DEPLOY_BRANCH }} + COOLIFY_API_TOKEN: ${{ secrets.COOLIFY_API_TOKEN }} + CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} + CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} + run: pnpm exec tsx scripts/deploy-coolify.ts + + - name: Verify deployed production protocols + if: steps.deployment.outputs.deployed == 'true' + env: + BASE_URL: https://agorahub.dev + run: pnpm release:smoke +``` + +- [ ] **Step 4: Make workflow contracts and syntax green** + +Run: + +```bash +pnpm exec vitest run scripts/ci-workflow.test.ts scripts/deploy-coolify.test.ts --exclude '.worktrees/**' +actionlint .github/workflows/ci.yml .github/workflows/deploy.yml .github/workflows/release-smoke.yml +``` + +Expected: all focused tests PASS and actionlint emits no findings. + +- [ ] **Step 5: Checkpoint-review the complete code path** + +Run `git diff --check`, then the fast inline-diff checkpoint reviewer. The +review prompt must verify event trust boundaries, expression/shell injection, +secret exposure, state-machine completeness, and the exact-SHA invariant. Fix +every CRITICAL/WARN before Task 3. + +--- + +### Task 3: Full verification, mutation evidence, and final review + +**Files:** +- Create temporarily: `.review/deploy-mutations.json` +- Modify only if a gate finds a defect: files from Tasks 1–2 +- Remove before commit: `.review/` + +**Interfaces:** +- Consumes: complete uncommitted implementation diff. +- Produces: fresh static evidence, mutation evidence, two independent PASS verdicts, and one local implementation commit. + +- [ ] **Step 1: Run the fresh static gates** + +Run in this order: + +```bash +pnpm lint +pnpm typecheck +pnpm exec vitest run --exclude '.worktrees/**' +pnpm build +actionlint +git diff --check +``` + +Expected: zero lint warnings/errors, typecheck exit 0, 443 baseline tests plus +all new tests green, production build exit 0, actionlint clean, and no whitespace +errors. + +- [ ] **Step 2: Mutation-check every new guard in a disposable copy** + +Create a `mktemp -d` copy containing the working tree but excluding `.git`, +`.worktrees`, `node_modules`, `.next`, and `.review`; link the original +`node_modules` read-only for test execution. Apply and test these mutations one +at a time, restoring the copy between cases: + +1. mode/disabled guards: accept missing mode as disabled, then separately remove + the disabled early return so a disabled run makes a request; +2. required configuration: bypass `required()` separately for each of its nine + enabled-mode paths; +3. fixed target: bypass each of the four approved-value equalities separately; +4. trusted refs: remove the current-`main` comparison, use singular `/git/ref/` + for PATCH, remove the final deploy-ref re-read, and accept a mismatched final + ref as four separate mutations; +5. Coolify target/auth: remove the application-branch comparison and remove + each of the three auth headers separately; separately remove the centralized + `redirect: "manual"` policy; +6. ref write behavior: make the update unconditional when the initial deploy ref + already equals the verified SHA; +7. queue/output safety: accept zero entries, two entries, a wrong resource, and + a newline-bearing deployment UUID as four separate mutations; +8. completion state: accept a mismatched finished commit; separately alter each + of the five explicit status paths (`queued`, `in_progress`, `finished`, + `failed`, `cancelled-by-user`); then separately accept an unknown status; +9. deadline/diagnostics: remove the absolute deadline; separately stop capping + request timeout and sleep to remaining time; then remove the `requestJson` + catch/rethrow that labels polling failures with the deployment UUID; +10. workflow admission: restore direct `workflow_dispatch`; separately remove + each of the conclusion, head-branch, push-event, and manual-CI-event + predicates; +11. workflow serialization: remove the concurrency group, change + `cancel-in-progress` to `true`, and remove the 40-minute job timeout; +12. ref identity/checkout: restore `contents: write` on `GITHUB_TOKEN`; remove + `persist-credentials: false`; separately remove the pinned GitHub App action, + its enabled-mode condition, its `permission-contents: write`, and its output + token source; then remove the verified checkout ref and verified-SHA binding + as separate mutations; +13. protected workflow configuration: remove each of the nine variable/secret + names separately; separately reintroduce a green "skipping deploy" fallback; +14. production smoke: remove the controller step id, the `deployed == 'true'` + condition, the production `BASE_URL`, and the smoke command separately. + +For each mutation run only its named focused test file, require non-zero exit, +and write this JSON record to `.review/deploy-mutations.json`: + +```json +{ + "schema": "agorahub.deploy-mutations.v1", + "cases": [ + { "name": "mode-required", "mutations": 1, "with": 1, "without": 0, "all_failed": true }, + { "name": "disabled-network", "mutations": 1, "with": 0, "without": 1, "all_failed": true }, + { "name": "enabled-config-required", "mutations": 9, "with": 9, "without_each": 8, "all_failed": true }, + { "name": "fixed-target-values", "mutations": 4, "with": 4, "without_each": 3, "all_failed": true }, + { "name": "three-auth-headers", "mutations": 3, "with": 3, "without_each": 2, "all_failed": true }, + { "name": "two-distinct-ref-checks", "mutations": 3, "with": 2, "without_each": 1, "all_failed": true }, + { "name": "plural-ref-update-route", "mutations": 1, "with": 1, "without": 0, "all_failed": true }, + { "name": "coolify-branch-check", "mutations": 1, "with": 1, "without": 0, "all_failed": true }, + { "name": "no-op-ref-writes", "mutations": 1, "with": 0, "without": 1, "all_failed": true }, + { "name": "single-safe-queue-entry", "mutations": 4, "with": 1, "without": "0|2|wrong|unsafe", "all_failed": true }, + { "name": "two-terminal-predicates", "mutations": 1, "with": 2, "without": 1, "all_failed": true }, + { "name": "five-explicit-statuses", "mutations": 5, "with": 5, "without_each": 4, "all_failed": true }, + { "name": "unknown-status-rejection", "mutations": 1, "with": 1, "without": 0, "all_failed": true }, + { "name": "absolute-deadline", "mutations": 1, "with_minutes": 20, "without": "unbounded", "all_failed": true }, + { "name": "remaining-time-caps", "mutations": 2, "with": 2, "without_each": 1, "all_failed": true }, + { "name": "labeled-request-failures", "mutations": 1, "with": 1, "without": 0, "all_failed": true }, + { "name": "manual-redirect-policy", "mutations": 1, "with": 1, "without": 0, "all_failed": true }, + { "name": "no-direct-dispatch", "mutations": 1, "with": 0, "without": 1, "all_failed": true }, + { "name": "trusted-admission-predicates", "mutations": 4, "with": 4, "without_each": 3, "all_failed": true }, + { "name": "concurrency-group", "mutations": 1, "with": 1, "without": 0, "all_failed": true }, + { "name": "cancel-running-deploy", "mutations": 1, "with": 0, "without": 1, "all_failed": true }, + { "name": "deploy-job-timeout", "mutations": 1, "with_minutes": 40, "without": "absent", "all_failed": true }, + { "name": "ordinary-token-write-grant", "mutations": 1, "with": 0, "without": 1, "all_failed": true }, + { "name": "persisted-checkout-credentials", "mutations": 1, "with": 0, "without": 1, "all_failed": true }, + { "name": "dedicated-app-token-controls", "mutations": 4, "with": 4, "without_each": 3, "all_failed": true }, + { "name": "verified-sha-bindings", "mutations": 2, "with": 2, "without_each": 1, "all_failed": true }, + { "name": "protected-workflow-config", "mutations": 9, "with": 9, "without_each": 8, "all_failed": true }, + { "name": "no-green-skip-fallback", "mutations": 1, "with": 0, "without": 1, "all_failed": true }, + { "name": "post-deploy-smoke-bindings", "mutations": 4, "with": 4, "without_each": 3, "all_failed": true } + ] +} +``` + +Every numbered submutation must name the exact focused test it killed; a grouped +entry is true only when all of its individual mutations fail. The manifest's +`mutations` values must total 70 and the reviewer recomputes that total from the +individual records. Confirm the real worktree is unchanged except for intended +Tasks 1–2 files. + +- [ ] **Step 3: Run the two-reviewer final gate** + +Slot A must be an executing reviewer with repo read/execute access. It may run +the controller functions and focused tests but not repeat the full suite. It +must recompute every Guard Quantities value and inspect +`.review/deploy-mutations.json`. + +Slot B is an independent second voice with the full inline diff. Both use the +global CRITICAL/WARN/INFO rubric. Fix all findings and rerun static gates, +mutations affected by the fix, and both reviewers until both verdicts PASS. + +- [ ] **Step 4: Commit the reviewed implementation locally** + +Remove `.review/`, then stage only: + +```bash +git add .github/workflows/deploy.yml scripts/ci-workflow.test.ts scripts/deploy-coolify.ts scripts/deploy-coolify.test.ts +git commit -m "ci: gate Coolify deploys on verified revisions" +``` + +Do not push. Report the commit SHA and stop at the explicit push gate in Task 4. + +--- + +### Task 4: Prepare the disabled cutover and obtain push authorization + +**Files:** +- No repository file changes. +- External state: GitHub repository variable only. + +**Interfaces:** +- Consumes: reviewed local commits and Markus push authorization. +- Produces: explicit disabled interlock before the workflow exists on `main`. + +- [ ] **Step 1: Set and verify the cutover interlock** + +Run: + +```bash +gh variable set COOLIFY_CD_ENABLED --repo Codevena/AgoraHub --body false +gh variable list --repo Codevena/AgoraHub +``` + +Expected: `COOLIFY_CD_ENABLED` exists with value `false`. No other Coolify +variable or secret is required yet. + +- [ ] **Step 2: Stop for explicit push/PR authorization** + +Present the reviewed commit list, test/review evidence, and exact external next +effects. Ask Markus to authorize pushing `ops/ci-gated-coolify-deploy` and +opening a PR. Do not infer this permission from the approved spec or plan. + +- [ ] **Step 3: After authorization, push and open the PR** + +Push only the feature branch and create a PR to `main`. Require PR CI green and +normal review. Do not merge until the diff tree equals the reviewed local tree. + +- [ ] **Step 4: Merge and verify the final native deployment** + +After explicit merge authorization, merge normally. Verify: + +- merged `main` SHA and tree; +- Main `CI` success; +- `Deploy to Coolify` reports the intentional `disabled` outcome; +- Coolify's native GitHub deployment finishes once for the merge SHA; +- `/api/health` is healthy and the running image/queue commit equals merge SHA. + +If any item fails, stop before creating credentials or changing the Coolify +branch. + +--- + +### Task 5: Create protected credentials and prove the replacement path + +**Files:** +- No repository file changes. +- External state: GitHub branch/App/ruleset/variables/secrets, Cloudflare Access application/service token, Coolify API token/application branch. + +**Interfaces:** +- Consumes: the final native merge SHA. +- Produces: one successful API deployment of that same SHA while native auto-deploy remains available as rollback. + +- [ ] **Step 1: Create the pinned production branch at the live SHA** + +Resolve the exact remote `main` SHA and require it to equal the live Coolify +deployment. Then create `refs/heads/coolify-production` at that SHA. Abort if the +branch already exists at a different SHA; never force before identifying why. + +- [ ] **Step 2: Create the dedicated production-ref writer and ruleset** + +Create a private GitHub App named `AgoraHub CD Ref Writer` with only repository +`Contents: Read and write` plus mandatory metadata read, install it only on +`Codevena/AgoraHub`, and generate one private key. Transfer the App ID and private +key directly into GitHub secrets `DEPLOY_REF_APP_ID` and +`DEPLOY_REF_APP_PRIVATE_KEY`; never print or save the private key elsewhere. + +Create an active repository ruleset targeting only +`refs/heads/coolify-production` with creation, update, deletion, and force-push +restrictions. The App installation is the sole `always` bypass actor; do not add +repository roles, users, teams, or another integration. Verify the ruleset JSON +read-only: exact include ref, enforcement `active`, the four restrictions, and +exactly one bypass actor whose integration/App ID equals `DEPLOY_REF_APP_ID`. +Also mint a short-lived App token through the pinned action and prove it can read +the ref; do not move the ref during this setup proof. + +- [ ] **Step 3: Create the Cloudflare path-scoped Service Auth** + +In Cloudflare Zero Trust: + +1. create service token `GitHub Actions - AgoraHub Coolify deploy` expiring + 2027-08-11; +2. create two more-specific self-hosted Access applications, one for + `panel.codevena.dev/api/v1/deploy*` (queue and deployment polling) and one + exact path for + `panel.codevena.dev/api/v1/applications/z5eij4n8c4ubvxsmtpb507p2` + (pre-queue branch verification); do not expose any broader `/api/v1/*` path; +3. add a Service Auth policy to both applications whose include rule is only + that service token; +4. verify an anonymous request to each application is denied by Cloudflare Access with an expected + `3xx`, `401`, or `403`; retain enough response metadata to prove the denial + came from Access and that no Coolify JSON reached the caller; +5. separately verify on both route families that the new service-token headers + reach Coolify and get Coolify's expected bearer-layer `401` without an + Authorization header. + +Do not expose either one-time value in tool output or terminal history. + +- [ ] **Step 4: Create the least-privilege Coolify API token** + +In Coolify API Tokens, create `GitHub Actions - AgoraHub deploy` with exactly +`read` and `deploy`. Verify the UI lists those two permissions and neither +`write` nor `root`. + +- [ ] **Step 5: Transfer API credentials directly into GitHub and set variables** + +Create these GitHub secrets through the authenticated GitHub UI, transferring +the one-time browser-held values directly: + +- `COOLIFY_API_TOKEN` +- `CF_ACCESS_CLIENT_ID` +- `CF_ACCESS_CLIENT_SECRET` + +Set variables: + +```bash +gh variable set COOLIFY_API_BASE_URL --repo Codevena/AgoraHub --body https://panel.codevena.dev/api/v1 +gh variable set COOLIFY_APP_UUID --repo Codevena/AgoraHub --body z5eij4n8c4ubvxsmtpb507p2 +gh variable set COOLIFY_DEPLOY_BRANCH --repo Codevena/AgoraHub --body coolify-production +``` + +Verify only names/presence with `gh secret list` and exact non-secret values with +`gh variable list`. The final secret-name set for this path is the three API +secrets above plus `DEPLOY_REF_APP_ID` and `DEPLOY_REF_APP_PRIVATE_KEY`. + +- [ ] **Step 6: Point Coolify at the pinned branch — authority cutover** + +Change only the AgoraHub application branch from `main` to +`coolify-production`. Keep `is_auto_deploy_enabled=true`. Verify persisted DB/API +state and confirm no deployment was queued because the branch creation preceded +this setting change. + +This branch switch is the moment native `main` pushes stop advancing the source +Coolify watches. From this point until Task 6 completes, every failure path must +run and verify the full rollback transaction: set `COOLIFY_CD_ENABLED=false`, +restore the Coolify application branch to `main`, set +`is_auto_deploy_enabled=true`, and prove the next controlled `main` push/native +webhook path can deploy. Re-enabling auto-deploy without restoring `main` is not +a rollback. + +- [ ] **Step 7: Enable CD and trigger the controlled proof** + +Set: + +```bash +gh variable set COOLIFY_CD_ENABLED --repo Codevena/AgoraHub --body true +gh workflow run CI --repo Codevena/AgoraHub --ref main +``` + +Watch the CI run, then its `Deploy to Coolify` workflow. Require: + +- CI success for the current `main` SHA; +- `coolify-production` already equals that SHA, so no ref write occurs; +- the active production-ref ruleset names only the dedicated GitHub App as its + bypass actor, and the workflow's ref token actor is that App; +- the controller's final pre-queue ref read equals the CI SHA and Coolify's live + application record reports `git_branch=coolify-production`; +- exactly one new AgoraHub deployment with `is_api=true`; +- terminal `finished`, recorded commit equals the CI SHA; +- deploy workflow production smoke reports 11/11; +- no paired native webhook deployment for that CI event. + +On failure, execute the full post-switch rollback from Step 6, verify native +`main` deployment authority, and only then diagnose or retry. + +--- + +### Task 6: Disable native auto-deploy and record the final operating contract + +**Files:** +- Modify outside repo: `/Users/markus/Documents/Brain/02 Projekte/Experimente/AgoraHub.md` +- Modify outside repo: `/Users/markus/Documents/Brain/04 Ressourcen/DevOps & Self-Hosting/DevOps & Self-Hosting.md` +- Modify outside repo: `/Users/markus/Documents/Brain/05 Daily Notes/2026-08-11.md` + +**Interfaces:** +- Consumes: successful Task 5 deployment UUID, exact SHA, smoke evidence. +- Produces: one enabled deployment authority and durable runbook/rotation notes. + +- [ ] **Step 1: Disable Coolify native auto-deploy** + +Toggle only AgoraHub's auto-deploy setting off. Verify read-only through the +Coolify database/API that: + +- branch is `coolify-production`; +- `is_auto_deploy_enabled = false`; +- application remains `running:healthy`; +- running image/last finished deployment commit equals the controlled CI SHA. + +If any post-disable verification fails, run the same full rollback transaction: +CD false, Coolify branch `main`, native auto-deploy enabled, then a verified +native main-push deployment. + +- [ ] **Step 2: Run the final independent audit** + +Verify GitHub variables/secrets by names, `COOLIFY_CD_ENABLED=true`, the latest +CI and deploy conclusions, production branch SHA, Coolify settings, deployment +UUID/status/commit, container health/restarts, `/api/health`, and a fresh +`BASE_URL=https://agorahub.dev pnpm release:smoke` (11/11). + +Also verify the original annotated tag still dereferences to the frozen release: + +```bash +git ls-remote --tags origin refs/tags/v0.2.0-alpha.0 'refs/tags/v0.2.0-alpha.0^{}' +``` + +Expected dereferenced SHA: +`63c4585114a01211b1767daed2ba99a1e78bad40`. + +- [ ] **Step 3: Document architecture, rollback, and rotation without secrets** + +Update the three Brain notes with: + +- sole authority: successful current-main GitHub CI; +- `coolify-production` exact-SHA ref; +- two-layer Cloudflare/Coolify authentication and secret names only; +- final deployment UUID/SHA/status and 11/11 smoke; +- rollback transaction: set CD false, restore Coolify branch `main`, re-enable + native auto-deploy, then verify the native main-push path; +- Cloudflare service-token expiry/rotation date 2027-08-11; +- Coolify token annual rotation paired with the Cloudflare token; +- no token values. + +Report point 3 complete only with the fresh evidence above. The PostgreSQL backup +audit remains the next separately planned roadmap item; do not start it inside +this CD cutover plan. + +## Plan-Gate Findings Mapping + +### Round 1 — FAIL (8 CRITICAL, 2 INFO) + +| Finding | Applied minimal fix | Plan task | +|---|---|---| +| Test fetch mocks produced 9 TypeScript diagnostics | Export/type `FetchLike`, type every inspected mock, and require plan-block compilation | 1 | +| PATCH used singular GitHub ref endpoint | Separate singular GET and plural PATCH URLs; assert exact URL/method/body | 1 | +| Target variables could redirect credentials/deployment | Enforce the four approved constants before networking, with negative tests | 1 | +| Production ref lacked an exclusive writer and final target checks | Add dedicated GitHub App + one-actor ruleset, final ref re-read, exact Coolify application-branch check, and the required second exact Cloudflare path | 1, 2, 5 | +| Poll attempts could exceed the 20-minute contract | Replace attempt count with injected monotonic deadline and cap requests/sleeps to remaining time | 1 | +| Red counts and mutation evidence contradicted the actual guards | Recompute baseline values and enumerate individually killing mutations covering every new guard | 2, 3 | +| Branch switch broke the documented rollback authority | Declare it the authority cutover and restore CD=false + branch main + native auto-deploy on every later failure | 5, 6 | +| Anonymous Cloudflare proof assumed only redirects | Accept Access-origin `3xx`/`401`/`403` denial and separately prove bearer-layer `401` on both allowed path families | 5 | +| Checkout retained a write token | Make ordinary token read-only, disable persisted checkout credentials, and pass only the short-lived App token to the controller | 2 | +| Backup audit expanded this plan's scope | Remove backup execution; leave it as the next separately planned roadmap item | 6 | + +### Round 2 — FAIL (2 CRITICAL) + +| Finding | Applied minimal fix | Plan task | +|---|---|---| +| Several declared mutations were non-killing and table quantities disagreed | Require exact missing-field errors, add final-ref mismatch/request-budget fixtures, use a non-divisible deadline, reconcile every one-at-a-time quantity, and enumerate 70 killing mutations | 1, 3 | +| Aborted polling fetch lost operation/deployment context | Catch fetch failures in `requestJson`, rethrow with the labeled operation/UUID and cause, and add an abort regression test | 1 | + +### Round 3 — PASS + +No findings. Embedded TypeScript compiled with 0 diagnostics, embedded workflow +passed `actionlint`, mutation JSON parsed as 29 groups/70 individual mutations, +and `git diff --check` passed. diff --git a/docs/superpowers/specs/2026-08-11-ci-gated-coolify-deploy-design.md b/docs/superpowers/specs/2026-08-11-ci-gated-coolify-deploy-design.md index 17175ab..c6d7a2a 100644 --- a/docs/superpowers/specs/2026-08-11-ci-gated-coolify-deploy-design.md +++ b/docs/superpowers/specs/2026-08-11-ci-gated-coolify-deploy-design.md @@ -1,6 +1,6 @@ # CI-Gated Coolify Deployment Design -**Status:** Approved direction; written specification awaiting final review +**Status:** Approved direction; amended by pre-implementation plan-gate findings **Date:** 2026-08-11 **Scope:** AgoraHub continuous deployment only @@ -42,19 +42,24 @@ Create the repository branch `coolify-production` at the currently deployed, green `main` SHA. Configure the Coolify application to build this branch instead of `main`. -Only the deploy workflow may move `coolify-production`. For each eligible -successful CI run it: +Only a dedicated repository-scoped GitHub App used by the deploy workflow may +move `coolify-production`. An active repository ruleset targets exactly that ref, +restricts creation/update/deletion/force-push, and names only the App as a bypass +actor. For each eligible successful CI run the workflow: 1. reads `workflow_run.head_sha` as the verified SHA; 2. confirms that the CI event was a `push` or `workflow_dispatch` on `main`; 3. confirms that GitHub's current `main` ref still equals that SHA, otherwise it exits successfully as superseded without deploying; -4. updates `coolify-production` to that exact SHA; -5. calls Coolify for the AgoraHub resource; -6. waits for the returned deployment UUID to reach a terminal state; -7. requires terminal state `finished` and Coolify's recorded `commit` to equal +4. updates `coolify-production` to that exact SHA using a short-lived App token; +5. re-reads the final production ref and requires the exact verified SHA; +6. reads the Coolify application and requires its configured branch to remain + `coolify-production`; +7. calls Coolify for the AgoraHub resource; +8. waits for the returned deployment UUID to reach a terminal state; +9. requires terminal state `finished` and Coolify's recorded `commit` to equal the verified SHA; -8. runs the existing `pnpm release:smoke` suite against +10. runs the existing `pnpm release:smoke` suite against `https://agorahub.dev` from the verified checkout. This closes the branch-head race: Coolify never reads the moving development @@ -86,12 +91,18 @@ and the production smoke completes. A later successful CI run cannot move the deployment ref during that interval. Superseded pending runs may be omitted; the latest green current `main` is the desired production target. -The job receives only these GitHub permissions: +The workflow's ordinary `GITHUB_TOKEN` receives only these permissions: -- `contents: write` to move `refs/heads/coolify-production` to the verified SHA; +- `contents: read` for the exact-SHA checkout; - `actions: read` to consume the trusted `workflow_run` context; - all other permissions remain absent. +The checkout does not persist credentials. Only while CD mode is exactly `true`, +the pinned `actions/create-github-app-token` action mints a short-lived token for +the dedicated App, scoped to `Codevena/AgoraHub` with contents write. That token +is passed only to the controller step. The ruleset, App installation, and secret +inventory are audited before the Coolify branch authority changes. + The ref update uses the SHA delivered by GitHub's successful CI event, never a shell-derived or user-provided ref. The implementation must not evaluate event text or interpolate it into executable shell syntax without an environment @@ -99,9 +110,12 @@ boundary. ### Two-layer API protection -Cloudflare Access gets a path-scoped self-hosted application for -`panel.codevena.dev/api/v1/deploy*`. Its only allow path for automation is a -Service Auth policy bound to a new service token named +Cloudflare Access gets two path-scoped self-hosted applications: one for +`panel.codevena.dev/api/v1/deploy*`, covering queue and deployment polling, and +one exact path for +`panel.codevena.dev/api/v1/applications/z5eij4n8c4ubvxsmtpb507p2`, covering the +pre-queue branch check. Neither grants the service token broader `/api/v1/*` +access. Their only automation policy is Service Auth bound to a new token named `GitHub Actions - AgoraHub Coolify deploy`. The existing human login policy for the rest of `panel.codevena.dev` stays unchanged. @@ -121,6 +135,8 @@ The GitHub repository stores: | Secret | `COOLIFY_API_TOKEN` | new Coolify `read` + `deploy` token | | Secret | `CF_ACCESS_CLIENT_ID` | new Access service-token client ID | | Secret | `CF_ACCESS_CLIENT_SECRET` | new Access service-token secret | +| Secret | `DEPLOY_REF_APP_ID` | dedicated ref-writer GitHub App ID | +| Secret | `DEPLOY_REF_APP_PRIVATE_KEY` | dedicated ref-writer private key | Every Coolify request supplies all three authentication headers: @@ -135,6 +151,12 @@ credentials. When it is `true`, missing variables or secrets are fatal configuration errors. The workflow must never turn missing production credentials into a green skip. +Before any enabled-mode network call, the controller also fails closed unless +the repository, Coolify API base URL, application UUID, and deploy branch equal +the four approved AgoraHub values in this specification. This prevents variable +drift from sending bearer/service credentials to another host or deploying a +different target. + ## Deployment Result Handling The queue request must return exactly one deployment for the configured AgoraHub @@ -142,8 +164,11 @@ resource and expose a non-empty `deployment_uuid`. Any malformed response, unexpected resource UUID, HTTP error, Cloudflare Access redirect, authentication failure, or Coolify rate limit fails the workflow. -The workflow polls `GET /api/v1/deployments/{deployment_uuid}` at a bounded -interval for at most 20 minutes. Coolify statuses are handled as follows: +The enabled controller uses one injected monotonic absolute deadline of at most +20 minutes across preflight requests, queueing, polling, request timeouts, and +sleeps. Every request timeout and sleep is capped to the remaining deadline. It +polls `GET /api/v1/deployments/{deployment_uuid}` at a bounded interval. Coolify +statuses are handled as follows: - `queued` and `in_progress`: continue polling; - `finished`: require `commit == workflow_run.head_sha`, then continue to the @@ -180,29 +205,36 @@ The cutover preserves a working rollback path at every step: API. 3. Verify that the final native deployment is healthy and then create `coolify-production` at that exact green/deployed `main` SHA. -4. Create the path-scoped Cloudflare Access application and service token. -5. Create the Coolify `read` + `deploy` API token. -6. Store the three credential secrets and the remaining API/app/branch variables - in GitHub. -7. Point the Coolify application at `coolify-production` while leaving native +4. Create and install the dedicated ref-writer GitHub App, store its App ID and + private key directly in GitHub, and activate/audit the single-App production + ref ruleset. +5. Create the two path-scoped Cloudflare Access applications and service token. + Anonymous denial may correctly be `3xx`, `401`, or `403`; separately prove + the service token reaches Coolify and receives Coolify's bearer-layer `401`. +6. Create the Coolify `read` + `deploy` API token. +7. Store the three API credential secrets and the remaining API/app/branch + variables in GitHub. +8. Point the Coolify application at `coolify-production` while leaving native auto-deploy enabled. Because the branch already points at the running SHA, this configuration change does not introduce new code or emit a branch push. -8. Set `COOLIFY_CD_ENABLED=true`, then manually dispatch `CI` on `main`. Its + This is the authority cutover: native `main` pushes no longer advance the + branch Coolify watches. +9. Set `COOLIFY_CD_ENABLED=true`, then manually dispatch `CI` on `main`. Its successful completion triggers CD. The production ref is already on the same SHA, so the workflow performs no ref write and native branch auto-deploy does not race the API call. Require the single API deployment, recorded commit, and full production smoke to succeed. -9. Disable Coolify native auto-deploy for AgoraHub. -10. Verify through Coolify's persisted settings that auto-deploy is false, CD is +10. Disable Coolify native auto-deploy for AgoraHub. +11. Verify through Coolify's persisted settings that auto-deploy is false, CD is enabled, and the application is healthy with the expected SHA and no paired native/API deployment for the controlled CI event. -If the controlled API deployment fails before step 9, native auto-deploy -remains active and no authority has been removed. If verification fails after -step 9, immediately re-enable native auto-deploy, set -`COOLIFY_CD_ENABLED=false`, leave -`coolify-production` at the last green SHA, and diagnose without moving the -production ref. +At or after step 8, re-enabling native auto-deploy alone is not a rollback because +Coolify no longer watches `main`. Every failure from that point uses one complete +transaction: set `COOLIFY_CD_ENABLED=false`, restore the Coolify application +branch to `main`, set native auto-deploy enabled, and verify the native main-push +deployment path before diagnosing or retrying. Failures before step 8 do not +change deployment authority. ## Testing and Review @@ -214,6 +246,12 @@ workflow and then enforce at least these properties: dispatch bypass; - current-`main` SHA equality is required before the deployment ref moves; - `coolify-production` receives the verified event SHA; +- the ref update uses GitHub's plural `/git/refs/{ref}` PATCH contract, the + final ref is re-read before queueing, and Coolify's live application branch is + checked; +- enabled configuration is pinned to the four approved AgoraHub target values; +- only the dedicated App token can write the ruleset-protected production ref, + while checkout credentials are not persisted; - deployment concurrency is serialized rather than canceled; - the mode variable is always required; when enabled, every API variable/secret is required and missing credentials fail; @@ -221,6 +259,8 @@ workflow and then enforce at least these properties: header; - the queue response is validated and the returned deployment is polled; - terminal status and deployed commit SHA are fail-closed; +- the monotonic absolute deadline cannot exceed 20 minutes, including request + and sleep time; - the existing release smoke runs only after the correct deployment finishes. The workflow must also pass `actionlint`, the focused workflow contract test, @@ -246,16 +286,18 @@ The work is complete only when all of the following are true: production ref or call Coolify. 3. A successful current-`main` CI run sets or retains `coolify-production` at exactly its `head_sha` and no other SHA. -4. GitHub reaches Coolify through Cloudflare Service Auth and a least-privilege +4. An active production-ref ruleset allows exactly the dedicated GitHub App to + bypass update restrictions; the ordinary workflow token is read-only. +5. GitHub reaches Coolify through Cloudflare Service Auth and a least-privilege Coolify token; neither token appears in files or logs. -5. GitHub waits for the matching Coolify deployment and fails unless status is +6. GitHub waits for the matching Coolify deployment and fails unless status is `finished` and the recorded commit is the verified SHA. -6. The deployed production origin passes the existing 11-check release smoke. -7. Coolify native auto-deploy is disabled only after the controlled replacement +7. The deployed production origin passes the existing 11-check release smoke. +8. Coolify native auto-deploy is disabled only after the controlled replacement deployment succeeds. -8. A read-only final audit shows one enabled deployment authority, the expected +9. A read-only final audit shows one enabled deployment authority, the expected running SHA, a healthy app/database, `COOLIFY_CD_ENABLED=true`, and no paired native/API deployment for the controlled CI event. -9. The AgoraHub project note, infrastructure resource note, and daily note record +10. The AgoraHub project note, infrastructure resource note, and daily note record the final architecture, credential rotation responsibility, verification evidence, and rollback procedure without recording secret values. From d3f1aa075b4e3ff2c72191f3962b2e9663661733 Mon Sep 17 00:00:00 2001 From: Codevena Date: Tue, 11 Aug 2026 17:14:41 +0200 Subject: [PATCH 3/4] docs: bind Coolify deploys to signed webhook --- .../2026-08-11-ci-gated-coolify-deploy.md | 116 ++++++++++++------ ...26-08-11-ci-gated-coolify-deploy-design.md | 43 +++++++ 2 files changed, 120 insertions(+), 39 deletions(-) diff --git a/docs/superpowers/plans/2026-08-11-ci-gated-coolify-deploy.md b/docs/superpowers/plans/2026-08-11-ci-gated-coolify-deploy.md index d920c87..3c3f416 100644 --- a/docs/superpowers/plans/2026-08-11-ci-gated-coolify-deploy.md +++ b/docs/superpowers/plans/2026-08-11-ci-gated-coolify-deploy.md @@ -8,6 +8,33 @@ **Tech Stack:** GitHub Actions, TypeScript 5.9, Vitest 4, Node 22 native `fetch`, Coolify 4.0.0-beta.470 API, Cloudflare Access, `gh`, `actionlint`. +## Binding post-checkpoint correction (2026-08-11) + +Task 2's security checkpoint found that a final mutable-ref read did not bind +Coolify's queued commit. Current Coolify source confirms `POST /api/v1/deploy` +has no commit parameter, while the HMAC-verified manual GitHub webhook passes +payload `after` as the explicit queue `commit`. The implementation therefore +uses the signed manual webhook and a separate read-only API token. + +This section supersedes conflicting names, request shapes, credential scopes, +Cloudflare paths, embedded code examples, and mutation counts later in this +plan: + +- fixed webhook: `https://panel.codevena.dev/source/github/events/manual`; +- variables: add `COOLIFY_WEBHOOK_URL`; +- secrets: use `COOLIFY_READ_TOKEN` and `COOLIFY_WEBHOOK_SECRET`, not + `COOLIFY_API_TOKEN`; +- Coolify token scope: exactly `read`; queue authority is the per-application + HMAC secret, not a `deploy` ability; +- Access paths: exact manual webhook path, exact application path, and + `/api/v1/deployments/*` only; +- enabled preflight: approved UUID, branch, GitHub repository, and empty watch + paths, followed by exactly one successful webhook acknowledgement; +- cutover mode is validated in a secretless step, and the secret-bearing deploy + step is guarded by exact `true`; +- Task 3 runs the 88 one-at-a-time mutations enumerated in its corrected list + and manifest below. + ## Global Constraints - The frozen tag `v0.2.0-alpha.0` remains on `63c4585114a01211b1767daed2ba99a1e78bad40`. @@ -48,16 +75,18 @@ and make its named test fail. | Guarded quantity | With mechanism | One-at-a-time mutation | |---|---:|---:| | Required strict CD-mode checks | 1 | 0 | +| Required verified-SHA format checks | 1 | 0 | | Direct deploy triggers in `deploy.yml` | 0 | 1 (`workflow_dispatch`) | | Successful-event predicates required before job admission | 4 | 3 | | Deploy concurrency-group declarations | 1 | 0 | | `cancel-in-progress: true` settings in the deploy workflow | 0 | 1 | | Deploy job timeout in minutes | 40 | absent | -| Required Coolify/Access auth headers per API request | 3 | 2 | -| Approved fixed target values enforced before networking | 4 | 3 | +| Required Coolify/Access/HMAC authentication mechanisms | 4 | 3 | +| Approved fixed target values enforced before networking | 5 | 4 | | Distinct Git ref SHAs checked before queueing (`main`, final deploy ref) | 2 | 1 | -| Coolify application-branch predicates before queueing | 1 | 0 | -| Coolify deployments accepted from one queue response | exactly 1 | 0 or 2 accepted by lax parsing | +| Coolify application predicates before queueing (UUID, branch, repository, watch paths) | 4 | 3 | +| Coolify deployments accepted from one signed webhook acknowledgement | exactly 1 | `0|2|wrong|failed|unsafe` accepted by lax parsing | +| Immutable signed-webhook bindings (`ref`, `after`, repository, event) | 4 | 3 | | Successful terminal predicates (`status`, exact `commit`) | 2 | 1 (`status` only) | | Coolify terminal statuses handled explicitly | 5 | 4 | | Unknown-status rejection guards | 1 | 0 | @@ -66,7 +95,7 @@ and make its named test fail. | Failed requests labeled with their operation/deployment UUID | 1 | 0 | | Central manual-redirect policies | 1 | 0 | | Post-deploy smoke-chain bindings (`id`, condition, origin, command) | 4 | 3 | -| Missing enabled-mode controller configuration paths that fail | 9 | 8 | +| Missing enabled-mode controller configuration paths that fail | 11 | 10 | | Network requests in explicit disabled mode | 0 | 1+ | | Deploy-ref writes when it already equals the verified SHA | 0 | 1 with an unconditional update | | GitHub ref-update calls using the plural `/git/refs/` contract | 1 | 0 | @@ -74,8 +103,9 @@ and make its named test fail. | Persisted contents-write checkout credentials | 0 | 1 | | Dedicated-App token controls (pinned action, enabled-mode condition, permission, output source) | 4 | 3 | | Verified-SHA bindings (checkout ref, controller input) | 2 | 1 | -| Protected configuration names passed by the workflow | 9 | 8 | +| Protected configuration names passed by the workflow | 11 | 10 | | Green skip fallbacks for missing production credentials | 0 | 1 | +| Secretless cutover boundary guards (validator, deploy-step condition) | 2 | 1 | Operational cutover guards are verified from external state rather than by source mutation: exactly one GitHub App may bypass the production-ref ruleset; @@ -989,41 +1019,46 @@ Create a `mktemp -d` copy containing the working tree but excluding `.git`, `node_modules` read-only for test execution. Apply and test these mutations one at a time, restoring the copy between cases: -1. mode/disabled guards: accept missing mode as disabled, then separately remove - the disabled early return so a disabled run makes a request; -2. required configuration: bypass `required()` separately for each of its nine - enabled-mode paths; -3. fixed target: bypass each of the four approved-value equalities separately; -4. trusted refs: remove the current-`main` comparison, use singular `/git/ref/` - for PATCH, remove the final deploy-ref re-read, and accept a mismatched final - ref as four separate mutations; -5. Coolify target/auth: remove the application-branch comparison and remove - each of the three auth headers separately; separately remove the centralized - `redirect: "manual"` policy; -6. ref write behavior: make the update unconditional when the initial deploy ref - already equals the verified SHA; -7. queue/output safety: accept zero entries, two entries, a wrong resource, and - a newline-bearing deployment UUID as four separate mutations; +1. mode and disabled guards: accept missing mode as disabled; remove the + disabled early return; accept a non-SHA verified revision (three mutations); +2. enabled configuration: bypass `required()` separately for each of its eleven + enabled-mode paths, and bypass each of the five approved-value equalities; +3. trusted refs: remove the current-`main` comparison, use singular `/git/ref/` + for PATCH, remove the final deploy-ref re-read, accept a mismatched final ref, + and make the ref update unconditional when no write is needed (five); +4. Coolify application contract: accept a mismatched UUID, branch, repository, + and omitted or non-empty watch paths separately (five); +5. request authentication and transport: remove the read bearer, Cloudflare + client ID, Cloudflare client secret, HMAC signature, and centralized manual + redirect policy separately (five); +6. signed webhook binding: separately alter the payload ref, `after` SHA, + repository, and `X-GitHub-Event` (four); +7. webhook acknowledgement/output safety: accept zero items, two items, the + wrong application, a non-success status, and a newline-bearing deployment + UUID separately (five); 8. completion state: accept a mismatched finished commit; separately alter each of the five explicit status paths (`queued`, `in_progress`, `finished`, - `failed`, `cancelled-by-user`); then separately accept an unknown status; + `failed`, `cancelled-by-user`); then accept an unknown status (seven); 9. deadline/diagnostics: remove the absolute deadline; separately stop capping - request timeout and sleep to remaining time; then remove the `requestJson` - catch/rethrow that labels polling failures with the deployment UUID; + request timeout and sleep to remaining time; remove the `requestJson` + catch/rethrow that labels polling failures with the deployment UUID (four); 10. workflow admission: restore direct `workflow_dispatch`; separately remove each of the conclusion, head-branch, push-event, and manual-CI-event - predicates; + predicates (five); 11. workflow serialization: remove the concurrency group, change - `cancel-in-progress` to `true`, and remove the 40-minute job timeout; + `cancel-in-progress` to `true`, and remove the 40-minute job timeout (three); 12. ref identity/checkout: restore `contents: write` on `GITHUB_TOKEN`; remove `persist-credentials: false`; separately remove the pinned GitHub App action, its enabled-mode condition, its `permission-contents: write`, and its output token source; then remove the verified checkout ref and verified-SHA binding - as separate mutations; -13. protected workflow configuration: remove each of the nine variable/secret - names separately; separately reintroduce a green "skipping deploy" fallback; -14. production smoke: remove the controller step id, the `deployed == 'true'` - condition, the production `BASE_URL`, and the smoke command separately. + (eight); +13. protected workflow configuration: remove each of the eleven variable/secret + names separately; reintroduce a green "skipping deploy" fallback (twelve); +14. cutover secret boundary: remove the secretless interlock validator and the + exact-true guard on the secret-bearing deploy step separately (two); +15. production smoke: remove the controller step id, the `deployed == 'true'` + condition, the production `BASE_URL`, and the smoke command separately + (four). For each mutation run only its named focused test file, require non-zero exit, and write this JSON record to `.review/deploy-mutations.json`: @@ -1034,14 +1069,15 @@ and write this JSON record to `.review/deploy-mutations.json`: "cases": [ { "name": "mode-required", "mutations": 1, "with": 1, "without": 0, "all_failed": true }, { "name": "disabled-network", "mutations": 1, "with": 0, "without": 1, "all_failed": true }, - { "name": "enabled-config-required", "mutations": 9, "with": 9, "without_each": 8, "all_failed": true }, - { "name": "fixed-target-values", "mutations": 4, "with": 4, "without_each": 3, "all_failed": true }, - { "name": "three-auth-headers", "mutations": 3, "with": 3, "without_each": 2, "all_failed": true }, + { "name": "verified-sha-format", "mutations": 1, "with": 1, "without": 0, "all_failed": true }, + { "name": "enabled-config-required", "mutations": 11, "with": 11, "without_each": 10, "all_failed": true }, + { "name": "fixed-target-values", "mutations": 5, "with": 5, "without_each": 4, "all_failed": true }, + { "name": "four-auth-mechanisms", "mutations": 4, "with": 4, "without_each": 3, "all_failed": true }, { "name": "two-distinct-ref-checks", "mutations": 3, "with": 2, "without_each": 1, "all_failed": true }, { "name": "plural-ref-update-route", "mutations": 1, "with": 1, "without": 0, "all_failed": true }, - { "name": "coolify-branch-check", "mutations": 1, "with": 1, "without": 0, "all_failed": true }, + { "name": "coolify-application-contract", "mutations": 5, "with": 4, "without_each": 3, "all_failed": true }, { "name": "no-op-ref-writes", "mutations": 1, "with": 0, "without": 1, "all_failed": true }, - { "name": "single-safe-queue-entry", "mutations": 4, "with": 1, "without": "0|2|wrong|unsafe", "all_failed": true }, + { "name": "single-safe-webhook-ack", "mutations": 5, "with": 1, "without": "0|2|wrong|failed|unsafe", "all_failed": true }, { "name": "two-terminal-predicates", "mutations": 1, "with": 2, "without": 1, "all_failed": true }, { "name": "five-explicit-statuses", "mutations": 5, "with": 5, "without_each": 4, "all_failed": true }, { "name": "unknown-status-rejection", "mutations": 1, "with": 1, "without": 0, "all_failed": true }, @@ -1058,16 +1094,18 @@ and write this JSON record to `.review/deploy-mutations.json`: { "name": "persisted-checkout-credentials", "mutations": 1, "with": 0, "without": 1, "all_failed": true }, { "name": "dedicated-app-token-controls", "mutations": 4, "with": 4, "without_each": 3, "all_failed": true }, { "name": "verified-sha-bindings", "mutations": 2, "with": 2, "without_each": 1, "all_failed": true }, - { "name": "protected-workflow-config", "mutations": 9, "with": 9, "without_each": 8, "all_failed": true }, + { "name": "protected-workflow-config", "mutations": 11, "with": 11, "without_each": 10, "all_failed": true }, { "name": "no-green-skip-fallback", "mutations": 1, "with": 0, "without": 1, "all_failed": true }, - { "name": "post-deploy-smoke-bindings", "mutations": 4, "with": 4, "without_each": 3, "all_failed": true } + { "name": "post-deploy-smoke-bindings", "mutations": 4, "with": 4, "without_each": 3, "all_failed": true }, + { "name": "secretless-cutover-boundary", "mutations": 2, "with": 2, "without_each": 1, "all_failed": true }, + { "name": "signed-webhook-bindings", "mutations": 4, "with": 4, "without_each": 3, "all_failed": true } ] } ``` Every numbered submutation must name the exact focused test it killed; a grouped entry is true only when all of its individual mutations fail. The manifest's -`mutations` values must total 70 and the reviewer recomputes that total from the +`mutations` values must total 88 and the reviewer recomputes that total from the individual records. Confirm the real worktree is unchanged except for intended Tasks 1–2 files. diff --git a/docs/superpowers/specs/2026-08-11-ci-gated-coolify-deploy-design.md b/docs/superpowers/specs/2026-08-11-ci-gated-coolify-deploy-design.md index c6d7a2a..21c7667 100644 --- a/docs/superpowers/specs/2026-08-11-ci-gated-coolify-deploy-design.md +++ b/docs/superpowers/specs/2026-08-11-ci-gated-coolify-deploy-design.md @@ -36,6 +36,49 @@ database schema, or introduce the later scheduled MCP/A2A synthetic monitor. ## Chosen Architecture +### Binding post-checkpoint correction (2026-08-11) + +The Task-2 security review exposed a remaining race in the original API design: +Coolify's current `POST /api/v1/deploy` accepts a resource UUID but no commit +SHA. A final branch-ref read therefore cannot prove which commit Coolify records +when it queues the deployment. The reviewed implementation uses Coolify's +HMAC-verified manual GitHub webhook instead. Its `push` payload carries the +successful CI SHA in `after`; Coolify passes that value as the explicit `commit` +argument to `queue_application_deployment`, which stores it in the deployment +queue before dispatch. + +This correction supersedes every later reference in this document to +`POST /api/v1/deploy`, a Coolify `deploy` token, `COOLIFY_API_TOKEN`, or four +fixed target values: + +- the controller still pins and verifies `coolify-production`, then preflights + the live Coolify application UUID, branch, GitHub repository, and an explicit + empty `watch_paths` value (`null` or blank string; omission fails closed); +- it POSTs the exact-SHA payload to the fixed + `https://panel.codevena.dev/source/github/events/manual` endpoint with + `X-GitHub-Event: push` and `X-Hub-Signature-256`; +- the webhook receives the two Cloudflare Access headers and the HMAC signature, + but no Coolify bearer token; +- application/deployment GETs receive the two Access headers plus a dedicated + Coolify token carrying only `read` (never `deploy`, `write`, or `root`); +- Cloudflare Access is scoped to the manual webhook path, the exact application + path, and `/api/v1/deployments/*`; `/api/v1/deploy` is not exposed; +- the approved pre-network values are repository, API base URL, webhook URL, + application UUID, and deploy branch; +- GitHub stores variable `COOLIFY_WEBHOOK_URL` and secrets + `COOLIFY_READ_TOKEN` and `COOLIFY_WEBHOOK_SECRET` instead of + `COOLIFY_API_TOKEN`; +- the queue acknowledgement must be an array containing exactly one `success` + item for the approved application with a safe deployment UUID; +- a secretless workflow step validates the cutover mode first, and every + secret-bearing controller step runs only when the mode is exactly `true`. + +During cutover, create a high-entropy `manual_webhook_secret_github` value on +the AgoraHub Coolify application and transfer the same one-time value directly +to GitHub as `COOLIFY_WEBHOOK_SECRET`. Create a separate read-only Coolify API +token for `COOLIFY_READ_TOKEN`. Neither value may be written to Git, Brain, +terminal history, logs, or review artifacts. + ### Exact-commit deployment ref Create the repository branch `coolify-production` at the currently deployed, From b48f401a1e4b53f2f3e8bd1ca077a5cffa24c460 Mon Sep 17 00:00:00 2001 From: Codevena Date: Tue, 11 Aug 2026 17:14:54 +0200 Subject: [PATCH 4/4] ci: gate Coolify deploys on verified revisions --- .github/workflows/deploy.yml | 97 +++++-- scripts/ci-workflow.test.ts | 74 ++++++ scripts/deploy-coolify.test.ts | 448 +++++++++++++++++++++++++++++++++ scripts/deploy-coolify.ts | 310 +++++++++++++++++++++++ 4 files changed, 909 insertions(+), 20 deletions(-) create mode 100644 scripts/deploy-coolify.test.ts create mode 100644 scripts/deploy-coolify.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 95cae5d..d22e5d6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,37 +5,94 @@ on: workflows: ["CI"] types: [completed] branches: [main] - workflow_dispatch: permissions: + actions: read contents: read concurrency: group: deploy-coolify - cancel-in-progress: true + cancel-in-progress: false + +env: + NODE_VERSION: 22.19.0 + PNPM_VERSION: 9.15.9 jobs: deploy: - name: Trigger Coolify webhook + name: Exact SHA · Coolify · Production smoke + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main' && + (github.event.workflow_run.event == 'push' || + github.event.workflow_run.event == 'workflow_dispatch') runs-on: ubuntu-latest - # Only deploy when CI finished successfully (or on manual dispatch). - if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + timeout-minutes: 40 steps: - - name: Trigger Coolify deploy + - name: Checkout verified CI revision + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + + - name: Validate cutover interlock env: - COOLIFY_WEBHOOK_URL: ${{ secrets.COOLIFY_WEBHOOK_URL }} - COOLIFY_WEBHOOK_TOKEN: ${{ secrets.COOLIFY_WEBHOOK_TOKEN }} + COOLIFY_CD_ENABLED: ${{ vars.COOLIFY_CD_ENABLED }} run: | - if [ -z "$COOLIFY_WEBHOOK_URL" ]; then - echo "COOLIFY_WEBHOOK_URL secret is not set — skipping deploy." - exit 0 - fi - if [ -n "$COOLIFY_WEBHOOK_TOKEN" ]; then - curl --fail --silent --show-error \ - -X POST "$COOLIFY_WEBHOOK_URL" \ - -H "Authorization: Bearer $COOLIFY_WEBHOOK_TOKEN" - else - curl --fail --silent --show-error -X POST "$COOLIFY_WEBHOOK_URL" - fi - echo "Coolify deploy triggered." + case "$COOLIFY_CD_ENABLED" in + true|false) ;; + *) echo "COOLIFY_CD_ENABLED must be exactly true or false" >&2; exit 1 ;; + esac + + - name: Mint dedicated production-ref token + id: deploy-ref-token + if: vars.COOLIFY_CD_ENABLED == 'true' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.DEPLOY_REF_APP_ID }} + private-key: ${{ secrets.DEPLOY_REF_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: AgoraHub + permission-contents: write + + - name: Setup pnpm + if: vars.COOLIFY_CD_ENABLED == 'true' + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + with: + version: ${{ env.PNPM_VERSION }} + run_install: false + + - name: Setup Node + if: vars.COOLIFY_CD_ENABLED == 'true' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install frozen dependencies + if: vars.COOLIFY_CD_ENABLED == 'true' + run: pnpm install --frozen-lockfile + + - name: Deploy exact verified revision + if: vars.COOLIFY_CD_ENABLED == 'true' + id: deployment + env: + VERIFIED_SHA: ${{ github.event.workflow_run.head_sha }} + GITHUB_TOKEN: ${{ steps.deploy-ref-token.outputs.token }} + COOLIFY_CD_ENABLED: ${{ vars.COOLIFY_CD_ENABLED }} + COOLIFY_API_BASE_URL: ${{ vars.COOLIFY_API_BASE_URL }} + COOLIFY_WEBHOOK_URL: ${{ vars.COOLIFY_WEBHOOK_URL }} + COOLIFY_APP_UUID: ${{ vars.COOLIFY_APP_UUID }} + COOLIFY_DEPLOY_BRANCH: ${{ vars.COOLIFY_DEPLOY_BRANCH }} + COOLIFY_READ_TOKEN: ${{ secrets.COOLIFY_READ_TOKEN }} + COOLIFY_WEBHOOK_SECRET: ${{ secrets.COOLIFY_WEBHOOK_SECRET }} + CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} + CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} + run: pnpm exec tsx scripts/deploy-coolify.ts + + - name: Verify deployed production protocols + if: steps.deployment.outputs.deployed == 'true' + env: + BASE_URL: https://agorahub.dev + run: pnpm release:smoke diff --git a/scripts/ci-workflow.test.ts b/scripts/ci-workflow.test.ts index d74526a..814637f 100644 --- a/scripts/ci-workflow.test.ts +++ b/scripts/ci-workflow.test.ts @@ -25,3 +25,77 @@ describe("CI workflow", () => { expect(securityJob).not.toContain("cache: pnpm"); }); }); + +describe("Deploy workflow", () => { + const workflow = readFileSync( + new URL("../.github/workflows/deploy.yml", import.meta.url), + "utf8" + ); + + it("admits only successful main CI workflow runs", () => { + expect(workflow).toContain('workflows: ["CI"]'); + expect(workflow).toContain("branches: [main]"); + expect(workflow).not.toMatch(/^ workflow_dispatch:/m); + expect(workflow).toContain("workflow_run.conclusion == 'success'"); + expect(workflow).toContain("workflow_run.head_branch == 'main'"); + expect(workflow).toContain("workflow_run.event == 'push'"); + expect(workflow).toContain("workflow_run.event == 'workflow_dispatch'"); + }); + + it("serializes the entire deploy and smoke lifecycle", () => { + expect(workflow).toContain("group: deploy-coolify"); + expect(workflow).toContain("cancel-in-progress: false"); + expect(workflow).toContain("timeout-minutes: 40"); + }); + + it("checks out and deploys the verified event SHA", () => { + expect(workflow).toContain("contents: read"); + expect(workflow).toContain("actions: read"); + expect(workflow).toContain("ref: ${{ github.event.workflow_run.head_sha }}"); + expect(workflow).toContain("persist-credentials: false"); + expect(workflow).toContain( + "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1" + ); + expect(workflow).toMatch( + /id: deploy-ref-token\n\s+if: vars\.COOLIFY_CD_ENABLED == 'true'/ + ); + expect(workflow).toContain("permission-contents: write"); + expect(workflow).toContain("GITHUB_TOKEN: ${{ steps.deploy-ref-token.outputs.token }}"); + expect(workflow).toContain("VERIFIED_SHA: ${{ github.event.workflow_run.head_sha }}"); + expect(workflow).toContain("pnpm exec tsx scripts/deploy-coolify.ts"); + expect(workflow).toContain("Validate cutover interlock"); + expect(workflow).toMatch( + /- name: Deploy exact verified revision\n\s+if: vars\.COOLIFY_CD_ENABLED == 'true'/ + ); + const interlockStep = workflow.slice( + workflow.indexOf("- name: Validate cutover interlock"), + workflow.indexOf("- name: Mint dedicated production-ref token") + ); + expect(interlockStep).toContain('case "$COOLIFY_CD_ENABLED" in'); + expect(interlockStep).not.toContain("secrets."); + }); + + it("passes every protected configuration value without fallback literals", () => { + for (const name of [ + "COOLIFY_CD_ENABLED", + "COOLIFY_API_BASE_URL", + "COOLIFY_WEBHOOK_URL", + "COOLIFY_APP_UUID", + "COOLIFY_DEPLOY_BRANCH", + "COOLIFY_READ_TOKEN", + "COOLIFY_WEBHOOK_SECRET", + "CF_ACCESS_CLIENT_ID", + "CF_ACCESS_CLIENT_SECRET", + "DEPLOY_REF_APP_ID", + "DEPLOY_REF_APP_PRIVATE_KEY", + ]) expect(workflow).toContain(name); + expect(workflow).not.toContain("skipping deploy"); + }); + + it("runs the production smoke only after a verified deployment", () => { + expect(workflow).toContain("id: deployment"); + expect(workflow).toContain("if: steps.deployment.outputs.deployed == 'true'"); + expect(workflow).toContain("BASE_URL: https://agorahub.dev"); + expect(workflow).toContain("run: pnpm release:smoke"); + }); +}); diff --git a/scripts/deploy-coolify.test.ts b/scripts/deploy-coolify.test.ts new file mode 100644 index 0000000..e1ff37c --- /dev/null +++ b/scripts/deploy-coolify.test.ts @@ -0,0 +1,448 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import { + readDeployConfig, + runCoolifyDeployment, + type EnabledDeployConfig, + type FetchLike, +} from "./deploy-coolify"; + +const SHA = "a".repeat(40); +const OLD_SHA = "b".repeat(40); + +function enabledConfig(overrides: Partial = {}): EnabledDeployConfig { + return { + enabled: true, + verifiedSha: SHA, + githubRepository: "Codevena/AgoraHub", + githubToken: "github-token", + coolifyApiBaseUrl: "https://panel.codevena.dev/api/v1", + coolifyWebhookUrl: "https://panel.codevena.dev/source/github/events/manual", + coolifyAppUuid: "z5eij4n8c4ubvxsmtpb507p2", + coolifyDeployBranch: "coolify-production", + coolifyReadToken: "coolify-read-token", + coolifyWebhookSecret: "webhook-secret", + cfAccessClientId: "access-id", + cfAccessClientSecret: "access-secret", + ...overrides, + }; +} + +function json(value: unknown, status = 200): Response { + return Response.json(value, { status }); +} + +function coolifyApplication(overrides: Record = {}): Record { + return { + uuid: "z5eij4n8c4ubvxsmtpb507p2", + git_branch: "coolify-production", + git_repository: "https://github.com/Codevena/AgoraHub.git", + watch_paths: null, + ...overrides, + }; +} + +function queuedWebhook(deploymentUuid = "deploy-1"): Response { + return json([{ + status: "success", + application_uuid: "z5eij4n8c4ubvxsmtpb507p2", + deployment_uuid: deploymentUuid, + }]); +} + +describe("readDeployConfig", () => { + it.each([undefined, "", "yes", "TRUE"])("rejects invalid CD mode %s", (mode) => { + expect(() => readDeployConfig({ COOLIFY_CD_ENABLED: mode })).toThrow( + /COOLIFY_CD_ENABLED must be exactly false or true/ + ); + }); + + it("allows explicit cutover-disabled mode without credentials", () => { + expect(readDeployConfig({ COOLIFY_CD_ENABLED: "false" })).toEqual({ enabled: false }); + }); + + it("rejects a non-SHA verified revision before networking", () => { + expect(() => readDeployConfig({ + COOLIFY_CD_ENABLED: "true", + VERIFIED_SHA: "main", + GITHUB_REPOSITORY: "Codevena/AgoraHub", + GITHUB_TOKEN: "github-token", + COOLIFY_API_BASE_URL: "https://panel.codevena.dev/api/v1", + COOLIFY_WEBHOOK_URL: "https://panel.codevena.dev/source/github/events/manual", + COOLIFY_APP_UUID: "z5eij4n8c4ubvxsmtpb507p2", + COOLIFY_DEPLOY_BRANCH: "coolify-production", + COOLIFY_READ_TOKEN: "coolify-read-token", + COOLIFY_WEBHOOK_SECRET: "webhook-secret", + CF_ACCESS_CLIENT_ID: "access-id", + CF_ACCESS_CLIENT_SECRET: "access-secret", + })).toThrow(/40-character Git SHA/); + }); + + it.each([ + "VERIFIED_SHA", + "GITHUB_REPOSITORY", + "GITHUB_TOKEN", + "COOLIFY_API_BASE_URL", + "COOLIFY_WEBHOOK_URL", + "COOLIFY_APP_UUID", + "COOLIFY_DEPLOY_BRANCH", + "COOLIFY_READ_TOKEN", + "COOLIFY_WEBHOOK_SECRET", + "CF_ACCESS_CLIENT_ID", + "CF_ACCESS_CLIENT_SECRET", + ])("rejects enabled mode when %s is missing", (name) => { + const env: Record = { + COOLIFY_CD_ENABLED: "true", + VERIFIED_SHA: SHA, + GITHUB_REPOSITORY: "Codevena/AgoraHub", + GITHUB_TOKEN: "github-token", + COOLIFY_API_BASE_URL: "https://panel.codevena.dev/api/v1", + COOLIFY_WEBHOOK_URL: "https://panel.codevena.dev/source/github/events/manual", + COOLIFY_APP_UUID: "z5eij4n8c4ubvxsmtpb507p2", + COOLIFY_DEPLOY_BRANCH: "coolify-production", + COOLIFY_READ_TOKEN: "coolify-read-token", + COOLIFY_WEBHOOK_SECRET: "webhook-secret", + CF_ACCESS_CLIENT_ID: "access-id", + CF_ACCESS_CLIENT_SECRET: "access-secret", + }; + delete env[name]; + expect(() => readDeployConfig(env)).toThrow( + new RegExp(`^${name} is required when COOLIFY_CD_ENABLED=true$`) + ); + }); + + it.each([ + ["GITHUB_REPOSITORY", "someone/else"], + ["COOLIFY_API_BASE_URL", "https://evil.example/api/v1"], + ["COOLIFY_WEBHOOK_URL", "https://evil.example/source/github/events/manual"], + ["COOLIFY_APP_UUID", "anotherapp"], + ["COOLIFY_DEPLOY_BRANCH", "main"], + ])("rejects drifted approved target %s before networking", (name, value) => { + const env: Record = { + COOLIFY_CD_ENABLED: "true", + VERIFIED_SHA: SHA, + GITHUB_REPOSITORY: "Codevena/AgoraHub", + GITHUB_TOKEN: "github-app-token", + COOLIFY_API_BASE_URL: "https://panel.codevena.dev/api/v1", + COOLIFY_WEBHOOK_URL: "https://panel.codevena.dev/source/github/events/manual", + COOLIFY_APP_UUID: "z5eij4n8c4ubvxsmtpb507p2", + COOLIFY_DEPLOY_BRANCH: "coolify-production", + COOLIFY_READ_TOKEN: "coolify-read-token", + COOLIFY_WEBHOOK_SECRET: "webhook-secret", + CF_ACCESS_CLIENT_ID: "access-id", + CF_ACCESS_CLIENT_SECRET: "access-secret", + }; + env[name] = value; + expect(() => readDeployConfig(env)).toThrow(/approved AgoraHub target/); + }); +}); + +describe("runCoolifyDeployment", () => { + it("performs no network work while the cutover interlock is false", async () => { + const fetchImpl = vi.fn(); + await expect(runCoolifyDeployment({ enabled: false }, { fetchImpl })).resolves.toEqual({ + status: "disabled", + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("does not deploy a successful CI SHA superseded on main", async () => { + const fetchImpl = vi.fn(async () => json({ object: { sha: OLD_SHA } })); + await expect(runCoolifyDeployment(enabledConfig(), { fetchImpl })).resolves.toEqual({ + status: "superseded", + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("pins the deploy ref, signs an immutable-SHA webhook, and verifies the finished SHA", async () => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: OLD_SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json(coolifyApplication()), + queuedWebhook(), + json({ status: "queued", commit: SHA }), + json({ status: "in_progress", commit: SHA }), + json({ status: "finished", commit: SHA }), + ]; + const fetchImpl = vi.fn(async () => responses.shift() ?? json({}, 500)); + const sleepImpl = vi.fn(async () => undefined); + + await expect( + runCoolifyDeployment(enabledConfig(), { fetchImpl, sleepImpl }) + ).resolves.toEqual({ status: "deployed", deploymentUuid: "deploy-1" }); + + expect(fetchImpl).toHaveBeenCalledTimes(9); + const patchRequest = new Request(fetchImpl.mock.calls[2][0], fetchImpl.mock.calls[2][1]); + expect(patchRequest.url).toBe( + "https://api.github.com/repos/Codevena/AgoraHub/git/refs/heads/coolify-production" + ); + expect(patchRequest.method).toBe("PATCH"); + expect(await patchRequest.json()).toEqual({ sha: SHA, force: true }); + + const finalRefRequest = new Request( + fetchImpl.mock.calls[3][0], + fetchImpl.mock.calls[3][1] + ); + expect(finalRefRequest.url).toBe( + "https://api.github.com/repos/Codevena/AgoraHub/git/ref/heads/coolify-production" + ); + + const webhookRequest = new Request(fetchImpl.mock.calls[5][0], fetchImpl.mock.calls[5][1]); + const webhookBody = await webhookRequest.text(); + expect(webhookRequest.url).toBe( + "https://panel.codevena.dev/source/github/events/manual" + ); + expect(webhookRequest.headers.get("authorization")).toBeNull(); + expect(webhookRequest.headers.get("x-github-event")).toBe("push"); + expect(JSON.parse(webhookBody)).toEqual({ + ref: "refs/heads/coolify-production", + after: SHA, + repository: { full_name: "Codevena/AgoraHub" }, + commits: [], + }); + expect(webhookRequest.headers.get("x-hub-signature-256")).toBe( + `sha256=${createHmac("sha256", "webhook-secret").update(webhookBody).digest("hex")}` + ); + + for (const [index, call] of fetchImpl.mock.calls.slice(4).entries()) { + const request = new Request(call[0], call[1]); + expect(request.headers.get("cf-access-client-id")).toBe("access-id"); + expect(request.headers.get("cf-access-client-secret")).toBe("access-secret"); + if (index !== 1) { + expect(request.headers.get("authorization")).toBe("Bearer coolify-read-token"); + } + expect(request.redirect).toBe("manual"); + } + expect(sleepImpl).toHaveBeenCalledTimes(2); + }); + + it("does not rewrite a deploy ref that already equals the verified SHA", async () => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json(coolifyApplication()), + queuedWebhook(), + json({ status: "finished", commit: SHA }), + ]; + const fetchImpl = vi.fn(async () => responses.shift() ?? json({}, 500)); + await expect(runCoolifyDeployment(enabledConfig(), { fetchImpl })).resolves.toEqual({ + status: "deployed", + deploymentUuid: "deploy-1", + }); + expect(fetchImpl).toHaveBeenCalledTimes(6); + expect(fetchImpl.mock.calls.map((call) => new Request(call[0], call[1]).method)) + .toEqual(["GET", "GET", "GET", "GET", "POST", "GET"]); + }); + + it("rejects Coolify branch drift before queueing", async () => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json(coolifyApplication({ git_branch: "main" })), + ]; + const fetchImpl = vi.fn(async () => responses.shift() ?? json({}, 500)); + await expect(runCoolifyDeployment(enabledConfig(), { fetchImpl })).rejects.toThrow( + /Coolify application branch/ + ); + expect(fetchImpl).toHaveBeenCalledTimes(4); + }); + + it.each([ + ["UUID", { uuid: "wrong" }], + ["repository", { git_repository: "someone/else" }], + ["watch paths", { watch_paths: "src/**" }], + ])("rejects unsafe Coolify application %s before queueing", async (_label, drift) => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json(coolifyApplication(drift)), + ]; + await expect(runCoolifyDeployment(enabledConfig(), { + fetchImpl: vi.fn(async () => responses.shift() ?? json({}, 500)), + })).rejects.toThrow(/Coolify application/); + }); + + it("rejects an omitted Coolify application watch-path contract before queueing", async () => { + const application = coolifyApplication(); + delete application.watch_paths; + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json(application), + ]; + const fetchImpl = vi.fn(async () => responses.shift() ?? json({}, 500)); + await expect(runCoolifyDeployment(enabledConfig(), { fetchImpl })).rejects.toThrow( + /Coolify application watch paths/ + ); + expect(fetchImpl).toHaveBeenCalledTimes(4); + }); + + it("rejects a final deploy ref that changed after the initial check", async () => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: OLD_SHA } }), + ]; + const fetchImpl = vi.fn(async () => responses.shift() ?? json({}, 500)); + await expect(runCoolifyDeployment(enabledConfig(), { fetchImpl })).rejects.toThrow( + /final deploy ref does not match/ + ); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it.each(["failed", "cancelled-by-user"])( + "fails closed for terminal status %s", + async (status) => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json(coolifyApplication()), + queuedWebhook(), + json({ status, commit: SHA }), + ]; + await expect( + runCoolifyDeployment(enabledConfig(), { + fetchImpl: vi.fn(async () => responses.shift()!), + sleepImpl: vi.fn(async () => undefined), + }) + ).rejects.toThrow(`ended with ${status}`); + } + ); + + it("fails closed for an unknown deployment status", async () => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json(coolifyApplication()), + queuedWebhook(), + json({ status: "unknown", commit: SHA }), + ]; + await expect(runCoolifyDeployment(enabledConfig(), { + fetchImpl: vi.fn(async () => responses.shift()!), + })).rejects.toThrow("returned unknown status unknown"); + }); + + it("rejects a finished deployment for a different commit", async () => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json(coolifyApplication()), + queuedWebhook(), + json({ status: "finished", commit: OLD_SHA }), + ]; + await expect( + runCoolifyDeployment(enabledConfig(), { + fetchImpl: vi.fn(async () => responses.shift()!), + }) + ).rejects.toThrow(/does not match verified SHA/); + }); + + it("rejects zero, multiple, or wrong-resource queue entries", async () => { + for (const webhookPayload of [ + [], + [ + { status: "success", application_uuid: "z5eij4n8c4ubvxsmtpb507p2", deployment_uuid: "deploy-1" }, + { status: "success", application_uuid: "z5eij4n8c4ubvxsmtpb507p2", deployment_uuid: "deploy-2" }, + ], + [{ status: "success", application_uuid: "wrong", deployment_uuid: "deploy-1" }], + [{ status: "failed", application_uuid: "z5eij4n8c4ubvxsmtpb507p2", deployment_uuid: "deploy-1" }], + [{ status: "success", application_uuid: "z5eij4n8c4ubvxsmtpb507p2", deployment_uuid: "deploy-1\ninjected=true" }], + ]) { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json(coolifyApplication()), + json(webhookPayload), + ]; + await expect( + runCoolifyDeployment(enabledConfig(), { + fetchImpl: vi.fn(async () => responses.shift()!), + }) + ).rejects.toThrow(/exactly one AgoraHub deployment/); + } + }); + + it("enforces one absolute non-divisible deadline across requests and sleeps", async () => { + let now = 0; + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json(coolifyApplication()), + queuedWebhook(), + ]; + const fetchImpl = vi.fn(async () => + responses.shift() ?? json({ status: "in_progress", commit: SHA }) + ); + await expect( + runCoolifyDeployment(enabledConfig(), { + fetchImpl, + nowImpl: () => now, + sleepImpl: vi.fn(async (milliseconds) => { now += milliseconds; }), + pollIntervalMs: 10_000, + deadlineMs: 15_000, + }) + ).rejects.toThrow(/deadline.*deploy-1/); + expect(now).toBe(15_000); + }); + + it("caps every request timeout to the remaining absolute deadline", async () => { + let now = 0; + let call = 0; + const timeoutBudgets: number[] = []; + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json(coolifyApplication()), + queuedWebhook(), + json({ status: "finished", commit: SHA }), + ]; + const fetchImpl = vi.fn(async () => { + const response = responses.shift() ?? json({}, 500); + if (call === 0) now = 19_000; + call += 1; + return response; + }); + + await expect(runCoolifyDeployment(enabledConfig(), { + fetchImpl, + nowImpl: () => now, + deadlineMs: 20_000, + requestTimeoutMs: 15_000, + timeoutSignalImpl: (milliseconds) => { + timeoutBudgets.push(milliseconds); + return new AbortController().signal; + }, + })).resolves.toEqual({ status: "deployed", deploymentUuid: "deploy-1" }); + expect(timeoutBudgets).toEqual([15_000, 1_000, 1_000, 1_000, 1_000, 1_000]); + }); + + it("labels an aborted polling request with its deployment UUID", async () => { + const responses = [ + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json({ object: { sha: SHA } }), + json(coolifyApplication()), + queuedWebhook(), + ]; + const fetchImpl = vi.fn(async () => { + const response = responses.shift(); + if (response) return response; + throw new DOMException("The operation timed out", "TimeoutError"); + }); + await expect(runCoolifyDeployment(enabledConfig(), { fetchImpl })).rejects.toThrow( + /Coolify deployment deploy-1 request failed.*timed out/i + ); + }); +}); diff --git a/scripts/deploy-coolify.ts b/scripts/deploy-coolify.ts new file mode 100644 index 0000000..f8491c2 --- /dev/null +++ b/scripts/deploy-coolify.ts @@ -0,0 +1,310 @@ +import { createHmac } from "node:crypto"; +import { appendFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; +type SleepLike = (milliseconds: number) => Promise; +type NowLike = () => number; +type TimeoutSignalLike = (milliseconds: number) => AbortSignal; + +export interface DisabledDeployConfig { enabled: false } +export interface EnabledDeployConfig { + enabled: true; + verifiedSha: string; + githubRepository: string; + githubToken: string; + coolifyApiBaseUrl: string; + coolifyWebhookUrl: string; + coolifyAppUuid: string; + coolifyDeployBranch: string; + coolifyReadToken: string; + coolifyWebhookSecret: string; + cfAccessClientId: string; + cfAccessClientSecret: string; +} +export type DeployConfig = DisabledDeployConfig | EnabledDeployConfig; +export type DeployOutcome = + | { status: "disabled" } + | { status: "superseded" } + | { status: "deployed"; deploymentUuid: string }; + +interface DeployDependencies { + fetchImpl?: FetchLike; + sleepImpl?: SleepLike; + nowImpl?: NowLike; + pollIntervalMs?: number; + requestTimeoutMs?: number; + deadlineMs?: number; + timeoutSignalImpl?: TimeoutSignalLike; +} + +const SHA_PATTERN = /^[0-9a-f]{40}$/i; +const DEPLOYMENT_UUID_PATTERN = /^[a-z0-9_-]+$/i; +const APPROVED_REPOSITORY = "Codevena/AgoraHub"; +const APPROVED_API_BASE_URL = "https://panel.codevena.dev/api/v1"; +const APPROVED_WEBHOOK_URL = "https://panel.codevena.dev/source/github/events/manual"; +const APPROVED_APP_UUID = "z5eij4n8c4ubvxsmtpb507p2"; +const APPROVED_DEPLOY_BRANCH = "coolify-production"; +const DEFAULT_DEADLINE_MS = 20 * 60 * 1_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; + +function required(env: Record, name: string): string { + const value = env[name]?.trim(); + if (!value) throw new Error(`${name} is required when COOLIFY_CD_ENABLED=true`); + return value; +} + +export function readDeployConfig( + env: Record = process.env +): DeployConfig { + const mode = env.COOLIFY_CD_ENABLED?.trim(); + if (mode !== "false" && mode !== "true") { + throw new Error("COOLIFY_CD_ENABLED must be exactly false or true"); + } + if (mode === "false") return { enabled: false }; + + const config: EnabledDeployConfig = { + enabled: true, + verifiedSha: required(env, "VERIFIED_SHA"), + githubRepository: required(env, "GITHUB_REPOSITORY"), + githubToken: required(env, "GITHUB_TOKEN"), + coolifyApiBaseUrl: required(env, "COOLIFY_API_BASE_URL"), + coolifyWebhookUrl: required(env, "COOLIFY_WEBHOOK_URL"), + coolifyAppUuid: required(env, "COOLIFY_APP_UUID"), + coolifyDeployBranch: required(env, "COOLIFY_DEPLOY_BRANCH"), + coolifyReadToken: required(env, "COOLIFY_READ_TOKEN"), + coolifyWebhookSecret: required(env, "COOLIFY_WEBHOOK_SECRET"), + cfAccessClientId: required(env, "CF_ACCESS_CLIENT_ID"), + cfAccessClientSecret: required(env, "CF_ACCESS_CLIENT_SECRET"), + }; + + if (!SHA_PATTERN.test(config.verifiedSha)) { + throw new Error("VERIFIED_SHA must be a 40-character Git SHA"); + } + for (const [actual, expected, name] of [ + [config.githubRepository, APPROVED_REPOSITORY, "GITHUB_REPOSITORY"], + [config.coolifyApiBaseUrl, APPROVED_API_BASE_URL, "COOLIFY_API_BASE_URL"], + [config.coolifyWebhookUrl, APPROVED_WEBHOOK_URL, "COOLIFY_WEBHOOK_URL"], + [config.coolifyAppUuid, APPROVED_APP_UUID, "COOLIFY_APP_UUID"], + [config.coolifyDeployBranch, APPROVED_DEPLOY_BRANCH, "COOLIFY_DEPLOY_BRANCH"], + ] as const) { + if (actual !== expected) { + throw new Error(`${name} does not match the approved AgoraHub target`); + } + } + return config; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function requestJson( + fetchImpl: FetchLike, + input: string, + init: RequestInit, + label: string, + deadlineAt: number, + nowImpl: NowLike, + requestTimeoutMs: number, + timeoutSignalImpl: TimeoutSignalLike +): Promise { + const remainingMs = Math.floor(deadlineAt - nowImpl()); + if (remainingMs <= 0) throw new Error(`${label} exceeded the deployment deadline`); + let response: Response; + try { + response = await fetchImpl(input, { + ...init, + redirect: "manual", + signal: init.signal ?? timeoutSignalImpl( + Math.max(1, Math.min(requestTimeoutMs, remainingMs)) + ), + }); + } catch (error) { + const detail = error instanceof Error ? `: ${error.message}` : ""; + throw new Error(`${label} request failed${detail}`, { cause: error }); + } + if (response.status >= 300 && response.status < 400) { + throw new Error(`${label} returned an unexpected redirect (${response.status})`); + } + if (!response.ok) throw new Error(`${label} failed with HTTP ${response.status}`); + try { return await response.json(); } + catch { throw new Error(`${label} returned invalid JSON`); } +} + +function refSha(value: unknown, label: string): string { + const sha = isRecord(value) && isRecord(value.object) ? value.object.sha : undefined; + if (typeof sha !== "string" || !SHA_PATTERN.test(sha)) throw new Error(`${label} returned no valid SHA`); + return sha; +} + +function isApprovedGitRepository(value: unknown, repository: string): boolean { + if (typeof value !== "string") return false; + const expected = repository.toLowerCase(); + return [ + repository, + `https://github.com/${repository}`, + `https://github.com/${repository}.git`, + `git@github.com:${repository}`, + `git@github.com:${repository}.git`, + ].some((candidate) => candidate.toLowerCase() === value.trim().toLowerCase() && expected.length > 0); +} + +export async function runCoolifyDeployment( + config: DeployConfig, + dependencies: DeployDependencies = {} +): Promise { + if (!config.enabled) return { status: "disabled" }; + const fetchImpl = dependencies.fetchImpl ?? fetch; + const sleepImpl = dependencies.sleepImpl ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const nowImpl = dependencies.nowImpl ?? performance.now.bind(performance); + const pollIntervalMs = dependencies.pollIntervalMs ?? 10_000; + const requestTimeoutMs = dependencies.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const deadlineMs = dependencies.deadlineMs ?? DEFAULT_DEADLINE_MS; + const timeoutSignalImpl = dependencies.timeoutSignalImpl ?? ((ms) => AbortSignal.timeout(ms)); + if (!Number.isInteger(pollIntervalMs) || pollIntervalMs < 1) throw new Error("pollIntervalMs must be positive"); + if (!Number.isInteger(requestTimeoutMs) || requestTimeoutMs < 1) throw new Error("requestTimeoutMs must be positive"); + if (!Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > DEFAULT_DEADLINE_MS) { + throw new Error("deadlineMs must be positive and at most 20 minutes"); + } + const deadlineAt = nowImpl() + deadlineMs; + const getJson = (input: string, init: RequestInit, label: string) => + requestJson( + fetchImpl, + input, + init, + label, + deadlineAt, + nowImpl, + requestTimeoutMs, + timeoutSignalImpl + ); + + const githubHeaders = { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${config.githubToken}`, + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }; + const githubBase = `https://api.github.com/repos/${config.githubRepository}`; + const main = await getJson(`${githubBase}/git/ref/heads/main`, { headers: githubHeaders }, "GitHub main ref"); + if (refSha(main, "GitHub main ref") !== config.verifiedSha) return { status: "superseded" }; + + const encodedBranch = encodeURIComponent(config.coolifyDeployBranch); + const deployRefReadUrl = `${githubBase}/git/ref/heads/${encodedBranch}`; + const deployRefUpdateUrl = `${githubBase}/git/refs/heads/${encodedBranch}`; + const deployRef = await getJson(deployRefReadUrl, { headers: githubHeaders }, "GitHub deploy ref"); + if (refSha(deployRef, "GitHub deploy ref") !== config.verifiedSha) { + await getJson(deployRefUpdateUrl, { + method: "PATCH", + headers: githubHeaders, + body: JSON.stringify({ sha: config.verifiedSha, force: true }), + }, "GitHub deploy ref update"); + } + + const finalDeployRef = await getJson( + deployRefReadUrl, + { headers: githubHeaders }, + "GitHub final deploy ref" + ); + if (refSha(finalDeployRef, "GitHub final deploy ref") !== config.verifiedSha) { + throw new Error("GitHub final deploy ref does not match the verified SHA"); + } + + const cloudflareHeaders = { + "CF-Access-Client-Id": config.cfAccessClientId, + "CF-Access-Client-Secret": config.cfAccessClientSecret, + }; + const coolifyReadHeaders = { + ...cloudflareHeaders, + Authorization: `Bearer ${config.coolifyReadToken}`, + "Content-Type": "application/json", + }; + const application = await getJson( + `${config.coolifyApiBaseUrl}/applications/${config.coolifyAppUuid}`, + { headers: coolifyReadHeaders }, + "Coolify application" + ); + if (!isRecord(application) || application.uuid !== config.coolifyAppUuid || + application.git_branch !== config.coolifyDeployBranch) { + throw new Error("Coolify application branch does not match the approved deploy branch"); + } + if (!isApprovedGitRepository(application.git_repository, config.githubRepository)) { + throw new Error("Coolify application repository does not match the approved GitHub repository"); + } + if (application.watch_paths !== null && + (typeof application.watch_paths !== "string" || application.watch_paths.trim() !== "")) { + throw new Error("Coolify application watch paths must be empty for signed CI webhooks"); + } + + const webhookBody = JSON.stringify({ + ref: `refs/heads/${config.coolifyDeployBranch}`, + after: config.verifiedSha, + repository: { full_name: config.githubRepository }, + commits: [], + }); + const signature = createHmac("sha256", config.coolifyWebhookSecret) + .update(webhookBody) + .digest("hex"); + const queued = await getJson(config.coolifyWebhookUrl, { + method: "POST", + headers: { + ...cloudflareHeaders, + "Content-Type": "application/json", + "X-GitHub-Delivery": `agorahub-${config.verifiedSha}`, + "X-GitHub-Event": "push", + "X-Hub-Signature-256": `sha256=${signature}`, + }, + body: webhookBody, + }, "Coolify signed webhook"); + const deployments = Array.isArray(queued) ? queued : null; + if (!deployments || deployments.length !== 1 || !isRecord(deployments[0]) || + deployments[0].status !== "success" || + deployments[0].application_uuid !== config.coolifyAppUuid || + typeof deployments[0].deployment_uuid !== "string" || + !DEPLOYMENT_UUID_PATTERN.test(deployments[0].deployment_uuid)) { + throw new Error("Coolify must queue exactly one AgoraHub deployment"); + } + const deploymentUuid = deployments[0].deployment_uuid; + + while (true) { + if (nowImpl() >= deadlineAt) { + throw new Error(`Coolify deployment exceeded the deadline: ${deploymentUuid}`); + } + const deployment = await getJson( + `${config.coolifyApiBaseUrl}/deployments/${encodeURIComponent(deploymentUuid)}`, + { headers: coolifyReadHeaders }, + `Coolify deployment ${deploymentUuid}` + ); + const status = isRecord(deployment) ? deployment.status : undefined; + const commit = isRecord(deployment) ? deployment.commit : undefined; + if (status === "finished") { + if (commit !== config.verifiedSha) throw new Error(`Coolify finished commit ${String(commit)} does not match verified SHA`); + return { status: "deployed", deploymentUuid }; + } + if (status === "failed" || status === "cancelled-by-user") throw new Error(`Coolify deployment ${deploymentUuid} ended with ${status}`); + if (status !== "queued" && status !== "in_progress") throw new Error(`Coolify deployment ${deploymentUuid} returned unknown status ${String(status)}`); + const remainingMs = Math.floor(deadlineAt - nowImpl()); + if (remainingMs <= 0) { + throw new Error(`Coolify deployment exceeded the deadline: ${deploymentUuid}`); + } + await sleepImpl(Math.min(pollIntervalMs, remainingMs)); + } +} + +async function main(): Promise { + const outcome = await runCoolifyDeployment(readDeployConfig()); + const outputPath = process.env.GITHUB_OUTPUT?.trim(); + if (!outputPath) throw new Error("GITHUB_OUTPUT is required"); + appendFileSync(outputPath, `deployed=${outcome.status === "deployed"}\noutcome=${outcome.status}\n`); + if (outcome.status === "deployed") appendFileSync(outputPath, `deployment_uuid=${outcome.deploymentUuid}\n`); + console.log(`Coolify CD outcome: ${outcome.status}`); +} + +const invokedPath = process.argv[1]; +if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : "Coolify deployment failed"); + process.exitCode = 1; + }); +}