Skip to content

Cleanup old preview environments #320

Cleanup old preview environments

Cleanup old preview environments #320

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();