From 35969d93c562076fb3effad313fd498e046cd9c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Repe=C4=87?= Date: Thu, 27 Aug 2026 08:31:06 +0000 Subject: [PATCH] ci: Exercise the runners images in the Docker smoke test (no-changelog) (#37118) Co-authored-by: Claude Fable 5 --- .github/workflows/docker-build-smoke.yml | 21 ++++++++++++-- scripts/dockerize-n8n.mjs | 23 ++++++++++++++++ scripts/smoke-n8n-image.mjs | 35 +++++++++++++++++++++++- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-build-smoke.yml b/.github/workflows/docker-build-smoke.yml index ba5648e3157..48ed9743e8d 100644 --- a/.github/workflows/docker-build-smoke.yml +++ b/.github/workflows/docker-build-smoke.yml @@ -13,6 +13,11 @@ env: # Node. dockerize-n8n.mjs reads this env for the image builds. Keep in sync with # NODE_VERSION in docker-build-push.yml, which is what the published images use. NODE_VERSION: '26.7.0' + # Also build the distroless runners image and exec-check + # both runners images' interpreters, so a broken runtime assembly (e.g. a + # missing shared library after a Node bump) fails at PR time. The smoke + # script derives the images to check from this same flag. + DOCKER_BUILD_DISTROLESS: 'true' on: schedule: @@ -33,8 +38,20 @@ concurrency: jobs: docker-smoke-test: - name: 'Docker Build (no cache)' - runs-on: blacksmith-4vcpu-ubuntu-2204 + name: 'Docker Build (no cache, ${{ matrix.platform }})' + # Both architectures build natively: the runtime images assemble binaries + # across images with arch-specific paths (e.g. the ELF interpreter lives at + # /lib/ld-linux-aarch64.so.1 only on arm64), so amd64 alone can miss + # arm64-only runtime breaks. + strategy: + fail-fast: false + matrix: + include: + - platform: amd64 + runner: blacksmith-4vcpu-ubuntu-2204 + - platform: arm64 + runner: blacksmith-8vcpu-ubuntu-2204-arm + runs-on: ${{ matrix.runner }} if: ${{ !github.event.pull_request.head.repo.fork }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/scripts/dockerize-n8n.mjs b/scripts/dockerize-n8n.mjs index 0599df47d5c..ea4e25a8bc6 100755 --- a/scripts/dockerize-n8n.mjs +++ b/scripts/dockerize-n8n.mjs @@ -142,6 +142,8 @@ const rootDir = isInScriptsDir ? path.join(__dirname, '..') : __dirname; const noCache = process.env.DOCKER_BUILD_NO_CACHE === 'true'; const withBaseImage = process.env.DOCKER_BUILD_BASE_IMAGE === 'true'; +// Opt-in: only cloud deploys the distroless runners image, so local builds skip it. +const withDistroless = process.env.DOCKER_BUILD_DISTROLESS === 'true'; // Keep in sync with NODE_VERSION in .github/workflows/docker-build-push.yml, // which is what the published images are actually built with. const nodeVersion = process.env.NODE_VERSION || '26.7.0'; @@ -172,6 +174,12 @@ const config = { return `${this.imageBaseName}:${this.imageTag}`; }, }, + runnersDistroless: { + dockerfilePath: path.join(rootDir, 'docker/images/runners/Dockerfile.distroless'), + get fullImageName() { + return `${config.runners.fullImageName}-distroless`; + }, + }, buildContext: rootDir, compiledAppDir: path.join(rootDir, 'compiled'), compiledTaskRunnerDir: path.join(rootDir, 'dist', 'task-runner-javascript'), @@ -236,6 +244,21 @@ async function main() { }, ]; + if (withDistroless) { + const buildTime = await buildDockerImage({ + name: 'runners-distroless', + dockerfilePath: config.runnersDistroless.dockerfilePath, + fullImageName: config.runnersDistroless.fullImageName, + buildArgs: nodeVersionArgs, + }); + imageStats.push({ + imageName: config.runnersDistroless.fullImageName, + platform, + size: await getImageSize(config.runnersDistroless.fullImageName), + buildTime, + }); + } + // Write docker build manifest for telemetry collection const dockerManifest = { buildTime: new Date().toISOString(), diff --git a/scripts/smoke-n8n-image.mjs b/scripts/smoke-n8n-image.mjs index 496018216a0..436cd2d2a2d 100644 --- a/scripts/smoke-n8n-image.mjs +++ b/scripts/smoke-n8n-image.mjs @@ -11,6 +11,12 @@ $.verbose = false; process.env.FORCE_COLOR = '1'; const IMAGE = process.env.SMOKE_IMAGE || 'n8nio/n8n:local'; +// Runners images to exec-check. Tracks DOCKER_BUILD_DISTROLESS so the same +// flag drives both build and check. +const RUNNERS_IMAGES = [ + 'n8nio/runners:local', + ...(process.env.DOCKER_BUILD_DISTROLESS === 'true' ? ['n8nio/runners:local-distroless'] : []), +]; const TIMEOUT = '45s'; // Matches an n8n runtime image ref (e.g. `n8nio/n8n:2.4.4`, `ghcr.io/n8n-io/n8n@sha256:…`) // but not sidecars like `n8nio/runners:…` or controller images that happen to contain "n8n". @@ -120,6 +126,27 @@ async function runWorkspaceDedupCheck() { } } +// Interpreter paths as launched by docker/images/runners/n8n-task-runners.json. +// The runners images assemble node/python by copying binaries across images, so a +// missing shared library only surfaces at exec time. +const RUNNER_INTERPRETERS = [ + ['/usr/local/bin/node', '--version'], + ['/opt/runners/task-runner-python/.venv/bin/python', '--version'], +]; + +async function runRunnersInterpreterCheck(image) { + const name = `runner interpreters exec in ${image}`; + try { + for (const [entrypoint, arg] of RUNNER_INTERPRETERS) { + await $({ timeout: TIMEOUT })`docker run --rm --entrypoint ${entrypoint} ${image} ${arg}`; + } + echo(chalk.green(`✓ ${name}`)); + return true; + } catch (err) { + return reportFailure(name, err); + } +} + async function run({ name, user, entrypoint, args }) { const dockerArgs = ['run', '--rm', '--user', `${user}:${user}`]; if (entrypoint) dockerArgs.push('--entrypoint', entrypoint); @@ -152,5 +179,11 @@ const invocations = [ ]; echo(chalk.bold(`Verifying ${IMAGE} against ${invocations.length} deployment pattern(s)`)); -const ok = (await Promise.all([...invocations.map(run), runWorkspaceDedupCheck()])).every(Boolean); +const ok = ( + await Promise.all([ + ...invocations.map(run), + runWorkspaceDedupCheck(), + ...RUNNERS_IMAGES.map(runRunnersInterpreterCheck), + ]) +).every(Boolean); if (!ok) process.exit(1);