Skip to content

Cleanup GHCR Images #23

Cleanup GHCR Images

Cleanup GHCR Images #23

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));
};
const { scope, versions } = await paginateVersions();
const sortedVersions = [...versions].sort(
(left, right) => 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),
);
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();