From c4becfcbbde9b6d56fa8f1baf32cf6da08d4a1ab Mon Sep 17 00:00:00 2001 From: Dak Washbrook Date: Wed, 7 Oct 2026 22:56:26 -0700 Subject: [PATCH] fix: prevent obsolete Neon previews from accumulating Signed-off-by: Dak Washbrook --- .github/neon-cleanup/cleanup.mjs | 223 ++++++++++++++++++ .github/neon-cleanup/config.json | 13 + .github/neon-cleanup/format-config.json | 1 + .github/neon-cleanup/lint-config.json | 1 + .github/neon-cleanup/package.json | 11 + .github/neon-cleanup/readme.md | 40 ++++ .github/neon-cleanup/run.mjs | 37 +++ .../neon-cleanup/tests/e2e/cleanup.test.mjs | 171 ++++++++++++++ .github/neon-cleanup/tests/policy.test.mjs | 134 +++++++++++ .github/workflows/neon-cleanup-check.yml | 32 +++ .github/workflows/neon-preview-cleanup.yml | 37 +++ 11 files changed, 700 insertions(+) create mode 100644 .github/neon-cleanup/cleanup.mjs create mode 100644 .github/neon-cleanup/config.json create mode 100644 .github/neon-cleanup/format-config.json create mode 100644 .github/neon-cleanup/lint-config.json create mode 100644 .github/neon-cleanup/package.json create mode 100644 .github/neon-cleanup/readme.md create mode 100644 .github/neon-cleanup/run.mjs create mode 100644 .github/neon-cleanup/tests/e2e/cleanup.test.mjs create mode 100644 .github/neon-cleanup/tests/policy.test.mjs create mode 100644 .github/workflows/neon-cleanup-check.yml create mode 100644 .github/workflows/neon-preview-cleanup.yml diff --git a/.github/neon-cleanup/cleanup.mjs b/.github/neon-cleanup/cleanup.mjs new file mode 100644 index 0000000..359b7dc --- /dev/null +++ b/.github/neon-cleanup/cleanup.mjs @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; + +// Only an exact Vercel preview branch owned by a closed, same-repository PR qualifies. +export function deletionCandidate({ + config, + pull, + openPulls, + branches, + gitHead, + defaultBranch, +}) { + if ( + pull.state !== "closed" || + !pull.closed_at || + pull.head.repo?.full_name !== config.repository || + pull.base.repo.full_name !== config.repository + ) + return null; + const ref = pull.head.ref; + if ([defaultBranch, ...config.permanentBranchNames].includes(ref)) + return null; + if (openPulls.some((p) => p.head.ref === ref)) return null; + if (gitHead !== null && gitHead !== pull.head.sha) return null; + const matching = branches.filter((b) => b.name === `preview/${ref}`); + assert(matching.length <= 1, "Ambiguous Neon branch name"); + const branch = matching[0]; + if ( + !branch || + branch.default || + branch.protected || + config.permanentBranchIds.includes(branch.id) + ) + return null; + if (branches.some((b) => b.parent_id === branch.id)) return null; + const created = Date.parse(branch.created_at); + const closed = Date.parse(pull.closed_at); + if (!Number.isFinite(created) || !Number.isFinite(closed) || created > closed) + return null; + return branch; +} + +export async function cleanup({ + config, + repository, + pullNumber, + dryRun = true, + neonKey, + githubToken, + neonBase = "https://console.neon.tech/api/v2", + githubBase = "https://api.github.com", +}) { + assert( + repository === config.repository, + "Repository does not match the reviewed database mapping", + ); + assert( + Number.isSafeInteger(pullNumber) && pullNumber > 0, + "Expected a positive PR number", + ); + assert(typeof dryRun === "boolean", "Expected a boolean dry-run flag"); + assert(neonKey && githubToken, "Missing NEON_API_KEY or GITHUB_TOKEN"); + assert( + config.permanentBranchIds.length > 0, + "Missing permanent branch safeguards", + ); + async function request( + base, + path, + token, + method = "GET", + allowMissing = false, + ) { + const response = await fetch(`${base}${path}`, { + method, + redirect: "error", + signal: AbortSignal.timeout(30_000), + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + "User-Agent": "neon-pr-cleanup", + }, + }); + if (allowMissing && response.status === 404) return null; + assert(response.ok, `${method} ${path}: HTTP ${response.status}`); + return response.status === 204 ? null : response.json(); + } + const repoPath = `/repos/${config.repository}`; + const neonPath = `/projects/${encodeURIComponent(config.projectId)}/branches`; + const gh = (path, missing = false) => + request(githubBase, `${repoPath}${path}`, githubToken, "GET", missing); + const repo = await gh(""); + assert( + repo.full_name === config.repository && + typeof repo.default_branch === "string", + "Unexpected GitHub repository", + ); + async function inventory() { + const branches = []; + let cursor; + const seen = new Set(); + do { + const query = new URLSearchParams({ + limit: "100", + sort_by: "created_at", + sort_order: "asc", + }); + if (cursor) query.set("cursor", cursor); + const page = await request(neonBase, `${neonPath}?${query}`, neonKey); + assert( + Array.isArray(page.branches) && + page.pagination && + Object.keys(page.pagination).every((key) => + ["next", "sort_by", "sort_order"].includes(key), + ), + "Incomplete Neon pagination", + ); + for (const branch of page.branches) { + assert( + branch.project_id === config.projectId && + typeof branch.id === "string" && + typeof branch.name === "string" && + typeof branch.default === "boolean" && + typeof branch.protected === "boolean", + "Invalid Neon branch", + ); + branches.push(branch); + } + cursor = page.pagination.next; + assert( + cursor == null || + (typeof cursor === "string" && + cursor.length > 0 && + !seen.has(cursor)), + "Invalid Neon pagination cursor", + ); + if (cursor) seen.add(cursor); + assert(seen.size < 1000, "Neon pagination limit exceeded"); + } while (cursor); + assert( + new Set(branches.map((b) => b.id)).size === branches.length, + "Duplicate branch inventory", + ); + assert( + branches.some( + (b) => b.default && config.permanentBranchIds.includes(b.id), + ), + "Default branch changed; review permanent IDs", + ); + return branches; + } + async function candidate() { + const pull = await gh(`/pulls/${pullNumber}`); + assert( + pull.number === pullNumber && + typeof pull.head?.ref === "string" && + typeof pull.head?.sha === "string", + "Invalid PR response", + ); + const openPulls = []; + for (let page = 1; ; page++) { + assert(page <= 1000, "GitHub pagination limit exceeded"); + const batch = await gh(`/pulls?state=open&per_page=100&page=${page}`); + assert( + Array.isArray(batch) && + batch.every( + (p) => p.state === "open" && typeof p.head?.ref === "string", + ), + "Invalid open PR response", + ); + openPulls.push(...batch); + if (batch.length < 100) break; + } + const ref = await gh( + `/git/ref/heads/${encodeURIComponent(pull.head.ref)}`, + true, + ); + assert( + ref === null || typeof ref.object?.sha === "string", + "Invalid Git ref response", + ); + return deletionCandidate({ + config, + pull, + openPulls, + branches: await inventory(), + gitHead: ref?.object.sha ?? null, + defaultBranch: repo.default_branch, + }); + } + const branch = await candidate(); + if (!branch) + return { + action: "keep", + reason: "No branch qualifies for safe PR cleanup", + }; + if (dryRun) + return { + action: "would-delete", + branchId: branch.id, + branchName: branch.name, + }; + // Re-check live PR/ref and the complete branch inventory immediately before deletion. + const current = await candidate(); + assert( + current?.id === branch.id && current.created_at === branch.created_at, + "Branch or PR safety state changed before deletion", + ); + await request( + neonBase, + `${neonPath}/${encodeURIComponent(branch.id)}`, + neonKey, + "DELETE", + ); + const remaining = await request( + neonBase, + `${neonPath}/${encodeURIComponent(branch.id)}`, + neonKey, + "GET", + true, + ); + assert(remaining === null, "Neon did not confirm branch deletion"); + return { action: "deleted", branchId: branch.id, branchName: branch.name }; +} diff --git a/.github/neon-cleanup/config.json b/.github/neon-cleanup/config.json new file mode 100644 index 0000000..47b1116 --- /dev/null +++ b/.github/neon-cleanup/config.json @@ -0,0 +1,13 @@ +{ + "repository": "dak-engineering/hooky", + "projectId": "frosty-mouse-25883459", + "permanentBranchIds": ["br-spring-art-a6d5ruas"], + "permanentBranchNames": [ + "develop", + "development", + "main", + "master", + "production", + "staging" + ] +} diff --git a/.github/neon-cleanup/format-config.json b/.github/neon-cleanup/format-config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.github/neon-cleanup/format-config.json @@ -0,0 +1 @@ +{} diff --git a/.github/neon-cleanup/lint-config.json b/.github/neon-cleanup/lint-config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.github/neon-cleanup/lint-config.json @@ -0,0 +1 @@ +{} diff --git a/.github/neon-cleanup/package.json b/.github/neon-cleanup/package.json new file mode 100644 index 0000000..668cda3 --- /dev/null +++ b/.github/neon-cleanup/package.json @@ -0,0 +1,11 @@ +{ + "name": "neon-pr-cleanup-checks", + "private": true, + "type": "module", + "scripts": { + "lint": "bunx --bun oxlint@1.87.0 --config lint-config.json --deny-warnings cleanup.mjs run.mjs tests", + "prettier": "bunx prettier@3.9.9 --config format-config.json --check . ../workflows/neon-preview-cleanup.yml ../workflows/neon-cleanup-check.yml", + "test": "bun test", + "test:e2e": "bun test tests/e2e" + } +} diff --git a/.github/neon-cleanup/readme.md b/.github/neon-cleanup/readme.md new file mode 100644 index 0000000..5e54130 --- /dev/null +++ b/.github/neon-cleanup/readme.md @@ -0,0 +1,40 @@ +# Neon preview cleanup + +Closing or merging a same-repository PR deletes its exact `preview/` +Neon branch. Old Vercel preview URLs then lose their database. Production, staging, +default/protected branches, parent branches, open PRs, and reused Git branches +are excluded. Fork PRs are skipped because branch-name ownership is ambiguous. + +The workflow runs trusted default-branch code with read-only `GITHUB_TOKEN` access. +It never checks out PR code or installs application dependencies. `NEON_API_KEY` +is a GitHub Actions secret; `config.json` pins the reviewed project and permanent +branch IDs. API failures fail the workflow before deletion. Each run can delete +at most one branch and verifies deletion afterward. + +After merging the workflow into the default branch, use **Actions → Clean up Neon +preview → Run workflow** with a closed PR number and dry-run enabled to review an +old preview. Disable dry-run only after inspecting the result. Previously closed +PRs do not emit new close events. For a reopened PR whose database was deleted, +trigger a new Vercel preview deployment to recreate the database branch. + +No scheduled cleanup service is required. Vercel deployment retention remains a +fallback for integrated preview branches. Branches without PRs need native Neon +expiration: **Neon Console → Branches → Update expiration** for an existing branch, +or select **Automatically delete branch after** when creating one. API/CLI branch +creators must explicitly supply `expires_at`/`--expires-at`; there is no global API +default. Do not expire shared, production, or active PR branches by age alone. + +Validation is isolated from application dependencies: + +```sh +cd .github/neon-cleanup +bun run lint +bun run prettier +bun test +bun run test:e2e +``` + +Tests use disposable local HTTP servers and never contact live databases. + +References: [Neon Vercel cleanup](https://neon.com/docs/guides/vercel-branch-cleanup), +[Neon expiration](https://neon.com/docs/guides/branch-expiration). diff --git a/.github/neon-cleanup/run.mjs b/.github/neon-cleanup/run.mjs new file mode 100644 index 0000000..92f9aff --- /dev/null +++ b/.github/neon-cleanup/run.mjs @@ -0,0 +1,37 @@ +import { readFile } from "node:fs/promises"; +import assert from "node:assert/strict"; +import { cleanup } from "./cleanup.mjs"; + +const config = JSON.parse( + await readFile(new URL("./config.json", import.meta.url), "utf8"), +); +const { + GITHUB_EVENT_NAME, + GITHUB_EVENT_PATH, + GITHUB_REPOSITORY, + GITHUB_TOKEN, + NEON_API_KEY, +} = process.env; +assert( + ["pull_request_target", "workflow_dispatch"].includes(GITHUB_EVENT_NAME), + "Unsupported workflow event", +); +const event = JSON.parse(await readFile(GITHUB_EVENT_PATH, "utf8")); +if (GITHUB_EVENT_NAME === "pull_request_target") + assert(event.action === "closed", "Expected a PR-close event"); +const result = await cleanup({ + config, + repository: GITHUB_REPOSITORY, + pullNumber: Number( + GITHUB_EVENT_NAME === "pull_request_target" + ? event.number + : event.inputs?.pr_number, + ), + dryRun: + GITHUB_EVENT_NAME === "workflow_dispatch" + ? event.inputs?.dry_run !== "false" + : false, + neonKey: NEON_API_KEY, + githubToken: GITHUB_TOKEN, +}); +console.log(JSON.stringify(result)); diff --git a/.github/neon-cleanup/tests/e2e/cleanup.test.mjs b/.github/neon-cleanup/tests/e2e/cleanup.test.mjs new file mode 100644 index 0000000..2e8071e --- /dev/null +++ b/.github/neon-cleanup/tests/e2e/cleanup.test.mjs @@ -0,0 +1,171 @@ +import { test, expect } from "bun:test"; +import { cleanup } from "../../cleanup.mjs"; + +async function scenario(options = {}) { + let deleted = false; + let pullReads = 0; + const writes = []; + const seen = []; + const main = { + id: "br-main", + project_id: "project-1", + name: "main", + default: true, + protected: false, + created_at: "2026-01-01T00:00:00Z", + }; + const branch = { + id: "br-preview", + project_id: "project-1", + name: "preview/feature/test", + default: false, + protected: false, + parent_id: "br-main", + created_at: "2026-10-01T00:00:00Z", + }; + const pull = { + number: 42, + state: "closed", + closed_at: "2026-10-02T00:00:00Z", + head: { ref: "feature/test", sha: "abc", repo: { full_name: "team/app" } }, + base: { repo: { full_name: "team/app" } }, + }; + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + seen.push(url.pathname + url.search); + if (request.method !== "GET") + writes.push(request.method + " " + url.pathname); + if (url.pathname.startsWith("/repos/")) { + if (request.headers.get("authorization") !== "Bearer github-test") + return new Response(null, { status: 401 }); + if (options.githubFailure) return new Response(null, { status: 503 }); + if (url.pathname === "/repos/team/app") + return Response.json({ + full_name: "team/app", + default_branch: "main", + }); + if (url.pathname.endsWith("/pulls/42")) { + pullReads++; + return Response.json({ + ...pull, + state: options.reopen && pullReads > 1 ? "open" : "closed", + }); + } + if (url.pathname.endsWith("/pulls")) { + const page = url.searchParams.get("page"); + if (options.lateOpen && page === "1") + return Response.json( + Array.from({ length: 100 }, () => ({ + state: "open", + head: { ref: "other" }, + })), + ); + return Response.json( + options.lateOpen + ? [{ state: "open", head: { ref: "feature/test" } }] + : [], + ); + } + if (url.pathname.includes("/git/ref/heads/")) + return Response.json({ object: { sha: "abc" } }); + } + if (request.headers.get("authorization") !== "Bearer neon-test") + return new Response(null, { status: 401 }); + if (url.pathname.endsWith("/branches")) { + if (url.searchParams.get("sort_order") !== "asc") + return new Response(null, { status: 400 }); + if (!url.searchParams.has("cursor")) + return Response.json({ + branches: [main], + pagination: options.badPagination + ? { cursor: "next" } + : { next: "next" }, + }); + if (options.neonFailure) return new Response(null, { status: 503 }); + const branches = deleted ? [] : [branch]; + if (options.newChild && pullReads > 1) + branches.push({ + ...branch, + id: "br-child", + name: "child", + parent_id: "br-preview", + }); + return Response.json({ branches, pagination: {} }); + } + if (url.pathname.endsWith("/branches/br-preview")) { + if (request.method === "DELETE") { + deleted = !options.unconfirmed; + return Response.json({ branch }); + } + return deleted + ? new Response(null, { status: 404 }) + : Response.json({ branch }); + } + return new Response(null, { status: 404 }); + }, + }); + try { + const result = await cleanup({ + config: { + repository: "team/app", + projectId: "project-1", + permanentBranchIds: ["br-main"], + permanentBranchNames: ["main", "production", "staging"], + }, + repository: "team/app", + pullNumber: 42, + dryRun: options.dryRun ?? false, + neonKey: "neon-test", + githubToken: "github-test", + neonBase: server.url.origin, + githubBase: server.url.origin, + }); + return { result, writes, seen }; + } catch (error) { + return { error: error.message, writes, seen }; + } finally { + await server.stop(true); + } +} + +test("real HTTP cleanup reads every page, deletes exactly one preview, verifies absence", async () => { + const outcome = await scenario(); + expect(outcome.result).toEqual({ + action: "deleted", + branchId: "br-preview", + branchName: "preview/feature/test", + }); + expect(outcome.writes).toEqual([ + "DELETE /projects/project-1/branches/br-preview", + ]); + expect(outcome.seen.at(-1)).toBe("/projects/project-1/branches/br-preview"); +}); +test("dry-run inventories live APIs without mutation", async () => { + const outcome = await scenario({ dryRun: true }); + expect(outcome.result.action).toBe("would-delete"); + expect(outcome.writes).toEqual([]); +}); +for (const option of [ + "githubFailure", + "neonFailure", + "badPagination", + "reopen", + "newChild", +]) { + test(`${option} prevents all writes`, async () => { + const outcome = await scenario({ [option]: true }); + expect(outcome.error).toBeTruthy(); + expect(outcome.writes).toEqual([]); + }); +} +test("an open PR on a later GitHub page protects its branch", async () => { + const outcome = await scenario({ lateOpen: true }); + expect(outcome.result.action).toBe("keep"); + expect(outcome.writes).toEqual([]); +}); +test("unconfirmed delete fails instead of reporting success", async () => { + const outcome = await scenario({ unconfirmed: true }); + expect(outcome.error).toContain("did not confirm"); +}); diff --git a/.github/neon-cleanup/tests/policy.test.mjs b/.github/neon-cleanup/tests/policy.test.mjs new file mode 100644 index 0000000..3590f81 --- /dev/null +++ b/.github/neon-cleanup/tests/policy.test.mjs @@ -0,0 +1,134 @@ +import { test, expect } from "bun:test"; +import { deletionCandidate } from "../cleanup.mjs"; + +function input() { + return { + config: { + repository: "team/app", + permanentBranchIds: ["br-main"], + permanentBranchNames: ["main", "production", "staging", "develop"], + }, + defaultBranch: "main", + gitHead: "abc", + openPulls: [], + pull: { + state: "closed", + closed_at: "2026-10-02T00:00:00Z", + head: { + ref: "feature/test", + sha: "abc", + repo: { full_name: "team/app" }, + }, + base: { repo: { full_name: "team/app" } }, + }, + branches: [ + { + id: "br-preview", + name: "preview/feature/test", + default: false, + protected: false, + created_at: "2026-10-01T00:00:00Z", + }, + ], + }; +} + +test("closed same-repository PR selects only its exact Vercel preview", () => { + expect(deletionCandidate(input())?.id).toBe("br-preview"); + const data = input(); + data.gitHead = null; + expect(deletionCandidate(data)?.id).toBe("br-preview"); +}); + +for (const [label, change] of [ + [ + "reopened PR", + (d) => { + d.pull.state = "open"; + }, + ], + [ + "fork PR", + (d) => { + d.pull.head.repo.full_name = "fork/app"; + }, + ], + [ + "different base repository", + (d) => { + d.pull.base.repo.full_name = "team/other"; + }, + ], + [ + "other open PR sharing a ref", + (d) => { + d.openPulls = [{ head: { ref: "feature/test" } }]; + }, + ], + [ + "new commits after closure", + (d) => { + d.gitHead = "def"; + }, + ], + [ + "branch recreated after closure", + (d) => { + d.branches[0].created_at = "2026-10-03T00:00:00Z"; + }, + ], + [ + "invalid creation timestamp", + (d) => { + d.branches[0].created_at = "invalid"; + }, + ], + [ + "default Neon branch", + (d) => { + d.branches[0].default = true; + }, + ], + [ + "protected Neon branch", + (d) => { + d.branches[0].protected = true; + }, + ], + [ + "explicit permanent ID", + (d) => { + d.config.permanentBranchIds.push("br-preview"); + }, + ], + [ + "permanent Git branch", + (d) => { + d.config.permanentBranchNames.push("feature/test"); + }, + ], + [ + "GitHub default branch", + (d) => { + d.defaultBranch = "feature/test"; + }, + ], + [ + "parent branch", + (d) => { + d.branches.push({ id: "br-child", parent_id: "br-preview" }); + }, + ], + [ + "unrecognized naming", + (d) => { + d.branches[0].name = "feature/test"; + }, + ], +]) { + test(`keeps ${label}`, () => { + const data = input(); + change(data); + expect(deletionCandidate(data)).toBeNull(); + }); +} diff --git a/.github/workflows/neon-cleanup-check.yml b/.github/workflows/neon-cleanup-check.yml new file mode 100644 index 0000000..d822d67 --- /dev/null +++ b/.github/workflows/neon-cleanup-check.yml @@ -0,0 +1,32 @@ +name: Check Neon cleanup +on: + pull_request: + paths: + - .github/neon-cleanup/** + - .github/workflows/neon-preview-cleanup.yml + - .github/workflows/neon-cleanup-check.yml + push: + paths: + - .github/neon-cleanup/** + - .github/workflows/neon-preview-cleanup.yml + - .github/workflows/neon-cleanup-check.yml +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: .github/neon-cleanup + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + - run: bun run lint + - run: bun run prettier + - run: bun test + - run: bun run test:e2e diff --git a/.github/workflows/neon-preview-cleanup.yml b/.github/workflows/neon-preview-cleanup.yml new file mode 100644 index 0000000..dffa1fc --- /dev/null +++ b/.github/workflows/neon-preview-cleanup.yml @@ -0,0 +1,37 @@ +name: Clean up Neon preview +on: + pull_request_target: + types: [closed] + workflow_dispatch: + inputs: + pr_number: + description: Closed pull request number to inspect + required: true + type: string + dry_run: + description: Report without deleting + type: boolean + default: true +permissions: + contents: read + pull-requests: read +concurrency: + group: neon-preview-cleanup + cancel-in-progress: false +jobs: + cleanup: + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # Always use trusted default-branch code, including for manual runs. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + sparse-checkout: .github/neon-cleanup + - name: Delete only the verified PR preview branch + run: node .github/neon-cleanup/run.mjs + env: + NEON_API_KEY: ${{ secrets.NEON_API_KEY }} + GITHUB_TOKEN: ${{ github.token }}