From a31d003c058156ec1be01453e7de2f8653a02a7a Mon Sep 17 00:00:00 2001 From: Declan Carroll Date: Thu, 23 Apr 2026 08:18:52 +0100 Subject: [PATCH] ci: Centralize CI Docker image build and distribution (#28798) Co-authored-by: Claude Opus 4.7 (1M context) --- .github/actions/build-n8n-docker/action.yml | 51 +++++++ .github/actions/load-n8n-docker/action.yml | 23 +++ .github/workflows/ci-pull-requests.yml | 38 ++++- .github/workflows/prepare-docker-reusable.yml | 49 +++++++ .github/workflows/test-e2e-ci-reusable.yml | 137 +++++++----------- .../workflows/test-e2e-coverage-weekly.yml | 2 +- .../test-e2e-docker-pull-reusable.yml | 13 +- .../test-e2e-infrastructure-reusable.yml | 8 +- .../test-e2e-performance-reusable.yml | 9 +- .github/workflows/test-e2e-reusable.yml | 68 ++------- .github/workflows/test-workflows-callable.yml | 7 +- .../testing/containers/services/postgres.ts | 2 +- .../playwright/fixtures/observability.ts | 34 ++++- .../playwright/tests/e2e/auth/oidc.spec.ts | 2 +- .../execution-status.spec.ts | 2 +- .../external-user-trigger.spec.ts | 2 +- .../resolver-deletion.spec.ts | 2 +- .../log-streaming-observability.spec.ts | 2 +- .../log-streaming-ui-e2e.spec.ts | 2 +- 19 files changed, 275 insertions(+), 178 deletions(-) create mode 100644 .github/actions/build-n8n-docker/action.yml create mode 100644 .github/actions/load-n8n-docker/action.yml create mode 100644 .github/workflows/prepare-docker-reusable.yml diff --git a/.github/actions/build-n8n-docker/action.yml b/.github/actions/build-n8n-docker/action.yml new file mode 100644 index 00000000000..6578ffbd344 --- /dev/null +++ b/.github/actions/build-n8n-docker/action.yml @@ -0,0 +1,51 @@ +# Builds the n8n and runners CI test images (n8nio/n8n:local + +# n8nio/runners:local) and caches the tarball under a SHA-derived key so +# downstream jobs in the same workflow run can restore it via +# load-n8n-docker. +# +# Cache-aware: if the tarball for this SHA is already cached (e.g. a parent +# workflow ran prepare-docker earlier), the build step is skipped and this +# action becomes a no-op. + +name: 'Build n8n CI Docker image' +description: 'Builds n8nio/n8n + n8nio/runners test images and publishes them as a SHA-keyed cache tarball for downstream shards.' + +inputs: + build-variant: + description: 'standard or coverage. Coverage uses build:docker:coverage.' + required: false + default: 'standard' + +runs: + using: 'composite' + steps: + - name: Check cache for existing image + id: cache-check + uses: actions/cache/restore@640a1c2554105b57832a23eea0b4672fc7a790d5 # v4.2.3 + with: + key: n8n-docker-image-${{ github.sha }} + path: /tmp/n8n-image.tar.zst + lookup-only: true + + - name: Build Docker image + if: steps.cache-check.outputs.cache-hit != 'true' + uses: ./.github/actions/setup-nodejs + with: + build-command: ${{ inputs.build-variant == 'coverage' && 'pnpm build:docker:coverage' || 'pnpm build:docker' }} + enable-docker-cache: true + env: + INCLUDE_TEST_CONTROLLER: 'true' + + - name: Save image tarball + if: steps.cache-check.outputs.cache-hit != 'true' + shell: bash + run: | + docker save n8nio/n8n:local n8nio/runners:local | zstd -T0 -3 -o /tmp/n8n-image.tar.zst + ls -lh /tmp/n8n-image.tar.zst + + - name: Publish image tarball to cache + if: steps.cache-check.outputs.cache-hit != 'true' + uses: actions/cache/save@640a1c2554105b57832a23eea0b4672fc7a790d5 # v4.2.3 + with: + key: n8n-docker-image-${{ github.sha }} + path: /tmp/n8n-image.tar.zst diff --git a/.github/actions/load-n8n-docker/action.yml b/.github/actions/load-n8n-docker/action.yml new file mode 100644 index 00000000000..e0741a9a0d1 --- /dev/null +++ b/.github/actions/load-n8n-docker/action.yml @@ -0,0 +1,23 @@ +# Restores the n8n + runners image tarball (produced by build-n8n-docker +# under the SHA-derived cache key) and loads both images into the local +# docker daemon. +# +# After this action runs, `n8nio/n8n:local` and `n8nio/runners:local` are +# present on the runner. + +name: 'Load n8n Docker images from cache' +description: 'Restores the zstd-compressed n8n + runners image tarball from the SHA-keyed GHA cache and loads both images into the local docker daemon.' + +runs: + using: 'composite' + steps: + - name: Restore image tarball from cache + uses: actions/cache/restore@640a1c2554105b57832a23eea0b4672fc7a790d5 # v4.2.3 + with: + key: n8n-docker-image-${{ github.sha }} + path: /tmp/n8n-image.tar.zst + fail-on-cache-miss: true + + - name: Load n8n and runners images into docker + shell: bash + run: zstd -d -c /tmp/n8n-image.tar.zst | docker load diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index 3970f267b54..1d4a9c3afad 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -56,7 +56,9 @@ jobs: !.github/** e2e: .github/workflows/test-e2e-*.yml - .github/scripts/cleanup-ghcr-images.mjs + .github/workflows/prepare-docker-reusable.yml + .github/actions/build-n8n-docker/** + .github/actions/load-n8n-docker/** packages/testing/playwright/** packages/testing/containers/** workflows: .github/** @@ -149,10 +151,31 @@ jobs: run: | pnpm -r pack --dry-run + # Seeds the SHA-keyed Docker image cache once so that downstream e2e jobs + # (each of which invokes prepare-docker internally) short-circuit to a + # cache hit instead of racing to rebuild. + prepare-docker: + name: Prepare Docker + needs: install-and-build + if: >- + github.repository == 'n8n-io/n8n' && + github.event_name != 'merge_group' && + (needs.install-and-build.outputs.ci == 'true' + || needs.install-and-build.outputs.e2e == 'true' + || needs.install-and-build.outputs.e2e_performance == 'true') + uses: ./.github/workflows/prepare-docker-reusable.yml + with: + branch: ${{ needs.install-and-build.outputs.commit_sha }} + secrets: inherit + e2e-tests: name: E2E Tests - needs: install-and-build - if: (needs.install-and-build.outputs.ci == 'true' || needs.install-and-build.outputs.e2e == 'true') && github.repository == 'n8n-io/n8n' && github.event_name != 'merge_group' + needs: [install-and-build, prepare-docker] + if: >- + needs.prepare-docker.result == 'success' && + (needs.install-and-build.outputs.ci == 'true' || needs.install-and-build.outputs.e2e == 'true') && + github.repository == 'n8n-io/n8n' && + github.event_name != 'merge_group' uses: ./.github/workflows/test-e2e-ci-reusable.yml with: branch: ${{ needs.install-and-build.outputs.commit_sha }} @@ -177,11 +200,12 @@ jobs: e2e-performance: name: E2E Performance - needs: install-and-build + needs: [install-and-build, prepare-docker] + # Performance is internal-only (license secrets required, not available on forks). if: >- - (needs.install-and-build.outputs.ci == 'true' || needs.install-and-build.outputs.e2e_performance == 'true') && - github.event_name == 'pull_request' && - github.repository == 'n8n-io/n8n' + needs.prepare-docker.result == 'success' && + needs.install-and-build.outputs.e2e_performance == 'true' && + github.event.pull_request.head.repo.fork != true uses: ./.github/workflows/test-e2e-performance-reusable.yml secrets: inherit diff --git a/.github/workflows/prepare-docker-reusable.yml b/.github/workflows/prepare-docker-reusable.yml new file mode 100644 index 00000000000..b7e227baf0a --- /dev/null +++ b/.github/workflows/prepare-docker-reusable.yml @@ -0,0 +1,49 @@ +name: 'Prepare n8n Docker (reusable)' + +# Reusable workflow that ensures the n8n + runners CI test images for the +# current commit SHA are present in the GHA cache. Cache-aware: if another +# job in the same run already populated `n8n-docker-image-`, this +# becomes a no-op. +# +# Downstream jobs restore the same SHA-keyed cache via load-n8n-docker. + +on: + workflow_call: + inputs: + build-variant: + description: 'standard or coverage' + required: false + default: 'standard' + type: string + runner: + description: 'Runner for the build.' + required: false + default: 'blacksmith-4vcpu-ubuntu-2204' + type: string + branch: + description: 'Git ref to check out.' + required: false + default: '' + type: string + +jobs: + prepare: + name: 'Build & publish image' + runs-on: ${{ inputs.runner }} + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.branch || github.ref }} + fetch-depth: 1 + + - name: Build and publish image + uses: ./.github/actions/build-n8n-docker + with: + build-variant: ${{ inputs.build-variant }} + env: + QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }} + QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }} + QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }} diff --git a/.github/workflows/test-e2e-ci-reusable.yml b/.github/workflows/test-e2e-ci-reusable.yml index 0c8fa159dc4..4104c13e732 100644 --- a/.github/workflows/test-e2e-ci-reusable.yml +++ b/.github/workflows/test-e2e-ci-reusable.yml @@ -36,17 +36,22 @@ on: type: string default: '' -env: - DOCKER_IMAGE: ghcr.io/${{ github.repository }}:ci-${{ github.run_id }} +# Fork PRs route to the community path (sqlite-only, no @licensed tests) because +# they have no license-secret access. Non-PR events (schedule, manual dispatch) +# always use the internal path. jobs: - prepare: - name: 'Prepare E2E' - if: ${{ !github.event.pull_request.head.repo.fork }} - runs-on: blacksmith-4vcpu-ubuntu-2204 - permissions: - packages: write - contents: read + # Ensures the n8n + runners image tarball is cached under the SHA-derived + # key. Cache-aware: no-op if an ancestor workflow already populated it. + prepare-docker: + uses: ./.github/workflows/prepare-docker-reusable.yml + with: + branch: ${{ inputs.branch }} + secrets: inherit + + prepare-matrix: + name: 'Prepare Matrix' + runs-on: blacksmith-2vcpu-ubuntu-2204 outputs: matrix: ${{ steps.generate-matrix.outputs.matrix }} skip-tests: ${{ steps.generate-matrix.outputs.skip-tests }} @@ -57,26 +62,12 @@ jobs: ref: ${{ inputs.branch || github.ref }} fetch-depth: 1 - - name: Login to GHCR - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push to GHCR + - name: Setup Environment uses: ./.github/actions/setup-nodejs with: - build-command: 'pnpm build:docker' - enable-docker-cache: true - env: - INCLUDE_TEST_CONTROLLER: 'true' - IMAGE_BASE_NAME: ghcr.io/${{ github.repository }} - IMAGE_TAG: ci-${{ github.run_id }} - RUNNERS_IMAGE_BASE_NAME: ghcr.io/${{ github.repository_owner }}/runners - QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }} - QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }} - QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }} + # Matrix generation calls the janitor CLI (via distribute-tests.mjs), + # which needs packages/testing/janitor/dist/cli.js on disk. + build-command: 'pnpm turbo run build --filter=@n8n/playwright-janitor' - name: Get changed files for impact analysis if: ${{ inputs.playwright-only }} @@ -98,79 +89,49 @@ jobs: echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" echo "skip-tests=$(node -e "process.stdout.write(JSON.parse(process.argv[1])[0]?.skip === true ? 'true' : 'false')" "$MATRIX")" >> "$GITHUB_OUTPUT" + # Internal-only 1-spec fail-fast sanity check on sqlite. sqlite-sanity: - needs: [prepare] name: 'SQLite: Sanity Check' - if: ${{ !github.event.pull_request.head.repo.fork }} + needs: [prepare-docker] + if: ${{ github.event.pull_request.head.repo.fork != true }} uses: ./.github/workflows/test-e2e-reusable.yml with: branch: ${{ inputs.branch }} - test-mode: docker-pull - docker-image: ghcr.io/${{ github.repository }}:ci-${{ github.run_id }} + test-mode: docker-artifact test-command: pnpm --filter=n8n-playwright test:container:sqlite:e2e tests/e2e/building-blocks/workflow-entry-points.spec.ts - shards: 1 - runner: blacksmith-2vcpu-ubuntu-2204 workers: '1' - pre-generated-matrix: '[{"shard":1,"images":""}]' - n8n-env: ${{ inputs.n8n-env }} - - # Multi-main: postgres + redis + caddy + 2 mains + 1 worker - # Only runs for internal PRs (not community/fork PRs) - # Pulls pre-built Docker image from GHCR - multi-main-e2e: - needs: [prepare] - name: 'Multi-Main: E2E' - if: ${{ !github.event.pull_request.head.repo.fork && needs.prepare.outputs.skip-tests != 'true' }} - uses: ./.github/workflows/test-e2e-reusable.yml - with: - branch: ${{ inputs.branch }} - test-mode: docker-pull - docker-image: ghcr.io/${{ github.repository }}:ci-${{ github.run_id }} - test-command: pnpm --filter=n8n-playwright test:container:multi-main:e2e - shards: 16 - runner: blacksmith-2vcpu-ubuntu-2204 - workers: '1' - use-custom-orchestration: true - pre-generated-matrix: ${{ needs.prepare.outputs.matrix }} n8n-env: ${{ inputs.n8n-env }} secrets: inherit - # Community PR tests: Local mode with SQLite (no container building, no secrets required) - # Runs on GitHub-hosted runners without Currents reporting - community-e2e: - name: 'Community: E2E' - if: ${{ github.event.pull_request.head.repo.fork }} + # Internal-only full run: postgres + redis + caddy + 2 mains + 1 worker. + # Requires enterprise license secrets so cannot run in community mode. + multi-main-e2e: + needs: [prepare-docker, prepare-matrix] + name: 'Multi-Main: E2E' + if: ${{ github.event.pull_request.head.repo.fork != true && needs.prepare-matrix.outputs.skip-tests != 'true' }} uses: ./.github/workflows/test-e2e-reusable.yml with: branch: ${{ inputs.branch }} - test-mode: local - test-command: pnpm --filter=n8n-playwright test:local - shards: 7 - runner: ubuntu-latest + test-mode: docker-artifact + test-command: pnpm --filter=n8n-playwright test:container:multi-main:e2e workers: '1' + pre-generated-matrix: ${{ needs.prepare-matrix.outputs.matrix }} + n8n-env: ${{ inputs.n8n-env }} + secrets: inherit + + # Community-mode full run on sqlite (no enterprise license needed). + # Excludes @licensed tests since community contributors have no license secrets. + community-sqlite-e2e: + needs: [prepare-docker, prepare-matrix] + name: 'SQLite: E2E (community)' + if: ${{ github.event.pull_request.head.repo.fork == true && needs.prepare-matrix.outputs.skip-tests != 'true' }} + uses: ./.github/workflows/test-e2e-reusable.yml + with: + branch: ${{ inputs.branch }} + test-mode: docker-artifact + test-command: pnpm --filter=n8n-playwright test:container:sqlite:e2e --grep-invert="@licensed" + workers: '1' + pre-generated-matrix: ${{ needs.prepare-matrix.outputs.matrix }} + n8n-env: ${{ inputs.n8n-env }} upload-failure-artifacts: true - - # Cleanup ephemeral Docker image from GHCR after tests complete - # Local runner cleanup is handled by each test shard in test-e2e-reusable.yml - cleanup-ghcr: - name: 'Cleanup GHCR Image' - needs: [prepare, multi-main-e2e, sqlite-sanity] - if: ${{ !failure() && !cancelled() && !github.event.pull_request.head.repo.fork }} - runs-on: blacksmith-2vcpu-ubuntu-2204 - permissions: - packages: write - contents: read - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: .github/scripts - sparse-checkout-cone-mode: false - - - name: Delete images from GHCR - continue-on-error: true - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GHCR_ORG: ${{ github.repository_owner }} - GHCR_REPO: ${{ github.event.repository.name }} - run: node .github/scripts/cleanup-ghcr-images.mjs --tag ci-${{ github.run_id }} + secrets: inherit diff --git a/.github/workflows/test-e2e-coverage-weekly.yml b/.github/workflows/test-e2e-coverage-weekly.yml index bb0e161fbc5..524f766ac46 100644 --- a/.github/workflows/test-e2e-coverage-weekly.yml +++ b/.github/workflows/test-e2e-coverage-weekly.yml @@ -39,7 +39,7 @@ jobs: --workers=${{ env.PLAYWRIGHT_WORKERS }} env: BUILD_WITH_COVERAGE: 'true' - CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }} + CURRENTS_RECORD_KEY: ${{ vars.CURRENTS_RECORD_VAR }} CURRENTS_PROJECT_ID: 'LRxcNt' QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }} QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }} diff --git a/.github/workflows/test-e2e-docker-pull-reusable.yml b/.github/workflows/test-e2e-docker-pull-reusable.yml index adbc2167656..223c0ee3eee 100644 --- a/.github/workflows/test-e2e-docker-pull-reusable.yml +++ b/.github/workflows/test-e2e-docker-pull-reusable.yml @@ -28,11 +28,22 @@ on: type: string jobs: + generate-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.gen.outputs.matrix }} + steps: + - id: gen + run: | + MATRIX=$(seq 1 ${{ inputs.shards }} | awk 'BEGIN{printf "["} {printf "%s{\"shard\":%s,\"images\":\"\"}", (NR>1?",":""), $1} END{printf "]"}') + echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + build-and-test: + needs: generate-matrix uses: ./.github/workflows/test-e2e-reusable.yml with: test-mode: docker-pull - shards: ${{ inputs.shards }} docker-image: ${{ inputs.image }} test-command: pnpm --filter=n8n-playwright test:container:standard + pre-generated-matrix: ${{ needs.generate-matrix.outputs.matrix }} secrets: inherit diff --git a/.github/workflows/test-e2e-infrastructure-reusable.yml b/.github/workflows/test-e2e-infrastructure-reusable.yml index eba31148938..9a20d0975cf 100644 --- a/.github/workflows/test-e2e-infrastructure-reusable.yml +++ b/.github/workflows/test-e2e-infrastructure-reusable.yml @@ -17,7 +17,12 @@ concurrency: cancel-in-progress: true jobs: + prepare-docker: + uses: ./.github/workflows/prepare-docker-reusable.yml + secrets: inherit + benchmark: + needs: [prepare-docker] name: ${{ matrix.profile }} strategy: fail-fast: false @@ -31,9 +36,8 @@ jobs: runner: blacksmith-8vcpu-ubuntu-2204 uses: ./.github/workflows/test-e2e-reusable.yml with: - test-mode: docker-build + test-mode: docker-artifact test-command: pnpm --filter=n8n-playwright test:all --project='${{ matrix.profile }}:infrastructure' --workers=1 - shards: 1 runner: ${{ matrix.runner }} timeout-minutes: 60 secrets: inherit diff --git a/.github/workflows/test-e2e-performance-reusable.yml b/.github/workflows/test-e2e-performance-reusable.yml index c3ec421fc81..4a5be5c943c 100644 --- a/.github/workflows/test-e2e-performance-reusable.yml +++ b/.github/workflows/test-e2e-performance-reusable.yml @@ -7,11 +7,16 @@ on: - cron: '0 0 * * *' # Runs daily at midnight jobs: + # Cache-aware: no-op if an ancestor already populated the SHA-keyed cache. + prepare-docker: + uses: ./.github/workflows/prepare-docker-reusable.yml + secrets: inherit + build-and-test-performance: + needs: [prepare-docker] uses: ./.github/workflows/test-e2e-reusable.yml with: - test-mode: docker-build + test-mode: docker-artifact test-command: pnpm --filter=n8n-playwright test:performance - shards: 1 currents-project-id: 'O9BJaN' secrets: inherit diff --git a/.github/workflows/test-e2e-reusable.yml b/.github/workflows/test-e2e-reusable.yml index 1e5ad5c0a6a..1608e76d29e 100644 --- a/.github/workflows/test-e2e-reusable.yml +++ b/.github/workflows/test-e2e-reusable.yml @@ -8,7 +8,7 @@ on: required: false type: string test-mode: - description: 'Test mode: local (pnpm start from local), docker-build, or docker-pull' + description: 'Test mode: local, docker-artifact, or docker-pull' required: false default: 'local' type: string @@ -17,13 +17,8 @@ on: required: false default: 'pnpm --filter=n8n-playwright test:local' type: string - shards: - description: 'Number of parallel shards' - required: false - default: 8 - type: number docker-image: - description: 'Docker image to use (for docker-pull mode). The runners image is derived automatically from the n8n image.' + description: 'Docker image to use (docker-pull mode only). Ignored in docker-artifact mode (image is loaded as n8nio/n8n:local).' required: false default: 'n8nio/n8n:nightly' type: string @@ -37,11 +32,6 @@ on: required: false default: 'blacksmith-2vcpu-ubuntu-2204' type: string - use-custom-orchestration: - description: 'Use duration-based custom orchestration instead of Playwright sharding' - required: false - default: false - type: boolean timeout-minutes: description: 'Job timeout in minutes' required: false @@ -58,9 +48,9 @@ on: default: 'LRxcNt' type: string pre-generated-matrix: - description: 'Pre-generated shard matrix JSON (skips matrix job if provided)' + description: 'Pre-generated shard matrix JSON. Defaults to 1 shard; multi-shard callers pass their own (see test-e2e-ci-reusable.yml prepare-matrix).' required: false - default: '' + default: '[{"shard":1,"images":""}]' type: string n8n-env: description: 'JSON string of n8n env vars to inject into test containers, e.g. {"N8N_EXPRESSION_ENGINE":"vm"}' @@ -73,44 +63,22 @@ env: PLAYWRIGHT_WORKERS: ${{ inputs.workers != '' && inputs.workers || '2' }} # Browser cache location - must match install-browsers script PLAYWRIGHT_BROWSERS_PATH: packages/testing/playwright/.playwright-browsers - TEST_IMAGE_N8N: ${{ inputs.test-mode == 'docker-build' && 'n8nio/n8n:local' || inputs.docker-image }} + # docker-artifact loads the image locally as n8nio/n8n:local. + TEST_IMAGE_N8N: ${{ inputs.test-mode == 'docker-artifact' && 'n8nio/n8n:local' || inputs.docker-image }} N8N_SKIP_LICENSES: 'true' CURRENTS_CI_BUILD_ID: ${{ github.repository }}-${{ github.run_id }}-${{ github.run_attempt }} CURRENTS_PROJECT_ID: ${{ inputs.currents-project-id }} jobs: - matrix: - if: ${{ inputs.pre-generated-matrix == '' }} - runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }} - outputs: - matrix: ${{ steps.generate.outputs.matrix }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ inputs.branch || github.ref }} - fetch-depth: 1 - - - name: Setup Environment - uses: ./.github/actions/setup-nodejs - with: - build-command: '' - - - name: Generate shard matrix - id: generate - run: echo "matrix=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix ${{ inputs.shards }} ${{ inputs.use-custom-orchestration && '--orchestrate' || '' }})" >> "$GITHUB_OUTPUT" - test: - needs: matrix - if: ${{ !cancelled() }} runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || inputs.runner }} timeout-minutes: ${{ inputs.timeout-minutes }} permissions: - packages: read contents: read strategy: fail-fast: false matrix: - include: ${{ fromJSON(inputs.pre-generated-matrix || needs.matrix.outputs.matrix) }} + include: ${{ fromJSON(inputs.pre-generated-matrix) }} name: Shard ${{ matrix.shard }} steps: @@ -122,14 +90,7 @@ jobs: - name: Setup Environment uses: ./.github/actions/setup-nodejs - with: - # docker-build: build app + docker image locally - # docker-pull: no build needed, image is pre-built - # local: build app for local server - build-command: ${{ inputs.test-mode == 'docker-build' && 'pnpm build:docker' || 'pnpm build' }} - enable-docker-cache: ${{ inputs.test-mode == 'docker-build' }} env: - INCLUDE_TEST_CONTROLLER: ${{ inputs.test-mode == 'docker-build' && 'true' || '' }} QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }} QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }} QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }} @@ -137,9 +98,9 @@ jobs: - name: Install Browsers run: pnpm turbo run install-browsers --filter=n8n-playwright - - name: Login to GHCR - if: ${{ inputs.test-mode == 'docker-pull' }} - uses: ./.github/actions/docker-registry-login + - name: Load n8n image from cache + if: ${{ inputs.test-mode == 'docker-artifact' }} + uses: ./.github/actions/load-n8n-docker - name: Pre-pull Test Container Images if: ${{ !contains(inputs.test-command, 'test:local') }} @@ -149,7 +110,7 @@ jobs: # Uses pre-distributed specs if orchestration enabled, otherwise falls back to Playwright sharding run: ${{ inputs.test-command }} --workers=${{ env.PLAYWRIGHT_WORKERS }} ${{ matrix.specs || format('--shard={0}/{1}', matrix.shard, strategy.job-total) }} env: - CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }} + CURRENTS_RECORD_KEY: ${{ vars.CURRENTS_RECORD_VAR }} QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }} QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }} QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }} @@ -168,13 +129,6 @@ jobs: packages/testing/playwright/playwright-report/ 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 if: ${{ cancelled() }} env: diff --git a/.github/workflows/test-workflows-callable.yml b/.github/workflows/test-workflows-callable.yml index 41620fdc181..fa8f3702ae4 100644 --- a/.github/workflows/test-workflows-callable.yml +++ b/.github/workflows/test-workflows-callable.yml @@ -16,9 +16,6 @@ on: ENCRYPTION_KEY: description: 'Encryption key for n8n operations.' required: true - CURRENTS_RECORD_KEY: - description: 'Currents record key for uploading test results.' - required: true env: NODE_OPTIONS: --max-old-space-size=3072 @@ -44,12 +41,12 @@ jobs: - name: Run Workflow Tests run: pnpm --filter=n8n-playwright test:workflows --workers 4 env: - CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }} + CURRENTS_RECORD_KEY: ${{ vars.CURRENTS_RECORD_VAR }} CURRENTS_PROJECT_ID: 'mpLFH9' - name: Run Workflow Schema Tests if: ${{ inputs.compare_schemas == 'true' }} run: pnpm --filter=n8n-playwright test:workflows:schema env: - CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }} + CURRENTS_RECORD_KEY: ${{ vars.CURRENTS_RECORD_VAR }} CURRENTS_PROJECT_ID: 'mpLFH9' diff --git a/packages/testing/containers/services/postgres.ts b/packages/testing/containers/services/postgres.ts index 00e021fa54f..1b882a1d62d 100644 --- a/packages/testing/containers/services/postgres.ts +++ b/packages/testing/containers/services/postgres.ts @@ -25,7 +25,7 @@ export const postgres: Service = { .withDatabase('n8n_db') .withUsername('n8n_user') .withPassword('test_password') - .withStartupTimeout(30000) + .withStartupTimeout(40000) .withLabels({ 'com.docker.compose.project': projectName, 'com.docker.compose.service': HOSTNAME, diff --git a/packages/testing/playwright/fixtures/observability.ts b/packages/testing/playwright/fixtures/observability.ts index caa16d38590..f469ebcdf38 100644 --- a/packages/testing/playwright/fixtures/observability.ts +++ b/packages/testing/playwright/fixtures/observability.ts @@ -9,12 +9,26 @@ export type ObservabilityWorkerFixtures = { n8nContainer: N8NStack; }; +/** + * `stack.services` is a Proxy whose factory throws when a service isn't in the + * project config (e.g. sqlite:e2e has no observability services). Optional + * chaining doesn't help — the throw happens inside the factory invocation. + */ +function tryGetObservability(stack: N8NStack | undefined) { + if (!stack) return undefined; + try { + return stack.services?.observability; + } catch { + return undefined; + } +} + async function attachLogsOnFailure( stack: N8NStack, testInfo: TestInfo, options: { lookbackMinutes?: number } = {}, ): Promise { - const obs = stack.services?.observability; + const obs = tryGetObservability(stack); if (!obs) return; const lookback = options.lookbackMinutes ?? 5; @@ -62,7 +76,7 @@ async function attachLogsOnFailure( } async function attachMetricsOnFailure(stack: N8NStack, testInfo: TestInfo): Promise { - const obs = stack.services?.observability; + const obs = tryGetObservability(stack); if (!obs) return; try { @@ -91,12 +105,16 @@ export const observabilityFixtures: Fixtures< async ({ n8nContainer }, use, testInfo) => { await use(undefined); - if (testInfo.status !== testInfo.expectedStatus && n8nContainer?.services?.observability) { - await Promise.all([ - attachLogsOnFailure(n8nContainer, testInfo), - attachMetricsOnFailure(n8nContainer, testInfo), - ]); - } + // n8nContainer is undefined when fixture setup failed (e.g. postgres timeout); + // observability may be unconfigured for this project (sqlite:e2e). + // Both cases must be handled gracefully so teardown never masks the real failure. + if (testInfo.status === testInfo.expectedStatus) return; + if (!tryGetObservability(n8nContainer)) return; + + await Promise.all([ + attachLogsOnFailure(n8nContainer, testInfo), + attachMetricsOnFailure(n8nContainer, testInfo), + ]); }, { auto: true }, ], diff --git a/packages/testing/playwright/tests/e2e/auth/oidc.spec.ts b/packages/testing/playwright/tests/e2e/auth/oidc.spec.ts index ad47cf6bd6c..75320e55db4 100644 --- a/packages/testing/playwright/tests/e2e/auth/oidc.spec.ts +++ b/packages/testing/playwright/tests/e2e/auth/oidc.spec.ts @@ -6,7 +6,7 @@ test.use({ }); test.describe( - 'OIDC Authentication @capability:oidc', + 'OIDC Authentication @capability:oidc @licensed', { annotation: [{ type: 'owner', description: 'Identity & Access' }], }, diff --git a/packages/testing/playwright/tests/e2e/dynamic-credentials/execution-status.spec.ts b/packages/testing/playwright/tests/e2e/dynamic-credentials/execution-status.spec.ts index feaeebf9119..2fa82549d8d 100644 --- a/packages/testing/playwright/tests/e2e/dynamic-credentials/execution-status.spec.ts +++ b/packages/testing/playwright/tests/e2e/dynamic-credentials/execution-status.spec.ts @@ -27,7 +27,7 @@ test.use({ * → Credential status returned (missing / configured) */ test.describe( - 'Dynamic Credentials: execution-status @capability:dynamic-credentials', + 'Dynamic Credentials: execution-status @capability:dynamic-credentials @licensed', { annotation: [{ type: 'owner', description: 'Identity & Access' }], }, diff --git a/packages/testing/playwright/tests/e2e/dynamic-credentials/external-user-trigger.spec.ts b/packages/testing/playwright/tests/e2e/dynamic-credentials/external-user-trigger.spec.ts index d08bbe09982..ac623a4eb7a 100644 --- a/packages/testing/playwright/tests/e2e/dynamic-credentials/external-user-trigger.spec.ts +++ b/packages/testing/playwright/tests/e2e/dynamic-credentials/external-user-trigger.spec.ts @@ -33,7 +33,7 @@ test.use({ * 10. Wait for execution and assert success (HTTP node resolved credential + called userinfo) */ test.describe( - 'Dynamic Credentials: webhook execution @capability:dynamic-credentials', + 'Dynamic Credentials: webhook execution @capability:dynamic-credentials @licensed', { annotation: [{ type: 'owner', description: 'Identity & Access' }], }, diff --git a/packages/testing/playwright/tests/e2e/dynamic-credentials/resolver-deletion.spec.ts b/packages/testing/playwright/tests/e2e/dynamic-credentials/resolver-deletion.spec.ts index 726ebe8686c..cfa281ab0ce 100644 --- a/packages/testing/playwright/tests/e2e/dynamic-credentials/resolver-deletion.spec.ts +++ b/packages/testing/playwright/tests/e2e/dynamic-credentials/resolver-deletion.spec.ts @@ -19,7 +19,7 @@ test.use({ }); test.describe( - 'Dynamic Credentials: resolver deletion cleanup @capability:dynamic-credentials', + 'Dynamic Credentials: resolver deletion cleanup @capability:dynamic-credentials @licensed', { annotation: [{ type: 'owner', description: 'Identity & Access' }], }, diff --git a/packages/testing/playwright/tests/e2e/settings/log-streaming/log-streaming-observability.spec.ts b/packages/testing/playwright/tests/e2e/settings/log-streaming/log-streaming-observability.spec.ts index 6726ff65cf3..b3b8a6d7fda 100644 --- a/packages/testing/playwright/tests/e2e/settings/log-streaming/log-streaming-observability.spec.ts +++ b/packages/testing/playwright/tests/e2e/settings/log-streaming/log-streaming-observability.spec.ts @@ -15,7 +15,7 @@ import { test, expect } from '../../../../fixtures/base'; test.use({ capability: 'observability' }); test.describe( - 'Log Streaming to VictoriaLogs @capability:observability', + 'Log Streaming to VictoriaLogs @capability:observability @licensed', { annotation: [{ type: 'owner', description: 'Lifecycle & Governance' }], }, diff --git a/packages/testing/playwright/tests/e2e/settings/log-streaming/log-streaming-ui-e2e.spec.ts b/packages/testing/playwright/tests/e2e/settings/log-streaming/log-streaming-ui-e2e.spec.ts index cc926194aaf..47f9802746b 100644 --- a/packages/testing/playwright/tests/e2e/settings/log-streaming/log-streaming-ui-e2e.spec.ts +++ b/packages/testing/playwright/tests/e2e/settings/log-streaming/log-streaming-ui-e2e.spec.ts @@ -12,7 +12,7 @@ import { test, expect } from '../../../../fixtures/base'; test.use({ capability: 'observability' }); test.describe( - 'Log Streaming UI E2E @capability:observability', + 'Log Streaming UI E2E @capability:observability @licensed', { annotation: [{ type: 'owner', description: 'Lifecycle & Governance' }], },