first commit
Some checks failed
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions

50
.github/workflows/backport.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
on:
pull_request:
types: [closed]
permissions:
contents: write
pull-requests: write
jobs:
backport:
if: github.event.pull_request.merged == true
runs-on: ubuntu-slim
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: npm install --prefix=.github/scripts --no-package-lock
- name: Compute backport targets
id: targets
run: node .github/scripts/compute-backport-targets.mjs
- name: Backport
if: steps.targets.outputs.target_branches != ''
uses: korthout/backport-action@c656f5d5851037b2b38fb5db2691a03fa229e3b2 # v4.0.1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
source_pr_number: ${{ github.event.pull_request.number }}
target_branches: ${{ steps.targets.outputs.target_branches }}
pull_description: |-
# Description
Backport of #${pull_number} to `${target_branch}`
# Original description
${pull_description}
pull_title: ${pull_title} (backport to ${target_branch})
add_author_as_assignee: true
copy_assignees: true
copy_requested_reviewers: true
copy_labels_pattern: '^(?!Backport to\b).+' # Copy everything except backport labels
experimental: >
{
"conflict_resolution": "draft_commit_conflicts"
}

71
.github/workflows/build-base-image.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: 'Build: Base Image'
on:
push:
branches:
- master
paths:
- 'docker/images/n8n-base/Dockerfile'
- '.github/workflows/build-base-image.yml'
pull_request:
paths:
- 'docker/images/n8n-base/Dockerfile'
- '.github/workflows/build-base-image.yml'
workflow_dispatch:
inputs:
push:
description: 'Push to registries'
required: false
default: false
type: boolean
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node_version: ['22', '24.13.1', '25']
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Login to DHI Registry (for pulling base images)
uses: ./.github/actions/docker-registry-login
with:
login-ghcr: 'false'
login-dhi: 'true'
dockerhub-username: ${{ secrets.DOCKER_USERNAME }}
dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to Docker registries (for pushing)
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true)
uses: ./.github/actions/docker-registry-login
with:
login-ghcr: 'true'
login-dockerhub: 'true'
dockerhub-username: ${{ secrets.DOCKER_USERNAME }}
dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./docker/images/n8n-base/Dockerfile
build-args: |
NODE_VERSION=${{ matrix.node_version }}
platforms: linux/amd64,linux/arm64
provenance: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true) }}
sbom: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true) }}
push: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true) }}
tags: |
${{ secrets.DOCKER_USERNAME }}/base:${{ matrix.node_version }}-${{ github.sha }}
${{ secrets.DOCKER_USERNAME }}/base:${{ matrix.node_version }}
ghcr.io/${{ github.repository_owner }}/base:${{ matrix.node_version }}-${{ github.sha }}
ghcr.io/${{ github.repository_owner }}/base:${{ matrix.node_version }}
no-cache: true

View File

@@ -0,0 +1,41 @@
name: 'Build: Benchmark Image'
on:
workflow_dispatch:
push:
branches:
- master
paths:
- 'packages/@n8n/benchmark/**'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/workflows/build-benchmark-image.yml'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Set up QEMU
uses: docker/setup-qemu-action@53851d14592bedcffcf25ea515637cff71ef929a # v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3.8.0
- name: Login to GitHub Container Registry
uses: ./.github/actions/docker-registry-login
- name: Build
uses: docker/build-push-action@b32b51a8eda65d6793cd0494a773d4f6bcef32dc # v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with:
context: .
file: ./packages/@n8n/benchmark/Dockerfile
platforms: linux/amd64
provenance: false
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/n8n-benchmark:latest

View File

@@ -0,0 +1,100 @@
name: 'Build: Unit Test PR Comment'
on:
issue_comment:
types: [created]
permissions:
pull-requests: read
contents: read
actions: write
issues: write
jobs:
validate_and_dispatch:
name: Validate user and dispatch CI workflow
if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/build-unit-test')
runs-on: ubuntu-latest
steps:
- name: Validate user permissions and collect PR data
id: check_permissions
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const commenter = context.actor;
const body = (context.payload.comment.body || '').trim();
const isCommand = body.startsWith('/build-unit-test');
const allowedPermissions = ['admin', 'write', 'maintain'];
const commentId = context.payload.comment.id;
const { owner, repo } = context.repo;
async function react(content) {
try {
await github.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: commentId,
content,
});
} catch (error) {
console.log(`Failed to add reaction '${content}': ${error.message}`);
}
}
core.setOutput('proceed', 'false');
core.setOutput('headSha', '');
core.setOutput('prNumber', '');
if (!context.payload.issue.pull_request || !isCommand) {
console.log('Comment is not /build-unit-test on a pull request. Skipping.');
return;
}
let permission;
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: commenter,
});
permission = data.permission;
} catch (error) {
console.log(`Could not verify permissions for @${commenter}: ${error.message}`);
await react('confused');
return;
}
if (!allowedPermissions.includes(permission)) {
console.log(`User @${commenter} has '${permission}' permission; requires admin/write/maintain.`);
await react('-1');
return;
}
try {
const prNumber = context.issue.number;
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
});
await react('+1');
core.setOutput('proceed', 'true');
core.setOutput('headSha', pr.head.sha);
core.setOutput('prNumber', String(prNumber));
} catch (error) {
console.log(`Failed to fetch PR details for PR #${context.issue.number}: ${error.message}`);
await react('confused');
}
- name: Dispatch build/unit test workflow
if: ${{ steps.check_permissions.outputs.proceed == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ steps.check_permissions.outputs.headSha }}
PR_NUMBER: ${{ steps.check_permissions.outputs.prNumber }}
run: |
gh workflow run ci-manual-unit-tests.yml \
--repo "${{ github.repository }}" \
-f ref="${HEAD_SHA}" \
-f pr_number="${PR_NUMBER}"

96
.github/workflows/build-windows.yml vendored Normal file
View File

@@ -0,0 +1,96 @@
name: 'Build: Windows'
on:
workflow_dispatch:
inputs:
notify_on_failure:
description: 'Send Slack notification on build failure'
required: false
type: boolean
default: false
workflow_call:
inputs:
notify_on_failure:
description: 'Send Slack notification on build failure'
required: false
type: boolean
default: false
secrets:
QBOT_SLACK_TOKEN:
required: false
pull_request:
branches: [master]
paths:
- '**/package.json'
- '**/turbo.json'
- '.github/workflows/build-windows.yml'
- '.github/actions/setup-nodejs/**'
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js and Build
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm build
- name: Smoke test pnpm start -- -- --version
shell: pwsh
run: |
Write-Host "Running smoke test: pnpm start -- -- --version"
pnpm start -- -- --version
if ($LASTEXITCODE -ne 0) {
Write-Host "`n❌ Smoke test failed (exit code: $LASTEXITCODE)"
exit $LASTEXITCODE
}
Write-Host "`n✓ Smoke test passed"
- name: Send Slack notification on failure
if: failure() && inputs.notify_on_failure == true
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
with:
method: chat.postMessage
token: ${{ secrets.QBOT_SLACK_TOKEN }}
payload: |
{
"channel": "C035KBDA917",
"text": "🚨 Windows build failed for `${{ github.repository }}` on branch `${{ github.ref_name }}`",
"blocks": [
{
"type": "header",
"text": { "type": "plain_text", "text": "🚨 Windows Build Failed" }
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Repository:*\n<${{ github.server_url }}/${{ github.repository }}|${{ github.repository }}>" },
{ "type": "mrkdwn", "text": "*Branch:*\n`${{ github.ref_name }}`" },
{ "type": "mrkdwn", "text": "*Commit:*\n`${{ github.sha }}`" },
{ "type": "mrkdwn", "text": "*Trigger:*\n${{ github.event_name }}" }
]
},
{
"type": "section",
"text": { "type": "mrkdwn", "text": ":warning: *Cross-platform compatibility issue detected*\nThis likely indicates Unix-specific commands in package.json scripts or build configuration that don't work on Windows." }
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": ":github: View Workflow Run" },
"style": "danger",
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
]
}
]
}

View File

@@ -0,0 +1,111 @@
# Determines if conditions are met for running subsequent jobs on a Pull Request.
#
# !! IMPORTANT !!
# This workflow RELIES on being called from a parent workflow triggered by
# a `pull_request` or `pull_request_target` event. It uses `github.event`
# to access PR details.
#
# It checks if all the following conditions are TRUE:
# 1. The PR is NOT from a fork (i.e., it's an internal PR).
# 2. The PR has been approved by a maintainer (`is_pr_approved_by_maintainer`).
# 3. The PR's source branch does NOT match an excluded pattern.
# 4. The PR includes relevant file changes (`paths_filter_patterns`).
#
# It outputs `should_run` as 'true' if ALL conditions pass, 'false' otherwise.
name: 'CI: Check Eligibility'
on:
workflow_call:
inputs:
is_pr_approved_by_maintainer:
required: true
type: boolean
paths_filter_patterns:
description: "Path filter patterns for 'paths-filter-action'."
required: false
type: string
default: |
not_ignored:
- '!.devcontainer/**'
- '!.github/*'
- '!.github/scripts/*'
- '!.github/workflows/benchmark-*'
- '!.github/workflows/check-*'
- '!.vscode/**'
- '!docker/**'
- '!packages/@n8n/benchmark/**'
- '!packages/@n8n/task-runner-python/**'
- '!**/*.md'
excluded_source_branch_patterns:
description: 'Newline-separated list of glob patterns for source branches to EXCLUDE.'
required: false
type: string
default: |
release/*
master
outputs:
should_run:
description: "Outputs 'true' if all eligibility checks pass, otherwise 'false'."
value: ${{ jobs.evaluate_conditions.outputs.run_decision }}
jobs:
evaluate_conditions:
runs-on: ubuntu-latest
outputs:
run_decision: ${{ steps.evaluate.outputs.should_run }}
steps:
- name: Check out current commit
uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Determine changed files
uses: tomi/paths-filter-action@32c62f5ca100c1110406e3477d5b3ecef4666fec # v3.0.2
id: changed
with:
filters: ${{ inputs.paths_filter_patterns }}
predicate-quantifier: 'every'
- name: Evaluate Conditions & Set Output
id: evaluate
env:
IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
IS_APPROVED: ${{ inputs.is_pr_approved_by_maintainer }}
FILES_CHANGED: ${{ steps.changed.outputs.not_ignored == 'true' }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
EXCLUDED_PATTERNS: ${{ inputs.excluded_source_branch_patterns }}
run: |
if [[ "$IS_FORK" == "true" ]]; then
is_community="true"
else
is_community="false"
fi
source_branch_excluded="false"
while IFS= read -r pattern; do
# shellcheck disable=SC2053
if [[ -n "$pattern" && "$HEAD_REF" == $pattern ]]; then
source_branch_excluded="true"
break
fi
done <<< "$EXCLUDED_PATTERNS"
echo "--- Checking Conditions ---"
echo "Is NOT Community PR: $([[ "$is_community" == "false" ]] && echo true || echo false)"
echo "Files Changed: $FILES_CHANGED"
echo "Source Branch Excluded: $source_branch_excluded"
echo "Is Approved: $IS_APPROVED"
echo "-------------------------"
if [[ "$is_community" == "false" && \
"$FILES_CHANGED" == "true" && \
"$source_branch_excluded" == "false" && \
"$IS_APPROVED" == "true" ]]; then
echo "Decision: Conditions met. Setting should_run=true."
echo "should_run=true" >> "$GITHUB_OUTPUT"
else
echo "Decision: Conditions not met. Setting should_run=false."
echo "should_run=false" >> "$GITHUB_OUTPUT"
fi

21
.github/workflows/ci-check-pr-title.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: 'CI: Check PR Title'
on:
pull_request:
types:
- opened
- edited
- synchronize
branches:
- 'master'
- '1.x'
jobs:
check-pr-title:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate PR title
uses: n8n-io/validate-n8n-pull-request-title@c3b6fd06bda12eebd57a592c0cf3b747d5b73569 # v2.4.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,135 @@
name: 'CI: Manual Unit Tests'
on:
workflow_dispatch:
inputs:
ref:
description: Commit SHA or ref to check out
required: true
pr_number:
description: PR number (optional, for check reporting)
required: false
type: string
permissions:
contents: read
checks: write
jobs:
create-check-run:
name: Create Check Run
runs-on: ubuntu-latest
if: inputs.pr_number != ''
outputs:
check_run_id: ${{ steps.create.outputs.check_run_id }}
steps:
- name: Create pending check run on PR
id: create
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { data: checkRun } = await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'Build & Unit Tests - Checks',
head_sha: '${{ inputs.ref }}',
status: 'in_progress',
output: {
title: 'Build & Unit Tests - Checks',
summary: 'Running build, unit tests, and lint...'
}
});
core.setOutput('check_run_id', checkRun.id);
console.log(`Created check run ${checkRun.id} on commit ${{ inputs.ref }}`);
install-and-build:
name: Install & Build
runs-on: blacksmith-2vcpu-ubuntu-2204
env:
NODE_OPTIONS: '--max-old-space-size=6144'
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ inputs.ref }}
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
- name: Run format check
run: pnpm format:check
- name: Run typecheck
run: pnpm typecheck
unit-tests:
name: Unit tests
needs: install-and-build
uses: ./.github/workflows/test-unit-reusable.yml
with:
ref: ${{ inputs.ref }}
collectCoverage: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
BUILD_STATS_WEBHOOK_URL: ${{ secrets.BUILD_STATS_WEBHOOK_URL }}
BUILD_STATS_WEBHOOK_USER: ${{ secrets.BUILD_STATS_WEBHOOK_USER }}
BUILD_STATS_WEBHOOK_PASSWORD: ${{ secrets.BUILD_STATS_WEBHOOK_PASSWORD }}
lint:
name: Lint
needs: install-and-build
uses: ./.github/workflows/test-linting-reusable.yml
with:
ref: ${{ inputs.ref }}
post-build-unit-tests:
name: Build & Unit Tests - Checks
runs-on: ubuntu-latest
needs: [create-check-run, install-and-build, unit-tests, lint]
if: always()
steps:
- name: Update check run on PR (if triggered from PR comment)
if: inputs.pr_number != ''
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const checkRunId = '${{ needs.create-check-run.outputs.check_run_id }}';
if (!checkRunId) {
console.log('No check run ID found, skipping update');
return;
}
const buildResult = '${{ needs.install-and-build.result }}';
const testResult = '${{ needs.unit-tests.result }}';
const lintResult = '${{ needs.lint.result }}';
const conclusion = (buildResult === 'success' && testResult === 'success' && lintResult === 'success')
? 'success'
: 'failure';
const summary = `
**Build**: ${buildResult}
**Unit Tests**: ${testResult}
**Lint**: ${lintResult}
`;
await github.rest.checks.update({
owner: context.repo.owner,
repo: context.repo.repo,
check_run_id: parseInt(checkRunId),
status: 'completed',
conclusion: conclusion,
output: {
title: 'Build & Unit Tests - Checks',
summary: summary
}
});
console.log(`Updated check run ${checkRunId} with conclusion: ${conclusion}`);
- name: Fail if any job failed
if: needs.install-and-build.result == 'failure' || needs.unit-tests.result == 'failure' || needs.lint.result == 'failure'
run: exit 1

66
.github/workflows/ci-master.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: 'CI: Master (Build, Test, Lint)'
on:
push:
branches:
- master
- 1.x
paths-ignore:
- packages/@n8n/task-runner-python/**
jobs:
build-github:
name: Build for Github Cache
runs-on: ubuntu-latest
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
BUILD_STATS_WEBHOOK_URL: ${{ secrets.BUILD_STATS_WEBHOOK_URL }}
BUILD_STATS_WEBHOOK_USER: ${{ secrets.BUILD_STATS_WEBHOOK_USER }}
BUILD_STATS_WEBHOOK_PASSWORD: ${{ secrets.BUILD_STATS_WEBHOOK_PASSWORD }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
unit-test:
name: Unit tests
uses: ./.github/workflows/test-unit-reusable.yml
strategy:
fail-fast: false
matrix:
node-version: [22.x, 24.13.1, 25.x]
with:
ref: ${{ github.sha }}
nodeVersion: ${{ matrix.node-version }}
collectCoverage: ${{ matrix.node-version == '24.13.1' }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
BUILD_STATS_WEBHOOK_URL: ${{ secrets.BUILD_STATS_WEBHOOK_URL }}
BUILD_STATS_WEBHOOK_USER: ${{ secrets.BUILD_STATS_WEBHOOK_USER }}
BUILD_STATS_WEBHOOK_PASSWORD: ${{ secrets.BUILD_STATS_WEBHOOK_PASSWORD }}
lint:
name: Lint
uses: ./.github/workflows/test-linting-reusable.yml
with:
ref: ${{ github.sha }}
performance:
name: Performance
uses: ./.github/workflows/test-bench-reusable.yml
with:
ref: ${{ github.sha }}
notify-on-failure:
name: Notify Slack on failure
runs-on: ubuntu-latest
needs: [unit-test, lint, performance, build-github]
steps:
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure()
with:
status: ${{ job.status }}
channel: '#alerts-build'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: ${{ github.ref_name }} branch (build or test or lint) failed (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})

188
.github/workflows/ci-pull-requests.yml vendored Normal file
View File

@@ -0,0 +1,188 @@
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-2vcpu-ubuntu-2204' }}
env:
NODE_OPTIONS: '--max-old-space-size=6144'
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
BUILD_STATS_WEBHOOK_URL: ${{ secrets.BUILD_STATS_WEBHOOK_URL }}
BUILD_STATS_WEBHOOK_USER: ${{ secrets.BUILD_STATS_WEBHOOK_USER }}
BUILD_STATS_WEBHOOK_PASSWORD: ${{ secrets.BUILD_STATS_WEBHOOK_PASSWORD }}
outputs:
ci: ${{ fromJSON(steps.ci-filter.outputs.results).ci == 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 }}
db: ${{ fromJSON(steps.ci-filter.outputs.results).db == true }}
design_system: ${{ fromJSON(steps.ci-filter.outputs.results)['design-system'] == true }}
performance: ${{ fromJSON(steps.ci-filter.outputs.results).performance == true }}
commit_sha: ${{ steps.commit-sha.outputs.sha }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
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/**
unit:
**
!packages/@n8n/task-runner-python/**
!packages/testing/playwright/**
!.github/**
e2e:
.github/workflows/test-e2e-*.yml
.github/scripts/cleanup-ghcr-images.mjs
packages/testing/playwright/**
packages/testing/containers/**
workflows: .github/**
design-system:
packages/frontend/@n8n/design-system/**
packages/frontend/@n8n/chat/**
packages/frontend/@n8n/storybook/**
.github/workflows/test-visual-chromatic.yml
performance:
packages/testing/performance/**
packages/workflow/src/**
.github/workflows/test-bench-reusable.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/@n8n/db/**
packages/cli/**/__tests__/**
packages/testing/containers/services/postgres.ts
.github/workflows/test-db-reusable.yml
- name: Setup and Build
if: fromJSON(steps.ci-filter.outputs.results).ci
uses: ./.github/actions/setup-nodejs
- name: Run format check
if: fromJSON(steps.ci-filter.outputs.results).ci
run: pnpm format:check
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: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
BUILD_STATS_WEBHOOK_URL: ${{ secrets.BUILD_STATS_WEBHOOK_URL }}
BUILD_STATS_WEBHOOK_USER: ${{ secrets.BUILD_STATS_WEBHOOK_USER }}
BUILD_STATS_WEBHOOK_PASSWORD: ${{ secrets.BUILD_STATS_WEBHOOK_PASSWORD }}
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
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm typecheck
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 }}
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'
uses: ./.github/workflows/test-e2e-ci-reusable.yml
with:
branch: ${{ needs.install-and-build.outputs.commit_sha }}
playwright-only: ${{ needs.install-and-build.outputs.e2e == 'true' && needs.install-and-build.outputs.unit == 'false' }}
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 }}
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 }}
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
chromatic:
name: Chromatic
needs: install-and-build
if: needs.install-and-build.outputs.design_system == 'true'
uses: ./.github/workflows/test-visual-chromatic.yml
with:
ref: ${{ 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, e2e-tests, db-tests, performance, security-checks, chromatic]
if: always()
runs-on: ubuntu-slim
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
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) }}

56
.github/workflows/ci-python.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: 'CI: Python'
on:
pull_request:
paths:
- packages/@n8n/task-runner-python/**
- .github/workflows/ci-python.yml
push:
paths:
- packages/@n8n/task-runner-python/**
jobs:
checks:
name: Checks
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/@n8n/task-runner-python
steps:
- name: Check out project
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install uv
uses: astral-sh/setup-uv@d9e0f98d3fc6adb07d1e3d37f3043649ddad06a1 # 6.5.0
with:
enable-cache: true
- name: Install just
uses: extractions/setup-just@e33e0265a09d6d736e2ee1e0eb685ef1de4669ff # v3.0.0
- name: Install Python
run: uv python install 3.13
- name: Install project dependencies
run: just sync-all
- name: Format check
run: just format-check
- name: Typecheck
run: just typecheck
- name: Lint
run: just lint
- name: Python unit tests
run: uv run pytest --cov=src --cov-report=xml --cov-report=term-missing
- name: Upload coverage to Codecov
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/@n8n/task-runner-python/coverage.xml
flags: tests
name: task-runner-python
fail_ci_if_error: false

View File

@@ -0,0 +1,84 @@
name: 'CI: Check merge source and destination'
on:
pull_request:
branches:
- master
permissions:
pull-requests: write
contents: read
jobs:
check_branch:
if: ${{ github.repository == 'n8n-io/n8n-private' }}
name: enforce-bundle-branches-only-in-private
runs-on: ubuntu-latest
steps:
- name: Validate head branch
id: validate
shell: bash
run: |
set -euo pipefail
head="${{ github.head_ref }}"
if [[ "$head" == bundle/* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"
fi
- name: Comment on PR (blocked)
if: ${{ steps.validate.outputs.allowed == 'false' }}
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = context.payload.pull_request.number;
const head = context.payload.pull_request.head.ref;
const base = context.payload.pull_request.base.ref;
const marker = "<!-- bundle-branch-only -->";
const body =
`${marker}\n` +
`🚫 **Merge blocked**: PRs into \`${base}\` are only allowed from branches named \`bundle/*\`.\n\n` +
`Current source branch: \`${head}\`\n\n` +
`Merge your developments into a bundle branch instead of directly merging to master.`;
// Find an existing marker comment (to update instead of spamming)
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number,
per_page: 100,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
- name: Fail (blocked)
if: ${{ steps.validate.outputs.allowed == 'false' }}
run: |
echo "::error::You can only merge to master from a bundle/* branch. Got '${{ github.head_ref }}'."
exit 1
- name: Allowed
if: ${{ steps.validate.outputs.allowed == 'true' }}
run: |
echo "OK: '${{ github.head_ref }}' can merge into '${{ github.base_ref }}'"

View File

@@ -0,0 +1,69 @@
name: Create Branch For Patch Release
on:
workflow_dispatch:
inputs:
commit_shas:
description: 'Comma-separated commit SHAs'
required: true
old_version:
description: 'Old version to be patched'
required: true
default: '1.0.0'
new_version:
description: 'The new patch version'
required: true
default: '1.0.1'
resumeUrl:
description: 'n8n workflow resume URL'
required: true
jobs:
create-branch:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Validate inputs
run: |
if ! [[ "${{ inputs.old_version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]]; then
echo "Invalid old version format: ${{ inputs.old_version }}"
exit 1
fi
if ! [[ "${{ inputs.new_version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]]; then
echo "Invalid new version format: ${{ inputs.new_version }}"
exit 1
fi
- name: Notify if inputs are invalid
if: ${{ failure() }}
run: |
curl -X POST -H "Content-Type: application/json" -d '{ "success": false, "message": "The old or new version you provided is invalid, make sure they both follow the SemVer format" }' ${{ inputs.resumeUrl }}
exit 1
- name: Setup, cherry-pick and push branch
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git switch "n8n@${{ inputs.old_version }}" --detach
BRANCH="patch/${{ inputs.new_version }}"
git checkout -b "$BRANCH"
IFS=',' read -ra SHAS <<< "${{ inputs.commit_shas }}"
for sha in "${SHAS[@]}"; do
sha=$(echo "$sha" | xargs)
if ! git merge-base --is-ancestor "$sha" HEAD; then
echo "Cherry-picking commit $sha"
git cherry-pick "$sha"
else
echo "Commit $sha is already in the branch, skipping"
fi
done
git push -f origin "$BRANCH"
- name: Notify if cherry-pick is successful
if: ${{ success() }}
run: |
curl -X POST -H "Content-Type: application/json" -d '{ "success": true }' ${{ inputs.resumeUrl }}
- name: Notify if cherry-pick is not successful
if: ${{ failure() }}
run: |
curl -X POST -H "Content-Type: application/json" -d '{ "success": false, "message": "There was a conflict when trying to create the branch, please do the cherry-pick and resolve the conflicts manually or do not include the PRs that caused the conflict" }' ${{ inputs.resumeUrl }}

422
.github/workflows/docker-build-push.yml vendored Normal file
View File

@@ -0,0 +1,422 @@
# This workflow is used to build and push the Docker image for n8nio/n8n and n8nio/runners
#
# - Uses docker-config.mjs for context determination, this determines what needs to be built based on the trigger
# - Uses docker-tags.mjs for tag generation, this generates the tags for the images
name: 'Docker: Build and Push'
env:
NODE_OPTIONS: '--max-old-space-size=7168'
NODE_VERSION: '24.13.1'
on:
schedule:
- cron: '0 0 * * *'
workflow_call:
inputs:
n8n_version:
description: 'N8N version to build'
required: true
type: string
release_type:
description: 'Release type (stable, nightly, dev)'
required: false
type: string
default: 'stable'
push_enabled:
description: 'Whether to push the built images'
required: false
type: boolean
default: true
workflow_dispatch:
inputs:
push_enabled:
description: 'Push image to registry'
required: false
type: boolean
default: true
success_url:
description: 'URL to call after the build is successful'
required: false
type: string
jobs:
determine-build-context:
name: Determine Build Context
runs-on: ubuntu-latest
outputs:
release_type: ${{ steps.context.outputs.release_type }}
n8n_version: ${{ steps.context.outputs.version }}
push_enabled: ${{ steps.context.outputs.push_enabled }}
push_to_docker: ${{ steps.context.outputs.push_to_docker }}
build_matrix: ${{ steps.context.outputs.build_matrix }}
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Determine build context
id: context
run: |
node .github/scripts/docker/docker-config.mjs \
--event "${{ github.event_name }}" \
--pr "${{ github.event.pull_request.number }}" \
--branch "${{ github.ref_name }}" \
--version "${{ inputs.n8n_version }}" \
--release-type "${{ inputs.release_type }}" \
--push-enabled "${{ inputs.push_enabled }}"
build-and-push-docker:
name: Build App, then Build and Push Docker Image (${{ matrix.platform }})
needs: determine-build-context
runs-on: ${{ matrix.runner }}
timeout-minutes: 25
strategy:
matrix: ${{ fromJSON(needs.determine-build-context.outputs.build_matrix) }}
outputs:
image_ref: ${{ steps.determine-tags.outputs.n8n_primary_tag }}
primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.n8n_primary_tag }}
runners_primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.runners_primary_tag }}
runners_distroless_primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.runners_distroless_primary_tag }}
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm build:n8n
enable-docker-cache: 'true'
env:
RELEASE: ${{ needs.determine-build-context.outputs.n8n_version }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Determine Docker tags for all images
id: determine-tags
run: |
node .github/scripts/docker/docker-tags.mjs \
--all \
--version "${{ needs.determine-build-context.outputs.n8n_version }}" \
--platform "${{ matrix.docker_platform }}" \
${{ needs.determine-build-context.outputs.push_to_docker == 'true' && '--include-docker' || '' }}
echo "=== Generated Docker Tags ==="
cat "$GITHUB_OUTPUT" | grep "_tags=" | while IFS='=' read -r key value; do
echo "${key}: ${value%%,*}..." # Show first tag for brevity
done
- name: Login to Docker registries
if: needs.determine-build-context.outputs.push_enabled == 'true'
uses: ./.github/actions/docker-registry-login
with:
login-ghcr: true
login-dockerhub: ${{ needs.determine-build-context.outputs.push_to_docker == 'true' }}
dockerhub-username: ${{ secrets.DOCKER_USERNAME }}
dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push n8n Docker image
id: build-n8n
uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2
with:
context: .
file: ./docker/images/n8n/Dockerfile
build-args: |
NODE_VERSION=${{ env.NODE_VERSION }}
N8N_VERSION=${{ needs.determine-build-context.outputs.n8n_version }}
N8N_RELEASE_TYPE=${{ needs.determine-build-context.outputs.release_type }}
platforms: ${{ matrix.docker_platform }}
provenance: false # Disabled - using SLSA L3 generator for isolated provenance
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-tags.outputs.n8n_tags }}
- name: Build and push task runners Docker image (Alpine)
id: build-runners
uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2
with:
context: .
file: ./docker/images/runners/Dockerfile
build-args: |
NODE_VERSION=${{ env.NODE_VERSION }}
N8N_VERSION=${{ needs.determine-build-context.outputs.n8n_version }}
N8N_RELEASE_TYPE=${{ needs.determine-build-context.outputs.release_type }}
platforms: ${{ matrix.docker_platform }}
provenance: false # Disabled - using SLSA L3 generator for isolated provenance
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-tags.outputs.runners_tags }}
- name: Build and push task runners Docker image (distroless)
id: build-runners-distroless
uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2
with:
context: .
file: ./docker/images/runners/Dockerfile.distroless
build-args: |
NODE_VERSION=${{ env.NODE_VERSION }}
N8N_VERSION=${{ needs.determine-build-context.outputs.n8n_version }}
N8N_RELEASE_TYPE=${{ needs.determine-build-context.outputs.release_type }}
platforms: ${{ matrix.docker_platform }}
provenance: false # Disabled - using SLSA L3 generator for isolated provenance
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-tags.outputs.runners_distroless_tags }}
create_multi_arch_manifest:
name: Create Multi-Arch Manifest
needs: [determine-build-context, build-and-push-docker]
runs-on: ubuntu-latest
if: |
needs.build-and-push-docker.result == 'success' &&
needs.determine-build-context.outputs.push_enabled == 'true'
outputs:
n8n_digest: ${{ steps.get-digests.outputs.n8n_digest }}
n8n_image: ${{ steps.get-digests.outputs.n8n_image }}
runners_digest: ${{ steps.get-digests.outputs.runners_digest }}
runners_image: ${{ steps.get-digests.outputs.runners_image }}
runners_distroless_digest: ${{ steps.get-digests.outputs.runners_distroless_digest }}
runners_distroless_image: ${{ steps.get-digests.outputs.runners_distroless_image }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Login to Docker registries
uses: ./.github/actions/docker-registry-login
with:
login-ghcr: true
login-dockerhub: ${{ needs.determine-build-context.outputs.push_to_docker == 'true' }}
dockerhub-username: ${{ secrets.DOCKER_USERNAME }}
dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create GHCR multi-arch manifests
run: |
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
# Function to create manifest for an image
create_manifest() {
local IMAGE_NAME=$1
local MANIFEST_TAG=$2
if [[ -z "$MANIFEST_TAG" ]]; then
echo "Skipping $IMAGE_NAME - no manifest tag"
return
fi
echo "Creating GHCR manifest for $IMAGE_NAME: $MANIFEST_TAG"
# For branch builds, only AMD64 is built
if [[ "$RELEASE_TYPE" == "branch" ]]; then
docker buildx imagetools create \
--tag "$MANIFEST_TAG" \
"${MANIFEST_TAG}-amd64"
else
docker buildx imagetools create \
--tag "$MANIFEST_TAG" \
"${MANIFEST_TAG}-amd64" \
"${MANIFEST_TAG}-arm64"
fi
}
# Create manifests for all images
create_manifest "n8n" "${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}"
create_manifest "runners" "${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}"
create_manifest "runners-distroless" "${{ needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag }}"
- name: Create Docker Hub manifests
if: needs.determine-build-context.outputs.push_to_docker == 'true'
run: |
VERSION="${{ needs.determine-build-context.outputs.n8n_version }}"
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}"
# Create manifests for each image type
declare -A images=(
["n8n"]="${VERSION}"
["runners"]="${VERSION}"
["runners-distroless"]="${VERSION}-distroless"
)
for image in "${!images[@]}"; do
TAG_SUFFIX="${images[$image]}"
IMAGE_NAME="${image//-distroless/}" # Remove -distroless from image name
echo "Creating Docker Hub manifest for $image"
docker buildx imagetools create \
--tag "${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}" \
"${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}-amd64" \
"${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}-arm64"
done
- name: Get manifest digests for attestation
id: get-digests
env:
N8N_TAG: ${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}
RUNNERS_TAG: ${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}
DISTROLESS_TAG: ${{ needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag }}
run: node .github/scripts/docker/get-manifest-digests.mjs
call-success-url:
name: Call Success URL
needs: [create_multi_arch_manifest]
runs-on: ubuntu-latest
if: needs.create_multi_arch_manifest.result == 'success' || needs.create_multi_arch_manifest.result == 'skipped'
steps:
- name: Call Success URL
env:
SUCCESS_URL: ${{ github.event.inputs.success_url }}
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.success_url != '' }}
run: |
echo "Calling success URL: ${{ env.SUCCESS_URL }}"
curl -v "${{ env.SUCCESS_URL }}" || echo "Failed to call success URL"
shell: bash
# SLSA L3 Provenance - Must use version tags (@vX.Y.Z), NOT SHAs
provenance-n8n:
name: SLSA Provenance (n8n)
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest]
if: |
needs.create_multi_arch_manifest.result == 'success' &&
needs.create_multi_arch_manifest.outputs.n8n_digest != ''
permissions:
id-token: write
packages: write
actions: read
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0
with:
image: ${{ needs.create_multi_arch_manifest.outputs.n8n_image }}
digest: ${{ needs.create_multi_arch_manifest.outputs.n8n_digest }}
registry-username: ${{ github.actor }}
secrets:
registry-password: ${{ secrets.GITHUB_TOKEN }}
provenance-runners:
name: SLSA Provenance (runners)
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest]
if: |
needs.create_multi_arch_manifest.result == 'success' &&
needs.create_multi_arch_manifest.outputs.runners_digest != ''
permissions:
id-token: write
packages: write
actions: read
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0
with:
image: ${{ needs.create_multi_arch_manifest.outputs.runners_image }}
digest: ${{ needs.create_multi_arch_manifest.outputs.runners_digest }}
registry-username: ${{ github.actor }}
secrets:
registry-password: ${{ secrets.GITHUB_TOKEN }}
provenance-runners-distroless:
name: SLSA Provenance (runners-distroless)
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest]
if: |
needs.create_multi_arch_manifest.result == 'success' &&
needs.create_multi_arch_manifest.outputs.runners_distroless_digest != ''
permissions:
id-token: write
packages: write
actions: read
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0
with:
image: ${{ needs.create_multi_arch_manifest.outputs.runners_distroless_image }}
digest: ${{ needs.create_multi_arch_manifest.outputs.runners_distroless_digest }}
registry-username: ${{ github.actor }}
secrets:
registry-password: ${{ secrets.GITHUB_TOKEN }}
# VEX Attestation - Documents which CVEs affect us (security/vex.openvex.json)
vex-attestation:
name: VEX Attestation
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest, provenance-n8n, provenance-runners, provenance-runners-distroless]
if: |
always() &&
needs.create_multi_arch_manifest.result == 'success' &&
(needs.determine-build-context.outputs.release_type == 'stable' ||
needs.determine-build-context.outputs.release_type == 'rc' ||
needs.determine-build-context.outputs.release_type == 'nightly')
runs-on: ubuntu-latest
permissions:
id-token: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Cosign
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
- name: Login to GHCR
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Attest VEX to n8n image
if: needs.create_multi_arch_manifest.outputs.n8n_digest != ''
run: |
cosign attest --yes \
--type openvex \
--predicate security/vex.openvex.json \
${{ needs.create_multi_arch_manifest.outputs.n8n_image }}@${{ needs.create_multi_arch_manifest.outputs.n8n_digest }}
- name: Attest VEX to runners image
if: needs.create_multi_arch_manifest.outputs.runners_digest != ''
run: |
cosign attest --yes \
--type openvex \
--predicate security/vex.openvex.json \
${{ needs.create_multi_arch_manifest.outputs.runners_image }}@${{ needs.create_multi_arch_manifest.outputs.runners_digest }}
- name: Attest VEX to runners-distroless image
if: needs.create_multi_arch_manifest.outputs.runners_distroless_digest != ''
run: |
cosign attest --yes \
--type openvex \
--predicate security/vex.openvex.json \
${{ needs.create_multi_arch_manifest.outputs.runners_distroless_image }}@${{ needs.create_multi_arch_manifest.outputs.runners_distroless_digest }}
security-scan:
name: Security Scan
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest]
if: |
success() &&
(needs.determine-build-context.outputs.release_type == 'stable' ||
needs.determine-build-context.outputs.release_type == 'nightly' ||
needs.determine-build-context.outputs.release_type == 'rc')
uses: ./.github/workflows/security-trivy-scan-callable.yml
with:
image_ref: ${{ needs.build-and-push-docker.outputs.image_ref }}
secrets: inherit
security-scan-runners:
name: Security Scan (runners)
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest]
if: |
success() &&
(needs.determine-build-context.outputs.release_type == 'stable' ||
needs.determine-build-context.outputs.release_type == 'nightly' ||
needs.determine-build-context.outputs.release_type == 'rc')
uses: ./.github/workflows/security-trivy-scan-callable.yml
with:
image_ref: ${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}
secrets: inherit
notify-on-failure:
name: Notify Cats on nightly build failure
runs-on: ubuntu-latest
needs: [build-and-push-docker]
if: needs.build-and-push-docker.result == 'failure' && github.event_name == 'schedule'
steps:
- uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
with:
status: ${{ needs.build-and-push-docker.result }}
channel: '#team-catalysts'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: Nightly Docker build failed - ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

View File

@@ -0,0 +1,15 @@
name: 'Release: Create Minor Release PR'
on:
workflow_dispatch:
# schedule:
# - cron: 0 13 * * 1
jobs:
create-release-pr:
name: Create release PR
uses: ./.github/workflows/release-create-pr.yml
secrets: inherit
with:
base-branch: master
release-type: minor

119
.github/workflows/release-create-pr.yml vendored Normal file
View File

@@ -0,0 +1,119 @@
name: 'Release: Create Pull Request'
on:
workflow_call:
inputs:
base-branch:
description: 'The branch, tag, or commit to create this release PR from.'
required: true
type: string
release-type:
description: 'A SemVer release type.'
required: true
type: string
workflow_dispatch:
inputs:
base-branch:
description: 'The branch, tag, or commit to create this release PR from.'
required: true
default: 'master'
release-type:
description: 'A SemVer release type.'
required: true
type: choice
default: 'minor'
options:
- patch
- minor
- major
- experimental
- premajor
jobs:
create-release-pr:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
timeout-minutes: 5
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
fetch-depth: 0
token: ${{ steps.generate_token.outputs.token }}
# Checkout base branch via separate step to prevent unsafe actions/checkout ref usage.
# poutine: untrusted_checkout_exec
- name: Switch to base branch
env:
BASE_BRANCH: ${{ inputs.base-branch }}
run: git checkout "$BASE_BRANCH"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.13.1
- run: npm install --prefix=.github/scripts --no-package-lock
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.33
corepack enable
- name: Bump package versions
run: |
echo "NEXT_RELEASE=$(node .github/scripts/bump-versions.mjs)" >> "$GITHUB_ENV"
env:
RELEASE_TYPE: ${{ inputs.release-type }}
- name: Update Changelog
run: node .github/scripts/update-changelog.mjs
- name: Push the base branch
env:
BASE_BRANCH: ${{ inputs.base-branch }}
run: |
git push -f origin "refs/remotes/origin/${{ env.BASE_BRANCH }}:refs/heads/release/${{ env.NEXT_RELEASE }}"
- name: Generate PR body
id: generate-body
run: |
set -e
CHANGELOG_FILE="CHANGELOG-${{ env.NEXT_RELEASE }}.md"
DELIMITER="EOF_$(uuidgen)"
if [ -f "${CHANGELOG_FILE}" ]; then
{
echo "content<<${DELIMITER}"
cat "${CHANGELOG_FILE}"
echo "${DELIMITER}"
} >> "$GITHUB_OUTPUT"
else
echo "content=No changelog generated. Likely points to fixes in our CI." >> "$GITHUB_OUTPUT"
fi
- name: Push the release branch, and Create the PR
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6
with:
token: ${{ steps.generate_token.outputs.token }}
base: 'release/${{ env.NEXT_RELEASE }}'
branch: 'release-pr/${{ env.NEXT_RELEASE }}'
commit-message: ':rocket: Release ${{ env.NEXT_RELEASE }}'
delete-branch: true
labels: release,release:${{ inputs.release-type }}
title: ':rocket: Release ${{ env.NEXT_RELEASE }}'
body: ${{ steps.generate-body.outputs.content }}

View File

@@ -0,0 +1,53 @@
name: 'Release: Publish: Post-release'
on:
workflow_call:
inputs:
track:
description: 'Release track acquired from determine-version-info. (e.g. stable, beta)'
required: true
type: string
version:
description: 'Release version acquired from determine-version-info. (e.g. 2.9.3, 1.123.23)'
required: true
type: string
bump:
description: 'Release bump size acquired from determine-version-info. (e.g. minor, patch)'
required: true
type: string
new_stable_version:
description: 'New stable version acquired from determine-version-info. (e.g. 2.9.3, null (on patch releases))'
required: true
type: string
release_type:
description: 'Release type acquired from determine-version-info. (stable or rc)'
required: true
type: string
jobs:
push-new-release-to-channel:
name: Push new release to channel
if: inputs.release_type != 'rc'
uses: ./.github/workflows/release-push-to-channel.yml
secrets: inherit
with:
version: ${{ inputs.version }}
release-channel: ${{ inputs.track }}
promote-previous-beta-to-stable:
name: Promote previous beta to stable
if: |
inputs.release_type != 'rc' &&
inputs.bump == 'minor'
uses: ./.github/workflows/release-push-to-channel.yml
secrets: inherit
with:
version: ${{ inputs.new_stable_version }}
release-channel: stable
ensure-release-candidate-branches:
name: 'Ensure release candidate branches'
if: |
inputs.release_type != 'rc'
uses: ./.github/workflows/util-ensure-release-candidate-branches.yml
secrets: inherit

213
.github/workflows/release-publish.yml vendored Normal file
View File

@@ -0,0 +1,213 @@
name: 'Release: Publish'
on:
pull_request:
types:
- closed
branches:
- 'release/*'
jobs:
determine-version-info:
name: Determine publishing track
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
outputs:
track: ${{ steps.determine-info.outputs.track }}
version: ${{ steps.determine-info.outputs.version }}
bump: ${{ steps.determine-info.outputs.bump }}
new_stable_version: ${{ steps.determine-info.outputs.new_stable_version }}
release_type: ${{ steps.determine-info.outputs.release_type }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: npm install --prefix=.github/scripts --no-package-lock
- name: Determine track from package version number
id: determine-info
run: node .github/scripts/determine-version-info.mjs
publish-to-npm:
name: Publish to NPM
needs: [determine-version-info]
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
timeout-minutes: 20
environment: npm
permissions:
id-token: write
env:
NPM_CONFIG_PROVENANCE: true
RELEASE: ${{ needs.determine-version-info.outputs.version }} # Used by Vite build process
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
env:
N8N_FAIL_ON_POPULARITY_FETCH_ERROR: true
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Check for new unpublished packages
run: node .github/scripts/detect-new-packages.mjs
- name: Dry-run publishing
run: |
pnpm --filter n8n publish --no-git-checks --dry-run
pnpm publish -r --filter '!n8n' --no-git-checks --dry-run
- name: Pre publishing changes
run: |
node .github/scripts/trim-fe-packageJson.js
node .github/scripts/ensure-provenance-fields.mjs
cp README.md packages/cli/README.md
sed -i "s/default: 'dev'/default: '${{ needs.determine-version-info.outputs.release_type }}'/g" packages/cli/dist/config/schema.js
- name: Publish n8n to NPM with rc tag
env:
PUBLISH_BRANCH: ${{ github.event.pull_request.base.ref }}
run: pnpm --filter n8n publish --publish-branch "$PUBLISH_BRANCH" --access public --tag rc --no-git-checks
- name: Publish other packages to NPM with latest tag
env:
PUBLISH_BRANCH: ${{ github.event.pull_request.base.ref }}
run: pnpm publish -r --filter '!n8n' --publish-branch "$PUBLISH_BRANCH" --access public --no-git-checks
- name: Cleanup rc tag
run: npm dist-tag rm n8n rc
continue-on-error: true
publish-to-docker-hub:
name: Publish to DockerHub
needs: [determine-version-info]
uses: ./.github/workflows/docker-build-push.yml
with:
n8n_version: ${{ needs.determine-version-info.outputs.version }}
release_type: ${{ needs.determine-version-info.outputs.release_type }}
secrets: inherit
create-github-release:
name: Create a GitHub Release
needs: [determine-version-info, publish-to-npm, publish-to-docker-hub]
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
timeout-minutes: 5
permissions:
contents: write
id-token: write
steps:
- name: Create a Release on GitHub
uses: ncipollo/release-action@1c89adf39833729d8f85a31ccbc451b078733c80 # v1
with:
commit: ${{github.event.pull_request.base.ref}}
tag: 'n8n@${{ needs.determine-version-info.outputs.version }}'
prerelease: ${{ needs.determine-version-info.outputs.track == 'beta' }}
makeLatest: ${{ needs.determine-version-info.outputs.track == 'stable' }}
body: ${{github.event.pull_request.body}}
move-track-tag:
name: Move track tag
needs: [determine-version-info, create-github-release]
if: github.event.pull_request.merged == true
uses: ./.github/workflows/release-update-pointer-tag.yml
with:
track: ${{ needs.determine-version-info.outputs.track }}
version-tag: 'n8n@${{ needs.determine-version-info.outputs.version }}'
secrets: inherit
promote-stable-tag:
name: Promote stable tag (minor bump)
needs: [determine-version-info, create-github-release]
if: |
github.event.pull_request.merged == true &&
needs.determine-version-info.outputs.new_stable_version != ''
uses: ./.github/workflows/release-update-pointer-tag.yml
with:
track: stable
version-tag: 'n8n@${{ needs.determine-version-info.outputs.new_stable_version }}'
secrets: inherit
generate-and-attach-sbom:
name: Generate and Attach SBOM to Release
needs: [determine-version-info, create-github-release]
uses: ./.github/workflows/sbom-generation-callable.yml
with:
n8n_version: ${{ needs.determine-version-info.outputs.version }}
release_tag_ref: 'n8n@${{ needs.determine-version-info.outputs.version }}'
secrets: inherit
merge-release-tag-to-master:
name: Merge release tag to master
needs: [determine-version-info, publish-to-npm, create-github-release]
if: |
github.event.pull_request.merged == true &&
needs.determine-version-info.outputs.bump == 'minor' &&
needs.determine-version-info.outputs.release_type != 'rc'
runs-on: ubuntu-latest
environment: minor-release-tag-merge
env:
VERSION: ${{ needs.determine-version-info.outputs.version }}
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.RELEASE_TAG_MERGE_APP_ID }}
private-key: ${{ secrets.RELEASE_TAG_MERGE_PRIVATE_KEY }}
skip-token-revoke: false
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: master
fetch-depth: 500
token: ${{ steps.generate_token.outputs.token }}
- name: Verify release tag exists
run: |
if ! git ls-remote --tags origin "refs/tags/n8n@${VERSION}" | grep -q .; then
echo "::error::Tag n8n@${VERSION} not found on remote"
exit 1
fi
- name: Fetch release tag
run: git fetch origin "refs/tags/n8n@${VERSION}:refs/tags/n8n@${VERSION}"
- name: Merge release tag to master
run: |
git config user.name "n8n-release-tag-merge[bot]"
git config user.email "256767729+n8n-release-tag-merge[bot]@users.noreply.github.com"
git merge --ff "n8n@${VERSION}"
- name: Push to master
run: git push origin HEAD:master
- name: Notify Slack on failure
if: failure()
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
with:
status: ${{ job.status }}
channel: '#updates-and-product-releases'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: |
<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}| Release tag merge to master failed for n8n@${{ needs.determine-version-info.outputs.version }} >
post-release:
name: Run Post-release actions
needs: [determine-version-info, publish-to-npm, create-github-release]
uses: ./.github/workflows/release-publish-post-release.yml
with:
track: ${{ needs.determine-version-info.outputs.track }}
version: ${{ needs.determine-version-info.outputs.version }}
bump: ${{ needs.determine-version-info.outputs.bump }}
new_stable_version: ${{ needs.determine-version-info.outputs.new_stable_version }}
release_type: ${{ needs.determine-version-info.outputs.release_type }}
secrets: inherit

View File

@@ -0,0 +1,161 @@
name: 'Release: Push to Channel'
on:
workflow_call:
inputs:
version:
description: 'n8n Release version to push to a channel (e.g., 1.2.3 or 1.2.3-beta.4)'
required: true
type: string
release-channel:
description: 'Release channel'
required: true
type: string
workflow_dispatch:
inputs:
version:
description: 'n8n Release version to push to a channel (e.g., 1.2.3 or 1.2.3-beta.4)'
required: true
type: string
release-channel:
description: 'Release channel'
required: true
type: choice
default: 'beta'
options:
- beta
- stable
jobs:
validate-inputs:
name: Validate Inputs
runs-on: ubuntu-latest
outputs:
version: ${{ steps.check_version.outputs.version }}
release_channel: ${{ inputs.release-channel }}
steps:
- name: Check Version Format
id: check_version
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
input_version="${{ env.INPUT_VERSION }}"
version_regex='^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$'
if [[ "$input_version" =~ $version_regex ]]; then
echo "Version format is valid: $input_version"
echo "version=$input_version" >> "$GITHUB_OUTPUT"
else
echo "::error::Invalid version format provided: '$input_version'. Must match regex '$version_regex'."
exit 1
fi
- name: Block RC promotion to stable/beta
env:
INPUT_VERSION: ${{ inputs.version }}
CHANNEL: ${{ inputs.release-channel }}
run: |
if [[ "$INPUT_VERSION" == *"-rc."* ]]; then
echo "::error::RC versions cannot be promoted to '$CHANNEL' channel. Version '$INPUT_VERSION' contains '-rc.'"
exit 1
fi
echo "✅ Version '$INPUT_VERSION' is allowed for '$CHANNEL' channel"
release-to-npm:
name: Release to NPM
runs-on: ubuntu-latest
needs: validate-inputs
timeout-minutes: 5
permissions:
id-token: write
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.13.1
# Remove after https://github.com/npm/cli/issues/8547 gets resolved
- run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- name: Add beta/next tags to NPM
if: needs.validate-inputs.outputs.release_channel == 'beta'
run: |
npm dist-tag add "n8n@${{ needs.validate-inputs.outputs.version }}" next
npm dist-tag add "n8n@${{ needs.validate-inputs.outputs.version }}" beta
- name: Add latest/stable tags to NPM
if: needs.validate-inputs.outputs.release_channel == 'stable'
run: |
npm dist-tag add "n8n@${{ needs.validate-inputs.outputs.version }}" latest
npm dist-tag add "n8n@${{ needs.validate-inputs.outputs.version }}" stable
release-to-docker-hub:
name: Release to DockerHub
runs-on: ubuntu-latest
needs: validate-inputs
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Login to DockerHub
uses: ./.github/actions/docker-registry-login
with:
login-ghcr: false
login-dockerhub: true
dockerhub-username: ${{ secrets.DOCKER_USERNAME }}
dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}
- name: Tag stable/latest Docker image
if: needs.validate-inputs.outputs.release_channel == 'stable'
run: |
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/n8n:stable" "${{ secrets.DOCKER_USERNAME }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/n8n:latest" "${{ secrets.DOCKER_USERNAME }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/runners:stable" "${{ secrets.DOCKER_USERNAME }}/runners:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/runners:latest" "${{ secrets.DOCKER_USERNAME }}/runners:${{ needs.validate-inputs.outputs.version }}"
- name: Tag beta/next Docker image
if: needs.validate-inputs.outputs.release_channel == 'beta'
run: |
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/n8n:beta" "${{ secrets.DOCKER_USERNAME }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/n8n:next" "${{ secrets.DOCKER_USERNAME }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/runners:beta" "${{ secrets.DOCKER_USERNAME }}/runners:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/runners:next" "${{ secrets.DOCKER_USERNAME }}/runners:${{ needs.validate-inputs.outputs.version }}"
release-to-github-container-registry:
name: Release to GitHub Container Registry
runs-on: ubuntu-latest
needs: validate-inputs
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Login to GitHub Container Registry
uses: ./.github/actions/docker-registry-login
- name: Tag stable/latest GHCR image
if: needs.validate-inputs.outputs.release_channel == 'stable'
run: |
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/n8n:stable" "ghcr.io/${{ github.repository_owner }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/n8n:latest" "ghcr.io/${{ github.repository_owner }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/runners:stable" "ghcr.io/${{ github.repository_owner }}/runners:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/runners:latest" "ghcr.io/${{ github.repository_owner }}/runners:${{ needs.validate-inputs.outputs.version }}"
- name: Tag beta/next GHCR image
if: needs.validate-inputs.outputs.release_channel == 'beta'
run: |
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/n8n:beta" "ghcr.io/${{ github.repository_owner }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/n8n:next" "ghcr.io/${{ github.repository_owner }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/runners:beta" "ghcr.io/${{ github.repository_owner }}/runners:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/runners:next" "ghcr.io/${{ github.repository_owner }}/runners:${{ needs.validate-inputs.outputs.version }}"
update-docs:
name: Update latest and next in the docs
runs-on: ubuntu-latest
needs: [validate-inputs, release-to-npm, release-to-docker-hub]
steps:
- continue-on-error: true
run: curl -u docsWorkflows:${{ secrets.N8N_WEBHOOK_DOCS_PASSWORD }} --request GET 'https://internal.users.n8n.cloud/webhook/update-latest-next'

View File

@@ -0,0 +1,60 @@
name: 'Release: Standalone Package'
on:
workflow_dispatch:
inputs:
package:
description: 'Package to release'
required: true
type: choice
options:
- '@n8n/codemirror-lang'
- '@n8n/codemirror-lang-html'
- '@n8n/codemirror-lang-sql'
- '@n8n/create-node'
- '@n8n/eslint-plugin-community-nodes'
- '@n8n/node-cli'
- '@n8n/scan-community-package'
# All packages listed above require OIDC enabled in NPM. https://docs.npmjs.com/trusted-publishers
concurrency:
group: release-package-${{ github.event.inputs.package }}
cancel-in-progress: false
env:
CACHE_KEY: ${{ github.sha }}-${{ github.event.inputs.package }}-build
jobs:
publish-to-npm:
name: Publish to NPM
runs-on: ubuntu-latest
timeout-minutes: 15
environment: npm
permissions:
id-token: write
env:
NPM_CONFIG_PROVENANCE: true
steps:
- name: Check branch
if: github.ref != 'refs/heads/master'
run: |
echo "::error::This workflow can only be run from the master branch"
exit 1
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
with:
build-command: 'pnpm turbo build --filter "...${{ github.event.inputs.package }}"'
- name: Pre publishing changes
run: |
node .github/scripts/ensure-provenance-fields.mjs
- name: Publish package
env:
PACKAGE: ${{ github.event.inputs.package }}
run: pnpm --filter "$PACKAGE" publish --access public --no-git-checks --publish-branch master

View File

@@ -0,0 +1,66 @@
name: 'Release: Update pointer tag'
run-name: 'Update pointer tag: ${{ inputs.track }} -> ${{ inputs.version-tag }}'
on:
workflow_call:
inputs:
track:
required: true
type: string
version-tag:
required: true
type: string
workflow_dispatch:
inputs:
track:
description: 'Release Track'
required: true
type: choice
options: [stable, beta, v1]
version-tag:
description: 'Version tag (e.g. n8n@2.7.0). Track tag will point to this version tag.'
required: true
type: string
permissions:
contents: write
jobs:
update-pointer-tags:
name: Update pointer tags
runs-on: ubuntu-slim
environment: minor-release-tag-merge
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.RELEASE_TAG_MERGE_APP_ID }}
private-key: ${{ secrets.RELEASE_TAG_MERGE_PRIVATE_KEY }}
skip-token-revoke: false
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
token: ${{ steps.generate_token.outputs.token }}
# We can manage with a shallow clone, since `ensureTagExists` in github-helpers.mjs
# does a targeted fetch for the tags it needs.
fetch-depth: 1
- name: Setup NodeJS
uses: ./.github/actions/setup-nodejs
with:
install-command: npm install --prefix=.github/scripts --no-package-lock
build-command: ''
- name: Configure git author
run: |
git config user.name "n8n-release-tag-merge[bot]"
git config user.email "256767729+n8n-release-tag-merge[bot]@users.noreply.github.com"
- name: Move track tag
env:
TRACK: ${{ inputs.track }}
VERSION_TAG: ${{ inputs.version-tag }}
run: node ./.github/scripts/move-track-tag.mjs

View File

@@ -0,0 +1,88 @@
name: 'Release: Attach SBOM'
on:
workflow_call:
inputs:
n8n_version:
description: 'N8N version to generate SBOM for'
required: true
type: string
release_tag_ref:
description: 'Git reference to checkout (e.g. n8n@1.2.3)'
required: true
type: string
secrets:
SLACK_WEBHOOK_URL:
required: true
workflow_dispatch:
inputs:
n8n_version:
description: 'N8N version to generate SBOM for'
required: true
type: string
release_tag_ref:
description: 'Git reference to checkout (e.g. n8n@1.2.3)'
required: true
type: string
permissions:
contents: write
id-token: write
attestations: write
jobs:
generate-sbom:
name: Generate and Attach SBOM to Release
runs-on: ubuntu-latest
timeout-minutes: 15
continue-on-error: true
steps:
- name: Checkout release tag
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ inputs.release_tag_ref }}
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
- name: Generate CycloneDX SBOM for source code
uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6
with:
path: ./
format: cyclonedx-json
output-file: sbom-source.cdx.json
- name: Attest SBOM for source release
uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3.0.0
with:
subject-path: './package.json'
sbom-path: 'sbom-source.cdx.json'
- name: Attach SBOM and VEX files to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Upload SBOM and VEX files to the existing release
gh release upload "${{ inputs.release_tag_ref }}" \
sbom-source.cdx.json \
security/vex.openvex.json \
--clobber
COMPONENT_COUNT=$(jq '.components | length' sbom-source.cdx.json 2>/dev/null || echo "unknown")
VEX_STATEMENTS=$(jq '.statements | length' security/vex.openvex.json 2>/dev/null || echo "0")
echo "SBOM and VEX attached to release"
echo " - SBOM: $COMPONENT_COUNT components"
echo " - VEX: $VEX_STATEMENTS CVE statements"
- name: Notify Slack on failure
if: failure()
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
with:
status: ${{ job.status }}
channel: '#alerts-build'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: |
<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}| SBOM generation and attachment failed for release ${{ inputs.release_tag_ref }} >

23
.github/workflows/sec-ci-reusable.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: 'Sec: CI Checks'
on:
workflow_call:
inputs:
ref:
description: GitHub ref to scan.
required: false
type: string
default: ''
jobs:
poutine-scan:
name: Poutine Security Scan
uses: ./.github/workflows/sec-poutine-reusable.yml
with:
ref: ${{ inputs.ref }}
secrets: inherit
# Future security checks can be added here:
# - dependency-scan:
# - secret-detection:
# - container-scan:

View File

@@ -0,0 +1,44 @@
name: 'Sec: Poutine Scan'
on:
workflow_dispatch:
workflow_call:
inputs:
ref:
description: GitHub ref to scan.
required: false
type: string
default: ''
permissions:
contents: read
security-events: write
jobs:
poutine_scan:
name: Poutine Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.ref }}
- name: Run Poutine Security Scanner
uses: boostsecurityio/poutine-action@84c0a0d32e8d57ae12651222be1eb15351429228 # v0.15.2
- name: Fail on error-level findings
run: |
# Check SARIF for error-level findings
if jq -e '.runs[].results[] | select(.level == "error")' results.sarif > /dev/null 2>&1; then
echo "::error::Poutine found error-level security findings:"
jq -r '.runs[].results[] | select(.level == "error") | " - \(.ruleId): \(.message.text)"' results.sarif
exit 1
fi
echo "No error-level findings detected"
- name: Upload SARIF results
uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
if: github.repository == 'n8n-io/n8n'
with:
sarif_file: results.sarif

61
.github/workflows/sec-publish-fix.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: 'Security: Publish fix'
on:
pull_request:
types: [closed]
branches: [master]
jobs:
sync-security-fix:
if: github.repository == 'n8n-io/n8n-private' && github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
owner: n8n-io
repositories: n8n,n8n-private
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
token: ${{ steps.generate_token.outputs.token }}
- name: Open PR to public repo
run: |
COMMIT_TO_PUBLISH=$(git rev-parse HEAD)
BRANCH_NAME="private-$(date +%Y%m%d-%H%M%S)"
git remote add public-repo https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/n8n-io/n8n.git
git fetch public-repo master
git checkout -b "$BRANCH_NAME" public-repo/master
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git cherry-pick "$COMMIT_TO_PUBLISH"
git push public-repo "$BRANCH_NAME"
gh pr create \
--repo n8n-io/n8n \
--base master \
--head "$BRANCH_NAME" \
--title "$PR_TITLE" \
--body "Cherry-picked from n8n-private. Original PR: $PR_URL"
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_URL: ${{ github.event.pull_request.html_url }}
- name: Notify on failure
if: failure()
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
with:
status: ${{ job.status }}
channel: '#alerts-security'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: 'Security fix PR creation failed. Run "Security: Sync from Public" workflow, rebase your branch, reopen PR. (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})'

View File

@@ -0,0 +1,62 @@
# Sync n8n-io/n8n to n8n-io/n8n-private
#
# Runs hourly to keep private in sync with public.
# Can also be triggered manually for conflict recovery.
#
# Scheduled runs only sync if private is not ahead of public.
# Manual runs always sync (for conflict recovery after failed cherry-pick).
name: 'Security: Sync from Public'
on:
schedule:
- cron: '0 * * * *'
workflow_dispatch:
inputs:
force:
description: Sync even if private is ahead (for conflict recovery)
type: boolean
default: true
jobs:
sync-from-public:
if: github.repository == 'n8n-io/n8n-private'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Generate App Token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- name: Sync from public
run: |
git fetch https://github.com/n8n-io/n8n.git master:public-master
# Check if private is ahead of public
AHEAD_COUNT=$(git rev-list public-master..HEAD --count)
if [ "$AHEAD_COUNT" -gt 0 ]; then
if [ "${{ github.event_name }}" = "schedule" ]; then
echo "Private is $AHEAD_COUNT commit(s) ahead of public, skipping scheduled sync"
exit 0
elif [ "${{ inputs.force }}" != "true" ]; then
echo "Private is $AHEAD_COUNT commit(s) ahead of public, skipping (force not enabled)"
exit 0
else
echo "Private is $AHEAD_COUNT commit(s) ahead of public, force syncing anyway"
fi
fi
git reset --hard public-master
git push origin master --force-with-lease

View File

@@ -0,0 +1,349 @@
name: Security - Scan Docker Image With Trivy
on:
workflow_dispatch:
inputs:
image_ref:
description: 'Full image reference to scan e.g. ghcr.io/n8n-io/n8n:latest'
required: true
default: 'ghcr.io/n8n-io/n8n:latest'
workflow_call:
inputs:
image_ref:
type: string
description: 'Full image reference to scan e.g. ghcr.io/n8n-io/n8n:latest'
required: true
secrets:
QBOT_SLACK_TOKEN:
required: true
permissions:
contents: read
env:
QBOT_SLACK_TOKEN: ${{ secrets.QBOT_SLACK_TOKEN }}
SLACK_CHANNEL_ID: C0A6Y62BH9T #notify-security-scan-outputs
SLACK_CHANNEL_CRITICAL: C042WDXPTEZ #mission-security
jobs:
security_scan:
name: Security - Scan Docker Image With Trivy
runs-on: ubuntu-latest
steps:
- name: Checkout for VEX file
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
sparse-checkout: |
security/vex.openvex.json
security/trivy.yaml
security/trivy-ignore-policy.rego
sparse-checkout-cone-mode: false
- name: Pull Docker image with retry
run: |
for i in {1..4}; do
docker pull "${{ inputs.image_ref }}" && break
[ "$i" -lt 4 ] && echo "Retry $i failed, waiting..." && sleep 15
done
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # v0.34.1
id: trivy_scan
with:
image-ref: ${{ inputs.image_ref }}
version: 'v0.69.2'
format: 'json'
output: 'trivy-results.json'
severity: 'CRITICAL,HIGH,MEDIUM,LOW'
ignore-unfixed: false
exit-code: '0'
trivy-config: 'security/trivy.yaml'
- name: Calculate vulnerability counts
id: process_results
run: |
if [ ! -s trivy-results.json ] || [ "$(jq '.Results | length' trivy-results.json)" -eq 0 ]; then
echo "No vulnerabilities found."
echo "vulnerabilities_found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Calculate counts by severity
CRITICAL_COUNT=$(jq '([.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length)' trivy-results.json)
HIGH_COUNT=$(jq '([.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length)' trivy-results.json)
MEDIUM_COUNT=$(jq '([.Results[]?.Vulnerabilities[]? | select(.Severity == "MEDIUM")] | length)' trivy-results.json)
LOW_COUNT=$(jq '([.Results[]?.Vulnerabilities[]? | select(.Severity == "LOW")] | length)' trivy-results.json)
TOTAL_VULNS=$((CRITICAL_COUNT + HIGH_COUNT + MEDIUM_COUNT + LOW_COUNT))
# Get unique CVE count
UNIQUE_CVES=$(jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique | length' trivy-results.json)
# Get affected packages count
AFFECTED_PACKAGES=$(jq -r '[.Results[]?.Vulnerabilities[]? | .PkgName] | unique | length' trivy-results.json)
{
echo "vulnerabilities_found=$( [ "$TOTAL_VULNS" -gt 0 ] && echo 'true' || echo 'false' )"
echo "total_count=$TOTAL_VULNS"
echo "critical_count=$CRITICAL_COUNT"
echo "high_count=$HIGH_COUNT"
echo "medium_count=$MEDIUM_COUNT"
echo "low_count=$LOW_COUNT"
echo "unique_cves=$UNIQUE_CVES"
echo "affected_packages=$AFFECTED_PACKAGES"
} >> "$GITHUB_OUTPUT"
- name: Generate GitHub Job Summary
if: always()
run: |
{
echo "# 🛡️ Trivy Security Scan Results"
echo ""
echo "**Image:** \`${{ inputs.image_ref }}\`"
echo "**Scan Date:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [ ! -s trivy-results.json ]; then
{
echo "⚠️ **Scan did not produce results.** Check the 'Run Trivy vulnerability scanner' step for errors."
} >> "$GITHUB_STEP_SUMMARY"
elif [ "${{ steps.process_results.outputs.vulnerabilities_found }}" == "false" ]; then
{
echo "✅ **No vulnerabilities found!**"
} >> "$GITHUB_STEP_SUMMARY"
else
{
echo "## 📊 Summary"
echo "| Metric | Count |"
echo "|--------|-------|"
echo "| 🔴 Critical Vulnerabilities | ${{ steps.process_results.outputs.critical_count }} |"
echo "| 🟠 High Vulnerabilities | ${{ steps.process_results.outputs.high_count }} |"
echo "| 🟡 Medium Vulnerabilities | ${{ steps.process_results.outputs.medium_count }} |"
echo "| 🟡 Low Vulnerabilities | ${{ steps.process_results.outputs.low_count }} |"
echo "| 📋 Unique CVEs | ${{ steps.process_results.outputs.unique_cves }} |"
echo "| 📦 Affected Packages | ${{ steps.process_results.outputs.affected_packages }} |"
echo ""
echo "## 🚨 Top Vulnerabilities"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
{
# Generate detailed vulnerability table
jq -r --arg image_ref "${{ inputs.image_ref }}" '
# Collect all vulnerabilities
[.Results[] | select(.Vulnerabilities != null) | .Vulnerabilities[]] |
# Group by CVE ID to avoid duplicates
group_by(.VulnerabilityID) |
map({
cve: .[0].VulnerabilityID,
severity: .[0].Severity,
cvss: (.[0].CVSS.nvd.V3Score // "N/A"),
cvss_sort: (.[0].CVSS.nvd.V3Score // 0),
packages: [.[] | "\(.PkgName)@\(.InstalledVersion)"] | unique | join(", "),
fixed: (.[0].FixedVersion // "No fix available"),
description: (.[0].Description // "No description available") | split("\n")[0] | .[0:150]
}) |
# Sort by severity (CRITICAL, HIGH, MEDIUM, LOW) and CVSS score
sort_by(
if .severity == "CRITICAL" then 0
elif .severity == "HIGH" then 1
elif .severity == "MEDIUM" then 2
elif .severity == "LOW" then 3
else 4 end,
-.cvss_sort
) |
# Take top 15
.[:15] |
# Generate markdown table
"| CVE | Severity | CVSS | Package(s) | Fix Version | Description |",
"|-----|----------|------|------------|-------------|-------------|",
(.[] | "| [\(.cve)](https://nvd.nist.gov/vuln/detail/\(.cve)) | \(.severity) | \(.cvss) | `\(.packages)` | `\(.fixed)` | \(.description) |")
' trivy-results.json
echo ""
echo "---"
echo "🔍 **View detailed logs above for full analysis**"
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Generate Slack Blocks JSON
if: steps.process_results.outputs.vulnerabilities_found == 'true'
id: generate_blocks
run: |
BLOCKS_JSON=$(jq -c --arg image_ref "${{ inputs.image_ref }}" \
--arg repo_url "${{ github.server_url }}/${{ github.repository }}" \
--arg repo_name "${{ github.repository }}" \
--arg run_url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
--arg critical_count "${{ steps.process_results.outputs.critical_count }}" \
--arg high_count "${{ steps.process_results.outputs.high_count }}" \
--arg medium_count "${{ steps.process_results.outputs.medium_count }}" \
--arg low_count "${{ steps.process_results.outputs.low_count }}" \
--arg unique_cves "${{ steps.process_results.outputs.unique_cves }}" \
'
# Function to create a vulnerability block with emoji indicators
def vuln_block: {
"type": "section",
"text": {
"type": "mrkdwn",
"text": "\(if .Severity == "CRITICAL" then ":red_circle:" elif .Severity == "HIGH" then ":large_orange_circle:" elif .Severity == "MEDIUM" then ":large_yellow_circle:" else ":large_green_circle:" end) *<https://nvd.nist.gov/vuln/detail/\(.VulnerabilityID)|\(.VulnerabilityID)>* (CVSS: `\(.CVSS.nvd.V3Score // "N/A")`)\n*Package:* `\(.PkgName)@\(.InstalledVersion)` → `\(.FixedVersion // "No fix available")`"
}
};
# Main structure
[
{
"type": "header",
"text": { "type": "plain_text", "text": ":warning: Trivy Scan: Vulnerabilities Detected" }
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Repository:*\n<\($repo_url)|\($repo_name)>" },
{ "type": "mrkdwn", "text": "*Image:*\n`\($image_ref)`" },
{ "type": "mrkdwn", "text": "*Critical:*\n:red_circle: \($critical_count)" },
{ "type": "mrkdwn", "text": "*High:*\n:large_orange_circle: \($high_count)" },
{ "type": "mrkdwn", "text": "*Medium:*\n:large_yellow_circle: \($medium_count)" },
{ "type": "mrkdwn", "text": "*Low:*\n:large_green_circle: \($low_count)" }
]
},
{
"type": "context",
"elements": [
{ "type": "mrkdwn", "text": ":shield: \($unique_cves) unique CVEs affecting packages" }
]
},
{ "type": "divider" }
] +
(
# Group vulnerabilities by CVE to avoid duplicates in notification
[.Results[] | select(.Vulnerabilities != null) | .Vulnerabilities[]] |
group_by(.VulnerabilityID) |
map(.[0]) |
sort_by(
(if .Severity == "CRITICAL" then 0
elif .Severity == "HIGH" then 1
elif .Severity == "MEDIUM" then 2
elif .Severity == "LOW" then 3
else 4 end),
-((.CVSS.nvd.V3Score // 0) | tonumber? // 0)
) |
.[:8] |
map(. | vuln_block)
) +
[
{ "type": "divider" },
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": ":github: View Full Report" },
"style": "primary",
"url": $run_url
}
]
}
]
' trivy-results.json)
echo "slack_blocks=$BLOCKS_JSON" >> "$GITHUB_OUTPUT"
- name: Send Slack Notification
if: steps.process_results.outputs.vulnerabilities_found == 'true'
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
with:
method: chat.postMessage
token: ${{ secrets.QBOT_SLACK_TOKEN }}
payload: |
channel: ${{ env.SLACK_CHANNEL_ID }}
text: "🚨 Trivy Scan: ${{ steps.process_results.outputs.critical_count }} Critical, ${{ steps.process_results.outputs.high_count }} High, ${{ steps.process_results.outputs.medium_count }} Medium, ${{ steps.process_results.outputs.low_count }} Low vulnerabilities found in ${{ inputs.image_ref }}"
blocks: ${{ steps.generate_blocks.outputs.slack_blocks }}
- name: Generate Critical Vulnerability Blocks for Mission Security
if: steps.process_results.outputs.vulnerabilities_found == 'true' && steps.process_results.outputs.critical_count != '0'
id: generate_critical_blocks
run: |
CRITICAL_BLOCKS_JSON=$(jq -c --arg image_ref "${{ inputs.image_ref }}" \
--arg repo_url "${{ github.server_url }}/${{ github.repository }}" \
--arg repo_name "${{ github.repository }}" \
--arg run_url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
--arg critical_count "${{ steps.process_results.outputs.critical_count }}" \
--arg unique_cves "${{ steps.process_results.outputs.unique_cves }}" \
'
# Function to create a detailed critical vulnerability block
def critical_vuln_block:
# Build references section if available
(if (.References | length) > 0 then
"\n*References:* " + ([.References[0:2][] | "<\(.)|\(. | split("/")[-1])>"] | join(", ")) + (if (.References | length) > 2 then " and \((.References | length) - 2) more" else "" end)
else "" end) as $refs |
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ":red_circle: *<https://nvd.nist.gov/vuln/detail/\(.VulnerabilityID)|\(.VulnerabilityID)>*\n*Severity:* CRITICAL | *CVSS Score:* `\(.CVSS.nvd.V3Score // "N/A")` | *Vector:* `\(.CVSS.nvd.V3Vector // "N/A")`\n*Package:* `\(.PkgName)@\(.InstalledVersion)` → `\(.FixedVersion // "No fix available")`\n*Published:* \(.PublishedDate // "Unknown" | split("T")[0])\n*Description:* \(.Description // "No description available" | split("\n")[0] | .[0:200])\($refs)"
}
};
# Main structure for critical vulnerabilities
[
{
"type": "header",
"text": { "type": "plain_text", "text": ":rotating_light: CRITICAL Vulnerabilities Detected" }
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Repository:*\n<\($repo_url)|\($repo_name)>" },
{ "type": "mrkdwn", "text": "*Image:*\n`\($image_ref)`" },
{ "type": "mrkdwn", "text": "*Critical Vulnerabilities:*\n:red_circle: \($critical_count)" },
{ "type": "mrkdwn", "text": "*Scan Time:*\n<!date^\(now | floor)^{date_num} {time_secs}|Unknown>" }
]
},
{ "type": "divider" },
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*:warning: Critical CVE Details:*"
}
}
] +
(
# Filter only CRITICAL vulnerabilities
[.Results[] | select(.Vulnerabilities != null) | .Vulnerabilities[] | select(.Severity == "CRITICAL")] |
# Group by CVE to avoid duplicates
group_by(.VulnerabilityID) |
map(.[0]) |
# Sort by CVSS score descending
sort_by(-((.CVSS.nvd.V3Score // 0) | tonumber? // 0)) |
# Include all critical vulnerabilities
map(. | critical_vuln_block)
) +
[
{ "type": "divider" },
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": ":github: View Full Scan Report" },
"style": "danger",
"url": $run_url
}
]
}
]
' trivy-results.json)
echo "critical_blocks=$CRITICAL_BLOCKS_JSON" >> "$GITHUB_OUTPUT"
- name: Send Critical Vulnerability Notification to Mission Security
if: steps.process_results.outputs.critical_count != '0'
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
with:
method: chat.postMessage
token: ${{ secrets.QBOT_SLACK_TOKEN }}
payload: |
channel: ${{ env.SLACK_CHANNEL_CRITICAL }}
text: "🚨 CRITICAL: ${{ steps.process_results.outputs.critical_count }} critical vulnerabilities found in ${{ inputs.image_ref }}"
blocks: ${{ steps.generate_critical_blocks.outputs.critical_blocks }}

View File

@@ -0,0 +1,35 @@
name: 'Test: Benchmarks'
on:
workflow_call:
inputs:
ref:
description: GitHub ref to test.
required: false
type: string
default: ''
workflow_dispatch:
inputs:
ref:
description: Branch or ref to benchmark (defaults to the workflow's branch).
required: false
type: string
default: ''
jobs:
bench:
name: Benchmarks
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.ref }}
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
- name: Run benchmarks
uses: CodSpeedHQ/action@2ac572851726409c88c02a307f1ea2632a9ea59b # v4.11.0
with:
mode: simulation
run: CODSPEED=true pnpm --filter=@n8n/performance bench

View File

@@ -0,0 +1,39 @@
name: 'Test: Benchmark Destroy Env'
on:
schedule:
- cron: '0 5 * * *'
workflow_dispatch:
permissions:
id-token: write
contents: read
concurrency:
group: benchmark
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
environment: benchmarking
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Azure login
uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a # v2.1.1
with:
client-id: ${{ secrets.BENCHMARK_ARM_CLIENT_ID }}
tenant-id: ${{ secrets.BENCHMARK_ARM_TENANT_ID }}
subscription-id: ${{ secrets.BENCHMARK_ARM_SUBSCRIPTION_ID }}
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
- name: Destroy cloud env
run: pnpm destroy-cloud-env
working-directory: packages/@n8n/benchmark

View File

@@ -0,0 +1,115 @@
name: 'Test: Benchmark Nightly'
run-name: Benchmark ${{ inputs.n8n_tag || 'nightly' }}
on:
schedule:
- cron: '30 1,2,3 * * *'
workflow_dispatch:
inputs:
debug:
description: 'Use debug logging'
required: true
default: 'false'
n8n_tag:
description: 'Name of the n8n docker tag to run the benchmark against.'
required: true
default: 'nightly'
benchmark_tag:
description: 'Name of the benchmark cli docker tag to run the benchmark with.'
required: true
default: 'latest'
env:
ARM_CLIENT_ID: ${{ secrets.BENCHMARK_ARM_CLIENT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.BENCHMARK_ARM_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ secrets.BENCHMARK_ARM_TENANT_ID }}
N8N_TAG: ${{ inputs.n8n_tag || 'nightly' }}
N8N_BENCHMARK_TAG: ${{ inputs.benchmark_tag || 'latest' }}
DEBUG: ${{ inputs.debug == 'true' && '--debug' || '' }}
permissions:
id-token: write
contents: read
concurrency:
group: benchmark
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
environment: benchmarking
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
- uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3
with:
terraform_version: '1.8.5'
- name: Azure login
uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a # v2.1.1
with:
client-id: ${{ env.ARM_CLIENT_ID }}
tenant-id: ${{ env.ARM_TENANT_ID }}
subscription-id: ${{ env.ARM_SUBSCRIPTION_ID }}
- name: Destroy any existing environment
run: pnpm destroy-cloud-env
working-directory: packages/@n8n/benchmark
- name: Provision the environment
run: pnpm provision-cloud-env ${{ env.DEBUG }}
working-directory: packages/@n8n/benchmark
- name: Run the benchmark
id: benchmark
env:
BENCHMARK_RESULT_WEBHOOK_URL: ${{ secrets.BENCHMARK_RESULT_WEBHOOK_URL }}
BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER: ${{ secrets.BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER }}
N8N_LICENSE_CERT: ${{ secrets.N8N_BENCHMARK_LICENSE_CERT }}
run: |
pnpm benchmark-in-cloud \
--vus 5 \
--duration 1m \
--n8nTag ${{ env.N8N_TAG }} \
--benchmarkTag ${{ env.N8N_BENCHMARK_TAG }} \
${{ env.DEBUG }}
working-directory: packages/@n8n/benchmark
# We need to login again because the access token expires
- name: Azure login
if: always()
uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a # v2.1.1
with:
client-id: ${{ env.ARM_CLIENT_ID }}
tenant-id: ${{ env.ARM_TENANT_ID }}
subscription-id: ${{ env.ARM_SUBSCRIPTION_ID }}
- name: Destroy the environment
if: always()
run: pnpm destroy-cloud-env ${{ env.DEBUG }}
working-directory: packages/@n8n/benchmark
- name: Fail `build` job if `benchmark` step failed
if: steps.benchmark.outcome == 'failure'
run: exit 1
notify-on-failure:
name: Notify Cats on failure
runs-on: ubuntu-latest
needs: [build]
if: failure()
steps:
- uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
with:
status: ${{ job.status }}
channel: '#team-catalysts'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: Benchmark run failed for n8n tag `${{ inputs.n8n_tag || 'nightly' }}` - ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

51
.github/workflows/test-db-reusable.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: 'Test: DB Integration'
on:
workflow_call:
inputs:
ref:
required: false
type: string
default: ''
env:
NODE_OPTIONS: '--max-old-space-size=6144'
jobs:
test:
name: ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- name: SQLite Pooled
runner: blacksmith-2vcpu-ubuntu-2204
test-cmd: pnpm test:sqlite
migration-cmd: pnpm test:sqlite:migrations
- name: Postgres 16
runner: blacksmith-4vcpu-ubuntu-2204
test-cmd: pnpm test:postgres:integration:tc
migration-cmd: pnpm test:postgres:migrations:tc
TEST_IMAGE_POSTGRES: 'postgres:16'
env:
TEST_IMAGE_POSTGRES: ${{ matrix.TEST_IMAGE_POSTGRES }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.ref }}
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
- name: Pre-pull Test Container Images
run: pnpm tsx packages/testing/containers/pull-test-images.ts || true
- name: Run Tests
working-directory: packages/cli
run: ${{ matrix.test-cmd }}
- name: Run Migration Tests
working-directory: packages/cli
run: ${{ matrix.migration-cmd }}

View File

@@ -0,0 +1,153 @@
name: 'Test: E2E CI'
on:
workflow_call:
inputs:
branch:
description: 'GitHub branch/ref to test'
required: false
type: string
default: ''
playwright-only:
description: 'Only Playwright files changed — run impacted tests only'
required: false
type: boolean
default: false
env:
DOCKER_IMAGE: ghcr.io/${{ github.repository }}:ci-${{ github.run_id }}
jobs:
prepare:
name: 'Prepare E2E'
if: ${{ !github.event.pull_request.head.repo.fork }}
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
packages: write
contents: read
outputs:
matrix: ${{ steps.generate-matrix.outputs.matrix }}
skip-tests: ${{ steps.generate-matrix.outputs.skip-tests }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.branch || github.ref }}
fetch-depth: 1
- name: Login to GHCR
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push to GHCR
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
BUILD_STATS_WEBHOOK_URL: ${{ secrets.BUILD_STATS_WEBHOOK_URL }}
BUILD_STATS_WEBHOOK_USER: ${{ secrets.BUILD_STATS_WEBHOOK_USER }}
BUILD_STATS_WEBHOOK_PASSWORD: ${{ secrets.BUILD_STATS_WEBHOOK_PASSWORD }}
DOCKER_STATS_WEBHOOK_URL: ${{ secrets.DOCKER_STATS_WEBHOOK_URL }}
- name: Get changed files for impact analysis
if: ${{ inputs.playwright-only }}
id: changed-files
run: |
git fetch --depth=1 origin ${{ github.event.pull_request.base.ref || 'master' }}
echo "list=$(git diff --name-only FETCH_HEAD HEAD | tr '\n' ',' | sed 's/,$//')" >> "$GITHUB_OUTPUT"
- name: Generate shard matrix
id: generate-matrix
env:
CHANGED_FILES: ${{ steps.changed-files.outputs.list }}
run: |
ARGS=(--matrix 16 --orchestrate)
if [[ "${{ inputs.playwright-only }}" == "true" ]]; then
ARGS+=(--impact "--files=$CHANGED_FILES")
fi
MATRIX=$(node packages/testing/playwright/scripts/distribute-tests.mjs "${ARGS[@]}")
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"
sqlite-sanity:
needs: [prepare]
name: 'SQLite: Sanity Check'
if: ${{ !github.event.pull_request.head.repo.fork }}
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: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":""}]'
# 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 }}
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 }}
uses: ./.github/workflows/test-e2e-reusable.yml
with:
branch: ${{ inputs.branch }}
test-mode: local
test-command: pnpm --filter=n8n-playwright test:local:e2e-only
shards: 7
runner: ubuntu-latest
workers: '1'
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.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 }}

View File

@@ -0,0 +1,84 @@
name: 'Test: E2E Coverage Weekly'
on:
schedule:
- cron: '0 2 * * 1' # Every Monday at 2 AM
workflow_dispatch: # Allow manual triggering
env:
NODE_OPTIONS: --max-old-space-size=16384
PLAYWRIGHT_WORKERS: 4
PLAYWRIGHT_BROWSERS_PATH: packages/testing/playwright/.playwright-browsers
jobs:
coverage:
runs-on: blacksmith-8vcpu-ubuntu-2204
name: Coverage Tests
steps:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Setup Environment
uses: ./.github/actions/setup-nodejs
env:
INCLUDE_TEST_CONTROLLER: 'true'
- name: Build Docker Image with Coverage
run: pnpm build:docker:coverage
env:
INCLUDE_TEST_CONTROLLER: 'true'
- name: Install Browsers
run: pnpm turbo run install-browsers --filter=n8n-playwright
- name: Run Container Coverage Tests
id: coverage-tests
run: |
pnpm --filter n8n-playwright test:container:sqlite \
--workers=${{ env.PLAYWRIGHT_WORKERS }}
env:
BUILD_WITH_COVERAGE: 'true'
CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }}
CURRENTS_PROJECT_ID: 'LRxcNt'
QA_PERFORMANCE_METRICS_WEBHOOK_URL: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_URL }}
QA_PERFORMANCE_METRICS_WEBHOOK_USER: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_USER }}
QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD }}
- name: Generate Coverage Report
if: always() && steps.coverage-tests.outcome != 'skipped'
run: pnpm --filter n8n-playwright coverage:report
- name: Upload Coverage Report Artifact
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: coverage-report
path: packages/testing/playwright/coverage/
retention-days: 14
- name: Upload E2E Coverage to Codecov
if: always()
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/testing/playwright/coverage/lcov.info
flags: frontend-e2e
name: playwright-e2e
fail_ci_if_error: false
- name: Analyse Coverage Gaps
if: always() && steps.coverage-tests.outcome != 'skipped'
env:
CODECOV_API_TOKEN: ${{ secrets.CODECOV_API_TOKEN }}
run: |
node packages/testing/playwright/scripts/coverage-analysis.mjs \
--md --top=15 --out-json=coverage-gaps.json >> "$GITHUB_STEP_SUMMARY"
- name: Upload Coverage Gap Report
if: always() && steps.coverage-tests.outcome != 'skipped'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: coverage-gap-report
path: coverage-gaps.json
retention-days: 21

View File

@@ -0,0 +1,38 @@
name: 'Test: E2E Docker Pull'
# This workflow is used to run Playwright tests in a Docker container pulled from the registry
on:
workflow_call:
inputs:
shards:
description: 'Shards for parallel execution'
required: false
default: 1
type: number
image:
description: 'Image to use'
required: false
default: 'n8nio/n8n:nightly'
type: string
workflow_dispatch:
inputs:
shards:
description: 'Shards for parallel execution'
required: false
default: 1
type: number
image:
description: 'Image to use'
required: false
default: 'n8nio/n8n:nightly'
type: string
jobs:
build-and-test:
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
secrets: inherit

173
.github/workflows/test-e2e-helm.yml vendored Normal file
View File

@@ -0,0 +1,173 @@
name: 'Test: E2E Helm Chart'
on:
pull_request:
paths:
- '.github/workflows/test-e2e-helm.yml'
- 'packages/testing/containers/helm-stack.ts'
- 'packages/testing/containers/helm-start-stack.ts'
workflow_dispatch:
inputs:
branch:
description: 'Branch to test'
required: false
default: ''
helm-chart-ref:
description: 'n8n-hosting branch/tag for Helm chart'
required: false
default: 'main'
mode:
description: 'Deployment mode to test'
required: true
type: choice
options:
- standalone
- queue
default: 'queue'
env:
DOCKER_IMAGE: ghcr.io/${{ github.repository }}:ci-${{ github.run_id }}
HELM_CHART_REF: ${{ inputs.helm-chart-ref || 'main' }}
jobs:
build:
name: 'Build Docker Image'
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
packages: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.branch || github.ref }}
fetch-depth: 1
- name: Login to GHCR
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push to GHCR
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
helm-e2e:
name: "Helm E2E (${{ inputs.mode || 'queue' }})"
needs: build
runs-on: blacksmith-4vcpu-ubuntu-2204
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.branch || github.ref }}
fetch-depth: 1
- name: Setup Environment
uses: ./.github/actions/setup-nodejs
with:
build-command: 'pnpm build'
- name: Install Browsers
working-directory: packages/testing/playwright
run: pnpm exec playwright install chromium
- name: Install Helm
uses: azure/setup-helm@bf6a7d304bc2fdb57e0331155b7ebf2c504acf0a # v4
- name: Install kubectl
uses: azure/setup-kubectl@c0c8b32d33a5244f1e5947304550403b63930415 # v4
- name: Start K3s + Helm stack
env:
TESTCONTAINERS_RYUK_DISABLED: 'true'
run: |
npx tsx packages/testing/containers/helm-start-stack.ts \
--env E2E_TESTS=true --env NODE_ENV=development \
--mode "${{ inputs.mode || 'queue' }}" \
--image "${{ env.DOCKER_IMAGE }}" \
--chart-ref "${{ env.HELM_CHART_REF }}" \
--url-file /tmp/n8n-helm-url.txt \
--kubeconfig-file /tmp/n8n-helm-kubeconfig.txt
N8N_URL=$(cat /tmp/n8n-helm-url.txt)
echo "n8n URL: ${N8N_URL}"
echo "N8N_BASE_URL=${N8N_URL}" >> "$GITHUB_ENV"
KUBECONFIG_PATH=$(cat /tmp/n8n-helm-kubeconfig.txt)
echo "KUBECONFIG=${KUBECONFIG_PATH}" >> "$GITHUB_ENV"
- name: Run Building Blocks E2E Tests
working-directory: packages/testing/playwright
run: |
N8N_BASE_URL="${{ env.N8N_BASE_URL }}" \
RESET_E2E_DB=true \
npx playwright test \
--project=e2e \
tests/e2e/building-blocks/ \
--workers=1 \
--retries=2 \
--reporter=list
- name: Debug K8s State
if: failure()
run: |
echo "=== Pod Status ==="
kubectl get pods -o wide
echo ""
echo "=== Pod Descriptions ==="
kubectl describe pods -l app.kubernetes.io/name=n8n
echo ""
echo "=== Recent Events ==="
kubectl get events --sort-by=.lastTimestamp | tail -30
echo ""
echo "=== n8n Pod Logs (last 100 lines) ==="
kubectl logs -l app.kubernetes.io/name=n8n --tail=100 || true
echo ""
echo "=== Health Check ==="
curl -s -o /dev/null -w "HTTP %{http_code}" "${{ env.N8N_BASE_URL }}/healthz/readiness" || echo "UNREACHABLE"
- name: Upload Failure Artifacts
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: helm-e2e-report-${{ inputs.mode || 'queue' }}
path: |
packages/testing/playwright/test-results/
packages/testing/playwright/playwright-report/
retention-days: 7
cleanup-ghcr:
name: 'Cleanup GHCR Image'
needs: [build, helm-e2e]
if: always()
runs-on: blacksmith-2vcpu-ubuntu-2204
permissions:
packages: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.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 }}

View File

@@ -0,0 +1,20 @@
name: 'Test: E2E Performance'
on:
workflow_call:
workflow_dispatch:
schedule:
- cron: '0 0 * * *' # Runs daily at midnight
pull_request:
paths:
- '.github/workflows/test-e2e-performance-reusable.yml'
jobs:
build-and-test-performance:
uses: ./.github/workflows/test-e2e-reusable.yml
with:
test-mode: docker-build
test-command: pnpm --filter=n8n-playwright test:performance
shards: 1
currents-project-id: 'O9BJaN'
secrets: inherit

196
.github/workflows/test-e2e-reusable.yml vendored Normal file
View File

@@ -0,0 +1,196 @@
name: 'Test: E2E'
on:
workflow_call:
inputs:
branch:
description: 'GitHub branch to test.'
required: false
type: string
test-mode:
description: 'Test mode: local (pnpm start from local), docker-build, or docker-pull'
required: false
default: 'local'
type: string
test-command:
description: 'Test command to run'
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.'
required: false
default: 'n8nio/n8n:nightly'
type: string
workers:
description: 'Number of parallel workers'
required: false
default: ''
type: string
runner:
description: 'GitHub runner to use'
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
upload-failure-artifacts:
description: 'Upload test failure artifacts (screenshots, traces, videos). Enable for community PRs without Currents access.'
required: false
default: false
type: boolean
currents-project-id:
description: 'Currents project ID for reporting'
required: false
default: 'LRxcNt'
type: string
pre-generated-matrix:
description: 'Pre-generated shard matrix JSON (skips matrix job if provided)'
required: false
default: ''
type: string
secrets:
CURRENTS_RECORD_KEY:
required: false
CURRENTS_API_KEY:
required: false
QA_PERFORMANCE_METRICS_WEBHOOK_URL:
required: false
QA_PERFORMANCE_METRICS_WEBHOOK_USER:
required: false
QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD:
required: false
N8N_LICENSE_ACTIVATION_KEY:
required: false
N8N_LICENSE_CERT:
required: false
N8N_ENCRYPTION_KEY:
required: false
CONTAINER_TELEMETRY_WEBHOOK:
required: false
env:
NODE_OPTIONS: ${{ contains(inputs.runner, '2vcpu') && '--max-old-space-size=6144' || '' }}
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 }}
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.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: 30
permissions:
packages: read
contents: read
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(inputs.pre-generated-matrix || needs.matrix.outputs.matrix) }}
name: Shard ${{ matrix.shard }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
ref: ${{ inputs.branch || github.ref }}
- 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' || '' }}
- 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: Pre-pull Test Container Images
if: ${{ !contains(inputs.test-command, 'test:local') }}
run: npx tsx packages/testing/containers/pull-test-images.ts ${{ matrix.images }} || true
- name: Run Tests
# 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 }}
QA_PERFORMANCE_METRICS_WEBHOOK_URL: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_URL }}
QA_PERFORMANCE_METRICS_WEBHOOK_USER: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_USER }}
QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD }}
N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }}
N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }}
N8N_ENCRYPTION_KEY: ${{ secrets.N8N_ENCRYPTION_KEY }}
CONTAINER_TELEMETRY_WEBHOOK: ${{secrets.CONTAINER_TELEMETRY_WEBHOOK}}
- name: Upload Failure Artifacts
if: ${{ failure() && inputs.upload-failure-artifacts }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: playwright-report-shard-${{ matrix.shard }}
path: |
packages/testing/playwright/test-results/
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:
CURRENTS_API_KEY: ${{ secrets.CURRENTS_API_KEY }}
run: |
if [ -n "$CURRENTS_API_KEY" ]; then
curl --location --request PUT \
"https://api.currents.dev/v1/runs/cancel-ci/github" \
--header "Authorization: Bearer $CURRENTS_API_KEY" \
--header "Content-Type: application/json" \
--data "{\"githubRunId\": \"${{ github.run_id }}\", \"githubRunAttempt\": \"${{ github.run_attempt }}\"}"
fi

View File

@@ -0,0 +1,60 @@
name: 'Test: Evals AI (Release)'
on:
release:
types: [published]
jobs:
check-minor-release:
name: Check Minor Release
runs-on: ubuntu-latest
outputs:
is_minor: ${{ steps.check.outputs.is_minor }}
version: ${{ steps.check.outputs.version }}
steps:
- name: Check if minor release
id: check
run: |
TAG="${{ github.event.release.tag_name }}"
echo "Release tag: $TAG"
# Check if it's a minor release (e.g., v1.2.0, not v1.2.1)
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.0$ ]]; then
echo "is_minor=true" >> "$GITHUB_OUTPUT"
echo "version=$TAG" >> "$GITHUB_OUTPUT"
echo "Minor release detected: $TAG"
else
echo "is_minor=false" >> "$GITHUB_OUTPUT"
echo "version=" >> "$GITHUB_OUTPUT"
echo "Not a minor release, skipping evals"
fi
run-pairwise-evals:
name: Run Pairwise (Spec) Evaluations
needs: check-minor-release
if: needs.check-minor-release.outputs.is_minor == 'true'
uses: ./.github/workflows/test-evals-ai-reusable.yml
with:
branch: ${{ github.event.release.tag_name }}
suite: pairwise
dataset: notion-pairwise-fresh
# Spec evals on minor release: 2 reps, 3 judges
repetitions: 2
judges: 3
experiment_name_prefix: CI_${{ needs.check-minor-release.outputs.version }}
secrets: inherit
run-llm-judge-evals:
name: Run LLM Judge (Matrix) Evaluations
needs: check-minor-release
if: needs.check-minor-release.outputs.is_minor == 'true'
uses: ./.github/workflows/test-evals-ai-reusable.yml
with:
branch: ${{ github.event.release.tag_name }}
suite: llm-judge
dataset: workflow-builder-canvas-prompts
# Matrix evals always use 3 reps, 3 judges
repetitions: 3
judges: 3
experiment_name_prefix: CI_${{ needs.check-minor-release.outputs.version }}
secrets: inherit

View File

@@ -0,0 +1,108 @@
name: 'Test: Evals AI'
on:
workflow_call:
inputs:
branch:
description: 'GitHub branch to test.'
type: string
default: 'master'
suite:
description: 'Evaluation suite to run (pairwise or llm-judge).'
type: string
default: 'pairwise'
dataset:
description: 'LangSmith dataset to use.'
type: string
required: true
repetitions:
description: 'Number of repetitions to run.'
type: number
default: 1
judges:
description: 'Number of judges to use.'
type: number
default: 1
concurrency:
description: 'Max concurrent evaluations.'
type: number
default: 10
experiment_name_prefix:
description: 'Prefix for the experiment name. If empty, will be auto-generated from branch name.'
type: string
default: ''
jobs:
evals:
name: Run ${{ inputs.suite }} Evaluations
runs-on: blacksmith-2vcpu-ubuntu-2204
env:
N8N_AI_ANTHROPIC_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
LANGSMITH_TRACING: true
LANGSMITH_ENDPOINT: ${{ secrets.EVALS_LANGSMITH_ENDPOINT }}
LANGSMITH_API_KEY: ${{ secrets.EVALS_LANGSMITH_API_KEY }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.branch }}
- name: Generate experiment name
id: experiment
run: |
DATE=$(date +%Y_%m_%d)
PREFIX="${{ inputs.experiment_name_prefix }}"
if [ -n "$PREFIX" ]; then
NAME="${PREFIX}_${DATE}"
else
# Extract ticket ID from branch name (e.g., AI-1234 from ai-1234-feature-name)
BRANCH="${{ inputs.branch }}"
TICKET=$(echo "$BRANCH" | grep -oE '^[Aa][Ii]-[0-9]+' | tr '[:lower:]' '[:upper:]' || true)
if [ -n "$TICKET" ]; then
NAME="${TICKET}_${DATE}"
else
# Sanitize branch name for experiment name
SANITIZED_BRANCH=$(echo "$BRANCH" | sed 's/[^a-zA-Z0-9_.-]/_/g' | sed 's/__*/_/g')
NAME="CI_${SANITIZED_BRANCH}_${DATE}"
fi
fi
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "Generated experiment name: $NAME"
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
- name: Install uv
uses: astral-sh/setup-uv@d9e0f98d3fc6adb07d1e3d37f3043649ddad06a1 # 6.5.0
with:
enable-cache: true
- name: Install just
uses: extractions/setup-just@e33e0265a09d6d736e2ee1e0eb685ef1de4669ff # v3.0.0
- name: Install Python
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python
run: uv python install 3.11
- name: Install workflow comparison dependencies
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python
run: just sync-all
- name: Export Node Types
run: |
./packages/cli/bin/n8n export:nodes --output ./packages/@n8n/ai-workflow-builder.ee/evaluations/nodes.json
- name: Run Evaluations
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations
run: |
pnpm eval \
--suite "${{ inputs.suite }}" \
--backend langsmith \
--dataset "${{ inputs.dataset }}" \
--repetitions ${{ inputs.repetitions }} \
--judges ${{ inputs.judges }} \
--concurrency ${{ inputs.concurrency }} \
--name "${{ steps.experiment.outputs.name }}" \
${{ secrets.EVALS_WEBHOOK_URL && format('--webhook-url "{0}"', secrets.EVALS_WEBHOOK_URL) || '' }} \
${{ secrets.EVALS_WEBHOOK_SECRET && format('--webhook-secret "{0}"', secrets.EVALS_WEBHOOK_SECRET) || '' }}

161
.github/workflows/test-evals-ai.yml vendored Normal file
View File

@@ -0,0 +1,161 @@
name: 'Test: Evals AI'
on:
push:
branches:
- master
paths:
- 'packages/@n8n/ai-workflow-builder.ee/src/prompts/**'
- 'packages/@n8n/ai-workflow-builder.ee/**/*.prompt.ts'
- '.github/workflows/test-evals-ai.yml'
- '.github/workflows/test-evals-ai-reusable.yml'
workflow_dispatch:
inputs:
branch:
description: 'GitHub branch to test.'
required: false
default: 'master'
eval_type:
description: 'Which evaluations to run.'
required: false
default: 'both'
type: choice
options:
- both
- spec
- matrix
spec_repetitions:
description: 'Number of repetitions for spec (pairwise) evals.'
required: false
default: '1'
spec_judges:
description: 'Number of judges for spec (pairwise) evals.'
required: false
default: '1'
jobs:
check-skip:
name: Check Skip Label
runs-on: ubuntu-latest
outputs:
should_skip: ${{ steps.check.outputs.should_skip }}
steps:
- name: Check for no-prompt-changes opt-out
id: check
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const SKIP_TAG = '(no-prompt-changes)';
const SKIP_LABEL = 'no-prompt-changes';
// Only check for push events (merges to master)
if (context.eventName !== 'push') {
core.setOutput('should_skip', 'false');
return;
}
// Check commit message for skip tag
const commitMessage = context.payload.head_commit?.message || '';
if (commitMessage.includes(SKIP_TAG)) {
console.log(`Found ${SKIP_TAG} in commit message, skipping evals`);
core.setOutput('should_skip', 'true');
return;
}
// Find the PR associated with this merge commit
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.sha
});
if (prs.length === 0) {
console.log('No PR found for this commit, running evals');
core.setOutput('should_skip', 'false');
return;
}
const pr = prs[0];
console.log(`PR #${pr.number}: "${pr.title}"`);
// Check PR title for skip tag
if (pr.title.includes(SKIP_TAG)) {
console.log(`Found ${SKIP_TAG} in PR title, skipping evals`);
core.setOutput('should_skip', 'true');
return;
}
// Check PR labels for skip label
const labels = pr.labels.map(l => l.name);
console.log(`PR labels: ${labels.join(', ') || '(none)'}`);
if (labels.includes(SKIP_LABEL)) {
console.log(`Found ${SKIP_LABEL} label, skipping evals`);
core.setOutput('should_skip', 'true');
} else {
console.log('No skip indicator found, running evals');
core.setOutput('should_skip', 'false');
}
determine-config:
name: Determine Configuration
needs: check-skip
if: needs.check-skip.outputs.should_skip != 'true'
runs-on: ubuntu-latest
outputs:
branch: ${{ steps.config.outputs.branch }}
spec_repetitions: ${{ steps.config.outputs.spec_repetitions }}
spec_judges: ${{ steps.config.outputs.spec_judges }}
experiment_prefix: ${{ steps.config.outputs.experiment_prefix }}
steps:
- name: Set configuration based on trigger
id: config
run: |
EVENT_NAME="${{ github.event_name }}"
if [ "$EVENT_NAME" = "push" ]; then
# Merge to master: spec evals use 1 rep, 1 judge
{
echo "branch=${{ github.ref_name }}"
echo "spec_repetitions=1"
echo "spec_judges=1"
echo "experiment_prefix="
} >> "$GITHUB_OUTPUT"
else
# Manual dispatch: use provided values for spec evals
{
echo "branch=${{ inputs.branch || 'master' }}"
echo "spec_repetitions=${{ inputs.spec_repetitions || '1' }}"
echo "spec_judges=${{ inputs.spec_judges || '1' }}"
echo "experiment_prefix=CI_manual"
} >> "$GITHUB_OUTPUT"
fi
run-pairwise-evals:
name: Run Pairwise (Spec) Evaluations
needs: determine-config
if: github.event_name == 'push' || inputs.eval_type == 'both' || inputs.eval_type == 'spec'
uses: ./.github/workflows/test-evals-ai-reusable.yml
with:
branch: ${{ needs.determine-config.outputs.branch }}
suite: pairwise
dataset: notion-pairwise-fresh
repetitions: ${{ fromJson(needs.determine-config.outputs.spec_repetitions) }}
judges: ${{ fromJson(needs.determine-config.outputs.spec_judges) }}
experiment_name_prefix: ${{ needs.determine-config.outputs.experiment_prefix }}
secrets: inherit
run-llm-judge-evals:
name: Run LLM Judge (Matrix) Evaluations
needs: determine-config
if: github.event_name == 'push' || inputs.eval_type == 'both' || inputs.eval_type == 'matrix'
uses: ./.github/workflows/test-evals-ai-reusable.yml
with:
branch: ${{ needs.determine-config.outputs.branch }}
suite: llm-judge
dataset: workflow-builder-canvas-prompts
# Matrix evals always use 3 reps, 3 judges regardless of trigger
repetitions: 3
judges: 3
experiment_name_prefix: ${{ needs.determine-config.outputs.experiment_prefix }}
secrets: inherit

56
.github/workflows/test-evals-python.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: 'Test: Evals Python'
on:
pull_request:
paths:
- packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/**
- .github/workflows/test-evals-python.yml
push:
paths:
- packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/**
jobs:
workflow-comparison:
name: Workflow Comparison Python
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python
steps:
- name: Check out project
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install uv
uses: astral-sh/setup-uv@d9e0f98d3fc6adb07d1e3d37f3043649ddad06a1 # 6.5.0
with:
enable-cache: true
- name: Install just
uses: extractions/setup-just@e33e0265a09d6d736e2ee1e0eb685ef1de4669ff # v3.0.0
- name: Install Python
run: uv python install 3.11
- name: Install project dependencies
run: just sync-all
- name: Format check
run: just format-check
- name: Typecheck
run: just typecheck
- name: Lint
run: just lint
- name: Python unit tests
run: uv run pytest --cov=src --cov-report=xml --cov-report=term-missing
- name: Upload coverage to Codecov
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/coverage.xml
flags: tests
name: workflow-comparison-python
fail_ci_if_error: false

View File

@@ -0,0 +1,33 @@
name: 'Test: Linting'
on:
workflow_call:
inputs:
ref:
description: GitHub ref to lint.
required: false
type: string
default: ''
nodeVersion:
description: Version of node to use.
required: false
type: string
default: 24.13.1
env:
NODE_OPTIONS: --max-old-space-size=7168
jobs:
lint:
name: Lint
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.ref }}
- name: Build and Test
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm lint
node-version: ${{ inputs.nodeVersion }}

217
.github/workflows/test-unit-reusable.yml vendored Normal file
View File

@@ -0,0 +1,217 @@
name: 'Test: Unit'
on:
workflow_call:
inputs:
ref:
description: GitHub ref to test.
required: false
type: string
default: ''
nodeVersion:
description: Version of node to use.
required: false
type: string
default: 24.13.1
collectCoverage:
required: false
default: false
type: boolean
secrets:
CODECOV_TOKEN:
description: 'Codecov upload token.'
required: false
BUILD_STATS_WEBHOOK_URL:
description: 'Webhook URL for test stats.'
required: false
BUILD_STATS_WEBHOOK_USER:
description: 'Basic auth username for test stats webhook.'
required: false
BUILD_STATS_WEBHOOK_PASSWORD:
description: 'Basic auth password for test stats webhook.'
required: false
env:
NODE_OPTIONS: --max-old-space-size=7168
jobs:
unit-test-backend:
name: Backend Unit Tests
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }} # Coverage collected when true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.ref }}
- name: Build
uses: ./.github/actions/setup-nodejs
with:
node-version: ${{ inputs.nodeVersion }}
- name: Test Unit
run: pnpm test:ci:backend:unit --summarize
- name: Send Test Stats
if: ${{ !cancelled() }}
run: node .github/scripts/send-build-stats.mjs || true
env:
BUILD_STATS_WEBHOOK_URL: ${{ secrets.BUILD_STATS_WEBHOOK_URL }}
BUILD_STATS_WEBHOOK_USER: ${{ secrets.BUILD_STATS_WEBHOOK_USER }}
BUILD_STATS_WEBHOOK_PASSWORD: ${{ secrets.BUILD_STATS_WEBHOOK_PASSWORD }}
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
report-type: test_results
name: backend-unit
- name: Upload coverage to Codecov
if: env.COVERAGE_ENABLED == 'true'
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: backend-unit
name: backend-unit
integration-test-backend:
name: Backend Integration Tests
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }} # Coverage collected when true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.ref }}
- name: Build
uses: ./.github/actions/setup-nodejs
with:
node-version: ${{ inputs.nodeVersion }}
- name: Test Integration
run: pnpm test:ci:backend:integration --summarize
- name: Send Test Stats
if: ${{ !cancelled() }}
run: node .github/scripts/send-build-stats.mjs || true
env:
BUILD_STATS_WEBHOOK_URL: ${{ secrets.BUILD_STATS_WEBHOOK_URL }}
BUILD_STATS_WEBHOOK_USER: ${{ secrets.BUILD_STATS_WEBHOOK_USER }}
BUILD_STATS_WEBHOOK_PASSWORD: ${{ secrets.BUILD_STATS_WEBHOOK_PASSWORD }}
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
name: backend-integration
- name: Upload coverage to Codecov
if: env.COVERAGE_ENABLED == 'true'
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: backend-integration
name: backend-integration
unit-test-nodes:
name: Nodes Unit Tests
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }} # Coverage collected when true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.ref }}
- name: Build
uses: ./.github/actions/setup-nodejs
with:
node-version: ${{ inputs.nodeVersion }}
- name: Test Nodes
run: pnpm turbo test --filter=n8n-nodes-base --summarize
- name: Send Test Stats
if: ${{ !cancelled() }}
run: node .github/scripts/send-build-stats.mjs || true
env:
BUILD_STATS_WEBHOOK_URL: ${{ secrets.BUILD_STATS_WEBHOOK_URL }}
BUILD_STATS_WEBHOOK_USER: ${{ secrets.BUILD_STATS_WEBHOOK_USER }}
BUILD_STATS_WEBHOOK_PASSWORD: ${{ secrets.BUILD_STATS_WEBHOOK_PASSWORD }}
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
name: nodes-unit
- name: Upload coverage to Codecov
if: env.COVERAGE_ENABLED == 'true'
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: nodes-unit
name: nodes-unit
unit-test-frontend:
name: Frontend (${{ matrix.shard }}/2)
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
strategy:
fail-fast: false
matrix:
shard: [1, 2]
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.ref }}
- name: Build
uses: ./.github/actions/setup-nodejs
with:
node-version: ${{ inputs.nodeVersion }}
- name: Test
run: pnpm test:ci:frontend --summarize -- --shard=${{ matrix.shard }}/2
env:
VITEST_SHARD: ${{ matrix.shard }}/2
- name: Send Test Stats
if: ${{ !cancelled() }}
run: node .github/scripts/send-build-stats.mjs || true
env:
BUILD_STATS_WEBHOOK_URL: ${{ secrets.BUILD_STATS_WEBHOOK_URL }}
BUILD_STATS_WEBHOOK_USER: ${{ secrets.BUILD_STATS_WEBHOOK_USER }}
BUILD_STATS_WEBHOOK_PASSWORD: ${{ secrets.BUILD_STATS_WEBHOOK_PASSWORD }}
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
name: frontend-shard-${{ matrix.shard }}
- name: Upload coverage to Codecov
if: env.COVERAGE_ENABLED == 'true'
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: frontend
name: frontend-shard-${{ matrix.shard }}
unit-test:
name: Unit tests
runs-on: ubuntu-latest
needs: [unit-test-backend, integration-test-backend, unit-test-nodes, unit-test-frontend]
if: always()
steps:
- name: Fail if tests failed
if: needs.unit-test-backend.result == 'failure' || needs.integration-test-backend.result == 'failure' || needs.unit-test-nodes.result == 'failure' || needs.unit-test-frontend.result == 'failure'
run: exit 1

View File

@@ -0,0 +1,37 @@
name: 'Test: Visual Chromatic'
on:
workflow_dispatch:
workflow_call:
inputs:
ref:
description: GitHub ref to check out.
required: false
type: string
default: ''
jobs:
chromatic:
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
fetch-depth: 0
ref: ${{ inputs.ref }}
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm run build --filter=@n8n/utils --filter=@n8n/vitest-config --filter=@n8n/chat --filter=@n8n/design-system
- name: Publish to Chromatic
uses: chromaui/action@1cfa065cbdab28f6ca3afaeb3d761383076a35aa # v11
id: chromatic_tests
with:
workingDir: packages/frontend/@n8n/storybook
buildScriptName: build
autoAcceptChanges: 'master'
skip: 'release/**'
onlyChanged: true
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
exitZeroOnChanges: false

View File

@@ -0,0 +1,54 @@
name: 'Test: Visual Storybook'
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
pull_request:
branches:
- master
paths:
- 'packages/frontend/@n8n/storybook/**'
- 'packages/frontend/@n8n/chat/**'
- 'packages/frontend/@n8n/design-system/**'
- '.github/workflows/test-visual-storybook.yml'
concurrency:
group: storybook-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
cloudflare:
name: Cloudflare Pages
if: |
!contains(github.event.pull_request.labels.*.name, 'community')
runs-on: blacksmith-2vcpu-ubuntu-2204
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
fetch-depth: 0
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm run build --filter=@n8n/utils --filter=@n8n/vitest-config --filter=@n8n/design-system
- name: Build
working-directory: packages/frontend/@n8n/storybook
run: |
pnpm build
- name: Setup Wrangler Pre-requisites
run: |
echo "ignore-workspace-root-check=true" >> .npmrc
pnpm add --global wrangler
- name: Deploy
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1
id: cloudflare_deployment
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy packages/frontend/@n8n/storybook/storybook-static --project-name=storybook --branch=${{github.ref_name}} --commit-hash=${{ github.sha }}
gitHubToken: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,55 @@
name: 'Test: Workflows'
on:
workflow_call:
inputs:
git_ref:
description: 'The Git ref (branch, tag, or SHA) to checkout and test.'
required: true
type: string
compare_schemas:
description: 'Set to "true" to enable schema comparison during tests.'
required: false
default: 'false'
type: string
secrets:
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
jobs:
run_workflow_tests:
name: Run Workflow Tests with Snapshots
runs-on: blacksmith-2vcpu-ubuntu-2204
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.git_ref }}
- name: Set up Environment
uses: ./.github/actions/setup-nodejs
- name: Set up Workflow Tests
run: pnpm --filter=n8n-playwright test:workflows:setup
env:
N8N_ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
- name: Run Workflow Tests
run: pnpm --filter=n8n-playwright test:workflows --workers 4
env:
CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }}
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_PROJECT_ID: 'mpLFH9'

View File

@@ -0,0 +1,20 @@
name: 'Test: Workflows Nightly'
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
inputs:
git_ref_to_test:
description: 'The Git ref (branch, tag, or SHA) to run tests against.'
required: true
type: string
default: 'master'
jobs:
run_workflow_tests:
name: Run Workflow Tests
uses: ./.github/workflows/test-workflows-callable.yml
with:
git_ref: ${{ github.event_name == 'schedule' && 'master' || github.event.inputs.git_ref_to_test }}
secrets: inherit

View File

@@ -0,0 +1,110 @@
name: 'Test: Workflows PR Comment'
on:
issue_comment:
types: [created]
permissions:
pull-requests: read
contents: read
jobs:
handle_comment_command:
name: Handle /test-workflows Command
if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/test-workflows')
runs-on: ubuntu-latest
outputs:
permission_granted: ${{ steps.pr_check_and_details.outputs.permission_granted }}
git_ref: ${{ steps.pr_check_and_details.outputs.head_sha }}
pr_number: ${{ steps.pr_check_and_details.outputs.pr_number_string }}
steps:
- name: Validate User, Get PR Details, and React
id: pr_check_and_details
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const commenter = context.actor;
const issueOwner = context.repo.owner;
const issueRepo = context.repo.repo;
const commentId = context.payload.comment.id;
const prNumber = context.issue.number; // In issue_comment on a PR, issue.number is the PR number
// Function to add a reaction to the comment
async function addReaction(content) {
try {
await github.rest.reactions.createForIssueComment({
owner: issueOwner,
repo: issueRepo,
comment_id: commentId,
content: content
});
} catch (reactionError) {
// Log if reaction fails but don't fail the script for this
console.log(`Failed to add reaction '${content}': ${reactionError.message}`);
}
}
// Initialize outputs to a non-triggering state
core.setOutput('permission_granted', 'false');
core.setOutput('head_sha', '');
core.setOutput('pr_number_string', '');
// 1. Check user permissions
try {
const { data: permissions } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: issueOwner,
repo: issueRepo,
username: commenter
});
const allowedPermissions = ['admin', 'write', 'maintain'];
if (!allowedPermissions.includes(permissions.permission)) {
console.log(`User @${commenter} has '${permissions.permission}' permission. Needs 'admin', 'write', or 'maintain'.`);
await addReaction('-1'); // User does not have permission
return; // Exit script, tests will not be triggered
}
console.log(`User @${commenter} has '${permissions.permission}' permission.`);
} catch (error) {
console.log(`Could not verify permissions for @${commenter}: ${error.message}`);
await addReaction('confused'); // Error checking permissions
return; // Exit script
}
// 2. Fetch PR details (if permission check passed)
let headSha;
try {
const { data: pr } = await github.rest.pulls.get({
owner: issueOwner,
repo: issueRepo,
pull_number: prNumber,
});
headSha = pr.head.sha;
console.log(`Workspaced PR details: SHA - ${headSha}, PR Number - ${prNumber}`);
// Set outputs for the next job
core.setOutput('permission_granted', 'true');
core.setOutput('head_sha', headSha);
core.setOutput('pr_number_string', prNumber.toString());
await addReaction('+1'); // Command accepted, tests will be triggered
} catch (error) {
console.log(`Failed to fetch PR details for PR #${prNumber}: ${error.message}`);
core.setOutput('permission_granted', 'false'); // Ensure this is false if PR fetch fails
await addReaction('confused'); // Error fetching PR details
}
trigger_reusable_tests:
name: Trigger Reusable Test Workflow
needs: handle_comment_command
if: >
always() &&
needs.handle_comment_command.result != 'skipped' &&
needs.handle_comment_command.outputs.permission_granted == 'true' &&
needs.handle_comment_command.outputs.git_ref != ''
uses: ./.github/workflows/test-workflows-callable.yml
with:
git_ref: ${{ needs.handle_comment_command.outputs.git_ref }}
secrets: inherit

View File

@@ -0,0 +1,37 @@
name: 'Util: Check Docs URLs'
on:
release:
types: [published]
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
jobs:
check-docs-urls:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: turbo build --filter=*nodes*
- run: npm install --prefix=.github/scripts --no-package-lock
- name: Test URLs
run: node .github/scripts/validate-docs-links.js
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure()
with:
status: ${{ job.status }}
channel: '#alerts-build'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: |
<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}| Documentation URLs check failed >

194
.github/workflows/util-claude-task.yml vendored Normal file
View File

@@ -0,0 +1,194 @@
name: 'Util: Claude Task Runner'
on:
workflow_dispatch:
inputs:
task:
description: 'Task description - what should Claude do?'
required: true
type: string
resumeUrl:
description: 'Optional callback URL to call with the result when the workflow completes'
required: false
type: string
model:
description: 'Claude model to use (e.g., claude-opus-4-6, claude-sonnet-4-5, claude-haiku-4-5)'
required: false
type: string
default: 'claude-sonnet-4-5-20250929'
use_raw_prompt:
description: 'Pass the task input directly as the prompt without wrapping it with guidelines and instructions'
required: false
type: boolean
default: false
max_turns:
description: 'Maximum conversation turns before Claude stops gracefully'
required: false
type: number
default: 50
suppress_output:
description: 'Suppress console output and job summary'
required: false
type: boolean
default: true
ref:
description: 'Git ref (branch/tag/SHA) to check out and work on'
required: false
type: string
default: 'master'
jobs:
run-claude-task:
runs-on: blacksmith-4vcpu-ubuntu-2204
timeout-minutes: 60
permissions:
contents: write
pull-requests: write
issues: write
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ inputs.ref }}
fetch-depth: 1
token: ${{ steps.generate_token.outputs.token }}
- name: Configure git remote with token
run: git remote set-url origin "https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/${{ github.repository }}.git"
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
- name: Create working branch
run: |
BRANCH_NAME="claude/task-${{ github.run_id }}-${{ github.run_attempt }}"
echo "BRANCH_NAME=$BRANCH_NAME" >> "$GITHUB_ENV"
git checkout -b "$BRANCH_NAME"
- name: Configure git author
run: |
git config user.name "${{ github.actor }}"
git config user.email "${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com"
echo "Git author configured as: ${{ github.actor }}"
- name: Prepare Claude prompt
env:
INPUT_TASK: ${{ inputs.task }}
USE_RAW_PROMPT: ${{ inputs.use_raw_prompt }}
run: node .github/scripts/claude-task/prepare-claude-prompt.mjs
- name: Create MCP config
run: |
cat > /tmp/mcp-config.json << 'MCPEOF'
{
"mcpServers": {
"linear": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://mcp.linear.app/sse",
"--header",
"Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer MCP_LINEAR_API_KEY_PLACEHOLDER"
}
},
"notion": {
"command": "npx",
"args": ["-y", "@notionhq/notion-mcp-server"],
"env": {
"OPENAPI_MCP_HEADERS": "{\"Authorization\":\"Bearer MCP_NOTION_TOKEN_PLACEHOLDER\",\"Notion-Version\":\"2025-02-19\"}"
}
}
}
}
MCPEOF
sed -i "s|MCP_LINEAR_API_KEY_PLACEHOLDER|${{ secrets.MCP_LINEAR_API_KEY }}|g" /tmp/mcp-config.json
sed -i "s|MCP_NOTION_TOKEN_PLACEHOLDER|${{ secrets.MCP_NOTION_TOKEN }}|g" /tmp/mcp-config.json
- name: Run Claude
id: claude
continue-on-error: true
uses: anthropics/claude-code-action@d668cc452deb931732f24c6b1bbf24d97bc0c586 # v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ steps.generate_token.outputs.token }}
prompt: ${{ env.CLAUDE_PROMPT }}
show_full_output: ${{ inputs.suppress_output != true }}
display_report: false
settings: |
{
"permissions": {
"allow": [
"Bash",
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
"TodoWrite",
"Skill",
"Task",
"mcp__linear__*",
"mcp__notion__*"
]
}
}
claude_args: |
--model ${{ inputs.model }} --max-turns ${{ inputs.max_turns }} --mcp-config /tmp/mcp-config.json
- name: Push branch
if: always()
run: |
if ! git diff --quiet "${{ inputs.ref }}" 2>/dev/null; then
git push -u origin "$BRANCH_NAME"
echo "::notice::Changes pushed to branch $BRANCH_NAME"
else
echo "::notice::No changes to push."
fi
- name: Summary
if: always()
env:
CLAUDE_OUTCOME: ${{ steps.claude.outcome }}
CLAUDE_SESSION_ID: ${{ steps.claude.outputs.session_id }}
run: |
{
echo "## Claude Task Runner"
echo "**Branch:** ${BRANCH_NAME:-N/A}"
echo "**Claude outcome:** $CLAUDE_OUTCOME"
if [ -n "$CLAUDE_SESSION_ID" ]; then
echo "**Session ID:** \`$CLAUDE_SESSION_ID\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Call resume url
if: always() && inputs.resumeUrl != ''
env:
RESUME_URL: ${{ inputs.resumeUrl }}
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
CLAUDE_OUTCOME: ${{ steps.claude.outcome }}
CLAUDE_SESSION_ID: ${{ steps.claude.outputs.session_id }}
run: node .github/scripts/claude-task/resume-callback.mjs
- name: Check final status
if: always()
run: |
if [ "${{ steps.claude.outcome }}" = "failure" ]; then
echo "::error::Claude task failed. Check the 'Run Claude' step logs for details."
exit 1
fi

48
.github/workflows/util-claude.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: 'Util: Claude'
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude-code-action:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 1
- name: Run Claude PR Action
uses: anthropics/claude-code-action@d668cc452deb931732f24c6b1bbf24d97bc0c586 # v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Or use OAuth token instead:
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
timeout_minutes: '60'
# mode: tag # Default: responds to @claude mentions
# Optional: Restrict network access to specific domains only
# experimental_allowed_domains: |
# .anthropic.com
# .github.com
# api.github.com
# .githubusercontent.com
# bun.sh
# registry.npmjs.org
# .blob.core.windows.net

View File

@@ -0,0 +1,40 @@
name: 'Util: Cleanup abandoned release branches'
on:
pull_request:
types: [closed]
jobs:
delete-release-branch:
# Only if PR was closed without merge
if: >
github.event.pull_request.merged == false
runs-on: ubuntu-slim
permissions:
contents: write
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: npm install --prefix=.github/scripts --no-package-lock
- name: Cleanup release branch if PR qualifies
run: node .github/scripts/cleanup-release-branch.mjs
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
GITHUB_EVENT_PATH: ${{ github.event_path }}
GITHUB_REPOSITORY: ${{ github.repository }}

View File

@@ -0,0 +1,27 @@
name: 'Util: Cleanup CI Docker Images'
on:
schedule:
# Daily cleanup at 3 AM UTC
- cron: '0 3 * * *'
jobs:
cleanup:
name: 'Delete stale CI images'
runs-on: ubuntu-slim
permissions:
packages: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Delete stale CI images
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 --stale 1

84
.github/workflows/util-data-tooling.yml vendored Normal file
View File

@@ -0,0 +1,84 @@
name: 'Util: Data Tooling'
# TODO: Uncomment this after it works on a manual invocation
# on:
# pull_request:
# branches:
# - '**'
# - '!release/*'
on:
workflow_dispatch:
jobs:
sqlite-export-sqlite-import:
name: sqlite database export -> sqlite database import
runs-on: blacksmith-2vcpu-ubuntu-2204
outputs:
db_changed: ${{ steps.paths-filter.outputs.db == 'true' }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# with:
# ref: refs/pull/${{ github.event.pull_request.number }}/merge
- name: Check for frontend changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: paths-filter
with:
filters: |
db:
- packages/@n8n/db/**
- packages/cli/**
- name: Setup, build and export sqlite
if: steps.paths-filter.outputs.db == 'true'
uses: ./.github/actions/setup-nodejs
with:
build-command: |
pnpm build
./packages/cli/bin/n8n export:entities --outputDir packages/cli/commands/export/outputs
./packages/cli/bin/n8n import:entities --inputDir packages/cli/commands/export/outputs --truncateTables
postgres-export-postgres-import:
name: postgres export -> postgres import
runs-on: blacksmith-2vcpu-ubuntu-2204
outputs:
db_changed: ${{ steps.paths-filter.outputs.db == 'true' }}
env:
DB_TYPE: postgresdb
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_HOST: localhost
DB_POSTGRESDB_PORT: 5432
DB_POSTGRESDB_USER: postgres
DB_POSTGRESDB_PASSWORD: password
DB_POSTGRESDB_POOL_SIZE: 1 # Detect connection pooling deadlocks
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# with:
# ref: refs/pull/${{ github.event.pull_request.number }}/merge
- name: Check for db changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: paths-filter
with:
filters: |
db:
- packages/@n8n/db/**
- packages/cli/**
- name: Setup and Build
if: steps.paths-filter.outputs.db == 'true'
uses: ./.github/actions/setup-nodejs
- name: Start Postgres
if: steps.paths-filter.outputs.db == 'true'
uses: isbang/compose-action@802a148945af6399a338c7906c267331b39a71af # v2.0.0
with:
compose-file: ./.github/docker-compose.yml
services: |
postgres
- name: Export postgres
if: steps.paths-filter.outputs.db == 'true'
run: ./packages/cli/bin/n8n export:entities --outputDir packages/cli/commands/export/outputs
- name: Import postgres
if: steps.paths-filter.outputs.db == 'true'
run: ./packages/cli/bin/n8n import:entities --inputDir packages/cli/commands/export/outputs --truncateTables

View File

@@ -0,0 +1,45 @@
name: 'Util: Determine current versions'
on:
workflow_dispatch:
workflow_call:
outputs:
stable:
description: 'Stable release version'
value: ${{ jobs.get-versions.outputs.stable }}
beta:
description: 'Beta release version'
value: ${{ jobs.get-versions.outputs.beta }}
v1:
description: 'v1 release version'
value: ${{ jobs.get-versions.outputs.v1 }}
jobs:
get-versions:
runs-on: ubuntu-latest
outputs:
stable: ${{ steps.get-tags.outputs.stable }}
beta: ${{ steps.get-tags.outputs.beta }}
v1: ${{ steps.get-tags.outputs.v1 }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: npm install --prefix=.github/scripts --no-package-lock
- name: Extract release versions
id: get-tags
run: node ./.github/scripts/get-release-versions.mjs
- name: Print detected versions
run: |
echo "Stable: ${{ steps.get-tags.outputs.stable }}"
echo "Beta: ${{ steps.get-tags.outputs.beta }}"
echo "v1: ${{ steps.get-tags.outputs.v1 }}"

View File

@@ -0,0 +1,27 @@
name: 'Util: Ensure release candidate branches'
on:
workflow_dispatch:
workflow_call:
jobs:
ensure-release-candidate-branches:
name: Ensure release-candidate branches
runs-on: ubuntu-slim
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
fetch-tags: true
- name: Setup NodeJS
uses: ./.github/actions/setup-nodejs
with:
install-command: npm install --prefix=.github/scripts --no-package-lock
build-command: ''
- name: Ensure release-candidate branches
run: node ./.github/scripts/ensure-release-candidate-branches.mjs

View File

@@ -0,0 +1,29 @@
name: 'Util: Notify PR Status'
on:
pull_request_review:
types: [submitted, dismissed]
pull_request:
types: [closed]
jobs:
notify:
runs-on: ubuntu-latest
if: >-
(github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
(github.event_name == 'pull_request_review' && github.event.review.state == 'dismissed') ||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true) ||
(github.event_name == 'pull_request' && github.event.pull_request.merged == false && github.event.action == 'closed')
env:
NOTIFY_URL: ${{ secrets.N8N_NOTIFY_PR_STATUS_CHANGED_URL }}
steps:
- uses: fjogeleit/http-request-action@bf78da14118941f7e940279dd58f67e863cbeff6 # v1
if: ${{ env.NOTIFY_URL != '' && !contains(github.event.pull_request.labels.*.name, 'community') }}
name: Notify
env:
PR_URL: ${{ github.event.pull_request.html_url }}
with:
url: ${{ secrets.N8N_NOTIFY_PR_STATUS_CHANGED_URL }}
method: 'POST'
customHeaders: '{ "x-api-token": "${{ secrets.N8N_NOTIFY_PR_STATUS_CHANGED_TOKEN }}" }'
data: '{ "event_name": "${{ github.event_name }}", "pr_url": "${{ env.PR_URL }}", "event": ${{ toJSON(github.event) }} }'

155
.github/workflows/util-sync-api-docs.yml vendored Normal file
View File

@@ -0,0 +1,155 @@
name: 'Util: Sync API Docs'
on:
# Triggers for the master branch if relevant Public API files have changed
push:
branches:
- master
paths:
# Trigger if:
# - any of the public API files change
- 'packages/cli/src/public-api/**/*.{css,yaml,yml}'
# - the build script or dependencies change
- 'packages/cli/package.json'
# - any main dependencies change
- 'pnpm-lock.yaml'
# Allow manual trigger
workflow_dispatch:
jobs:
sync-public-api:
runs-on: ubuntu-latest
permissions:
contents: read
actions: read # Needed for `gh` to read the workflow history
steps:
- name: Checkout Main n8n Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0 # Fetch all history for git log
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
- name: Build Public API Schema
run: pnpm run build:data
working-directory: ./packages/cli
- name: Verify OpenAPI schema exists
id: verify_file
run: |
if [[ -f "packages/cli/dist/public-api/v1/openapi.yml" ]]; then
echo "OpenAPI file found: packages/cli/dist/public-api/v1/openapi.yml"
echo "file_exists=true" >> "$GITHUB_OUTPUT"
else
echo "ERROR: OpenAPI file not found at packages/cli/dist/public-api/v1/openapi.yml after build."
echo "file_exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Generate GitHub App Token
if: steps.verify_file.outputs.file_exists == 'true'
id: generate_token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: n8n-docs
- name: Checkout Docs Repository
if: steps.verify_file.outputs.file_exists == 'true'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: n8n-io/n8n-docs
token: ${{ steps.generate_token.outputs.token }}
path: public-docs
- name: Copy OpenAPI file to Docs Repo
if: steps.verify_file.outputs.file_exists == 'true'
run: |
# Destination path within the 'public-docs' checkout directory
DOCS_TARGET_PATH="public-docs/docs/api/v1/openapi.yml"
echo "Copying 'packages/cli/dist/public-api/v1/openapi.yml' to '${DOCS_TARGET_PATH}'"
cp packages/cli/dist/public-api/v1/openapi.yml "${DOCS_TARGET_PATH}"
- name: Find Relevant Source Commits
id: find_source_commits
if: steps.verify_file.outputs.file_exists == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "Finding last successful workflow run..."
LAST_SUCCESS_SHA=$(gh run list \
--workflow "${{ github.workflow }}" \
--branch "${{ github.ref_name }}" \
--status "success" \
--json "headSha" \
--jq '.[0].headSha // empty' \
-L 1)
RELEVANT_COMMITS=""
if [[ -n "$LAST_SUCCESS_SHA" ]]; then
echo "Last successful commit: $LAST_SUCCESS_SHA"
echo "Current commit: ${{ github.sha }}"
# Find all commits between the last success and now that touched the relevant files
# NOTE: The pathspecs here mirror the workflow's 'paths' trigger for precision
RELEVANT_COMMITS=$(git log --pretty=format:"- [%h](https://github.com/${{ github.repository }}/commit/%H) %s" "${LAST_SUCCESS_SHA}..${{ github.sha }}" -- \
'packages/cli/src/public-api/**/*.css' \
'packages/cli/src/public-api/**/*.yaml' \
'packages/cli/src/public-api/**/*.yml')
fi
if [[ -z "$RELEVANT_COMMITS" ]]; then
if [[ -z "$LAST_SUCCESS_SHA" ]]; then
echo "No previous successful run found. Using current commit as source."
else
echo "No commits touching source files found. Linking to trigger commit (likely a dependency update)."
fi
FULL_SHA="${{ github.sha }}"
SHORT_SHA="${FULL_SHA:0:7}"
SOURCE_LINK="Source commit: [$SHORT_SHA](https://github.com/${{ github.repository }}/commit/$FULL_SHA)"
else
echo "Found relevant source commits:"
echo "$RELEVANT_COMMITS"
# Build the string manually to avoid heredoc capturing leading whitespace from YAML
SOURCE_LINK="Source commit(s):"$'\n'"$RELEVANT_COMMITS"
fi
{
echo "source_link<<EOF"
echo "$SOURCE_LINK"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Create PR in Docs Repo
if: steps.verify_file.outputs.file_exists == 'true'
# Pin v7.0.8
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412
with:
token: ${{ steps.generate_token.outputs.token }}
path: public-docs
commit-message: 'feat(public-api): Update Public API schema'
committer: GitHub <noreply@github.com>
author: ${{ github.actor }} <${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com>
signoff: false
# Create a single branch for multiple PRs
branch: 'chore/sync-public-api-schema'
delete-branch: false
title: 'chore: Update Public API schema'
body: |
Automated update of the Public API OpenAPI YAML schema.
This PR was generated by a GitHub Action in the [${{ github.repository }} repository](https://github.com/${{ github.repository }}).
${{ steps.find_source_commits.outputs.source_link }}
Please review the changes and merge if appropriate.

View File

@@ -0,0 +1,67 @@
name: 'Util: Update Node Popularity'
on:
schedule:
# Run every Monday at 00:00 UTC
- cron: '0 0 * * 1'
workflow_dispatch: # Allow manual trigger for testing
permissions:
contents: write
pull-requests: write
jobs:
update-popularity:
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'schedule' && github.repository == 'n8n-io/n8n')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js and Dependencies
uses: ./.github/actions/setup-nodejs
with:
build-command: '' # Skip build, we only need to fetch data
- name: Fetch node popularity data
run: |
cd packages/frontend/editor-ui
node scripts/fetch-node-popularity.mjs
env:
N8N_FAIL_ON_POPULARITY_FETCH_ERROR: 'false' # Don't fail if API is down
- name: Format generated file
run: pnpm biome format --write packages/frontend/editor-ui/data/node-popularity.json
- name: Check for changes
id: check-changes
run: |
if git diff --quiet packages/frontend/editor-ui/data/node-popularity.json; then
echo "No changes to popularity data"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "Popularity data has changed"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
- name: Create Pull Request
if: steps.check-changes.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
branch-token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: Update node popularity data'
title: 'chore: Update node popularity data'
body: |
This automated PR updates the node popularity data used for sorting nodes in the node creator panel.
The data is fetched weekly from the n8n telemetry endpoint to reflect current usage patterns.
_Generated by the weekly node popularity update workflow._
branch: update-node-popularity
base: master
delete-branch: true
reviewers: Matsuuu
author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>