Skip to content

Commit 4394634

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 544c7f6 commit 4394634

29 files changed

Lines changed: 4729 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 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
@@ -90,6 +90,12 @@ jobs:
9090
[ "$published" = "80 443 " ] || {
9191
echo "::error::reverse-proxy publishes '$published', expected '80 443 ' — !override was ignored"; exit 1; }
9292
93+
- name: Set up the repository's Bun version
94+
uses: ./.github/actions/setup-bun
95+
96+
- name: Render the preview stack and assert it stays sandboxed
97+
run: bun run check:preview-stack
98+
9399
- name: Render the reference deployment
94100
working-directory: docker
95101
run: |

.github/workflows/cicd.yml

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

3+
# Since 2025-12-08 `pull_request_target` always takes the workflow file and the checked-out commit from
4+
# the default branch, so no fork code runs beside the secrets here. A Coolify 2xx is not
5+
# proof of teardown, so this ends with a forced SSH command that proves the resources are gone.
36
on:
4-
pull_request:
5-
branches: ["**"]
6-
types: [closed]
7+
pull_request_target:
8+
types: [closed, unlabeled, converted_to_draft]
79

8-
permissions:
9-
contents: read
10+
permissions: {}
11+
12+
concurrency:
13+
group: hephaestus-preview-lifecycle
14+
queue: max
1015

1116
jobs:
1217
cleanup:
13-
name: "Preview / Delete Coolify resources"
18+
name: "Preview / Remove and verify resources"
1419
if: >-
1520
vars.COOLIFY_URL != '' &&
1621
vars.COOLIFY_APP_UUID != '' &&
17-
github.event.pull_request.head.repo.full_name == github.repository
22+
vars.PREVIEW_HOST != '' &&
23+
github.event.pull_request.head.repo.full_name == github.repository &&
24+
(github.event.label.name == 'preview' ||
25+
(github.event.action != 'unlabeled' &&
26+
contains(github.event.pull_request.labels.*.name, 'preview')))
1827
runs-on: ubuntu-latest
19-
timeout-minutes: 3
28+
permissions:
29+
contents: read
30+
deployments: write
31+
pull-requests: write
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(`${process.env.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)