Skip to content
Open
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
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
.next
out
.git
.env*
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
LUMA_API_KEY="secret-..."

# Used by scripts/scaffold.sh and scripts/deploy-tar.sh (local CapRover testing).
# Requires the caprover CLI to be installed and logged in: caprover login
CAPROVER_URL="https://captain.your-caprover-domain.com"
CAPROVER_APP="devx-preview-test"
CAPROVER_APP_DOMAIN="your-caprover-root-domain.com"
CAPROVER_PASSWORD="secret-..."

66 changes: 66 additions & 0 deletions .github/workflows/cleanup-previews.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Cleanup Expired Previews

on:
schedule:
- cron: '0 */6 * * *' # every 6 hours
workflow_dispatch:

permissions:
pull-requests: write

jobs:
cleanup:
runs-on: ubuntu-latest
env:
CAPROVER_URL: ${{ secrets.CAPROVER_URL }}
CAPROVER_PASSWORD: ${{ secrets.CAPROVER_PASSWORD }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
EXPIRY_HOURS: 6
steps:
- name: Expire old PR previews
run: |
ADMIN_TOKEN=$(curl -sf -X POST "$CAPROVER_URL/api/v2/login" \
-H "Content-Type: application/json" \
-d "$(jq -nc --arg pass "$CAPROVER_PASSWORD" '{password:$pass}')" \
| jq -r '.data.token')

NOW_TS=$(date +%s)
REDEPLOY_URL="https://github.com/$REPO/actions/workflows/preview.yml"

gh pr list --repo "$REPO" --state open --json number --jq '.[].number' | while read PR_NUM; do
APP_NAME="pr-$PR_NUM"

# Find the active (non-expired) preview comment
COMMENT=$(gh api "repos/$REPO/issues/$PR_NUM/comments" \
--jq '[.[] | select(.body | contains("<!-- caprover-preview -->")) | select(.body | contains("expired") | not)] | first')

[ "$COMMENT" = "null" ] || [ -z "$COMMENT" ] && continue

COMMENT_ID=$(echo "$COMMENT" | jq -r '.id')
UPDATED_AT=$(echo "$COMMENT" | jq -r '.updated_at')
UPDATED_TS=$(date -d "$UPDATED_AT" +%s)
AGE_HOURS=$(( (NOW_TS - UPDATED_TS) / 3600 ))

[ "$AGE_HOURS" -lt "$EXPIRY_HOURS" ] && continue

echo "Expiring preview for PR #$PR_NUM (age: ${AGE_HOURS}h)"

# Delete CapRover app
curl -s -X POST "$CAPROVER_URL/api/v2/user/apps/appDefinitions/delete" \
-H "Content-Type: application/json" \
-H "x-captain-auth: $ADMIN_TOKEN" \
-d "{\"appName\": \"$APP_NAME\"}" || true

# Update comment to show expired state with re-deploy link
gh api "repos/$REPO/issues/comments/$COMMENT_ID" \
-X PATCH \
--field body="<!-- caprover-preview -->
## Preview deployment _(expired)_

Removed after ${EXPIRY_HOURS}h of inactivity.

[Re-deploy preview]($REDEPLOY_URL) — click **Run workflow** and enter PR number \`$PR_NUM\`.

_Expired: $(date -u '+%a, %d %b %Y %H:%M:%S UTC')_"
done
142 changes: 142 additions & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
name: Preview

on:
pull_request:
branches: ["main"]
types: [opened, reopened, synchronize, closed]
workflow_dispatch:
inputs:
pr_number:
description: PR number to (re-)deploy
required: true

concurrency:
group: "preview-${{ github.event.number || inputs.pr_number }}"
cancel-in-progress: true

permissions:
pull-requests: write
packages: write

jobs:
deploy-preview:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
env:
PR_NUMBER: ${{ github.event.number || inputs.pr_number }}
APP_NAME: pr-${{ github.event.number || inputs.pr_number }}
CAPROVER_URL: ${{ secrets.CAPROVER_URL }}
CAPROVER_PASSWORD: ${{ secrets.CAPROVER_PASSWORD }}
CAPROVER_APP_DOMAIN: ${{ secrets.CAPROVER_APP_DOMAIN }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Set image URL
run: |
REPO=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
SHA=$(echo "${{ github.sha }}" | cut -c1-7)
echo "IMAGE=ghcr.io/${REPO}-preview:pr-${PR_NUMBER}-${SHA}" >> $GITHUB_ENV

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0

- name: Log in to GHCR
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push Docker image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ env.IMAGE }}
build-args: |
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Create app and deploy image via CapRover API
run: |
ADMIN_TOKEN=$(curl -sf -X POST "$CAPROVER_URL/api/v2/login" \
-H "Content-Type: application/json" \
-d "$(jq -nc --arg pass "$CAPROVER_PASSWORD" '{password:$pass}')" \
| jq -r '.data.token')

# Create app if it doesn't exist
curl -s -X POST "$CAPROVER_URL/api/v2/user/apps/appDefinitions/register" \
-H "Content-Type: application/json" \
-H "x-captain-auth: $ADMIN_TOKEN" \
-d "{\"appName\": \"$APP_NAME\", \"hasPersistentData\": false}" || true

curl -sf -X POST "$CAPROVER_URL/api/v2/user/apps/appDefinitions/enablebasedomainssl" \
-H "Content-Type: application/json" \
-H "x-captain-auth: $ADMIN_TOKEN" \
-d "{\"appName\": \"$APP_NAME\"}" || true

# Point app at the pre-built image and trigger deploy
curl -sf -X POST "$CAPROVER_URL/api/v2/user/apps/appDefinitions/update" \
-H "Content-Type: application/json" \
-H "x-captain-auth: $ADMIN_TOKEN" \
-d "{\"appName\": \"$APP_NAME\", \"imageName\": \"$IMAGE\", \"instanceCount\": 1}"

- name: Comment preview URL on PR
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
CAPROVER_APP_DOMAIN: ${{ secrets.CAPROVER_APP_DOMAIN }}
with:
script: |
const prNumber = process.env.PR_NUMBER;
const appName = `pr-${prNumber}`;
const url = `https://${appName}.${process.env.CAPROVER_APP_DOMAIN}`;
const marker = '<!-- caprover-preview -->';
const body = `${marker}\n## Preview deployment\n\n${url}\n\n_Updated: ${new Date().toUTCString()} — expires after 6h of inactivity._`;

const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(prNumber),
});

const existing = comments.find(c => c.body.includes(marker));

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(prNumber),
body,
});
}

cleanup-preview:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
env:
APP_NAME: pr-${{ github.event.number }}
CAPROVER_URL: ${{ secrets.CAPROVER_URL }}
CAPROVER_PASSWORD: ${{ secrets.CAPROVER_PASSWORD }}
steps:
- name: Delete CapRover app
run: |
TOKEN=$(curl -sf -X POST "$CAPROVER_URL/api/v2/login" \
-H "Content-Type: application/json" \
-d "$(jq -nc --arg pass "$CAPROVER_PASSWORD" '{password:$pass}')" \
| jq -r '.data.token')

curl -s -X POST "$CAPROVER_URL/api/v2/user/apps/appDefinitions/delete" \
-H "Content-Type: application/json" \
-H "x-captain-auth: $TOKEN" \
-d "{\"appName\": \"$APP_NAME\"}" || true
25 changes: 25 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Stage 1: build the static export from source (no host-built out/ required —
# this makes the image buildable directly by CapRover, via `caprover deploy`
# or a tarball upload, with no CI/registry round-trip needed).
FROM oven/bun:1 AS build
WORKDIR /app
COPY package.json bun.lock ./

# --ignore-scripts: sqlite3/better-sqlite3 are unused dead deps that otherwise
# try to compile a native module at install time and need a full toolchain.
RUN bun install --frozen-lockfile --ignore-scripts
COPY . .

# Public Supabase anon key/URL — safe to bake in, matches .github/workflows/check.yml.
ARG NEXT_PUBLIC_SUPABASE_URL="https://psbmuerdpmkajkkldqtz.supabase.co"
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBzYm11ZXJkcG1rYWpra2xkcXR6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjMzNDE1NjcsImV4cCI6MjA3ODkxNzU2N30.JKaPS9tajIe6YJklEAdlih8a5xA-XgD3hStwKOEiihI"
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY

RUN bun run build

# Stage 2: serve the static export
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/out /usr/share/nginx/html
EXPOSE 80
4 changes: 4 additions & 0 deletions captain-definition
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
18 changes: 18 additions & 0 deletions nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;

# Next.js static export writes routes as flat `<route>.html` files
# (e.g. /events -> events.html), not `<route>/index.html`, so try the
# .html sibling before falling back to a directory index.
location / {
try_files $uri $uri.html $uri/index.html $uri/ =404;
}

error_page 404 /404.html;
location = /404.html {
internal;
}
}
106 changes: 106 additions & 0 deletions scripts/deploy-tar.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Deploy to CapRover via tarball upload.
#
# Packages the project source + captain-definition into a tar and deploys
# using `caprover deploy -t`. CapRover builds the Docker image on the server —
# no container registry or GitHub Actions required. Useful for fast local
# iteration: no CI round-trip, just build-on-server and check the URL.
#
# Usage:
# ./scripts/deploy-tar.sh # deploy using .env.local
# ./scripts/deploy-tar.sh --dry-run # print what would happen without doing it
# ./scripts/deploy-tar.sh --env=.env.staging
#
# Requires:
# - caprover CLI installed and logged in (run `caprover login` first)
# - CAPROVER_URL and CAPROVER_APP set in .env.local

set -euo pipefail

ENV_FILE=".env.local"
DRY_RUN=false
TAR_FILE="./deploy.tar"

for arg in "$@"; do
case $arg in
--dry-run) DRY_RUN=true ;;
--env=*) ENV_FILE="${arg#--env=}" ;;
esac
done

# --- Parse env file ---
declare -A ENV
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line// }" ]] && continue
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
val="${BASH_REMATCH[2]}"
val="${val#\"}" ; val="${val%\"}"
val="${val#\'}" ; val="${val%\'}"
ENV["$key"]="$val"
fi
done < "$ENV_FILE"

get() { echo "${ENV[$1]:-}"; }

CAPROVER_URL="$(get CAPROVER_URL)"
APP_NAME="$(get CAPROVER_APP)"

for var in CAPROVER_URL CAPROVER_APP; do
if [[ -z "${ENV[$var]:-}" ]]; then
echo "Error: $var not set in $ENV_FILE" >&2; exit 1
fi
done

# --- Find the caprover machine name matching CAPROVER_URL ---
echo "==> Finding caprover CLI session for $CAPROVER_URL..."
CAPROVER_NAME=$(caprover ls 2>/dev/null \
| awk -v url="$CAPROVER_URL" '$0 ~ url { print $2 }')

if [[ -z "$CAPROVER_NAME" ]]; then
echo "Error: no caprover CLI session found for $CAPROVER_URL" >&2
echo " Run: caprover login" >&2
exit 1
fi
echo " using machine '$CAPROVER_NAME'"

if $DRY_RUN; then
echo ""
echo "[dry-run] Would create $TAR_FILE from project source"
echo "[dry-run] caprover deploy -t $TAR_FILE -n $CAPROVER_NAME -a $APP_NAME"
exit 0
fi

# --- Build tar ---
# Excludes match .dockerignore, plus the tar itself.
echo ""
echo "==> Creating $TAR_FILE..."

tar -cf "$TAR_FILE" \
--exclude='./node_modules' \
--exclude='./.next' \
--exclude='./out' \
--exclude='./.env*' \
--exclude='./.git' \
--exclude="$TAR_FILE" \
.

echo " $(du -sh "$TAR_FILE" | cut -f1) — $(tar -tf "$TAR_FILE" | wc -l | tr -d ' ') files"

# --- Deploy ---
echo ""
echo "==> Deploying '$APP_NAME' to '$CAPROVER_NAME'..."
echo " CapRover will build the Docker image on the server."
echo " Build logs will stream below — this takes a few minutes."
echo ""

caprover deploy \
--tarFile "$TAR_FILE" \
--caproverName "$CAPROVER_NAME" \
--caproverApp "$APP_NAME"

# --- Cleanup ---
rm -f "$TAR_FILE"
echo ""
echo "Deploy complete."
Loading
Loading