Cleanup Container Images #10
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 { | |
| // 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" |