ci: Fix Docker image cleanup and simplify CI image tagging (#26002)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Declan Carroll 2026-02-20 07:49:36 +00:00 committed by GitHub
parent 3b41ca8d2f
commit aa12777c61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 50 additions and 74 deletions

View File

@ -3,14 +3,13 @@
* Cleanup GHCR images for n8n CI * Cleanup GHCR images for n8n CI
* *
* Modes: * Modes:
* --tag <tag> Delete exact tag (merge queue cleanup - single image) * --tag <tag> Delete exact tag (post-run cleanup)
* --pr <number> Delete all pr-{number}-* tags (PR cleanup - all runs for a PR) * --stale <days> Delete ci-* images older than N days (daily scheduled cleanup)
* --stale <days> Delete pr-* images older than N days (weekly scheduled cleanup)
* *
* Context: * Context:
* - PR runs use --pr to clean all images from failed/retried commits * - Each CI run tags images as ci-{run_id}
* - Merge queue runs use --tag since PR number isn't available (image tagged pr--{run_id}) * - Post-run cleanup uses --tag to delete the current run's images
* - Weekly cron uses --stale to catch any orphaned images * - Daily cron uses --stale to catch any orphaned images
*/ */
import { exec } from 'node:child_process'; import { exec } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
@ -21,8 +20,8 @@ const REPO = process.env.GHCR_REPO || 'n8n';
const PACKAGES = [REPO, 'runners']; const PACKAGES = [REPO, 'runners'];
const [mode, rawValue] = process.argv.slice(2); const [mode, rawValue] = process.argv.slice(2);
if (!['--tag', '--pr', '--stale'].includes(mode) || !rawValue) { if (!['--tag', '--stale'].includes(mode) || !rawValue) {
console.error('Usage: cleanup-ghcr-images.mjs --tag <tag> | --pr <number> | --stale <days>'); console.error('Usage: cleanup-ghcr-images.mjs --tag <tag> | --stale <days>');
process.exit(1); process.exit(1);
} }
@ -52,14 +51,14 @@ async function fetchPage(pkg, page) {
} }
} }
const isPrImage = (v, prNum) => { const isCiImage = (v) => {
const tags = v.metadata?.container?.tags || []; const tags = v.metadata?.container?.tags || [];
return prNum ? tags.some((t) => t.startsWith(`pr-${prNum}-`)) : tags.some((t) => t.startsWith('pr-')); return tags.some((t) => t.startsWith('ci-') || t.startsWith('pr-'));
}; };
const isStale = (v, days) => { const isStale = (v, days) => {
const cutoff = Date.now() - days * 86400000; const cutoff = Date.now() - days * 86400000;
return isPrImage(v) && new Date(v.created_at) < cutoff; return isCiImage(v) && new Date(v.created_at) < cutoff;
}; };
async function getVersionsForTag(pkg, tag) { async function getVersionsForTag(pkg, tag) {
@ -68,33 +67,12 @@ async function getVersionsForTag(pkg, tag) {
return match ? [match] : []; return match ? [match] : [];
} }
async function getVersionsForPr(pkg, prNumber) {
const versions = [];
let emptyPages = 0;
for (let page = 1; ; page++) {
const batch = await fetchPage(pkg, page);
if (!batch.length) break;
const matches = batch.filter((v) => isPrImage(v, prNumber));
if (matches.length) {
versions.push(...matches);
emptyPages = 0;
} else if (++emptyPages >= 3) {
console.log(` Early termination after ${page} pages`);
break;
}
if (batch.length < 100) break;
}
return versions;
}
async function getVersionsForStale(pkg, days) { async function getVersionsForStale(pkg, days) {
const versions = []; const versions = [];
const cutoff = Date.now() - days * 86400000; const cutoff = Date.now() - days * 86400000;
// Use 2x cutoff as safety window for early termination // Use 2x cutoff as safety window for early termination
const earlyExitCutoff = Date.now() - days * 2 * 86400000; const earlyExitCutoff = Date.now() - days * 2 * 86400000;
let pagesWithoutPrImages = 0; let pagesWithoutCiImages = 0;
const firstPage = await fetchPage(pkg, 1); const firstPage = await fetchPage(pkg, 1);
if (!firstPage.length) return []; if (!firstPage.length) return [];
@ -112,22 +90,22 @@ async function getVersionsForStale(pkg, days) {
for (const batch of batches) { for (const batch of batches) {
if (!batch.length || batch.length < 100) done = true; if (!batch.length || batch.length < 100) done = true;
let hasPrImages = false; let hasCiImages = false;
for (const v of batch) { for (const v of batch) {
if (isPrImage(v)) { if (isCiImage(v)) {
hasPrImages = true; hasCiImages = true;
if (new Date(v.created_at) < cutoff) versions.push(v); if (new Date(v.created_at) < cutoff) versions.push(v);
} }
} }
// Early termination: if we've gone through pages without finding // Early termination: if we've gone through pages without finding
// any PR images and all items are older than 2x cutoff, we're past // any CI images and all items are older than 2x cutoff, we're past
// the PR image window // the CI image window
if (!hasPrImages) { if (!hasCiImages) {
pagesWithoutPrImages++; pagesWithoutCiImages++;
const oldestInBatch = batch[batch.length - 1]; const oldestInBatch = batch[batch.length - 1];
if ( if (
pagesWithoutPrImages >= 3 && pagesWithoutCiImages >= 3 &&
oldestInBatch && oldestInBatch &&
new Date(oldestInBatch.created_at) < earlyExitCutoff new Date(oldestInBatch.created_at) < earlyExitCutoff
) { ) {
@ -135,7 +113,7 @@ async function getVersionsForStale(pkg, days) {
done = true; done = true;
} }
} else { } else {
pagesWithoutPrImages = 0; pagesWithoutCiImages = 0;
} }
if (!batch.length || done) break; if (!batch.length || done) break;
@ -154,9 +132,7 @@ for (const pkg of PACKAGES) {
const toDelete = const toDelete =
mode === '--tag' mode === '--tag'
? await getVersionsForTag(pkg, value) ? await getVersionsForTag(pkg, value)
: mode === '--pr' : await getVersionsForStale(pkg, value);
? await getVersionsForPr(pkg, value)
: await getVersionsForStale(pkg, value);
if (!toDelete.length) { if (!toDelete.length) {
console.log(` No matching images found`); console.log(` No matching images found`);

View File

@ -21,6 +21,7 @@ jobs:
outputs: outputs:
ci: ${{ fromJSON(steps.ci-filter.outputs.results).ci == true }} ci: ${{ fromJSON(steps.ci-filter.outputs.results).ci == true }}
unit: ${{ fromJSON(steps.ci-filter.outputs.results).unit == true }} unit: ${{ fromJSON(steps.ci-filter.outputs.results).unit == true }}
e2e: ${{ fromJSON(steps.ci-filter.outputs.results).e2e == true }}
workflows: ${{ fromJSON(steps.ci-filter.outputs.results).workflows == true }} workflows: ${{ fromJSON(steps.ci-filter.outputs.results).workflows == true }}
db: ${{ fromJSON(steps.ci-filter.outputs.results).db == true }} db: ${{ fromJSON(steps.ci-filter.outputs.results).db == true }}
commit_sha: ${{ steps.commit-sha.outputs.sha }} commit_sha: ${{ steps.commit-sha.outputs.sha }}
@ -49,6 +50,11 @@ jobs:
!packages/@n8n/task-runner-python/** !packages/@n8n/task-runner-python/**
!packages/testing/playwright/** !packages/testing/playwright/**
!.github/** !.github/**
e2e:
.github/workflows/test-e2e-*.yml
.github/scripts/cleanup-ghcr-images.mjs
packages/testing/playwright/**
packages/testing/containers/**
workflows: .github/** workflows: .github/**
db: db:
packages/cli/src/databases/** packages/cli/src/databases/**
@ -111,7 +117,7 @@ jobs:
e2e-tests: e2e-tests:
name: E2E Tests name: E2E Tests
needs: install-and-build needs: install-and-build
if: needs.install-and-build.outputs.ci == 'true' && github.repository == 'n8n-io/n8n' if: (needs.install-and-build.outputs.ci == 'true' || needs.install-and-build.outputs.e2e == 'true') && github.repository == 'n8n-io/n8n'
uses: ./.github/workflows/test-e2e-ci-reusable.yml uses: ./.github/workflows/test-e2e-ci-reusable.yml
with: with:
branch: ${{ needs.install-and-build.outputs.commit_sha }} branch: ${{ needs.install-and-build.outputs.commit_sha }}

View File

@ -10,7 +10,7 @@ on:
default: '' default: ''
env: env:
DOCKER_IMAGE: ghcr.io/${{ github.repository }}:pr-${{ github.event.pull_request.number }}-${{ github.run_id }} DOCKER_IMAGE: ghcr.io/${{ github.repository }}:ci-${{ github.run_id }}
jobs: jobs:
prepare: prepare:
@ -44,7 +44,7 @@ jobs:
env: env:
INCLUDE_TEST_CONTROLLER: 'true' INCLUDE_TEST_CONTROLLER: 'true'
IMAGE_BASE_NAME: ghcr.io/${{ github.repository }} IMAGE_BASE_NAME: ghcr.io/${{ github.repository }}
IMAGE_TAG: pr-${{ github.event.pull_request.number }}-${{ github.run_id }} IMAGE_TAG: ci-${{ github.run_id }}
RUNNERS_IMAGE_BASE_NAME: ghcr.io/${{ github.repository_owner }}/runners RUNNERS_IMAGE_BASE_NAME: ghcr.io/${{ github.repository_owner }}/runners
- name: Generate shard matrix - name: Generate shard matrix
@ -59,7 +59,7 @@ jobs:
with: with:
branch: ${{ inputs.branch }} branch: ${{ inputs.branch }}
test-mode: docker-pull test-mode: docker-pull
docker-image: ghcr.io/${{ github.repository }}:pr-${{ github.event.pull_request.number }}-${{ github.run_id }} docker-image: ghcr.io/${{ github.repository }}:ci-${{ github.run_id }}
test-command: pnpm --filter=n8n-playwright test:container:sqlite:e2e tests/e2e/building-blocks/workflow-entry-points.spec.ts test-command: pnpm --filter=n8n-playwright test:container:sqlite:e2e tests/e2e/building-blocks/workflow-entry-points.spec.ts
shards: 1 shards: 1
runner: blacksmith-2vcpu-ubuntu-2204 runner: blacksmith-2vcpu-ubuntu-2204
@ -77,7 +77,7 @@ jobs:
with: with:
branch: ${{ inputs.branch }} branch: ${{ inputs.branch }}
test-mode: docker-pull test-mode: docker-pull
docker-image: ghcr.io/${{ github.repository }}:pr-${{ github.event.pull_request.number }}-${{ github.run_id }} docker-image: ghcr.io/${{ github.repository }}:ci-${{ github.run_id }}
test-command: pnpm --filter=n8n-playwright test:container:multi-main:e2e test-command: pnpm --filter=n8n-playwright test:container:multi-main:e2e
shards: 16 shards: 16
runner: blacksmith-2vcpu-ubuntu-2204 runner: blacksmith-2vcpu-ubuntu-2204
@ -101,9 +101,10 @@ jobs:
workers: '1' workers: '1'
upload-failure-artifacts: true upload-failure-artifacts: true
# Cleanup ephemeral Docker image from GHCR and local cache after tests complete # Cleanup ephemeral Docker image from GHCR after tests complete
cleanup-docker: # Local runner cleanup is handled by each test shard in test-e2e-reusable.yml
name: 'Cleanup Docker Image' cleanup-ghcr:
name: 'Cleanup GHCR Image'
needs: [prepare, multi-main-e2e, sqlite-sanity] needs: [prepare, multi-main-e2e, sqlite-sanity]
if: ${{ !failure() && !cancelled() && !github.event.pull_request.head.repo.fork }} if: ${{ !failure() && !cancelled() && !github.event.pull_request.head.repo.fork }}
runs-on: blacksmith-2vcpu-ubuntu-2204 runs-on: blacksmith-2vcpu-ubuntu-2204
@ -117,24 +118,10 @@ jobs:
sparse-checkout: .github/scripts sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false sparse-checkout-cone-mode: false
- name: Cleanup local Docker cache
run: |
echo "Removing PR images from local cache..."
docker rmi ghcr.io/n8n-io/n8n:pr-${{ github.event.pull_request.number }}-${{ github.run_id }} || true
docker rmi ghcr.io/n8n-io/runners:pr-${{ github.event.pull_request.number }}-${{ github.run_id }} || true
docker system prune -f || true
- name: Delete images from GHCR - name: Delete images from GHCR
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GHCR_ORG: ${{ github.repository_owner }} GHCR_ORG: ${{ github.repository_owner }}
GHCR_REPO: ${{ github.event.repository.name }} GHCR_REPO: ${{ github.event.repository.name }}
run: | run: node .github/scripts/cleanup-ghcr-images.mjs --tag ci-${{ github.run_id }}
if [ -n "${{ github.event.pull_request.number }}" ]; then
echo "PR context: cleaning all images for PR ${{ github.event.pull_request.number }}"
node .github/scripts/cleanup-ghcr-images.mjs --pr ${{ github.event.pull_request.number }}
else
echo "Merge queue context: cleaning image pr--${{ github.run_id }}"
node .github/scripts/cleanup-ghcr-images.mjs --tag pr--${{ github.run_id }}
fi

View File

@ -175,6 +175,13 @@ jobs:
packages/testing/playwright/playwright-report/ packages/testing/playwright/playwright-report/
retention-days: 7 retention-days: 7
- name: Cleanup cached CI images
if: ${{ inputs.test-mode == 'docker-pull' }}
continue-on-error: true
run: |
docker images --format '{{.Repository}}:{{.Tag}}' | grep -E 'ghcr\.io/n8n-io/(n8n|runners):(ci|pr)-' | xargs -r docker rmi || true
docker system prune -f || true
- name: Cancel Currents run if workflow is cancelled - name: Cancel Currents run if workflow is cancelled
if: ${{ cancelled() }} if: ${{ cancelled() }}
env: env:

View File

@ -1,13 +1,13 @@
name: 'Util: Cleanup PR Docker Images' name: 'Util: Cleanup CI Docker Images'
on: on:
schedule: schedule:
# Weekly cleanup: Sunday at 3 AM UTC # Daily cleanup at 3 AM UTC
- cron: '0 3 * * 0' - cron: '0 3 * * *'
jobs: jobs:
cleanup: cleanup:
name: 'Delete stale PR images' name: 'Delete stale CI images'
runs-on: ubuntu-slim runs-on: ubuntu-slim
permissions: permissions:
packages: write packages: write
@ -19,9 +19,9 @@ jobs:
sparse-checkout: .github/scripts sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false sparse-checkout-cone-mode: false
- name: Delete stale PR images - name: Delete stale CI images
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GHCR_ORG: ${{ github.repository_owner }} GHCR_ORG: ${{ github.repository_owner }}
GHCR_REPO: ${{ github.event.repository.name }} GHCR_REPO: ${{ github.event.repository.name }}
run: node .github/scripts/cleanup-ghcr-images.mjs --stale 7 run: node .github/scripts/cleanup-ghcr-images.mjs --stale 1