Cleanup GHCR Images #26
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: Delete old container versions | |
| id: cleanup_by_age | |
| uses: actions/github-script@v8 | |
| env: | |
| PACKAGE_NAME: ${{ env.PACKAGE_NAME }} | |
| IMAGES_TO_KEEP: ${{ env.IMAGES_TO_KEEP }} | |
| RETENTION_DAYS: ${{ env.RETENTION_DAYS }} | |
| 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 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; | |
| // Prerelease versions are lower than release | |
| 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)); // Highest version first | |
| return semverTags[0] || null; | |
| }; | |
| const { scope, versions } = await paginateVersions(); | |
| // 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; // Has version > no version | |
| } else if (!leftSemver && rightSemver) { | |
| return 1; | |
| } | |
| // Tiebreaker: newer update time first | |
| return new Date(right.updated_at) - new Date(left.updated_at); | |
| }); | |
| const nonShaOnlyVersions = sortedVersions.filter(v => !isShaOnlyVersion(v)); | |
| // Step 1: Filter by retention window (if RETENTION_DAYS > 0) | |
| 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`); | |
| } | |
| // Step 2: Cap by IMAGES_TO_KEEP (high water mark, if > 0) | |
| if (imagesToKeep > 0 && candidates.length > imagesToKeep) { | |
| candidates = candidates.slice(0, imagesToKeep); | |
| core.info(`Count cap: limiting to ${imagesToKeep} newest versions`); | |
| } | |
| // Step 3: 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)); | |
| let deletedCount = 0; | |
| let retainedCount = 0; | |
| let retainedTaggedCount = 0; | |
| for (const version of sortedVersions) { | |
| const tags = version.metadata?.container?.tags ?? []; | |
| const isShaOnly = isShaOnlyVersion(version); | |
| if (isShaOnly) { | |
| core.info(`Skipping SHA-only version ${version.id} with tags: ${tags.join(', ')}`); | |
| continue; | |
| } | |
| 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 ? 'Would delete' : 'Deleting'} version ${version.id} updated ${version.updated_at} with tags: ${tags.join(', ') || '(none)'}`); | |
| if (!dryRun) { | |
| await deleteVersion(scope, version.id); | |
| } | |
| deletedCount += 1; | |
| } | |
| } | |
| return JSON.stringify({ deletedCount, retainedCount, retainedTaggedCount }); | |
| - name: Delete container versions that only have SHA tags | |
| id: cleanup_sha_only | |
| if: ${{ env.DELETE_SHA_ONLY_TAGS == 'true' }} | |
| uses: actions/github-script@v8 | |
| env: | |
| PACKAGE_NAME: ${{ env.PACKAGE_NAME }} | |
| DRY_RUN: ${{ env.DRY_RUN }} | |
| AGE_BASED_RESULT: ${{ steps.cleanup_by_age.outputs.result }} | |
| with: | |
| result-encoding: string | |
| script: | | |
| const owner = context.repo.owner; | |
| const packageType = 'container'; | |
| const packageName = process.env.PACKAGE_NAME; | |
| const dryRun = process.env.DRY_RUN === 'true'; | |
| const ageBasedResult = JSON.parse(process.env.AGE_BASED_RESULT || '{}'); | |
| 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 { scope, versions } = await paginateVersions(); | |
| const sortedVersions = [...versions].sort( | |
| (left, right) => new Date(right.updated_at) - new Date(left.updated_at), | |
| ); | |
| const shaOnlyVersions = sortedVersions.filter(v => { | |
| const tags = v.metadata?.container?.tags ?? []; | |
| return tags.length > 0 && tags.every(isShaTag); | |
| }); | |
| core.info(`Found ${shaOnlyVersions.length} SHA-only versions to evaluate`); | |
| let shaOnlyDeletedCount = 0; | |
| for (const version of shaOnlyVersions) { | |
| const tags = version.metadata?.container?.tags ?? []; | |
| core.info(`${dryRun ? 'Would delete' : 'Deleting'} version ${version.id} with SHA-only tags: ${tags.join(', ')}`); | |
| if (!dryRun) { | |
| await deleteVersion(scope, version.id); | |
| } | |
| shaOnlyDeletedCount += 1; | |
| } | |
| if (shaOnlyDeletedCount > 0) { | |
| core.info(`${dryRun ? 'Would delete' : 'Deleted'} ${shaOnlyDeletedCount} SHA-only version(s)`); | |
| } else { | |
| core.info('No SHA-only versions to delete'); | |
| } | |
| return JSON.stringify({ | |
| totalBefore: sortedVersions.length, | |
| ageBasedDeleted: ageBasedResult.deletedCount || 0, | |
| ageBasedRetained: ageBasedResult.retainedCount || 0, | |
| ageBasedRetainedTagged: ageBasedResult.retainedTaggedCount || 0, | |
| shaOnlyDeleted: shaOnlyDeletedCount, | |
| }); | |
| - name: Generate cleanup report | |
| if: always() | |
| uses: actions/github-script@v8 | |
| env: | |
| AGE_BASED_RESULT: ${{ steps.cleanup_by_age.outputs.result }} | |
| SHA_ONLY_RESULT: ${{ steps.cleanup_sha_only.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 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 | ${process.env.DELETE_SHA_ONLY_TAGS} |\n\n`; | |
| const shaOnlyResult = process.env.SHA_ONLY_RESULT; | |
| const ageBasedResult = process.env.AGE_BASED_RESULT; | |
| if (shaOnlyResult && shaOnlyResult !== 'null') { | |
| const result = JSON.parse(shaOnlyResult); | |
| summary += '### Summary\n'; | |
| summary += '| Metric | Count |\n'; | |
| summary += '|--------|-------|\n'; | |
| summary += `| Total versions before | ${result.totalBefore || 0} |\n`; | |
| summary += `| Age-based deletions | ${result.ageBasedDeleted || 0} |\n`; | |
| summary += `| SHA-only deletions | ${result.shaOnlyDeleted || 0} |\n`; | |
| summary += `| **Retained tagged images** | **${result.ageBasedRetainedTagged || 0}** |\n`; | |
| } else if (ageBasedResult && ageBasedResult !== 'null') { | |
| const result = JSON.parse(ageBasedResult); | |
| summary += '### Summary\n'; | |
| summary += '| Metric | Count |\n'; | |
| summary += '|--------|-------|\n'; | |
| summary += `| Age-based deletions | ${result.deletedCount || 0} |\n`; | |
| summary += `| **Retained tagged images** | **${result.retainedTaggedCount || 0}** |\n`; | |
| } | |
| core.summary.addRaw(summary).write(); |