From 7502b1fc1f49bd371c7a49a670930d20612c498e Mon Sep 17 00:00:00 2001 From: Ezekiel Lopez Date: Wed, 18 Mar 2026 12:58:59 -0700 Subject: [PATCH 1/7] init --- .github/workflows/preview.yml | 116 ++++++++++++++++++++++++++++++++++ Dockerfile | 3 + captain-definition | 4 ++ 3 files changed, 123 insertions(+) create mode 100644 .github/workflows/preview.yml create mode 100644 Dockerfile create mode 100644 captain-definition diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..8483221 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,116 @@ +name: Preview + +on: + pull_request: + branches: ["main"] + types: [opened, reopened, synchronize, closed] + +concurrency: + group: "preview-${{ github.event.number }}" + cancel-in-progress: true + +permissions: + pull-requests: write + +jobs: + deploy-preview: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + env: + APP_NAME: devx-pr-${{ github.event.number }} + CAPROVER_URL: ${{ secrets.CAPROVER_URL }} + CAPROVER_PASSWORD: ${{ secrets.CAPROVER_PASSWORD }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build + run: bun run build + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + + - name: Create CapRover app (idempotent) + run: | + TOKEN=$(curl -sf -X POST "$CAPROVER_URL/api/v2/login" \ + -H "Content-Type: application/json" \ + -d "{\"password\": \"$CAPROVER_PASSWORD\"}" \ + | jq -r '.data.token') + + curl -s -X POST "$CAPROVER_URL/api/v2/user/apps/appDefinitions/register" \ + -H "Content-Type: application/json" \ + -H "x-captain-auth: $TOKEN" \ + -d "{\"appName\": \"$APP_NAME\", \"hasPersistentData\": false}" || true + + - name: Package and deploy to CapRover + run: | + tar -czf deploy.tar.gz captain-definition Dockerfile out/ + npm install -g caprover + caprover deploy \ + --caproverUrl "$CAPROVER_URL" \ + --caproverPassword "$CAPROVER_PASSWORD" \ + --appName "$APP_NAME" \ + --tarFile ./deploy.tar.gz + + - name: Comment preview URL on PR + uses: actions/github-script@v7 + env: + APP_NAME: devx-pr-${{ github.event.number }} + CAPROVER_APP_DOMAIN: ${{ secrets.CAPROVER_APP_DOMAIN }} + with: + script: | + const url = `https://${process.env.APP_NAME}.${process.env.CAPROVER_APP_DOMAIN}`; + const marker = ''; + const body = `${marker}\n## Preview deployment\n\n${url}\n\n_Updated: ${new Date().toUTCString()}_`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + 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: context.issue.number, + body, + }); + } + + cleanup-preview: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + env: + APP_NAME: devx-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 "{\"password\": \"$CAPROVER_PASSWORD\"}" \ + | 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3308cac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM nginx:alpine +COPY out/ /usr/share/nginx/html +EXPOSE 80 diff --git a/captain-definition b/captain-definition new file mode 100644 index 0000000..0e14f82 --- /dev/null +++ b/captain-definition @@ -0,0 +1,4 @@ +{ + "schemaVersion": 2, + "dockerfilePath": "./Dockerfile" +} From 6532e74b0ab09bcc3454b23bca866969a9acbb37 Mon Sep 17 00:00:00 2001 From: Ezekiel Lopez Date: Wed, 18 Mar 2026 20:11:04 -0700 Subject: [PATCH 2/7] update pattern --- .github/workflows/preview.yml | 43 +++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 8483221..96b6fa1 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -11,13 +11,15 @@ concurrency: permissions: pull-requests: write + packages: write jobs: deploy-preview: if: github.event.action != 'closed' runs-on: ubuntu-latest env: - APP_NAME: devx-pr-${{ github.event.number }} + APP_NAME: pr-${{ github.event.number }} + IMAGE: ghcr.io/${{ github.repository_owner }}/devx-preview:pr-${{ github.event.number }} CAPROVER_URL: ${{ secrets.CAPROVER_URL }} CAPROVER_PASSWORD: ${{ secrets.CAPROVER_PASSWORD }} steps: @@ -38,32 +40,43 @@ jobs: NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} - - name: Create CapRover app (idempotent) + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ env.IMAGE }} + + - name: Create app and deploy image via CapRover API run: | - TOKEN=$(curl -sf -X POST "$CAPROVER_URL/api/v2/login" \ + ADMIN_TOKEN=$(curl -sf -X POST "$CAPROVER_URL/api/v2/login" \ -H "Content-Type: application/json" \ -d "{\"password\": \"$CAPROVER_PASSWORD\"}" \ | 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: $TOKEN" \ + -H "x-captain-auth: $ADMIN_TOKEN" \ -d "{\"appName\": \"$APP_NAME\", \"hasPersistentData\": false}" || true - - name: Package and deploy to CapRover - run: | - tar -czf deploy.tar.gz captain-definition Dockerfile out/ - npm install -g caprover - caprover deploy \ - --caproverUrl "$CAPROVER_URL" \ - --caproverPassword "$CAPROVER_PASSWORD" \ - --appName "$APP_NAME" \ - --tarFile ./deploy.tar.gz + # 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@v7 env: - APP_NAME: devx-pr-${{ github.event.number }} + APP_NAME: pr-${{ github.event.number }} CAPROVER_APP_DOMAIN: ${{ secrets.CAPROVER_APP_DOMAIN }} with: script: | @@ -99,7 +112,7 @@ jobs: if: github.event.action == 'closed' runs-on: ubuntu-latest env: - APP_NAME: devx-pr-${{ github.event.number }} + APP_NAME: pr-${{ github.event.number }} CAPROVER_URL: ${{ secrets.CAPROVER_URL }} CAPROVER_PASSWORD: ${{ secrets.CAPROVER_PASSWORD }} steps: From 37d7a0f7a91ee1ce153d163cf557ca0c6f85e2bc Mon Sep 17 00:00:00 2001 From: Ezekiel Lopez Date: Wed, 18 Mar 2026 20:18:03 -0700 Subject: [PATCH 3/7] update / pin actions --- .github/workflows/preview.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 96b6fa1..97b1842 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -24,10 +24,10 @@ jobs: CAPROVER_PASSWORD: ${{ secrets.CAPROVER_PASSWORD }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Bun - uses: oven-sh/setup-bun@v1 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest @@ -41,14 +41,14 @@ jobs: NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . push: true @@ -74,7 +74,7 @@ jobs: -d "{\"appName\": \"$APP_NAME\", \"imageName\": \"$IMAGE\", \"instanceCount\": 1}" - name: Comment preview URL on PR - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: APP_NAME: pr-${{ github.event.number }} CAPROVER_APP_DOMAIN: ${{ secrets.CAPROVER_APP_DOMAIN }} From f3a5da1714c4a792076660973fee33c5738140fd Mon Sep 17 00:00:00 2001 From: Ezekiel Lopez Date: Wed, 18 Mar 2026 20:29:09 -0700 Subject: [PATCH 4/7] build docker image in ci --- .github/workflows/preview.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 97b1842..2575611 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -19,7 +19,6 @@ jobs: runs-on: ubuntu-latest env: APP_NAME: pr-${{ github.event.number }} - IMAGE: ghcr.io/${{ github.repository_owner }}/devx-preview:pr-${{ github.event.number }} CAPROVER_URL: ${{ secrets.CAPROVER_URL }} CAPROVER_PASSWORD: ${{ secrets.CAPROVER_PASSWORD }} steps: @@ -40,17 +39,24 @@ jobs: NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + - name: Set image URL + run: echo "IMAGE=$(echo ghcr.io/${{ github.repository_owner }}/devx-preview:pr-${{ github.event.number }}-$(echo ${{ github.sha }} | cut -c1-7) | tr '[:upper:]' '[:lower:]')" >> $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.actor }} + 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 }} From 7ecc07d86191f2f8df9ce057864a150084398dd5 Mon Sep 17 00:00:00 2001 From: Ezekiel Lopez Date: Wed, 18 Mar 2026 20:33:29 -0700 Subject: [PATCH 5/7] cleanup cron job --- .github/workflows/cleanup-previews.yml | 66 ++++++++++++++++++++++++++ .github/workflows/preview.yml | 23 +++++---- 2 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/cleanup-previews.yml diff --git a/.github/workflows/cleanup-previews.yml b/.github/workflows/cleanup-previews.yml new file mode 100644 index 0000000..940f529 --- /dev/null +++ b/.github/workflows/cleanup-previews.yml @@ -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 "{\"password\": \"$CAPROVER_PASSWORD\"}" \ + | 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("")) | 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=" +## 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 diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 2575611..117b1ed 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -4,9 +4,14 @@ 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 }}" + group: "preview-${{ github.event.number || inputs.pr_number }}" cancel-in-progress: true permissions: @@ -18,7 +23,8 @@ jobs: if: github.event.action != 'closed' runs-on: ubuntu-latest env: - APP_NAME: pr-${{ github.event.number }} + 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 }} steps: @@ -40,7 +46,7 @@ jobs: NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} - name: Set image URL - run: echo "IMAGE=$(echo ghcr.io/${{ github.repository_owner }}/devx-preview:pr-${{ github.event.number }}-$(echo ${{ github.sha }} | cut -c1-7) | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + run: echo "IMAGE=$(echo ghcr.io/${{ github.repository_owner }}/devx-preview:pr-${PR_NUMBER}-$(echo ${{ github.sha }} | cut -c1-7) | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 @@ -82,18 +88,19 @@ jobs: - name: Comment preview URL on PR uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: - APP_NAME: pr-${{ github.event.number }} CAPROVER_APP_DOMAIN: ${{ secrets.CAPROVER_APP_DOMAIN }} with: script: | - const url = `https://${process.env.APP_NAME}.${process.env.CAPROVER_APP_DOMAIN}`; + const prNumber = process.env.PR_NUMBER; + const appName = `pr-${prNumber}`; + const url = `https://${appName}.${process.env.CAPROVER_APP_DOMAIN}`; const marker = ''; - const body = `${marker}\n## Preview deployment\n\n${url}\n\n_Updated: ${new Date().toUTCString()}_`; + 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: context.issue.number, + issue_number: Number(prNumber), }); const existing = comments.find(c => c.body.includes(marker)); @@ -109,7 +116,7 @@ jobs: await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: context.issue.number, + issue_number: Number(prNumber), body, }); } From 44d7aa8850c374130195d07e98d75a22b85b06ae Mon Sep 17 00:00:00 2001 From: Ezekiel Lopez Date: Wed, 18 Mar 2026 20:35:53 -0700 Subject: [PATCH 6/7] deploy on merge --- .github/workflows/deploy-staging.yml | 66 ++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/deploy-staging.yml diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 0000000..665ad03 --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,66 @@ +name: Deploy Staging + +on: + push: + branches: ["main"] + +permissions: + packages: write + +jobs: + deploy: + runs-on: ubuntu-latest + env: + APP_NAME: ${{ secrets.CAPROVER_STAGING_APP }} + CAPROVER_URL: ${{ secrets.CAPROVER_URL }} + CAPROVER_PASSWORD: ${{ secrets.CAPROVER_PASSWORD }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build + run: bun run build + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + + - name: Set image URL + run: echo "IMAGE=$(echo ghcr.io/${{ github.repository_owner }}/devx-staging:$(echo ${{ github.sha }} | cut -c1-7) | tr '[:upper:]' '[:lower:]')" >> $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 }} + + - name: Deploy image to CapRover + run: | + ADMIN_TOKEN=$(curl -sf -X POST "$CAPROVER_URL/api/v2/login" \ + -H "Content-Type: application/json" \ + -d "{\"password\": \"$CAPROVER_PASSWORD\"}" \ + | jq -r '.data.token') + + 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}" From 83ce00bc2c9261b3707e7ef83f33cd404ba1e380 Mon Sep 17 00:00:00 2001 From: Ezekiel Lopez Date: Wed, 18 Mar 2026 20:57:01 -0700 Subject: [PATCH 7/7] remove legacy captain-definition file --- captain-definition | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 captain-definition diff --git a/captain-definition b/captain-definition deleted file mode 100644 index 0e14f82..0000000 --- a/captain-definition +++ /dev/null @@ -1,4 +0,0 @@ -{ - "schemaVersion": 2, - "dockerfilePath": "./Dockerfile" -}