Skip to content

Commit 0965d56

Browse files
feat(ci): add label-gated pull request preview deployments
Add the `preview` label to a pull request and its current commit deploys once CI passes; every later green head follows automatically. Removing the label, closing the pull request, or converting it to draft tears the stack down and frees its slot. Previews run signed, commit-addressed CI images with their own database, broker and credentials — no staging Docker socket, network or data. Forks never deploy, and a head that introduces changes to the deployment workflows or the preview Compose file is refused until that change merges. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017VKWqbmrPJFv8aKZBp36uD
1 parent 4baf4e4 commit 0965d56

29 files changed

Lines changed: 4532 additions & 842 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"hephaestus": minor
3+
---
4+
5+
Pull request previews are now self-service. Add the `preview` label to a pull request in this repository and it deploys; every commit after that redeploys on its own. A preview waits only for its images to be published, never for the test suite, so it exists even when the tests are red — and it runs the same artifacts staging and production run, so what you see is what ships. A comment on the pull request carries the preview link, and GitHub's native deployment link opens it too. Removing the label, closing the pull request, or converting it back to draft removes the stack. Up to three previews run at once by default, and when the host is full the pull request comment names the ones holding the slots.
6+
7+
Preview stacks use their own database, message broker, credentials, and Docker networks, and reach neither the staging Docker socket, its data, nor any integration credential. Previews never run for forks, nor for changes to the deployment workflows themselves. Stacked pull requests each get their own preview.
8+
9+
**Operators:** follow the preview runbook before enabling the Coolify application. It requires a `preview` repository label, a preview-only Coolify application, two scoped Coolify secrets, a forced-command SSH cleanup key, and the repository variables listed there — including the optional `PREVIEW_MAX_ACTIVE` limit. Keep Coolify's automatic repository webhook disabled.

.github/workflows/ci-compose-validate.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ jobs:
8181
[ "$published" = "80 443 " ] || {
8282
echo "::error::reverse-proxy publishes '$published', expected '80 443 ' — !override was ignored"; exit 1; }
8383
84+
- name: Set up the repository's Bun version
85+
uses: ./.github/actions/setup-bun
86+
87+
- name: Render the preview stack and assert it stays sandboxed
88+
run: bun scripts/check-preview-stack.ts
89+
8490
- name: Assert the pinned release version has not drifted
8591
run: |
8692
set -euo pipefail

.github/workflows/cicd.yml

Lines changed: 4 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -167,93 +167,6 @@ jobs:
167167
- '!webapp/src/**/*.stories.*'
168168
- '!webapp/src/test/**'
169169
170-
# Coolify creates previews for trusted same-repository pull requests. This job waits for the
171-
# immutable application-server image, then updates the preview to the exact head commit. Coolify's
172-
# deploy API only accepts a pull request that already has a preview, so a missing preview remains a
173-
# safe no-op. Instance identifiers come from repository variables; forks never receive credentials.
174-
preview:
175-
name: "Preview / Coolify"
176-
runs-on: ubuntu-latest
177-
# Wait for Docker so SOURCE_COMMIT always names an image that already exists in GHCR. `always`
178-
# preserves the link/no-op behavior when the Docker workflow is legitimately skipped.
179-
needs: [detect-changes, Docker]
180-
if: >-
181-
always() &&
182-
github.event_name == 'pull_request' &&
183-
vars.COOLIFY_URL != '' &&
184-
vars.COOLIFY_APP_UUID != '' &&
185-
(needs.Docker.result == 'success' || needs.Docker.result == 'skipped')
186-
permissions:
187-
statuses: write
188-
timeout-minutes: 2
189-
env:
190-
COOLIFY_URL: ${{ vars.COOLIFY_URL }}
191-
COOLIFY_APP_UUID: ${{ vars.COOLIFY_APP_UUID }}
192-
COOLIFY_PROJECT_UUID: ${{ vars.COOLIFY_PROJECT_UUID }}
193-
COOLIFY_ENVIRONMENT_UUID: ${{ vars.COOLIFY_ENVIRONMENT_UUID }}
194-
steps:
195-
- name: Link the preview deployments page on the PR
196-
if: vars.COOLIFY_PROJECT_UUID != '' && vars.COOLIFY_ENVIRONMENT_UUID != ''
197-
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
198-
with:
199-
script: |
200-
const { COOLIFY_URL, COOLIFY_PROJECT_UUID, COOLIFY_ENVIRONMENT_UUID, COOLIFY_APP_UUID } = process.env;
201-
await github.rest.repos.createCommitStatus({
202-
owner: context.repo.owner,
203-
repo: context.repo.repo,
204-
sha: context.payload.pull_request.head.sha,
205-
state: 'success',
206-
target_url: `${COOLIFY_URL}/project/${COOLIFY_PROJECT_UUID}/environment/${COOLIFY_ENVIRONMENT_UUID}/application/${COOLIFY_APP_UUID}/preview-deployments`,
207-
description: 'Click Details to view Coolify preview deployments',
208-
context: 'Preview / Coolify',
209-
});
210-
211-
# Forks are skipped deliberately: a preview runs with the instance's real credentials.
212-
- name: Update this PR's preview deployment, if it has one
213-
if: >-
214-
(github.event.action == 'opened' ||
215-
github.event.action == 'reopened' ||
216-
github.event.action == 'synchronize') &&
217-
github.event.pull_request.head.repo.full_name == github.repository
218-
env:
219-
COOLIFY_TOKEN: ${{ secrets.COOLIFY_API_TOKEN }}
220-
PR_NUMBER: ${{ github.event.pull_request.number }}
221-
APP_SERVER_PUBLISHED: ${{ needs.Docker.outputs.application-server-published }}
222-
run: |
223-
set -euo pipefail
224-
225-
if [ -z "${COOLIFY_TOKEN}" ]; then
226-
echo "::notice::COOLIFY_API_TOKEN is not configured; skipping preview update."
227-
exit 0
228-
fi
229-
230-
# Coolify pins the preview to SOURCE_COMMIT, so without that tag the deployment can only
231-
# fail on `manifest unknown` — under a green check, since queueing one always succeeds.
232-
if [ "${APP_SERVER_PUBLISHED}" != 'true' ]; then
233-
echo "::notice::No application-server image for this commit; skipping preview update."
234-
exit 0
235-
fi
236-
237-
body=$(mktemp)
238-
status=$(curl -sS -o "${body}" -w '%{http_code}' -X POST \
239-
-H "Authorization: Bearer ${COOLIFY_TOKEN}" \
240-
-H 'Accept: application/json' \
241-
--retry 3 --retry-connrefused --max-time 30 \
242-
"${COOLIFY_URL}/api/v1/deploy?uuid=${COOLIFY_APP_UUID}&pr=${PR_NUMBER}")
243-
244-
if [ "${status}" -ge 400 ]; then
245-
echo "::error::Coolify returned HTTP ${status}: $(cat "${body}")"
246-
exit 1
247-
fi
248-
249-
deployment=$(jq -r '.deployments[0].deployment_uuid // empty' "${body}")
250-
if [ -z "${deployment}" ]; then
251-
echo "::notice::PR #${PR_NUMBER} has no preview deployment ($(jq -r '.deployments[0].message // "no deployment queued"' "${body}"))."
252-
exit 0
253-
fi
254-
255-
echo "::notice::Queued Coolify deployment ${deployment} for PR #${PR_NUMBER}."
256-
257170
Quality:
258171
uses: ./.github/workflows/ci-quality-gates.yml
259172
needs: [detect-changes]
@@ -308,9 +221,10 @@ jobs:
308221
# Image builds consume the detected source tree, not Quality outputs. Running both branches at
309222
# once keeps image and preview confidence without adding Docker as a second CI stage.
310223
needs: [detect-changes]
311-
# Every same-repository pull request gets a Coolify preview pinned to SOURCE_COMMIT, so it needs
312-
# an application-server tag at its head commit even when it touches only docs or the preview
313-
# stack. The path filters below therefore gate fork pull requests only — forks get no preview.
224+
# A preview runs the images CI published for its exact head commit, so a same-repository pull
225+
# request needs commit-addressed tags even when it touches only docs — `Tag unchanged images`
226+
# re-tags rather than rebuilds. The path filters therefore gate fork pull requests only; forks
227+
# never receive a preview.
314228
if: |
315229
needs.detect-changes.outputs.should_skip != 'true' && (
316230
github.event_name != 'pull_request' ||
Lines changed: 91 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,110 @@
11
name: Preview cleanup
22

3+
# Runs trusted default-branch code, because it holds the Coolify webhook secret: `pull_request_target`
4+
# loads the workflow and the adapter from the pull request's base branch, and the filter below admits
5+
# only `main`. A Coolify 2xx is not proof of teardown, so this ends with a forced SSH command that
6+
# proves the host resources are gone and the slot is free.
37
on:
4-
pull_request:
5-
branches: ["**"]
6-
types: [closed]
8+
pull_request_target:
9+
types: [closed, unlabeled, converted_to_draft]
710

811
permissions:
912
contents: read
13+
deployments: write
14+
pull-requests: write
15+
16+
concurrency:
17+
group: hephaestus-preview-lifecycle
18+
queue: max
1019

1120
jobs:
1221
cleanup:
13-
name: "Preview / Delete Coolify resources"
22+
name: "Preview / Remove and verify resources"
1423
if: >-
1524
vars.COOLIFY_URL != '' &&
1625
vars.COOLIFY_APP_UUID != '' &&
17-
github.event.pull_request.head.repo.full_name == github.repository
26+
vars.PREVIEW_HOST != '' &&
27+
github.event.pull_request.head.repo.full_name == github.repository &&
28+
(github.event.label.name == 'preview' ||
29+
(github.event.action != 'unlabeled' &&
30+
contains(github.event.pull_request.labels.*.name, 'preview')))
1831
runs-on: ubuntu-latest
19-
timeout-minutes: 3
32+
timeout-minutes: 8
2033
env:
2134
COOLIFY_URL: ${{ vars.COOLIFY_URL }}
2235
COOLIFY_APP_UUID: ${{ vars.COOLIFY_APP_UUID }}
23-
COOLIFY_TOKEN: ${{ secrets.COOLIFY_API_TOKEN }}
2436
PR_NUMBER: ${{ github.event.pull_request.number }}
37+
PR_URL: ${{ github.event.pull_request.html_url }}
38+
PR_TITLE: ${{ github.event.pull_request.title }}
39+
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
40+
HEAD_REF: ${{ github.event.pull_request.head.ref }}
41+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
42+
# Coolify's routing key: the branch its preview application is configured for.
43+
BASE_REF: ${{ github.event.repository.default_branch }}
44+
ENVIRONMENT: preview/pr-${{ github.event.pull_request.number }}
2545
steps:
26-
- name: Delete preview containers, volumes, and network
46+
- name: Load the trusted preview adapter
47+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
48+
with:
49+
ref: ${{ github.sha }}
50+
persist-credentials: false
51+
sparse-checkout: |
52+
.github/actions/setup-bun
53+
package.json
54+
scripts
55+
56+
- name: Set up the repository's Bun version
57+
uses: ./.github/actions/setup-bun
58+
59+
- name: Request Coolify cleanup through the signed close event
60+
id: close
61+
continue-on-error: true
62+
env:
63+
COOLIFY_WEBHOOK_SECRET: ${{ secrets.COOLIFY_PREVIEW_WEBHOOK_SECRET }}
64+
run: bun scripts/coolify-preview.ts close
65+
66+
- name: Remove and verify matching host resources
67+
id: host_cleanup
68+
if: always()
69+
env:
70+
PREVIEW_HOST: ${{ vars.PREVIEW_HOST }}
71+
PREVIEW_HOST_KEY: ${{ vars.PREVIEW_HOST_KEY }}
72+
PREVIEW_SSH_USER: ${{ vars.PREVIEW_SSH_USER }}
73+
PREVIEW_SSH_PRIVATE_KEY: ${{ secrets.PREVIEW_SSH_PRIVATE_KEY }}
74+
run: bun scripts/preview-ssh.ts cleanup "${PR_NUMBER}"
75+
76+
- name: Keep a verified cleanup tombstone
77+
id: tombstone
78+
# Proven-absent host resources are what frees the slot. A Coolify hiccup must not leave the
79+
# environment counting against admission forever; the tombstone says the nightly run still
80+
# owes Coolify a second pass.
81+
if: always() && steps.host_cleanup.outcome == 'success'
82+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
83+
with:
84+
script: |
85+
const controller = await import('${{ github.workspace }}/scripts/preview-controller.ts');
86+
await controller.inactivate({ github, context, core });
87+
88+
- name: Report the released preview
89+
# Always overwrites the sticky comment, closed pull requests included: a torn-down preview
90+
# must never leave a live-looking link behind.
91+
if: always() && steps.tombstone.outcome == 'success'
92+
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2
93+
with:
94+
header: app-preview
95+
number: ${{ github.event.pull_request.number }}
96+
message: |
97+
## 🧹 App Preview
98+
99+
~~Preview removed~~ — the slot on the preview host is free again.
100+
101+
<sub>Add the `preview` label, or mark the pull request ready for review, to deploy it again.</sub>
102+
103+
- name: Fail if cleanup could not be verified
104+
if: >-
105+
always() &&
106+
(steps.close.outcome != 'success' ||
107+
steps.host_cleanup.outcome != 'success')
27108
run: |
28-
set -euo pipefail
29-
30-
if [ -z "${COOLIFY_TOKEN}" ]; then
31-
echo "::notice::COOLIFY_API_TOKEN is not configured; preview cleanup must be done manually."
32-
exit 0
33-
fi
34-
35-
body=$(mktemp)
36-
status=$(curl -sS -o "${body}" -w '%{http_code}' -X DELETE \
37-
-H "Authorization: Bearer ${COOLIFY_TOKEN}" \
38-
-H 'Accept: application/json' \
39-
--retry 3 --retry-connrefused --max-time 60 \
40-
"${COOLIFY_URL}/api/v1/applications/${COOLIFY_APP_UUID}/previews/${PR_NUMBER}")
41-
42-
if [ "${status}" -eq 404 ]; then
43-
echo "::notice::PR #${PR_NUMBER} had no Coolify preview to clean up."
44-
exit 0
45-
fi
46-
if [ "${status}" -ge 400 ]; then
47-
echo "::error::Coolify preview cleanup returned HTTP ${status}: $(cat "${body}")"
48-
exit 1
49-
fi
50-
51-
echo "::notice::Deleted Coolify preview and persistent volumes for PR #${PR_NUMBER}."
109+
echo "::error::Preview cleanup was not fully verified; the nightly reconciler will retry."
110+
exit 1

0 commit comments

Comments
 (0)