Skip to content

Commit 0524316

Browse files
feat(ci): add maintainer-approved pull request previews
1 parent 5728244 commit 0524316

19 files changed

Lines changed: 3656 additions & 846 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 now start only after a maintainer requests one. Once started, each new approved head deploys after its current CI run passes, appears through GitHub's native deployment link, and is removed when the pull request closes.
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.
8+
9+
**Operators:** follow the preview runbook before enabling the Coolify application. It requires a preview-only environment, two scoped Coolify secrets, a forced-command SSH cleanup key, and the repository variables listed there. Keep Coolify's automatic repository webhook disabled.

.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: 76 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,94 @@
11
name: Preview cleanup
22

3+
# pull_request_target loads the trusted default-branch workflow. It signs a close event for Coolify,
4+
# then uses a forced SSH command to prove that the matching host resources are gone.
35
on:
4-
pull_request:
6+
pull_request_target:
57
branches: ["**"]
68
types: [closed]
79

810
permissions:
911
contents: read
12+
deployments: write
13+
14+
concurrency:
15+
group: hephaestus-preview-lifecycle
16+
queue: max
1017

1118
jobs:
1219
cleanup:
13-
name: "Preview / Delete Coolify resources"
14-
if: >-
15-
vars.COOLIFY_URL != '' &&
16-
vars.COOLIFY_APP_UUID != '' &&
17-
github.event.pull_request.head.repo.full_name == github.repository
20+
name: "Preview / Remove and verify resources"
21+
if: github.event.pull_request.head.repo.full_name == github.repository
1822
runs-on: ubuntu-latest
19-
timeout-minutes: 3
23+
timeout-minutes: 8
2024
env:
2125
COOLIFY_URL: ${{ vars.COOLIFY_URL }}
2226
COOLIFY_APP_UUID: ${{ vars.COOLIFY_APP_UUID }}
23-
COOLIFY_TOKEN: ${{ secrets.COOLIFY_API_TOKEN }}
2427
PR_NUMBER: ${{ github.event.pull_request.number }}
28+
PR_URL: ${{ github.event.pull_request.html_url }}
29+
PR_TITLE: ${{ github.event.pull_request.title }}
30+
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
31+
HEAD_REF: ${{ github.event.pull_request.head.ref }}
32+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
33+
BASE_REF: ${{ github.event.pull_request.base.ref }}
34+
ENVIRONMENT: preview/pr-${{ github.event.pull_request.number }}
2535
steps:
26-
- name: Delete preview containers, volumes, and network
36+
- name: Load the trusted preview adapter
37+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
38+
with:
39+
ref: ${{ github.sha }}
40+
persist-credentials: false
41+
sparse-checkout: |
42+
.github/actions/setup-bun
43+
package.json
44+
scripts
45+
46+
- name: Set up the repository's Bun version
47+
uses: ./.github/actions/setup-bun
48+
49+
- name: Request Coolify cleanup through the signed close event
50+
id: close
51+
continue-on-error: true
52+
env:
53+
COOLIFY_WEBHOOK_SECRET: ${{ secrets.COOLIFY_PREVIEW_WEBHOOK_SECRET }}
54+
run: bun scripts/coolify-preview.ts close
55+
56+
- name: Wait for the Coolify deployment queue to drain
57+
id: drain
58+
if: always()
59+
continue-on-error: true
60+
env:
61+
COOLIFY_TOKEN: ${{ secrets.COOLIFY_PREVIEW_READ_TOKEN }}
62+
run: bun scripts/coolify-preview.ts drain
63+
64+
- name: Remove and verify matching host resources
65+
id: host_cleanup
66+
if: always()
67+
env:
68+
PREVIEW_HOST: ${{ vars.PREVIEW_HOST }}
69+
PREVIEW_HOST_KEY: ${{ vars.PREVIEW_HOST_KEY }}
70+
PREVIEW_SSH_USER: ${{ vars.PREVIEW_SSH_USER }}
71+
PREVIEW_SSH_PRIVATE_KEY: ${{ secrets.PREVIEW_SSH_PRIVATE_KEY }}
72+
run: bun scripts/preview-ssh.ts cleanup "${PR_NUMBER}"
73+
74+
- name: Keep a verified cleanup tombstone
75+
if: >-
76+
always() &&
77+
steps.close.outcome == 'success' &&
78+
steps.drain.outcome == 'success' &&
79+
steps.host_cleanup.outcome == 'success'
80+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
81+
with:
82+
script: |
83+
const controller = await import('${{ github.workspace }}/scripts/preview-controller.ts');
84+
await controller.inactivate({ github, context, core });
85+
86+
- name: Fail if cleanup could not be verified
87+
if: >-
88+
always() &&
89+
(steps.close.outcome != 'success' ||
90+
steps.drain.outcome != 'success' ||
91+
steps.host_cleanup.outcome != 'success')
2792
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}."
93+
echo "::error::Preview cleanup was not fully verified; the nightly reconciler will retry."
94+
exit 1

0 commit comments

Comments
 (0)