mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
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:
parent
3b41ca8d2f
commit
aa12777c61
66
.github/scripts/cleanup-ghcr-images.mjs
vendored
66
.github/scripts/cleanup-ghcr-images.mjs
vendored
|
|
@ -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`);
|
||||||
|
|
|
||||||
8
.github/workflows/ci-pull-requests.yml
vendored
8
.github/workflows/ci-pull-requests.yml
vendored
|
|
@ -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 }}
|
||||||
|
|
|
||||||
31
.github/workflows/test-e2e-ci-reusable.yml
vendored
31
.github/workflows/test-e2e-ci-reusable.yml
vendored
|
|
@ -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
|
|
||||||
|
|
|
||||||
7
.github/workflows/test-e2e-reusable.yml
vendored
7
.github/workflows/test-e2e-reusable.yml
vendored
|
|
@ -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:
|
||||||
|
|
|
||||||
12
.github/workflows/util-cleanup-pr-images.yml
vendored
12
.github/workflows/util-cleanup-pr-images.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user