Skip to content

Cleanup Container Images #10

Cleanup Container Images

Cleanup Container Images #10

name: Cleanup Container Images
# WARNING: This workflow deletes container images permanently!
# Always test with dry_run=true first to preview what will be deleted.
# The 'latest' tag and the N most recent versions are always preserved.
"on":
schedule:
# Run monthly on the 1st at 02:00 UTC
- cron: "0 2 1 * *"
workflow_dispatch:
inputs:
retention_days:
description: "Number of days to keep images (default: 30)"
required: false
default: "30"
type: string
dry_run:
description: "Perform a dry run without actually deleting images"
required: false
default: true
type: boolean
keep_latest_count:
description: "Number of latest versions to always keep (default: 5)"
required: false
default: "5"
type: string
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
packages: write
jobs:
cleanup:
name: Clean Up Old Container Images
runs-on: ubuntu-latest
steps:
- name: Validate inputs
run: |
# Set defaults for scheduled runs
RETENTION_DAYS="${{ github.event.inputs.retention_days || '30' }}"
DRY_RUN="${{ github.event.inputs.dry_run || 'true' }}"
KEEP_LATEST_COUNT="${{ github.event.inputs.keep_latest_count || '5' }}"
# Validate inputs
if ! [[ "$RETENTION_DAYS" =~ ^[0-9]+$ ]] || [ "$RETENTION_DAYS" -lt 1 ]; then
echo "❌ Error: retention_days must be a positive integer"
exit 1
fi
if ! [[ "$KEEP_LATEST_COUNT" =~ ^[0-9]+$ ]] || [ "$KEEP_LATEST_COUNT" -lt 1 ]; then
echo "❌ Error: keep_latest_count must be a positive integer"
exit 1
fi
# Export validated values
echo "RETENTION_DAYS=$RETENTION_DAYS" >> $GITHUB_ENV
echo "DRY_RUN=$DRY_RUN" >> $GITHUB_ENV
echo "KEEP_LATEST_COUNT=$KEEP_LATEST_COUNT" >> $GITHUB_ENV
echo "✅ Configuration validated:"
echo " - Retention period: $RETENTION_DAYS days"
echo " - Dry run mode: $DRY_RUN"
echo " - Keep latest count: $KEEP_LATEST_COUNT"
- name: Cleanup old container images
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const retentionDays = parseInt(process.env.RETENTION_DAYS);
const dryRun = process.env.DRY_RUN === 'true';
const keepLatestCount = parseInt(process.env.KEEP_LATEST_COUNT);
const [owner, repo] = process.env.IMAGE_NAME.split('/');
console.log(`🔍 Starting cleanup for ${owner}/${repo}`);
console.log(`📅 Retention period: ${retentionDays} days`);
console.log(`🏷️ Keep latest count: ${keepLatestCount}`);
console.log(`🧪 Dry run mode: ${dryRun ? 'ENABLED' : 'DISABLED'}`);
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
console.log(`📆 Cutoff date: ${cutoffDate.toISOString()}`);
try {
// Detect whether the owner is an organization or a user. The Packages API has
// separate org vs user endpoints; using the wrong one can make deletions appear
// to succeed in logs while not affecting the actual package namespace.
let isOrg = false;
try {
await github.rest.orgs.get({ org: owner });
isOrg = true;
console.log(`🔎 Owner ${owner} is an organization`);
} catch (err) {
// If org lookup fails with 404, assume it's a user account.
if (err.status === 404) {
console.log(`🔎 Owner ${owner} is a user account`);
isOrg = false;
} else {
throw err;
}
}
console.log('📦 Fetching packages...');
// Use pagination to ensure we see all package versions
let packages = [];
if (isOrg) {
packages = await github.paginate(github.rest.packages.getAllPackageVersionsForOrg, {
package_type: 'container',
package_name: repo,
org: owner,
state: 'active',
per_page: 100
});
} else {
packages = await github.paginate(github.rest.packages.getAllPackageVersionsForPackageOwnedByUser, {
package_type: 'container',
package_name: repo,
username: owner,
state: 'active',
per_page: 100
});
}
console.log(`📋 Found ${packages.length} package versions`);
if (packages.length === 0) {
console.log('ℹ️ No packages found to clean up');
return;
}
// Sort packages by creation date (newest first)
packages.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
console.log('\n📊 Package Analysis:');
packages.slice(0, 10).forEach((pkg, index) => {
const createdAt = new Date(pkg.created_at);
const age = Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24));
const tags = pkg.metadata?.container?.tags || [];
console.log(` ${index + 1}. ID: ${pkg.id}, Age: ${age}d, Tags: [${tags.join(', ')}], Created: ${createdAt.toISOString()}`);
});
if (packages.length > 10) {
console.log(` ... and ${packages.length - 10} more`);
}
let deletedCount = 0;
let keptCount = 0;
const errors = [];
// Always keep the latest N versions regardless of age
const latestToKeep = packages.slice(0, keepLatestCount);
console.log(`\n🔒 Always keeping latest ${keepLatestCount} versions:`);
latestToKeep.forEach((pkg, index) => {
const tags = pkg.metadata?.container?.tags || [];
console.log(` ${index + 1}. ID: ${pkg.id}, Tags: [${tags.join(', ')}]`);
});
// Process remaining packages
const packagesToConsider = packages.slice(keepLatestCount);
console.log(`\n🔍 Evaluating ${packagesToConsider.length} packages for cleanup...`);
for (const pkg of packagesToConsider) {
const createdAt = new Date(pkg.created_at);
const isOld = createdAt < cutoffDate;
const tags = pkg.metadata?.container?.tags || [];
const hasLatestTag = tags.includes('latest');
const age = Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24));
// Never delete 'latest' tag
if (hasLatestTag) {
console.log(`🔒 KEEP: ID ${pkg.id} (has 'latest' tag), Age: ${age}d, Tags: [${tags.join(', ')}]`);
keptCount++;
continue;
}
// Delete if older than retention period
if (isOld) {
console.log(`🗑️ DELETE: ID ${pkg.id}, Age: ${age}d, Tags: [${tags.join(', ')}]`);
if (!dryRun) {
try {
if (isOrg) {
await github.rest.packages.deletePackageVersionForOrg({
package_type: 'container',
package_name: repo,
org: owner,
package_version_id: pkg.id
});
} else {
await github.rest.packages.deletePackageVersionForUser({
package_type: 'container',
package_name: repo,
username: owner,
package_version_id: pkg.id
});
}
console.log(` ✅ Successfully deleted package version ${pkg.id}`);
} catch (error) {
const errorMsg = `Failed to delete package version ${pkg.id}: ${error.message}`;
console.log(` ❌ ${errorMsg}`);
errors.push(errorMsg);
// If we hit a permissions error, surface it immediately
if (error.status === 403) {
console.log(' 🔐 Permission error deleting package version - check token scope and org settings');
}
continue;
}
}
deletedCount++;
} else {
console.log(`🔒 KEEP: ID ${pkg.id} (within retention period), Age: ${age}d, Tags: [${tags.join(', ')}]`);
keptCount++;
}
}
// Summary
console.log('\n📊 Cleanup Summary:');
console.log(` 🔒 Kept packages: ${keptCount + keepLatestCount}`);
console.log(` 🗑️ ${dryRun ? 'Would delete' : 'Deleted'} packages: ${deletedCount}`);
console.log(` 📦 Total packages processed: ${packages.length}`);
if (errors.length > 0) {
console.log(`\n❌ Errors encountered (${errors.length}):`);
errors.forEach(error => console.log(` - ${error}`));
}
if (dryRun) {
console.log('\n🧪 This was a DRY RUN - no packages were actually deleted');
console.log('💡 To perform actual deletion, set dry_run to false');
} else if (deletedCount > 0) {
console.log('\n✅ Cleanup completed successfully');
} else {
console.log('\nℹ️ No packages needed cleanup');
}
} catch (error) {
console.error('❌ Failed to cleanup packages:', error.message);
// Check if it's a permissions issue
if (error.status === 403) {
console.error('🔐 This might be a permissions issue. Ensure the workflow has "packages: write" permission.');
} else if (error.status === 404) {
console.error('📦 Package not found. This might be normal if no container images exist yet.');
}
throw error;
}
- name: Cleanup summary
run: |
echo "🎉 Container image cleanup workflow completed"
echo ""
echo "ℹ️ Configuration used:"
echo " - Retention period: $RETENTION_DAYS days"
echo " - Keep latest count: $KEEP_LATEST_COUNT"
echo " - Dry run mode: $DRY_RUN"
echo ""
echo "💡 Tips:"
echo " - Run with dry_run=true first to preview changes"
echo " - Adjust retention_days based on your needs"
echo " - The 'latest' tag is always preserved"
echo " - The newest $KEEP_LATEST_COUNT versions are always kept"