Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/previews-deploy-on-purpose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"hephaestus": minor
---

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.

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.

**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, and the repository variables listed there — including the optional `PREVIEW_MAX_ACTIVE` limit. Keep Coolify's automatic repository webhook disabled.
6 changes: 6 additions & 0 deletions .github/workflows/ci-compose-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ jobs:
[ "$published" = "80 443 " ] || {
echo "::error::reverse-proxy publishes '$published', expected '80 443 ' — !override was ignored"; exit 1; }

- name: Set up the repository's Bun version
uses: ./.github/actions/setup-bun

- name: Render the preview stack and assert it stays sandboxed
run: bun run check:preview-stack

- name: Render the reference deployment
working-directory: docker
run: |
Expand Down
94 changes: 4 additions & 90 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,93 +164,6 @@ jobs:
- '!webapp/src/**/*.stories.*'
- '!webapp/src/test/**'

# Coolify creates previews for trusted same-repository pull requests. This job waits for the
# immutable application-server image, then updates the preview to the exact head commit. Coolify's
# deploy API only accepts a pull request that already has a preview, so a missing preview remains a
# safe no-op. Instance identifiers come from repository variables; forks never receive credentials.
preview:
name: "Preview / Coolify"
runs-on: ubuntu-latest
# Wait for Docker so SOURCE_COMMIT always names an image that already exists in GHCR. `always`
# preserves the link/no-op behavior when the Docker workflow is legitimately skipped.
needs: [detect-changes, Docker]
if: >-
always() &&
github.event_name == 'pull_request' &&
vars.COOLIFY_URL != '' &&
vars.COOLIFY_APP_UUID != '' &&
(needs.Docker.result == 'success' || needs.Docker.result == 'skipped')
permissions:
statuses: write
timeout-minutes: 2
env:
COOLIFY_URL: ${{ vars.COOLIFY_URL }}
COOLIFY_APP_UUID: ${{ vars.COOLIFY_APP_UUID }}
COOLIFY_PROJECT_UUID: ${{ vars.COOLIFY_PROJECT_UUID }}
COOLIFY_ENVIRONMENT_UUID: ${{ vars.COOLIFY_ENVIRONMENT_UUID }}
steps:
- name: Link the preview deployments page on the PR
if: vars.COOLIFY_PROJECT_UUID != '' && vars.COOLIFY_ENVIRONMENT_UUID != ''
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { COOLIFY_URL, COOLIFY_PROJECT_UUID, COOLIFY_ENVIRONMENT_UUID, COOLIFY_APP_UUID } = process.env;
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: context.payload.pull_request.head.sha,
state: 'success',
target_url: `${COOLIFY_URL}/project/${COOLIFY_PROJECT_UUID}/environment/${COOLIFY_ENVIRONMENT_UUID}/application/${COOLIFY_APP_UUID}/preview-deployments`,
description: 'Click Details to view Coolify preview deployments',
context: 'Preview / Coolify',
});

# Forks are skipped deliberately: a preview runs with the instance's real credentials.
- name: Update this PR's preview deployment, if it has one
if: >-
(github.event.action == 'opened' ||
github.event.action == 'reopened' ||
github.event.action == 'synchronize') &&
github.event.pull_request.head.repo.full_name == github.repository
env:
COOLIFY_TOKEN: ${{ secrets.COOLIFY_API_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
APP_SERVER_PUBLISHED: ${{ needs.Docker.outputs.application-server-published }}
run: |
set -euo pipefail

if [ -z "${COOLIFY_TOKEN}" ]; then
echo "::notice::COOLIFY_API_TOKEN is not configured; skipping preview update."
exit 0
fi

# Coolify pins the preview to SOURCE_COMMIT, so without that tag the deployment can only
# fail on `manifest unknown` — under a green check, since queueing one always succeeds.
if [ "${APP_SERVER_PUBLISHED}" != 'true' ]; then
echo "::notice::No application-server image for this commit; skipping preview update."
exit 0
fi

body=$(mktemp)
status=$(curl -sS -o "${body}" -w '%{http_code}' -X POST \
-H "Authorization: Bearer ${COOLIFY_TOKEN}" \
-H 'Accept: application/json' \
--retry 3 --retry-connrefused --max-time 30 \
"${COOLIFY_URL}/api/v1/deploy?uuid=${COOLIFY_APP_UUID}&pr=${PR_NUMBER}")

if [ "${status}" -ge 400 ]; then
echo "::error::Coolify returned HTTP ${status}: $(cat "${body}")"
exit 1
fi

deployment=$(jq -r '.deployments[0].deployment_uuid // empty' "${body}")
if [ -z "${deployment}" ]; then
echo "::notice::PR #${PR_NUMBER} has no preview deployment ($(jq -r '.deployments[0].message // "no deployment queued"' "${body}"))."
exit 0
fi

echo "::notice::Queued Coolify deployment ${deployment} for PR #${PR_NUMBER}."

Quality:
uses: ./.github/workflows/ci-quality-gates.yml
needs: [detect-changes]
Expand Down Expand Up @@ -305,9 +218,10 @@ jobs:
# Image builds consume the detected source tree, not Quality outputs. Running both branches at
# once keeps image and preview confidence without adding Docker as a second CI stage.
needs: [detect-changes]
# Every same-repository pull request gets a Coolify preview pinned to SOURCE_COMMIT, so it needs
# an application-server tag at its head commit even when it touches only docs or the preview
# stack. The path filters below therefore gate fork pull requests only — forks get no preview.
# A preview runs the images CI published for its exact head commit, so a same-repository pull
# request needs commit-addressed tags even when it touches only docs — `Tag unchanged images`
# re-tags rather than rebuilds. The path filters therefore gate fork pull requests only; forks
# never receive a preview.
if: |
needs.detect-changes.outputs.should_skip != 'true' && (
github.event_name != 'pull_request' ||
Expand Down
109 changes: 75 additions & 34 deletions .github/workflows/cleanup-preview.yml
Original file line number Diff line number Diff line change
@@ -1,51 +1,92 @@
name: Preview cleanup

# Since 2025-12-08 `pull_request_target` always takes the workflow file and the checked-out commit from
# the default branch, so no fork code runs beside the secrets here.
on:
pull_request:
branches: ["**"]
types: [closed]
pull_request_target:
types: [closed, unlabeled, converted_to_draft]

permissions:
contents: read
permissions: {}

concurrency:
group: hephaestus-preview-lifecycle
queue: max

jobs:
cleanup:
name: "Preview / Delete Coolify resources"
name: "Preview / Remove and verify resources"
if: >-
vars.COOLIFY_URL != '' &&
vars.COOLIFY_APP_UUID != '' &&
github.event.pull_request.head.repo.full_name == github.repository
github.event.pull_request.head.repo.full_name == github.repository &&
(github.event.label.name == 'preview' ||
(github.event.action != 'unlabeled' &&
contains(github.event.pull_request.labels.*.name, 'preview')))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
runs-on: ubuntu-latest
timeout-minutes: 3
permissions:
contents: read
deployments: write
pull-requests: write
timeout-minutes: 8
env:
COOLIFY_URL: ${{ vars.COOLIFY_URL }}
COOLIFY_APP_UUID: ${{ vars.COOLIFY_APP_UUID }}
COOLIFY_TOKEN: ${{ secrets.COOLIFY_API_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_URL: ${{ github.event.pull_request.html_url }}
PR_TITLE: ${{ github.event.pull_request.title }}
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
# Coolify's routing key: the branch its preview application is configured for.
BASE_REF: ${{ github.event.repository.default_branch }}
ENVIRONMENT: preview/pr-${{ github.event.pull_request.number }}
steps:
- name: Delete preview containers, volumes, and network
- name: Load the trusted preview adapter
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
sparse-checkout: |
.github/actions/setup-bun
package.json
scripts

- name: Set up the repository's Bun version
uses: ./.github/actions/setup-bun

- name: Request Coolify cleanup through the signed close event
id: close
continue-on-error: true
env:
COOLIFY_WEBHOOK_SECRET: ${{ secrets.COOLIFY_PREVIEW_WEBHOOK_SECRET }}
run: bun scripts/coolify-preview.ts close

- name: Keep a verified cleanup tombstone
id: tombstone
if: steps.close.outcome == 'success'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const controller = await import(`${process.env.GITHUB_WORKSPACE}/scripts/preview-controller.ts`);
await controller.inactivate({ github, context, core });

- name: Report the released preview
# Always overwrites the sticky comment, closed pull requests included: a torn-down preview
# must never leave a live-looking link behind.
if: always() && steps.tombstone.outcome == 'success'
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2
with:
header: app-preview
number: ${{ github.event.pull_request.number }}
message: |
## 🧹 App Preview

~~Preview removed~~ — its slot is free again.

<sub>Add the `preview` label, or mark the pull request ready for review, to deploy it again.</sub>

- name: Fail if Coolify did not accept the close event
if: always() && steps.close.outcome != 'success'
run: |
set -euo pipefail

if [ -z "${COOLIFY_TOKEN}" ]; then
echo "::notice::COOLIFY_API_TOKEN is not configured; preview cleanup must be done manually."
exit 0
fi

body=$(mktemp)
status=$(curl -sS -o "${body}" -w '%{http_code}' -X DELETE \
-H "Authorization: Bearer ${COOLIFY_TOKEN}" \
-H 'Accept: application/json' \
--retry 3 --retry-connrefused --max-time 60 \
"${COOLIFY_URL}/api/v1/applications/${COOLIFY_APP_UUID}/previews/${PR_NUMBER}")

if [ "${status}" -eq 404 ]; then
echo "::notice::PR #${PR_NUMBER} had no Coolify preview to clean up."
exit 0
fi
if [ "${status}" -ge 400 ]; then
echo "::error::Coolify preview cleanup returned HTTP ${status}: $(cat "${body}")"
exit 1
fi

echo "::notice::Deleted Coolify preview and persistent volumes for PR #${PR_NUMBER}."
echo "::error::Coolify did not accept the close event; the nightly reconcile will retry."
exit 1
Loading
Loading