Prune old deployments #1026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Prune old deployments | |
| on: | |
| workflow_run: | |
| workflows: [CI / CD] | |
| types: [completed] | |
| schedule: | |
| # Weekly cleanup on Sundays at 03:00 UTC | |
| - cron: '0 3 * * 0' | |
| workflow_dispatch: | |
| # QNBS-v3: top-level read-only; deployments:write scoped to job (CodeQL Token-Permissions). | |
| permissions: | |
| contents: read | |
| jobs: | |
| prune: | |
| name: Keep latest 3 deployments | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| deployments: write | |
| steps: | |
| - name: Delete stale deployments β keep latest 3 per environment | |
| # QNBS-v3: v9.0.0 uses node24 β v7 was node20 (deprecated June 2026) | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| script: | | |
| const KEEP = 3; | |
| // QNBS-v3: Paginate ALL deployments (not just one environment) β Vercel creates | |
| // "Production" and "Preview" environments that accumulate independently | |
| // of the "github-pages" environment; the old filter missed them entirely. | |
| const allDeployments = await github.paginate( | |
| github.rest.repos.listDeployments, | |
| { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| per_page: 100, | |
| }, | |
| ); | |
| // Group by environment, sort each group newest-first, keep the first KEEP | |
| const byEnv = {}; | |
| for (const dep of allDeployments) { | |
| const env = dep.environment || 'unknown'; | |
| (byEnv[env] ??= []).push(dep); | |
| } | |
| let totalDeleted = 0; | |
| let totalFailed = 0; | |
| for (const [env, deps] of Object.entries(byEnv)) { | |
| const sorted = deps.sort((a, b) => b.id - a.id); | |
| const toDelete = sorted.slice(KEEP); | |
| if (toDelete.length === 0) { | |
| core.info(`[${env}] Nothing to prune β only ${deps.length} deployment(s).`); | |
| continue; | |
| } | |
| core.info(`[${env}] ${deps.length} total β pruning ${toDelete.length}, keeping ${KEEP} latest.`); | |
| let deleted = 0; | |
| let failed = 0; | |
| for (const dep of toDelete) { | |
| // A deployment must be inactive before it can be deleted | |
| try { | |
| await github.rest.repos.createDeploymentStatus({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| deployment_id: dep.id, | |
| state: 'inactive', | |
| }); | |
| } catch (e) { | |
| core.warning(`[${env}] Could not inactivate ${dep.id}: ${e.message}`); | |
| } | |
| try { | |
| await github.rest.repos.deleteDeployment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| deployment_id: dep.id, | |
| }); | |
| deleted++; | |
| } catch (e) { | |
| core.warning(`[${env}] Could not delete ${dep.id}: ${e.message}`); | |
| failed++; | |
| } | |
| } | |
| core.info(`[${env}] Done: ${deleted} deleted, ${failed} failed.`); | |
| totalDeleted += deleted; | |
| totalFailed += failed; | |
| } | |
| core.info(`All environments: ${totalDeleted} deleted, ${totalFailed} failed.`); |