Skip to content

Cleanup GHCR Images

Cleanup GHCR Images #3

name: Cleanup GHCR Images
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
inputs:
retention_days:
description: Delete image versions older than this many days
required: false
default: ''
dry_run:
description: Log deletions without removing image versions
required: false
type: boolean
default: true
permissions:
packages: write
env:
PACKAGE_NAME: opencode-cli
RETENTION_DAYS: ${{ inputs.retention_days || vars.GHCR_RETENTION_DAYS || '45' }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Delete old container versions but keep one
id: cleanup_by_age
uses: actions/github-script@v8
env:
PACKAGE_NAME: ${{ env.PACKAGE_NAME }}
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 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 = [];
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 { scope, versions } = await paginateVersions();
const sortedVersions = [...versions].sort(
(left, right) => new Date(right.updated_at) - new Date(left.updated_at),
);
if (sortedVersions.length <= 1) {
core.info('Skipping age-based cleanup because only one package version exists.');
return JSON.stringify(deletedVersionIds);
}
let retainedCount = sortedVersions.length;
for (const [index, version] of sortedVersions.entries()) {
const updatedAt = new Date(version.updated_at);
const tags = version.metadata?.container?.tags ?? [];
const isNewest = index === 0;
const isOlderThanRetention = updatedAt < cutoff;
if (!isOlderThanRetention) {
core.info(`Keeping version ${version.id}; updated ${version.updated_at} is within ${retentionDays} days.`);
continue;
}
if (isNewest || retainedCount <= 1) {
core.info(`Keeping version ${version.id} to ensure at least one image remains available.`);
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);
retainedCount -= 1;
}
return JSON.stringify(deletedVersionIds);
- name: Delete container versions that only have SHA tags
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 <= 1) {
core.info('Skipping SHA-only cleanup because only one package version would remain after age-based cleanup.');
return;
}
let retainedCount = remainingVersions.length;
for (const [index, version] of remainingVersions.entries()) {
const tags = version.metadata?.container?.tags ?? [];
const isShaOnly = tags.length > 0 && tags.every(isShaTag);
const isNewest = index === 0;
if (!isShaOnly) {
core.info(`Keeping version ${version.id} with tags: ${tags.join(', ') || '(none)'}`);
continue;
}
if (isNewest || retainedCount <= 1) {
core.info(`Keeping version ${version.id} to ensure at least one image remains available.`);
continue;
}
core.info(`${dryRun ? 'Would delete' : 'Deleting'} version ${version.id} with SHA-only tags: ${tags.join(', ')}`);
if (!dryRun) {
await deleteVersion(scope, version.id);
}
retainedCount -= 1;
}