Cleanup GHCR Images #27
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 GHCR Images | |
| on: | |
| schedule: | |
| - cron: '0 3 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| images_to_keep: | |
| description: Maximum number of tagged images to keep (high water mark, 0 = no limit) | |
| required: false | |
| default: '4' | |
| retention_days: | |
| description: Delete images older than this many days (0 = no age limit) | |
| required: false | |
| default: '15' | |
| delete_sha_only_tags: | |
| description: Delete image versions that only have SHA tags | |
| required: false | |
| type: boolean | |
| default: true | |
| dry_run: | |
| description: Log deletions without removing image versions | |
| required: false | |
| type: boolean | |
| default: true | |
| permissions: | |
| packages: write | |
| env: | |
| PACKAGE_NAME: opencode-cli | |
| IMAGES_TO_KEEP: ${{ inputs.images_to_keep || vars.GHCR_IMAGES_TO_KEEP }} | |
| RETENTION_DAYS: ${{ inputs.retention_days || vars.GHCR_RETENTION_DAYS }} | |
| DELETE_SHA_ONLY_TAGS: ${{ github.event_name == 'workflow_dispatch' && inputs.delete_sha_only_tags || vars.GHCR_DELETE_SHA_ONLY_TAGS || 'true' }} | |
| DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} | |
| jobs: | |
| cleanup: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Cleanup container versions | |
| id: cleanup | |
| uses: actions/github-script@v8 | |
| env: | |
| PACKAGE_NAME: ${{ env.PACKAGE_NAME }} | |
| IMAGES_TO_KEEP: ${{ env.IMAGES_TO_KEEP }} | |
| RETENTION_DAYS: ${{ env.RETENTION_DAYS }} | |
| DELETE_SHA_ONLY_TAGS: ${{ env.DELETE_SHA_ONLY_TAGS }} | |
| DRY_RUN: ${{ env.DRY_RUN }} | |
| with: | |
| result-encoding: string | |
| script: | | |
| const owner = context.repo.owner; | |
| const packageType = 'container'; | |
| const packageName = process.env.PACKAGE_NAME; | |
| const imagesToKeep = Number(process.env.IMAGES_TO_KEEP || 0); | |
| const retentionDays = Number(process.env.RETENTION_DAYS || 0); | |
| const deleteShaOnly = process.env.DELETE_SHA_ONLY_TAGS === 'true'; | |
| const dryRun = process.env.DRY_RUN === 'true'; | |
| const cutoff = retentionDays > 0 ? new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000) : null; | |
| if (!Number.isFinite(imagesToKeep) || imagesToKeep < 0) { | |
| throw new Error(`IMAGES_TO_KEEP must be a non-negative number, received: ${process.env.IMAGES_TO_KEEP}`); | |
| } | |
| if (!Number.isFinite(retentionDays) || retentionDays < 0) { | |
| throw new Error(`RETENTION_DAYS must be a non-negative number, received: ${process.env.RETENTION_DAYS}`); | |
| } | |
| const paginateVersions = async () => { | |
| try { | |
| return { | |
| scope: 'org', | |
| versions: await github.paginate( | |
| github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg, | |
| { | |
| package_type: packageType, | |
| package_name: packageName, | |
| org: owner, | |
| per_page: 100, | |
| }, | |
| ), | |
| }; | |
| } catch (error) { | |
| if (error.status !== 404) { | |
| throw error; | |
| } | |
| return { | |
| scope: 'user', | |
| versions: await github.paginate( | |
| github.rest.packages.getAllPackageVersionsForPackageOwnedByUser, | |
| { | |
| package_type: packageType, | |
| package_name: packageName, | |
| username: owner, | |
| per_page: 100, | |
| }, | |
| ), | |
| }; | |
| } | |
| }; | |
| const deleteVersion = async (scope, versionId) => { | |
| if (scope === 'org') { | |
| await github.rest.packages.deletePackageVersionForOrg({ | |
| package_type: packageType, | |
| package_name: packageName, | |
| org: owner, | |
| package_version_id: versionId, | |
| }); | |
| return; | |
| } | |
| await github.rest.packages.deletePackageVersionForUser({ | |
| package_type: packageType, | |
| package_name: packageName, | |
| username: owner, | |
| package_version_id: versionId, | |
| }); | |
| }; | |
| const isShaTag = (value) => /^sha-[0-9a-f]{7,}$/i.test(value); | |
| const isShaOnlyVersion = (version) => { | |
| const tags = version.metadata?.container?.tags ?? []; | |
| return tags.length > 0 && tags.every(isShaTag); | |
| }; | |
| const hasNonShaTag = (version) => { | |
| const tags = version.metadata?.container?.tags ?? []; | |
| return tags.length > 0 && tags.some(tag => !isShaTag(tag)); | |
| }; | |
| // Semantic version handling | |
| const specialTags = new Set(['latest', 'stable', 'next', 'canary', 'edge', 'dev', 'alpha', 'beta', 'rc', 'release']); | |
| const semverPattern = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-._]?(.+))?$/; | |
| const parseSemver = (tag) => { | |
| const match = tag.match(semverPattern); | |
| if (!match) return null; | |
| return { | |
| major: parseInt(match[1], 10), | |
| minor: match[2] !== undefined ? parseInt(match[2], 10) : 0, | |
| patch: match[3] !== undefined ? parseInt(match[3], 10) : 0, | |
| prerelease: match[4] || null, | |
| original: tag | |
| }; | |
| }; | |
| const compareSemver = (a, b) => { | |
| if (a.major !== b.major) return a.major - b.major; | |
| if (a.minor !== b.minor) return a.minor - b.minor; | |
| if (a.patch !== b.patch) return a.patch - b.patch; | |
| if (a.prerelease && !b.prerelease) return -1; | |
| if (!a.prerelease && b.prerelease) return 1; | |
| if (a.prerelease && b.prerelease) return a.prerelease.localeCompare(b.prerelease); | |
| return 0; | |
| }; | |
| const extractSemver = (version) => { | |
| const tags = version.metadata?.container?.tags ?? []; | |
| const semverTags = tags | |
| .filter(tag => !isShaTag(tag) && !specialTags.has(tag.toLowerCase())) | |
| .map(parseSemver) | |
| .filter(Boolean) | |
| .sort((a, b) => compareSemver(b, a)); | |
| return semverTags[0] || null; | |
| }; | |
| const { scope, versions } = await paginateVersions(); | |
| core.info(`Found ${versions.length} total versions`); | |
| // Sort by semantic version DESC, then by updated_at DESC as tiebreaker | |
| const sortedVersions = [...versions].sort((left, right) => { | |
| const leftSemver = extractSemver(left); | |
| const rightSemver = extractSemver(right); | |
| if (leftSemver && rightSemver) { | |
| const cmp = compareSemver(rightSemver, leftSemver); | |
| if (cmp !== 0) return cmp; | |
| } else if (leftSemver && !rightSemver) { | |
| return -1; | |
| } else if (!leftSemver && rightSemver) { | |
| return 1; | |
| } | |
| return new Date(right.updated_at) - new Date(left.updated_at); | |
| }); | |
| // Separate SHA-only from regular versions | |
| const shaOnlyVersions = sortedVersions.filter(isShaOnlyVersion); | |
| const nonShaOnlyVersions = sortedVersions.filter(v => !isShaOnlyVersion(v)); | |
| core.info(`${shaOnlyVersions.length} SHA-only versions${deleteShaOnly ? ' (will be deleted)' : ''}`); | |
| core.info(`${nonShaOnlyVersions.length} non-SHA-only versions to evaluate for retention`); | |
| // Apply retention filter to non-SHA-only versions | |
| let candidates = nonShaOnlyVersions; | |
| if (retentionDays > 0) { | |
| candidates = nonShaOnlyVersions.filter(v => new Date(v.updated_at) >= cutoff); | |
| core.info(`Retention filter: ${candidates.length} of ${nonShaOnlyVersions.length} versions within ${retentionDays} days`); | |
| } | |
| // Apply count cap | |
| if (imagesToKeep > 0 && candidates.length > imagesToKeep) { | |
| candidates = candidates.slice(0, imagesToKeep); | |
| core.info(`Count cap: limiting to ${imagesToKeep} newest versions`); | |
| } | |
| // Safety: ensure at least 1 tagged image | |
| const taggedCandidates = candidates.filter(hasNonShaTag); | |
| if (taggedCandidates.length === 0) { | |
| const newestTagged = nonShaOnlyVersions.find(hasNonShaTag); | |
| if (newestTagged) { | |
| candidates.unshift(newestTagged); | |
| core.info(`Safety: added newest tagged version ${newestTagged.id} to ensure minimum of 1 tagged image`); | |
| } | |
| } | |
| // Build set of IDs to keep | |
| const keepIds = new Set(candidates.map(v => v.id)); | |
| core.info(`Will keep ${keepIds.size} non-SHA-only versions`); | |
| // Process all versions in a single pass | |
| let shaOnlyDeleted = 0; | |
| let shaOnlyRetained = 0; | |
| let ageBasedDeleted = 0; | |
| let retainedCount = 0; | |
| let retainedTaggedCount = 0; | |
| core.info(''); | |
| if (deleteShaOnly) { | |
| core.info('=== SHA-only versions (will be deleted) ==='); | |
| for (const version of shaOnlyVersions) { | |
| const tags = version.metadata?.container?.tags ?? []; | |
| core.info(`${dryRun ? '[DRY RUN] Would delete' : 'Deleting'} SHA-only version ${version.id} with tags: ${tags.join(', ')}`); | |
| if (!dryRun) { | |
| await deleteVersion(scope, version.id); | |
| } | |
| shaOnlyDeleted += 1; | |
| } | |
| } else { | |
| core.info('=== SHA-only versions (skipped - DELETE_SHA_ONLY_TAGS is false) ==='); | |
| for (const version of shaOnlyVersions) { | |
| const tags = version.metadata?.container?.tags ?? []; | |
| core.info(`Keeping SHA-only version ${version.id} with tags: ${tags.join(', ')}`); | |
| shaOnlyRetained += 1; | |
| } | |
| } | |
| core.info(''); | |
| core.info('=== Non-SHA-only versions ==='); | |
| for (const version of nonShaOnlyVersions) { | |
| const tags = version.metadata?.container?.tags ?? []; | |
| if (keepIds.has(version.id)) { | |
| core.info(`Keeping version ${version.id} with tags: ${tags.join(', ') || '(none)'}`); | |
| retainedCount += 1; | |
| if (hasNonShaTag(version)) retainedTaggedCount += 1; | |
| } else { | |
| core.info(`${dryRun ? '[DRY RUN] Would delete' : 'Deleting'} version ${version.id} with tags: ${tags.join(', ') || '(none)'}`); | |
| if (!dryRun) { | |
| await deleteVersion(scope, version.id); | |
| } | |
| ageBasedDeleted += 1; | |
| } | |
| } | |
| core.info(''); | |
| core.info('=== Summary ==='); | |
| core.info(`SHA-only deleted: ${shaOnlyDeleted}`); | |
| if (!deleteShaOnly) { | |
| core.info(`SHA-only retained: ${shaOnlyRetained}`); | |
| } | |
| core.info(`Age/count-based deleted: ${ageBasedDeleted}`); | |
| core.info(`Retained: ${retainedCount} (${retainedTaggedCount} tagged)`); | |
| return JSON.stringify({ | |
| totalBefore: versions.length, | |
| shaOnlyDeleted, | |
| shaOnlyRetained, | |
| ageBasedDeleted, | |
| retainedCount, | |
| retainedTaggedCount, | |
| }); | |
| - name: Generate cleanup report | |
| if: always() | |
| uses: actions/github-script@v8 | |
| env: | |
| CLEANUP_RESULT: ${{ steps.cleanup.outputs.result }} | |
| DELETE_SHA_ONLY_TAGS: ${{ env.DELETE_SHA_ONLY_TAGS }} | |
| DRY_RUN: ${{ env.DRY_RUN }} | |
| IMAGES_TO_KEEP: ${{ env.IMAGES_TO_KEEP }} | |
| RETENTION_DAYS: ${{ env.RETENTION_DAYS }} | |
| with: | |
| script: | | |
| const dryRun = process.env.DRY_RUN === 'true'; | |
| const deleteShaOnly = process.env.DELETE_SHA_ONLY_TAGS === 'true'; | |
| const imagesToKeep = process.env.IMAGES_TO_KEEP || '0'; | |
| const retentionDays = process.env.RETENTION_DAYS || '0'; | |
| let summary = '## 🧹 GHCR Cleanup Report\n\n'; | |
| if (dryRun) { | |
| summary += '> ⚠️ **Dry run mode** - no images were actually deleted\n\n'; | |
| } | |
| summary += '### Configuration\n'; | |
| summary += '| Setting | Value |\n'; | |
| summary += '|---------|-------|\n'; | |
| summary += `| Images to keep | ${imagesToKeep} |\n`; | |
| summary += `| Retention days | ${retentionDays} |\n`; | |
| summary += `| Delete SHA-only tags | ${deleteShaOnly} |\n\n`; | |
| const result = process.env.CLEANUP_RESULT; | |
| if (result && result !== 'null') { | |
| const data = JSON.parse(result); | |
| summary += '### Summary\n'; | |
| summary += '| Metric | Count |\n'; | |
| summary += '|--------|-------|\n'; | |
| summary += `| Total versions before | ${data.totalBefore || 0} |\n`; | |
| summary += `| SHA-only deleted | ${data.shaOnlyDeleted || 0} |\n`; | |
| if (data.shaOnlyRetained > 0) { | |
| summary += `| SHA-only retained | ${data.shaOnlyRetained || 0} |\n`; | |
| } | |
| summary += `| Age/count-based deleted | ${data.ageBasedDeleted || 0} |\n`; | |
| summary += `| **Retained images** | **${data.retainedCount || 0}** |\n`; | |
| summary += `| **Retained tagged images** | **${data.retainedTaggedCount || 0}** |\n`; | |
| } | |
| core.summary.addRaw(summary).write(); |