Cleanup old preview environments #320
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: Cleanup old preview environments | |
| on: | |
| schedule: | |
| - cron: "17 3 * * *" # daily at 03:17 UTC | |
| workflow_dispatch: | |
| inputs: | |
| retention_days: | |
| description: "Delete environments older than N days" | |
| required: false | |
| default: "14" | |
| dry_run: | |
| description: "Dry run (true/false)" | |
| required: false | |
| default: "false" | |
| keep: | |
| description: "Comma-separated envs to keep" | |
| required: false | |
| default: "production" | |
| permissions: | |
| actions: read | |
| contents: read | |
| deployments: write | |
| concurrency: | |
| group: cleanup-environments | |
| cancel-in-progress: false | |
| jobs: | |
| cleanup: | |
| if: ${{ github.repository_owner == 'getarcaneapp' }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Delete old environments and deployments | |
| uses: actions/github-script@v9 | |
| env: | |
| RETENTION_DAYS: ${{ github.event.inputs.retention_days || '14' }} | |
| DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} | |
| KEEP: ${{ github.event.inputs.keep || 'production' }} | |
| HAS_ADMIN_TOKEN: ${{ secrets.ARCANE_BOT_TOKEN != '' }} | |
| with: | |
| github-token: ${{ secrets.ARCANE_BOT_TOKEN || github.token }} | |
| script: | | |
| const keep = (process.env.KEEP || 'production') | |
| .split(',') | |
| .map(s => s.trim()) | |
| .filter(Boolean); | |
| const retentionDays = parseInt(process.env.RETENTION_DAYS || '14', 10); | |
| const dryRun = String(process.env.DRY_RUN || 'true').toLowerCase() === 'true'; | |
| const hasAdminToken = String(process.env.HAS_ADMIN_TOKEN || 'false').toLowerCase() === 'true'; | |
| const effectiveDryRun = dryRun || !hasAdminToken; | |
| const cutoffMs = retentionDays * 24 * 60 * 60 * 1000; | |
| const now = Date.now(); | |
| const previewEnvironmentPattern = /^.+-pr-\d+$/; | |
| const cleanupDescription = 'Removed by preview environment cleanup workflow'; | |
| if (!hasAdminToken) { | |
| core.warning('ARCANE_BOT_TOKEN is not configured. The default workflow token cannot delete repository environments, so this run will report candidates without deleting them.'); | |
| } | |
| const envs = await github.paginate('GET /repos/{owner}/{repo}/environments', { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| per_page: 100 | |
| }); | |
| let deleted = 0; | |
| let candidateEnvironments = 0; | |
| let skippedNonPreview = 0; | |
| let deploymentCount = 0; | |
| let deletedDeployments = 0; | |
| let deploymentFailures = 0; | |
| let environmentFailures = 0; | |
| let skippedEnvironmentDeletes = 0; | |
| for (const env of envs) { | |
| if (keep.includes(env.name)) continue; | |
| if (!previewEnvironmentPattern.test(env.name)) { | |
| skippedNonPreview++; | |
| continue; | |
| } | |
| const updatedAt = env.updated_at || env.created_at; | |
| if (!updatedAt) continue; | |
| const ageMs = now - new Date(updatedAt).getTime(); | |
| if (ageMs >= cutoffMs) { | |
| candidateEnvironments++; | |
| const deployments = await github.paginate('GET /repos/{owner}/{repo}/deployments', { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| environment: env.name, | |
| per_page: 100 | |
| }); | |
| deploymentCount += deployments.length; | |
| let deploymentDeleteFailed = false; | |
| for (const deployment of deployments) { | |
| core.info(`${effectiveDryRun ? '[DRY-RUN] Would delete' : 'Deleting'} deployment #${deployment.id} for environment "${env.name}" (created ${deployment.created_at})`); | |
| if (!effectiveDryRun) { | |
| try { | |
| await github.request('POST /repos/{owner}/{repo}/deployments/{deployment_id}/statuses', { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| deployment_id: deployment.id, | |
| state: 'inactive', | |
| auto_inactive: false, | |
| description: cleanupDescription, | |
| environment: env.name | |
| }); | |
| } catch (e) { | |
| core.warning(`Failed to mark deployment #${deployment.id} in "${env.name}" inactive: ${e.message}`); | |
| } | |
| try { | |
| await github.request('DELETE /repos/{owner}/{repo}/deployments/{deployment_id}', { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| deployment_id: deployment.id | |
| }); | |
| deletedDeployments++; | |
| } catch (e) { | |
| deploymentDeleteFailed = true; | |
| deploymentFailures++; | |
| core.warning(`Failed to delete deployment #${deployment.id} in "${env.name}": ${e.message}`); | |
| } | |
| } | |
| } | |
| core.info(`${effectiveDryRun ? '[DRY-RUN] Would delete' : 'Deleting'} environment "${env.name}" (updated ${updatedAt})`); | |
| if (!effectiveDryRun && !deploymentDeleteFailed) { | |
| try { | |
| await github.request('DELETE /repos/{owner}/{repo}/environments/{environment_name}', { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| environment_name: env.name | |
| }); | |
| deleted++; | |
| } catch (e) { | |
| environmentFailures++; | |
| core.warning(`Failed to delete "${env.name}": ${e.message}`); | |
| } | |
| } else if (deploymentDeleteFailed) { | |
| skippedEnvironmentDeletes++; | |
| core.warning(`Skipping environment delete for "${env.name}" because one or more deployments could not be removed first.`); | |
| } | |
| } | |
| } | |
| core.summary | |
| .addHeading('Environment cleanup') | |
| .addRaw(`Retention: ${retentionDays} days\n`) | |
| .addRaw(`Dry-run: ${effectiveDryRun}\n`) | |
| .addRaw(`Preview-only filter: ${previewEnvironmentPattern}\n`) | |
| .addRaw(`Checked: ${envs.length}\n`) | |
| .addRaw(`Eligible preview environments: ${candidateEnvironments}\n`) | |
| .addRaw(`Skipped non-preview: ${skippedNonPreview}\n`) | |
| .addRaw(`Matched deployments: ${deploymentCount}\n`) | |
| .addRaw(`Deleted deployments: ${deletedDeployments}\n`) | |
| .addRaw(`Deleted environments: ${deleted}\n`); | |
| if (deploymentFailures > 0) { | |
| core.summary.addRaw(`Deployment delete failures: ${deploymentFailures}\n`); | |
| } | |
| if (environmentFailures > 0) { | |
| core.summary.addRaw(`Environment delete failures: ${environmentFailures}\n`); | |
| } | |
| if (skippedEnvironmentDeletes > 0) { | |
| core.summary.addRaw(`Skipped environment deletes: ${skippedEnvironmentDeletes}\n`); | |
| } | |
| await core.summary.write(); |