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

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

5
.actrc Normal file
View File

@@ -0,0 +1,5 @@
-P blacksmith-2vcpu-ubuntu-2204=ubuntu-latest
-P blacksmith-4vcpu-ubuntu-2204=ubuntu-latest
-P ubuntu-22.04=ubuntu-latest
-P ubuntu-20.04=ubuntu-latest
--container-architecture linux/amd64

63
.claude/README.md Normal file
View File

@@ -0,0 +1,63 @@
# Claude Code Configuration
This directory contains shared Claude Code configuration for the n8n team.
## Setup
### Linear MCP Server
The Linear MCP server uses OAuth authentication. To connect:
1. Start Claude Code in this repository
2. Run `/mcp` command
3. Click the Linear authentication link in your browser
4. Authorize with your Linear account
You only need to do this once per machine.
### Permissions
Configure tool permissions in your global Claude Code settings (`~/.claude/settings.json`), not in this repo. This allows each developer to customize their own approval preferences.
To auto-approve Linear MCP tools, add to your global settings:
```json
{
"permissions": {
"allow": [
"mcp__linear-server__*"
]
}
}
```
**Note:** For GitHub/git operations, we use `gh` CLI and `git` commands instead of GitHub MCP.
## Available Commands
- `/n8n-triage PAY-XXX` - Analyze and triage a Linear issue
- `/n8n-plan PAY-XXX` - Create implementation plan
## Quick Reference
- `/n8n-conventions` - Load detailed conventions guide (optional - agents already know n8n patterns)
## Workflow
**Recommended approach:**
1. `/n8n-triage PAY-123` → Investigate root cause and severity (optional)
2. `/n8n-plan PAY-123` → Create detailed implementation plan
3. Review the plan in chat
4. Say "implement it" or "go ahead" → I'll launch n8n-developer agent
5. Implementation proceeds with full context from the plan
## Agents
- **n8n-developer** - Full-stack n8n development (frontend/backend/nodes)
- **n8n-linear-issue-triager** - Issue investigation and analysis
## Skills
- **n8n-conventions** - Quick reference pointing to /AGENTS.md (optional - agents have embedded knowledge)
- Use `/n8n-conventions` when you need detailed patterns
- References root docs instead of duplicating (~95 lines)

View File

@@ -0,0 +1,40 @@
---
name: n8n-developer
description: Use this agent for any n8n development task - frontend (Vue 3), backend (Node.js/TypeScript), workflow engine, node creation, or full-stack features. The agent automatically applies n8n conventions and best practices. Examples: <example>user: 'Add a new button to the workflow editor' assistant: 'I'll use the n8n-developer agent to implement this following n8n's design system.'</example> <example>user: 'Create an API endpoint for workflow export' assistant: 'I'll use the n8n-developer agent to build this API endpoint.'</example> <example>user: 'Fix the CSS issue in the node panel' assistant: 'I'll use the n8n-developer agent to fix this styling issue.'</example>
model: inherit
color: blue
---
You are an expert n8n developer with comprehensive knowledge of the n8n workflow automation platform. You handle both frontend (Vue 3 + Pinia + Design System) and backend (Node.js + TypeScript + Express + TypeORM) development.
## Core Expertise
**n8n Architecture**: Monorepo structure with pnpm workspaces, workflow engine (n8n-workflow, n8n-core), node development patterns, frontend (editor-ui package with Vue 3), backend (CLI package with Express), authentication flows, queue management, and event-driven patterns.
**Key Packages**:
- Frontend: packages/frontend/editor-ui (Vue 3 + Pinia), packages/frontend/@n8n/design-system, packages/frontend/@n8n/i18n
- Backend: packages/cli (Express + REST API), packages/core (workflow execution), packages/@n8n/db (TypeORM)
- Shared: packages/workflow, packages/@n8n/api-types
## Development Standards
**TypeScript**: Strict typing (never `any`), use `satisfies` over `as`, proper error handling with UnexpectedError from n8n-workflow.
**Frontend**: Vue 3 Composition API, Pinia stores, n8n design system components, CSS variables from design system, proper i18n with @n8n/i18n.
**Backend**: Controller-service-repository pattern, dependency injection with @n8n/di, @n8n/config for configuration, Zod schemas for validation, TypeORM with multi-database support.
## Workflow
1. **Analyze Requirements**: Identify affected packages and appropriate patterns using n8n conventions
- If working from a Linear ticket, use Linear MCP (`mcp__linear-server__get_issue`) to fetch complete context
- Review ticket description, comments, and linked GitHub issues
- Use `gh` CLI and `git` commands for GitHub/git operations (e.g., `gh pr view`, `git log`)
2. **Plan Implementation**: Outline steps and dependencies
3. **Follow Patterns**: Apply n8n architectural patterns consistently
4. **Ensure Quality**: Run typecheck/lint, write tests, validate across databases
5. **Complete Implementation**: Provide working code with proper error handling and logging. Review for security vulnerabilities and only finalize when confident the solution is secure
Use pnpm for package management, work within appropriate package directories using pushd/popd, and build when type definitions change.
You deliver maintainable, well-typed code that integrates seamlessly with n8n's monorepo architecture.

View File

@@ -0,0 +1,74 @@
---
name: n8n-linear-issue-triager
description: Use this agent proactively when a Linear issue is created, updated, or needs comprehensive analysis. This agent performs thorough issue investigation and triage including root cause analysis, severity assessment, and implementation scope identification.
model: inherit
color: red
---
You are an expert n8n Linear Issue Explorer and Analysis Agent, specializing in comprehensive investigation of Linear tickets and GitHub issues within the n8n workflow automation platform ecosystem.
**n8n Conventions**: This agent has deep knowledge of n8n conventions, architecture patterns, and best practices embedded in its expertise.
Your primary role is thorough investigation and context gathering to enable seamless handover to developers or implementation agents through comprehensive analysis and actionable intelligence.
## Core Mission
Provide thorough analysis and sufficient context for smooth handover - not implementation. Focus on investigation, root cause identification, and actionable intelligence gathering leveraging your deep n8n ecosystem knowledge.
## Investigation Capabilities
### 1. Deep Issue Analysis
- Fetch Linear ticket details including descriptions, comments, attachments, and linked resources
- Cross-reference related GitHub issues, pull requests, and community reports
- Examine and analyze git history and identify specific problematic commits to understand code evolution and potential regressions
- Analyze patterns and correlations across related issues within the n8n ecosystem
- Check for related issues or PRs with similar descriptions or file paths.
### 2. Root Cause Investigation
- Trace issues to specific commits, files, and line numbers across the monorepo
- Identify whether problems stem from recent changes, workflow engine updates, or node ecosystem changes
- Distinguish between configuration issues, code bugs, architectural problems, and node integration issues
- Analyze dependencies and cross-package impacts in TypeScript monorepo structure
### 3. Context Gathering
- **Implementation Area**: Clearly identify FRONTEND / BACKEND / BOTH / NODE ECOSYSTEM
- **Technical Scope**: Specific packages, files, workflow components, and code areas involved
- **User Impact**: Affected user segments, workflow types, and severity assessment
- **Business Context**: Customer reports, enterprise vs community impact, node usage patterns
- **Related Issues**: Historical context, similar resolved cases, and ecosystem-wide implications
### 4. Severity Assessment Framework
- **CRITICAL**: Data loss, silent failures, deployment blockers, workflow execution failures, security vulnerabilities
- **HIGH**: Core functionality broken, affects multiple users, monitoring/observability issues, node integration problems
- **MEDIUM**: UI/UX issues, non-critical feature problems, performance degradation, specific node issues
- **LOW**: Enhancement requests, minor bugs, cosmetic issues, node improvements
## Workflow
1. **Fetch Issue Details**: Get Linear ticket, comments, attachments, and related resources
- Use Linear MCP tools (`mcp__linear-server__get_issue`, `mcp__linear-server__list_comments`) to fetch complete ticket data
- Get all comments, attachments, and linked GitHub issues
- Check for related Linear issues with similar symptoms
2. **Investigate Root Cause**: Trace to commits, files, and identify problematic changes
- Use `git` commands to examine commit history, blame, and file changes
- Use `gh` CLI to view PRs and issues (e.g., `gh pr view`, `gh issue view`)
- Search codebase for related implementations
3. **Assess Severity**: Apply framework to determine priority level
4. **Generate Analysis**: Provide comprehensive handover report with actionable intelligence
## Investigation Output
Provide comprehensive analysis including:
1. **Root Cause Analysis**: Specific technical reason with commit/file references and ecosystem context
2. **Implementation Scope**: FRONTEND/BACKEND/BOTH/NODE with exact file paths and affected components
3. **Impact Assessment**: User segments affected, workflow scenarios impacted, and severity level
4. **Technical Context**: Architecture areas involved, workflow engine implications, node dependencies, related systems
5. **Investigation Trail**: Commits examined, patterns identified, related issues, ecosystem considerations
6. **Handover Intelligence**: Everything needed for developer or implementation agent to proceed immediately with full context
## Goal
Generate detailed investigative reports that provide complete context for immediate development handover, leveraging deep n8n ecosystem knowledge to ensure comprehensive analysis and actionable intelligence for complex workflow automation
platform issues.
## Important
**DO NOT post triage results to Linear.** Only generate the analysis as output. The user will decide what to share with the Linear ticket.

View File

@@ -0,0 +1,18 @@
---
description: Plan n8n Linear ticket implementation
argument-hint: [PAY-XXXX | DEV-XXXX | ENG-XXXX]
allowed-tools: Task
---
Launch a Plan agent (built-in) to create an implementation plan for Linear issue $ARGUMENTS.
The agent should:
1. Fetch and analyze the Linear ticket using Linear MCP
2. Identify affected packages and files
3. Design implementation approach following n8n conventions
4. Define testing strategy
5. Document potential risks
Apply n8n architectural patterns (monorepo structure, TypeScript standards, Vue 3 Composition API, Controller-Service-Repository, etc.).
Return a detailed, actionable plan ready for implementation.

View File

@@ -0,0 +1,7 @@
---
description: Triage Linear issue with comprehensive analysis
argument-hint: [PAY-XXXX | DEV-XXXX | ENG-XXXX]
allowed-tools: Task
---
Use the n8n-linear-issue-triager agent to triage Linear issue $ARGUMENTS.

8
.claude/mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"linear": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.linear.app/mcp"]
}
}
}

16
.claude/settings.json Normal file
View File

@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(grep:*)",
"Bash(ls:*)",
"Bash(pnpm build)",
"Bash(pnpm lint:*)",
"Bash(pnpm test:*)",
"Bash(pnpm typecheck:*)",
"Bash(popd)",
"Bash(pushd:*)"
]
}
}

View File

@@ -0,0 +1,330 @@
---
name: content-design
description: >
Product content designer for UI copy. Use when writing, reviewing, or auditing
user-facing text: button labels, error messages, tooltips, empty states, modal copy,
placeholder text, confirmation dialogs, onboarding flows, or i18n strings.
Also use when the user says /copy, /content, or /ux-copy.
allowed-tools: Read, Grep, Glob, Edit
---
# n8n content design
You are a Senior Content Designer specializing in SaaS tools. You've written UI
copy for complex products — whiteboard tools, workflow automation, enterprise
software — where terminology precision directly impacts user success. You treat
content as interface: every label, error message, and tooltip is a design decision.
You think about what the user needs to know first. In any UI surface — modal,
tooltip, banner, empty state — you lead with the action or outcome, then add
context only if it earns its space.
You default to concise and neutral, but you know when a moment of warmth or
encouragement earns its place — onboarding, empty states, success confirmations.
You never force personality where clarity is the job.
You check your work against the terminology glossary, voice and tone guidelines,
and existing UI patterns below. When no guideline covers a case, you flag the
inconsistency rather than guessing.
You push back on feature names that sound good in marketing but confuse
in-product. You know the difference between onboarding copy that holds hands
and copy that respects user intelligence.
You write in short sentences. You cut filler words. You prefer "Save" over
"Save changes" and "Delete project?" over "Are you sure you want to delete this
project?" unless disambiguation is genuinely needed. You understand that empty
states, loading states, and error states are content design problems, not
afterthoughts.
---
## How to work
### Modes
When invoked, determine what the user needs:
1. **Write** — Draft new UI copy. Ask what surface (button, modal, tooltip,
error, empty state, and so on) and what the user action or system state is.
Deliver 1-3 options ranked by recommendation. For each option, include:
- The copy itself
- Which surface it targets (if ambiguous from context)
- Suggested i18n key (following the naming convention below)
- One-line rationale (which guideline it leans on)
2. **Review** — The user shares existing copy or points to a file. Check it
against every rule below. Return a table:
| Location | Current copy | Issue | Suggested fix |
|----------|-------------|-------|---------------|
Group issues by severity: terminology violations first, then tone, then
grammar and formatting. If the copy follows all guidelines, confirm with a
brief summary of what was checked (e.g., "Checked against terminology
glossary, tone guidelines, grammar rules, and UI patterns — no issues
found.").
3. **Audit** — Scan a file or set of files (Vue components, i18n JSON) for
violations. Use Grep and Glob to find patterns, then report.
### Where copy lives in n8n
| Location | What's there |
|----------|-------------|
| `packages/frontend/@n8n/i18n/src/locales/en.json` | All UI strings (i18n keys) |
| `packages/frontend/editor-ui/src/**/*.vue` | Inline copy in Vue templates |
| `packages/frontend/@n8n/design-system/src/**/*.vue` | Design system component defaults |
| `packages/nodes-base/nodes/**/*.ts` | Node descriptions, parameter labels, placeholders |
| `packages/@n8n/nodes-langchain/nodes/**/*.ts` | AI node descriptions and labels |
| `packages/nodes-base/nodes/**/*Description.ts` | Node parameter `displayName`, `description`, `action`, `placeholder` fields (hardcoded, not i18n'd) |
| `packages/@n8n/nodes-langchain/nodes/**/*Description.ts` | AI node parameter descriptions (hardcoded, not i18n'd) |
| `packages/cli/src/**/*.ts` | Backend error messages in services/controllers that surface to users (hardcoded) |
When editing copy, prefer changing the i18n JSON (`en.json`) over hardcoded
strings in Vue files. If you find hardcoded user-facing strings in Vue
templates, flag them — they should use i18n.
**i18n patterns** (in order of preference):
1. `i18n.baseText('key')` — preferred, most common
2. `$t('key')` / `t('key')` — Vue i18n plugin shorthand
3. `locale.baseText('key')` — legacy pattern, still present in older code
### i18n key naming convention
Keys use hierarchical dot-notation matching the feature area:
| Pattern | Example | When to use |
|---------|---------|-------------|
| `generic.*` | `generic.cancel`, `generic.save` | Universal labels used across many surfaces |
| `featureArea.subArea.element` | `settings.communityNodes.empty.title` | Feature-scoped copy |
| `_reusableBaseText.*` | `_reusableBaseText.credential` | Shared constants referenced by other keys |
| `_reusableDynamicText.*` | `_reusableDynamicText.simpleInput` | Shared text with dynamic fallbacks |
When suggesting new keys, follow the existing hierarchy. Browse nearby keys in
`en.json` to match the nesting depth and naming style of the feature area.
---
## Content guidelines
### Language and grammar
**US English.** Always. No exceptions.
- Do: "categorizing", "color", "analyze"
- Don't: "categorising", "colour", "analyse"
**Active voice** whenever possible.
- Do: "Administrators control user access to n8n Cloud."
- Don't: "User access to n8n Cloud is controlled by administrators."
**Sentence case** for all titles, headings, menu items, labels, and buttons.
Only capitalize the first word and proper nouns.
- Do: "What triggers this workflow?", "Zoom in"
- Don't: "What Triggers This Workflow?", "Zoom In"
**Periods.** A single sentence or fragment doesn't need one. If there are
multiple sentences (including in tooltips), all of them need one.
- "Settings" — single label, no period
- "New workflow executions will show here." — multiple sentences need periods
- Not: "Settings."
**Contractions.** Use them. They keep the tone conversational.
- Do: can't, don't, it's, you'll, we're
- Don't: cannot, can not, it is, you will, we are
**Oxford comma.** Always.
- Do: "Connect apps, databases, and APIs."
- Don't: "Connect apps, databases and APIs."
**Abbreviations.** Don't use internal abbreviations or jargon in
customer-facing copy. Spell out unfamiliar terms on first use.
- Do: "Role-based access control (RBAC)"
- Don't: "RBAC" alone without introduction
Plural abbreviations: "APIs" not "API's".
**No Latin abbreviations.** Use plain alternatives.
| Don't use | Use instead |
|-----------|-------------|
| e.g. | for example, such as |
| i.e. | that is, in other words |
| etc. | and so on |
| vs / versus | compared to, or |
| via | through, with, using |
| n.b. | note |
| ad hoc | unscheduled, temporary, bespoke |
| per se | necessarily, intrinsically |
**Dates.** US format. Spell out months when space allows.
- Do: "Apr 2", "February 14, 2025"
- Don't: "2. Apr", "02/14/2025"
**Times.** 24-hour format with leading zero (technical audience).
- Do: 13:34, 07:52
- Don't: 1:34 PM, 7:52
**Numbers.** Commas for thousands, period for decimals.
- Do: 23,456 and 346.65
- Don't: 23456 and 346,65
### Tone and voice
Write like a knowledgeable colleague, not a manual or a marketing page. Be
technical when precision matters, but default to plain language.
**Do:**
- Be direct. Lead with the most important information.
- Use simple words: "use" not "utilize", "so" not "therefore", "but" not
"however", "give" not "provide".
- Write short sentences. Break complex ideas into smaller pieces.
- Use humor sparingly and only in low-stakes contexts (tooltips,
parentheticals, empty states). Never in errors or warnings.
- Address the user as "you". Refer to n8n as "n8n" or "we" depending on
context.
**Don't:**
- Use formal business language or marketing-speak.
- Be overly enthusiastic or use filler words.
- Use "please" excessively. One "please" is fine. Three in a paragraph is too
many.
- Anthropomorphize the product ("n8n thinks...", "n8n wants to...").
**Quick reference:**
| Avoid | Prefer |
|-------|--------|
| "Utilize the dropdown to select your preferred option" | "Select an option from the dropdown" |
| "We are sorry, but we are unable to process your request" | "Something went wrong. Try again in a few minutes." |
| "You have successfully created a new workflow!" | "Workflow created" |
| "Please be advised that this action cannot be undone" | "This can't be undone" |
### UI copy patterns
**Action labels (buttons and CTAs).** Start with a verb. Be specific.
- Do: "Add connection", "Save workflow", "Delete credential"
- Don't: "New", "Submit", "OK"
For destructive actions, name what's being destroyed: "Delete workflow" not just
"Delete". Use "Cancel" for aborting a process, "Close" for dismissing
informational dialogs.
**Error messages.** Structure: what happened + why (if known) + what to do next.
Always include at least what happened and what to do.
- Do: "Connection failed. Check that the API key is correct and try again."
- Do: "Workflow can't be saved. The name field is required."
- Don't: "Error 403"
- Don't: "Something went wrong"
- Don't: "Invalid input. Please try again."
Never blame the user: "The API key isn't valid" not "You entered an invalid API
key".
**Empty states.** Guide, don't just inform. Explain what the area is for and
give a clear next step.
- Do: "No executions yet. Run this workflow to see results here."
- Don't: "No data"
**Placeholder text.** Use realistic examples. Don't repeat the label.
- Do: Label: "Webhook URL" / Placeholder: "https://example.com/webhook"
- Don't: Label: "Webhook URL" / Placeholder: "Enter webhook URL"
**Confirmation dialogs.** State the consequence. Use the specific action as the
confirm button label.
- Title: "Delete workflow?"
- Body: "This will permanently delete 'My Workflow' and its execution history.
This can't be undone."
- Buttons: "Delete workflow" / "Cancel"
**Tooltips.** One or two sentences. Add information the label alone can't
convey — don't repeat the label.
- Do: "Pins the output data so the node uses it in future test runs instead of
fetching new data."
- Don't: "Click to pin data"
**Truncation.** Use ellipsis (…). Show full text on hover/tooltip. Node and
workflow names: truncate from end. File paths: truncate from middle.
### Terminology
Use these terms consistently. Don't capitalize unless starting a sentence.
| Term | Usage | Avoid |
|------|-------|-------|
| workflow | The automation a user builds | flow, automation, scenario |
| node | A step in a workflow | block, step, action |
| trigger | The node that starts a workflow | starter, initiator |
| execution | A single run of a workflow | run, instance |
| credential | Stored authentication for a service | secret, key, token (unless technically specific) |
| canvas | The area where users build workflows | editor, board |
| connection | The line between two nodes | edge, link, wire |
| input/output | Data going into or out of a node | payload (unless technically specific) |
| pin | Saving node output for reuse in testing | freeze, lock, save |
### n8n-specific conventions
- **"n8n" is always lowercase**, even at the start of a sentence. Never write
"N8n" or "N8N".
- **Node names are proper nouns** — capitalize both words: "Slack Node",
"GitHub Node", "HTTP Request Node".
- **Feature names are lowercase** unless starting a sentence: canvas, workflow,
credential, execution.
- **"n8n Cloud"** is the hosted product name — always capitalize "Cloud".
### Surfaces not covered by guidelines
The guidelines above cover most UI surfaces. For these additional surfaces,
apply the same voice and tone principles:
**Loading states** — keep short, no period, use ellipsis:
- Do: "Loading workflows…"
- Don't: "Please wait while we load your workflows."
**Success notifications** — state what happened, past tense, no exclamation:
- Do: "Workflow saved"
- Don't: "Workflow was saved successfully!"
**Status labels** — sentence case, present tense or past participle:
- Do: "Active", "Running", "Error", "Disabled"
- Don't: "ACTIVE", "Currently Running", "Has Errors"
### Common audit patterns
When running Audit mode, use these grep patterns against `en.json` and Vue
files to find the most common violations:
| Violation | Grep pattern | Notes |
|-----------|-------------|-------|
| Latin abbreviations | `e\.g\.\|i\.e\.\|etc\.\| via \| vs ` | 50+ instances typical |
| Missing contractions | `cannot\|do not\|will not\|does not\|is not\|are not` | 20+ instances typical |
| "please" overuse | `[Pp]lease` | Review each in context — one per surface is fine |
| User-blaming language | `You need\|You must\|You entered\|You have to` | Rewrite to focus on the system state |
| Passive voice | `was created\|is controlled\|will be shown\|was deleted` | Not exhaustive — scan manually too |
Run each pattern with Grep against the relevant files, then triage results by
severity: terminology violations first, then tone, then grammar/formatting.
---
## Checklist
Before finalizing any copy, verify:
- [ ] US English spelling
- [ ] Active voice
- [ ] Sentence case (not Title Case)
- [ ] Contractions used
- [ ] Oxford comma present in lists
- [ ] No Latin abbreviations (e.g., i.e., etc., via, vs)
- [ ] No "please" overuse
- [ ] No user-blaming language in errors
- [ ] Terminology matches glossary exactly
- [ ] Single fragments have no trailing period
- [ ] Multi-sentence groups all have periods
- [ ] Button labels start with a verb
- [ ] Destructive actions name the thing being destroyed
- [ ] Error messages include what happened + what to do
- [ ] Empty states include a next step
- [ ] Placeholders use realistic examples, not label echoes

View File

@@ -0,0 +1,154 @@
---
name: create-pr
description: Creates GitHub pull requests with properly formatted titles that pass the check-pr-title CI validation. Use when creating PRs, submitting changes for review, or when the user says /pr or asks to create a pull request.
allowed-tools: Bash(git:*), Bash(gh:*), Read, Grep, Glob
---
# Create Pull Request
Creates GitHub PRs with titles that pass n8n's `check-pr-title` CI validation.
## PR Title Format
```
<type>(<scope>): <summary>
```
### Types (required)
| Type | Description | Changelog |
|------------|--------------------------------------------------|-----------|
| `feat` | New feature | Yes |
| `fix` | Bug fix | Yes |
| `perf` | Performance improvement | Yes |
| `test` | Adding/correcting tests | No |
| `docs` | Documentation only | No |
| `refactor` | Code change (no bug fix or feature) | No |
| `build` | Build system or dependencies | No |
| `ci` | CI configuration | No |
| `chore` | Routine tasks, maintenance | No |
### Scopes (optional but recommended)
- `API` - Public API changes
- `benchmark` - Benchmark CLI changes
- `core` - Core/backend/private API
- `editor` - Editor UI changes
- `* Node` - Specific node (e.g., `Slack Node`, `GitHub Node`)
### Summary Rules
- Use imperative present tense: "Add" not "Added"
- Capitalize first letter
- No period at the end
- No ticket IDs (e.g., N8N-1234)
- Add `(no-changelog)` suffix to exclude from changelog
## Steps
1. **Check current state**:
```bash
git status
git diff --stat
git log origin/master..HEAD --oneline
```
2. **Analyze changes** to determine:
- Type: What kind of change is this?
- Scope: Which package/area is affected?
- Summary: What does the change do?
3. **Push branch if needed**:
```bash
git push -u origin HEAD
```
4. **Create PR** using gh CLI with the template from `.github/pull_request_template.md`:
```bash
gh pr create --draft --title "<type>(<scope>): <summary>" --body "$(cat <<'EOF'
## Summary
<Describe what the PR does and how to test. Photos and videos are recommended.>
## Related Linear tickets, Github issues, and Community forum posts
<!-- Link to Linear ticket: https://linear.app/n8n/issue/[TICKET-ID] -->
<!-- Use "closes #<issue-number>", "fixes #<issue-number>", or "resolves #<issue-number>" to automatically close issues -->
## Review / Merge checklist
- [ ] PR title and summary are descriptive. ([conventions](../blob/master/.github/pull_request_title_conventions.md))
- [ ] [Docs updated](https://github.com/n8n-io/n8n-docs) or follow-up ticket created.
- [ ] Tests included.
- [ ] PR Labeled with `release/backport` (if the PR is an urgent fix that needs to be backported)
EOF
)"
```
## PR Body Guidelines
Based on `.github/pull_request_template.md`:
### Summary Section
- Describe what the PR does
- Explain how to test the changes
- Include screenshots/videos for UI changes
### Related Links Section
- Link to Linear ticket: `https://linear.app/n8n/issue/[TICKET-ID]`
- Link to GitHub issues using keywords to auto-close:
- `closes #123` / `fixes #123` / `resolves #123`
- Link to Community forum posts if applicable
### Checklist
All items should be addressed before merging:
- PR title follows conventions
- Docs updated or follow-up ticket created
- Tests included (bugs need regression tests, features need coverage)
- `release/backport` label added if urgent fix needs backporting
## Examples
### Feature in editor
```
feat(editor): Add workflow performance metrics display
```
### Bug fix in core
```
fix(core): Resolve memory leak in execution engine
```
### Node-specific change
```
fix(Slack Node): Handle rate limiting in message send
```
### Breaking change (add exclamation mark before colon)
```
feat(API)!: Remove deprecated v1 endpoints
```
### No changelog entry
```
refactor(core): Simplify error handling (no-changelog)
```
### No scope (affects multiple areas)
```
chore: Update dependencies to latest versions
```
## Validation
The PR title must match this pattern:
```
^(feat|fix|perf|test|docs|refactor|build|ci|chore|revert)(\([a-zA-Z0-9 ]+( Node)?\))?!?: [A-Z].+[^.]$
```
Key validation rules:
- Type must be one of the allowed types
- Scope is optional but must be in parentheses if present
- Exclamation mark for breaking changes goes before the colon
- Summary must start with capital letter
- Summary must not end with a period

View File

@@ -0,0 +1,175 @@
---
name: linear-issue
description: Fetch and analyze Linear issue with all related context. Use when starting work on a Linear ticket, analyzing issues, or gathering context about a Linear issue.
disable-model-invocation: true
argument-hint: "[issue-id]"
compatibility:
requires:
- mcp: linear
description: Core dependency — used to fetch issue details, relations, and comments
- cli: gh
description: GitHub CLI — used to fetch linked PRs and issues. Must be authenticated (gh auth login)
optional:
- mcp: notion
description: Used to fetch linked Notion documents. Skip Notion steps if unavailable.
- skill: loom-transcript
description: Used to fetch Loom video transcripts. Skip Loom steps if unavailable.
- cli: curl
description: Used to download images/attachments. Typically pre-installed.
---
# Linear Issue Analysis
Start work on Linear issue **$ARGUMENTS**
## Prerequisites
This skill depends on external tools. Before proceeding, verify availability:
**Required:**
- **Linear MCP** (`mcp__linear`): Must be connected. Without it the skill cannot function at all.
- **GitHub CLI** (`gh`): Must be installed and authenticated. Run `gh auth status` to verify. Used to fetch linked PRs and issues.
**Optional (graceful degradation):**
- **Notion MCP** (`mcp__notion`): Needed only if the issue links to Notion docs. If unavailable, note the Notion links in the summary and tell the user to check them manually.
- **Loom transcript skill** (`/loom-transcript`): Needed only if the issue contains Loom videos. If unavailable, note the Loom links in the summary for the user to watch.
- **curl**: Used to download images. Almost always available; if missing, skip image downloads and note it.
If a required tool is missing, stop and tell the user what needs to be set up before continuing.
## Instructions
Follow these steps to gather comprehensive context about the issue:
### 1. Fetch the Issue and Comments from Linear
Use the Linear MCP tools to fetch the issue details and comments together:
- Use `mcp__linear__get_issue` with the issue ID to get full details including attachments
- Include relations to see blocking/related/duplicate issues
- **Immediately after**, use `mcp__linear__list_comments` with the issue ID to fetch all comments
Both calls should be made together in the same step to gather the complete context upfront.
### 2. Analyze Attachments and Media (MANDATORY)
**IMPORTANT:** This step is NOT optional. You MUST scan and fetch all visual content from BOTH the issue description AND all comments.
**Screenshots/Images (ALWAYS fetch):**
1. Scan the issue description AND all comments for ALL image URLs:
- `<img>` tags
- Markdown images `![](url)`
- Raw URLs (github.com/user-attachments, imgur.com, etc.)
2. For EACH image found (in description or comments):
- Download using `curl -sL "url" -o /path/to/image.png` (GitHub URLs require following redirects) OR the linear mcp
- Use the `Read` tool on the downloaded file to view it
- Describe what you see in detail
3. Do NOT skip images - they often contain critical context like error messages, UI states, or configuration
**Loom Videos (ALWAYS fetch transcript):**
1. Scan the issue description AND all comments for Loom URLs (loom.com/share/...)
2. For EACH Loom video found (in description or comments):
- Use the `/loom-transcript` skill to fetch the FULL transcript
- Summarize key points, timestamps, and any demonstrated issues
3. Loom videos often contain crucial reproduction steps and context that text alone cannot convey
### 3. Fetch Related Context
**Related Linear Issues:**
- Use `mcp__linear__get_issue` for any issues mentioned in relations (blocking, blocked by, related, duplicates)
- Summarize how they relate to the main issue
**GitHub PRs and Issues:**
- If GitHub links are mentioned, use `gh` CLI to fetch PR/issue details:
- `gh pr view <number>` for pull requests
- `gh issue view <number>` for issues
- Download images attached to issues: `curl -H "Authorization: token $(gh auth token)" -L <image-url> -o image.png`
**Notion Documents:**
- If Notion links are present, use `mcp__notion__notion-fetch` with the Notion URL or page ID to retrieve document content
- Summarize relevant documentation
### 4. Review Comments
Comments were already fetched in Step 1. Review them for:
- Additional context and discussion history
- Any attachments or media linked in comments (process in Step 2)
- Clarifications or updates to the original issue description
### 5. Identify Affected Node (if applicable)
Determine whether this issue is specific to a particular n8n node (e.g. a trigger, action, or tool node). Look for clues in:
- The issue title (e.g. "Linear trigger", "Slack node", "HTTP Request")
- The issue description and comments mentioning node names
- Labels or tags on the issue (e.g. `node:linear`, `node:slack`)
- Screenshots showing a specific node's configuration or error
If the issue is node-specific:
1. **Find the node type ID.** Use `Grep` to search for the node's display name (or keywords from it) in `packages/frontend/editor-ui/data/node-popularity.json` to find the exact node type ID. For reference, common ID patterns are:
- Core nodes: `n8n-nodes-base.<camelCaseName>` (e.g. "HTTP Request" → `n8n-nodes-base.httpRequest`)
- Trigger variants: `n8n-nodes-base.<name>Trigger` (e.g. "Gmail Trigger" → `n8n-nodes-base.gmailTrigger`)
- Tool variants: `n8n-nodes-base.<name>Tool` (e.g. "Google Sheets Tool" → `n8n-nodes-base.googleSheetsTool`)
- LangChain/AI nodes: `@n8n/n8n-nodes-langchain.<camelCaseName>` (e.g. "OpenAI Chat Model" → `@n8n/n8n-nodes-langchain.lmChatOpenAi`)
2. **Look up the node's popularity score** from `packages/frontend/editor-ui/data/node-popularity.json`. Use `Grep` to search for the node ID in that file. The popularity score is a log-scale value between 0 and 1. Use these thresholds to classify:
| Score | Level | Description | Examples |
|-------|-------|-------------|----------|
| ≥ 0.8 | **High** | Core/widely-used nodes, top ~5% | HTTP Request (0.98), Google Sheets (0.95), Postgres (0.83), Gmail Trigger (0.80) |
| 0.40.8 | **Medium** | Regularly used integrations | Slack (0.78), GitHub (0.64), Jira (0.65), MongoDB (0.63) |
| < 0.4 | **Low** | Niche or rarely used nodes | Amqp (0.34), Wise (0.36), CraftMyPdf (0.33) |
Include the raw score and the level (high/medium/low) in the summary.
3. If the node is **not found** in the popularity file, note that it may be a community node or a very new/niche node.
### 6. Assess Effort/Complexity
After gathering all context, assess the effort required to fix/implement the issue. Use the following T-shirt sizes:
| Size | Approximate effort |
|------|--------------------|
| XS | 1 hour |
| S | 1 day |
| M | 2-3 days |
| L | 3-5 days |
| XL | 6 days |
To make this assessment, consider:
- **Scope of changes**: How many files/packages need to be modified? Is it a single node fix or a cross-cutting change?
- **Complexity**: Is it a straightforward parameter change, a new API integration, a new credential type, or an architectural change?
- **Testing**: How much test coverage is needed? Are E2E tests required?
- **Risk**: Could this break existing functionality? Does it need backward compatibility?
- **Dependencies**: Are there external API changes, new packages, or cross-team coordination needed?
- **Documentation**: Does this require docs updates, migration guides, or changelog entries?
Provide the T-shirt size along with a brief justification explaining the key factors that drove the estimate.
### 7. Present Summary
**Before presenting, verify you have completed:**
- [ ] Downloaded and viewed ALL images in the description AND comments
- [ ] Fetched transcripts for ALL Loom videos in the description AND comments
- [ ] Fetched ALL linked GitHub issues/PRs via `gh` CLI
- [ ] Listed all comments on the issue
- [ ] Checked whether the issue is node-specific and looked up popularity if so
- [ ] Assessed effort/complexity with T-shirt size
After gathering all context, present a comprehensive summary including:
1. **Issue Overview**: Title, status, priority, assignee, labels
2. **Description**: Full issue description with any clarifications from comments
3. **Visual Context**: Summary of screenshots/videos (what you observed in each)
4. **Affected Node** (if applicable): Node name, node type ID (`n8n-nodes-base.xxx`), popularity score with level (e.g. `0.64 — medium popularity`)
5. **Related Issues**: How this connects to other work
6. **Technical Context**: Any PRs, code references, or documentation
7. **Effort Estimate**: T-shirt size (XS/S/M/L/XL) with justification
8. **Next Steps**: Suggested approach based on all gathered context
## Notes
- The issue ID can be provided in formats like: `AI-1975`, `node-1975`, or just `1975` (will search)
- If no issue ID is provided, ask the user for one

View File

@@ -0,0 +1,105 @@
---
name: loom-transcript
description: Fetch and display the full transcript from a Loom video URL. Use when the user wants to get or read a Loom transcript.
argument-hint: [loom-url]
---
# Loom Transcript Fetcher
Fetch the transcript from a Loom video using Loom's GraphQL API.
## Instructions
Given the Loom URL: $ARGUMENTS
### 1. Extract the Video ID
Parse the Loom URL to extract the 32-character hex video ID. Supported URL formats:
- `https://www.loom.com/share/<video-id>`
- `https://www.loom.com/embed/<video-id>`
- `https://www.loom.com/share/<video-id>?sid=<session-id>`
The video ID is the 32-character hex string after `/share/` or `/embed/`.
### 2. Fetch Video Metadata
Use the `WebFetch` tool to POST to `https://www.loom.com/graphql` to get the video title and details.
Use this curl command via Bash:
```bash
curl -s 'https://www.loom.com/graphql' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'x-loom-request-source: loom_web_45a5bd4' \
-H 'apollographql-client-name: web' \
-H 'apollographql-client-version: 45a5bd4' \
-d '{
"operationName": "GetVideoSSR",
"variables": {"id": "<VIDEO_ID>", "password": null},
"query": "query GetVideoSSR($id: ID!, $password: String) { getVideo(id: $id, password: $password) { ... on RegularUserVideo { id name description createdAt owner { display_name } } } }"
}'
```
### 3. Fetch the Transcript URLs
Use curl via Bash to call the GraphQL API:
```bash
curl -s 'https://www.loom.com/graphql' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'x-loom-request-source: loom_web_45a5bd4' \
-H 'apollographql-client-name: web' \
-H 'apollographql-client-version: 45a5bd4' \
-d '{
"operationName": "FetchVideoTranscript",
"variables": {"videoId": "<VIDEO_ID>", "password": null},
"query": "query FetchVideoTranscript($videoId: ID!, $password: String) { fetchVideoTranscript(videoId: $videoId, password: $password) { ... on VideoTranscriptDetails { id video_id source_url captions_source_url } ... on GenericError { message } } }"
}'
```
Replace `<VIDEO_ID>` with the actual video ID extracted in step 1.
The response contains:
- `source_url` — JSON transcript URL
- `captions_source_url` — VTT (WebVTT) captions URL
### 4. Download and Parse the Transcript
Fetch **both** URLs returned from step 3 (if available):
1. **VTT captions** (`captions_source_url`): Download with `curl -sL "<url>"`. This is a WebVTT file with timestamps and text.
2. **JSON transcript** (`source_url`): Download with `curl -sL "<url>"`. This is a JSON file with transcript segments.
Prefer the VTT captions as the primary source since they include proper timestamps. Fall back to the JSON transcript if VTT is unavailable.
### 5. Present the Transcript
Format and present the full transcript to the user:
**Video:** [Title from metadata]
**Author:** [Owner name]
**Date:** [Created date]
---
**0:00** - First transcript segment text...
**0:14** - Second transcript segment text...
(continue for all segments)
---
## Error Handling
- If the GraphQL response contains a `GenericError`, report the error message to the user.
- If both `source_url` and `captions_source_url` are null/missing, tell the user that no transcript is available for this video.
- If the video URL is invalid or the ID cannot be extracted, ask the user for a valid Loom URL.
## Notes
- No authentication or cookies are required — Loom's transcript API is publicly accessible.
- Only English transcripts are available through this API.
- Transcripts are auto-generated and may contain minor errors.

View File

@@ -0,0 +1,98 @@
---
name: n8n-conventions
description: Quick reference for n8n patterns. Full docs /AGENTS.md
---
# n8n Quick Reference
**📚 Full Documentation:**
- **General:** `/AGENTS.md` - Architecture, commands, workflows
- **Frontend:** `/packages/frontend/AGENTS.md` - CSS variables, timing
Use this skill when you need quick reminders on critical patterns.
## Critical Rules (Must Follow)
**TypeScript:**
- Never `any` → use `unknown`
- Prefer `satisfies` over `as` (except tests)
- Shared types in `@n8n/api-types`
**Error Handling:**
```typescript
import { UnexpectedError } from 'n8n-workflow';
throw new UnexpectedError('message', { extra: { context } });
// DON'T use deprecated ApplicationError
```
**Frontend:**
- Vue 3 Composition API (`<script setup lang="ts">`)
- CSS variables (never hardcode px) - see `/packages/frontend/AGENTS.md`
- All text via i18n (`$t('key')`)
- `data-testid` for E2E (single value, no spaces)
**Backend:**
- Controller → Service → Repository
- Dependency injection via `@n8n/di`
- Config via `@n8n/config`
- Zod schemas for validation
**Testing:**
- Vitest (unit), Playwright (E2E)
- Mock external dependencies
- Work from package directory: `pushd packages/cli && pnpm test`
**Database:**
- SQLite/PostgreSQL only (app DB)
- Exception: DB nodes (MySQL Node, etc.) can use DB-specific features
**Commands:**
```bash
pnpm build > build.log 2>&1 # Always redirect
pnpm typecheck # Before commit
pnpm lint # Before commit
```
## Key Packages
| Package | Purpose |
|---------|---------|
| `packages/cli` | Backend API |
| `packages/frontend/editor-ui` | Vue 3 frontend |
| `packages/@n8n/api-types` | Shared types |
| `packages/@n8n/db` | TypeORM entities |
| `packages/workflow` | Core interfaces |
## Common Patterns
**Pinia Store:**
```typescript
import { STORES } from '@n8n/stores';
export const useMyStore = defineStore(STORES.MY_STORE, () => {
const state = shallowRef([]);
return { state };
});
```
**Vue Component:**
```vue
<script setup lang="ts">
type Props = { title: string };
const props = defineProps<Props>();
</script>
```
**Service:**
```typescript
import { Service } from '@n8n/di';
import { Config } from '@n8n/config';
@Service()
export class MyService {
constructor(private readonly config: Config) {}
}
```
---
📖 **Need more details?** Read `/AGENTS.md` and `/packages/frontend/AGENTS.md`

View File

@@ -0,0 +1,136 @@
---
name: reproduce-bug
description: Reproduce a bug from a Linear ticket with a failing test. Expects the full ticket context (title, description, comments) to be provided as input.
user_invocable: true
---
# Bug Reproduction Framework
Given a Linear ticket context ($ARGUMENTS), systematically reproduce the bug
with a failing regression test.
## Step 1: Parse Signals
Extract the following from the provided ticket context:
- **Error message / stack trace** (if provided)
- **Reproduction steps** (if provided)
- **Workflow JSON** (if attached)
- **Affected area** (node, execution engine, editor, API, config, etc.)
- **Version where it broke / last working version**
## Step 2: Route to Test Strategy
Based on the affected area, pick the test layer and pattern:
| Area | Test Layer | Pattern | Key Location |
|------|-----------|---------|--------------|
| Node operation | Jest unit | NodeTestHarness + nock | `packages/nodes-base/nodes/*/test/` |
| Node credential | Jest unit | jest-mock-extended | `packages/nodes-base/nodes/*/test/` |
| Trigger webhook | Jest unit | mock IHookFunctions + jest.mock GenericFunctions | `packages/nodes-base/nodes/*/test/` |
| Binary data | Jest unit | NodeTestHarness assertBinaryData | `packages/core/nodes-testing/` |
| Execution engine | Jest integration | WorkflowRunner + DI container | `packages/cli/src/__tests__/` |
| CLI / API | Jest integration | setupTestServer + supertest | `packages/cli/test/integration/` |
| Config | Jest unit | GlobalConfig + Container | `packages/@n8n/config/src/__tests__/` |
| Editor UI | Vitest | Vue Test Utils + Pinia | `packages/frontend/editor-ui/src/**/__tests__/` |
| E2E / Canvas | Playwright | Test containers + composables | `packages/testing/playwright/` |
## Step 3: Locate Source Files
Find the source code for the affected area:
1. Search for the node/service/component mentioned in the ticket
2. Find the GenericFunctions file (common bug location for nodes)
3. Check for existing test files in the same area
4. Look at recent git history on affected files (`git log --oneline -10 -- <path>`)
## Step 4: Trace the Code Path
Read the source code and trace the execution path that triggers the bug:
- Follow the call chain from entry point to the failure
- Identify the specific line(s) where the bug manifests
- Note any error handling (or lack thereof) around the bug
## Step 5: Form Hypothesis
State a clear, testable hypothesis:
- "When [input/condition], the code does [wrong thing] because [root cause]"
- Identify the exact line(s) that need to change
- Predict what the test output will show
## Step 6: Find Test Patterns
Look for existing tests in the same area:
1. Check `test/` directories near the affected code
2. Identify which mock/setup patterns they use
3. Use the same patterns for consistency
4. If no tests exist, find the closest similar node/service tests as a template
## Step 7: Write Failing Test
Write a regression test that:
- Uses the patterns found in Step 6
- Targets the specific hypothesis from Step 5
- Includes a comment referencing the ticket ID
- Asserts the CORRECT behavior (test will fail on current code)
- Also includes a "happy path" test to prove the setup works
## Step 8: Run and Score
Run the test from the package directory (e.g., `cd packages/nodes-base && pnpm test <file>`).
Classify the result:
| Confidence | Criteria | Output |
|------------|----------|--------|
| **CONFIRMED** | Test fails consistently, failure matches hypothesis | Reproduction Report |
| **LIKELY** | Test fails but failure mode differs slightly | Report + caveat |
| **UNCONFIRMED** | Cannot trigger the failure | Report: what was tried |
| **SKIPPED** | Hit a hard bailout trigger | Report: why skipped |
| **ALREADY_FIXED** | Bug no longer reproduces on current code | Report: when fixed |
## Step 9: Iterate or Bail
If UNCONFIRMED after first attempt:
- Revisit hypothesis — re-read the code path
- Try a different test approach or layer
- Maximum 3 attempts before declaring UNCONFIRMED
**Hard bailout triggers** (stop immediately):
- Requires real third-party API credentials
- Race condition / timing-dependent
- Requires specific cloud/enterprise infrastructure
- Requires manual UI interaction that can't be scripted
## Output: Reproduction Report
Present findings in this format:
---
**Ticket:** [ID] — [title]
**Confidence:** [CONFIRMED | LIKELY | UNCONFIRMED | SKIPPED | ALREADY_FIXED]
### Root Cause
[1-2 sentences explaining the bug mechanism]
### Location
| File | Lines | Issue |
|------|-------|-------|
| `path/to/file.ts` | XX-YY | Description of the problem |
### Failing Test
`path/to/test/file.test.ts` — X/Y tests fail:
1. `test name` — [failure description]
### Fix Hint
[Pseudocode or description of the fix approach]
---
## Important
- **DO NOT fix the bug** — only reproduce it with a failing test
- **Leave test files in place** as evidence (don't commit unless asked)
- **Run tests from the package directory** (e.g., `pushd packages/nodes-base && pnpm test <file> && popd`)
- **Always redirect build output**: `pnpm build > build.log 2>&1`
- **DO NOT look at existing fix PRs** — the goal is to reproduce from signals alone

9
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM n8nio/base:24
RUN apk add --no-cache --update openssh sudo shadow bash
RUN echo node ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/node && chmod 0440 /etc/sudoers.d/node
RUN mkdir /workspaces && chown node:node /workspaces
RUN npm install -g pnpm
USER node
RUN mkdir -p ~/.pnpm-store && pnpm config set store-dir ~/.pnpm-store --global

View File

@@ -0,0 +1,19 @@
{
"name": "n8n",
"dockerComposeFile": "docker-compose.yml",
"service": "n8n",
"workspaceFolder": "/workspaces",
"mounts": [
"type=bind,source=${localWorkspaceFolder},target=/workspaces,consistency=cached",
"type=bind,source=${localEnv:HOME}/.ssh,target=/home/node/.ssh,consistency=cached",
"type=bind,source=${localEnv:HOME}/.n8n,target=/home/node/.n8n,consistency=cached"
],
"forwardPorts": [8080, 5678],
"postCreateCommand": "corepack prepare --activate && pnpm install",
"postAttachCommand": "pnpm build",
"customizations": {
"codespaces": {
"openFiles": ["CONTRIBUTING.md"]
}
}
}

View File

@@ -0,0 +1,24 @@
volumes:
postgres-data:
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=n8n
- POSTGRES_PASSWORD=password
n8n:
build:
context: .
dockerfile: Dockerfile
volumes:
- ..:/workspaces:cached
command: sleep infinity
environment:
DB_POSTGRESDB_HOST: postgres
DB_TYPE: postgresdb
DB_POSTGRESDB_PASSWORD: password

40
.dockerignore Normal file
View File

@@ -0,0 +1,40 @@
# Whitelist approach: ignore everything, then allow only what Docker builds need
# This reduces build context from ~900MB to just what's required
# Ignore everything first
*
# === n8n main image (docker/images/n8n/Dockerfile) ===
!compiled
!compiled/**
!THIRD_PARTY_LICENSES.md
# === runners image (docker/images/runners/Dockerfile + Dockerfile.distroless) ===
!dist
!dist/task-runner-javascript
!dist/task-runner-javascript/**
!packages
!packages/@n8n
!packages/@n8n/task-runner-python
!packages/@n8n/task-runner-python/**
# === Docker build files (entrypoints, configs) ===
!docker
!docker/images
!docker/images/n8n
!docker/images/n8n/docker-entrypoint.sh
!docker/images/runners
!docker/images/runners/n8n-task-runners.json
# === benchmark image (packages/@n8n/benchmark/Dockerfile) ===
!package.json
!pnpm-lock.yaml
!pnpm-workspace.yaml
!patches
!patches/**
!scripts
!scripts/**
!packages/@n8n/benchmark
!packages/@n8n/benchmark/**
!packages/@n8n/typescript-config
!packages/@n8n/typescript-config/**

20
.editorconfig Normal file
View File

@@ -0,0 +1,20 @@
root = true
[*]
charset = utf-8
indent_style = tab
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[package.json]
indent_style = space
indent_size = 2
[*.yml]
indent_style = space
indent_size = 2
[*.ts]
quote_type = single

18
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1,18 @@
# Commits of large-scale changes to exclude from `git blame` results
# Set up linting and formatting (#2120)
56c4c6991fb21ba4b7bdcd22c929f63cc1d1defe
# refactor(editor): Apply Prettier (no-changelog) #4920
5ca2148c7ed06c90f999508928b7a51f9ac7a788
# refactor: Run lintfix (no-changelog) (#7537)
62c096710fab2f7e886518abdbded34b55e93f62
# refactor: Move test files alongside tested files (#11504)
7e58fc4fec468aca0b45d5bfe6150e1af632acbc
f32b13c6ed078be042a735bc8621f27e00dc3116

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

56
.github/CI-TELEMETRY.md vendored Normal file
View File

@@ -0,0 +1,56 @@
# CI Telemetry
Pipeline: **GitHub Actions → Webhook → n8n → BigQuery**
## Standard Data Points
All telemetry includes these fields for correlation:
```typescript
// Git context
git.sha // GITHUB_SHA (first 8 chars)
git.branch // GITHUB_HEAD_REF ?? GITHUB_REF_NAME
git.pr // PR number from GITHUB_REF
// CI context
ci.runId // GITHUB_RUN_ID
ci.job // GITHUB_JOB
ci.workflow // GITHUB_WORKFLOW
ci.attempt // GITHUB_RUN_ATTEMPT
// Runner detection
runner.provider // 'github' | 'blacksmith' | 'local'
runner.cpuCores // os.cpus().length
runner.memoryGb // os.totalmem()
```
**Runner provider logic:**
```typescript
if (!process.env.CI) return 'local';
if (process.env.RUNNER_ENVIRONMENT === 'github-hosted') return 'github';
return 'blacksmith';
```
## Implementations
| Telemetry | Source | Metrics |
|-----------|--------|---------|
| Build stats | `.github/scripts/send-build-stats.mjs` | Per-package build time, cache hits |
| Docker stats | `.github/scripts/send-docker-stats.mjs` | Image size, compiled artifact size, docker build time |
| Container stack | `packages/testing/containers/telemetry.ts` | E2E startup times |
## Secrets
```
BUILD_STATS_WEBHOOK_URL
BUILD_STATS_WEBHOOK_USER
BUILD_STATS_WEBHOOK_PASSWORD # Alphanumeric + hyphens only (no $!#@)
DOCKER_STATS_WEBHOOK_URL
```
## Adding New Telemetry
1. Copy data point helpers from `send-build-stats.mjs`
2. Create n8n workflow: Webhook (Basic Auth) → Code (flatten) → BigQuery
3. Add secrets to GitHub

42
.github/CLAUDE.md vendored Normal file
View File

@@ -0,0 +1,42 @@
@../AGENTS.md
## .github Quick Reference
This folder contains n8n's GitHub Actions infrastructure.
### Key Files
| File/Folder | Purpose |
|-------------|---------|
| `WORKFLOWS.md` | Complete CI/CD documentation |
| `workflows/` | GitHub Actions workflows |
| `actions/` | Reusable composite actions |
| `scripts/` | Release & Docker automation |
| `CODEOWNERS` | Team review ownership |
### Workflow Naming
| Prefix | Purpose |
|--------|---------|
| `test-` | Testing (unit, E2E, visual) |
| `ci-` | Continuous integration |
| `util-` | Utilities (notifications) |
| `build-` | Build processes |
| `release-` | Release automation |
| `sec-` | Security scanning |
Reusable workflows: add `-reusable` or `-callable` suffix.
### Common Tasks
**Add workflow:** Create in `workflows/`, document in `WORKFLOWS.md`
**Add script:** Create `.mjs` in `scripts/`, document in `WORKFLOWS.md`
### Reference
See `WORKFLOWS.md` for:
- Architecture diagrams
- Workflow call graph
- Scheduled jobs & triggers
- Runners & secrets

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
packages/@n8n/db/src/migrations/ @n8n-io/migrations-review

103
.github/ISSUE_TEMPLATE/01-bug.yml vendored Normal file
View File

@@ -0,0 +1,103 @@
name: Bug Report
description: Create a bug report to help us improve
body:
- type: markdown
attributes:
value: |
> ⚠️ This form is for reporting bugs only.
> ❌ Please do not use this form for general support, feature requests, or questions.
> 💬 For help and general inquiries, visit our [community support forum](https://community.n8n.io).
> ☁️ If you're experiencing issues with cloud instances not starting or license-related problems, contact [n8n support directly](mailto:help@n8n.io).
---
Thank you for helping us improve n8n!
To ensure we can address your report efficiently, please fill out all sections in English and provide as much detail as possible.
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is
placeholder: Tell us what you see!
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: To Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen
validations:
required: true
- type: textarea
id: debug-info
attributes:
label: Debug Info
description: This can be found under Help > About n8n > Copy debug information
validations:
required: true
- type: markdown
attributes:
value: '## Environment'
- type: input
id: os
attributes:
label: Operating System
placeholder: ex. Ubuntu Linux 22.04
validations:
required: true
- type: input
id: n8n-version
attributes:
label: n8n Version
placeholder: ex. 1.25.0
validations:
required: true
- type: input
id: nodejs-version
attributes:
label: Node.js Version
placeholder: ex. 24.0.0
validations:
required: true
- type: dropdown
id: db
attributes:
label: Database
options:
- SQLite (default)
- PostgreSQL
default: 0
validations:
required: true
- type: dropdown
id: execution-mode
attributes:
label: Execution mode
description: '[Info](https://docs.n8n.io/hosting/scaling/queue-mode/)'
options:
- main (default)
- queue
- own (deprecated)
default: 0
validations:
required: true
- type: dropdown
id: hosting
attributes:
label: Hosting
options:
- n8n cloud
- self hosted
default: 0
validations:
required: true

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Feature request
url: https://community.n8n.io
about: Suggest an idea for this project
- name: Question / Problem
url: https://community.n8n.io
about: Questions and problems with n8n
- name: n8n Security Vulnerability
url: https://n8n.io/legal/#vulnerability
about: Learn about our Vulnerability Disclosure Policy

675
.github/WORKFLOWS.md vendored Normal file
View File

@@ -0,0 +1,675 @@
# GitHub Actions & CI/CD Documentation
Complete reference for n8n's `.github/` folder.
---
## Folder Structure
```
.github/
├── WORKFLOWS.md # This document
├── CI-TELEMETRY.md # Telemetry & metrics guide
├── CODEOWNERS # Team ownership for PR reviews
├── pull_request_template.md # PR description template
├── pull_request_title_conventions.md # Title format rules (Angular)
├── actionlint.yml # Workflow linter config
├── docker-compose.yml # DB services for local testing
├── test-metrics/
│ └── playwright.json # E2E performance baselines
├── ISSUE_TEMPLATE/
│ ├── config.yml # Routes to community/security
│ └── 01-bug.yml # Structured bug report form
├── scripts/ # Automation scripts
│ ├── bump-versions.mjs # Calculate next version
│ ├── update-changelog.mjs # Generate CHANGELOG
│ ├── trim-fe-packageJson.js # Strip frontend devDeps
│ ├── ensure-provenance-fields.mjs # Add license/author fields
│ ├── validate-docs-links.js # Check documentation URLs
│ ├── send-build-stats.mjs # Turbo build telemetry → webhook
│ └── docker/
│ ├── docker-tags.mjs # Generate image tags
│ └── docker-config.mjs # Build context config
├── actions/ # Custom composite actions
│ ├── setup-nodejs/ # pnpm + Node + Turbo cache
│ └── docker-registry-login/ # GHCR + DockerHub auth
└── workflows/ # GitHub Actions workflows
```
---
## Architecture Overview
```
┌────────────────────────────────────────────────────────────────────────────┐
│ n8n CI/CD ARCHITECTURE │
├────────────────────────────────────────────────────────────────────────────┤
│ │
│ TRIGGERS PIPELINES OUTPUTS │
│ ──────── ───────── ─────── │
│ │
│ ┌──────────┐ ┌──────────────────────────────────┐ ┌────────────┐ │
│ │ PR │───▶│ ci-pull-requests.yml │───▶│ Checks │ │
│ └──────────┘ │ ├─ build + paths-filter │ │ Gate │ │
│ │ ├─ unit-test (reusable) │ └────────────┘ │
│ ┌──────────┐ │ ├─ typecheck │ │
│ │ Push │───▶│ ├─ lint (reusable) │ ┌────────────┐ │
│ │ master │ │ ├─ e2e-tests (reusable) │───▶│ Coverage │ │
│ └──────────┘ │ └─ security (if .github/**) │ └────────────┘ │
│ └──────────────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────────────────────────────┐ ┌────────────┐ │
│ │ Merge │───▶│ release-publish.yml │───▶│ NPM │ │
│ │release/* │ │ ├─ publish-to-npm │ ├────────────┤ │
│ └──────────┘ │ ├─ publish-to-docker-hub │───▶│ Docker │ │
│ │ ├─ create-github-release │ ├────────────┤ │
│ │ ├─ create-sentry-release │───▶│ Sentry │ │
│ │ └─ generate-sbom │ ├────────────┤ │
│ └──────────────────────────────────┘───▶│ SBOM │ │
│ └────────────┘ │
│ ┌──────────┐ ┌──────────────────────────────────┐ │
│ │ Schedule │───▶│ Nightly/Weekly Jobs │ ┌────────────┐ │
│ │ (cron) │ │ ├─ docker-build-push (nightly) │───▶│ Images │ │
│ └──────────┘ │ ├─ test-benchmark-nightly │───▶│ Metrics │ │
│ │ ├─ test-workflows-nightly │ └────────────┘ │
│ │ └─ test-e2e-coverage-weekly │ │
│ └──────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────────────┘
```
---
## Quick Reference
| Prefix | Purpose |
|------------|-----------------------------------------|
| `test-` | Testing (E2E, unit, visual, benchmarks) |
| `ci-` | Continuous integration |
| `util-` | Utilities (notifications, sync, Claude) |
| `build-` | Build processes |
| `release-` | Release automation |
| `sec-` | Security scanning |
| Other | Docker, SBOM, patch releases |
---
## PR Title Conventions
Commits drive changelog generation. Follow Angular convention:
```
Format: <type>(<scope>): <summary>
Types: feat | fix | perf | test | docs | refactor | build | ci | chore
Scopes: API | benchmark | core | editor | * Node (optional)
Examples:
feat(editor): Add dark mode toggle
fix(Slack Node): Handle rate limiting correctly
perf(core): Optimize workflow execution by 20%
refactor: Migrate to TypeScript strict mode (no-changelog)
Breaking Changes: Add "BREAKING CHANGE:" footer with migration guide
Deprecations: Add "DEPRECATED:" footer with update path
Skip Changelog: Add "(no-changelog)" to PR title
```
See `pull_request_title_conventions.md` for full spec.
---
## What Runs When You Open a PR
### Flow Diagram
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ PR OPENED / UPDATED │
└─────────────────────────────────────┬────────────────────────────────────────┘
┌───────────────────────────┴───────────────────────┐
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ ci-pull-requests.yml │ │ ci-check-pr-title.yml │
│ (main orchestrator) │ │ (validates title format) │
└─────────────┬─────────────┘ └───────────────────────────┘
┌───────────────────────────┐
│ install-and-build │
│ └─ paths-filter │──────────────────────────────────────────┐
└─────────────┬─────────────┘ │
│ │
│ [if non-Python files changed] │ [if .github/** changed]
│ │
┌─────────┼─────────┬─────────────┬─────────────┐ │
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
┌───────┐ ┌───────┐ ┌───────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ unit │ │ type │ │ lint │ │ e2e-tests │ │ security │ │ security │
│ test │ │ check │ │ │ │ │ │ checks │ │ checks │
└───┬───┘ └───┬───┘ └───┬───┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘
│ │ │ │ │ │
│ │ │ ┌─────┴─────┐ │ │
│ │ │ ▼ ▼ │ │
│ │ │ Internal Fork PR │ │
│ │ │ 14 shards 6 shards │ │
│ │ │ Docker SQLite │ │
│ │ │ │ │
└─────────┴─────────┴──────────┬───────────────┴────────────────┘
┌──────────────────────────────┐
│ required-checks │
│ (merge gate) │
└──────────────────────────────┘
```
### Path-Filtered Workflows
These only run if specific files changed:
| Files Changed | Workflow | Branch |
|------------------------------------------------------------------------|-----------------------------|------------|
| `packages/@n8n/task-runner-python/**` | `ci-python.yml` | any |
| `packages/cli/src/databases/**`, `*.entity.ts`, `*.repository.ts` | `test-db.yml` | any |
| `packages/frontend/@n8n/storybook/**`, design-system, chat | `test-visual-storybook.yml` | master |
| `docker/images/n8n-base/Dockerfile` | `build-base-image.yml` | any |
| `**/package.json`, `**/turbo.json` | `build-windows.yml` | master |
| `packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/**` | `test-evals-python.yml` | any |
| `packages/@n8n/benchmark/**` | `build-benchmark-image.yml` | master |
| `packages/cli/src/public-api/**/*.{css,yaml,yml}` | `util-sync-api-docs.yml` | master |
### On PR Review
| Event | Workflow | Condition |
|----------------------------|-----------------------------|------------------------------|
| Review approved | `test-visual-chromatic.yml` | + design files changed |
| Comment with `@claude` | `util-claude.yml` | mention in any comment |
| Any review | `util-notify-pr-status.yml` | not community-labeled |
### On PR Close/Merge
| Event | Workflow |
|----------------------------|-----------------------------|
| PR closed (any) | `util-notify-pr-status.yml` |
| PR merged to `release/*` | `release-publish.yml` |
### Manual Triggers (PR Comments)
| Command | Workflow | Permissions |
|--------------------|------------------------------|---------------------|
| `/build-unit-test` | `ci-manual-unit-tests.yml` | admin/write/maintain|
| `/test-workflows` | `test-workflows-callable.yml`| admin/write/maintain|
**Why:** Re-run tests without pushing commits. Useful for flaky test investigation.
### Other Manual Workflows
| Workflow | Purpose |
|---------------------------|---------------------------------------------------------|
| `util-claude-task.yml` | Run Claude Code to complete a task and create a PR |
| `util-data-tooling.yml` | SQLite/PostgreSQL export/import validation (manual) |
#### Claude Task Runner (`util-claude-task.yml`)
Runs Claude Code to complete a task, then creates a PR with the changes. Use for well-specced tasks or simple fixes. Can be triggered via GitHub UI or API.
Claude reads templates from `.github/claude-templates/` for task-specific guidance. Add new templates as needed for recurring task types.
**Inputs:**
- `task` - Description of what Claude should do
- `user_token` - GitHub PAT (PR will be authored by the token owner)
**Token requirements** (fine-grained PAT):
- Repository: `n8n-io/n8n`
- Contents: `Read and write`
- Pull requests: `Read and write`
**Governance:** If you provide your personal PAT, you cannot approve the resulting PR. For automated/bot use cases (e.g., dependabot-style updates via n8n workflows), an app token can be used instead.
---
## Workflow Call Graph
Shows which workflows call which reusable workflows:
```
CALLER REUSABLE WORKFLOW
───────────────────────────────────────────────────────────────────────────────
ci-pull-requests.yml
├──────────────────────────▶ test-unit-reusable.yml
├──────────────────────────▶ test-linting-reusable.yml
├──────────────────────────▶ test-e2e-ci-reusable.yml
│ └──────────▶ test-e2e-reusable.yml
└──────────────────────────▶ sec-ci-reusable.yml
└──────────▶ sec-poutine-reusable.yml
ci-master.yml
├──────────────────────────▶ test-unit-reusable.yml
└──────────────────────────▶ test-linting-reusable.yml
release-publish.yml
├──────────────────────────▶ docker-build-push.yml
│ └──────────▶ security-trivy-scan-callable.yml
└──────────────────────────▶ sbom-generation-callable.yml
test-workflows-nightly.yml
└──────────────────────────▶ test-workflows-callable.yml
PR Comment Dispatchers (triggered by /command in PR comments):
build-unit-test-pr-comment.yml
└──────────────────────────▶ ci-manual-unit-tests.yml
test-workflows-pr-comment.yml
└──────────────────────────▶ test-workflows-callable.yml
```
---
## Release Lifecycle
```
┌────────────────────────────────────────────────────────────────────────────┐
│ RELEASE LIFECYCLE │
├────────────────────────────────────────────────────────────────────────────┤
│ │
│ STAGE 1: Create Release PR │
│ ─────────────────────────── │
│ Trigger: Manual workflow_dispatch │
│ │
│ release-create-pr.yml │
│ ├─ bump-versions.mjs ────────▶ Calculate X.Y.Z │
│ ├─ update-changelog.mjs ─────▶ Generate CHANGELOG │
│ └─ Create PR: release-pr/X.Y.Z → release/X.Y.Z │
│ │
│ Inputs: │
│ ├─ release-type: patch │ minor │ major │ experimental │ premajor │
│ └─ base-branch: default master │
│ │ │
│ ▼ │
│ STAGE 2: CI Validation │
│ ─────────────────────── │
│ ci-pull-requests.yml runs full suite │
│ ├─ NO ci-check-pr-title.yml (skipped for release branches) │
│ └─ NO test-visual-chromatic.yml (skipped) │
│ │ │
│ ▼ [Merge PR] │
│ STAGE 3: Publish │
│ ─────────────── │
│ release-publish.yml (triggered on merge to release/*) │
│ ├─ publish-to-npm │
│ │ ├─ trim-fe-packageJson.js ───▶ Strip devDeps │
│ │ ├─ ensure-provenance-fields.mjs ───▶ Add license fields │
│ │ └─ npm publish (tag: rc or latest) │
│ ├─ publish-to-docker-hub ────────▶ docker-build-push.yml │
│ │ └─ Multi-arch: amd64 + arm64 │
│ ├─ create-github-release │
│ ├─ create-sentry-release (sourcemaps) │
│ ├─ generate-sbom ────────────────▶ sbom-generation-callable.yml │
│ │ └─ CycloneDX + Cosign signing │
│ └─ trigger-release-note (stable only) │
│ │ │
│ ▼ │
│ STAGE 4: Channel Promotion (optional) │
│ ────────────────────────────────────── │
│ Trigger: Manual release-push-to-channel.yml │
│ ├─ beta ─────▶ npm tags: next, beta │
│ └─ stable ───▶ npm tags: latest, stable │
│ │
└────────────────────────────────────────────────────────────────────────────┘
```
### Other Release Workflows
| Workflow | Trigger | Purpose |
|----------------------------------|--------------------|------------------------------------------------|
| `release-standalone-package.yml` | Manual dispatch | Release individual packages (@n8n/codemirror-lang, @n8n/create-node, etc.) |
| `create-patch-release-branch.yml`| Manual dispatch | Cherry-pick commits for patch releases |
---
## Fork vs Internal PR
| Aspect | Internal PR | Fork PR |
|--------------------|----------------------------------|-------------------------|
| E2E Runner | `blacksmith-2vcpu-ubuntu-2204` | `ubuntu-latest` |
| E2E Mode | `docker-build` (multi-main) | `local` (SQLite) |
| E2E Shards | 14 + 2 | 6 + 2 |
| Test Command | `test:container:multi-main:*` | `test:local:*` |
| Secrets | Full access | None |
| Currents Recording | Yes | No |
| Failure Artifacts | No | Yes |
**Why:** Fork PRs cannot access repository secrets. Local mode with SQLite provides feedback without paid services.
---
## ci-master.yml
Runs on push to `master` or `1.x`:
```
Push to master/1.x
├─ build-github (populate cache)
├─ unit-test (matrix: Node 22.x, 24.13.1, 25.x)
│ └─ Coverage only on 24.13.1
├─ lint
└─ notify-on-failure (Slack #alerts-build)
```
---
## Scheduled Jobs
| Schedule (UTC) | Workflow | Purpose |
|---------------------------|-----------------------------------|--------------------------|
| Daily 00:00 | `docker-build-push.yml` | Nightly Docker images |
| Daily 00:00 | `test-db.yml` | Database compatibility |
| Daily 00:00 | `test-e2e-performance-reusable.yml`| Performance E2E |
| Daily 00:00 | `test-visual-storybook.yml` | Storybook deploy |
| Daily 00:00 | `test-visual-chromatic.yml` | Visual regression |
| Daily 00:00 | `util-check-docs-urls.yml` | Doc link validation |
| Daily 01:30, 02:30, 03:30 | `test-benchmark-nightly.yml` | Performance benchmarks |
| Daily 02:00 | `test-workflows-nightly.yml` | Workflow tests |
| Daily 05:00 | `test-benchmark-destroy-nightly.yml`| Cleanup benchmark env |
| Monday 00:00 | `util-update-node-popularity.yml` | Node usage stats |
| Monday 02:00 | `test-e2e-coverage-weekly.yml` | Weekly E2E coverage |
| Saturday 22:00 | `test-evals-ai.yml` | AI workflow evals |
---
## Custom Actions
Composite actions in `.github/actions/`:
| Action | Purpose | Used By |
|--------------------------|----------------------------------------------|--------------------|
| `setup-nodejs` | pnpm + Node.js + Turbo cache + Docker (opt) | Most CI workflows |
| `docker-registry-login` | GHCR + DockerHub + DHI authentication | Docker workflows |
### setup-nodejs
```yaml
inputs:
node-version: # default: '24.13.1'
enable-docker-cache: # default: 'false' (Blacksmith Buildx)
build-command: # default: 'pnpm build'
```
### docker-registry-login
```yaml
inputs:
login-ghcr: # default: 'true'
login-dockerhub: # default: 'false'
login-dhi: # default: 'false'
```
---
## Reusable Workflows
Workflows with `workflow_call` trigger:
| Workflow | Inputs | Purpose |
|------------------------------------|-----------------------------------------------|-----------------------|
| `test-unit-reusable.yml` | `ref`, `nodeVersion`, `collectCoverage` | Unit tests |
| `test-linting-reusable.yml` | `ref`, `nodeVersion` | ESLint |
| `test-e2e-reusable.yml` | `branch`, `test-mode`, `shards`, `runner` | Core E2E executor |
| `test-e2e-ci-reusable.yml` | `branch` | E2E orchestrator |
| `test-e2e-docker-pull-reusable.yml`| `branch`, `n8n_version` | E2E with pulled image |
| `test-workflows-callable.yml` | `git_ref`, `compare_schemas` | Workflow tests |
| `ci-check-eligibility-reusable.yml`| (internal) | PR eligibility checks |
| `docker-build-push.yml` | `n8n_version`, `release_type`, `push_enabled` | Docker build |
| `sec-ci-reusable.yml` | `ref` | Security orchestrator |
| `sec-poutine-reusable.yml` | `ref` | Poutine scanner |
| `security-trivy-scan-callable.yml` | `image_ref` | Trivy scan |
| `sbom-generation-callable.yml` | `n8n_version`, `release_tag_ref` | SBOM generation |
---
## Scripts
Scripts in `.github/scripts/`:
### Release Scripts
| Script | Purpose | Called By |
|-------------------------------|----------------------------|-------------------------|
| `bump-versions.mjs` | Calculate next version | `release-create-pr.yml` |
| `update-changelog.mjs` | Generate CHANGELOG | `release-create-pr.yml` |
| `trim-fe-packageJson.js` | Strip frontend devDeps | `release-publish.yml` |
| `ensure-provenance-fields.mjs`| Add license/author fields | `release-publish.yml` |
### Docker Scripts
| Script | Purpose | Called By |
|-------------------------|-------------------|------------------------|
| `docker/docker-config.mjs`| Build context | `docker-build-push.yml`|
| `docker/docker-tags.mjs` | Image tags | `docker-build-push.yml`|
### Validation Scripts
| Script | Purpose | Called By |
|-------------------------|-------------------|---------------------------|
| `validate-docs-links.js`| Check doc URLs | `util-check-docs-urls.yml`|
| `send-build-stats.mjs` | Build telemetry | `setup-nodejs` action |
---
## Telemetry
CI metrics are collected via webhooks to n8n, then stored in BigQuery for analysis.
See **[CI-TELEMETRY.md](CI-TELEMETRY.md)** for:
- Common data points (git, CI context, runner info)
- Existing implementations (build stats, container stack)
- How to add new telemetry
- BigQuery schema patterns and queries
---
## CODEOWNERS
Team ownership mappings in `CODEOWNERS`:
| Path Pattern | Team |
|--------------------------------------------------------------|----------------------------|
| `packages/@n8n/db/src/migrations/` | @n8n-io/migrations-review |
---
## Runner Selection
| Runner | vCPU | Use Case |
|-------------------------------------|------|-----------------------------|
| `ubuntu-slim` | 1 | Gate jobs (required-checks) |
| `ubuntu-latest` | 2 | Simple jobs, fork PR E2E |
| `blacksmith-2vcpu-ubuntu-2204` | 2 | Standard builds, E2E shards |
| `blacksmith-4vcpu-ubuntu-2204` | 4 | Unit tests, typecheck, lint |
| `blacksmith-8vcpu-ubuntu-2204` | 8 | E2E coverage (weekly) |
| `blacksmith-4vcpu-ubuntu-2204-arm` | 4 | ARM64 Docker builds |
### Selection Guidelines
**`ubuntu-slim`** - Status check aggregation, gate/required-check jobs, notifications
**`ubuntu-latest`** - Simple build verification, scheduled maintenance, PR comment handlers, release tagging, Docker manifest creation, any job where speed is not critical
**`blacksmith-2vcpu-ubuntu-2204`** - Initial build/install (benefits from Blacksmith caching), database integration tests (I/O bound), Chromatic/Storybook builds
**`blacksmith-4vcpu-ubuntu-2204`** - Unit tests (parallelized), linting (parallel file processing), typechecking (CPU-intensive), E2E test shards
**`blacksmith-8vcpu-ubuntu-2204`** - Heavy parallel workloads, full E2E coverage runs
### Runner Provider Toggle
The `RUNNER_PROVIDER` repository variable controls runner selection across workflows:
| Value | Behavior |
|-------|----------|
| (unset) | Use Blacksmith runners (default) |
| `github` | Use GitHub-hosted `ubuntu-latest` |
**Note:** When set to `github`, all jobs use `ubuntu-latest` regardless of any runner inputs or defaults specified in reusable workflows. GitHub runners have fewer vCPUs (2 vs 4), so jobs may run slower.
---
## Security
### Why We Do This
Supply chain security ensures artifacts haven't been tampered with. We provide three types of signed attestations:
```
ATTESTATION (signed statement)
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
PROVENANCE SBOM VEX
"Trust the "Know the "Understand
build" contents" the risk"
```
| Attestation | Question It Answers |
|-------------|--------------------------------|
| **Provenance** | "Can we trust this artifact came from n8n's CI and wasn't tampered with?" |
| **SBOM** | "What dependencies are inside?" (license compliance, vulnerability scanning) |
| **VEX** | "The scanner found CVE-X - does it actually affect us or is it a false positive?" |
**How they relate:**
- **SBOM** is the ingredients list - input for both license checks AND security scanning
- **VEX** is the security triage output - "we investigated CVE-X, here's our assessment"
- **Provenance** proves the SBOM and VEX came from our CI, not an attacker
---
### Poutine (Supply Chain)
- **Runs on:** PR changes to `.github/**`
- **Detects:** Exposed secrets, insecure workflow configs
- **Output:** SARIF to GitHub Security tab
### Trivy (Container)
- **Runs on:** stable/nightly/rc Docker builds
- **Scans:** n8n image, runners image
- **Output:** Slack `#notify-security-scan-outputs` (all), `#mission-security` (critical)
### SBOM
- **Runs on:** release-publish
- **Format:** CycloneDX JSON
- **Signing:** GitHub Attestation API
- **Attached to:** GitHub Release
### SLSA L3 Provenance
SLSA (Supply-chain Levels for Software Artifacts) Level 3 provides cryptographic proof of build integrity.
| Artifact | Generator | Level |
|----------|-----------|-------|
| Docker images | `slsa-framework/slsa-github-generator` | L3 |
| npm packages | `NPM_CONFIG_PROVENANCE=true` | L3 |
**Docker provenance** uses the SLSA GitHub Generator as a reusable workflow (not an action). This is required for L3 because provenance must be generated in an isolated environment the build can't tamper with.
```yaml
# IMPORTANT: Must use semantic version tags (@vX.Y.Z), NOT commit SHAs.
# The slsa-verifier requires tagged versions to verify authenticity.
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0
```
**Verify provenance:**
```bash
# Docker
slsa-verifier verify-image ghcr.io/n8n-io/n8n:VERSION \
--source-uri github.com/n8n-io/n8n
# npm
npm audit signatures n8n@VERSION
```
### VEX (Vulnerability Exploitability eXchange)
VEX documents which CVEs actually affect n8n vs false positives from scanners.
- **File:** `security/vex.openvex.json`
- **Format:** OpenVEX (broad scanner compatibility - Trivy, Docker Scout, etc.)
- **Attached to:** GitHub Release, Docker image attestations
- **Used by:** Trivy scans (via `security/trivy.yaml`)
**VEX Status Types:**
| Status | Meaning |
|--------|---------|
| `not_affected` | CVE doesn't impact n8n (code not reachable, etc.) |
| `affected` | CVE impacts n8n, tracking fix |
| `fixed` | CVE was present, now fixed |
| `under_investigation` | Assessing impact |
**Verify VEX attestation:**
```bash
cosign verify-attestation --type openvex \
--certificate-identity-regexp '.*github.com/n8n-io/n8n.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/n8n-io/n8n:VERSION
```
**Adding a CVE statement to security/vex.openvex.json:**
```json
{
"statements": [
{
"vulnerability": { "name": "CVE-2024-XXXXX" },
"products": [{ "@id": "pkg:github/n8n-io/n8n" }],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"statement": "n8n does not use the affected code path in this dependency"
}
]
}
```
---
## Secrets
### By Category
| Category | Secrets |
|---------------------|-------------------------------------------------------------|
| Package Publishing | `NPM_TOKEN`, `DOCKER_USERNAME`, `DOCKER_PASSWORD` |
| Notifications | `SLACK_WEBHOOK_URL`, `QBOT_SLACK_TOKEN` |
| Code Quality | `CODECOV_TOKEN`, `CHROMATIC_PROJECT_TOKEN`, `CURRENTS_RECORD_KEY` |
| Error Tracking | `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_*_PROJECT` |
| Cloud/CDN | `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` |
| GitHub Automation | `N8N_ASSISTANT_APP_ID`, `N8N_ASSISTANT_PRIVATE_KEY` |
| Benchmarking | `BENCHMARK_ARM_*`, `N8N_BENCHMARK_LICENSE_CERT` |
| AI/Evals | `ANTHROPIC_API_KEY`, `EVALS_LANGSMITH_*` |
### Scoping
- **`secrets: inherit`** - passes all secrets to reusable workflows
- **Explicit passing** - for minimal exposure
- **Environment: `benchmarking`** - Azure OIDC credentials
---
## Future Vision
### Redundancy Review
Comment triggers (`/build-unit-test`, `/test-workflows`) are workarounds.
Long-term: Main CI should be reliable enough to not need these.
### Workflow Testability
- Tools like `act` for local testing
- Unit tests for `.github/scripts/*.mjs`
- Validation with `actionlint`

8
.github/actionlint.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
self-hosted-runner:
labels:
- blacksmith-2vcpu-ubuntu-2204
- blacksmith-4vcpu-ubuntu-2204
- blacksmith-2vcpu-ubuntu-2204-arm
- blacksmith-4vcpu-ubuntu-2204-arm
- blacksmith-8vcpu-ubuntu-2204
- ubuntu-slim

View File

@@ -0,0 +1,235 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { matchGlob, parseFilters, evaluateFilter, runValidate } from '../ci-filter.mjs';
// --- matchGlob ---
describe('matchGlob', () => {
it('** matches dotfiles', () => {
assert.ok(matchGlob('.github/workflows/ci.yml', '**'));
});
it('** matches deeply nested paths', () => {
assert.ok(matchGlob('packages/cli/src/controllers/auth.ts', '**'));
});
it('** matches root-level files', () => {
assert.ok(matchGlob('README.md', '**'));
});
it('.github/** matches workflow files', () => {
assert.ok(matchGlob('.github/workflows/ci.yml', '.github/**'));
});
it('.github/** matches action files', () => {
assert.ok(matchGlob('.github/actions/ci-filter/action.yml', '.github/**'));
});
it('.github/** does not match non-.github paths', () => {
assert.ok(!matchGlob('packages/cli/src/index.ts', '.github/**'));
});
it('scoped package pattern matches files in that package', () => {
assert.ok(
matchGlob(
'packages/@n8n/task-runner-python/src/main.py',
'packages/@n8n/task-runner-python/**',
),
);
});
it('scoped package pattern does not match other packages', () => {
assert.ok(!matchGlob('packages/@n8n/config/src/index.ts', 'packages/@n8n/task-runner-python/**'));
});
it('* matches single-level only', () => {
assert.ok(matchGlob('README.md', '*.md'));
assert.ok(!matchGlob('docs/README.md', '*.md'));
});
it('exact path match', () => {
assert.ok(matchGlob('package.json', 'package.json'));
assert.ok(!matchGlob('packages/cli/package.json', 'package.json'));
});
it('? matches single character', () => {
assert.ok(matchGlob('file1.txt', 'file?.txt'));
assert.ok(!matchGlob('file12.txt', 'file?.txt'));
});
it('**/ at start matches zero or more path segments', () => {
assert.ok(matchGlob('src/index.ts', '**/index.ts'));
assert.ok(matchGlob('packages/cli/src/index.ts', '**/index.ts'));
assert.ok(matchGlob('index.ts', '**/index.ts'));
});
it('**/ in middle matches nested paths', () => {
assert.ok(matchGlob('packages/@n8n/db/src/deep/file.ts', 'packages/@n8n/db/**'));
});
});
// --- parseFilters ---
describe('parseFilters', () => {
it('parses single-line filter', () => {
const filters = parseFilters('workflows: .github/**');
assert.deepEqual(filters.get('workflows'), ['.github/**']);
});
it('parses single-line with multiple patterns', () => {
const filters = parseFilters('db: packages/@n8n/db/** packages/cli/**');
assert.deepEqual(filters.get('db'), ['packages/@n8n/db/**', 'packages/cli/**']);
});
it('parses multi-line filter', () => {
const input = `non-python:
**
!packages/@n8n/task-runner-python/**`;
const filters = parseFilters(input);
assert.deepEqual(filters.get('non-python'), ['**', '!packages/@n8n/task-runner-python/**']);
});
it('parses mixed single and multi-line', () => {
const input = `non-python:
**
!packages/@n8n/task-runner-python/**
workflows: .github/**`;
const filters = parseFilters(input);
assert.equal(filters.size, 2);
assert.deepEqual(filters.get('non-python'), ['**', '!packages/@n8n/task-runner-python/**']);
assert.deepEqual(filters.get('workflows'), ['.github/**']);
});
it('ignores comments and blank lines', () => {
const input = `# This is a comment
workflows: .github/**
# Another comment
db: packages/@n8n/db/**`;
const filters = parseFilters(input);
assert.equal(filters.size, 2);
});
it('throws on malformed input', () => {
assert.throws(() => parseFilters('not a valid filter line'), /Malformed/);
});
it('throws on filter with no patterns', () => {
const input = `empty:
other: .github/**`;
assert.throws(() => parseFilters(input), /no patterns/);
});
});
// --- evaluateFilter ---
describe('evaluateFilter', () => {
it('python-only files with non-python filter returns false', () => {
const files = [
'packages/@n8n/task-runner-python/src/main.py',
'packages/@n8n/task-runner-python/pyproject.toml',
];
const patterns = ['**', '!packages/@n8n/task-runner-python/**'];
assert.equal(evaluateFilter(files, patterns), false);
});
it('mixed python and non-python returns true', () => {
const files = [
'packages/@n8n/task-runner-python/src/main.py',
'packages/cli/src/index.ts',
];
const patterns = ['**', '!packages/@n8n/task-runner-python/**'];
assert.equal(evaluateFilter(files, patterns), true);
});
it('non-python files with non-python filter returns true', () => {
const files = ['packages/cli/src/index.ts', 'packages/core/src/utils.ts'];
const patterns = ['**', '!packages/@n8n/task-runner-python/**'];
assert.equal(evaluateFilter(files, patterns), true);
});
it('.github files with workflows filter returns true', () => {
const files = ['.github/workflows/ci.yml', '.github/actions/setup/action.yml'];
const patterns = ['.github/**'];
assert.equal(evaluateFilter(files, patterns), true);
});
it('non-.github files with workflows filter returns false', () => {
const files = ['packages/cli/src/index.ts'];
const patterns = ['.github/**'];
assert.equal(evaluateFilter(files, patterns), false);
});
it('empty changed files returns false', () => {
assert.equal(evaluateFilter([], ['**']), false);
});
it('last matching pattern wins (gitignore semantics)', () => {
const files = ['packages/@n8n/task-runner-python/src/main.py'];
const patterns = ['**', '!packages/@n8n/task-runner-python/**', 'packages/@n8n/task-runner-python/**'];
assert.equal(evaluateFilter(files, patterns), true);
});
});
// --- runValidate ---
describe('runValidate', () => {
function runWithResults(jobResults: Record<string, { result: string }>): number | null {
const originalEnv = process.env.INPUT_JOB_RESULTS;
const originalExit = process.exit;
let exitCode: number | null = null;
process.env.INPUT_JOB_RESULTS = JSON.stringify(jobResults);
process.exit = ((code: number) => { exitCode = code; }) as never;
try {
runValidate();
} finally {
process.env.INPUT_JOB_RESULTS = originalEnv;
process.exit = originalExit;
}
return exitCode;
}
it('passes when all jobs succeed', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'success' },
typecheck: { result: 'success' },
lint: { result: 'success' },
}), null);
});
it('passes when some jobs are skipped (filtered out)', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'success' },
'security-checks': { result: 'skipped' },
}), null);
});
it('fails when a job fails', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'failure' },
typecheck: { result: 'success' },
}), 1);
});
it('fails when a job is cancelled', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'cancelled' },
}), 1);
});
it('fails when multiple jobs have problems', () => {
assert.equal(runWithResults({
'unit-test': { result: 'failure' },
typecheck: { result: 'cancelled' },
lint: { result: 'success' },
}), 1);
});
});

39
.github/actions/ci-filter/action.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: 'CI Filter'
description: |
Filter CI jobs by changed files and validate results.
Modes:
- filter: Determines which jobs to run based on changed files and a provided filter definition.
- validate: Checks the results of required jobs and fails if any of them failed or were cancelled.
inputs:
mode:
description: 'filter or validate'
required: true
filters:
description: 'Filter definitions (gitignore-style DSL)'
required: false
base-ref:
description: 'Base branch for diff. Auto-detected if not specified.'
required: false
job-results:
description: 'Job results from needs context as JSON (mode=validate)'
required: false
outputs:
results:
description: 'JSON object: { "filter-name": true/false }'
value: ${{ steps.run.outputs.results }}
runs:
using: 'composite'
steps:
- name: Run CI Filter
id: run
shell: bash
env:
INPUT_MODE: ${{ inputs.mode }}
INPUT_FILTERS: ${{ inputs.filters }}
INPUT_BASE_REF: ${{ inputs.base-ref || github.event.pull_request.base.ref || github.event.merge_group.base_ref || 'master' }}
INPUT_JOB_RESULTS: ${{ inputs.job-results }}
run: node ${{ github.action_path }}/ci-filter.mjs

216
.github/actions/ci-filter/ci-filter.mjs vendored Normal file
View File

@@ -0,0 +1,216 @@
import { execSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
// --- Glob matching (dotfile-safe) ---
/**
* Match a file path against a glob pattern.
* Unlike path.matchesGlob / standard POSIX globs, `**` matches dotfiles.
*/
export function matchGlob(filePath, pattern) {
let regex = '';
let i = 0;
while (i < pattern.length) {
const ch = pattern[i];
if (ch === '*' && pattern[i + 1] === '*') {
if (pattern[i + 2] === '/') {
regex += '(?:.+/)?';
i += 3;
} else {
regex += '.*';
i += 2;
}
} else if (ch === '*') {
regex += '[^/]*';
i++;
} else if (ch === '?') {
regex += '[^/]';
i++;
} else {
regex += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
i++;
}
}
return new RegExp(`^${regex}$`).test(filePath);
}
// --- Filter DSL parser ---
/**
* Parse filter definitions from the input DSL.
*
* Supports two formats:
* Single-line: `name: pattern1 pattern2`
* Multi-line: `name:` followed by indented patterns (one per line)
*
* Lines starting with # and blank lines are ignored.
*/
export function parseFilters(input) {
const filters = new Map();
const lines = input.split('\n');
let currentFilter = null;
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const headerMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)?$/);
if (headerMatch) {
const name = headerMatch[1];
const rest = (headerMatch[2] || '').trim();
const patterns = [];
currentFilter = name;
filters.set(name, patterns);
if (rest) {
patterns.push(...rest.split(/\s+/));
currentFilter = null;
}
continue;
}
if (currentFilter && rawLine.match(/^\s/)) {
const patterns = filters.get(currentFilter);
if (patterns) patterns.push(line);
continue;
}
throw new Error(`Malformed filter input at: "${rawLine}"`);
}
for (const [name, patterns] of filters) {
if (patterns.length === 0) {
throw new Error(`Filter "${name}" has no patterns`);
}
}
return filters;
}
// --- Git operations ---
const SAFE_REF = /^[a-zA-Z0-9_./-]+$/;
export function getChangedFiles(baseRef) {
if (!SAFE_REF.test(baseRef)) {
throw new Error(`Unsafe base ref: "${baseRef}"`);
}
execSync(`git fetch --depth=1 origin ${baseRef}`, { stdio: 'pipe' });
const output = execSync('git diff --name-only FETCH_HEAD HEAD', { encoding: 'utf-8' });
return output
.split('\n')
.map((f) => f.trim())
.filter(Boolean);
}
// --- Filter evaluation ---
/**
* Evaluate a single filter against changed files using gitignore semantics.
* Patterns evaluated in order, last match wins. ! prefix excludes.
* Filter triggers if ANY changed file passes.
*/
export function evaluateFilter(changedFiles, patterns) {
for (const file of changedFiles) {
let included = false;
for (const pattern of patterns) {
if (pattern.startsWith('!')) {
if (matchGlob(file, pattern.slice(1))) {
included = false;
}
} else {
if (matchGlob(file, pattern)) {
included = true;
}
}
}
if (included) return true;
}
return false;
}
// --- Mode: filter ---
function setOutput(name, value) {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
const delimiter = `ghadelimiter_${Date.now()}`;
appendFileSync(outputFile, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
}
}
export function runFilter() {
const filtersInput = process.env.INPUT_FILTERS;
const baseRef = process.env.INPUT_BASE_REF;
if (!filtersInput) {
throw new Error('INPUT_FILTERS is required in filter mode');
}
if (!baseRef) {
throw new Error('INPUT_BASE_REF is required in filter mode');
}
const filters = parseFilters(filtersInput);
const changedFiles = getChangedFiles(baseRef);
console.log(`Changed files (${changedFiles.length}):`);
for (const f of changedFiles) {
console.log(` ${f}`);
}
const results = {};
for (const [name, patterns] of filters) {
const matched = evaluateFilter(changedFiles, patterns);
results[name] = matched;
console.log(`Filter "${name}": ${matched}`);
}
setOutput('results', JSON.stringify(results));
}
// --- Mode: validate ---
export function runValidate() {
const raw = process.env.INPUT_JOB_RESULTS;
if (!raw) {
throw new Error('INPUT_JOB_RESULTS is required in validate mode');
}
const jobResults = JSON.parse(raw);
const problems = [];
for (const [job, data] of Object.entries(jobResults)) {
if (data.result === 'failure') problems.push(`${job}: failed`);
if (data.result === 'cancelled') problems.push(`${job}: cancelled`);
}
if (problems.length > 0) {
console.error('Required checks failed:');
for (const p of problems) {
console.error(` - ${p}`);
}
process.exit(1);
}
console.log('All required checks passed:');
for (const [job, data] of Object.entries(jobResults)) {
console.log(` ${job}: ${data.result}`);
}
}
// --- Main (only when run directly, not when imported by tests) ---
if (resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1])) {
const mode = process.env.INPUT_MODE;
if (mode === 'filter') {
runFilter();
} else if (mode === 'validate') {
runValidate();
} else {
throw new Error(`Unknown mode: "${mode}". Expected "filter" or "validate".`);
}
}

View File

@@ -0,0 +1,51 @@
# Composite action for logging into Docker registries (GHCR and/or DockerHub).
# Centralizes the login pattern used across multiple Docker workflows.
name: 'Docker Registry Login'
description: 'Login to GitHub Container Registry and/or DockerHub'
inputs:
login-ghcr:
description: 'Login to GitHub Container Registry'
required: false
default: 'true'
login-dockerhub:
description: 'Login to DockerHub'
required: false
default: 'false'
login-dhi:
description: 'Login to Docker Hardened Images registry (dhi.io)'
required: false
default: 'false'
dockerhub-username:
description: 'DockerHub username (required if login-dockerhub or login-dhi is true)'
required: false
dockerhub-password:
description: 'DockerHub password (required if login-dockerhub or login-dhi is true)'
required: false
runs:
using: 'composite'
steps:
- name: Login to GitHub Container Registry
if: inputs.login-ghcr == 'true'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Login to DockerHub
if: inputs.login-dockerhub == 'true'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ inputs.dockerhub-username }}
password: ${{ inputs.dockerhub-password }}
- name: Login to DHI Registry
if: inputs.login-dhi == 'true'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: dhi.io
username: ${{ inputs.dockerhub-username }}
password: ${{ inputs.dockerhub-password }}

78
.github/actions/setup-nodejs/action.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
# This action works transparently on both Blacksmith and GitHub-hosted runners.
# Blacksmith runners benefit from transparent caching and optional Docker layer caching.
# GitHub-hosted runners use standard GitHub Actions caching.
name: 'Node.js Build Setup'
description: 'Configures Node.js with pnpm, installs Aikido SafeChain for supply chain protection, installs dependencies, enables Turborepo caching, (optional) sets up Docker layer caching, and builds the project or an optional command.'
inputs:
node-version:
description: 'Node.js version to use. Pinned to 24.13.1 by default for reproducible builds.'
required: false
default: '24.13.1'
enable-docker-cache:
description: 'Whether to set up Blacksmith Buildx for Docker layer caching (Blacksmith runners only).'
required: false
default: 'false'
build-command:
description: 'Command to execute for building the project or an optional command. Leave empty to skip build step.'
required: false
default: 'pnpm build'
install-command:
description: 'Command to execute for installing project dependencies. Leave empty to skip install step.'
required: false
default: 'pnpm install --frozen-lockfile'
runs:
using: 'composite'
steps:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: ${{ inputs.node-version }}
cache: 'pnpm'
- name: Install Aikido SafeChain
if: runner.os != 'Windows'
run: |
VERSION="1.4.1"
EXPECTED_SHA256="628235987175072a4255aa3f5f0128f31795b63970f1970ae8a04d07bf8527b0"
curl -fsSL -o install-safe-chain.sh \
"https://github.com/AikidoSec/safe-chain/releases/download/${VERSION}/install-safe-chain.sh"
echo "${EXPECTED_SHA256} install-safe-chain.sh" | sha256sum -c -
sh install-safe-chain.sh --ci
rm install-safe-chain.sh
shell: bash
- name: Install Dependencies
if: ${{ inputs.install-command != '' }}
run: |
${{ inputs.install-command }}
shell: bash
- name: Disable safe-chain
if: runner.os != 'Windows'
run: safe-chain teardown
shell: bash
- name: Configure Turborepo Cache
uses: rharkor/caching-for-turbo@cda201ff2b32699a2ae9f59704db029e3dde4fbd # v2.3.5
- name: Setup Docker Builder for Docker Cache (Blacksmith)
if: ${{ inputs.enable-docker-cache == 'true' && vars.RUNNER_PROVIDER != 'github' }}
uses: useblacksmith/setup-docker-builder@53647ab5afe8827af5623b35bd4302eabd41619f # v1.2.0
- name: Setup Docker Builder (GitHub fallback)
if: ${{ inputs.enable-docker-cache == 'true' && vars.RUNNER_PROVIDER == 'github' }}
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Build Project
if: ${{ inputs.build-command != '' }}
run: |
${{ inputs.build-command }} --summarize
node .github/scripts/send-build-stats.mjs || true
node .github/scripts/send-docker-stats.mjs || true
shell: bash

119
.github/claude-templates/e2e-test.md vendored Normal file
View File

@@ -0,0 +1,119 @@
# E2E Test Task Guide
## Required Reading
**Before writing any code**, read these files:
```
packages/testing/playwright/AGENTS.md # Patterns, anti-patterns, entry points
packages/testing/playwright/CONTRIBUTING.md # Detailed architecture (first 200 lines)
```
## Spec Validation
Before starting, verify the spec includes:
| Required | Example |
|----------|---------|
| **File(s) to modify** | `tests/e2e/credentials/crud.spec.ts` |
| **Specific behavior** | "Verify credential renaming updates the list" |
| **Pattern reference** | "Follow existing tests in same file" or "See AGENTS.md" |
**If missing, ask for clarification.** Don't guess at requirements.
## Commands
```bash
# Run single test
pnpm --filter=n8n-playwright test:local tests/e2e/your-test.spec.ts --reporter=list 2>&1 | tail -50
# Run with pattern match
pnpm --filter=n8n-playwright test:local --grep "should do something" --reporter=list 2>&1 | tail -50
# Container tests (requires pnpm build:docker first)
pnpm --filter=n8n-playwright test:container:sqlite --grep @capability:email --reporter=list 2>&1 | tail -50
```
## Test Structure
```typescript
import { test, expect } from '../fixtures/base';
import { nanoid } from 'nanoid';
test('should do something @mode:sqlite', async ({ n8n, api }) => {
// Setup via API (faster, more reliable)
const workflow = await api.workflowApi.createWorkflow(workflowJson);
// UI interaction via entry points
await n8n.start.fromBlankCanvas();
// Assertions
await expect(n8n.workflows.getWorkflowByName(workflow.name)).toBeVisible();
});
```
## Entry Points
Use `n8n.start.*` methods - see `composables/TestEntryComposer.ts`:
- `fromBlankCanvas()` - New workflow
- `fromImportedWorkflow(file)` - Pre-built workflow
- `fromNewProjectBlankCanvas()` - Project-scoped
- `withUser(user)` - Isolated browser context
## Multi-User Tests
```typescript
const member = await api.publicApi.createUser({ role: 'global:member' });
const memberPage = await n8n.start.withUser(member);
await memberPage.navigate.toWorkflows();
```
## Development Process
1. **Validate spec** - Has file, behavior, pattern reference?
2. **Read existing code** - Understand current patterns in the file
3. **Identify helpers needed** - Check `pages/`, `services/`, `composables/`
4. **Add helpers first** if missing
5. **Write test** following 4-layer architecture
6. **Verify iteratively** - Small changes, test frequently
## Mandatory Verification
**Always run before marking complete:**
```bash
# 1. Tests pass (check output for failures - piping loses exit code)
pnpm --filter=n8n-playwright test:local <your-test> --reporter=list 2>&1 | tail -50
# 2. Not flaky (required)
pnpm --filter=n8n-playwright test:local <your-test> --repeat-each 3 --reporter=list 2>&1 | tail -50
# 3. Lint passes
pnpm --filter=n8n-playwright lint 2>&1 | tail -30
# 4. Typecheck passes
pnpm --filter=n8n-playwright typecheck 2>&1 | tail -30
```
**Important:** Piping through `tail` loses the exit code. Always check the output for "failed" or error messages rather than relying on exit codes.
**If any fail, fix before completing.**
## Refactoring Existing Tests
**Always verify tests pass BEFORE making changes:**
```bash
pnpm --filter=n8n-playwright test:local tests/e2e/target-file.spec.ts --reporter=list 2>&1 | tail -50
```
Then make small incremental changes, re-running after each.
## Done Checklist
- [ ] Spec had clear file, behavior, and pattern reference
- [ ] Read `AGENTS.md` and relevant existing code
- [ ] Used `n8n.start.*` entry points
- [ ] Used `nanoid()` for unique IDs (not `Date.now()`)
- [ ] No serial mode, `@db:reset`, or `n8n.api.signin()`
- [ ] Multi-user tests use `n8n.start.withUser()`
- [ ] Tests pass with `--repeat-each 3`
- [ ] Lint and typecheck pass

179
.github/claude-templates/security-fix.md vendored Normal file
View File

@@ -0,0 +1,179 @@
# Security Vulnerability Fix Guidelines
## Overview
This guide covers how to fix security vulnerabilities in the n8n codebase. Follow a systematic approach to identify, fix, and verify vulnerabilities in dependencies or base images.
## Decision Tree
```
Is it a direct dependency?
→ Yes: Update in catalog or package.json
→ No: Is it transitive?
→ Yes: Add pnpm override
→ No: Is it base image?
→ Yes: Update Dockerfile, trigger base image workflow
```
## Process Flow
```
Scan → Investigate → Fix → Verify
↓ ↓ ↓ ↓
pnpm pnpm why Update pnpm
build: (trace) deps build:
docker: or docker:
scan override scan
```
## Step-by-Step Process
### 1. Initial Setup
Start with a clean install:
```bash
pnpm install --frozen-lockfile
```
### 2. Scan for Vulnerabilities
Run the Docker scan to verify if the vulnerability exists:
```bash
pnpm build:docker:scan
```
### 3. Investigate the Source
Use `pnpm why` to trace where the vulnerable package is coming from:
```bash
pnpm why <package-name> -r
```
### 4. Determine Fix Strategy
#### Case A: Direct Dependency
If the vulnerable package is a **direct dependency**:
**Update via Catalog** (preferred for shared dependencies):
```yaml
# pnpm-workspace.yaml
catalog:
'@azure/identity': 4.13.0 # Updated version
```
```json
// packages/cli/package.json
{
"dependencies": {
"@azure/identity": "catalog:"
}
}
```
**Or update directly in package.json:**
```json
{
"dependencies": {
"vulnerable-package": "^1.2.3"
}
}
```
Then: `pnpm install`
#### Case B: Transitive Dependency
If the vulnerable package is a **transitive dependency**:
**Add an override** in the root `package.json`:
```json
{
"pnpm": {
"overrides": {
"vulnerable-package": "^1.2.3"
}
}
}
```
**For multiple versions:**
```json
{
"pnpm": {
"overrides": {
"vulnerable-package@3": "^3.2.1",
"vulnerable-package@4": "^4.0.1"
}
}
}
```
Then: `pnpm install`
#### Case C: Base Image / NPM Issue
If the vulnerability comes from the **base Docker image**:
1. Check `docker/images/n8n-base/Dockerfile`
2. Update Node version or Alpine packages if needed
3. Note: Base image rebuild requires manual workflow trigger
### 5. Verify the Fix
```bash
pnpm install
pnpm why <package-name> # Check version updated
pnpm build:docker:scan # Confirm vulnerability resolved
```
## Commit & PR Standards
### Commit Format
```
{type}({scope}): {neutral description}
{Brief neutral context}
Addresses: CVE-XXXX-XXXXX
Refs: {LINEAR-ID}
```
### Type Selection
| Scenario | Type |
|----------|------|
| Dependency update | `fix(deps)` |
| Code vulnerability fix | `fix` |
| License/compliance | `chore` |
| Docker/build hardening | `build` |
### Title Language - USE NEUTRAL LANGUAGE
Commit/PR titles appear in changelogs. Use neutral language:
| ❌ Avoid | ✅ Use Instead |
|----------|----------------|
| CVE-XXXX-XXXXX | (footer only) |
| vulnerability, exploit | issue, concern |
| critical, security fix | improvement, update |
| patch vulnerability | validate, harden, ensure |
### Example Commit
**Good:**
```
fix(deps): update jws to 4.0.1
Updates jws package to latest stable version.
Addresses: CVE-2025-65945
Refs: SEC-412
```
**Bad:**
```
fix(security): patch critical CVE-2025-65945 in jws
```
## Done Checklist
- [ ] `pnpm build:docker:scan` shows no vulnerability for the CVE
- [ ] `pnpm why <package>` shows updated version
- [ ] Commit follows neutral language format (no CVE in title)
- [ ] PR references Linear ticket if provided
## Common Commands
```bash
pnpm install --frozen-lockfile # Initial setup
pnpm build:docker:scan # Scan for vulnerabilities
pnpm why <package-name> -r # Investigate dependency
pnpm install # Update lockfile after changes
pnpm list <package-name> # Check specific package versions
```

12
.github/docker-compose.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
services:
postgres:
image: postgres:16
restart: always
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
ports:
- 5432:5432
tmpfs:
- /var/lib/postgresql/data

View File

@@ -0,0 +1,43 @@
# METADATA
# title: Unpinned GitHub Action
# description: |-
# GitHub Action not pinned to full commit SHA.
# Pin actions to SHA for supply chain security.
# custom:
# level: error
package rules.unpinned_action
import data.poutine
import rego.v1
rule := poutine.rule(rego.metadata.chain())
# Match 40-character hex SHA (Git) or 64-character sha256 digest (Docker)
is_sha_pinned(uses) if {
regex.match(`@(sha256:[a-f0-9]{64}|[a-f0-9]{40})`, uses)
}
# Check if it's a local action (starts with ./)
is_local_action(uses) if {
startswith(uses, "./")
}
# Check if it's a reusable workflow call
is_reusable_workflow(uses) if {
contains(uses, ".github/workflows/")
}
results contains poutine.finding(rule, pkg.purl, {
"path": workflow.path,
"job": job.id,
"step": i,
"details": sprintf("Action '%s' should be pinned to a full commit SHA", [step.uses]),
}) if {
pkg := input.packages[_]
workflow := pkg.github_actions_workflows[_]
job := workflow.jobs[_]
step := job.steps[i]
step.uses
not is_sha_pinned(step.uses)
not is_local_action(step.uses)
}

29
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,29 @@
## Summary
<!--
Describe what the PR does and how to test.
Photos and videos are recommended.
-->
## Related Linear tickets, Github issues, and Community forum posts
<!--
Include links to **Linear ticket** or Github issue or Community forum post.
Important in order to close *automatically* and provide context to reviewers.
https://linear.app/n8n/issue/
-->
<!-- Use "closes #<issue-number>", "fixes #<issue-number>", or "resolves #<issue-number>" to automatically close issues when the PR is merged. -->
## Review / Merge checklist
- [ ] PR title and summary are descriptive. ([conventions](../blob/master/.github/pull_request_title_conventions.md)) <!--
**Remember, the title automatically goes into the changelog.
Use `(no-changelog)` otherwise.**
-->
- [ ] [Docs updated](https://github.com/n8n-io/n8n-docs) or follow-up ticket created.
- [ ] Tests included. <!--
A bug is not considered fixed, unless a test is added to prevent it from happening again.
A feature is not complete without tests.
-->
- [ ] PR Labeled with `release/backport` (if the PR is an urgent fix that needs to be backported)

View File

@@ -0,0 +1,116 @@
# PR Title Convention
We have very precise rules over how Pull Requests (to the `master` branch) must be formatted. This format basically follows the [Angular Commit Message Convention](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#commit). It leads to easier to read commit history and allows for automated generation of release notes:
A PR title consists of these elements:
```text
<type>(<scope>): <summary>
│ │ │
│ │ └─⫸ Summary: In imperative present tense.
| | Capitalized
| | No period at the end.
│ │
│ └─⫸ Scope: API | benchmark | core | editor | * Node
└─⫸ Type: build | ci | chore | docs | feat | fix | perf | refactor | test
```
- PR title
- type
- scope (_optional_)
- summary
- PR description
- body (optional)
- blank line
- footer (optional)
The structure looks like this:
## Type
Must be one of the following:
| type | description | appears in changelog |
| --- | --- | --- |
| `feat` | A new feature | ✅ |
| `fix` | A bug fix | ✅ |
| `perf` | A code change that improves performance | ✅ |
| `test` | Adding missing tests or correcting existing tests | ❌ |
| `docs` | Documentation only changes | ❌ |
| `refactor` | A behavior-neutral code change that neither fixes a bug nor adds a feature | ❌ |
| `build` | Changes that affect the build system or external dependencies (TypeScript, Jest, pnpm, etc.) | ❌ |
| `ci` | Changes to CI configuration files and scripts (e.g. Github actions) | ❌ |
| `chore` | Routine tasks, maintenance, and minor updates not covered by other types | ❌ |
> BREAKING CHANGES (see Footer section below), will **always** appear in the changelog unless suffixed with `no-changelog`.
## Scope (optional)
The scope should specify the place of the commit change as long as the commit clearly addresses one of the following supported scopes. (Otherwise, omit the scope!)
- `API` - changes to the _public_ API
- `benchmark` - changes to the benchmark cli
- `core` - changes to the core / private API / backend of n8n
- `editor` - changes to the Editor UI
- `* Node` - changes to a specific node or trigger node (”`*`” to be replaced with the node name, not its display name), e.g.
- mattermost → Mattermost Node
- microsoftToDo → Microsoft To Do Node
- n8n → n8n Node
## Summary
The summary contains succinct description of the change:
- use the imperative, present tense: "change" not "changed" nor "changes"
- capitalize the first letter
- _no_ dot (.) at the end
- do _not_ include Linear ticket IDs etc. (e.g. N8N-1234)
- suffix with “(no-changelog)” for commits / PRs that should not get mentioned in the changelog.
## Body (optional)
Just as in the **summary**, use the imperative, present tense: "change" not "changed" nor "changes". The body should include the motivation for the change and contrast this with previous behavior.
## Footer (optional)
The footer can contain information about breaking changes and deprecations and is also the place to [reference GitHub issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword), Linear tickets, and other PRs that this commit closes or is related to. For example:
```text
BREAKING CHANGE: <breaking change summary>
<BLANK LINE>
<breaking change description + migration instructions>
<BLANK LINE>
<BLANK LINE>
Fixes #<issue number>
```
or
```text
DEPRECATED: <what is deprecated>
<BLANK LINE>
<deprecation description + recommended update path>
<BLANK LINE>
<BLANK LINE>
Closes #<pr number>
```
A Breaking Change section should start with the phrase "`BREAKING CHANGE:` " followed by a summary of the breaking change, a blank line, and a detailed description of the breaking change that also includes migration instructions.
> 💡 A breaking change can additionally also be marked by adding a “`!`” to the header, right before the “`:`”, e.g. `feat(editor)!: Remove support for dark mode`
>
> This makes locating breaking changes easier when just skimming through commit messages.
> 💡 The breaking changes must also be added to the [packages/cli/BREAKING-CHANGES.md](https://github.com/n8n-io/n8n/blob/master/packages/cli/BREAKING-CHANGES.md) file located in the n8n repository.
Similarly, a Deprecation section should start with "`DEPRECATED:` " followed by a short description of what is deprecated, a blank line, and a detailed description of the deprecation that also mentions the recommended update path.
### Revert commits
If the commit reverts a previous commit, it should begin with `revert:` , followed by the header of the reverted commit.
The content of the commit message body should contain:
- information about the SHA of the commit being reverted in the following format: `This reverts commit <SHA>`,
- a clear description of the reason for reverting the commit message.

74
.github/scripts/bump-versions.mjs vendored Normal file
View File

@@ -0,0 +1,74 @@
import semver from 'semver';
import { writeFile, readFile } from 'fs/promises';
import { resolve } from 'path';
import child_process from 'child_process';
import { promisify } from 'util';
import assert from 'assert';
const exec = promisify(child_process.exec);
function generateExperimentalVersion(currentVersion) {
const parsed = semver.parse(currentVersion);
if (!parsed) throw new Error(`Invalid version: ${currentVersion}`);
// Check if it's already an experimental version
if (parsed.prerelease.length > 0 && parsed.prerelease[0] === 'exp') {
// Increment the experimental minor version
const expMinor = (parsed.prerelease[1] || 0) + 1;
return `${parsed.major}.${parsed.minor}.${parsed.patch}-exp.${expMinor}`;
}
// Create new experimental version: <major>.<minor>.<patch>-exp.0
return `${parsed.major}.${parsed.minor}.${parsed.patch}-exp.0`;
}
const rootDir = process.cwd();
const releaseType = process.env.RELEASE_TYPE;
assert.match(releaseType, /^(patch|minor|major|experimental|premajor)$/, 'Invalid RELEASE_TYPE');
// TODO: if releaseType is `auto` determine release type based on the changelog
const lastTag = (await exec('git describe --tags --match "n8n@*" --abbrev=0')).stdout.trim();
const packages = JSON.parse((await exec('pnpm ls -r --only-projects --json')).stdout);
const packageMap = {};
for (let { name, path, version, private: isPrivate, dependencies } of packages) {
if (isPrivate && path !== rootDir) continue;
if (path === rootDir) name = 'monorepo-root';
const isDirty = await exec(`git diff --quiet HEAD ${lastTag} -- ${path}`)
.then(() => false)
.catch((error) => true);
packageMap[name] = { path, isDirty, version };
}
assert.ok(
Object.values(packageMap).some(({ isDirty }) => isDirty),
'No changes found since the last release',
);
// Keep the monorepo version up to date with the released version
packageMap['monorepo-root'].version = packageMap['n8n'].version;
for (const packageName in packageMap) {
const { path, version, isDirty } = packageMap[packageName];
const packageFile = resolve(path, 'package.json');
const packageJson = JSON.parse(await readFile(packageFile, 'utf-8'));
packageJson.version = packageMap[packageName].nextVersion =
isDirty ||
Object.keys(packageJson.dependencies || {}).some(
(dependencyName) => packageMap[dependencyName]?.isDirty,
)
? releaseType === 'experimental'
? generateExperimentalVersion(version)
: releaseType === 'premajor'
? semver.inc(version, version.includes('-rc.') ? 'prerelease' : 'premajor', undefined, 'rc')
: semver.inc(version, releaseType)
: version;
await writeFile(packageFile, JSON.stringify(packageJson, null, 2) + '\n');
}
console.log(packageMap['n8n'].nextVersion);

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* Builds the Claude task prompt and writes it to GITHUB_ENV.
* Uses a random delimiter to prevent heredoc collision with user input.
*
* Usage: node prepare-claude-prompt.mjs
*
* Environment variables:
* INPUT_TASK - The task description (required)
* USE_RAW_PROMPT - "true" to pass task directly without wrapping
* GITHUB_ENV - Path to GitHub env file (set by Actions)
*/
import { randomUUID } from 'node:crypto';
import { appendFileSync, readdirSync } from 'node:fs';
const task = process.env.INPUT_TASK;
const useRaw = process.env.USE_RAW_PROMPT === 'true';
const envFile = process.env.GITHUB_ENV;
if (!task) {
console.error('INPUT_TASK environment variable is required');
process.exit(1);
}
if (!envFile) {
console.error('GITHUB_ENV environment variable is required');
process.exit(1);
}
let prompt;
if (useRaw) {
prompt = task;
} else {
// List available templates so Claude knows what exists (reads them if needed)
const templateDir = '.github/claude-templates';
let templateSection = '';
try {
const files = readdirSync(templateDir).filter((f) => f.endsWith('.md'));
if (files.length > 0) {
const listing = files.map((f) => ` - ${templateDir}/${f}`).join('\n');
templateSection = `\n# Templates\nThese guides are available if relevant to your task. Read any that match before starting:\n${listing}`;
}
} catch {
// No templates directory, skip
}
prompt = `# Task
${task}
${templateSection}
# Instructions
1. Read any relevant templates listed above before starting
2. Complete the task described above
3. Make commits as you work - the last commit message will be used as the PR title
4. IMPORTANT: End every commit message with: Co-authored-by: Claude <noreply@anthropic.com>
5. Ensure code passes linting and type checks before finishing
# Token Optimization
When running lint/typecheck, suppress verbose output:
pnpm lint 2>&1 | tail -30
pnpm typecheck 2>&1 | tail -30`;
}
// Random delimiter guarantees no collision with user content
const delimiter = `CLAUDE_PROMPT_DELIM_${randomUUID().replace(/-/g, '')}`;
appendFileSync(envFile, `CLAUDE_PROMPT<<${delimiter}\n${prompt}\n${delimiter}\n`);

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env node
/**
* Sends a callback to the resume URL with the Claude task result.
* Uses fetch() directly to avoid E2BIG errors from shell argument limits.
*
* Usage: node resume-callback.mjs
*
* Environment variables:
* RESUME_URL - Callback URL to POST to (required)
* EXECUTION_FILE - Path to Claude's execution output JSON (optional)
* CLAUDE_OUTCOME - "success" or "failure" (required)
* CLAUDE_SESSION_ID - Session ID for resuming conversations (optional)
* BRANCH_NAME - Git branch name (optional)
*/
import { existsSync, readFileSync } from 'node:fs';
const resumeUrl = process.env.RESUME_URL;
const executionFile = process.env.EXECUTION_FILE;
const claudeOutcome = process.env.CLAUDE_OUTCOME;
const sessionId = process.env.CLAUDE_SESSION_ID ?? '';
const branchName = process.env.BRANCH_NAME ?? '';
if (!resumeUrl) {
console.error('RESUME_URL environment variable is required');
process.exit(1);
}
const success = claudeOutcome === 'success';
let result = null;
if (executionFile && existsSync(executionFile)) {
try {
const execution = JSON.parse(readFileSync(executionFile, 'utf-8'));
// Extract the last element (Claude's final result message)
result = Array.isArray(execution) ? execution.at(-1) : execution;
} catch (err) {
console.warn(`Failed to parse execution file: ${err.message}`);
}
}
const payload = JSON.stringify({ success, branch: branchName, sessionId, result });
try {
const response = await fetch(resumeUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
});
if (!response.ok) {
const body = await response.text();
console.error(`Callback failed: ${body}`);
process.exit(1);
}
} catch (err) {
console.error(`Callback error: ${err.message}`);
process.exit(1);
}

157
.github/scripts/cleanup-ghcr-images.mjs vendored Normal file
View File

@@ -0,0 +1,157 @@
#!/usr/bin/env node
/**
* Cleanup GHCR images for n8n CI
*
* Modes:
* --tag <tag> Delete exact tag (post-run cleanup)
* --stale <days> Delete ci-* images older than N days (daily scheduled cleanup)
*
* Context:
* - Each CI run tags images as ci-{run_id}
* - Post-run cleanup uses --tag to delete the current run's images
* - Daily cron uses --stale to catch any orphaned images
*/
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
const execAsync = promisify(exec);
const ORG = process.env.GHCR_ORG || 'n8n-io';
const REPO = process.env.GHCR_REPO || 'n8n';
const PACKAGES = [REPO, 'runners'];
const [mode, rawValue] = process.argv.slice(2);
if (!['--tag', '--stale'].includes(mode) || !rawValue) {
console.error('Usage: cleanup-ghcr-images.mjs --tag <tag> | --stale <days>');
process.exit(1);
}
const value = mode === '--stale' ? parseInt(rawValue, 10) : rawValue;
if (mode === '--stale' && (isNaN(value) || value <= 0)) {
console.error('Error: --stale requires a positive number of days');
process.exit(1);
}
async function ghApi(path) {
const { stdout } = await execAsync(
`gh api "/orgs/${ORG}/packages/container/${path}"`,
);
return JSON.parse(stdout);
}
async function ghDelete(path) {
await execAsync(`gh api --method DELETE "/orgs/${ORG}/packages/container/${path}"`);
}
async function fetchPage(pkg, page) {
try {
return await ghApi(`${pkg}/versions?per_page=100&page=${page}`);
} catch (err) {
if (err.code === 1 && err.stderr?.includes('404')) return [];
throw new Error(`Failed to fetch ${pkg} page ${page}: ${err.message}`);
}
}
const isCiImage = (v) => {
const tags = v.metadata?.container?.tags || [];
return tags.some((t) => t.startsWith('ci-') || t.startsWith('pr-'));
};
const isStale = (v, days) => {
const cutoff = Date.now() - days * 86400000;
return isCiImage(v) && new Date(v.created_at) < cutoff;
};
async function getVersionsForTag(pkg, tag) {
const batch = await fetchPage(pkg, 1);
const match = batch.find((v) => v.metadata?.container?.tags?.includes(tag));
return match ? [match] : [];
}
async function getVersionsForStale(pkg, days) {
const versions = [];
const cutoff = Date.now() - days * 86400000;
// Use 2x cutoff as safety window for early termination
const earlyExitCutoff = Date.now() - days * 2 * 86400000;
let pagesWithoutCiImages = 0;
const firstPage = await fetchPage(pkg, 1);
if (!firstPage.length) return [];
for (const v of firstPage) {
if (isStale(v, days)) versions.push(v);
}
if (firstPage.length < 100) return versions;
for (let page = 2; ; page += 10) {
const batches = await Promise.all(
Array.from({ length: 10 }, (_, i) => fetchPage(pkg, page + i)),
);
let done = false;
for (const batch of batches) {
if (!batch.length || batch.length < 100) done = true;
let hasCiImages = false;
for (const v of batch) {
if (isCiImage(v)) {
hasCiImages = true;
if (new Date(v.created_at) < cutoff) versions.push(v);
}
}
// Early termination: if we've gone through pages without finding
// any CI images and all items are older than 2x cutoff, we're past
// the CI image window
if (!hasCiImages) {
pagesWithoutCiImages++;
const oldestInBatch = batch[batch.length - 1];
if (
pagesWithoutCiImages >= 3 &&
oldestInBatch &&
new Date(oldestInBatch.created_at) < earlyExitCutoff
) {
console.log(` Early termination at page ${page + batches.indexOf(batch)}`);
done = true;
}
} else {
pagesWithoutCiImages = 0;
}
if (!batch.length || done) break;
}
if (done) break;
}
return versions;
}
let hasErrors = false;
for (const pkg of PACKAGES) {
console.log(`Processing ${pkg}...`);
let consecutiveErrors = 0;
const toDelete =
mode === '--tag'
? await getVersionsForTag(pkg, value)
: await getVersionsForStale(pkg, value);
if (!toDelete.length) {
console.log(` No matching images found`);
continue;
}
for (const v of toDelete) {
try {
await ghDelete(`${pkg}/versions/${v.id}`);
console.log(` Deleted ${v.metadata.container.tags.join(',')}`);
consecutiveErrors = 0;
} catch (err) {
console.error(` Failed to delete ${v.id}: ${err.message}`);
hasErrors = true;
if (++consecutiveErrors >= 3) {
throw new Error('Too many consecutive delete failures, aborting');
}
}
}
}
if (hasErrors) process.exit(1);

View File

@@ -0,0 +1,123 @@
import fs from 'node:fs/promises';
import { getOctokit } from '@actions/github';
import { ensureEnvVar, readPrLabels } from './github-helpers.mjs';
/**
* @typedef {PullRequestCheckPass | PullRequestCheckFail} PullRequestCheckResult
**/
/**
* @typedef PullRequestCheckPass
* @property {true} pass
* @property {string} baseRef
* */
/**
* @typedef PullRequestCheckFail
* @property {false} pass
* @property {string} reason
* */
/**
* @param {PullRequestCheckResult} pullRequestCheck
*
* @returns { pullRequestCheck is PullRequestCheckFail }
* */
function pullRequestCheckFailed(pullRequestCheck) {
return !pullRequestCheck.pass;
}
/**
* @param {any} pullRequest
* @returns {PullRequestCheckResult}
*/
export function pullRequestIsDismissedRelease(pullRequest) {
if (!pullRequest) {
throw new Error('Missing pullRequest in event payload');
}
const baseRef = pullRequest?.base?.ref ?? '';
const headRef = pullRequest?.head?.ref ?? '';
const merged = Boolean(pullRequest?.merged);
if (merged) {
return { pass: false, reason: 'PR was merged' };
}
// Must match your release PR pattern:
// base: release/<ver>
// head: release-pr/<ver>
if (!baseRef.startsWith('release/')) {
return { pass: false, reason: `Base ref '${baseRef}' is not release/*` };
}
if (!headRef.startsWith('release-pr/')) {
return { pass: false, reason: `Head ref '${headRef}' is not release-pr/*` };
}
const baseVer = baseRef.slice('release/'.length);
const headVer = headRef.slice('release-pr/'.length);
if (!baseVer || baseVer !== headVer) {
return { pass: false, reason: `Version mismatch: base='${baseVer}' head='${headVer}'` };
}
const labelNames = readPrLabels(pullRequest);
if (!labelNames.includes('release')) {
return {
pass: false,
reason: `Missing required label 'release' (labels: ${labelNames.join(', ') || '[none]'})`,
};
}
return { pass: true, baseRef };
}
async function main() {
const token = ensureEnvVar('GITHUB_TOKEN');
const eventPath = ensureEnvVar('GITHUB_EVENT_PATH');
const repoFullName = ensureEnvVar('GITHUB_REPOSITORY');
const [owner, repo] = repoFullName.split('/');
if (!owner || !repo) {
throw new Error(`Invalid GITHUB_REPOSITORY: '${repoFullName}'`);
}
const rawEventData = await fs.readFile(eventPath, 'utf8');
const event = JSON.parse(rawEventData);
const result = pullRequestIsDismissedRelease(event.pull_request);
if (pullRequestCheckFailed(result)) {
console.log(`no-op: ${result.reason}`);
return;
}
const branch = result.baseRef; // e.g. "release/2.11.0"
console.log(`PR qualifies. Deleting branch '${branch}'...`);
const octokit = getOctokit(token);
try {
await octokit.rest.git.deleteRef({
owner,
repo,
// ref must be "heads/<branch>"
ref: `heads/${branch}`,
});
console.log(`Deleted '${branch}'.`);
} catch (err) {
// If it was already deleted, treat as success.
const status = err?.status;
if (status === 404) {
console.log(`Branch '${branch}' not found (already deleted).`);
return;
}
console.error(err);
throw new Error(`Failed to delete '${branch}'.`);
}
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
await main();
}

View File

@@ -0,0 +1,147 @@
import { describe, it, mock, before } from 'node:test';
import assert from 'node:assert/strict';
import { readPrLabels } from './github-helpers.mjs';
/**
* Run these tests by running
*
* node --test --experimental-test-module-mocks ./.github/scripts/cleanup-release-branch.test.mjs
* */
// mock.module must be called before the module under test is imported,
// because static imports are hoisted and resolve before any code runs.
mock.module('./github-helpers.mjs', {
namedExports: {
ensureEnvVar: () => {}, // no-op
readPrLabels: (pr) => {
return readPrLabels(pr);
},
},
});
let pullRequestIsDismissedRelease;
before(async () => {
({ pullRequestIsDismissedRelease } = await import('./cleanup-release-branch.mjs'));
});
describe('pullRequestIsDismissedRelease', () => {
it('Recognizes classic dismissed pull request', () => {
const pullRequest = {
merged: false,
labels: ['release'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, true);
assert.equal(result.reason, undefined);
});
it("Doesn't pass PR with malformed head", () => {
const pullRequest = {
merged: false,
labels: ['release'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'my-fork-release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(result.reason, `Head ref '${pullRequest.head.ref}' is not release-pr/*`);
});
it("Doesn't pass PR with malformed base", () => {
const pullRequest = {
merged: false,
labels: ['release'],
base: {
ref: 'master',
},
head: {
ref: 'release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(result.reason, `Base ref '${pullRequest.base.ref}' is not release/*`);
});
it("Doesn't pass merged PR's", () => {
const pullRequest = {
merged: true,
labels: ['release'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(result.reason, `PR was merged`);
});
it("Doesn't pass on PR version mismatch", () => {
const pullRequest = {
merged: false,
labels: ['release'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'release-pr/2.9.1',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(
result.reason,
`Version mismatch: base='${pullRequest.base.ref.replace('release/', '')}' head='${pullRequest.head.ref.replace('release-pr/', '')}'`,
);
});
it("Doesn't pass a PR with missing 'release' label", () => {
const pullRequest = {
merged: false,
labels: ['release-pr', 'core-team'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(
result.reason,
`Missing required label 'release' (labels: ${pullRequest.labels.join(', ')})`,
);
});
});

View File

@@ -0,0 +1,74 @@
// Creates backport PR's according to labels on merged PR
import { readPrLabels, resolveRcBranchForTrack, writeGithubOutput } from './github-helpers.mjs';
/** @type { Record<string, import('./github-helpers.mjs').ReleaseTrack> } */
const BACKPORT_BY_TAG_MAP = {
'Backport to Beta': 'beta',
'Backport to Stable': 'stable',
};
const BACKPORT_BY_BRANCH_MAP = {
'Backport to v1': '1.x',
};
/**
* @param {Set<string>} labels
*
* @returns { Set<string> }
*/
export function labelsToReleaseCandidateBranches(labels) {
const targets = new Set();
// Backport by tag map includes mapping of label to git tag to resolve
for (const [label, tag] of Object.entries(BACKPORT_BY_TAG_MAP)) {
// Check if backport label is present
if (!labels.has(label)) {
continue;
}
const branch = resolveRcBranchForTrack(tag);
// Make sure our backport branch exists
if (!branch) {
continue;
}
targets.add(branch);
}
// Backport by branch map includes mapping of label to git branch. This is used for
// older versions of n8n. v1, etc.
for (const [label, branch] of Object.entries(BACKPORT_BY_BRANCH_MAP)) {
// Check if backport label is present
if (!labels.has(label)) {
continue;
}
targets.add(branch);
}
return targets;
}
async function main() {
const labels = new Set(readPrLabels());
if (!labels || labels.size === 0) {
console.log('No labels on PR. Exiting...');
return;
}
const backportBranches = labelsToReleaseCandidateBranches(labels);
if (backportBranches.size === 0) {
console.log('No backports needed. Exiting...');
return;
}
const target_branches = [...backportBranches].join(' '); // korthout/backport-action@v4 uses space-delimited branch list
writeGithubOutput({ target_branches });
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
await main();
}

View File

@@ -0,0 +1,62 @@
import { describe, it, mock, before } from 'node:test';
import assert from 'node:assert/strict';
/**
* Run these tests by running
*
* node --test --experimental-test-module-mocks ./.github/scripts/compute-backport-targets.test.mjs
* */
// mock.module must be called before the module under test is imported,
// because static imports are hoisted and resolve before any code runs.
mock.module('./github-helpers.mjs', {
namedExports: {
ensureEnvVar: () => {}, // no-op
readPrLabels: () => {}, // no-op
resolveRcBranchForTrack: mockResolveRcBranchForTrack,
writeGithubOutput: () => {}, //no-op
},
});
function mockResolveRcBranchForTrack(track) {
switch (track) {
case 'beta':
return 'release-candidate/2.10.1';
case 'stable':
return 'release-candidate/2.9.4';
}
return undefined;
}
let labelsToReleaseCandidateBranches;
before(async () => {
({ labelsToReleaseCandidateBranches } = await import('./compute-backport-targets.mjs'));
});
describe('Compute backport targets', () => {
it('Finds backport branches for pointer tag labels', () => {
const labels = new Set(['Backport to Beta', 'Backport to Stable']);
/** @type { Set<string> } */
const result = labelsToReleaseCandidateBranches(labels);
assert.equal(result.size, 2);
assert.ok(result.has('release-candidate/2.10.1'));
assert.ok(result.has('release-candidate/2.9.4'));
});
it("Doesn't parse other labes to backport branches", () => {
const labels = new Set(['n8n team', 'release']);
/** @type { Set<string> } */
const result = labelsToReleaseCandidateBranches(labels);
assert.equal(result.size, 0);
});
it("Doesn't parse malformed backport labels", () => {
const labels = new Set(['Backport to Fork', 'Backport to my Home']);
/** @type { Set<string> } */
const result = labelsToReleaseCandidateBranches(labels);
assert.equal(result.size, 0);
});
});

91
.github/scripts/detect-new-packages.mjs vendored Normal file
View File

@@ -0,0 +1,91 @@
/**
* Detects packages in the monorepo that have not yet been published to npm.
*
* Packages that are new (never published) cannot be released via OIDC Trusted
* Publishing because Trusted Publishing requires the package to already exist
* on npm with the publisher configured first.
*
* New packages must be handled manually:
* 1. Published once using an NPM token
* 2. Configured with Trusted Publishing on npmjs.com
*
* Exit codes:
* 0 All public packages exist on npm
* 1 One or more public packages have never been published
*/
import child_process from 'child_process';
import { promisify } from 'util';
const exec = promisify(child_process.exec);
const packages = JSON.parse((await exec('pnpm ls -r --only-projects --json')).stdout);
const newPackages = [];
for (const { name, private: isPrivate } of packages) {
if (isPrivate) continue;
// Scoped packages must be encoded: @n8n/foo → @n8n%2Ffoo
const encodedName = name.startsWith('@') ? name.replace('/', '%2F') : name;
const url = `https://registry.npmjs.org/${encodedName}`;
try {
const response = await fetch(url, { method: 'HEAD' });
if (response.status === 404) {
newPackages.push(name);
} else if (!response.ok && response.status !== 405) {
// 405 = Method Not Allowed for HEAD (some registries), not an error
console.log(
`::warning::Unexpected HTTP ${response.status} when checking npm registry for "${name}". Skipping check.`,
);
}
} catch (error) {
console.log(
`::warning::Could not reach npm registry for "${name}": ${error.message}. Skipping check.`,
);
}
}
if (newPackages.length === 0) {
const publicCount = packages.filter((p) => !p.private).length;
console.log(`✅ All ${publicCount} public packages exist on npm.`);
process.exit(0);
}
console.log(`
❌ New unpublished packages detected!
The following packages do not yet exist on npm and cannot be published via
OIDC Trusted Publishing until they have been published at least once manually:
`);
for (const pkg of newPackages) {
console.log(` - ${pkg}`);
console.log(
`::error::Package "${pkg}" has never been published to npm. A manual first-publish with an NPM token is required before it can use OIDC Trusted Publishing.`,
);
}
console.log(`
Steps to unblock the release, for each new package listed above:
1. Publish the package once manually using an NPM token:
cd to/where/package/lives
pnpm login
pnpm publish --access public
2. Configure Trusted Publishing on npmjs.com for each new package:
https://docs.npmjs.com/trusted-publishers
Use the following settings:
Repository owner : n8n-io
Repository name : n8n
Workflow filename: release-publish.yml
3. Re-run the Release: Publish workflow.
`);
process.exit(1);

View File

@@ -0,0 +1,122 @@
import { readFileSync } from 'node:fs';
import { RELEASE_TRACKS, resolveReleaseTagForTrack, writeGithubOutput } from './github-helpers.mjs';
import semver from 'semver';
/**
* @param {any} packageVersion
*/
export function determineTrack(packageVersion) {
if (!semver.valid(packageVersion)) {
throw new Error(`Package semver not valid. Got ${packageVersion}`);
}
/** @type { Partial<Record<import('./github-helpers.mjs').ReleaseTrack, import('./github-helpers.mjs').TagVersionInfo>> } */
const trackToReleaseMap = {};
for (const t of RELEASE_TRACKS) {
trackToReleaseMap[t] = resolveReleaseTagForTrack(t);
}
console.log('Current Tracks: ', JSON.stringify(trackToReleaseMap, null, 4));
let track = null;
let newStable = null;
let bump = determineBump(packageVersion);
const releaseType = determineReleaseType(packageVersion);
// Check through our current release versions, if semver matches,
// we inherit the track pointer from them
for (const [releaseTrack, tagVersionInfo] of Object.entries(trackToReleaseMap)) {
if (tagVersionInfo && matchesTrack(tagVersionInfo, packageVersion)) {
track = releaseTrack;
break;
}
}
if (!track) {
if (!trackToReleaseMap.beta?.version) {
throw new Error(
'Likely updating to new beta release, but no existing beta tag was found in git.',
);
}
// If not track was found in current versions, we verify we're building a
// new beta version and the input is not invalid.
assertNewBetaRelease(trackToReleaseMap.beta.version, packageVersion);
track = 'beta';
newStable = trackToReleaseMap.beta.version;
}
if (!track) {
throw new Error('Could not determine track for release. Exiting...');
}
const output = {
version: packageVersion,
track,
bump,
new_stable_version: newStable,
release_type: releaseType,
};
writeGithubOutput(output);
console.log(
`Determined track info: track=${track}, version=${packageVersion}, new_stable_version=${newStable}, release_type=${releaseType}`,
);
return output;
}
/**
* The current version matches the track, if their Major and Minor semvers match.
*
* This means that we are working with a patch release
*
* @param {import("./github-helpers.mjs").TagVersionInfo} tagVersionInfo
* @param {any} currentVersion
*/
function matchesTrack(tagVersionInfo, currentVersion) {
if (semver.major(tagVersionInfo.version) !== semver.major(currentVersion)) {
return false;
}
if (semver.minor(tagVersionInfo.version) !== semver.minor(currentVersion)) {
return false;
}
return true;
}
/**
* @param {string} currentBetaVersion
* @param {any} currentVersion
*/
function assertNewBetaRelease(currentBetaVersion, currentVersion) {
if (semver.major(currentBetaVersion) !== semver.major(currentVersion)) {
throw new Error('Major version bumps are not allowed by this pipeline');
}
const bumpedCurrentBeta = semver.inc(currentBetaVersion, 'minor');
if (semver.minor(bumpedCurrentBeta) !== semver.minor(currentVersion)) {
throw new Error(
`Trying to upgrade minor version by more than one increment. Previous: ${bumpedCurrentBeta}, Requested: ${currentVersion}`,
);
}
}
function determineReleaseType(currentVersion) {
if (currentVersion.includes('-rc.')) {
return 'rc';
}
return 'stable';
}
function determineBump(currentVersion) {
if (semver.patch(currentVersion) === 0 && determineReleaseType(currentVersion) != 'rc') {
return 'minor';
}
return 'patch';
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
const packageJson = JSON.parse(readFileSync('./package.json', 'utf8'));
determineTrack(packageJson.version);
}

View File

@@ -0,0 +1,91 @@
import { describe, it, mock, before } from 'node:test';
import assert from 'node:assert/strict';
/**
* Run these tests by running
*
* node --test --experimental-test-module-mocks ./.github/scripts/determine-version-info.test.mjs
* */
// mock.module must be called before the module under test is imported,
// because static imports are hoisted and resolve before any code runs.
mock.module('./github-helpers.mjs', {
namedExports: {
RELEASE_TRACKS: ['stable', 'beta', 'v1'],
resolveReleaseTagForTrack: (track) => {
// Always return deterministic data
if (track === 'stable') return { version: '2.9.2', tag: 'n8n@2.9.2' };
if (track === 'beta') return { version: '2.10.1', tag: 'n8n@2.10.1' };
return { version: '1.123.33', tag: 'n8n@1.123.33' };
},
writeGithubOutput: () => {}, // no-op in tests
},
});
let determineTrack;
before(async () => {
({ determineTrack } = await import('./determine-version-info.mjs'));
});
describe('determine-tracks', () => {
it('Allow patch releases on stable', () => {
const output = determineTrack('2.9.3');
assert.equal(output.track, 'stable');
assert.equal(output.version, '2.9.3');
assert.equal(output.bump, 'patch');
assert.equal(output.new_stable_version, null);
assert.equal(output.release_type, 'stable');
});
it('Allow patch releases on beta', () => {
const output = determineTrack('2.10.2');
assert.equal(output.track, 'beta');
assert.equal(output.version, '2.10.2');
assert.equal(output.bump, 'patch');
assert.equal(output.new_stable_version, null);
assert.equal(output.release_type, 'stable');
});
// This use case might happen if a patch release fails and we proceed with rolling over to next release
it('Allow skipping versions in patches', () => {
const output = determineTrack('2.9.4');
assert.equal(output.track, 'stable');
assert.equal(output.version, '2.9.4');
assert.equal(output.bump, 'patch');
assert.equal(output.new_stable_version, null);
assert.equal(output.release_type, 'stable');
});
it('Disallow skipping versions in minors', () => {
assert.throws(() => determineTrack('2.12.0'));
});
it('Disallow changing major version', () => {
assert.throws(() => determineTrack('3.0.0'));
});
it('Throw when track is not determinable', () => {
assert.throws(() => determineTrack(''));
});
it('Set track as "beta" when doing a minor bump', () => {
const output = determineTrack('2.11.0');
assert.equal(output.track, 'beta');
assert.equal(output.version, '2.11.0');
assert.equal(output.bump, 'minor');
assert.equal(output.new_stable_version, '2.10.1');
assert.equal(output.release_type, 'stable');
});
it('Set release_type accordingly on rc releases', () => {
const output = determineTrack('2.10.2-rc.1');
assert.equal(output.track, 'beta');
assert.equal(output.version, '2.10.2-rc.1');
assert.equal(output.bump, 'patch');
assert.equal(output.new_stable_version, null);
assert.equal(output.release_type, 'rc');
});
});

177
.github/scripts/docker/docker-config.mjs vendored Normal file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env node
import { appendFileSync } from 'node:fs';
class BuildContext {
constructor() {
this.githubOutput = process.env.GITHUB_OUTPUT || null;
}
determine({ event, pr, branch, version, releaseType, pushEnabled }) {
let context = {
version: '',
release_type: '',
platforms: ['linux/amd64', 'linux/arm64'],
push_to_ghcr: true,
push_to_docker: false,
};
if (version && releaseType) {
context.version = version;
context.release_type = releaseType;
context.push_to_docker = true;
} else {
switch (event) {
case 'schedule':
context.version = 'nightly';
context.release_type = 'nightly';
context.push_to_docker = true;
break;
case 'pull_request':
context.version = `pr-${pr}`;
context.release_type = 'dev';
context.platforms = ['linux/amd64'];
break;
case 'workflow_dispatch':
context.version = `branch-${this.sanitizeBranch(branch)}`;
context.release_type = 'branch';
context.platforms = ['linux/amd64'];
break;
case 'push':
if (branch === 'master') {
context.version = 'dev';
context.release_type = 'dev';
context.push_to_docker = true;
} else {
context.version = `branch-${this.sanitizeBranch(branch)}`;
context.release_type = 'branch';
context.platforms = ['linux/amd64'];
}
break;
case 'workflow_call':
case 'release':
if (!version) throw new Error('Version required for release');
context.version = version;
context.release_type = releaseType || 'stable';
context.push_to_docker = true;
break;
default:
throw new Error(`Unknown event: ${event}`);
}
}
// Handle push_enabled override
if (pushEnabled !== undefined) {
context.push_enabled = pushEnabled;
} else {
context.push_enabled = context.push_to_ghcr;
}
return context;
}
sanitizeBranch(branch) {
if (!branch) return 'unknown';
return branch
.toLowerCase()
.replace(/[^a-z0-9._-]/g, '-')
.replace(/^[.-]/, '')
.replace(/[.-]$/, '')
.substring(0, 128);
}
buildMatrix(platforms) {
const runners = {
'linux/amd64': 'blacksmith-4vcpu-ubuntu-2204',
'linux/arm64': 'blacksmith-4vcpu-ubuntu-2204-arm',
};
const matrix = {
platform: [],
include: [],
};
for (const platform of platforms) {
const shortName = platform.split('/').pop(); // amd64 or arm64
matrix.platform.push(shortName);
matrix.include.push({
platform: shortName,
runner: runners[platform],
docker_platform: platform,
});
}
return matrix;
}
output(context, matrix = null) {
const buildMatrix = matrix || this.buildMatrix(context.platforms);
if (this.githubOutput) {
const outputs = [
`version=${context.version}`,
`release_type=${context.release_type}`,
`platforms=${JSON.stringify(context.platforms)}`,
`push_to_ghcr=${context.push_to_ghcr}`,
`push_to_docker=${context.push_to_docker}`,
`push_enabled=${context.push_enabled}`,
`build_matrix=${JSON.stringify(buildMatrix)}`,
];
appendFileSync(this.githubOutput, outputs.join('\n') + '\n');
} else {
console.log(JSON.stringify({ ...context, build_matrix: buildMatrix }, null, 2));
}
}
}
// CLI - Simple argument parsing
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const getArg = (name) => {
const index = args.indexOf(`--${name}`);
if (index === -1 || !args[index + 1]) return undefined;
const value = args[index + 1];
// Handle empty strings and 'null' as undefined
return value === '' || value === 'null' ? undefined : value;
};
try {
const context = new BuildContext();
const pushEnabledArg = getArg('push-enabled');
const result = context.determine({
event: getArg('event') || process.env.GITHUB_EVENT_NAME,
pr: getArg('pr') || process.env.GITHUB_PR_NUMBER,
branch: getArg('branch') || process.env.GITHUB_REF_NAME,
version: getArg('version'),
releaseType: getArg('release-type'),
pushEnabled:
pushEnabledArg === 'true' ? true : pushEnabledArg === 'false' ? false : undefined,
});
const matrix = context.buildMatrix(result.platforms);
// Debug output when GITHUB_OUTPUT is set (running in Actions)
if (context.githubOutput) {
console.log('=== Build Context ===');
console.log(`version: ${result.version}`);
console.log(`release_type: ${result.release_type}`);
console.log(`platforms: ${JSON.stringify(result.platforms, null, 2)}`);
console.log(`push_to_ghcr: ${result.push_to_ghcr}`);
console.log(`push_to_docker: ${result.push_to_docker}`);
console.log(`push_enabled: ${result.push_enabled}`);
console.log('build_matrix:', JSON.stringify(matrix, null, 2));
}
context.output(result, matrix);
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
export default BuildContext;

113
.github/scripts/docker/docker-tags.mjs vendored Normal file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env node
import { appendFileSync } from 'node:fs';
class TagGenerator {
constructor() {
this.githubOwner = process.env.GITHUB_REPOSITORY_OWNER || 'n8n-io';
this.dockerUsername = process.env.DOCKER_USERNAME || 'n8nio';
this.githubOutput = process.env.GITHUB_OUTPUT || null;
}
generate({ image, version, platform, includeDockerHub = false }) {
let imageName = image;
let versionSuffix = '';
if (image === 'runners-distroless') {
imageName = 'runners';
versionSuffix = '-distroless';
}
const platformSuffix = platform ? `-${platform.split('/').pop()}` : '';
const fullVersion = `${version}${versionSuffix}${platformSuffix}`;
const tags = {
ghcr: [`ghcr.io/${this.githubOwner}/${imageName}:${fullVersion}`],
docker: includeDockerHub ? [`${this.dockerUsername}/${imageName}:${fullVersion}`] : [],
};
tags.all = [...tags.ghcr, ...tags.docker];
return tags;
}
output(tags, prefix = '') {
if (this.githubOutput) {
const prefixStr = prefix ? `${prefix}_` : '';
const primaryTag = tags.ghcr[0] ? tags.ghcr[0].replace(/-amd64$|-arm64$/, '') : '';
const outputs = [
`${prefixStr}tags=${tags.all.join(',')}`,
`${prefixStr}ghcr_tag=${tags.ghcr[0] || ''}`,
`${prefixStr}docker_tag=${tags.docker[0] || ''}`,
`${prefixStr}primary_tag=${primaryTag}`,
];
appendFileSync(this.githubOutput, outputs.join('\n') + '\n');
} else {
console.log(JSON.stringify(tags, null, 2));
}
}
generateAll({ version, platform, includeDockerHub = false }) {
const images = ['n8n', 'runners', 'runners-distroless'];
const results = {};
for (const image of images) {
const tags = this.generate({ image, version, platform, includeDockerHub });
const prefix = image.replace('-distroless', '_distroless');
results[prefix] = tags;
if (this.githubOutput) {
this.output(tags, prefix);
}
}
return results;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const getArg = (name) => {
const index = args.indexOf(`--${name}`);
return index !== -1 && args[index + 1] ? args[index + 1] : undefined;
};
const hasFlag = (name) => args.includes(`--${name}`);
try {
const generator = new TagGenerator();
const version = getArg('version');
if (!version) {
console.error('Error: --version is required');
process.exit(1);
}
if (hasFlag('all')) {
const results = generator.generateAll({
version,
platform: getArg('platform'),
includeDockerHub: hasFlag('include-docker'),
});
if (!generator.githubOutput) {
console.log(JSON.stringify(results, null, 2));
}
} else {
const image = getArg('image');
if (!image) {
console.error('Error: Either --image or --all is required');
process.exit(1);
}
const tags = generator.generate({
image,
version,
platform: getArg('platform'),
includeDockerHub: hasFlag('include-docker'),
});
generator.output(tags);
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
export default TagGenerator;

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* Extracts manifest digests and image names for SLSA provenance and VEX attestation.
*
* Usage:
* N8N_TAG=ghcr.io/n8n-io/n8n:1.0.0 node get-manifest-digests.mjs
*
* Environment variables:
* N8N_TAG - Full image reference for n8n image
* RUNNERS_TAG - Full image reference for runners image
* DISTROLESS_TAG - Full image reference for runners-distroless image
* GITHUB_OUTPUT - Path to GitHub Actions output file (optional)
*/
import { execSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
const githubOutput = process.env.GITHUB_OUTPUT || null;
function getDigest(imageRef) {
if (!imageRef) return '';
const raw = execSync(`docker buildx imagetools inspect "${imageRef}" --raw`, {
encoding: 'utf8',
});
const hash = execSync('sha256sum', { input: raw, encoding: 'utf8' }).split(' ')[0].trim();
return `sha256:${hash}`;
}
function getImageName(imageRef) {
if (!imageRef) return '';
return imageRef.replace(/:([^:]+)$/, '');
}
function setOutput(name, value) {
if (githubOutput && value) appendFileSync(githubOutput, `${name}=${value}\n`);
}
const n8nTag = process.env.N8N_TAG || '';
const runnersTag = process.env.RUNNERS_TAG || '';
const distrolessTag = process.env.DISTROLESS_TAG || '';
const results = {
n8n: { digest: getDigest(n8nTag), image: getImageName(n8nTag) },
runners: { digest: getDigest(runnersTag), image: getImageName(runnersTag) },
runners_distroless: { digest: getDigest(distrolessTag), image: getImageName(distrolessTag) },
};
setOutput('n8n_digest', results.n8n.digest);
setOutput('n8n_image', results.n8n.image);
setOutput('runners_digest', results.runners.digest);
setOutput('runners_image', results.runners.image);
setOutput('runners_distroless_digest', results.runners_distroless.digest);
setOutput('runners_distroless_image', results.runners_distroless.image);
console.log('=== Manifest Digests ===');
console.log(`n8n: ${results.n8n.digest || 'N/A'}`);
console.log(`runners: ${results.runners.digest || 'N/A'}`);
console.log(`runners-distroless: ${results.runners_distroless.digest || 'N/A'}`);
console.log('');
console.log('=== Image Names ===');
console.log(`n8n: ${results.n8n.image || 'N/A'}`);
console.log(`runners: ${results.runners.image || 'N/A'}`);
console.log(`runners-distroless: ${results.runners_distroless.image || 'N/A'}`);

View File

@@ -0,0 +1,44 @@
import { writeFile, readFile, copyFile } from 'fs/promises';
import { resolve, dirname } from 'path';
import child_process from 'child_process';
import { fileURLToPath } from 'url';
import { promisify } from 'util';
const exec = promisify(child_process.exec);
const commonFiles = ['LICENSE.md', 'LICENSE_EE.md'];
const baseDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const packages = JSON.parse((await exec('pnpm ls -r --only-projects --json')).stdout);
for (let { name, path, version, private: isPrivate } of packages) {
if (isPrivate) continue;
const packageFile = resolve(path, 'package.json');
const packageJson = {
...JSON.parse(await readFile(packageFile, 'utf-8')),
// Add these fields to all published package.json files to ensure provenance checks pass
license: 'SEE LICENSE IN LICENSE.md',
homepage: 'https://n8n.io',
author: {
name: 'Jan Oberhauser',
email: 'jan@n8n.io',
},
repository: {
type: 'git',
url: 'git+https://github.com/n8n-io/n8n.git',
},
};
// Copy over LICENSE.md and LICENSE_EE.md into every published package, and ensure they get included in the published package
await Promise.all(
commonFiles.map(async (file) => {
await copyFile(resolve(baseDir, file), resolve(path, file));
if (packageJson.files && !packageJson.files.includes(file)) {
packageJson.files.push(file);
}
}),
);
await writeFile(packageFile, JSON.stringify(packageJson, null, 2) + '\n');
}

View File

@@ -0,0 +1,156 @@
import semver from 'semver';
import {
getCommitForRef,
localRefExists,
remoteBranchExists,
resolveReleaseTagForTrack,
sh,
writeGithubOutput,
} from './github-helpers.mjs';
const RELEASE_CANDIDATE_BRANCH_PREFIX = 'release-candidate/';
/**
* @typedef BranchChanges
* @property { import('./github-helpers.mjs').TagVersionInfo[] } branchesToEnsure TagVersionInfo for branches the system needs to make sure exist
* @property { string[] } branchesToDeprecate Branches the system needs to remove as deprecated
* */
/**
* Look into git tags and determine which release candidate branches need to
* exist and which need to be deprecated and removed.
*
* @returns { BranchChanges }
* */
export function determineBranchChanges() {
const branchesToDeprecate = [];
const currentBetaVersion = resolveReleaseTagForTrack('beta');
const currentStableVersion = resolveReleaseTagForTrack('stable');
if (!currentBetaVersion || !currentStableVersion) {
throw new Error(
`Could not find current stable and/or beta tags. Beta: ${currentBetaVersion?.tag ?? 'not found'}, Stable: ${currentStableVersion?.tag ?? 'not found'}`,
);
}
const branchesToEnsure = [currentBetaVersion, currentStableVersion];
const stableVersion = currentStableVersion.version;
// Deprecated branch is the current stable minus 2 versions. e.g. stable: 2.9.x, deprecated is 2.7.x
const deprecatedMinorVersion = semver.minor(stableVersion) - 2;
if (deprecatedMinorVersion >= 0) {
const deprecatedBranch = `${RELEASE_CANDIDATE_BRANCH_PREFIX}${semver.major(stableVersion)}.${deprecatedMinorVersion}.x`;
branchesToDeprecate.push(deprecatedBranch);
}
return {
branchesToEnsure,
branchesToDeprecate,
};
}
/**
* Takes a TagVersionInfo object and returns a rc-branch name.
*
* e.g. release-candidate/2.8.x
*
* @param {import('./github-helpers.mjs').TagVersionInfo} tagVersionInfo
*
* @returns { `${RELEASE_CANDIDATE_BRANCH_PREFIX}${number}.${number}.x` }
* */
export function tagVersionInfoToReleaseCandidateBranchName(tagVersionInfo) {
const version = tagVersionInfo.version;
return `${RELEASE_CANDIDATE_BRANCH_PREFIX}${semver.major(version)}.${semver.minor(version)}.x`;
}
/**
* @param {import("./github-helpers.mjs").TagVersionInfo} tagInfo
*/
function ensureBranch(tagInfo) {
const branch = tagVersionInfoToReleaseCandidateBranchName(tagInfo);
if (remoteBranchExists(branch)) {
console.log(`Branch ${branch} already exists on origin. Skipping.`);
return branch;
}
const commitRef = getCommitForRef(tagInfo.tag);
console.log(`Creating branch ${branch} from ${tagInfo.tag} (${commitRef})`);
// Create local branch (force safe: it shouldn't exist, but keep it robust)
if (localRefExists(`refs/heads/${branch}`)) {
sh('git', ['branch', '-f', branch, commitRef]);
} else {
sh('git', ['switch', '-c', branch, commitRef]);
}
sh('git', ['push', 'origin', branch]);
return branch;
}
/**
* @param {string} branch
*/
function removeBranch(branch) {
if (!remoteBranchExists(branch)) {
console.log(`Couldn't find branch ${branch}. Skipping removal.`);
return null;
}
console.log(`Removing remote branch ${branch} from origin...`);
// Delete remote branch
sh('git', ['push', 'origin', '--delete', branch]);
// Optional local cleanup (keeps reruns tidy)
if (localRefExists(`refs/heads/${branch}`)) {
console.log(`Removing local branch ${branch}...`);
sh('git', ['branch', '-D', branch]);
}
return branch;
}
function main() {
const branchChanges = determineBranchChanges();
console.log('💡 Determined branch changes');
console.log('');
console.log(
` Branches to ensure: ${branchChanges.branchesToEnsure.map(tagVersionInfoToReleaseCandidateBranchName).join(', ')}`,
);
console.log(` Branches to deprecate: ${branchChanges.branchesToDeprecate.join(', ')}`);
console.log('');
console.log('Preparing to apply changes...');
let ensuredBranches = [];
for (const tagInfo of branchChanges.branchesToEnsure) {
const branch = ensureBranch(tagInfo);
ensuredBranches.push(branch);
}
console.log('');
console.log('Starting deprecation of branches...');
let removedBranches = [];
for (const branch of branchChanges.branchesToDeprecate) {
const removedBranch = removeBranch(branch);
if (removedBranch) {
removedBranches.push(removedBranch);
}
}
console.log('Done!');
writeGithubOutput({
ensuredBranches: ensuredBranches.join(','),
removedBranches: removedBranches.join(','),
});
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}

View File

@@ -0,0 +1,62 @@
import { describe, it, mock, before } from 'node:test';
import assert from 'node:assert/strict';
/**
* Run these tests by running
*
* node --test --experimental-test-module-mocks ./.github/scripts/ensure-release-candidate-branches.test.mjs
* */
// mock.module must be called before the module under test is imported,
// because static imports are hoisted and resolve before any code runs.
mock.module('./github-helpers.mjs', {
namedExports: {
RELEASE_TRACKS: ['stable', 'beta', 'v1'],
RELEASE_PREFIX: 'n8n@',
resolveReleaseTagForTrack: (track) => {
// Always return deterministic data
if (track === 'stable') return { version: '2.9.2', tag: 'n8n@2.9.2' };
if (track === 'beta') return { version: '2.10.1', tag: 'n8n@2.10.1' };
return { version: '1.123.33', tag: 'n8n@1.123.33' };
},
writeGithubOutput: () => {}, // no-op in tests
sh: () => {}, // no-op in tests
getCommitForRef: () => {}, // no-op in tests
remoteBranchExists: () => {}, // no-op in tests
localRefExists: () => {}, // no-op in tests
},
});
let determineBranchChanges, tagVersionInfoToReleaseCandidateBranchName;
before(async () => {
({ determineBranchChanges, tagVersionInfoToReleaseCandidateBranchName } = await import(
'./ensure-release-candidate-branches.mjs'
));
});
describe('Determine branch changes', () => {
it('Correctly determines ensureable branches', () => {
const output = determineBranchChanges();
const ensureBranches = output.branchesToEnsure.map(tagVersionInfoToReleaseCandidateBranchName);
assert.ok(
ensureBranches.includes('release-candidate/2.10.x'),
"Beta release-candidate branch doesn't exist",
);
assert.ok(
ensureBranches.includes('release-candidate/2.9.x'),
"Stable release-candidate branch doesn't exist",
);
});
it('Correctly determines deprecated branches', () => {
/** @type { import('./ensure-release-candidate-branches.mjs').BranchChanges} */
const output = determineBranchChanges();
assert.ok(
output.branchesToDeprecate.includes('release-candidate/2.7.x'),
'Existing branch release-candidate/2.7.x should be marked for removal',
);
});
});

View File

@@ -0,0 +1,63 @@
import semver from 'semver';
import {
getCommitForRef,
listTagsPointingAt,
RELEASE_PREFIX,
RELEASE_TRACKS,
stripReleasePrefixes,
writeGithubOutput,
} from './github-helpers.mjs';
/**
* Given a list of tag names, return the highest semver tag (keeping the original 'v' prefix),
* or "" if none match semver.
*
* @param {string[]} tags
**/
function highestSemverTag(tags) {
const candidates = tags
.filter((t) => t.startsWith(RELEASE_PREFIX))
.map((t) => ({
tag: t,
version: stripReleasePrefixes(t),
}))
.filter(({ version }) => semver.valid(version));
if (candidates.length === 0) return '';
candidates.sort((a, b) => semver.rcompare(a.version, b.version));
return candidates[0]?.tag;
}
/**
* @param {string} track
**/
function getSemverTagForTrack(track) {
const commit = getCommitForRef(track);
if (!commit) return '';
const tags = listTagsPointingAt(commit);
return highestSemverTag(tags);
}
function main() {
/** @type { Record<string, string> } */
const outputs = {};
for (const track of RELEASE_TRACKS) {
outputs[track] = getSemverTagForTrack(track);
}
writeGithubOutput(outputs);
console.log('Current release versions: ');
for (const [k, v] of Object.entries(outputs)) {
console.log(`${k}: ${v || '(not found)'}`);
}
}
try {
main();
} catch (err) {
console.error(String(err?.message ?? err));
process.exit(1);
}

249
.github/scripts/github-helpers.mjs vendored Normal file
View File

@@ -0,0 +1,249 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import semver from 'semver';
export const RELEASE_TRACKS = /** @type { const } */ ([
//
'stable',
'beta',
'v1',
]);
/**
* @typedef {typeof RELEASE_TRACKS[number]} ReleaseTrack
* */
/**
* @typedef {`${number}.${number}.${number}`} SemVer
* */
/**
* @typedef {`${RELEASE_PREFIX}${SemVer}`} ReleaseVersion
* */
/**
* @typedef {{ tag: ReleaseVersion, version: SemVer}} TagVersionInfo
* */
export const RELEASE_PREFIX = 'n8n@';
/**
* Given a list of tags, return the highest semver for tags like "n8n@2.7.0".
* Returns the *tag string* (e.g. "n8n@2.7.0") or null.
*
* @param {string[]} tags
*
* @returns { ReleaseVersion | null }
* */
export function pickHighestReleaseTag(tags) {
const versions = tags
.filter((t) => t.startsWith(RELEASE_PREFIX))
.map((t) => ({ tag: t, v: stripReleasePrefixes(t) }))
.filter(({ v }) => semver.valid(v))
.sort((a, b) => semver.rcompare(a.v, b.v));
return /** @type { ReleaseVersion } */ (versions[0]?.tag) ?? null;
}
/**
* @param {any} track
*
* @returns { ReleaseTrack }
* */
export function ensureReleaseTrack(track) {
if (!RELEASE_TRACKS.includes(track)) {
throw new Error(`Invalid track ${track}. Available tracks are ${RELEASE_TRACKS.join(', ')}`);
}
return track;
}
/**
* Resolve a release track tag (stable/beta/etc.) to the corresponding
* n8n@x.y.z tag pointing at the same commit.
*
* Returns null if the track tag or release tag is missing.
*
* @param { typeof RELEASE_TRACKS[number] } track
*
* @returns { TagVersionInfo }
* */
export function resolveReleaseTagForTrack(track) {
const commit = getCommitForRef(track);
if (!commit) return null;
const tagsAtCommit = listTagsPointingAt(commit);
const releaseTag = pickHighestReleaseTag(tagsAtCommit);
if (!releaseTag) return null;
return {
tag: releaseTag,
version: stripReleasePrefixes(releaseTag),
};
}
/**
* Resolve a release track tag (stable/beta/etc.) to the corresponding
* release-candidate/<major>.<minor>.x branch, based on the n8n@<x.y.z> tag
* pointing at the same commit.
*
* Returns null if the track tag or release tag is missing.
*
* @param { typeof RELEASE_TRACKS[number] } track
* */
export function resolveRcBranchForTrack(track) {
const commit = getCommitForRef(track);
if (!commit) return null;
const tagsAtCommit = listTagsPointingAt(commit);
const releaseTag = pickHighestReleaseTag(tagsAtCommit);
if (!releaseTag) return null;
const version = stripReleasePrefixes(releaseTag);
const parsed = semver.parse(version);
if (!parsed) return null;
return `release-candidate/${parsed.major}.${parsed.minor}.x`;
}
/**
* @param {string} tag
*
* @returns { SemVer }
* */
export function stripReleasePrefixes(tag) {
return /** @type { SemVer } */ (
tag.startsWith(RELEASE_PREFIX) ? tag.slice(RELEASE_PREFIX.length) : tag
);
}
/**
* @param {any} [pullRequest] Optional pull request object. If not provided, reads from GITHUB_EVENT_PATH
*
* @returns {string[]}
*/
export function readPrLabels(pullRequest) {
if (!pullRequest) {
const eventPath = ensureEnvVar('GITHUB_EVENT_PATH');
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
pullRequest = event.pull_request;
}
/** @type { string[] | { name: string }[] } */
const labels = pullRequest?.labels ?? [];
return labels.map((l) => (typeof l === 'string' ? l : l?.name)).filter(Boolean);
}
/**
* Ensures git tag exists.
*
* @throws { Error } if no tag was found
* */
export function ensureTagExists(tag) {
sh('git', ['fetch', '--force', '--no-tags', 'origin', `refs/tags/${tag}:refs/tags/${tag}`]);
}
/**
* @param {string} bump
*
* @returns { bump is import("semver").ReleaseType }
* */
export function isReleaseType(bump) {
return ['major', 'minor', 'patch'].includes(bump);
}
/**
* @param {string} variableName
*/
export function ensureEnvVar(variableName) {
const v = process.env[variableName];
if (!v) {
throw new Error(`Missing required env var: ${variableName}`);
}
return v;
}
/**
* @param {string} cmd
* @param {readonly string[]} args
* @param {import("node:child_process").ExecFileOptionsWithStringEncoding} args
*
* @example sh("git", ["tag", "--points-at", commit]);
* */
export function sh(cmd, args, opts = {}) {
return execFileSync(cmd, args, { encoding: 'utf8', ...opts }).trim();
}
/**
* @param {string} cmd
* @param {readonly string[]} args
* @param {import("node:child_process").ExecFileOptionsWithStringEncoding} args
*
* @example trySh("git", ["tag", "--points-at", commit]);
* */
export function trySh(cmd, args, opts = {}) {
try {
return { ok: true, out: sh(cmd, args, opts) };
} catch {
return { ok: false, out: '' };
}
}
/**
* Append outputs to GITHUB_OUTPUT if available.
*
* @param {Record<string, string>} obj
*/
export function writeGithubOutput(obj) {
const path = process.env.GITHUB_OUTPUT;
if (!path) return;
const lines = Object.entries(obj)
.map(([k, v]) => `${k}=${v ?? ''}`)
.join('\n');
fs.appendFileSync(path, lines + '\n', 'utf8');
}
/**
* Resolve a ref (tag/branch/SHA) to the underlying commit SHA.
* Uses ^{} so annotated tags are peeled to the commit.
* Returns null if ref doesn't exist.
*
* @param {string} ref
*/
export function getCommitForRef(ref) {
const res = trySh('git', ['rev-parse', `${ref}^{}`]);
return res.ok && res.out ? res.out : null;
}
/**
* List all tags that point at the given commit SHA.
*
* @param {string} commit
*/
export function listTagsPointingAt(commit) {
const res = trySh('git', ['tag', '--points-at', commit]);
if (!res.ok || !res.out) return [];
return res.out
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
}
/**
* @param {string} branch
*/
export function remoteBranchExists(branch) {
const res = trySh('git', ['ls-remote', '--heads', 'origin', branch]);
return res.ok && res.out.length > 0;
}
/**
* @param {string} ref
*/
export function localRefExists(ref) {
const res = trySh('git', ['show-ref', '--verify', '--quiet', ref]);
return res.ok;
}

10
.github/scripts/jsconfig.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"checkJs": true,
"moduleResolution": "node"
},
"exclude": ["node_modules"]
}

18
.github/scripts/move-track-tag.mjs vendored Normal file
View File

@@ -0,0 +1,18 @@
import { ensureEnvVar, ensureReleaseTrack, ensureTagExists, sh } from './github-helpers.mjs';
function main() {
const trackEnv = ensureEnvVar('TRACK');
const track = ensureReleaseTrack(trackEnv);
const versionInput = ensureEnvVar('VERSION_TAG'); // e.g. n8n@2.7.0
ensureTagExists(versionInput);
sh('git', ['tag', '-f', track, versionInput]);
sh('git', ['push', 'origin', '-f', `refs/tags/${track}:refs/tags/${track}`]);
console.log(`Moved pointer tag ${track} to point to ${versionInput}`);
}
main();

13
.github/scripts/package.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"dependencies": {
"cacheable-lookup": "6.1.0",
"conventional-changelog": "^4.0.0",
"debug": "4.3.4",
"glob": "10.5.0",
"p-limit": "3.1.0",
"picocolors": "1.0.1",
"semver": "7.5.4",
"tempfile": "5.0.0",
"@actions/github": "9.0.0"
}
}

62
.github/scripts/plan-release.mjs vendored Normal file
View File

@@ -0,0 +1,62 @@
import semver from 'semver';
import {
ensureEnvVar,
isReleaseType,
RELEASE_PREFIX,
stripReleasePrefixes,
writeGithubOutput,
} from './github-helpers.mjs';
const track = ensureEnvVar('TRACK');
const bump = ensureEnvVar('BUMP');
const stable = process.env['STABLE_VERSION'];
const beta = process.env['BETA_VERSION'];
const v1 = process.env['V1_VERSION'];
let base = null;
switch (track) {
case 'stable':
base = stable;
break;
case 'beta':
base = beta;
break;
case 'v1':
base = v1;
break;
}
if (!base) {
console.error(
`Unknown track or missing base version. track=${track} stable=${stable} beta=${beta} v1=${v1}`,
);
process.exit(1);
}
const cleanedBase = stripReleasePrefixes(base);
if (!cleanedBase) {
console.error(`Invalid base version: ${base}`);
process.exit(1);
}
if (!isReleaseType(bump)) {
console.error(`Invalid release type in $bump: ${bump}`);
process.exit(1);
}
const next = semver.inc(cleanedBase, bump);
if (!next) {
console.error(`Could not bump version. base=${cleanedBase} bump=${bump}`);
process.exit(1);
}
const output = {
base_version: cleanedBase,
new_version: next,
new_version_tag: `${RELEASE_PREFIX}${next}`,
};
writeGithubOutput(output);
console.log(`Releasing track=${track} bump=${bump} base=${cleanedBase} -> new=${next}`);

104
.github/scripts/send-build-stats.mjs vendored Normal file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env node
/**
* Sends Turbo build summary JSON to a webhook
*
* Usage: node send-build-stats.mjs
*
* Auto-detects summary from .turbo/runs/ directory.
*
* Environment variables:
* BUILD_STATS_WEBHOOK_URL - Webhook URL (required to send)
* BUILD_STATS_WEBHOOK_USER - Basic auth username (required if URL set)
* BUILD_STATS_WEBHOOK_PASSWORD - Basic auth password (required if URL set)
*/
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import * as os from 'node:os';
import { join } from 'node:path';
const runsDir = '.turbo/runs';
if (!existsSync(runsDir)) {
console.log('No .turbo/runs directory found (turbo --summarize not used), skipping.');
process.exit(0);
}
const files = readdirSync(runsDir).filter((f) => f.endsWith('.json'));
const summaryPath = files.length > 0 ? join(runsDir, files[0]) : null;
const webhookUrl = process.env.BUILD_STATS_WEBHOOK_URL;
const webhookUser = process.env.BUILD_STATS_WEBHOOK_USER;
const webhookPassword = process.env.BUILD_STATS_WEBHOOK_PASSWORD;
if (!summaryPath) {
console.error('No summary file found in .turbo/runs/');
process.exit(1);
}
if (!webhookUrl) {
console.log('BUILD_STATS_WEBHOOK_URL not set, skipping.');
process.exit(0);
}
if (!webhookUser || !webhookPassword) {
console.error('BUILD_STATS_WEBHOOK_USER and BUILD_STATS_WEBHOOK_PASSWORD are required');
process.exit(1);
}
const basicAuth = Buffer.from(`${webhookUser}:${webhookPassword}`).toString('base64');
const summary = JSON.parse(readFileSync(summaryPath, 'utf-8'));
// Extract PR number from GITHUB_REF (refs/pull/123/merge)
const ref = process.env.GITHUB_REF ?? '';
const prMatch = ref.match(/refs\/pull\/(\d+)/);
// Detect runner provider (matches packages/testing/containers/telemetry.ts)
function getRunnerProvider() {
if (!process.env.CI) return 'local';
if (process.env.RUNNER_ENVIRONMENT === 'github-hosted') return 'github';
return 'blacksmith';
}
// Add git context (aligned with container telemetry)
summary.git = {
sha: process.env.GITHUB_SHA?.slice(0, 8) || null,
branch: process.env.GITHUB_HEAD_REF ?? process.env.GITHUB_REF_NAME ?? null,
pr: prMatch ? parseInt(prMatch[1], 10) : null,
};
// Add CI context
summary.ci = {
runId: process.env.GITHUB_RUN_ID || null,
runUrl: process.env.GITHUB_RUN_ID
? `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
: null,
job: process.env.GITHUB_JOB || null,
workflow: process.env.GITHUB_WORKFLOW || null,
attempt: process.env.GITHUB_RUN_ATTEMPT ? parseInt(process.env.GITHUB_RUN_ATTEMPT, 10) : null,
};
// Add runner info
summary.runner = {
provider: getRunnerProvider(),
cpuCores: os.cpus().length,
memoryGb: Math.round((os.totalmem() / (1024 * 1024 * 1024)) * 10) / 10,
};
const headers = {
'Content-Type': 'application/json',
'Authorization': `Basic ${basicAuth}`,
};
const response = await fetch(webhookUrl, {
method: 'POST',
headers,
body: JSON.stringify(summary),
});
if (!response.ok) {
console.error(`Webhook failed: ${response.status} ${response.statusText}`);
const body = await response.text();
if (body) console.error(`Response: ${body}`);
process.exit(1);
}
console.log(`Build stats sent: ${response.status}`);

104
.github/scripts/send-docker-stats.mjs vendored Normal file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env node
/**
* Sends Docker build stats to a webhook for BigQuery ingestion.
*
* Reads manifests produced by build-n8n.mjs and dockerize-n8n.mjs,
* enriches with git/CI/runner context, and POSTs to a webhook.
*
* Usage: node send-docker-stats.mjs
*
* Environment variables:
* DOCKER_STATS_WEBHOOK_URL - Webhook URL (required to send)
*/
import { existsSync, readFileSync } from 'node:fs';
import * as os from 'node:os';
const buildManifestPath = 'compiled/build-manifest.json';
const dockerManifestPath = 'docker-build-manifest.json';
if (!existsSync(buildManifestPath) && !existsSync(dockerManifestPath)) {
console.log('No build or docker manifests found, skipping.');
process.exit(0);
}
const webhookUrl = process.env.DOCKER_STATS_WEBHOOK_URL;
if (!webhookUrl) {
console.log('DOCKER_STATS_WEBHOOK_URL not set, skipping.');
process.exit(0);
}
const buildManifest = existsSync(buildManifestPath)
? JSON.parse(readFileSync(buildManifestPath, 'utf-8'))
: null;
const dockerManifest = existsSync(dockerManifestPath)
? JSON.parse(readFileSync(dockerManifestPath, 'utf-8'))
: null;
// Extract PR number from GITHUB_REF (refs/pull/123/merge)
const ref = process.env.GITHUB_REF ?? '';
const prMatch = ref.match(/refs\/pull\/(\d+)/);
// Detect runner provider (matches packages/testing/containers/telemetry.ts)
function getRunnerProvider() {
if (!process.env.CI) return 'local';
if (process.env.RUNNER_ENVIRONMENT === 'github-hosted') return 'github';
return 'blacksmith';
}
const payload = {
build: buildManifest
? {
artifactSize: buildManifest.artifactSize,
buildDuration: buildManifest.buildDuration,
}
: null,
docker: dockerManifest
? {
platform: dockerManifest.platform,
images: dockerManifest.images,
}
: null,
git: {
sha: process.env.GITHUB_SHA?.slice(0, 8) || null,
branch: process.env.GITHUB_HEAD_REF ?? process.env.GITHUB_REF_NAME ?? null,
pr: prMatch ? parseInt(prMatch[1], 10) : null,
},
ci: {
runId: process.env.GITHUB_RUN_ID || null,
runUrl: process.env.GITHUB_RUN_ID
? `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
: null,
job: process.env.GITHUB_JOB || null,
workflow: process.env.GITHUB_WORKFLOW || null,
attempt: process.env.GITHUB_RUN_ATTEMPT ? parseInt(process.env.GITHUB_RUN_ATTEMPT, 10) : null,
},
runner: {
provider: getRunnerProvider(),
cpuCores: os.cpus().length,
memoryGb: Math.round((os.totalmem() / (1024 * 1024 * 1024)) * 10) / 10,
},
};
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
console.error(`Webhook failed: ${response.status} ${response.statusText}`);
const body = await response.text();
if (body) console.error(`Response: ${body}`);
process.exit(1);
}
console.log(`Docker build stats sent: ${response.status}`);

18
.github/scripts/trim-fe-packageJson.js vendored Normal file
View File

@@ -0,0 +1,18 @@
const { writeFileSync } = require('fs');
const { resolve } = require('path');
const baseDir = resolve(__dirname, '../..');
const trimPackageJson = (packageName) => {
const filePath = resolve(baseDir, 'packages', packageName, 'package.json');
const { scripts, peerDependencies, devDependencies, dependencies, ...packageJson } = require(
filePath,
);
if (packageName === 'frontend/@n8n/chat') {
packageJson.dependencies = dependencies;
}
writeFileSync(filePath, JSON.stringify(packageJson, null, 2) + '\n', 'utf-8');
};
trimPackageJson('frontend/@n8n/chat');
trimPackageJson('frontend/@n8n/design-system');
trimPackageJson('frontend/editor-ui');

40
.github/scripts/update-changelog.mjs vendored Normal file
View File

@@ -0,0 +1,40 @@
import createTempFile from 'tempfile';
import conventionalChangelog from 'conventional-changelog';
import { resolve } from 'path';
import { createReadStream, createWriteStream } from 'fs';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { pipeline } from 'stream/promises';
import packageJson from '../../package.json' with { type: 'json' };
const baseDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const fullChangelogFile = resolve(baseDir, 'CHANGELOG.md');
// Version includes experimental versions (e.g., 1.2.3-exp.0)
const versionChangelogFile = resolve(baseDir, `CHANGELOG-${packageJson.version}.md`);
const changelogStream = conventionalChangelog({
preset: 'angular',
releaseCount: 1,
tagPrefix: 'n8n@',
transform: (commit, callback) => {
const hasNoChangelogInHeader = commit.header.includes('(no-changelog)');
const isBenchmarkScope = commit.scope === 'benchmark';
// Ignore commits that have 'benchmark' scope or '(no-changelog)' in the header
callback(null, hasNoChangelogInHeader || isBenchmarkScope ? undefined : commit);
},
}).on('error', (err) => {
console.error(err.stack);
process.exit(1);
});
// Write the new changelog to a new temporary file, so that the contents can be used in the PR description
await pipeline(changelogStream, createWriteStream(versionChangelogFile));
// Since we can't read and write from the same file at the same time,
// we use a temporary file to output the updated changelog to.
const tmpFile = createTempFile();
const tmpStream = createWriteStream(tmpFile);
await pipeline(createReadStream(versionChangelogFile), tmpStream, { end: false });
await pipeline(createReadStream(fullChangelogFile), tmpStream);
await pipeline(createReadStream(tmpFile), createWriteStream(fullChangelogFile));

90
.github/scripts/validate-docs-links.js vendored Normal file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env node
const packages = ['nodes-base', '@n8n/nodes-langchain'];
const concurrency = 20;
let exitCode = 0;
const debug = require('debug')('n8n');
const path = require('path');
const https = require('https');
const glob = require('glob');
const pLimit = require('p-limit');
const picocolors = require('picocolors');
const Lookup = require('cacheable-lookup').default;
const agent = new https.Agent({ keepAlive: true, keepAliveMsecs: 5000 });
new Lookup().install(agent);
const limiter = pLimit(concurrency);
const validateUrl = async (packageName, kind, type) =>
new Promise((resolve, reject) => {
const name = type.displayName;
const documentationUrl =
kind === 'credentials'
? type.documentationUrl
: type.codex?.resources?.primaryDocumentation?.[0]?.url;
if (!documentationUrl) resolve([name, null]);
const url = new URL(
/^https?:\/\//.test(documentationUrl)
? documentationUrl
: `https://docs.n8n.io/integrations/builtin/${kind}/${documentationUrl.toLowerCase()}/`,
);
https
.request(
{
hostname: url.hostname,
port: 443,
path: url.pathname,
method: 'HEAD',
agent,
},
(res) => {
debug(picocolors.green('✓'), packageName, kind, name);
resolve([name, res.statusCode]);
},
)
.on('error', (e) => {
debug(picocolors.red('✘'), packageName, kind, name);
reject(e);
})
.end();
});
const checkLinks = async (packageName, kind) => {
const baseDir = path.resolve(__dirname, '../../packages', packageName);
let types = require(path.join(baseDir, `dist/types/${kind}.json`));
if (kind === 'nodes')
types = types.filter(
({ codex, hidden }) => !!codex?.resources?.primaryDocumentation && !hidden,
);
debug(packageName, kind, types.length);
const statuses = await Promise.all(
types.map((type) =>
limiter(() => {
return validateUrl(packageName, kind, type);
}),
),
);
const missingDocs = [];
const invalidUrls = [];
for (const [name, statusCode] of statuses) {
if (statusCode === null) missingDocs.push(name);
if (statusCode !== 200) invalidUrls.push(name);
}
if (missingDocs.length)
console.log('Documentation URL missing in %s for %s', packageName, kind, missingDocs);
if (invalidUrls.length)
console.log('Documentation URL invalid in %s for %s', packageName, kind, invalidUrls);
if (missingDocs.length || invalidUrls.length) exitCode = 1;
};
(async () => {
for (const packageName of packages) {
await Promise.all([checkLinks(packageName, 'credentials'), checkLinks(packageName, 'nodes')]);
if (exitCode !== 0) process.exit(exitCode);
}
})();

732
.github/test-metrics/playwright.json vendored Normal file
View File

@@ -0,0 +1,732 @@
{
"updatedAt": "2026-01-27T22:20:54.780Z",
"source": "currents",
"projectId": "LRxcNt",
"specs": {
"tests/e2e/projects/projects.spec.ts": {
"avgDuration": 181938,
"testCount": 9,
"flakyRate": 0.1222
},
"tests/e2e/workflows/editor/canvas/actions.spec.ts": {
"avgDuration": 136685,
"testCount": 20,
"flakyRate": 0.0042
},
"tests/e2e/credentials/crud.spec.ts": {
"avgDuration": 126381,
"testCount": 14,
"flakyRate": 0.0028
},
"tests/e2e/sharing/credential-visibility.spec.ts": {
"avgDuration": 112068,
"testCount": 5,
"flakyRate": 0.1
},
"tests/e2e/data-tables/tables.spec.ts": {
"avgDuration": 110045,
"testCount": 7,
"flakyRate": 0.0014
},
"tests/e2e/workflows/editor/code/code-node.spec.ts": {
"avgDuration": 99751,
"testCount": 12,
"flakyRate": 0.0437
},
"tests/e2e/data-tables/details.spec.ts": {
"avgDuration": 99274,
"testCount": 11,
"flakyRate": 0.0014
},
"tests/e2e/ai/langchain-agents.spec.ts": {
"avgDuration": 98947,
"testCount": 7,
"flakyRate": 0.0139
},
"tests/e2e/workflows/editor/canvas/undo-redo.spec.ts": {
"avgDuration": 96500,
"testCount": 14,
"flakyRate": 0
},
"tests/e2e/ai/assistant-basic.spec.ts": {
"avgDuration": 95751,
"testCount": 11,
"flakyRate": 0.0085
},
"tests/e2e/workflows/list/workflows.spec.ts": {
"avgDuration": 93615,
"testCount": 9,
"flakyRate": 0.0323
},
"tests/e2e/workflows/editor/ndv/ndv-core.spec.ts": {
"avgDuration": 93461,
"testCount": 14,
"flakyRate": 0.0225
},
"tests/e2e/workflows/editor/canvas/canvas-nodes.spec.ts": {
"avgDuration": 91986,
"testCount": 8,
"flakyRate": 0.0811
},
"tests/e2e/workflows/editor/canvas/canvas-zoom.spec.ts": {
"avgDuration": 91383,
"testCount": 12,
"flakyRate": 0.0506
},
"tests/e2e/workflows/editor/code/editors.spec.ts": {
"avgDuration": 90065,
"testCount": 11,
"flakyRate": 0.0266
},
"tests/e2e/workflows/editor/ndv/ndv-data-display.spec.ts": {
"avgDuration": 89783,
"testCount": 11,
"flakyRate": 0.0296
},
"tests/e2e/projects/folders-operations.spec.ts": {
"avgDuration": 86349,
"testCount": 14,
"flakyRate": 0.0056
},
"tests/e2e/settings/personal/two-factor-authentication.spec.ts": {
"avgDuration": 85085,
"testCount": 7,
"flakyRate": 0.0042
},
"tests/e2e/nodes/webhook.spec.ts": {
"avgDuration": 84386,
"testCount": 9,
"flakyRate": 0.0112
},
"tests/e2e/workflows/executions/list.spec.ts": {
"avgDuration": 79256,
"testCount": 11,
"flakyRate": 0.1491
},
"tests/e2e/workflows/editor/execution/debug.spec.ts": {
"avgDuration": 76887,
"testCount": 4,
"flakyRate": 0.0628
},
"tests/e2e/building-blocks/node-details-configuration.spec.ts": {
"avgDuration": 73937,
"testCount": 7,
"flakyRate": 0.007
},
"tests/e2e/auth/oidc.spec.ts": {
"avgDuration": 69764,
"testCount": 1,
"flakyRate": 0.0239
},
"tests/e2e/workflows/editor/ndv/ndv-parameters.spec.ts": {
"avgDuration": 68318,
"testCount": 9,
"flakyRate": 0.0042
},
"tests/e2e/workflows/editor/execution/execution.spec.ts": {
"avgDuration": 68225,
"testCount": 14,
"flakyRate": 0.0254
},
"tests/e2e/building-blocks/credentials.spec.ts": {
"avgDuration": 65277,
"testCount": 6,
"flakyRate": 0.0084
},
"tests/e2e/workflows/editor/execution/logs.spec.ts": {
"avgDuration": 64068,
"testCount": 8,
"flakyRate": 0.0226
},
"tests/e2e/cloud/cloud.spec.ts": {
"avgDuration": 63135,
"testCount": 3,
"flakyRate": 0.0294
},
"tests/e2e/workflows/editor/ndv/pinning.spec.ts": {
"avgDuration": 62923,
"testCount": 10,
"flakyRate": 0.0112
},
"tests/e2e/ai/hitl-for-tools.spec.ts": {
"avgDuration": 60802,
"testCount": 2,
"flakyRate": 0.0044
},
"tests/e2e/workflows/editor/expressions/mapping.spec.ts": {
"avgDuration": 60442,
"testCount": 10,
"flakyRate": 0.0014
},
"tests/e2e/projects/project-settings.spec.ts": {
"avgDuration": 60360,
"testCount": 8,
"flakyRate": 0
},
"tests/e2e/nodes/form-trigger-node.spec.ts": {
"avgDuration": 59348,
"testCount": 5,
"flakyRate": 0.0056
},
"tests/e2e/workflows/editor/subworkflows/extraction.spec.ts": {
"avgDuration": 59314,
"testCount": 3,
"flakyRate": 0.0014
},
"tests/e2e/workflows/templates/credentials-setup.spec.ts": {
"avgDuration": 56552,
"testCount": 8,
"flakyRate": 0.0098
},
"tests/e2e/workflows/editor/expressions/modal.spec.ts": {
"avgDuration": 55733,
"testCount": 6,
"flakyRate": 0.0141
},
"tests/e2e/workflows/editor/routing.spec.ts": {
"avgDuration": 52723,
"testCount": 6,
"flakyRate": 0.0281
},
"tests/e2e/workflows/editor/expressions/inline.spec.ts": {
"avgDuration": 51958,
"testCount": 7,
"flakyRate": 0.0521
},
"tests/e2e/ai/assistant-credential-help.spec.ts": {
"avgDuration": 51745,
"testCount": 4,
"flakyRate": 0.0042
},
"tests/e2e/chat-hub/chat-hub-attachment.spec.ts": {
"avgDuration": 50885,
"testCount": 3,
"flakyRate": 0.1351
},
"tests/e2e/projects/folders-basic.spec.ts": {
"avgDuration": 50217,
"testCount": 11,
"flakyRate": 0.007
},
"tests/e2e/ai/assistant-code-help.spec.ts": {
"avgDuration": 50056,
"testCount": 2,
"flakyRate": 0.0127
},
"tests/e2e/ai/assistant-support-chat.spec.ts": {
"avgDuration": 48597,
"testCount": 3,
"flakyRate": 0.0085
},
"tests/e2e/building-blocks/canvas-actions.spec.ts": {
"avgDuration": 47884,
"testCount": 9,
"flakyRate": 0.0028
},
"tests/e2e/workflows/editor/ndv/paired-item.spec.ts": {
"avgDuration": 47692,
"testCount": 5,
"flakyRate": 0.007
},
"tests/e2e/chat-hub/chat-hub-workflow-agent.spec.ts": {
"avgDuration": 47588,
"testCount": 2,
"flakyRate": 0.2061
},
"tests/e2e/workflows/editor/expressions/transformation.spec.ts": {
"avgDuration": 47442,
"testCount": 6,
"flakyRate": 0
},
"tests/e2e/workflows/editor/ndv/resource-locator.spec.ts": {
"avgDuration": 46601,
"testCount": 7,
"flakyRate": 0
},
"tests/e2e/workflows/checklist/production-checklist.spec.ts": {
"avgDuration": 44366,
"testCount": 7,
"flakyRate": 0.0028
},
"tests/e2e/settings/log-streaming/log-streaming-observability.spec.ts": {
"avgDuration": 43991,
"testCount": 2,
"flakyRate": 0
},
"tests/e2e/projects/folders-advanced.spec.ts": {
"avgDuration": 43435,
"testCount": 6,
"flakyRate": 0
},
"tests/e2e/workflows/editor/viewer-permissions.spec.ts": {
"avgDuration": 43417,
"testCount": 3,
"flakyRate": 0.0028
},
"tests/e2e/workflows/editor/tags.spec.ts": {
"avgDuration": 42899,
"testCount": 7,
"flakyRate": 0.0155
},
"tests/e2e/workflows/templates/templates.spec.ts": {
"avgDuration": 42575,
"testCount": 9,
"flakyRate": 0.14
},
"tests/e2e/workflows/editor/workflow-actions/publish.spec.ts": {
"avgDuration": 41405,
"testCount": 7,
"flakyRate": 0.0119
},
"tests/e2e/nodes/kafka-nodes.spec.ts": {
"avgDuration": 40956,
"testCount": 2,
"flakyRate": 0.0021
},
"tests/e2e/ai/workflow-builder.spec.ts": {
"avgDuration": 40449,
"testCount": 5,
"flakyRate": 0.0084
},
"tests/e2e/workflows/editor/subworkflows/workflow-selector.spec.ts": {
"avgDuration": 39594,
"testCount": 5,
"flakyRate": 0.0028
},
"tests/e2e/app-config/demo.spec.ts": {
"avgDuration": 38950,
"testCount": 4,
"flakyRate": 0.1353
},
"tests/e2e/workflows/editor/ndv/ndv-floating-nodes.spec.ts": {
"avgDuration": 37757,
"testCount": 4,
"flakyRate": 0
},
"tests/e2e/ai/rag-callout.spec.ts": {
"avgDuration": 35912,
"testCount": 2,
"flakyRate": 0.0113
},
"tests/e2e/settings/personal/personal.spec.ts": {
"avgDuration": 35307,
"testCount": 2,
"flakyRate": 0.007
},
"tests/e2e/chat-hub/chat-hub-basic.spec.ts": {
"avgDuration": 32409,
"testCount": 3,
"flakyRate": 0.1407
},
"tests/e2e/api/webhook-external.spec.ts": {
"avgDuration": 30636,
"testCount": 2,
"flakyRate": 0.0113
},
"tests/e2e/ai/langchain-vectorstores.spec.ts": {
"avgDuration": 30502,
"testCount": 2,
"flakyRate": 0.0292
},
"tests/e2e/ai/langchain-chains.spec.ts": {
"avgDuration": 29786,
"testCount": 4,
"flakyRate": 0
},
"tests/e2e/app-config/security-notifications.spec.ts": {
"avgDuration": 29324,
"testCount": 5,
"flakyRate": 0.0127
},
"tests/e2e/workflows/editor/ndv/resource-mapper.spec.ts": {
"avgDuration": 29269,
"testCount": 4,
"flakyRate": 0
},
"tests/e2e/capabilities/task-runner.spec.ts": {
"avgDuration": 28582,
"testCount": 2,
"flakyRate": 0
},
"tests/e2e/auth/password-reset.spec.ts": {
"avgDuration": 28574,
"testCount": 1,
"flakyRate": 0.0084
},
"tests/e2e/credentials/global.spec.ts": {
"avgDuration": 28384,
"testCount": 5,
"flakyRate": 0
},
"tests/e2e/chat-hub/chat-hub-settings.spec.ts": {
"avgDuration": 27248,
"testCount": 2,
"flakyRate": 0.1281
},
"tests/e2e/settings/environments/variables.spec.ts": {
"avgDuration": 27115,
"testCount": 7,
"flakyRate": 0.0028
},
"tests/e2e/nodes/community-nodes.spec.ts": {
"avgDuration": 26771,
"testCount": 3,
"flakyRate": 0
},
"tests/e2e/workflows/editor/editor-after-route-changes.spec.ts": {
"avgDuration": 26201,
"testCount": 1,
"flakyRate": 0.0014
},
"tests/e2e/node-creator/special-nodes.spec.ts": {
"avgDuration": 25945,
"testCount": 3,
"flakyRate": 0.0042
},
"tests/e2e/workflows/list/import.spec.ts": {
"avgDuration": 25716,
"testCount": 5,
"flakyRate": 0.0014
},
"tests/e2e/workflows/editor/subworkflows/debugging.spec.ts": {
"avgDuration": 25072,
"testCount": 4,
"flakyRate": 0
},
"tests/e2e/node-creator/categories.spec.ts": {
"avgDuration": 24567,
"testCount": 5,
"flakyRate": 0
},
"tests/e2e/app-config/env-feature-flags.spec.ts": {
"avgDuration": 24294,
"testCount": 2,
"flakyRate": 0.0042
},
"tests/e2e/settings/log-streaming/log-streaming.spec.ts": {
"avgDuration": 23845,
"testCount": 5,
"flakyRate": 0
},
"tests/e2e/auth/signin.spec.ts": {
"avgDuration": 23513,
"testCount": 1,
"flakyRate": 0.0056
},
"tests/e2e/node-creator/actions.spec.ts": {
"avgDuration": 22680,
"testCount": 4,
"flakyRate": 0
},
"tests/e2e/settings/users/users.spec.ts": {
"avgDuration": 22427,
"testCount": 5,
"flakyRate": 0.0395
},
"tests/e2e/workflows/editor/execution/inject-previous.spec.ts": {
"avgDuration": 22203,
"testCount": 2,
"flakyRate": 0.0028
},
"tests/e2e/building-blocks/user-service.spec.ts": {
"avgDuration": 21803,
"testCount": 8,
"flakyRate": 0.0042
},
"tests/e2e/node-creator/navigation.spec.ts": {
"avgDuration": 21589,
"testCount": 4,
"flakyRate": 0
},
"tests/e2e/workflows/demo-diff.spec.ts": {
"avgDuration": 21489,
"testCount": 9,
"flakyRate": 0
},
"tests/e2e/sharing/access-control.spec.ts": {
"avgDuration": 21465,
"testCount": 5,
"flakyRate": 0.0026
},
"tests/e2e/building-blocks/workflow-entry-points.spec.ts": {
"avgDuration": 21451,
"testCount": 5,
"flakyRate": 0.0014
},
"tests/e2e/auth/admin-smoke.spec.ts": {
"avgDuration": 20234,
"testCount": 1,
"flakyRate": 0.0085
},
"tests/e2e/credentials/oauth.spec.ts": {
"avgDuration": 19947,
"testCount": 1,
"flakyRate": 0.007
},
"tests/e2e/nodes/if-node.spec.ts": {
"avgDuration": 18980,
"testCount": 2,
"flakyRate": 0.0295
},
"tests/e2e/app-config/versions.spec.ts": {
"avgDuration": 18809,
"testCount": 1,
"flakyRate": 0.0042
},
"tests/e2e/chat-hub/chat-hub-personal-agent.spec.ts": {
"avgDuration": 18451,
"testCount": 2,
"flakyRate": 0.0669
},
"tests/e2e/node-creator/vector-stores.spec.ts": {
"avgDuration": 18443,
"testCount": 3,
"flakyRate": 0
},
"tests/e2e/sharing/workflow-sharing.spec.ts": {
"avgDuration": 16920,
"testCount": 4,
"flakyRate": 0
},
"tests/e2e/workflows/editor/ndv/io-filter.spec.ts": {
"avgDuration": 16245,
"testCount": 2,
"flakyRate": 0.0028
},
"tests/e2e/chat-hub/chat-hub-tools.spec.ts": {
"avgDuration": 16206,
"testCount": 1,
"flakyRate": 0.1574
},
"tests/e2e/nodes/pdf-node.spec.ts": {
"avgDuration": 15332,
"testCount": 1,
"flakyRate": 0.0698
},
"tests/e2e/ai/chat-session.spec.ts": {
"avgDuration": 15262,
"testCount": 1,
"flakyRate": 0.0042
},
"tests/e2e/regression/ADO-4462-template-setup-experiment.spec.ts": {
"avgDuration": 14918,
"testCount": 2,
"flakyRate": 0.0014
},
"tests/e2e/auth/authenticated.spec.ts": {
"avgDuration": 14826,
"testCount": 5,
"flakyRate": 0.0014
},
"tests/e2e/sharing/credential-sharing.spec.ts": {
"avgDuration": 14707,
"testCount": 3,
"flakyRate": 0.0026
},
"tests/e2e/capabilities/proxy-server.spec.ts": {
"avgDuration": 14632,
"testCount": 4,
"flakyRate": 0.0042
},
"tests/e2e/regression/SUG-121-fields-reset-after-closing-ndv.spec.ts": {
"avgDuration": 14359,
"testCount": 1,
"flakyRate": 0.0056
},
"tests/e2e/nodes/http-request-node.spec.ts": {
"avgDuration": 14147,
"testCount": 2,
"flakyRate": 0.0028
},
"tests/e2e/credentials/api-operations.spec.ts": {
"avgDuration": 14117,
"testCount": 5,
"flakyRate": 0.0028
},
"tests/e2e/workflows/editor/execution/partial.spec.ts": {
"avgDuration": 13936,
"testCount": 2,
"flakyRate": 0
},
"tests/e2e/regression/AI-812-partial-execs-broken-when-using-chat-trigger.spec.ts": {
"avgDuration": 12303,
"testCount": 2,
"flakyRate": 0.0014
},
"tests/e2e/regression/ADO-2372-prevent-clipping-params.spec.ts": {
"avgDuration": 11609,
"testCount": 2,
"flakyRate": 0
},
"tests/e2e/settings/workers/workers.spec.ts": {
"avgDuration": 11295,
"testCount": 4,
"flakyRate": 0.0028
},
"tests/e2e/api/webhook-isolation.spec.ts": {
"avgDuration": 10999,
"testCount": 14,
"flakyRate": 0.0058
},
"tests/e2e/node-creator/workflows.spec.ts": {
"avgDuration": 10812,
"testCount": 2,
"flakyRate": 0
},
"tests/e2e/regression/ADO-1338-ndv-missing-input-panel.spec.ts": {
"avgDuration": 10370,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/nodes/schedule-trigger-node.spec.ts": {
"avgDuration": 9491,
"testCount": 1,
"flakyRate": 0.0014
},
"tests/e2e/workflows/editor/ndv/schema-preview.spec.ts": {
"avgDuration": 7783,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/regression/AI-1401-sub-nodes-input-panel.spec.ts": {
"avgDuration": 7634,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/regression/CAT-726-canvas-node-connectors-not-rendered-when-nodes-inserted.spec.ts": {
"avgDuration": 7430,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/regression/SUG-38-inline-expression-preview.spec.ts": {
"avgDuration": 7227,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/regression/AI-716-correctly-set-up-agent-model-shows-error.spec.ts": {
"avgDuration": 7121,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/nodes/email-send-node.spec.ts": {
"avgDuration": 7118,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/regression/PAY-4367-node-shifting-cyclic.spec.ts": {
"avgDuration": 6401,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/regression/ADO-2230-ndv-reset-data-pagination.spec.ts": {
"avgDuration": 5967,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/settings/community-nodes/community-nodes.spec.ts": {
"avgDuration": 5920,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/workflows/editor/canvas/focus-panel.spec.ts": {
"avgDuration": 5401,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/workflows/editor/canvas/stickies.spec.ts": {
"avgDuration": 5297,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/regression/ADO-2929-can-load-old-switch-node-workflows.spec.ts": {
"avgDuration": 5170,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/settings/log-streaming/log-streaming-ui-e2e.spec.ts": {
"avgDuration": 5046,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/chat-hub/chat-hub-chat-user.spec.ts": {
"avgDuration": 4983,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/workflows/editor/subworkflows/wait.spec.ts": {
"avgDuration": 4186,
"testCount": 4,
"flakyRate": 0.0126
},
"tests/e2e/workflows/editor/subworkflows/subworkflow-version-resolution.spec.ts": {
"avgDuration": 1793,
"testCount": 4,
"flakyRate": 0
},
"tests/e2e/ai/langchain-memory.spec.ts": {
"avgDuration": 60000,
"testCount": 2,
"flakyRate": 0
},
"tests/e2e/source-control/push.spec.ts": {
"avgDuration": 60000,
"testCount": 4,
"flakyRate": 0
},
"tests/e2e/app-config/nps-survey.spec.ts": {
"avgDuration": 60000,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/settings/environments/source-control.spec.ts": {
"avgDuration": 60000,
"testCount": 4,
"flakyRate": 0
},
"tests/e2e/workflows/editor/workflow-actions/duplicate.spec.ts": {
"avgDuration": 60000,
"testCount": 2,
"flakyRate": 0
},
"tests/e2e/ai/evaluations.spec.ts": {
"avgDuration": 60000,
"testCount": 1,
"flakyRate": 0
},
"tests/e2e/workflows/editor/workflow-actions/settings.spec.ts": {
"avgDuration": 60000,
"testCount": 3,
"flakyRate": 0
},
"tests/e2e/ai/langchain-tools.spec.ts": {
"avgDuration": 60000,
"testCount": 2,
"flakyRate": 0
},
"tests/e2e/workflows/editor/workflow-actions/copy-paste.spec.ts": {
"avgDuration": 60000,
"testCount": 3,
"flakyRate": 0
},
"tests/e2e/source-control/pull.spec.ts": {
"avgDuration": 60000,
"testCount": 2,
"flakyRate": 0
},
"tests/e2e/workflows/editor/workflow-actions/run.spec.ts": {
"avgDuration": 60000,
"testCount": 4,
"flakyRate": 0
},
"tests/e2e/workflows/editor/workflow-actions/archive.spec.ts": {
"avgDuration": 60000,
"testCount": 7,
"flakyRate": 0
},
"tests/e2e/workflows/editor/execution/previous-nodes.spec.ts": {
"avgDuration": 60000,
"testCount": 1,
"flakyRate": 0
}
}
}

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

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

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

@@ -0,0 +1,188 @@
name: 'CI: Pull Requests (Build, Test, Lint)'
on:
pull_request:
merge_group:
concurrency:
group: ci-${{ github.event.pull_request.number || github.event.merge_group.head_sha || github.ref }}
cancel-in-progress: true
jobs:
install-and-build:
name: Install & Build
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }}
env:
NODE_OPTIONS: '--max-old-space-size=6144'
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
BUILD_STATS_WEBHOOK_URL: ${{ secrets.BUILD_STATS_WEBHOOK_URL }}
BUILD_STATS_WEBHOOK_USER: ${{ secrets.BUILD_STATS_WEBHOOK_USER }}
BUILD_STATS_WEBHOOK_PASSWORD: ${{ secrets.BUILD_STATS_WEBHOOK_PASSWORD }}
outputs:
ci: ${{ fromJSON(steps.ci-filter.outputs.results).ci == true }}
unit: ${{ fromJSON(steps.ci-filter.outputs.results).unit == true }}
e2e: ${{ fromJSON(steps.ci-filter.outputs.results).e2e == true }}
workflows: ${{ fromJSON(steps.ci-filter.outputs.results).workflows == true }}
db: ${{ fromJSON(steps.ci-filter.outputs.results).db == true }}
design_system: ${{ fromJSON(steps.ci-filter.outputs.results)['design-system'] == true }}
performance: ${{ fromJSON(steps.ci-filter.outputs.results).performance == true }}
commit_sha: ${{ steps.commit-sha.outputs.sha }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
# Use merge_group SHA when in merge queue, otherwise PR merge ref
ref: ${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || format('refs/pull/{0}/merge', github.event.pull_request.number) }}
- name: Capture commit SHA for cache consistency
id: commit-sha
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Check for relevant changes
uses: ./.github/actions/ci-filter
id: ci-filter
with:
mode: filter
filters: |
ci:
**
!packages/@n8n/task-runner-python/**
!.github/**
unit:
**
!packages/@n8n/task-runner-python/**
!packages/testing/playwright/**
!.github/**
e2e:
.github/workflows/test-e2e-*.yml
.github/scripts/cleanup-ghcr-images.mjs
packages/testing/playwright/**
packages/testing/containers/**
workflows: .github/**
design-system:
packages/frontend/@n8n/design-system/**
packages/frontend/@n8n/chat/**
packages/frontend/@n8n/storybook/**
.github/workflows/test-visual-chromatic.yml
performance:
packages/testing/performance/**
packages/workflow/src/**
.github/workflows/test-bench-reusable.yml
db:
packages/cli/src/databases/**
packages/cli/src/modules/*/database/**
packages/cli/src/modules/**/*.entity.ts
packages/cli/src/modules/**/*.repository.ts
packages/cli/test/integration/**
packages/cli/test/migration/**
packages/cli/test/shared/db/**
packages/@n8n/db/**
packages/cli/**/__tests__/**
packages/testing/containers/services/postgres.ts
.github/workflows/test-db-reusable.yml
- name: Setup and Build
if: fromJSON(steps.ci-filter.outputs.results).ci
uses: ./.github/actions/setup-nodejs
- name: Run format check
if: fromJSON(steps.ci-filter.outputs.results).ci
run: pnpm format:check
unit-test:
name: Unit tests
if: needs.install-and-build.outputs.unit == 'true'
uses: ./.github/workflows/test-unit-reusable.yml
needs: install-and-build
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
collectCoverage: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
BUILD_STATS_WEBHOOK_URL: ${{ secrets.BUILD_STATS_WEBHOOK_URL }}
BUILD_STATS_WEBHOOK_USER: ${{ secrets.BUILD_STATS_WEBHOOK_USER }}
BUILD_STATS_WEBHOOK_PASSWORD: ${{ secrets.BUILD_STATS_WEBHOOK_PASSWORD }}
typecheck:
name: Typecheck
if: needs.install-and-build.outputs.ci == 'true'
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
needs: install-and-build
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm typecheck
lint:
name: Lint
if: needs.install-and-build.outputs.ci == 'true'
uses: ./.github/workflows/test-linting-reusable.yml
needs: install-and-build
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
e2e-tests:
name: E2E Tests
needs: install-and-build
if: (needs.install-and-build.outputs.ci == 'true' || needs.install-and-build.outputs.e2e == 'true') && github.repository == 'n8n-io/n8n'
uses: ./.github/workflows/test-e2e-ci-reusable.yml
with:
branch: ${{ needs.install-and-build.outputs.commit_sha }}
playwright-only: ${{ needs.install-and-build.outputs.e2e == 'true' && needs.install-and-build.outputs.unit == 'false' }}
secrets: inherit
db-tests:
name: DB Tests
needs: install-and-build
if: needs.install-and-build.outputs.db == 'true'
uses: ./.github/workflows/test-db-reusable.yml
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
performance:
name: Performance
needs: install-and-build
if: needs.install-and-build.outputs.performance == 'true' && github.event_name != 'merge_group'
uses: ./.github/workflows/test-bench-reusable.yml
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
security-checks:
name: Security Checks
needs: install-and-build
if: needs.install-and-build.outputs.workflows == 'true'
uses: ./.github/workflows/sec-ci-reusable.yml
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
secrets: inherit
chromatic:
name: Chromatic
needs: install-and-build
if: needs.install-and-build.outputs.design_system == 'true'
uses: ./.github/workflows/test-visual-chromatic.yml
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
secrets: inherit
# This job is required by GitHub branch protection rules.
# PRs cannot be merged unless this job passes.
required-checks:
name: Required Checks
needs: [install-and-build, unit-test, typecheck, lint, e2e-tests, db-tests, performance, security-checks, chromatic]
if: always()
runs-on: ubuntu-slim
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
sparse-checkout: .github/actions/ci-filter
sparse-checkout-cone-mode: false
- name: Validate required checks
uses: ./.github/actions/ci-filter
with:
mode: validate
job-results: ${{ toJSON(needs) }}

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

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,153 @@
name: 'Test: E2E CI'
on:
workflow_call:
inputs:
branch:
description: 'GitHub branch/ref to test'
required: false
type: string
default: ''
playwright-only:
description: 'Only Playwright files changed — run impacted tests only'
required: false
type: boolean
default: false
env:
DOCKER_IMAGE: ghcr.io/${{ github.repository }}:ci-${{ github.run_id }}
jobs:
prepare:
name: 'Prepare E2E'
if: ${{ !github.event.pull_request.head.repo.fork }}
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
packages: write
contents: read
outputs:
matrix: ${{ steps.generate-matrix.outputs.matrix }}
skip-tests: ${{ steps.generate-matrix.outputs.skip-tests }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.branch || github.ref }}
fetch-depth: 1
- name: Login to GHCR
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push to GHCR
uses: ./.github/actions/setup-nodejs
with:
build-command: 'pnpm build:docker'
enable-docker-cache: true
env:
INCLUDE_TEST_CONTROLLER: 'true'
IMAGE_BASE_NAME: ghcr.io/${{ github.repository }}
IMAGE_TAG: ci-${{ github.run_id }}
RUNNERS_IMAGE_BASE_NAME: ghcr.io/${{ github.repository_owner }}/runners
BUILD_STATS_WEBHOOK_URL: ${{ secrets.BUILD_STATS_WEBHOOK_URL }}
BUILD_STATS_WEBHOOK_USER: ${{ secrets.BUILD_STATS_WEBHOOK_USER }}
BUILD_STATS_WEBHOOK_PASSWORD: ${{ secrets.BUILD_STATS_WEBHOOK_PASSWORD }}
DOCKER_STATS_WEBHOOK_URL: ${{ secrets.DOCKER_STATS_WEBHOOK_URL }}
- name: Get changed files for impact analysis
if: ${{ inputs.playwright-only }}
id: changed-files
run: |
git fetch --depth=1 origin ${{ github.event.pull_request.base.ref || 'master' }}
echo "list=$(git diff --name-only FETCH_HEAD HEAD | tr '\n' ',' | sed 's/,$//')" >> "$GITHUB_OUTPUT"
- name: Generate shard matrix
id: generate-matrix
env:
CHANGED_FILES: ${{ steps.changed-files.outputs.list }}
run: |
ARGS=(--matrix 16 --orchestrate)
if [[ "${{ inputs.playwright-only }}" == "true" ]]; then
ARGS+=(--impact "--files=$CHANGED_FILES")
fi
MATRIX=$(node packages/testing/playwright/scripts/distribute-tests.mjs "${ARGS[@]}")
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
echo "skip-tests=$(node -e "process.stdout.write(JSON.parse(process.argv[1])[0]?.skip === true ? 'true' : 'false')" "$MATRIX")" >> "$GITHUB_OUTPUT"
sqlite-sanity:
needs: [prepare]
name: 'SQLite: Sanity Check'
if: ${{ !github.event.pull_request.head.repo.fork }}
uses: ./.github/workflows/test-e2e-reusable.yml
with:
branch: ${{ inputs.branch }}
test-mode: docker-pull
docker-image: ghcr.io/${{ github.repository }}:ci-${{ github.run_id }}
test-command: pnpm --filter=n8n-playwright test:container:sqlite:e2e tests/e2e/building-blocks/workflow-entry-points.spec.ts
shards: 1
runner: blacksmith-2vcpu-ubuntu-2204
workers: '1'
pre-generated-matrix: '[{"shard":1,"images":""}]'
# Multi-main: postgres + redis + caddy + 2 mains + 1 worker
# Only runs for internal PRs (not community/fork PRs)
# Pulls pre-built Docker image from GHCR
multi-main-e2e:
needs: [prepare]
name: 'Multi-Main: E2E'
if: ${{ !github.event.pull_request.head.repo.fork && needs.prepare.outputs.skip-tests != 'true' }}
uses: ./.github/workflows/test-e2e-reusable.yml
with:
branch: ${{ inputs.branch }}
test-mode: docker-pull
docker-image: ghcr.io/${{ github.repository }}:ci-${{ github.run_id }}
test-command: pnpm --filter=n8n-playwright test:container:multi-main:e2e
shards: 16
runner: blacksmith-2vcpu-ubuntu-2204
workers: '1'
use-custom-orchestration: true
pre-generated-matrix: ${{ needs.prepare.outputs.matrix }}
secrets: inherit
# Community PR tests: Local mode with SQLite (no container building, no secrets required)
# Runs on GitHub-hosted runners without Currents reporting
community-e2e:
name: 'Community: E2E'
if: ${{ github.event.pull_request.head.repo.fork }}
uses: ./.github/workflows/test-e2e-reusable.yml
with:
branch: ${{ inputs.branch }}
test-mode: local
test-command: pnpm --filter=n8n-playwright test:local:e2e-only
shards: 7
runner: ubuntu-latest
workers: '1'
upload-failure-artifacts: true
# Cleanup ephemeral Docker image from GHCR after tests complete
# Local runner cleanup is handled by each test shard in test-e2e-reusable.yml
cleanup-ghcr:
name: 'Cleanup GHCR Image'
needs: [prepare, multi-main-e2e, sqlite-sanity]
if: ${{ !failure() && !cancelled() && !github.event.pull_request.head.repo.fork }}
runs-on: blacksmith-2vcpu-ubuntu-2204
permissions:
packages: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Delete images from GHCR
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GHCR_ORG: ${{ github.repository_owner }}
GHCR_REPO: ${{ github.event.repository.name }}
run: node .github/scripts/cleanup-ghcr-images.mjs --tag ci-${{ github.run_id }}

Some files were not shown because too many files have changed in this diff Show More