Skip to content

Cleanup GHCR Images #17

Cleanup GHCR Images

Cleanup GHCR Images #17

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
required: false
default: '4'
retention_days:
description: Delete non-SHA-only image versions older than this many days
required: false
default: '45'
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 || '4' }}
RETENTION_DAYS: ${{ inputs.retention_days || vars.GHCR_RETENTION_DAYS || '45' }}
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 = Math.max(1, Number(process.env.IMAGES_TO_KEEP || '4'));
const retentionDays = Number(process.env.RETENTION_DAYS);
const dryRun = process.env.DRY_RUN === 'true';
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
const deletedVersionIds = [];
if (!Number.isFinite(imagesToKeep)) {
throw new Error(`IMAGES_TO_KEEP must be a number, received: ${process.env.IMAGES_TO_KEEP}`);
}
if (!Number.isFinite(retentionDays)) {
throw new Error(`RETENTION_DAYS must be a 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 { scope, versions } = await paginateVersions();
const sortedVersions = [...versions].sort(
(left, right) => new Date(right.updated_at) - new Date(left.updated_at),
);
const nonShaOnlyVersions = sortedVersions.filter((version) => !isShaOnlyVersion(version));
if (nonShaOnlyVersions.length <= imagesToKeep) {
core.info(`Skipping age-based cleanup because ${nonShaOnlyVersions.length} non-SHA-only package version(s) do not exceed the keep limit of ${imagesToKeep}.`);
return JSON.stringify(deletedVersionIds);
}
let retainedNonShaOnlyCount = nonShaOnlyVersions.length;
let retainedNewestNonShaOnly = 0;
for (const version of sortedVersions) {
const updatedAt = new Date(version.updated_at);
const tags = version.metadata?.container?.tags ?? [];
const isShaOnly = isShaOnlyVersion(version);
const isOlderThanRetention = updatedAt < cutoff;
if (isShaOnly) {
core.info(`Skipping age-based keep-count evaluation for SHA-only version ${version.id} with tags: ${tags.join(', ')}`);
continue;
}
if (!isOlderThanRetention) {
core.info(`Keeping version ${version.id}; updated ${version.updated_at} is within ${retentionDays} days.`);
retainedNewestNonShaOnly += 1;
continue;
}
if (retainedNewestNonShaOnly < imagesToKeep || retainedNonShaOnlyCount <= imagesToKeep) {
core.info(`Keeping version ${version.id} because it is within the newest ${imagesToKeep} non-SHA-only image version(s).`);
retainedNewestNonShaOnly += 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);
}
deletedVersionIds.push(version.id);
retainedNonShaOnlyCount -= 1;
}
return JSON.stringify(deletedVersionIds);
- 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 }}
DELETED_VERSION_IDS: ${{ steps.cleanup_by_age.outputs.result }}
with:
script: |
const owner = context.repo.owner;
const packageType = 'container';
const packageName = process.env.PACKAGE_NAME;
const dryRun = process.env.DRY_RUN === 'true';
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 remainingVersions = sortedVersions;
if (dryRun) {
const deletedVersionIds = new Set(JSON.parse(process.env.DELETED_VERSION_IDS || '[]').map(String));
core.info('Dry run enabled; excluding versions marked by the age-based cleanup step from SHA-only evaluation.');
remainingVersions = sortedVersions.filter((version) => !deletedVersionIds.has(String(version.id)));
}
if (remainingVersions.length === 0) {
core.info(`Skipping SHA-only cleanup because there are no remaining package versions to evaluate after the age-based cleanup step.`);
return;
}
for (const version of remainingVersions) {
const tags = version.metadata?.container?.tags ?? [];
const isShaOnly = tags.length > 0 && tags.every(isShaTag);
if (!isShaOnly) {
core.info(`Keeping version ${version.id} with tags: ${tags.join(', ') || '(none)'}`);
continue;
}
core.info(`${dryRun ? 'Would delete' : 'Deleting'} version ${version.id} with SHA-only tags: ${tags.join(', ')}`);
if (!dryRun) {
await deleteVersion(scope, version.id);
}
}
const shaOnlyDeleted = remainingVersions.filter(v => {
const tags = v.metadata?.container?.tags ?? [];
return tags.length > 0 && tags.every(isShaTag);
});
return JSON.stringify({
totalBefore: sortedVersions.length,
ageBasedDeleted: JSON.parse(process.env.DELETED_VERSION_IDS || '[]').length,
shaOnlyDeleted: shaOnlyDeleted.length,
remaining: sortedVersions.length - (dryRun ? JSON.parse(process.env.DELETED_VERSION_IDS || '[]').length + shaOnlyDeleted.length : 0),
});
- name: Generate cleanup report
if: always()
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 }}
run: |
echo "## 🧹 GHCR Cleanup Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$DRY_RUN" = "true" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "> ⚠️ **Dry run mode** - no images were actually deleted" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Configuration" >> $GITHUB_STEP_SUMMARY
echo "| Setting | Value |" >> $GITHUB_STEP_SUMMARY
echo "|---------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Images to keep | ${IMAGES_TO_KEEP} |" >> $GITHUB_STEP_SUMMARY
echo "| Retention days | ${RETENTION_DAYS} |" >> $GITHUB_STEP_SUMMARY
echo "| Delete SHA-only tags | ${DELETE_SHA_ONLY_TAGS} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -n "$SHA_ONLY_RESULT" ] && [ "$SHA_ONLY_RESULT" != "null" ]; then
AGE_DELETED=$(echo "$AGE_BASED_RESULT" | jq -r 'if type == "array" then length elif type == "object" then .ageBasedDeleted // 0 else 0 end')
SHA_DELETED=$(echo "$SHA_ONLY_RESULT" | jq -Rr 'fromjson? // {} | .shaOnlyDeleted // 0')
TOTAL_BEFORE=$(echo "$SHA_ONLY_RESULT" | jq -Rr 'fromjson? // {} | .totalBefore // 0')
REMAINING=$(echo "$SHA_ONLY_RESULT" | jq -Rr 'fromjson? // {} | .remaining // 0')
echo "### Summary" >> $GITHUB_STEP_SUMMARY
echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Total versions before | ${TOTAL_BEFORE} |" >> $GITHUB_STEP_SUMMARY
echo "| Age-based deletions | ${AGE_DELETED} |" >> $GITHUB_STEP_SUMMARY
echo "| SHA-only deletions | ${SHA_DELETED} |" >> $GITHUB_STEP_SUMMARY
echo "| **Remaining versions** | **${REMAINING}** |" >> $GITHUB_STEP_SUMMARY
elif [ -n "$AGE_BASED_RESULT" ] && [ "$AGE_BASED_RESULT" != "null" ]; then
AGE_DELETED=$(echo "$AGE_BASED_RESULT" | jq -r 'if type == "array" then length else 0 end')
echo "### Summary" >> $GITHUB_STEP_SUMMARY
echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Age-based deletions | ${AGE_DELETED} |" >> $GITHUB_STEP_SUMMARY
fi