Cleanup GHCR Images #21
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: Keep this many newest non-SHA-only image versions (0 = no count limit, minimum 1 always kept) | |
| required: false | |
| default: '4' | |
| retention_days: | |
| description: Delete non-SHA-only image versions older than this many days (0 = no age limit) | |
| required: false | |
| default: '30' | |
| 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 minimumToKeep = Math.max(1, imagesToKeep); | |
| const dryRun = process.env.DRY_RUN === 'true'; | |
| const cutoff = retentionDays > 0 ? new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000) : null; | |
| let deletedCount = 0; | |
| 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)); | |
| }; | |
| const { scope, versions } = await paginateVersions(); | |
| const sortedVersions = [...versions].sort( | |
| (left, right) => new Date(right.updated_at) - new Date(left.updated_at), | |
| ); | |
| let retainedCount = 0; | |
| let retainedTaggedCount = 0; | |
| for (const version of sortedVersions) { | |
| const updatedAt = new Date(version.updated_at); | |
| const tags = version.metadata?.container?.tags ?? []; | |
| const isShaOnly = isShaOnlyVersion(version); | |
| const isTagged = hasNonShaTag(version); | |
| const withinRetention = retentionDays > 0 && updatedAt >= cutoff; | |
| const needForMinimum = retainedTaggedCount < minimumToKeep; | |
| if (isShaOnly) { | |
| core.info(`Skipping age-based keep-count evaluation for SHA-only version ${version.id} with tags: ${tags.join(', ')}`); | |
| continue; | |
| } | |
| if (withinRetention) { | |
| core.info(`Keeping version ${version.id}; updated ${version.updated_at} is within ${retentionDays} days.`); | |
| retainedCount += 1; | |
| if (isTagged) retainedTaggedCount += 1; | |
| continue; | |
| } | |
| if (isTagged && needForMinimum) { | |
| core.info(`Keeping version ${version.id} to maintain minimum of ${minimumToKeep} tagged image(s).`); | |
| retainedCount += 1; | |
| retainedTaggedCount += 1; | |
| continue; | |
| } | |
| 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), | |
| ); | |
| let shaOnlyDeletedCount = 0; | |
| for (const version of sortedVersions) { | |
| const tags = version.metadata?.container?.tags ?? []; | |
| const isShaOnly = tags.length > 0 && tags.every(isShaTag); | |
| if (!isShaOnly) continue; | |
| core.info(`${dryRun ? 'Would delete' : 'Deleting'} version ${version.id} with SHA-only tags: ${tags.join(', ')}`); | |
| if (!dryRun) { | |
| await deleteVersion(scope, version.id); | |
| } | |
| shaOnlyDeletedCount += 1; | |
| } | |
| 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(); |