n8n/.github/workflows/ci-pull-requests.yml
Charlie Kolb b97ffe94df
ci: Skip the scoped cli integration step when the DB matrix runs
The DB matrix's SQLite Pooled leg runs the full cli integration suite
with the same command and environment as the scoped step in the unit
workflow. Skip the scoped step on those runs. Master runs keep the
default and do not change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 14:12:52 +02:00

458 lines
20 KiB
YAML

name: 'CI: Pull Requests (Build, Test, Lint)'
on:
pull_request:
merge_group:
concurrency:
group: ci-${{ github.event.pull_request.number || github.event.merge_group.head_sha || github.ref }}
cancel-in-progress: true
jobs:
install-and-build:
name: Install & Build
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
env:
NODE_OPTIONS: '--max-old-space-size=7168'
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
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 }}
outputs:
ci: ${{ fromJSON(steps.ci-filter.outputs.results).ci == true }}
runtime: ${{ fromJSON(steps.ci-filter.outputs.results).runtime == true }}
unit: ${{ fromJSON(steps.ci-filter.outputs.results).unit == true }}
e2e: ${{ fromJSON(steps.ci-filter.outputs.results).e2e == true }}
dev_server_smoke: ${{ fromJSON(steps.ci-filter.outputs.results)['dev-server-smoke'] == true }}
workflows: ${{ fromJSON(steps.ci-filter.outputs.results).workflows == true }}
workflow_scripts: ${{ fromJSON(steps.ci-filter.outputs.results)['workflow-scripts'] == true }}
db: ${{ fromJSON(steps.ci-filter.outputs.results).db == true }}
performance: ${{ fromJSON(steps.ci-filter.outputs.results).performance == true }}
e2e_performance: ${{ fromJSON(steps.ci-filter.outputs.results)['e2e-performance'] == true }}
instance_ai_workflow_eval: ${{ fromJSON(steps.ci-filter.outputs.results)['instance-ai-workflow-eval'] == true }}
commit_sha: ${{ steps.commit-sha.outputs.sha }}
merge_base: ${{ steps.ci-filter.outputs.merge-base }}
matrix: ${{ steps.generate-matrix.outputs.matrix }}
db_test_matrix: ${{ steps.generate-db-test-matrix.outputs.db_test_matrix }}
skip_tests: ${{ steps.generate-matrix.outputs.skip-tests }}
affected_packages: ${{ steps.affected-packages.outputs.list }}
changed_files: ${{ steps.ci-filter.outputs.changed-files }}
steps:
- uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1
with:
# Use merge_group SHA when in merge queue, otherwise PR merge ref
ref: ${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || format('refs/pull/{0}/merge', github.event.pull_request.number) }}
- name: Capture commit SHA for cache consistency
id: commit-sha
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Check for relevant changes
uses: ./.github/actions/ci-filter
id: ci-filter
with:
mode: filter
filters: |
ci:
**
!packages/@n8n/task-runner-python/**
!.github/**
!**/*.md
!**/LICENSE
!**/CHANGELOG.md
runtime:
**
!packages/@n8n/task-runner-python/**
!.github/**
!**/*.md
!**/LICENSE
!**/CHANGELOG.md
!**/*.test.ts
!**/*.spec.ts
!packages/testing/playwright/**
!packages/frontend/@n8n/storybook/**
!.devcontainer/**
!scripts/agent-setup.mjs
!scripts/backend-module/**
!scripts/cloud-session.mjs
!scripts/licenses/**
!scripts/mutation-health/**
!scripts/sync-agent-skill-links.mjs
unit:
**
!packages/@n8n/task-runner-python/**
!packages/testing/playwright/**
!.github/**
!**/*.md
!**/LICENSE
!**/CHANGELOG.md
!.devcontainer/**
!scripts/agent-setup.mjs
!scripts/backend-module/**
!scripts/cloud-session.mjs
!scripts/licenses/**
!scripts/mutation-health/**
!scripts/sync-agent-skill-links.mjs
e2e:
.github/workflows/test-e2e-*.yml
.github/workflows/prepare-docker-reusable.yml
.github/actions/build-n8n-docker/**
.github/actions/load-n8n-docker/**
packages/testing/playwright/**
packages/testing/containers/**
dev-server-smoke:
packages/frontend/editor-ui/vite.config.mts
# If this job becomes too slow, use a smaller glob. Do not delete the assertion.
packages/frontend/editor-ui/src/**
pnpm-workspace.yaml
packages/@n8n/*/package.json
packages/frontend/@n8n/frontend-vite-config/**
packages/testing/playwright/tests/dev-server-smoke/**
packages/testing/playwright/playwright.config.ts
packages/testing/playwright/playwright-projects.ts
packages/testing/playwright/package.json
.github/workflows/test-dev-server-smoke-reusable.yml
workflows: .github/**
workflow-scripts:
.github/scripts/**
OWNERS
scripts/licenses/**
scripts/mutation-health/**
cubic.yaml
.agents/review-rules/**
performance:
packages/testing/performance/**
packages/workflow/src/**
packages/@n8n/expression-runtime/src/**
.github/workflows/test-bench-reusable.yml
e2e-performance:
packages/testing/playwright/tests/performance/**
packages/testing/playwright/utils/performance-helper.ts
packages/testing/containers/**
.github/workflows/test-e2e-performance-reusable.yml
instance-ai-workflow-eval:
packages/@n8n/instance-ai/src/**
packages/@n8n/instance-ai/evaluations/**
packages/cli/src/modules/instance-ai/**
packages/core/src/execution-engine/eval-mock-helpers.ts
.github/workflows/test-evals-discovery.yml
db:
packages/cli/src/databases/**
packages/cli/src/modules/*/database/**
packages/cli/src/modules/**/*.entity.ts
packages/cli/src/modules/**/*.repository.ts
packages/cli/test/integration/**
packages/cli/test/migration/**
packages/cli/test/shared/db/**
packages/cli/test/setup-testcontainers.js
packages/cli/test/teardown-testcontainers.js
packages/@n8n/db/**
packages/@n8n/backend-test-utils/**
packages/@n8n/scheduler/src/**
packages/cli/**/__tests__/**
packages/testing/containers/services/postgres.ts
packages/testing/containers/postgres-versions.json
packages/testing/containers/test-containers.ts
.github/workflows/test-db-reusable.yml
.github/scripts/db-test-matrix.mjs
docs/generated/**
.tbls.sqlite.yml
.tbls.postgres.yml
- name: Setup and Build
if: fromJSON(steps.ci-filter.outputs.results).ci || fromJSON(steps.ci-filter.outputs.results).e2e
uses: ./.github/actions/setup-nodejs
with:
build-command: ${{ fromJSON(steps.ci-filter.outputs.results).ci && 'pnpm build' || 'pnpm turbo run build --filter=@n8n/playwright-janitor' }}
- name: Report build critical path
if: fromJSON(steps.ci-filter.outputs.results).ci
run: node scripts/turbo-critical-path.mjs >> "$GITHUB_STEP_SUMMARY"
- name: Run format check
if: fromJSON(steps.ci-filter.outputs.results).ci
run: pnpm format:check
- name: Generate shard matrix
id: generate-matrix
if: fromJSON(steps.ci-filter.outputs.results).ci || fromJSON(steps.ci-filter.outputs.results).e2e
env:
CHANGED_FILES: ${{ steps.ci-filter.outputs.changed-files }}
MERGE_BASE: ${{ steps.ci-filter.outputs.merge-base }}
run: |
FILES_CSV=$(echo "$CHANGED_FILES" | tr '\n' ',' | sed 's/,$//')
MATRIX=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix 16 --orchestrate --impact "--files=$FILES_CSV" "--base=$MERGE_BASE")
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"
# Generated here because a reusable workflow cannot resolve its own matrix.
- name: Generate DB test matrix
id: generate-db-test-matrix
if: fromJSON(steps.ci-filter.outputs.results).db
run: echo "db_test_matrix=$(node .github/scripts/db-test-matrix.mjs)" >> "$GITHUB_OUTPUT"
- name: Compute affected packages
id: affected-packages
if: fromJSON(steps.ci-filter.outputs.results).unit
env:
CHANGED_FILES: ${{ steps.ci-filter.outputs.changed-files }}
run: |
PACKAGES=$(node packages/testing/janitor/dist/cli.js affected-packages | tr '\n' ' ' | sed 's/ *$//')
echo "Affected packages: $PACKAGES"
echo "list=$PACKAGES" >> "$GITHUB_OUTPUT"
unit-test:
name: Unit tests
if: needs.install-and-build.outputs.unit == 'true'
uses: ./.github/workflows/test-unit-reusable.yml
needs: install-and-build
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
collectCoverage: ${{ github.event_name != 'merge_group' }}
affectedPackages: ${{ needs.install-and-build.outputs.affected_packages }}
changedFiles: ${{ needs.install-and-build.outputs.changed_files }}
# The DB matrix's "SQLite Pooled" leg runs the full cli integration
# suite with the same command and environment as the scoped step.
skipCliIntegration: ${{ needs.install-and-build.outputs.db == 'true' }}
secrets: inherit
typecheck:
name: Typecheck
if: needs.install-and-build.outputs.ci == 'true'
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
needs: install-and-build
env:
# Inherited by every worker turbo spawns, so the real budget is
# concurrency x cap: 2 x 6 GB fits the runner's 15.2 GB.
NODE_OPTIONS: --max-old-space-size=6144
steps:
- uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
# turbo directly, not the root `typecheck` script, to keep the
# concurrency bound CI-only.
build-command: pnpm turbo typecheck --concurrency=2
lint:
name: Lint
if: needs.install-and-build.outputs.ci == 'true'
uses: ./.github/workflows/test-linting-reusable.yml
needs: install-and-build
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
check-packaging:
name: Check packaging
if: needs.install-and-build.outputs.ci == 'true'
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
needs: install-and-build
steps:
- uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
- name: Check packaging
shell: bash
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.runtime == 'true'
|| needs.install-and-build.outputs.e2e == 'true'
|| needs.install-and-build.outputs.e2e_performance == 'true'
|| contains(github.event.pull_request.labels.*.name, 'force-e2e')
|| contains(github.event.pull_request.labels.*.name, 'run-perf-tests'))
uses: ./.github/workflows/prepare-docker-reusable.yml
with:
branch: ${{ needs.install-and-build.outputs.commit_sha }}
secrets: inherit
# Full e2e run. Internal PRs run multi-main (postgres + redis + caddy + 2 mains + 1 worker).
# Fork PRs run sqlite-only and skip @licensed tests (no enterprise license secrets on forks).
e2e:
name: E2E
needs: [install-and-build, prepare-docker]
if: >-
needs.prepare-docker.result == 'success' &&
(needs.install-and-build.outputs.runtime == 'true'
|| needs.install-and-build.outputs.e2e == 'true'
|| contains(github.event.pull_request.labels.*.name, 'force-e2e')) &&
needs.install-and-build.outputs.skip_tests != 'true' &&
github.event_name != 'merge_group'
uses: ./.github/workflows/test-e2e-reusable.yml
with:
branch: ${{ needs.install-and-build.outputs.commit_sha }}
test-mode: docker-artifact
test-command: ${{ github.event.pull_request.head.repo.fork == true && 'pnpm --filter=n8n-playwright test:container:sqlite:e2e --grep-invert=@licensed' || 'pnpm --filter=n8n-playwright test:container:multi-main:e2e' }}
workers: '1'
pre-generated-matrix: ${{ needs.install-and-build.outputs.matrix }}
artifact-prefix: e2e
secrets: inherit
# Boots the editor-ui against the Vite dev server and fails on any console
# or page error during load. Catches regressions in dev-mode module
# resolution (missing Vite alias, broken workspace package interop) that
# the production-bundle e2e job bundles around.
dev-server-smoke:
name: Dev-server boot smoke
needs: install-and-build
if: needs.install-and-build.outputs.dev_server_smoke == 'true' && github.event_name != 'merge_group'
uses: ./.github/workflows/test-dev-server-smoke-reusable.yml
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
secrets: inherit
db-tests:
name: DB Tests
needs: install-and-build
if: needs.install-and-build.outputs.db == 'true'
uses: ./.github/workflows/test-db-reusable.yml
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
matrix: ${{ needs.install-and-build.outputs.db_test_matrix }}
performance:
name: Performance
needs: install-and-build
if: needs.install-and-build.outputs.performance == 'true' && github.event_name != 'merge_group'
uses: ./.github/workflows/test-bench-reusable.yml
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
e2e-performance:
name: E2E Performance
needs: [install-and-build, prepare-docker]
# Performance is internal-only (license secrets required, not available on forks).
# Runs when path filter trips OR when the PR carries the `run-perf-tests` label
# (opt-in coverage for shared-utility / Vue Flow changes that the path filter
# would otherwise miss).
if: >-
needs.prepare-docker.result == 'success' &&
(needs.install-and-build.outputs.e2e_performance == 'true' ||
contains(github.event.pull_request.labels.*.name, 'run-perf-tests')) &&
github.event.pull_request.head.repo.fork != true
uses: ./.github/workflows/test-e2e-performance-reusable.yml
secrets: inherit
security-checks:
name: Security Checks
needs: install-and-build
if: needs.install-and-build.outputs.workflows == 'true'
uses: ./.github/workflows/sec-ci-reusable.yml
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
secrets: inherit
workflow-scripts:
name: Workflow scripts
needs: install-and-build
if: needs.install-and-build.outputs.workflow_scripts == 'true'
uses: ./.github/workflows/test-workflow-scripts-reusable.yml
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
secrets: inherit
# In-process discovery eval — asserts the orchestrator reaches for browser/computer-use
# tools at OAuth/screenshot moments. Lightweight (no Docker), runs in parallel with the
# heavy workflow eval. Non-blocking initially; promote to required after stability.
instance-ai-discovery-evals:
name: Instance AI Discovery Evals
needs: install-and-build
if: >-
!cancelled() &&
needs.install-and-build.result == 'success' &&
needs.install-and-build.outputs.instance_ai_workflow_eval == 'true' &&
github.repository == 'n8n-io/n8n' &&
github.event_name != 'merge_group' &&
(github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork)
uses: ./.github/workflows/test-evals-discovery.yml
with:
branch: ${{ needs.install-and-build.outputs.commit_sha }}
secrets: inherit
# This job is required by GitHub branch protection rules.
# PRs cannot be merged unless this job passes.
required-checks:
name: Required Checks
needs:
[
install-and-build,
unit-test,
typecheck,
lint,
check-packaging,
e2e,
dev-server-smoke,
db-tests,
performance,
security-checks,
workflow-scripts,
]
if: always()
runs-on: ubuntu-slim
steps:
- uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1
with:
sparse-checkout: .github/actions/ci-filter
sparse-checkout-cone-mode: false
- name: Validate required checks
uses: ./.github/actions/ci-filter
with:
mode: validate
job-results: ${{ toJSON(needs) }}
# A red `chore: Bundle/*` PR blocks publishing every fix batched into it, so the
# failure is announced instead of waiting to be noticed. Only fires in n8n-private,
# where bundle branches exist.
notify-bundle-pr-failure:
name: Notify Slack on bundle PR failure
needs: required-checks
if: >-
always() &&
needs.required-checks.result == 'failure' &&
(github.head_ref == 'bundle/2.x' || github.head_ref == 'bundle/1.x')
runs-on: ubuntu-slim
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
sparse-checkout: .github/scripts/slack
sparse-checkout-cone-mode: false
- name: Notify Slack
env:
SLACK_TOKEN: ${{ secrets.QBOT_SLACK_TOKEN }}
HEAD_REF: ${{ github.head_ref }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
node .github/scripts/slack/notify.mjs \
--channel '#alerts-build' \
--text "<${PR_URL}|Required Checks failed on the ${HEAD_REF} PR> - the batched fixes stay unpublished until it is green."
# Posts a QA metrics comparison comment on the PR.
# Runs after all checks so any job can emit metrics before this reports.
post-qa-metrics-comment:
name: QA Metrics
needs: [required-checks, e2e-performance]
# Only post when the perf suite actually measured this PR, otherwise the
# comment fires on trivial PRs and flags master drift/noise that isn't theirs.
if: always() && needs.e2e-performance.result == 'success'
uses: ./.github/workflows/util-qa-metrics-comment-reusable.yml
with:
metrics: memory-heap-used-baseline,memory-rss-baseline,instance-ai-heap-used-baseline,instance-ai-rss-baseline,agents-heap-used-baseline,agents-rss-baseline,docker-image-size-n8n,docker-image-size-runners,canvas-cold-load-S-ms,canvas-cold-load-M-ms,canvas-cold-load-L-ms,canvas-server-heap-M-mb,canvas-server-heap-L-mb,canvas-browser-heap-M-mb,canvas-browser-heap-L-mb,canvas-pan-frame-p95-M-ms,canvas-pan-frame-p95-L-ms,canvas-tidy-up-M-ms,canvas-tidy-up-L-ms,canvas-exec-heavy-M-ms,canvas-exec-heavy-L-ms
secrets: inherit