Vercel preview cleanup
Authors a GitHub Actions workflow that removes a branch's preview deployments once the
branch is gone. This skill installs workflows; it never deletes anything itself. At
runtime there is no agent and no model call — deterministic bash + curl only.
Verified API detail (endpoints, pagination, rate limits, retention) lives in
reference/vercel-api.md — read it before changing the
delete logic.
Step 0 — prerequisites (check before installing)
This skill is downstream of branch deletion. If branches are never deleted, the
delete event never fires and this workflow looks broken when it isn't.
delete_branch_on_mergeis on:gh api repos/{owner}/{repo} --jq .delete_branch_on_merge→ iffalse, installbranch-cleanupfirst (it enables this and adds the closed-unmerged + orphan-sweep paths).- A branch-cleanup workflow exists (
.github/workflows/— closed-unmerged path). Without it, only merged branches get deleted; abandoned ones linger forever and so do their previews.
Install branch-cleanup first. It is the prerequisite, not an optional companion.
Neon's Vercel integration reaps preview database branches when the git branch disappears — no code needed here. It is another reason timely branch deletion matters.
The problem this solves
Vercel creates a new immutable deployment per push. The branch alias repoints to the newest; every older deployment stays live at its own URL. A branch with 15 pushes leaves 15 reachable deployments. Deleting the branch removes none of them. You must enumerate and delete all of them.
Why on: delete, not pull_request: closed
- Deployments are keyed to the branch, not the PR. A branch pushed without a PR still produces deployments; a PR-close trigger never sees them.
deletefires however the branch went away — merge auto-delete, manual, orgh pr merge --delete-branch.- In stacked workflows PRs close and reopen during restacks. A PR-close trigger would delete previews for stack entries still in active use.
delete event caveats (all handled in the templates)
| Caveat | Handling |
|---|---|
| Fires for tags too | every job gated on github.event.ref_type == 'branch' |
No branch filter supported (unlike push) |
filtered in-job; expect noisy run history |
| Workflow must exist on the default branch | documented in the caller; it runs the default-branch definition because the branch is already gone |
github.ref is useless here (resolves to default branch) |
read github.event.ref |
github.event.ref is the bare name |
do not strip a refs/heads/ prefix that isn't there |
| Documented cap: not triggered when deleting >3 tags at once (branch equivalent unverified) | the reconciliation sweep is the backstop either way |
The four safety layers
Deleting the wrong thing here means destroying a production deployment, so the guards are layered and independent:
- The branch must be gone —
on: deleteguarantees it, and becauseworkflow_dispatchdoes not, branch mode explicitly verifies the branch is absent before deleting anything. (Without that check a dispatch could wipe a live branch's previews, including the aliased one.) - Include pattern
*/*(default) — only branches containing a/are cleaned, so flat trunks (main,master,develop,staging,production) can never match. - Protected denylist —
release/*andhotfix/*do contain a slash and would pass layer 2, so they're denied explicitly. Layer 2 alone is not sufficient. target != "production"in code — the hard guard, applied to every listed deployment. ⚠️ Preview deployments carrytarget: null, not"preview"— selecting.target == "preview"matches zero rows and silently deletes nothing.
Both guards are re-applied per-candidate in the sweep too, not just on the event path — and because deletes are paced at ~3 s each, the sweep re-verifies each ref is still absent immediately before deleting rather than trusting the live-ref snapshot it built at the start (a branch recreated mid-sweep would otherwise lose its previews). The step summary reports counts per run (found / deleted / kept / failed) — enough to notice a filter that's excluding everything, though it does not list every kept ref.
Runtime behaviour
- List every deployment for the branch —
GET /v7/deployments, scoped byprojectId,teamId,branch. - Paginate to exhaustion.
pagination.nextis a millisecond timestamp passed back asuntil; stop only when it isnull. Partial pagination silently orphans the oldest deployments — the exact problem being solved. - Filter: drop
target == "production"and already-deleted tombstones (delete is a soft delete — they reappear in later lists). - Cross-check each deployment's ref client-side before deleting — never trust the
server-side
branch=filter alone. - Delete each, paced at ~1 per 3 s. Vercel allows 200 deletes per 600 s per team;
a collapsing stack or a backfill will hit it.
200/404/410all count as success.
Install
- Host
templates/reusable-vercel-preview-cleanup.ymlonce in the org's.githubrepo. Do not copy it per repo. - Add
templates/caller-vercel-preview-cleanup.ymlto each repo's default branch (~30 lines). ReplaceYOUR-ORGand keep theuses:pinned to a full commit SHA (the template ships an obvious placeholder so it cannot silently run unpinned; a tag is mutable and can be moved). It forwards a delete-capable token;@mainwould let any change there take effect across every repo at once. - Set per-repo
vars.VERCEL_PROJECT_IDS(comma-separated — a monorepo maps one git repo to several Vercel projects) andvars.VERCEL_TEAM_ID; setsecrets.VERCEL_TOKEN. - Private repos: the hosting repo's Actions access settings must permit other org repos to call its reusable workflows, or every caller fails.
- Test via
workflow_dispatchwithdry_run: true— you cannot teston: deletefrom a feature branch.
Installation is idempotent: if a workflow already exists, diff it and offer an upgrade —
never clobber local edits without showing them first. Validate generated YAML
(actionlint if available, otherwise a YAML parse) before writing.
Fork PRs — the event path does NOT cover them
Be clear about this before installing. A fork PR's branch lives in the fork, so:
on: deletenever fires for it — there is no ref in your repo to delete.- It never appears in
repos/{owner}/{repo}/branches.
The only trigger that could clean a fork PR's previews the moment it closes is
pull_request_target, and this skill deliberately does not use it. That trigger
runs with your secrets in the base repo's context; combined with a VERCEL_TOKEN that can
delete deployments, it is a supply-chain surface — and one actions/checkout added by a
later edit turns it into remote code execution. Not worth it for this feature. If your org
accepts that trade-off, the wiring is a pull_request_target: [closed] job gated to
head.repo.full_name != github.repository; adopt it knowingly, and never check out fork
code in it.
What you actually get instead:
| Fork PR state | Behaviour |
|---|---|
| Open | previews are protected — the sweep's "still live" set is repo branches ∪ every open PR head ref, so an in-review fork PR's previews are never deleted (without this union they'd look like orphans and be swept mid-review) |
| Closed / merged | the ref is neither a branch nor an open PR head → the sweep collects it on its next run |
So forks are cleaned — just on the sweep's cadence (daily by default) rather than instantly. Two honest caveats:
- Latency. A fork PR's previews stay live until the next sweep. Tighten the cron if that matters.
- Flat-named fork branches are never swept. GitHub's web editor defaults to
patch-1, which has no/and so fails the*/*include guard by design. Those previews persist until Vercel's retention reaps them. Widening the pattern to catch them would also expose your own flat trunks — not a trade worth making.
Rolling out across many repos
- Pin the reusable workflow to a full commit SHA. It holds delete
permissions;
@mainmeans every repo silently picks up any change to it. - Replace
YOUR-ORGin the caller'suses:with your organisation. VERCEL_PROJECT_IDSmisconfiguration is the top operational hazard. Point a repo at a project belonging to a different repo and the sweep sees all of that project's refs as orphans. Verify the mapping per repo before enablingsweep_delete.- Watch for failing sweeps. A daily sweep that errors is a red run nobody reads — route the workflow's failure notifications somewhere a human sees.
Deliberately not built
- No cross-branch dependency check. Deployments are immutable and branch-scoped — unlike branches, nothing else can depend on one. A later pass should not add a "does anything else use this deployment" guard; there is nothing to check.
- No
pull_request_target. It's the only trigger that could clean fork PR previews on close, and it runs with secrets in the base repo's context. Declined deliberately — see "Fork PRs" above for what covers them instead. - Retention is not a substitute. It's an independent backstop with its own floor
(last 10 project deployments, last 20 READY non-production, the latest preview of an
active branch, …) and
deploymentsToKeepis production-only. Seereference/vercel-api.md.
Backfill
The workflow only affects branches deleted from now on. For deployments already
accumulated, run scripts/vercel-backfill.sh once —
dry-run by default, same four guards, same pacing.
See also
branch-cleanup— the prerequisite. Deletes the branches whose deletion triggers this skill. Install it first; it also works standalone for repos with no Vercel.