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
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:
5
.actrc
Normal file
5
.actrc
Normal 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
63
.claude/README.md
Normal 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)
|
||||
40
.claude/agents/n8n-developer.md
Normal file
40
.claude/agents/n8n-developer.md
Normal 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.
|
||||
74
.claude/agents/n8n-linear-issue-triager.md
Normal file
74
.claude/agents/n8n-linear-issue-triager.md
Normal 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.
|
||||
18
.claude/commands/n8n-plan.md
Normal file
18
.claude/commands/n8n-plan.md
Normal 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.
|
||||
7
.claude/commands/n8n-triage.md
Normal file
7
.claude/commands/n8n-triage.md
Normal 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
8
.claude/mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"linear": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "https://mcp.linear.app/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
16
.claude/settings.json
Normal file
16
.claude/settings.json
Normal 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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
330
.claude/skills/content-design/SKILL.md
Normal file
330
.claude/skills/content-design/SKILL.md
Normal 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
|
||||
154
.claude/skills/create-pr/SKILL.md
Normal file
154
.claude/skills/create-pr/SKILL.md
Normal 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
|
||||
175
.claude/skills/linear-issue/SKILL.md
Normal file
175
.claude/skills/linear-issue/SKILL.md
Normal 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 ``
|
||||
- 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.4–0.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
|
||||
105
.claude/skills/loom-transcript/SKILL.md
Normal file
105
.claude/skills/loom-transcript/SKILL.md
Normal 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.
|
||||
98
.claude/skills/n8n-conventions/SKILL.md
Normal file
98
.claude/skills/n8n-conventions/SKILL.md
Normal 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`
|
||||
136
.claude/skills/reproduce-bug/SKILL.md
Normal file
136
.claude/skills/reproduce-bug/SKILL.md
Normal 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
9
.devcontainer/Dockerfile
Normal 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
|
||||
19
.devcontainer/devcontainer.json
Normal file
19
.devcontainer/devcontainer.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
24
.devcontainer/docker-compose.yml
Normal file
24
.devcontainer/docker-compose.yml
Normal 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
40
.dockerignore
Normal 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
20
.editorconfig
Normal 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
18
.git-blame-ignore-revs
Normal 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
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
56
.github/CI-TELEMETRY.md
vendored
Normal file
56
.github/CI-TELEMETRY.md
vendored
Normal 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
42
.github/CLAUDE.md
vendored
Normal 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
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
packages/@n8n/db/src/migrations/ @n8n-io/migrations-review
|
||||
103
.github/ISSUE_TEMPLATE/01-bug.yml
vendored
Normal file
103
.github/ISSUE_TEMPLATE/01-bug.yml
vendored
Normal 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
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
675
.github/WORKFLOWS.md
vendored
Normal 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
8
.github/actionlint.yml
vendored
Normal 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
|
||||
235
.github/actions/ci-filter/__tests__/ci-filter.test.ts
vendored
Normal file
235
.github/actions/ci-filter/__tests__/ci-filter.test.ts
vendored
Normal 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
39
.github/actions/ci-filter/action.yml
vendored
Normal 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
216
.github/actions/ci-filter/ci-filter.mjs
vendored
Normal 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".`);
|
||||
}
|
||||
}
|
||||
51
.github/actions/docker-registry-login/action.yml
vendored
Normal file
51
.github/actions/docker-registry-login/action.yml
vendored
Normal 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
78
.github/actions/setup-nodejs/action.yml
vendored
Normal 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
119
.github/claude-templates/e2e-test.md
vendored
Normal 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
179
.github/claude-templates/security-fix.md
vendored
Normal 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
12
.github/docker-compose.yml
vendored
Normal 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
|
||||
43
.github/poutine-rules/unpinned_action.rego
vendored
Normal file
43
.github/poutine-rules/unpinned_action.rego
vendored
Normal 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
29
.github/pull_request_template.md
vendored
Normal 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)
|
||||
116
.github/pull_request_title_conventions.md
vendored
Normal file
116
.github/pull_request_title_conventions.md
vendored
Normal 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
74
.github/scripts/bump-versions.mjs
vendored
Normal 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);
|
||||
68
.github/scripts/claude-task/prepare-claude-prompt.mjs
vendored
Normal file
68
.github/scripts/claude-task/prepare-claude-prompt.mjs
vendored
Normal 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`);
|
||||
|
||||
59
.github/scripts/claude-task/resume-callback.mjs
vendored
Normal file
59
.github/scripts/claude-task/resume-callback.mjs
vendored
Normal 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
157
.github/scripts/cleanup-ghcr-images.mjs
vendored
Normal 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);
|
||||
123
.github/scripts/cleanup-release-branch.mjs
vendored
Normal file
123
.github/scripts/cleanup-release-branch.mjs
vendored
Normal 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();
|
||||
}
|
||||
147
.github/scripts/cleanup-release-branch.test.mjs
vendored
Normal file
147
.github/scripts/cleanup-release-branch.test.mjs
vendored
Normal 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(', ')})`,
|
||||
);
|
||||
});
|
||||
});
|
||||
74
.github/scripts/compute-backport-targets.mjs
vendored
Normal file
74
.github/scripts/compute-backport-targets.mjs
vendored
Normal 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();
|
||||
}
|
||||
62
.github/scripts/compute-backport-targets.test.mjs
vendored
Normal file
62
.github/scripts/compute-backport-targets.test.mjs
vendored
Normal 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
91
.github/scripts/detect-new-packages.mjs
vendored
Normal 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);
|
||||
122
.github/scripts/determine-version-info.mjs
vendored
Normal file
122
.github/scripts/determine-version-info.mjs
vendored
Normal 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);
|
||||
}
|
||||
91
.github/scripts/determine-version-info.test.mjs
vendored
Normal file
91
.github/scripts/determine-version-info.test.mjs
vendored
Normal 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
177
.github/scripts/docker/docker-config.mjs
vendored
Normal 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
113
.github/scripts/docker/docker-tags.mjs
vendored
Normal 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;
|
||||
63
.github/scripts/docker/get-manifest-digests.mjs
vendored
Normal file
63
.github/scripts/docker/get-manifest-digests.mjs
vendored
Normal 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'}`);
|
||||
44
.github/scripts/ensure-provenance-fields.mjs
vendored
Normal file
44
.github/scripts/ensure-provenance-fields.mjs
vendored
Normal 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');
|
||||
}
|
||||
156
.github/scripts/ensure-release-candidate-branches.mjs
vendored
Normal file
156
.github/scripts/ensure-release-candidate-branches.mjs
vendored
Normal 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();
|
||||
}
|
||||
62
.github/scripts/ensure-release-candidate-branches.test.mjs
vendored
Normal file
62
.github/scripts/ensure-release-candidate-branches.test.mjs
vendored
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
63
.github/scripts/get-release-versions.mjs
vendored
Normal file
63
.github/scripts/get-release-versions.mjs
vendored
Normal 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
249
.github/scripts/github-helpers.mjs
vendored
Normal 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
10
.github/scripts/jsconfig.json
vendored
Normal 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
18
.github/scripts/move-track-tag.mjs
vendored
Normal 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
13
.github/scripts/package.json
vendored
Normal 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
62
.github/scripts/plan-release.mjs
vendored
Normal 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
104
.github/scripts/send-build-stats.mjs
vendored
Normal 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
104
.github/scripts/send-docker-stats.mjs
vendored
Normal 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
18
.github/scripts/trim-fe-packageJson.js
vendored
Normal 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
40
.github/scripts/update-changelog.mjs
vendored
Normal 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
90
.github/scripts/validate-docs-links.js
vendored
Normal 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
732
.github/test-metrics/playwright.json
vendored
Normal 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
50
.github/workflows/backport.yml
vendored
Normal 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
71
.github/workflows/build-base-image.yml
vendored
Normal 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
|
||||
41
.github/workflows/build-benchmark-image.yml
vendored
Normal file
41
.github/workflows/build-benchmark-image.yml
vendored
Normal 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
|
||||
100
.github/workflows/build-unit-test-pr-comment.yml
vendored
Normal file
100
.github/workflows/build-unit-test-pr-comment.yml
vendored
Normal 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
96
.github/workflows/build-windows.yml
vendored
Normal 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 }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
111
.github/workflows/ci-check-eligibility-reusable.yml
vendored
Normal file
111
.github/workflows/ci-check-eligibility-reusable.yml
vendored
Normal 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
21
.github/workflows/ci-check-pr-title.yml
vendored
Normal 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 }}
|
||||
135
.github/workflows/ci-manual-unit-tests.yml
vendored
Normal file
135
.github/workflows/ci-manual-unit-tests.yml
vendored
Normal 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
66
.github/workflows/ci-master.yml
vendored
Normal 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
188
.github/workflows/ci-pull-requests.yml
vendored
Normal 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
56
.github/workflows/ci-python.yml
vendored
Normal 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
|
||||
84
.github/workflows/ci-restrict-private-merges.yml
vendored
Normal file
84
.github/workflows/ci-restrict-private-merges.yml
vendored
Normal 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 }}'"
|
||||
69
.github/workflows/create-patch-release-branch.yml
vendored
Normal file
69
.github/workflows/create-patch-release-branch.yml
vendored
Normal 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
422
.github/workflows/docker-build-push.yml
vendored
Normal 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 }}
|
||||
15
.github/workflows/release-create-minor-pr.yml
vendored
Normal file
15
.github/workflows/release-create-minor-pr.yml
vendored
Normal 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
119
.github/workflows/release-create-pr.yml
vendored
Normal 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 }}
|
||||
53
.github/workflows/release-publish-post-release.yml
vendored
Normal file
53
.github/workflows/release-publish-post-release.yml
vendored
Normal 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
213
.github/workflows/release-publish.yml
vendored
Normal 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
|
||||
161
.github/workflows/release-push-to-channel.yml
vendored
Normal file
161
.github/workflows/release-push-to-channel.yml
vendored
Normal 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'
|
||||
60
.github/workflows/release-standalone-package.yml
vendored
Normal file
60
.github/workflows/release-standalone-package.yml
vendored
Normal 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
|
||||
66
.github/workflows/release-update-pointer-tag.yml
vendored
Normal file
66
.github/workflows/release-update-pointer-tag.yml
vendored
Normal 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
|
||||
88
.github/workflows/sbom-generation-callable.yml
vendored
Normal file
88
.github/workflows/sbom-generation-callable.yml
vendored
Normal 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
23
.github/workflows/sec-ci-reusable.yml
vendored
Normal 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:
|
||||
44
.github/workflows/sec-poutine-reusable.yml
vendored
Normal file
44
.github/workflows/sec-poutine-reusable.yml
vendored
Normal 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
61
.github/workflows/sec-publish-fix.yml
vendored
Normal 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 }})'
|
||||
62
.github/workflows/sec-sync-public-to-private.yml
vendored
Normal file
62
.github/workflows/sec-sync-public-to-private.yml
vendored
Normal 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
|
||||
349
.github/workflows/security-trivy-scan-callable.yml
vendored
Normal file
349
.github/workflows/security-trivy-scan-callable.yml
vendored
Normal 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 }}
|
||||
35
.github/workflows/test-bench-reusable.yml
vendored
Normal file
35
.github/workflows/test-bench-reusable.yml
vendored
Normal 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
|
||||
39
.github/workflows/test-benchmark-destroy-nightly.yml
vendored
Normal file
39
.github/workflows/test-benchmark-destroy-nightly.yml
vendored
Normal 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
|
||||
115
.github/workflows/test-benchmark-nightly.yml
vendored
Normal file
115
.github/workflows/test-benchmark-nightly.yml
vendored
Normal 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
51
.github/workflows/test-db-reusable.yml
vendored
Normal 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 }}
|
||||
153
.github/workflows/test-e2e-ci-reusable.yml
vendored
Normal file
153
.github/workflows/test-e2e-ci-reusable.yml
vendored
Normal 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
Reference in New Issue
Block a user