Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 54 additions & 20 deletions .github/workflows/cleanup-ghcr-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,19 @@ on:
- 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 image versions older than this many days
description: Delete non-SHA-only image versions older than this many days
required: false
default: ''
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
Expand All @@ -20,7 +29,9 @@ permissions:

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:
Expand All @@ -32,6 +43,7 @@ jobs:
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:
Expand All @@ -40,11 +52,20 @@ jobs:
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 {
Expand Down Expand Up @@ -98,31 +119,46 @@ jobs:
});
};

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 (sortedVersions.length <= 1) {
core.info('Skipping age-based cleanup because only one package version exists.');
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 retainedCount = sortedVersions.length;
let retainedNonShaOnlyCount = nonShaOnlyVersions.length;
let retainedNewestNonShaOnly = 0;

for (const [index, version] of sortedVersions.entries()) {
for (const version of sortedVersions) {
const updatedAt = new Date(version.updated_at);
const tags = version.metadata?.container?.tags ?? [];
const isNewest = index === 0;
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 (isNewest || retainedCount <= 1) {
core.info(`Keeping version ${version.id} to ensure at least one image remains available.`);
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;
}

Expand All @@ -133,24 +169,31 @@ jobs:
await deleteVersion(scope, version.id);
}
deletedVersionIds.push(version.id);
retainedCount -= 1;
retainedNonShaOnlyCount -= 1;
}

return JSON.stringify(deletedVersionIds);

- name: Delete container versions that only have SHA tags
if: ${{ env.DELETE_SHA_ONLY_TAGS == 'true' }}
uses: actions/github-script@v8
env:
PACKAGE_NAME: ${{ env.PACKAGE_NAME }}
IMAGES_TO_KEEP: ${{ env.IMAGES_TO_KEEP }}
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 imagesToKeep = Math.max(1, Number(process.env.IMAGES_TO_KEEP || '4'));
const dryRun = process.env.DRY_RUN === 'true';

if (!Number.isFinite(imagesToKeep)) {
throw new Error(`IMAGES_TO_KEEP must be a number, received: ${process.env.IMAGES_TO_KEEP}`);
}

const paginateVersions = async () => {
try {
return {
Expand Down Expand Up @@ -219,28 +262,19 @@ jobs:
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.');
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;
}

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);
Expand Down
47 changes: 47 additions & 0 deletions .github/workflows/trigger-build-and-deploy-latest-tag.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
name: Trigger Build and Deploy for Latest Tag

on:
schedule:
- cron: '0 0,12 * * *'
workflow_dispatch:

permissions:
actions: write
contents: read

jobs:
trigger-build:
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.WORKFLOW_APP_ID }}
private-key: ${{ secrets.WORKFLOW_APP_PRIVATE_KEY }}

- name: Find latest version tag
id: latest_tag
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
REPO: ${{ github.repository }}
run: |
latest_tag="$({
gh api "repos/${REPO}/tags" --paginate --jq '.[].name' || exit 1
} | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | sed 's/^v//' | sort -V | tail -n 1)"

if [[ -z "$latest_tag" ]]; then
echo "No version tags found."
exit 1
fi

echo "tag=v${latest_tag}" >> "$GITHUB_OUTPUT"

- name: Trigger build-and-deploy workflow
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
REPO: ${{ github.repository }}
TAG: ${{ steps.latest_tag.outputs.tag }}
run: |
gh workflow run build-and-deploy.yml --repo "$REPO" --ref "$TAG"