n8n-io/n8n

70 workflows · maturity 83% · 15 patterns · GitHub ↗

Security 48.69/100

Security dimensions

permissions
5.4
security scan
8.3
supply chain
20
secret handling
15
harden runner
0

Tools: aquasecurity/trivy-action, github/codeql-action/upload-sarif

Workflows (70)

backport perms .github/workflows/backport.yml
Triggers
pull_request, workflow_dispatch
Runs on
ubuntu-slim
Jobs
backport
Actions
actions/create-github-app-token, korthout/backport-action
Commands
  • node .github/scripts/compute-backport-targets.mjs
View raw YAML
name: 'Util: Backport pull request changes'

run-name: Backport pull request ${{ github.event.pull_request.number || inputs.pull-request-id }}

on:
  pull_request:
    types: [closed]
  workflow_dispatch:
    inputs:
      pull-request-id:
        description: 'The ID number of the pull request (e.g. 3342). No #, no extra letters.'
        required: true
        type: string

permissions:
  contents: write
  pull-requests: write

jobs:
  backport:
    if: |
      github.event.pull_request.merged == true ||
      github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-slim
    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 }}

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          token: ${{ steps.generate-token.outputs.token }}
          fetch-depth: 0

      - name: Setup Node.js
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: ''
          install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace

      - name: Compute backport targets
        id: targets
        env:
          PULL_REQUEST_ID: ${{ inputs.pull-request-id }}
          GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
        run: node .github/scripts/compute-backport-targets.mjs

      - name: Backport
        if: steps.targets.outputs.target_branches != ''
        uses: korthout/backport-action@4aaf0e03a94ff0a619c9a511b61aeb42adea5b02 # v4.2.0
        with:
          github_token: ${{ steps.generate-token.outputs.token }}
          source_pr_number: ${{ github.event.pull_request.number || inputs.pull-request-id }}
          target_branches: ${{ steps.targets.outputs.target_branches }}
          pull_description: |-
            # Description
            Backport of #${pull_number} to `${target_branch}`.

            ## Checklist for the author (@${pull_author}) to go through.

            - [ ] Review the backport changes
            - [ ] Fix possible conflicts
            - [ ] Merge to target branch

            After this PR has been merged, it will be picked up in the next patch release for release track.

            # Original description

            ${pull_description}
          pull_title: ${pull_title} (backport to ${target_branch})
          add_author_as_assignee: true
          add_author_as_reviewer: true
          copy_assignees: true
          copy_requested_reviewers: false
          copy_labels_pattern: '^(?!Backport to\b).+' # Copy everything except backport labels
          add_labels: 'automation:backport'
          experimental: >
            {
              "conflict_resolution": "draft_commit_conflicts"
            }
build-base-image matrix .github/workflows/build-base-image.yml
Triggers
push, pull_request, workflow_dispatch
Runs on
ubuntu-latest
Jobs
build
Matrix
node_version→ 22, 24.13.1, 25
Actions
docker/setup-qemu-action, docker/setup-buildx-action, docker/build-push-action
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up QEMU
        uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0

      - 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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.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
build-benchmark-image .github/workflows/build-benchmark-image.yml
Triggers
workflow_dispatch, push
Runs on
ubuntu-latest
Jobs
build
Actions
docker/setup-qemu-action, docker/setup-buildx-action, docker/build-push-action
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up QEMU
        uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0

      - name: Login to GitHub Container Registry
        uses: ./.github/actions/docker-registry-login

      - name: Build
        uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.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
build-windows .github/workflows/build-windows.yml
Triggers
workflow_dispatch, workflow_call, pull_request
Runs on
windows-latest
Jobs
build
Actions
slackapi/slack-github-action
Commands
  • 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"
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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 }}"
                    }
                  ]
                }
              ]
            }
ci-check-pr-title .github/workflows/ci-check-pr-title.yml
Triggers
pull_request
Runs on
ubuntu-latest
Jobs
check-pr-title
Actions
n8n-io/validate-n8n-pull-request-title
View raw YAML
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 }}
ci-check-release-from-fork .github/workflows/ci-check-release-from-fork.yml
Triggers
pull_request
Runs on
ubuntu-slim
Jobs
block-fork-prs
Commands
  • if [ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then echo "fork=true" >> "$GITHUB_OUTPUT" else echo "fork=false" >> "$GITHUB_OUTPUT" fi
  • echo "PR from fork targeting a release branch is not allowed." exit 1
View raw YAML
name: 'CI: Block fork PRs to release branches'

on:
  pull_request:
    branches:
      - 'release/**'
    types:
      - opened
      - reopened
      - synchronize
      - ready_for_review
      - edited

jobs:
  block-fork-prs:
    runs-on: ubuntu-slim
    permissions:
      pull-requests: write
      contents: read

    steps:
      - name: Check if PR is from a fork
        id: check
        run: |
          if [ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then
            echo "fork=true" >> "$GITHUB_OUTPUT"
          else
            echo "fork=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Comment on PR explaining the block
        if: steps.check.outputs.fork == 'true'
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
        with:
          script: |
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.payload.pull_request.number,
            });

            const alreadyCommented = comments.some(
              (c) => c.user.login === 'github-actions[bot]' && c.body.includes('Pull request blocked')
            );

            if (!alreadyCommented) {
                const body = `
              🚫 **Pull request blocked**

              Pull requests from **forked repositories** are not allowed to target **release branches** in this repository.

              **Target branch:** \`${context.payload.pull_request.base.ref}\`

              If you believe this was blocked in error, contact the repository maintainers.
              `;

                await github.rest.issues.createComment({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: context.payload.pull_request.number,
                  body
                });
            }

      - name: Fail workflow if from fork
        if: steps.check.outputs.fork == 'true'
        run: |
          echo "PR from fork targeting a release branch is not allowed."
          exit 1
ci-detect-new-packages .github/workflows/ci-detect-new-packages.yml
Triggers
pull_request
Runs on
ubuntu-latest
Jobs
detect-new-packages
Actions
slackapi/slack-github-action
Commands
  • node .github/scripts/detect-new-packages.mjs
View raw YAML
name: 'CI: Detect New Packages on Master'

on:
  pull_request:
    types:
      - closed
    branches:
      - master

jobs:
  detect-new-packages:
    name: Check for new unpublished packages
    runs-on: ubuntu-latest
    if: github.event.pull_request.merged == true
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Setup Node.js
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: ''
          install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace

      - name: Check for new unpublished packages
        id: detect
        continue-on-error: true
        run: node .github/scripts/detect-new-packages.mjs

      - name: Notify Slack about new packages
        if: steps.detect.outcome == 'failure' && steps.detect.outputs.packages != ''
        uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
        with:
          method: chat.postMessage
          token: ${{ secrets.RELEASE_HELPER_SLACK_TOKEN }}
          payload: |
            channel: C036AELNMV0
            text: |-
              :warning: *New unpublished packages detected* after merging <${{ github.event.pull_request.html_url }}|PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}>

              The following packages do not exist on npm yet: `${{ steps.detect.outputs.packages }}`

              *If a package is not intended for npm*, set `"private": true` in its `package.json` to exclude it from future checks.

              *Otherwise, to unblock the next release:*
              1. Run the <${{ github.server_url }}/${{ github.repository }}/actions/workflows/release-publish-new-package.yml|Release: Publish New Package> workflow for each package
              2. Configure Trusted Publishing on npmjs.com (owner: `n8n-io`, repo: `n8n`, workflow: `release-publish.yml`)
ci-master matrix .github/workflows/ci-master.yml
Triggers
push
Runs on
ubuntu-latest, ubuntu-latest
Jobs
build-github, unit-test, lint, performance, notify-on-failure
Matrix
node-version→ 22.x, 24.13.1, 25.x
Actions
act10ns/slack
View raw YAML
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 }}
      QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }}
      QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }}
      QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }}
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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: inherit

  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 }})
ci-pull-requests .github/workflows/ci-pull-requests.yml
Triggers
pull_request, merge_group
Runs on
${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }}, ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}, ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}, ubuntu-slim
Jobs
install-and-build, unit-test, typecheck, lint, check-packaging, e2e-tests, db-tests, performance, security-checks, workflow-scripts, chromatic, required-checks
Commands
  • echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
  • pnpm format:check
  • pnpm -r pack --dry-run
View raw YAML
name: 'CI: Pull Requests (Build, Test, Lint)'

on:
  pull_request:
  merge_group:

concurrency:
  group: ci-${{ github.event.pull_request.number || github.event.merge_group.head_sha || github.ref }}
  cancel-in-progress: true

jobs:
  install-and-build:
    name: Install & Build
    runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }}
    env:
      NODE_OPTIONS: '--max-old-space-size=6144'
      CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
      QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }}
      QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }}
      QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }}
    outputs:
      ci: ${{ fromJSON(steps.ci-filter.outputs.results).ci == true }}
      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 }}
      workflow_scripts: ${{ fromJSON(steps.ci-filter.outputs.results)['workflow-scripts'] == 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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/**
            workflow-scripts: .github/scripts/**
            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: inherit

  typecheck:
    name: Typecheck
    if: needs.install-and-build.outputs.ci == 'true'
    runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
    needs: install-and-build
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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 }}

  check-packaging:
    name: Check packaging
    if: needs.install-and-build.outputs.ci == 'true'
    runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
    needs: install-and-build
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ needs.install-and-build.outputs.commit_sha }}

      - name: Setup Node.js
        uses: ./.github/actions/setup-nodejs

      - name: Check packaging
        shell: bash
        run: |
          pnpm -r pack --dry-run

  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

  workflow-scripts:
    name: Workflow scripts
    needs: install-and-build
    if: needs.install-and-build.outputs.workflow_scripts == 'true'
    uses: ./.github/workflows/test-workflow-scripts-reusable.yml
    with:
      ref: ${{ needs.install-and-build.outputs.commit_sha }}
    secrets: inherit

  chromatic:
    name: Chromatic
    needs: install-and-build
    if: needs.install-and-build.outputs.design_system == 'true' && github.event_name == 'pull_request'
    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,
        check-packaging,
        e2e-tests,
        db-tests,
        performance,
        security-checks,
        workflow-scripts,
        chromatic,
      ]
    if: always()
    runs-on: ubuntu-slim
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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) }}
ci-python .github/workflows/ci-python.yml
Triggers
pull_request, push
Runs on
ubuntu-latest
Jobs
checks
Actions
astral-sh/setup-uv, extractions/setup-just, codecov/codecov-action
Commands
  • uv python install 3.13
  • just sync-all
  • just format-check
  • just typecheck
  • just lint
  • uv run pytest --cov=src --cov-report=xml --cov-report=term-missing
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Install uv
        uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.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@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: packages/@n8n/task-runner-python/coverage.xml
          flags: tests
          name: task-runner-python
          fail_ci_if_error: false
ci-restrict-private-merges perms .github/workflows/ci-restrict-private-merges.yml
Triggers
pull_request
Runs on
ubuntu-latest
Jobs
check_branch
Commands
  • set -euo pipefail head="$HEAD_REF" if [[ "$head" == bundle/* ]]; then echo "allowed=true" >> "$GITHUB_OUTPUT" else echo "allowed=false" >> "$GITHUB_OUTPUT" fi
  • echo "::error::You can only merge to master from a bundle/* branch. Got '$HEAD_REF'." exit 1
  • echo "OK: '$HEAD_REF' can merge into '$BASE_REF'"
View raw YAML
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
        env:
          HEAD_REF: ${{ github.head_ref }}
        run: |
          set -euo pipefail
          head="$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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
        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' }}
        env:
          HEAD_REF: ${{ github.head_ref }}
        run: |
          echo "::error::You can only merge to master from a bundle/* branch. Got '$HEAD_REF'."
          exit 1

      - name: Allowed
        if: ${{ steps.validate.outputs.allowed == 'true' }}
        env:
          HEAD_REF: ${{ github.head_ref }}
          BASE_REF: ${{ github.base_ref }}
        run: |
          echo "OK: '$HEAD_REF' can merge into '$BASE_REF'"
docker-build-push matrix .github/workflows/docker-build-push.yml
Triggers
schedule, workflow_call, workflow_dispatch
Runs on
ubuntu-latest, ${{ matrix.runner }}, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
determine-build-context, build-and-push-docker, create_multi_arch_manifest, call-success-url, provenance-n8n, provenance-runners, provenance-runners-distroless, vex-attestation, security-scan, security-scan-runners, notify-on-failure
Matrix
Actions
useblacksmith/build-push-action, useblacksmith/build-push-action, useblacksmith/build-push-action, docker/setup-buildx-action, sigstore/cosign-installer, docker/login-action, act10ns/slack
Commands
  • 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 }}"
  • 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
  • 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 }}"
  • 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
  • node .github/scripts/docker/get-manifest-digests.mjs
  • echo "Calling success URL: ${{ env.SUCCESS_URL }}" curl -v "${{ env.SUCCESS_URL }}" || echo "Failed to call success URL"
  • 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 }}
  • 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 }}
View raw YAML
# 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0

      - 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@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a
    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@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a
    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@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a
    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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Install Cosign
        uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1

      - name: Login to GHCR
        uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: 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 }}
docker-build-smoke .github/workflows/docker-build-smoke.yml
Triggers
schedule, pull_request, workflow_dispatch
Runs on
blacksmith-4vcpu-ubuntu-2204, ubuntu-slim
Jobs
docker-smoke-test, notify-on-failure
Actions
slackapi/slack-github-action
Commands
  • docker run --rm -d --name n8n-smoke-test n8nio/n8n:local sleep 5 docker logs n8n-smoke-test 2>&1 | tail -20 docker stop n8n-smoke-test
View raw YAML
name: 'Docker Build Smoke Test'

# Verifies the full Docker build chain works without any caching.
# Catches native module compilation failures (e.g., isolated-vm, sqlite3)
# that layer caching can mask in the regular E2E pipeline.
#
# Full chain: pnpm install → pnpm build (no Turbo cache) →
#             build base image (no Docker cache) →
#             build n8n + runners images (no Docker cache)

on:
  schedule:
    - cron: '0 3 * * *' # 3:00 AM UTC, after the nightly Docker build at midnight
  pull_request:
    paths:
      - 'docker/images/n8n/**'
      - 'docker/images/n8n-base/**'
      - 'docker/images/runners/**'
      - 'scripts/build-n8n.mjs'
      - 'scripts/dockerize-n8n.mjs'
  workflow_dispatch:

jobs:
  docker-smoke-test:
    name: 'Docker Build (no cache)'
    runs-on: blacksmith-4vcpu-ubuntu-2204
    if: ${{ !github.event.pull_request.head.repo.fork }}
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Login to DHI Registry (for base image)
        uses: ./.github/actions/docker-registry-login
        with:
          login-ghcr: 'false'
          login-dhi: 'true'
          dockerhub-username: ${{ secrets.DOCKER_USERNAME }}
          dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build full chain (no cache)
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: 'pnpm build:docker:clean'
          enable-docker-cache: true

      - name: Verify n8n image starts
        run: |
          docker run --rm -d --name n8n-smoke-test n8nio/n8n:local
          sleep 5
          docker logs n8n-smoke-test 2>&1 | tail -20
          docker stop n8n-smoke-test

  notify-on-failure:
    name: Notify on nightly smoke test failure
    runs-on: ubuntu-slim
    needs: [docker-smoke-test]
    if: needs.docker-smoke-test.result == 'failure' && github.event_name == 'schedule'
    steps:
      - uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
        with:
          method: chat.postMessage
          token: ${{ secrets.QBOT_SLACK_TOKEN }}
          payload: |
            channel: C0A9RLY8Y20
            text: "🚨 Nightly Docker smoke test failed (no-cache build) - ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
release-create-github-releases perms .github/workflows/release-create-github-releases.yml
Triggers
workflow_call, workflow_dispatch
Runs on
ubuntu-slim
Jobs
create-github-releases
Actions
actions/create-github-app-token
Commands
  • node ./.github/scripts/create-github-release.mjs
View raw YAML
name: 'Release: Create GitHub Releases'
run-name: 'Creating GitHub Releases for ${{ inputs.version-tag }} (${{ inputs.track }})'

on:
  workflow_call:
    inputs:
      track:
        required: true
        type: string
      version-tag:
        required: true
        type: string
      body:
        required: true
        type: string
      commit:
        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).'
        required: true
        type: string
      body:
        description: 'Release notes body.'
        required: true
        type: string
      commit:
        description: 'Commitish the release points to (e.g. branch name or SHA).'
        required: true
        type: string

permissions:
  contents: write
  id-token: write

jobs:
  create-github-releases:
    name: Create GitHub releases
    runs-on: ubuntu-slim
    environment: release

    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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          token: ${{ steps.generate_token.outputs.token }}
          fetch-depth: 1

      - name: Setup NodeJS
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: ''
          install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace

      - name: Create GitHub releases
        env:
          GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
          RELEASE_TAG: ${{ inputs.version-tag }}
          BODY: ${{ inputs.body }}
          IS_PRE_RELEASE: ${{ inputs.track == 'beta' }}
          MAKE_LATEST: ${{ inputs.track == 'stable' }}
          COMMIT: ${{ inputs.commit }}
          ADDITIONAL_TAGS: ${{ inputs.track }}
        run: node ./.github/scripts/create-github-release.mjs
release-create-minor-pr .github/workflows/release-create-minor-pr.yml
Triggers
workflow_dispatch, schedule
Runs on
ubuntu-latest
Jobs
create-release-pr, notify-slack
Actions
slackapi/slack-github-action
View raw YAML
name: 'Release: Create Minor Release PR'

on:
  workflow_dispatch:
  schedule:
    - cron: 0 13 * * 1 # 2pm CET (UTC+1), Monday

jobs:
  create-release-pr:
    name: Create release PR
    uses: ./.github/workflows/release-create-pr.yml
    secrets: inherit
    with:
      base-branch: master
      release-type: minor

  notify-slack:
    name: Notify Slack
    needs: [create-release-pr]
    if: needs.create-release-pr.result == 'success' && needs.create-release-pr.outputs.pull-request-number != ''
    runs-on: ubuntu-latest
    steps:
      - name: Post to Slack
        uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
        with:
          method: chat.postMessage
          token: ${{ secrets.RELEASE_HELPER_SLACK_TOKEN }}
          payload: |
            channel: C036AELNMV0
            text: ":rocket: Minor release PR created. <${{ github.server_url }}/${{ github.repository }}/pull/${{ needs.create-release-pr.outputs.pull-request-number }}|View PR> — close it to cancel the release."
release-create-patch-pr .github/workflows/release-create-patch-pr.yml
Triggers
workflow_call, workflow_dispatch
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
determine-version-info, skip-release-pr, create-release-pr, notify-slack
Actions
slackapi/slack-github-action
Commands
  • node .github/scripts/determine-release-candidate-branch-for-track.mjs
  • echo "No new commits found between the release candidate branch and the current release tag. Skipping PR creation."
View raw YAML
name: 'Release: Create Patch Release PR'
run-name: 'Release: Create Patch Release PR for track ${{ inputs.track }}'

on:
  workflow_call:
    inputs:
      track:
        description: 'Release Track'
        required: true
        type: string

  workflow_dispatch:
    inputs:
      track:
        description: 'Release Track'
        required: true
        type: choice
        options: [stable, beta, v1]

jobs:
  determine-version-info:
    name: Determine publishing track
    runs-on: ubuntu-latest
    outputs:
      release_candidate_branch: ${{ steps.determine-branch.outputs.release_candidate_branch }}
      should_update: ${{ steps.determine-branch.outputs.should_update }}
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0

      - name: Setup Node.js
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: ''
          install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace

      - name: Determine release candidate branch from track
        id: determine-branch
        env:
          TRACK: ${{ inputs.track }}
        run: node .github/scripts/determine-release-candidate-branch-for-track.mjs

  skip-release-pr:
    name: Skip release PR (no new commits)
    needs: [determine-version-info]
    if: needs.determine-version-info.outputs.should_update != 'true'
    runs-on: ubuntu-latest
    steps:
      - name: Log skip reason
        run: echo "No new commits found between the release candidate branch and the current release tag. Skipping PR creation."

  create-release-pr:
    name: Create release PR
    needs: [determine-version-info]
    if: needs.determine-version-info.outputs.should_update == 'true'
    uses: ./.github/workflows/release-create-pr.yml
    secrets: inherit
    with:
      base-branch: ${{ needs.determine-version-info.outputs.release_candidate_branch }}
      release-type: patch

  notify-slack:
    name: Notify Slack
    needs: [create-release-pr]
    if: needs.create-release-pr.result == 'success' && needs.create-release-pr.outputs.pull-request-number != ''
    runs-on: ubuntu-latest
    steps:
      - name: Post to Slack
        uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
        with:
          method: chat.postMessage
          token: ${{ secrets.RELEASE_HELPER_SLACK_TOKEN }}
          payload: |
            channel: C036AELNMV0
            text: ":rocket: Patch release PR created for *${{ inputs.track }}* track. <${{ github.server_url }}/${{ github.repository }}/pull/${{ needs.create-release-pr.outputs.pull-request-number }}|View PR> — close it to cancel the release."
release-create-pr perms .github/workflows/release-create-pr.yml
Triggers
workflow_call, workflow_dispatch
Runs on
ubuntu-latest
Jobs
create-release-pr, approve-and-automerge
Actions
actions/create-github-app-token, peter-evans/create-pull-request
Commands
  • git checkout "$BASE_BRANCH"
  • npm i -g corepack@0.33 corepack enable
  • echo "NEXT_RELEASE=$(node .github/scripts/bump-versions.mjs)" >> "$GITHUB_ENV"
  • node .github/scripts/update-changelog.mjs
  • git push -f origin "refs/remotes/origin/${{ env.BASE_BRANCH }}:refs/heads/release/${{ env.NEXT_RELEASE }}"
  • 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
View raw YAML
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

    outputs:
      pull-request-number:
        description: 'Number of the created pull request'
        value: ${{ jobs.create-release-pr.outputs.pull-request-number }}

  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

permissions:
  contents: write
  pull-requests: write

jobs:
  create-release-pr:
    runs-on: ubuntu-latest

    permissions:
      contents: write
      pull-requests: write

    timeout-minutes: 5

    outputs:
      pull-request-number: ${{ steps.create-pr.outputs.pull-request-number }}

    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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        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"

      - name: Setup Node.js
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: ''
          install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace

      - 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@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
        id: create-pr
        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 }}

  approve-and-automerge:
    needs: [create-release-pr]
    if: |
      needs.create-release-pr.outputs.pull-request-number != ''
    uses: ./.github/workflows/util-approve-and-set-automerge.yml
    secrets: inherit
    with:
      pull-request-number: ${{ needs.create-release-pr.outputs.pull-request-number }}
release-merge-tag-to-branch .github/workflows/release-merge-tag-to-branch.yml
Triggers
workflow_call
Runs on
ubuntu-latest
Jobs
merge-tag-to-branch
Actions
actions/create-github-app-token, act10ns/slack
Commands
  • 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
  • git fetch origin "refs/tags/n8n@${VERSION}:refs/tags/n8n@${VERSION}"
  • 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}"
  • git push origin "HEAD:${TARGET_BRANCH}"
View raw YAML
name: 'Release: Merge tag to branch'
run-name: 'Merge n8n@${{ inputs.version }} to ${{ inputs.target-branch }}'

on:
  workflow_call:
    inputs:
      version:
        description: 'The release version (e.g. 2.10.2)'
        required: true
        type: string
      target-branch:
        description: 'The branch to merge the release tag into (e.g. master or release-candidate/2.10.x)'
        required: true
        type: string

jobs:
  merge-tag-to-branch:
    name: Merge release tag to ${{ inputs.target-branch }}
    runs-on: ubuntu-latest
    environment: minor-release-tag-merge
    env:
      VERSION: ${{ inputs.version }}
      TARGET_BRANCH: ${{ inputs.target-branch }}
    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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ inputs.target-branch }}
          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 branch
        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 ${{ inputs.target-branch }}
        run: git push origin "HEAD:${TARGET_BRANCH}"

      - 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 ${{ inputs.target-branch }} failed for n8n@${{ inputs.version }} >
release-populate-cloud-with-releases .github/workflows/release-populate-cloud-with-releases.yml
Triggers
workflow_dispatch, workflow_call
Runs on
ubuntu-slim
Jobs
determine-changes
Commands
  • node ./.github/scripts/determine-release-version-changes.mjs
  • node ./.github/scripts/populate-cloud-databases.mjs
View raw YAML
name: 'Release: Populate cloud with releases'
run-name: 'Populate cloud with version n8n@${{ inputs.version }}'

on:
  workflow_dispatch:
    inputs:
      previous-version:
        description: 'The previous release version (e.g. 2.10.2)'
        required: true
        type: string
      version:
        description: 'The release version (e.g. 2.11.0)'
        required: true
        type: string
      experimental:
        description: 'If publishing experimental version'
        type: boolean
        default: false
  workflow_call:
    inputs:
      previous-version:
        description: 'The previous release version (e.g. 2.10.2)'
        required: true
        type: string
      version:
        description: 'The release version (e.g. 2.11.0)'
        required: true
        type: string
      experimental:
        description: 'If publishing experimental version'
        type: boolean
        default: false

jobs:
  determine-changes:
    runs-on: ubuntu-slim
    environment: release
    outputs:
      has_node_enhancements: ${{ steps.get-changes.outputs.has_node_enhancements }}
      has_core_changes: ${{ steps.get-changes.outputs.has_core_changes }}
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0

      - name: Setup Node.js
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: ''
          install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace

      - name: Extract changes
        id: get-changes
        env:
          PREVIOUS_VERSION_TAG: 'n8n@${{ inputs.previous-version }}'
          RELEASE_VERSION_TAG: 'n8n@${{ inputs.version }}'
        run: node ./.github/scripts/determine-release-version-changes.mjs

      - name: Populate databases
        id: populate-databases
        env:
          N8N_POPULATE_CLOUD_WEBHOOK_DATA: ${{ secrets.N8N_POPULATE_CLOUD_WEBHOOK_DATA }}
          PAYLOAD: |
            {
              "target_version_to_update": "${{ inputs.version }}",
              "has_node_enhancements": ${{ steps.get-changes.outputs.has_node_enhancements }},
              "has_core_changes": ${{ steps.get-changes.outputs.has_core_changes }},
              "has_breaking_change": false,
              "is_experimental": ${{ inputs.experimental }}
            }
        run: node ./.github/scripts/populate-cloud-databases.mjs
release-promote-github-release perms .github/workflows/release-promote-github-release.yml
Triggers
workflow_call, workflow_dispatch
Runs on
ubuntu-slim
Jobs
promote-github-releases
Actions
actions/create-github-app-token
Commands
  • node ./.github/scripts/promote-github-release.mjs
View raw YAML
name: 'Release: Promote GitHub Releases'
run-name: 'Promoting GitHub Release ${{ inputs.version-tag }} to latest'

on:
  workflow_call:
    inputs:
      version-tag:
        required: true
        type: string
  workflow_dispatch:
    inputs:
      version-tag:
        description: 'Version tag (e.g. n8n@2.7.0).'
        required: true
        type: string

permissions:
  contents: write

jobs:
  promote-github-releases:
    name: Promote GitHub release to latest
    runs-on: ubuntu-slim
    environment: release

    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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          token: ${{ steps.generate_token.outputs.token }}
          fetch-depth: 1

      - name: Setup NodeJS
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: ''
          install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace

      - name: Promote GitHub releases
        env:
          GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
          RELEASE_TAG: ${{ inputs.version-tag }}
        run: node ./.github/scripts/promote-github-release.mjs
release-publish .github/workflows/release-publish.yml
Triggers
pull_request
Runs on
ubuntu-latest, ubuntu-latest
Jobs
determine-version-info, publish-to-npm, publish-to-docker-hub, create-github-release, move-track-tag, promote-stable-tag, generate-and-attach-sbom, merge-release-tag-to-master, merge-release-tag-to-rc-branch, post-release
Commands
  • node .github/scripts/determine-version-info.mjs
  • node .github/scripts/detect-new-packages.mjs
  • pnpm --filter n8n publish --no-git-checks --dry-run pnpm publish -r --filter '!n8n' --no-git-checks --dry-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
  • pnpm --filter n8n publish --publish-branch "$PUBLISH_BRANCH" --access public --tag rc --no-git-checks
  • # Prefix version-like track names (e.g. "1", "v1") to avoid npm rejecting them as semver ranges if [[ "$PUBLISH_TAG" =~ ^v?[0-9] ]]; then PUBLISH_TAG="release-${PUBLISH_TAG}" fi pnpm publish -r --filter '!n8n' --publish-branch "$PUBLISH_BRANCH" --access public --tag "$PUBLISH_TAG" --no-git-checks
  • npm dist-tag rm n8n rc
View raw YAML
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 }}
      previous_version: ${{ steps.determine-info.outputs.previous_version }}
      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 }}
      rc_branch: ${{ steps.determine-info.outputs.rc_branch }}
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0

      - name: Setup Node.js
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: ''
          install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace

      - 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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
        env:
          PUBLISH_BRANCH: ${{ github.event.pull_request.base.ref }}
          PUBLISH_TAG: ${{ needs.determine-version-info.outputs.track == 'stable' && 'latest' || needs.determine-version-info.outputs.track }}
        run: |
          # Prefix version-like track names (e.g. "1", "v1") to avoid npm rejecting them as semver ranges
          if [[ "$PUBLISH_TAG" =~ ^v?[0-9] ]]; then
            PUBLISH_TAG="release-${PUBLISH_TAG}"
          fi
          pnpm publish -r --filter '!n8n' --publish-branch "$PUBLISH_BRANCH" --access public --tag "$PUBLISH_TAG" --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 GitHub Release
    needs: [determine-version-info, publish-to-npm, publish-to-docker-hub]
    if: github.event.pull_request.merged == true
    uses: ./.github/workflows/release-create-github-releases.yml
    with:
      track: ${{ needs.determine-version-info.outputs.track }}
      version-tag: 'n8n@${{ needs.determine-version-info.outputs.version }}'
      body: ${{ github.event.pull_request.body }}
      commit: ${{ github.event.pull_request.base.ref }}
    secrets: inherit

  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 on minor release
    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'
    uses: ./.github/workflows/release-merge-tag-to-branch.yml
    with:
      version: ${{ needs.determine-version-info.outputs.version }}
      target-branch: master
    secrets: inherit

  merge-release-tag-to-rc-branch:
    name: Merge release tag to RC branch on patch release
    needs: [determine-version-info, publish-to-npm, create-github-release]
    if: |
      github.event.pull_request.merged == true &&
      needs.determine-version-info.outputs.bump == 'patch' &&
      needs.determine-version-info.outputs.release_type != 'rc'
    uses: ./.github/workflows/release-merge-tag-to-branch.yml
    with:
      version: ${{ needs.determine-version-info.outputs.version }}
      target-branch: ${{ needs.determine-version-info.outputs.rc_branch }}
    secrets: inherit

  post-release:
    name: Run Post-release actions
    needs:
      [
        determine-version-info,
        publish-to-npm,
        create-github-release,
        move-track-tag,
        promote-stable-tag,
      ]
    if: |
      always() &&
      needs.publish-to-npm.result == 'success' &&
      needs.create-github-release.result == 'success' &&
      (needs.move-track-tag.result == 'success' || needs.move-track-tag.result == 'skipped') &&
      (needs.promote-stable-tag.result == 'success' || needs.promote-stable-tag.result == 'skipped')
    uses: ./.github/workflows/release-publish-post-release.yml
    with:
      track: ${{ needs.determine-version-info.outputs.track }}
      previous_version: ${{ needs.determine-version-info.outputs.previous_version }}
      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
release-publish-new-package .github/workflows/release-publish-new-package.yml
Triggers
workflow_dispatch
Runs on
ubuntu-latest
Jobs
publish-to-npm
Commands
  • echo "::error::This workflow can only be run from the master branch" exit 1
  • PACKAGE_NAME=$(node -p "require('./package.json').name") if [ "$PACKAGE_NAME" = "n8n-monorepo" ]; then echo "::error::Package 'n8n-monorepo' cannot be published." exit 1 fi if npm view "$PACKAGE_NAME" > /dev/null 2>&1; then echo "::error::Package '$PACKAGE_NAME' already exists on NPM. Use the regular release workflow for updates." exit 1 fi echo "Package '$PACKAGE_NAME' does not exist on NPM yet. Proceeding with publish."
  • echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_INITIAL_PUBLISH_TOKEN }}" > ~/.npmrc
  • pnpm publish --access public --no-git-checks
View raw YAML
name: 'Release: Publish New Package'

on:
  workflow_dispatch:
    inputs:
      package-path:
        description: 'Path to the package to publish (e.g. packages/@n8n/my-new-package)'
        required: true
        type: string

concurrency:
  group: release-new-package-${{ github.event.inputs.package-path }}
  cancel-in-progress: false

jobs:
  publish-to-npm:
    name: Publish to NPM
    runs-on: ubuntu-latest
    timeout-minutes: 30
    environment: release

    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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Setup and Build
        uses: ./.github/actions/setup-nodejs

      - name: Check package does not already exist on NPM
        working-directory: ${{ github.event.inputs.package-path }}
        run: |
          PACKAGE_NAME=$(node -p "require('./package.json').name")
          if [ "$PACKAGE_NAME" = "n8n-monorepo" ]; then
            echo "::error::Package 'n8n-monorepo' cannot be published."
            exit 1
          fi
          if npm view "$PACKAGE_NAME" > /dev/null 2>&1; then
            echo "::error::Package '$PACKAGE_NAME' already exists on NPM. Use the regular release workflow for updates."
            exit 1
          fi
          echo "Package '$PACKAGE_NAME' does not exist on NPM yet. Proceeding with publish."

      - name: Configure NPM token
        run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_INITIAL_PUBLISH_TOKEN }}" > ~/.npmrc

      - name: Publish package
        working-directory: ${{ github.event.inputs.package-path }}
        run: pnpm publish --access public --no-git-checks
release-publish-post-release .github/workflows/release-publish-post-release.yml
Triggers
workflow_call
Runs on
Jobs
push-new-release-to-channel, promote-previous-beta-to-stable, promote-previous-minor-github-release-to-latest, ensure-release-candidate-branches, populate-cloud-with-releases, send-version-release-notification
View raw YAML
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
      previous_version:
        description: 'Previous release version acquired from determine-version-info. (e.g. 2.9.2, 1.123.22)'
        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

  promote-previous-minor-github-release-to-latest:
    name: Promote previous minor Github Release to latest
    if: |
      inputs.release_type != 'rc' &&
      inputs.bump == 'minor'
    uses: ./.github/workflows/release-promote-github-release.yml
    secrets: inherit
    with:
      version-tag: 'n8n@${{ inputs.new_stable_version }}'

  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

  populate-cloud-with-releases:
    name: 'Populate cloud database with releases'
    uses: ./.github/workflows/release-populate-cloud-with-releases.yml
    with:
      previous-version: ${{ inputs.previous_version }}
      version: ${{ inputs.version }}
      experimental: ${{ inputs.release_type == 'rc' }}
    secrets: inherit

  send-version-release-notification:
    name: 'Send version release notifications'
    uses: ./.github/workflows/release-version-release-notification.yml
    with:
      previous-version: ${{ inputs.previous_version }}
      version: ${{ inputs.version }}
    secrets: inherit
release-push-to-channel .github/workflows/release-push-to-channel.yml
Triggers
workflow_call, workflow_dispatch
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
validate-inputs, release-to-npm, release-to-docker-hub, release-to-github-container-registry, update-docs
Commands
  • 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
  • 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"
  • echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
  • npm dist-tag add "n8n@${{ needs.validate-inputs.outputs.version }}" next npm dist-tag add "n8n@${{ needs.validate-inputs.outputs.version }}" beta
  • npm dist-tag add "n8n@${{ needs.validate-inputs.outputs.version }}" latest npm dist-tag add "n8n@${{ needs.validate-inputs.outputs.version }}" stable
  • docker buildx imagetools create -t "${DOCKER_USERNAME}/n8n:stable" "${DOCKER_USERNAME}/n8n:${{ needs.validate-inputs.outputs.version }}" docker buildx imagetools create -t "${DOCKER_USERNAME}/n8n:latest" "${DOCKER_USERNAME}/n8n:${{ needs.validate-inputs.outputs.version }}" docker buildx imagetools create -t "${DOCKER_USERNAME}/runners:stable" "${DOCKER_USERNAME}/runners:${{ needs.validate-inputs.outputs.version }}" docker buildx imagetools create -t "${DOCKER_USERNAME}/runners:latest" "${DOCKER_USERNAME}/runners:${{ needs.validate-inputs.outputs.version }}"
  • docker buildx imagetools create -t "${DOCKER_USERNAME}/n8n:beta" "${DOCKER_USERNAME}/n8n:${{ needs.validate-inputs.outputs.version }}" docker buildx imagetools create -t "${DOCKER_USERNAME}/n8n:next" "${DOCKER_USERNAME}/n8n:${{ needs.validate-inputs.outputs.version }}" docker buildx imagetools create -t "${DOCKER_USERNAME}/runners:beta" "${DOCKER_USERNAME}/runners:${{ needs.validate-inputs.outputs.version }}" docker buildx imagetools create -t "${DOCKER_USERNAME}/runners:next" "${DOCKER_USERNAME}/runners:${{ needs.validate-inputs.outputs.version }}"
  • 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 }}"
View raw YAML
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
    environment: release
    permissions:
      id-token: write
    steps:
      - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
        with:
          node-version: 24.13.1

      # Remove after https://github.com/npm/cli/issues/8547 gets resolved
      - run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

      - 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
    environment: release
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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'
        env:
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
        run: |
          docker buildx imagetools create -t "${DOCKER_USERNAME}/n8n:stable" "${DOCKER_USERNAME}/n8n:${{ needs.validate-inputs.outputs.version }}"
          docker buildx imagetools create -t "${DOCKER_USERNAME}/n8n:latest" "${DOCKER_USERNAME}/n8n:${{ needs.validate-inputs.outputs.version }}"
          docker buildx imagetools create -t "${DOCKER_USERNAME}/runners:stable" "${DOCKER_USERNAME}/runners:${{ needs.validate-inputs.outputs.version }}"
          docker buildx imagetools create -t "${DOCKER_USERNAME}/runners:latest" "${DOCKER_USERNAME}/runners:${{ needs.validate-inputs.outputs.version }}"

      - name: Tag beta/next Docker image
        if: needs.validate-inputs.outputs.release_channel == 'beta'
        env:
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
        run: |
          docker buildx imagetools create -t "${DOCKER_USERNAME}/n8n:beta" "${DOCKER_USERNAME}/n8n:${{ needs.validate-inputs.outputs.version }}"
          docker buildx imagetools create -t "${DOCKER_USERNAME}/n8n:next" "${DOCKER_USERNAME}/n8n:${{ needs.validate-inputs.outputs.version }}"
          docker buildx imagetools create -t "${DOCKER_USERNAME}/runners:beta" "${DOCKER_USERNAME}/runners:${{ needs.validate-inputs.outputs.version }}"
          docker buildx imagetools create -t "${DOCKER_USERNAME}/runners:next" "${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
    environment: release
    permissions:
      packages: write
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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]
    environment: release
    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'
release-schedule-patch-prs matrix .github/workflows/release-schedule-patch-prs.yml
Triggers
workflow_dispatch, schedule
Runs on
Jobs
create-patch-prs
Matrix
track→ beta, stable, v1
View raw YAML
name: 'Release: Schedule Patch Release PRs'

on:
  workflow_dispatch:
  schedule:
    - cron: '0 8 * * 2-5' # 9am CET (UTC+1), Tuesday–Friday

jobs:
  create-patch-prs:
    name: Create patch release PR (${{ matrix.track }})
    strategy:
      matrix:
        track: [stable, beta, v1]
    uses: ./.github/workflows/release-create-patch-pr.yml
    secrets: inherit
    with:
      track: ${{ matrix.track }}
release-standalone-package .github/workflows/release-standalone-package.yml
Triggers
workflow_dispatch
Runs on
ubuntu-latest
Jobs
publish-to-npm
Commands
  • echo "::error::This workflow can only be run from the master branch" exit 1
  • node .github/scripts/ensure-provenance-fields.mjs
  • pnpm --filter "$PACKAGE" publish --access public --no-git-checks --publish-branch master
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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
release-update-pointer-tag perms .github/workflows/release-update-pointer-tag.yml
Triggers
workflow_call, workflow_dispatch
Runs on
ubuntu-slim
Jobs
update-pointer-tags
Actions
actions/create-github-app-token
Commands
  • git config user.name "n8n-release-tag-merge[bot]" git config user.email "256767729+n8n-release-tag-merge[bot]@users.noreply.github.com"
  • node ./.github/scripts/move-track-tag.mjs
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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:
          build-command: ''
          install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace

      - 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
release-version-release-notification .github/workflows/release-version-release-notification.yml
Triggers
workflow_dispatch, workflow_call
Runs on
ubuntu-slim
Jobs
release-notification
Commands
  • node ./.github/scripts/send-version-release-notification.mjs
View raw YAML
name: 'Release: Send version release notification'
run-name: 'Send version release notification for n8n@${{ inputs.version }}'

on:
  workflow_dispatch:
    inputs:
      previous-version:
        description: 'The previous release version (e.g. 2.10.2)'
        required: true
        type: string
      version:
        description: 'The release version (e.g. 2.11.0)'
        required: true
        type: string
  workflow_call:
    inputs:
      previous-version:
        description: 'The previous release version (e.g. 2.10.2)'
        required: true
        type: string
      version:
        description: 'The release version (e.g. 2.11.0)'
        required: true
        type: string

jobs:
  release-notification:
    runs-on: ubuntu-slim
    environment: release
    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: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace

      - name: Send release notification
        env:
          N8N_VERSION_RELEASE_NOTIFICATION_DATA: ${{ secrets.N8N_VERSION_RELEASE_NOTIFICATION_DATA }}
          PAYLOAD: |
            {
              "previous_version": "${{ inputs.previous-version }}",
              "new_version": "${{ inputs.version }}"
            }
        run: node ./.github/scripts/send-version-release-notification.mjs
sbom-generation-callable perms .github/workflows/sbom-generation-callable.yml
Triggers
workflow_call, workflow_dispatch
Runs on
ubuntu-latest
Jobs
generate-sbom
Actions
anchore/sbom-action, actions/attest-sbom, act10ns/slack
Commands
  • # 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"
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        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@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1
        with:
          path: ./
          format: cyclonedx-json
          output-file: sbom-source.cdx.json

      - name: Attest SBOM for source release
        uses: actions/attest-sbom@07e74fc4e78d1aad915e867f9a094073a9f71527 # v4.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 }} >
sec-ci-reusable .github/workflows/sec-ci-reusable.yml
Triggers
workflow_call
Runs on
Jobs
poutine-scan
View raw YAML
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:
sec-poutine-reusable perms security .github/workflows/sec-poutine-reusable.yml
Triggers
workflow_dispatch, workflow_call
Runs on
ubuntu-latest
Jobs
poutine_scan
Actions
boostsecurityio/poutine-action, github/codeql-action/upload-sarif
Commands
  • # 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"
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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
sec-publish-fix .github/workflows/sec-publish-fix.yml
Triggers
pull_request
Runs on
ubuntu-latest
Jobs
sync-security-fix
Actions
actions/create-github-app-token, act10ns/slack
Commands
  • 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"
View raw YAML
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@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
        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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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 }})'
sec-publish-fix-1x .github/workflows/sec-publish-fix-1x.yml
Triggers
pull_request
Runs on
ubuntu-latest
Jobs
sync-security-fix
Actions
actions/create-github-app-token, act10ns/slack
Commands
  • COMMIT_TO_PUBLISH=$(git rev-parse HEAD) BRANCH_NAME="private-1x-$(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 1.x git checkout -b "$BRANCH_NAME" public-repo/1.x 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 1.x \ --head "$BRANCH_NAME" \ --title "$PR_TITLE" \ --body "Cherry-picked from n8n-private. Original PR: $PR_URL"
View raw YAML
name: 'Security: Publish fix (1.x)'

on:
  pull_request:
    types: [closed]
    branches: ['1.x']

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@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
        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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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-1x-$(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 1.x
          git checkout -b "$BRANCH_NAME" public-repo/1.x
          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 1.x \
            --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 (1.x). Run "Security: Sync from Public" workflow, rebase your branch, reopen PR. (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})'
sec-sync-public-to-private .github/workflows/sec-sync-public-to-private.yml
Triggers
schedule, workflow_dispatch
Runs on
ubuntu-latest
Jobs
sync-from-public
Actions
actions/create-github-app-token
Commands
  • git fetch https://github.com/n8n-io/n8n.git master:public-master # Check if private is ahead of public, ignore Bundle commits AHEAD_COUNT=$(git rev-list public-master..HEAD --pretty=oneline --grep="chore: Bundle" --invert-grep --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
  • git fetch https://github.com/n8n-io/n8n.git 1.x:public-1.x git checkout 1.x # Check if private is ahead of public, ignore Bundle commits AHEAD_COUNT=$(git rev-list public-1.x..HEAD --pretty=oneline --grep="chore: Bundle" --invert-grep --count) if [ "$AHEAD_COUNT" -gt 0 ]; then if [ "${{ github.event_name }}" = "schedule" ]; then echo "Private 1.x is $AHEAD_COUNT commit(s) ahead of public, skipping scheduled sync" exit 0 elif [ "${{ inputs.force }}" != "true" ]; then echo "Private 1.x is $AHEAD_COUNT commit(s) ahead of public, skipping (force not enabled)" exit 0 else echo "Private 1.x is $AHEAD_COUNT commit(s) ahead of public, force syncing anyway" fi fi git reset --hard public-1.x git push origin 1.x --force-with-lease
  • if git ls-remote --exit-code origin refs/heads/bundle/2.x; then echo "bundle/2.x already exists, skipping" else echo "bundle/2.x not found, creating from master" git checkout master git checkout -b bundle/2.x git push origin bundle/2.x fi
  • if git ls-remote --exit-code origin refs/heads/bundle/1.x; then echo "bundle/1.x already exists, skipping" else echo "bundle/1.x not found, creating from 1.x" git checkout 1.x git checkout -b bundle/1.x git push origin bundle/1.x fi
View raw YAML
# 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          token: ${{ steps.app-token.outputs.token }}

      - name: Sync master from public
        run: |
          git fetch https://github.com/n8n-io/n8n.git master:public-master

          # Check if private is ahead of public, ignore Bundle commits
          AHEAD_COUNT=$(git rev-list public-master..HEAD --pretty=oneline --grep="chore: Bundle" --invert-grep --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

      - name: Sync 1.x from public
        run: |
          git fetch https://github.com/n8n-io/n8n.git 1.x:public-1.x
          git checkout 1.x

          # Check if private is ahead of public, ignore Bundle commits
          AHEAD_COUNT=$(git rev-list public-1.x..HEAD --pretty=oneline --grep="chore: Bundle" --invert-grep --count)

          if [ "$AHEAD_COUNT" -gt 0 ]; then
            if [ "${{ github.event_name }}" = "schedule" ]; then
              echo "Private 1.x is $AHEAD_COUNT commit(s) ahead of public, skipping scheduled sync"
              exit 0
            elif [ "${{ inputs.force }}" != "true" ]; then
              echo "Private 1.x is $AHEAD_COUNT commit(s) ahead of public, skipping (force not enabled)"
              exit 0
            else
              echo "Private 1.x is $AHEAD_COUNT commit(s) ahead of public, force syncing anyway"
            fi
          fi

          git reset --hard public-1.x
          git push origin 1.x --force-with-lease

      - name: Ensure bundle/2.x exists
        run: |
          if git ls-remote --exit-code origin refs/heads/bundle/2.x; then
            echo "bundle/2.x already exists, skipping"
          else
            echo "bundle/2.x not found, creating from master"
            git checkout master
            git checkout -b bundle/2.x
            git push origin bundle/2.x
          fi

      - name: Ensure bundle/1.x exists
        run: |
          if git ls-remote --exit-code origin refs/heads/bundle/1.x; then
            echo "bundle/1.x already exists, skipping"
          else
            echo "bundle/1.x not found, creating from 1.x"
            git checkout 1.x
            git checkout -b bundle/1.x
            git push origin bundle/1.x
          fi
security-trivy-scan-callable perms security .github/workflows/security-trivy-scan-callable.yml
Triggers
workflow_dispatch, workflow_call
Runs on
ubuntu-latest
Jobs
security_scan
Actions
aquasecurity/trivy-action, slackapi/slack-github-action
Commands
  • node .github/scripts/retry.mjs --attempts 4 --delay 15 'docker pull "${{ inputs.image_ref }}"'
  • 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"
  • { 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
  • 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"
View raw YAML
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: C0AHNJU9XFA #updates-security

jobs:
  security_scan:
    name: Security - Scan Docker Image With Trivy
    runs-on: ubuntu-latest
    steps:
      - name: Checkout for VEX file
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          sparse-checkout: |
            security/vex.openvex.json
            security/trivy.yaml
            security/trivy-ignore-policy.rego
            .github/scripts/retry.mjs
          sparse-checkout-cone-mode: false

      - name: Pull Docker image with retry
        run: node .github/scripts/retry.mjs --attempts 4 --delay 15 'docker pull "${{ inputs.image_ref }}"'

      - 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 }}

test-bench-reusable .github/workflows/test-bench-reusable.yml
Triggers
workflow_call, workflow_dispatch
Runs on
${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }}
Jobs
bench
Actions
CodSpeedHQ/action
View raw YAML
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
    if: github.repository == 'n8n-io/n8n'
    runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }}
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ inputs.ref }}

      - name: Setup Node.js
        uses: ./.github/actions/setup-nodejs

      - name: Run benchmarks
        uses: CodSpeedHQ/action@281164b0f014a4e7badd2c02cecad9b595b70537 # v4.11.1
        with:
          mode: simulation
          run: CODSPEED=true pnpm --filter=@n8n/performance bench
test-benchmark-destroy-nightly perms .github/workflows/test-benchmark-destroy-nightly.yml
Triggers
schedule, workflow_dispatch
Runs on
ubuntu-latest
Jobs
build
Actions
azure/login
Commands
  • pnpm destroy-cloud-env
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Azure login
        uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
        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
test-benchmark-nightly perms .github/workflows/test-benchmark-nightly.yml
Triggers
schedule, workflow_dispatch
Runs on
ubuntu-latest, ubuntu-latest
Jobs
build, notify-on-failure
Actions
hashicorp/setup-terraform, azure/login, azure/login, act10ns/slack
Commands
  • pnpm destroy-cloud-env
  • pnpm provision-cloud-env ${{ env.DEBUG }}
  • pnpm benchmark-in-cloud \ --vus 5 \ --duration 1m \ --n8nTag ${{ env.N8N_TAG }} \ --benchmarkTag ${{ env.N8N_BENCHMARK_TAG }} \ ${{ env.DEBUG }}
  • pnpm destroy-cloud-env ${{ env.DEBUG }}
  • exit 1
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Setup Node.js and install dependencies
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: ''

      - uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # v4.0.0
        with:
          terraform_version: '1.8.5'

      - name: Azure login
        uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
        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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
        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 }}
test-db-reusable matrix .github/workflows/test-db-reusable.yml
Triggers
workflow_call
Runs on
${{ matrix.runner }}
Jobs
test
Matrix
include, include.TEST_IMAGE_POSTGRES, include.migration-cmd, include.name, include.runner, include.test-cmd→ Postgres 16, SQLite Pooled, blacksmith-2vcpu-ubuntu-2204, blacksmith-4vcpu-ubuntu-2204, pnpm test:postgres:integration:tc, pnpm test:postgres:migrations:tc, pnpm test:sqlite, pnpm test:sqlite:migrations, postgres:16
Commands
  • pnpm tsx packages/testing/containers/pull-test-images.ts || true
  • ${{ matrix.test-cmd }}
  • ${{ matrix.migration-cmd }}
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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 }}
test-e2e-ci-reusable .github/workflows/test-e2e-ci-reusable.yml
Triggers
workflow_call
Runs on
blacksmith-4vcpu-ubuntu-2204, blacksmith-2vcpu-ubuntu-2204
Jobs
prepare, sqlite-sanity, multi-main-e2e, community-e2e, cleanup-ghcr
Actions
docker/login-action
Commands
  • 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"
  • ARGS=(--matrix 16 --orchestrate) if [[ "${{ inputs.playwright-only }}" == "true" ]]; then ARGS+=(--impact "--files=$CHANGED_FILES" "--base=FETCH_HEAD") 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"
  • node .github/scripts/cleanup-ghcr-images.mjs --tag ci-${{ github.run_id }}
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ inputs.branch || github.ref }}
          fetch-depth: 1

      - name: Login to GHCR
        uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push to GHCR
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: 'pnpm build:docker'
          enable-docker-cache: true
        env:
          INCLUDE_TEST_CONTROLLER: 'true'
          IMAGE_BASE_NAME: ghcr.io/${{ github.repository }}
          IMAGE_TAG: ci-${{ github.run_id }}
          RUNNERS_IMAGE_BASE_NAME: ghcr.io/${{ github.repository_owner }}/runners
          QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }}
          QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }}
          QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }}

      - 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" "--base=FETCH_HEAD")
          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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          sparse-checkout: .github/scripts
          sparse-checkout-cone-mode: false

      - name: Delete images from GHCR
        continue-on-error: true
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GHCR_ORG: ${{ github.repository_owner }}
          GHCR_REPO: ${{ github.event.repository.name }}
        run: node .github/scripts/cleanup-ghcr-images.mjs --tag ci-${{ github.run_id }}
test-e2e-coverage-weekly .github/workflows/test-e2e-coverage-weekly.yml
Triggers
schedule, workflow_dispatch
Runs on
blacksmith-8vcpu-ubuntu-2204
Jobs
coverage
Actions
codecov/codecov-action
Commands
  • pnpm build:docker:coverage
  • pnpm turbo run install-browsers --filter=n8n-playwright
  • pnpm --filter n8n-playwright test:container:sqlite \ --workers=${{ env.PLAYWRIGHT_WORKERS }}
  • pnpm --filter n8n-playwright coverage:report
  • node packages/testing/playwright/scripts/coverage-analysis.mjs \ --md --top=15 --out-json=coverage-gaps.json >> "$GITHUB_STEP_SUMMARY"
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - 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_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }}
          QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }}
          QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }}

      - 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: coverage-report
          path: packages/testing/playwright/coverage/
          retention-days: 14

      - name: Upload E2E Coverage to Codecov
        if: always()
        uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
        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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: coverage-gap-report
          path: coverage-gaps.json
          retention-days: 21
test-e2e-docker-pull-reusable .github/workflows/test-e2e-docker-pull-reusable.yml
Triggers
workflow_call, workflow_dispatch
Runs on
Jobs
build-and-test
View raw YAML
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
test-e2e-helm .github/workflows/test-e2e-helm.yml
Triggers
pull_request, workflow_dispatch
Runs on
blacksmith-4vcpu-ubuntu-2204, blacksmith-4vcpu-ubuntu-2204, blacksmith-2vcpu-ubuntu-2204
Jobs
build, helm-e2e, cleanup-ghcr
Actions
docker/login-action, azure/setup-helm, azure/setup-kubectl
Commands
  • pnpm exec playwright install chromium
  • 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"
  • 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
  • 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"
  • node .github/scripts/cleanup-ghcr-images.mjs --tag ci-${{ github.run_id }}
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ inputs.branch || github.ref }}
          fetch-depth: 1

      - name: Login to GHCR
        uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push to GHCR
        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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1

      - name: Install kubectl
        uses: azure/setup-kubectl@776406bce94f63e41d621b960d78ee25c8b76ede # v4.0.1

      - 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          sparse-checkout: .github/scripts
          sparse-checkout-cone-mode: false

      - name: Delete images from GHCR
        continue-on-error: true
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GHCR_ORG: ${{ github.repository_owner }}
          GHCR_REPO: ${{ github.event.repository.name }}
        run: node .github/scripts/cleanup-ghcr-images.mjs --tag ci-${{ github.run_id }}
test-e2e-infrastructure-reusable matrix .github/workflows/test-e2e-infrastructure-reusable.yml
Triggers
workflow_call, workflow_dispatch, pull_request
Runs on
Jobs
benchmark
Matrix
include, include.profile, include.runner→ benchmark-direct, benchmark-queue, benchmark-queue-tuned, blacksmith-4vcpu-ubuntu-2204, blacksmith-8vcpu-ubuntu-2204
View raw YAML
name: 'Test: E2E Infrastructure'

on:
  workflow_call:
  workflow_dispatch:
  pull_request:
    paths:
      - 'packages/testing/playwright/tests/infrastructure/**'
      - 'packages/testing/playwright/utils/benchmark/**'
      - 'packages/testing/playwright/utils/performance-helper.ts'
      - 'packages/testing/playwright/reporters/benchmark-summary-reporter.ts'
      - 'packages/testing/containers/services/**'
      - '.github/workflows/test-e2e-infrastructure-reusable.yml'

concurrency:
  group: e2e-infrastructure-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

jobs:
  benchmark:
    name: ${{ matrix.profile }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - profile: benchmark-direct
            runner: blacksmith-4vcpu-ubuntu-2204
          - profile: benchmark-queue
            runner: blacksmith-8vcpu-ubuntu-2204
          - profile: benchmark-queue-tuned
            runner: blacksmith-8vcpu-ubuntu-2204
    uses: ./.github/workflows/test-e2e-reusable.yml
    with:
      test-mode: docker-build
      test-command: pnpm --filter=n8n-playwright test:all --project='${{ matrix.profile }}:infrastructure' --workers=1
      shards: 1
      runner: ${{ matrix.runner }}
      timeout-minutes: 60
    secrets: inherit
test-e2e-performance-reusable .github/workflows/test-e2e-performance-reusable.yml
Triggers
workflow_call, workflow_dispatch, schedule, pull_request
Runs on
Jobs
build-and-test-performance
View raw YAML
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
test-e2e-reusable matrix .github/workflows/test-e2e-reusable.yml
Triggers
workflow_call
Runs on
${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }}, ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || inputs.runner }}
Jobs
matrix, test
Matrix
include→ ${{ fromJSON(inputs.pre-generated-matrix || needs.matrix.outputs.matrix) }}
Commands
  • echo "matrix=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix ${{ inputs.shards }} ${{ inputs.use-custom-orchestration && '--orchestrate' || '' }})" >> "$GITHUB_OUTPUT"
  • pnpm turbo run install-browsers --filter=n8n-playwright
  • npx tsx packages/testing/containers/pull-test-images.ts ${{ matrix.images }} || true
  • ${{ inputs.test-command }} --workers=${{ env.PLAYWRIGHT_WORKERS }} ${{ matrix.specs || format('--shard={0}/{1}', matrix.shard, strategy.job-total) }}
  • 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
  • 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 raw YAML
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
      timeout-minutes:
        description: 'Job timeout in minutes'
        required: false
        default: 30
        type: number
      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


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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ inputs.branch || github.ref }}
          fetch-depth: 1

      - name: Setup Environment
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: ''

      - name: Generate shard matrix
        id: generate
        run: echo "matrix=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix ${{ inputs.shards }} ${{ inputs.use-custom-orchestration && '--orchestrate' || '' }})" >> "$GITHUB_OUTPUT"

  test:
    needs: matrix
    if: ${{ !cancelled() }}
    runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || inputs.runner }}
    timeout-minutes: ${{ inputs.timeout-minutes }}
    permissions:
      packages: read
      contents: read
    strategy:
      fail-fast: false
      matrix:
        include: ${{ fromJSON(inputs.pre-generated-matrix || needs.matrix.outputs.matrix) }}
    name: Shard ${{ matrix.shard }}

    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }}
          QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }}
          QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }}
          N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }}
          N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }}
          N8N_ENCRYPTION_KEY: ${{ secrets.N8N_ENCRYPTION_KEY }}

      - name: Upload Failure Artifacts
        if: ${{ failure() && inputs.upload-failure-artifacts }}
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.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
test-evals-ai .github/workflows/test-evals-ai.yml
Triggers
push, workflow_dispatch
Runs on
ubuntu-latest, ubuntu-latest
Jobs
check-skip, determine-config, run-pairwise-evals, run-llm-judge-evals
Commands
  • 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
View raw YAML
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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
        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
test-evals-ai-release .github/workflows/test-evals-ai-release.yml
Triggers
release
Runs on
ubuntu-latest
Jobs
check-minor-release, run-pairwise-evals, run-llm-judge-evals
Commands
  • 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
View raw YAML
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
test-evals-ai-reusable .github/workflows/test-evals-ai-reusable.yml
Triggers
workflow_call
Runs on
blacksmith-2vcpu-ubuntu-2204
Jobs
evals
Actions
astral-sh/setup-uv, extractions/setup-just
Commands
  • 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"
  • uv python install 3.11
  • just sync-all
  • ./packages/cli/bin/n8n export:nodes --output ./packages/@n8n/ai-workflow-builder.ee/evaluations/nodes.json
  • 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) || '' }}
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.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) || '' }}
test-evals-python .github/workflows/test-evals-python.yml
Triggers
pull_request, push
Runs on
ubuntu-latest
Jobs
workflow-comparison
Actions
astral-sh/setup-uv, extractions/setup-just, codecov/codecov-action
Commands
  • uv python install 3.11
  • just sync-all
  • just format-check
  • just typecheck
  • just lint
  • uv run pytest --cov=src --cov-report=xml --cov-report=term-missing
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Install uv
        uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.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@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
        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
test-linting-reusable .github/workflows/test-linting-reusable.yml
Triggers
workflow_call
Runs on
${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
Jobs
lint
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ inputs.ref }}

      - name: Build and Test
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: pnpm lint
          node-version: ${{ inputs.nodeVersion }}
test-unit-reusable matrix .github/workflows/test-unit-reusable.yml
Triggers
workflow_call
Runs on
${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}, ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}, ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}, ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}, ubuntu-latest
Jobs
unit-test-backend, integration-test-backend, unit-test-nodes, unit-test-frontend, unit-test
Matrix
shard→ 1, 2
Actions
codecov/codecov-action, codecov/codecov-action, codecov/test-results-action, codecov/codecov-action, codecov/test-results-action, codecov/codecov-action, codecov/test-results-action, codecov/codecov-action
Commands
  • pnpm test:ci:backend:unit --summarize
  • node .github/scripts/send-build-stats.mjs || true
  • pnpm test:ci:backend:integration --summarize
  • node .github/scripts/send-build-stats.mjs || true
  • pnpm turbo test --filter=n8n-nodes-base --summarize
  • node .github/scripts/send-build-stats.mjs || true
  • pnpm test:ci:frontend --summarize -- --shard=${{ matrix.shard }}/2
  • node .github/scripts/send-build-stats.mjs || true
View raw YAML
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
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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:
          QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }}
          QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }}
          QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }}

      - name: Upload test results to Codecov
        if: ${{ !cancelled() }}
        uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
        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@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
        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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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:
          QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }}
          QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }}
          QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }}

      - name: Upload test results to Codecov
        if: ${{ !cancelled() }}
        uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          name: backend-integration

      - name: Upload coverage to Codecov
        if: env.COVERAGE_ENABLED == 'true'
        uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
        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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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:
          QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }}
          QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }}
          QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }}

      - name: Upload test results to Codecov
        if: ${{ !cancelled() }}
        uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          name: nodes-unit

      - name: Upload coverage to Codecov
        if: env.COVERAGE_ENABLED == 'true'
        uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
        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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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:
          QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }}
          QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }}
          QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }}

      - name: Upload test results to Codecov
        if: ${{ !cancelled() }}
        uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.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@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
        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
test-visual-chromatic .github/workflows/test-visual-chromatic.yml
Triggers
workflow_dispatch, workflow_call
Runs on
blacksmith-4vcpu-ubuntu-2204
Jobs
chromatic
Actions
chromaui/action
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        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
test-visual-storybook .github/workflows/test-visual-storybook.yml
Triggers
schedule, workflow_dispatch, pull_request
Runs on
blacksmith-2vcpu-ubuntu-2204
Jobs
cloudflare
Actions
cloudflare/wrangler-action
Commands
  • pnpm build
  • echo "ignore-workspace-root-check=true" >> .npmrc pnpm add --global wrangler
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        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 }}
test-workflow-scripts-reusable .github/workflows/test-workflow-scripts-reusable.yml
Triggers
workflow_call
Runs on
${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-slim' || 'blacksmith-4vcpu-ubuntu-2204' }}
Jobs
workflow-script-tests
Commands
  • npm test --prefix=.github/scripts
View raw YAML
name: 'Test: Workflow scripts'

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:
  workflow-script-tests:
    name: Run tests for workflow scripts
    runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-slim' || 'blacksmith-4vcpu-ubuntu-2204' }}
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ inputs.ref }}

      - name: Setup Node.js
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: ''
          install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace

      - name: Run tests
        id: run-tests
        run: npm test --prefix=.github/scripts
test-workflows-callable .github/workflows/test-workflows-callable.yml
Triggers
workflow_call
Runs on
blacksmith-2vcpu-ubuntu-2204
Jobs
run_workflow_tests
Commands
  • pnpm --filter=n8n-playwright test:workflows:setup
  • pnpm --filter=n8n-playwright test:workflows --workers 4
  • pnpm --filter=n8n-playwright test:workflows:schema
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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'
test-workflows-nightly .github/workflows/test-workflows-nightly.yml
Triggers
schedule, workflow_dispatch
Runs on
Jobs
run_workflow_tests
View raw YAML
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
test-workflows-pr-comment perms .github/workflows/test-workflows-pr-comment.yml
Triggers
issue_comment
Runs on
ubuntu-latest
Jobs
handle_comment_command, trigger_reusable_tests
View raw YAML
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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
        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
util-approve-and-set-automerge perms .github/workflows/util-approve-and-set-automerge.yml
Triggers
workflow_call
Runs on
ubuntu-slim
Jobs
approve-and-automerge
Actions
actions/create-github-app-token
Commands
  • gh pr review "${{ inputs.pull-request-number }}" \ --approve \ --repo "${{ github.repository }}"
  • gh pr merge "${{ inputs.pull-request-number }}" \ --auto \ --squash \ --repo "${{ github.repository }}"
View raw YAML
name: 'Util: Approve and set Automerge'

run-name: Approve and automerge PR ${{ inputs.pull-request-number }}

on:
  workflow_call:
    inputs:
      pull-request-number:
        type: string
        required: true

permissions:
  contents: write
  pull-requests: write

jobs:
  approve-and-automerge:
    if: |
      inputs.pull-request-number != ''
    runs-on: ubuntu-slim
    environment: release
    steps:
      - name: Generate GitHub App Token
        id: generate-token
        uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
        with:
          app-id: ${{ secrets.N8N_RELEASE_HELPER_APP_ID }}
          private-key: ${{ secrets.N8N_RELEASE_HELPER_PRIVATE_KEY }}

      - name: Approve PR (as the App)
        env:
          GH_TOKEN: ${{ steps.generate-token.outputs.token }}
        run: |
          gh pr review "${{ inputs.pull-request-number }}" \
            --approve \
            --repo "${{ github.repository }}"

      - name: Enable auto-merge (merge when checks pass)
        env:
          GH_TOKEN: ${{ steps.generate-token.outputs.token }}
        run: |
          gh pr merge "${{ inputs.pull-request-number }}" \
            --auto \
            --squash \
            --repo "${{ github.repository }}"
util-backport-bundle perms .github/workflows/util-backport-bundle.yml
Triggers
pull_request, workflow_dispatch
Runs on
ubuntu-slim
Jobs
backport
Actions
actions/create-github-app-token, korthout/backport-action
View raw YAML
name: 'Util: Backport bundle PR to bundle/1.x'

run-name: Backport pull request ${{ github.event.pull_request.number || inputs.pull-request-id }}

on:
  pull_request:
    types: [closed]
    branches:
      - 'bundle/2.x'
  workflow_dispatch:
    inputs:
      pull-request-id:
        description: 'The ID number of the pull request (e.g. 3342). No #, no extra letters.'
        required: true
        type: string

permissions:
  contents: write
  pull-requests: write

jobs:
  backport:
    if: |
      github.repository == 'n8n-io/n8n-private' &&
      (github.event.pull_request.merged == true ||
      github.event_name == 'workflow_dispatch')
    runs-on: ubuntu-slim
    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 }}

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          token: ${{ steps.generate-token.outputs.token }}
          fetch-depth: 0

      - name: Backport
        uses: korthout/backport-action@4aaf0e03a94ff0a619c9a511b61aeb42adea5b02 # v4.2.0
        with:
          github_token: ${{ steps.generate-token.outputs.token }}
          source_pr_number: ${{ github.event.pull_request.number || inputs.pull-request-id }}
          target_branches: bundle/1.x
          pull_description: |-
            # Description
            Backport of #${pull_number} to `${target_branch}`.

            ## Checklist for the author (@${pull_author}) to go through.

            - [ ] Review the backport changes
            - [ ] Fix possible conflicts
            - [ ] Merge to target branch

            After this PR has been merged, it will be picked up in the next patch release for release track.

            # Original description

            ${pull_description}
          pull_title: ${pull_title} (backport to ${target_branch})
          add_author_as_assignee: true
          add_author_as_reviewer: true
          copy_assignees: true
          copy_requested_reviewers: false
          copy_labels_pattern: '^(?!Backport to\b).+'
          add_labels: 'automation:backport'
          experimental: >
            {
              "conflict_resolution": "draft_commit_conflicts"
            }
util-claude AI .github/workflows/util-claude.yml
Triggers
issue_comment, pull_request_review_comment, issues, pull_request_review
Runs on
ubuntu-latest
Jobs
claude-code-action
Actions
anthropics/claude-code-action
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        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
util-claude-task AI .github/workflows/util-claude-task.yml
Triggers
workflow_dispatch
Runs on
blacksmith-4vcpu-ubuntu-2204
Jobs
run-claude-task
Actions
actions/create-github-app-token, anthropics/claude-code-action
Commands
  • git remote set-url origin "https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/${{ github.repository }}.git"
  • BRANCH_NAME="claude/task-${{ github.run_id }}-${{ github.run_attempt }}" echo "BRANCH_NAME=$BRANCH_NAME" >> "$GITHUB_ENV" git checkout -b "$BRANCH_NAME"
  • 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 }}"
  • node .github/scripts/claude-task/prepare-claude-prompt.mjs
  • 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|${MCP_LINEAR_API_KEY}|g" /tmp/mcp-config.json sed -i "s|MCP_NOTION_TOKEN_PLACEHOLDER|${MCP_NOTION_TOKEN}|g" /tmp/mcp-config.json
  • 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
  • { 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"
  • node .github/scripts/claude-task/resume-callback.mjs
View raw YAML
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@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
        with:
          app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
          private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}

      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        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
        env:
          MCP_LINEAR_API_KEY: ${{ secrets.MCP_LINEAR_API_KEY }}
          MCP_NOTION_TOKEN: ${{ secrets.MCP_NOTION_TOKEN }}
        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|${MCP_LINEAR_API_KEY}|g" /tmp/mcp-config.json
          sed -i "s|MCP_NOTION_TOKEN_PLACEHOLDER|${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
util-cleanup-abandoned-release-branches .github/workflows/util-cleanup-abandoned-release-branches.yml
Triggers
pull_request
Runs on
ubuntu-slim
Jobs
delete-release-branch
Actions
actions/create-github-app-token
Commands
  • node .github/scripts/cleanup-release-branch.mjs
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 1

      - name: Setup Node.js
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: ''
          install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace

      - 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 }}
util-cleanup-pr-images .github/workflows/util-cleanup-pr-images.yml
Triggers
schedule
Runs on
ubuntu-slim
Jobs
cleanup
Commands
  • node .github/scripts/cleanup-ghcr-images.mjs --stale 1
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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
util-data-tooling .github/workflows/util-data-tooling.yml
Triggers
workflow_dispatch
Runs on
blacksmith-2vcpu-ubuntu-2204, blacksmith-2vcpu-ubuntu-2204
Jobs
sqlite-export-sqlite-import, postgres-export-postgres-import
Actions
isbang/compose-action
Commands
  • ./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
View raw YAML
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: ${{ fromJSON(steps.paths-filter.outputs.results).db }}
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Check for db changes
        uses: ./.github/actions/ci-filter
        id: paths-filter
        with:
          mode: filter
          base-ref: master
          filters: |
            db:
              - packages/@n8n/db/**
              - packages/cli/**
      - name: Setup, build and export sqlite
        if: fromJSON(steps.paths-filter.outputs.results).db
        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: ${{ fromJSON(steps.paths-filter.outputs.results).db }}
    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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Check for db changes
        uses: ./.github/actions/ci-filter
        id: paths-filter
        with:
          mode: filter
          base-ref: master
          filters: |
            db:
              - packages/@n8n/db/**
              - packages/cli/**

      - name: Setup and Build
        if: fromJSON(steps.paths-filter.outputs.results).db
        uses: ./.github/actions/setup-nodejs

      - name: Start Postgres
        if: fromJSON(steps.paths-filter.outputs.results).db
        uses: isbang/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
        with:
          compose-file: ./.github/docker-compose.yml
          services: |
            postgres

      - name: Export postgres
        if: fromJSON(steps.paths-filter.outputs.results).db
        run: ./packages/cli/bin/n8n export:entities --outputDir packages/cli/commands/export/outputs
      - name: Import postgres
        if: fromJSON(steps.paths-filter.outputs.results).db
        run: ./packages/cli/bin/n8n import:entities --inputDir packages/cli/commands/export/outputs --truncateTables
util-determine-current-version .github/workflows/util-determine-current-version.yml
Triggers
workflow_dispatch, workflow_call
Runs on
ubuntu-latest
Jobs
get-versions
Commands
  • node ./.github/scripts/get-release-versions.mjs
  • echo "Stable: ${{ steps.get-tags.outputs.stable }}" echo "Beta: ${{ steps.get-tags.outputs.beta }}" echo "v1: ${{ steps.get-tags.outputs.v1 }}"
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0

      - name: Setup Node.js
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: ''
          install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace

      - 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 }}"
util-ensure-release-candidate-branches .github/workflows/util-ensure-release-candidate-branches.yml
Triggers
workflow_dispatch, workflow_call
Runs on
ubuntu-slim
Jobs
ensure-release-candidate-branches
Actions
actions/create-github-app-token
Commands
  • node ./.github/scripts/ensure-release-candidate-branches.mjs
View raw YAML
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: 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          fetch-tags: true

      - name: Setup NodeJS
        uses: ./.github/actions/setup-nodejs
        with:
          build-command: ''
          install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace

      - name: Ensure release-candidate branches
        run: node ./.github/scripts/ensure-release-candidate-branches.mjs
util-notify-pr-status .github/workflows/util-notify-pr-status.yml
Triggers
pull_request_review, pull_request
Runs on
ubuntu-latest
Jobs
notify
Actions
fjogeleit/http-request-action
View raw YAML
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@551353b829c3646756b2ec2b3694f819d7957495 # v2.0.0
        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) }} }'
util-sync-api-docs .github/workflows/util-sync-api-docs.yml
Triggers
push, workflow_dispatch
Runs on
ubuntu-latest
Jobs
sync-public-api
Actions
actions/create-github-app-token, peter-evans/create-pull-request
Commands
  • pnpm run build:data
  • 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
  • # 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}"
  • 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"
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
        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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
        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.
util-update-node-popularity perms .github/workflows/util-update-node-popularity.yml
Triggers
schedule, workflow_dispatch
Runs on
ubuntu-latest
Jobs
update-popularity, approve-and-automerge
Actions
actions/create-github-app-token, peter-evans/create-pull-request
Commands
  • cd packages/frontend/editor-ui node scripts/fetch-node-popularity.mjs
  • pnpm biome format --write packages/frontend/editor-ui/data/node-popularity.json
  • 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
View raw YAML
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
    outputs:
      pull-request-number: ${{ steps.create-pr.outputs.pull-request-number }}
    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 repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          token: ${{ steps.generate-token.outputs.token }}

      - 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'
        id: create-pr
        uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
        with:
          token: ${{ steps.generate-token.outputs.token }}
          commit-message: 'chore: Update node popularity data'
          labels: 'automation:scheduled-update'
          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
          author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
          committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

  approve-and-automerge:
    needs: [update-popularity]
    if: |
      needs.update-popularity.outputs.pull-request-number != '' &&
      (github.event_name == 'workflow_dispatch' ||
      (github.event_name == 'schedule' && github.repository == 'n8n-io/n8n'))
    uses: ./.github/workflows/util-approve-and-set-automerge.yml
    secrets: inherit
    with:
      pull-request-number: ${{ needs.update-popularity.outputs.pull-request-number }}