Skip to content

Commit 60e964e

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 60e964e

26 files changed

Lines changed: 4575 additions & 849 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 its current commit deploys as soon as CI passes; every commit after that redeploys on its own. 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. They run signed CI images without the staging Docker socket, data, or integration credentials, and previews never run for forks or 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: 3 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,9 @@ 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 onlyforks get no preview.
224+
# Any same-repository pull request can be opted into a preview after CI, so it needs signed,
225+
# commit-addressed image tags even when it touches only docs or the preview stack. The path
226+
# filters below therefore gate fork pull requests only; forks never receive a preview.
314227
if: |
315228
needs.detect-changes.outputs.should_skip != 'true' && (
316229
github.event_name != 'pull_request' ||
Lines changed: 97 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,114 @@
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: >-
15-
vars.COOLIFY_URL != '' &&
16-
vars.COOLIFY_APP_UUID != '' &&
17-
github.event.pull_request.head.repo.full_name == github.repository
24+
github.event.pull_request.head.repo.full_name == github.repository &&
25+
(github.event.label.name == 'preview' ||
26+
(github.event.action != 'unlabeled' &&
27+
contains(github.event.pull_request.labels.*.name, 'preview')))
1828
runs-on: ubuntu-latest
19-
timeout-minutes: 3
29+
timeout-minutes: 8
2030
env:
2131
COOLIFY_URL: ${{ vars.COOLIFY_URL }}
2232
COOLIFY_APP_UUID: ${{ vars.COOLIFY_APP_UUID }}
23-
COOLIFY_TOKEN: ${{ secrets.COOLIFY_API_TOKEN }}
2433
PR_NUMBER: ${{ github.event.pull_request.number }}
34+
PR_URL: ${{ github.event.pull_request.html_url }}
35+
PR_TITLE: ${{ github.event.pull_request.title }}
36+
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
37+
HEAD_REF: ${{ github.event.pull_request.head.ref }}
38+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
39+
# Coolify's routing key: the branch its preview application is configured for.
40+
BASE_REF: ${{ github.event.repository.default_branch }}
41+
ENVIRONMENT: preview/pr-${{ github.event.pull_request.number }}
2542
steps:
26-
- name: Delete preview containers, volumes, and network
43+
- name: Load the trusted preview adapter
44+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
45+
with:
46+
ref: ${{ github.sha }}
47+
persist-credentials: false
48+
sparse-checkout: |
49+
.github/actions/setup-bun
50+
package.json
51+
scripts
52+
53+
- name: Set up the repository's Bun version
54+
uses: ./.github/actions/setup-bun
55+
56+
- name: Request Coolify cleanup through the signed close event
57+
id: close
58+
continue-on-error: true
59+
env:
60+
COOLIFY_WEBHOOK_SECRET: ${{ secrets.COOLIFY_PREVIEW_WEBHOOK_SECRET }}
61+
run: bun scripts/coolify-preview.ts close
62+
63+
- name: Wait for the Coolify deployment queue to drain
64+
id: drain
65+
if: always()
66+
continue-on-error: true
67+
env:
68+
COOLIFY_TOKEN: ${{ secrets.COOLIFY_PREVIEW_READ_TOKEN }}
69+
run: bun scripts/coolify-preview.ts drain
70+
71+
- name: Remove and verify matching host resources
72+
id: host_cleanup
73+
if: always()
74+
env:
75+
PREVIEW_HOST: ${{ vars.PREVIEW_HOST }}
76+
PREVIEW_HOST_KEY: ${{ vars.PREVIEW_HOST_KEY }}
77+
PREVIEW_SSH_USER: ${{ vars.PREVIEW_SSH_USER }}
78+
PREVIEW_SSH_PRIVATE_KEY: ${{ secrets.PREVIEW_SSH_PRIVATE_KEY }}
79+
run: bun scripts/preview-ssh.ts cleanup "${PR_NUMBER}"
80+
81+
- name: Keep a verified cleanup tombstone
82+
id: tombstone
83+
# Proven-absent host resources are what frees the slot. A Coolify hiccup must not leave the
84+
# environment counting against admission forever; the tombstone says the nightly run still
85+
# owes Coolify a second pass.
86+
if: always() && steps.host_cleanup.outcome == 'success'
87+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
88+
with:
89+
script: |
90+
const controller = await import('${{ github.workspace }}/scripts/preview-controller.ts');
91+
await controller.inactivate({ github, context, core });
92+
93+
- name: Report the released preview
94+
if: always() && steps.tombstone.outcome == 'success' && github.event.pull_request.state == 'open'
95+
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2
96+
with:
97+
header: app-preview
98+
number: ${{ github.event.pull_request.number }}
99+
message: |
100+
## 🧹 App Preview
101+
102+
~~Preview removed~~ — the slot on the preview host is free again.
103+
104+
<sub>Add the `preview` label, or mark the pull request ready for review, to deploy it again.</sub>
105+
106+
- name: Fail if cleanup could not be verified
107+
if: >-
108+
always() &&
109+
(steps.close.outcome != 'success' ||
110+
steps.drain.outcome != 'success' ||
111+
steps.host_cleanup.outcome != 'success')
27112
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}."
113+
echo "::error::Preview cleanup was not fully verified; the nightly reconciler will retry."
114+
exit 1

0 commit comments

Comments
 (0)