Cleanup Container Images #5
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 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 { | |
| // Get all packages for the repository | |
| console.log('📦 Fetching packages...'); | |
| const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByUser({ | |
| package_type: 'container', | |
| package_name: repo, | |
| username: owner, | |
| per_page: 100, | |
| state: 'active' | |
| }); | |
| 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 { | |
| 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); | |
| 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: ${{ env.RETENTION_DAYS }} days" | |
| echo " - Keep latest count: ${{ env.KEEP_LATEST_COUNT }}" | |
| echo " - Dry run mode: ${{ env.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 ${{ env.KEEP_LATEST_COUNT }} versions are always kept" |