From 5a1e7ea1739063dd24ef93ab2589b14eddd3b15b Mon Sep 17 00:00:00 2001 From: Kyle Bouwman Date: Thu, 6 Aug 2026 20:51:28 -0700 Subject: [PATCH 1/5] add Neon DB branching on PR open/close (KB-26) Creates a Neon branch per PR, applies pending Drizzle migrations to it, and repoints the Vercel preview deployment for that git branch at it via a branch-scoped DATABASE_URL. Vercel resolves env vars at build time, so the first run forces one rebuild; later pushes reuse the existing branch and skip it. PR close removes both the env var and the Neon branch. Co-Authored-By: Claude Opus 5 --- .github/workflows/neon-preview-branch.yml | 119 +++++++++++++++++++++ .github/workflows/neon-preview-cleanup.yml | 58 ++++++++++ 2 files changed, 177 insertions(+) create mode 100644 .github/workflows/neon-preview-branch.yml create mode 100644 .github/workflows/neon-preview-cleanup.yml diff --git a/.github/workflows/neon-preview-branch.yml b/.github/workflows/neon-preview-branch.yml new file mode 100644 index 0000000..9c28c5b --- /dev/null +++ b/.github/workflows/neon-preview-branch.yml @@ -0,0 +1,119 @@ +name: Neon preview branch + +# Creates (or reuses) a Neon branch for each PR, applies pending Drizzle +# migrations to it, and points the Vercel preview deployment for this git +# branch at it. Teardown lives in neon-preview-cleanup.yml. + +on: + pull_request: + types: [opened, reopened, synchronize] + +concurrency: + group: neon-preview-${{ github.event.pull_request.number }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + preview-db: + # Forked PRs cannot read secrets, so every API call below would fail. + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + env: + NEON_BRANCH: preview/pr-${{ github.event.pull_request.number }} + GIT_BRANCH: ${{ github.event.pull_request.head.ref }} + VERCEL_API: https://api.vercel.com + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }} + VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} + + steps: + - uses: actions/checkout@v4 + + - name: Create Neon branch + id: neon + uses: neondatabase/create-branch-action@v6.4.0 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch_name: ${{ env.NEON_BRANCH }} + api_key: ${{ secrets.NEON_API_KEY }} + # database/role default to neondb/neondb_owner, which match production. + + - name: Mask connection strings + run: | + echo "::add-mask::${{ steps.neon.outputs.db_url }}" + echo "::add-mask::${{ steps.neon.outputs.db_url_pooled }}" + echo "::add-mask::${{ steps.neon.outputs.password }}" + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Apply migrations to the preview branch + # Direct (unpooled) endpoint — DDL should not go through the pooler. + env: + DATABASE_URL: ${{ steps.neon.outputs.db_url }} + run: bun db:migrate + + - name: Point Vercel preview at the Neon branch + id: vercel_env + env: + DB_URL_POOLED: ${{ steps.neon.outputs.db_url_pooled }} + run: | + set -euo pipefail + + existing=$(curl -sS -f \ + -H "Authorization: Bearer $VERCEL_TOKEN" \ + "$VERCEL_API/v10/projects/$VERCEL_PROJECT_ID/env?teamId=$VERCEL_ORG_ID&decrypt=true&gitBranch=$GIT_BRANCH" \ + | jq -r --arg b "$GIT_BRANCH" \ + '.envs[] | select(.key == "DATABASE_URL" and .gitBranch == $b) | .value' \ + | head -n 1) + + if [ "$existing" = "$DB_URL_POOLED" ]; then + echo "DATABASE_URL for $GIT_BRANCH is already correct; skipping redeploy." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + jq -n \ + --arg value "$DB_URL_POOLED" \ + --arg branch "$GIT_BRANCH" \ + --arg comment "Neon branch $NEON_BRANCH (managed by neon-preview-branch.yml)" \ + '{key: "DATABASE_URL", value: $value, type: "encrypted", + target: ["preview"], gitBranch: $branch, comment: $comment}' \ + | curl -sS -f -X POST \ + -H "Authorization: Bearer $VERCEL_TOKEN" \ + -H "Content-Type: application/json" \ + --data @- \ + "$VERCEL_API/v10/projects/$VERCEL_PROJECT_ID/env?teamId=$VERCEL_ORG_ID&upsert=true" \ + > /dev/null + + echo "Set DATABASE_URL for preview branch $GIT_BRANCH." + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Redeploy preview with the new DATABASE_URL + # Vercel's own build for this push already started with the previous + # (project-level) DATABASE_URL. Env changes only take effect on a new + # build, so force one. Only needed when the value actually changed — + # i.e. the first run for this PR. + if: steps.vercel_env.outputs.changed == 'true' + run: | + set -euo pipefail + + jq -n \ + --arg name "${{ github.event.repository.name }}" \ + --arg ref "$GIT_BRANCH" \ + --arg sha "${{ github.event.pull_request.head.sha }}" \ + --argjson repoId ${{ github.event.repository.id }} \ + '{name: $name, + gitSource: {type: "github", repoId: $repoId, ref: $ref, sha: $sha}}' \ + | curl -sS -f -X POST \ + -H "Authorization: Bearer $VERCEL_TOKEN" \ + -H "Content-Type: application/json" \ + --data @- \ + "$VERCEL_API/v13/deployments?teamId=$VERCEL_ORG_ID&forceNew=1&skipAutoDetectionConfirmation=1" \ + | jq -r '"Triggered preview deployment: https://" + .url' diff --git a/.github/workflows/neon-preview-cleanup.yml b/.github/workflows/neon-preview-cleanup.yml new file mode 100644 index 0000000..59934e4 --- /dev/null +++ b/.github/workflows/neon-preview-cleanup.yml @@ -0,0 +1,58 @@ +name: Neon preview cleanup + +# Tears down everything neon-preview-branch.yml created for a PR: the +# branch-scoped Vercel env var and the Neon branch itself. + +on: + pull_request: + types: [closed] + +concurrency: + group: neon-preview-${{ github.event.pull_request.number }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + cleanup: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + env: + NEON_BRANCH: preview/pr-${{ github.event.pull_request.number }} + GIT_BRANCH: ${{ github.event.pull_request.head.ref }} + VERCEL_API: https://api.vercel.com + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }} + VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} + + steps: + - name: Remove the branch-scoped Vercel env var + run: | + set -euo pipefail + + ids=$(curl -sS -f \ + -H "Authorization: Bearer $VERCEL_TOKEN" \ + "$VERCEL_API/v10/projects/$VERCEL_PROJECT_ID/env?teamId=$VERCEL_ORG_ID&gitBranch=$GIT_BRANCH" \ + | jq -r --arg b "$GIT_BRANCH" \ + '.envs[] | select(.key == "DATABASE_URL" and .gitBranch == $b) | .id') + + if [ -z "$ids" ]; then + echo "No branch-scoped DATABASE_URL found for $GIT_BRANCH." + exit 0 + fi + + for id in $ids; do + curl -sS -f -X DELETE \ + -H "Authorization: Bearer $VERCEL_TOKEN" \ + "$VERCEL_API/v9/projects/$VERCEL_PROJECT_ID/env/$id?teamId=$VERCEL_ORG_ID" \ + > /dev/null + echo "Deleted env var $id." + done + + - name: Delete the Neon branch + uses: neondatabase/delete-branch-action@v3.2.1 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch: ${{ env.NEON_BRANCH }} + api_key: ${{ secrets.NEON_API_KEY }} From 30178360e130fdbda4f884eff06cf5eebcddfa6c Mon Sep 17 00:00:00 2001 From: Kyle Bouwman Date: Thu, 6 Aug 2026 20:53:01 -0700 Subject: [PATCH 2/5] harden jq handling in Neon preview workflows (KB-26) Co-Authored-By: Claude Opus 5 --- .github/workflows/neon-preview-branch.yml | 5 ++--- .github/workflows/neon-preview-cleanup.yml | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/neon-preview-branch.yml b/.github/workflows/neon-preview-branch.yml index 9c28c5b..9328fdb 100644 --- a/.github/workflows/neon-preview-branch.yml +++ b/.github/workflows/neon-preview-branch.yml @@ -70,8 +70,7 @@ jobs: -H "Authorization: Bearer $VERCEL_TOKEN" \ "$VERCEL_API/v10/projects/$VERCEL_PROJECT_ID/env?teamId=$VERCEL_ORG_ID&decrypt=true&gitBranch=$GIT_BRANCH" \ | jq -r --arg b "$GIT_BRANCH" \ - '.envs[] | select(.key == "DATABASE_URL" and .gitBranch == $b) | .value' \ - | head -n 1) + 'first(.envs[]? | select(.key == "DATABASE_URL" and .gitBranch == $b) | .value) // ""') if [ "$existing" = "$DB_URL_POOLED" ]; then echo "DATABASE_URL for $GIT_BRANCH is already correct; skipping redeploy." @@ -108,7 +107,7 @@ jobs: --arg name "${{ github.event.repository.name }}" \ --arg ref "$GIT_BRANCH" \ --arg sha "${{ github.event.pull_request.head.sha }}" \ - --argjson repoId ${{ github.event.repository.id }} \ + --argjson repoId '${{ github.event.repository.id }}' \ '{name: $name, gitSource: {type: "github", repoId: $repoId, ref: $ref, sha: $sha}}' \ | curl -sS -f -X POST \ diff --git a/.github/workflows/neon-preview-cleanup.yml b/.github/workflows/neon-preview-cleanup.yml index 59934e4..3a166f4 100644 --- a/.github/workflows/neon-preview-cleanup.yml +++ b/.github/workflows/neon-preview-cleanup.yml @@ -35,7 +35,7 @@ jobs: -H "Authorization: Bearer $VERCEL_TOKEN" \ "$VERCEL_API/v10/projects/$VERCEL_PROJECT_ID/env?teamId=$VERCEL_ORG_ID&gitBranch=$GIT_BRANCH" \ | jq -r --arg b "$GIT_BRANCH" \ - '.envs[] | select(.key == "DATABASE_URL" and .gitBranch == $b) | .id') + '.envs[]? | select(.key == "DATABASE_URL" and .gitBranch == $b) | .id') if [ -z "$ids" ]; then echo "No branch-scoped DATABASE_URL found for $GIT_BRANCH." From ffa315ea1fb207533a96e834f418b3c29ac6c408 Mon Sep 17 00:00:00 2001 From: Kyle Bouwman Date: Thu, 6 Aug 2026 21:04:51 -0700 Subject: [PATCH 3/5] ignore local .claude settings Co-Authored-By: Claude Opus 5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7531726..becf597 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ next-env.d.ts # clerk configuration (can include secrets) /.clerk/ + +# local claude code settings +/.claude/ From 956383455ed73111f2b3990fd9ebf051f0eb64f3 Mon Sep 17 00:00:00 2001 From: Kyle Bouwman Date: Thu, 6 Aug 2026 21:05:45 -0700 Subject: [PATCH 4/5] always attempt Neon branch delete during teardown (KB-26) A failure removing the Vercel env var previously skipped the branch delete, stranding the Neon branch. Co-Authored-By: Claude Opus 5 --- .github/workflows/neon-preview-cleanup.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/neon-preview-cleanup.yml b/.github/workflows/neon-preview-cleanup.yml index 3a166f4..42b1394 100644 --- a/.github/workflows/neon-preview-cleanup.yml +++ b/.github/workflows/neon-preview-cleanup.yml @@ -51,6 +51,9 @@ jobs: done - name: Delete the Neon branch + # Best-effort teardown: a failure removing the Vercel env var must not + # strand the Neon branch, since that is the piece that costs storage. + if: always() uses: neondatabase/delete-branch-action@v3.2.1 with: project_id: ${{ vars.NEON_PROJECT_ID }} From 88cb308ec15cb259ee2c6e84b31426e2f929eac2 Mon Sep 17 00:00:00 2001 From: Kyle Bouwman Date: Thu, 6 Aug 2026 21:09:15 -0700 Subject: [PATCH 5/5] remove add-mask step that leaked the branch password (KB-26) Actions echoes a run block's script into the log before executing it, so masking a literal printed the password in cleartext. create-branch-action already masks its own connection strings. Co-Authored-By: Claude Opus 5 --- .github/workflows/neon-preview-branch.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/neon-preview-branch.yml b/.github/workflows/neon-preview-branch.yml index 9328fdb..2c3d5c0 100644 --- a/.github/workflows/neon-preview-branch.yml +++ b/.github/workflows/neon-preview-branch.yml @@ -40,11 +40,10 @@ jobs: api_key: ${{ secrets.NEON_API_KEY }} # database/role default to neondb/neondb_owner, which match production. - - name: Mask connection strings - run: | - echo "::add-mask::${{ steps.neon.outputs.db_url }}" - echo "::add-mask::${{ steps.neon.outputs.db_url_pooled }}" - echo "::add-mask::${{ steps.neon.outputs.password }}" + # NOTE: do not add an "::add-mask::" step here. Actions echoes a run + # block's script into the log before executing it, so masking a literal + # prints the very secret it is protecting. create-branch-action already + # registers its connection strings as masked values. - uses: oven-sh/setup-bun@v2 with: