From bbc74965ed0022f5bbac4779bc163e634627284e Mon Sep 17 00:00:00 2001 From: Artem Sorokin <38620398+seemewalkin@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:24:21 +0100 Subject: [PATCH] ci: Extract GHCR cleanup to shared script and add scheduled cleanup (#24870) --- .github/scripts/cleanup-ghcr-images.mjs | 72 ++++++++++++++++++++ .github/workflows/test-e2e-ci-reusable.yml | 28 +++----- .github/workflows/util-cleanup-pr-images.yml | 57 +++++++--------- 3 files changed, 105 insertions(+), 52 deletions(-) create mode 100644 .github/scripts/cleanup-ghcr-images.mjs diff --git a/.github/scripts/cleanup-ghcr-images.mjs b/.github/scripts/cleanup-ghcr-images.mjs new file mode 100644 index 00000000000..1babf64c5c0 --- /dev/null +++ b/.github/scripts/cleanup-ghcr-images.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * Cleanup GHCR images for n8n CI + * + * Usage: + * node cleanup-ghcr-images.mjs --tag # Delete specific tag + * node cleanup-ghcr-images.mjs --pr # Delete all pr-{number}-* tags + * node cleanup-ghcr-images.mjs --stale # Delete pr-* images older than N days + */ +import { execSync } from 'node:child_process'; + +const ORG = 'n8n-io'; +const PACKAGES = ['n8n', 'runners']; +const [mode, rawValue] = process.argv.slice(2); +if (!['--tag', '--pr', '--stale'].includes(mode) || !rawValue) { + console.error('Usage: cleanup-ghcr-images.mjs --tag|--pr|--stale '); + process.exit(1); +} +const value = mode === '--stale' ? parseInt(rawValue, 10) : rawValue; +if (mode === '--stale' && (!Number.isFinite(value) || value <= 0)) { + console.error('Error: --stale requires a positive number'); + process.exit(1); +} + +let hasErrors = false; + +function ghApi(path, del = false) { + const method = del ? '--method DELETE ' : ''; + try { + const out = execSync( + `gh api ${method}"/orgs/${ORG}/packages/container/${path}" -H "Accept: application/vnd.github+json"`, + { encoding: 'utf8', stdio: 'pipe' }, + ); + return del ? null : JSON.parse(out); + } catch { + if (del) hasErrors = true; + return null; + } +} + +function getVersions(pkg) { + const versions = []; + for (let page = 1; ; page++) { + const batch = ghApi(`${pkg}/versions?per_page=100&page=${page}`); + if (!batch?.length) break; + versions.push(...batch); + if (batch.length < 100) break; + } + return versions; +} + +function shouldDelete(v) { + const tags = v.metadata?.container?.tags || []; + if (mode === '--tag') return tags.includes(value); + if (mode === '--pr') return tags.some((t) => t.startsWith(`pr-${value}-`)); + if (mode === '--stale') { + const cutoff = Date.now() - value * 86400000; + return tags.some((t) => t.startsWith('pr-')) && new Date(v.created_at) < cutoff; + } + return false; +} + +for (const pkg of PACKAGES) { + const toDelete = getVersions(pkg).filter(shouldDelete); + if (!toDelete.length) console.log(`No matching images found for ${pkg}`); + for (const v of toDelete) { + ghApi(`${pkg}/versions/${v.id}`, true); + console.log(`Deleted ${pkg}:${v.metadata.container.tags.join(',')}`); + } +} + +if (hasErrors) process.exit(1); diff --git a/.github/workflows/test-e2e-ci-reusable.yml b/.github/workflows/test-e2e-ci-reusable.yml index c91662ac262..7287e2befdb 100644 --- a/.github/workflows/test-e2e-ci-reusable.yml +++ b/.github/workflows/test-e2e-ci-reusable.yml @@ -18,7 +18,7 @@ jobs: build-docker: name: 'Build Docker Image' if: ${{ !github.event.pull_request.head.repo.fork }} - runs-on: blacksmith-2vcpu-ubuntu-2204 + runs-on: blacksmith-4vcpu-ubuntu-2204 permissions: packages: write contents: read @@ -126,25 +126,15 @@ jobs: runs-on: ubuntu-latest permissions: packages: write + contents: read steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false + - name: Delete images from GHCR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: pr-${{ github.event.pull_request.number }}-${{ github.run_id }} - run: | - for PACKAGE in n8n runners; do - echo "Deleting $PACKAGE:$TAG..." - VERSION_ID=$(gh api \ - -H "Accept: application/vnd.github+json" \ - "/orgs/n8n-io/packages/container/$PACKAGE/versions" \ - --jq ".[] | select(.metadata.container.tags[] == \"$TAG\") | .id") - - if [ -n "$VERSION_ID" ]; then - gh api --method DELETE \ - -H "Accept: application/vnd.github+json" \ - "/orgs/n8n-io/packages/container/$PACKAGE/versions/$VERSION_ID" - echo " Deleted $PACKAGE version $VERSION_ID" - else - echo " Not found, may have already been deleted" - fi - done + run: node .github/scripts/cleanup-ghcr-images.mjs --tag pr-${{ github.event.pull_request.number }}-${{ github.run_id }} diff --git a/.github/workflows/util-cleanup-pr-images.yml b/.github/workflows/util-cleanup-pr-images.yml index c1173ef3ced..3402c0c7032 100644 --- a/.github/workflows/util-cleanup-pr-images.yml +++ b/.github/workflows/util-cleanup-pr-images.yml @@ -3,47 +3,38 @@ name: 'Util: Cleanup PR Docker Images' on: pull_request: types: [closed] + schedule: + # Weekly cleanup: Sunday at 3 AM UTC + - cron: '0 3 * * 0' jobs: cleanup: name: 'Delete PR images' - if: ${{ !github.event.pull_request.head.repo.fork }} + # Skip for fork PRs (they don't have GHCR images) + # Always run on schedule + if: ${{ github.event_name == 'schedule' || !github.event.pull_request.head.repo.fork }} runs-on: ubuntu-latest permissions: packages: write + contents: read steps: - - name: Delete all images for this PR + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # On PR close, the PR branch may be deleted - use default branch + # On schedule, use the default ref + ref: ${{ github.event.repository.default_branch || github.ref }} + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false + + - name: Delete images for closed PR + if: ${{ github.event_name == 'pull_request' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail + run: node .github/scripts/cleanup-ghcr-images.mjs --pr ${{ github.event.pull_request.number }} - echo "Cleaning up images for PR #$PR_NUMBER..." - - for PACKAGE in n8n runners; do - echo "Checking $PACKAGE package..." - - # Fetch all versions with pr-{number}-* tags - VERSIONS=$(gh api "/orgs/n8n-io/packages/container/$PACKAGE/versions" \ - --paginate \ - --jq ".[] | select(.metadata.container.tags | any(startswith(\"pr-${PR_NUMBER}-\"))) | {id, tags: .metadata.container.tags}" \ - ) || { echo " No images found or API error"; continue; } - - if [ -z "$VERSIONS" ]; then - echo " No images found" - continue - fi - - while IFS= read -r version; do - [ -z "$version" ] && continue - - ID=$(echo "$version" | jq -r '.id') - TAGS=$(echo "$version" | jq -r '.tags | join(", ")') - - echo " Deleting: $TAGS" - gh api --method DELETE "/orgs/n8n-io/packages/container/$PACKAGE/versions/$ID" || echo " Failed" - done <<< "$VERSIONS" - done - - echo "Cleanup complete" + - name: Delete stale PR images + if: ${{ github.event_name == 'schedule' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/cleanup-ghcr-images.mjs --stale 7