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/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..3c3f416 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-ci-gated-coolify-deploy.md @@ -0,0 +1,1395 @@ +# 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`. + +## 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`. +- 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 | +| 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/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 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 | +| 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 | 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 | +| 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 | 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; +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 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 accept an unknown status (seven); +9. deadline/diagnostics: remove the absolute deadline; separately stop capping + 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 (five); +11. workflow serialization: remove the concurrency group, change + `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 + (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`: + +```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": "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-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-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 }, + { "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": 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": "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 88 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 new file mode 100644 index 0000000..21c7667 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-ci-gated-coolify-deploy-design.md @@ -0,0 +1,346 @@ +# CI-Gated Coolify Deployment Design + +**Status:** Approved direction; amended by pre-implementation plan-gate findings +**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 + +### 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, +green `main` SHA. Configure the Coolify application to build this branch instead +of `main`. + +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 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; +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 +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 workflow's ordinary `GITHUB_TOKEN` receives only these permissions: + +- `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 +boundary. + +### Two-layer API protection + +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. + +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 | +| 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: + +- `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. + +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 +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 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 + 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 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. + 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. +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. + +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 + +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; +- 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; +- 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 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, +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. 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. +6. GitHub waits for the matching Coolify deployment and fails unless status is + `finished` and the recorded commit is the verified SHA. +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. +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. +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. 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; + }); +}