Merge remote-tracking branch 'origin/master' into CharlieKolb/publish-modal-version-changelog
Some checks failed
CI: Python / Checks (push) Has been cancelled

This commit is contained in:
Charlie Kolb 2026-08-21 13:35:48 +02:00
commit e9e006ba66
No known key found for this signature in database
3324 changed files with 230574 additions and 48165 deletions

View File

@ -111,6 +111,10 @@ When suggesting new keys, follow the existing hierarchy. Browse nearby keys in
### Language and grammar
**ASD-STE100 Simplified Technical English.** Use short sentences, the active
voice, and one instruction for each sentence. Use one approved word for each
meaning.
**US English.** Always. No exceptions.
- Do: "categorizing", "color", "analyze"
- Don't: "categorising", "colour", "analyse"

View File

@ -13,6 +13,10 @@ Use this skill when you need quick reminders on critical patterns.
## Critical Rules (Must Follow)
**Technical writing (comments, PRs, issues, docs):**
- Write in ASD-STE100 Simplified Technical English: short sentences, the
active voice, one instruction for each sentence
**TypeScript:**
- Never `any` → use `unknown`
- Prefer `satisfies` over `as` (except tests)

View File

@ -73,17 +73,20 @@ Structure the description using markdown headers. Use the appropriate template:
**For features / enhancements:**
```markdown
## Summary
[One-paragraph overview of what this adds or changes]
## Goal
[What this adds and why: the user problem it solves]
## Problem
[What limitation or gap exists today]
## Background
[Current state and the gap, plus the technical context needed to plan the work: relevant constraints, prior findings, and links to any related investigation.]
## Proposed solution
[How it should work — technical approach if known]
## Scope
[Concrete list of what changes. Name the files or areas to create or modify and any existing pattern to follow.]
## Acceptance criteria
[Testable outcomes, including automated tests.]
## Out of scope
[Explicitly note what this does NOT cover, if helpful]
[What this explicitly does not cover]
```
**For tech debt:**
@ -109,13 +112,19 @@ Structure the description using markdown headers. Use the appropriate template:
```markdown
## Goal
[What question are we trying to answer]
[What question(s) are we trying to answer]
## Context
[Why this investigation is needed now]
## Questions
1. [Specific question to resolve]
## Expected output
[What deliverable is expected — RFC, PoC, decision document, etc.]
[What deliverable is expected: RFC, PoC, decision document, path matrix, etc.]
## Acceptance criteria
[How we know the spike is done: each question answered, deliverable produced]
```
#### Attachments (Screenshots / Videos)

View File

@ -34,6 +34,7 @@ Creates GitHub PRs with titles that pass n8n's `check-pr-title` CI validation.
- `benchmark` - Benchmark CLI changes
- `core` - Core/backend/private API
- `editor` - Editor UI changes
- `engine` - New workflow execution engine v2 (@n8n/engine package)
- `* Node` - Specific node (e.g., `Slack Node`, `GitHub Node`)
### Summary Rules
@ -93,6 +94,9 @@ Creates GitHub PRs with titles that pass n8n's `check-pr-title` CI validation.
Based on `.github/pull_request_template.md`:
Write the PR body in ASD-STE100 Simplified Technical English: use short
sentences, the active voice, and one instruction for each sentence.
### Summary Section
- Describe what the PR does
- Include screenshots/videos for UI changes

View File

@ -0,0 +1,891 @@
---
description: |
Manage stacked branches and pull requests with the gh-stack GitHub CLI extension. Use when the user wants to create, push, rebase, sync, navigate, or view stacks of dependent PRs. Triggers on tasks involving stacked diffs, dependent pull requests, branch chains, or incremental code review workflows.
metadata:
author: github
github-path: skills/gh-stack
github-ref: refs/tags/v0.1.0
github-repo: https://github.com/github/gh-stack
github-tree-sha: c95c8b5b4dd850f3fef007b304428f5684f2fb87
version: 0.0.9
name: n8n:gh-stack
---
# gh-stack
`gh stack` is a [GitHub CLI](https://cli.github.com/) extension for managing **stacked branches and pull requests**. A stack is an ordered list of branches where each branch builds on the one below it, rooted on a trunk branch (typically the repo's default branch). Each branch maps to one PR whose base is the branch below it, so reviewers see only the diff for that layer.
```
main (trunk)
└── auth-layer → PR #1 (base: main) - bottom (closest to trunk)
└── api-endpoints → PR #2 (base: auth-layer)
└── frontend → PR #3 (base: api-endpoints) - top (furthest from trunk)
```
The **bottom** of the stack is the branch closest to the trunk, and the **top** is the branch furthest from the trunk. Each branch inherits from the one below it. Navigation commands (`up`, `down`, `top`, `bottom`) follow this model: `up` moves away from trunk, `down` moves toward it.
## When to use this skill
Use this skill when the user wants to:
- Break a large change into a chain of small, reviewable PRs
- Create, rebase, push, or sync a stack of dependent branches
- Navigate between layers of a branch stack
- View the status of stacked PRs
- Tear down and rebuild a stack to remove, reorder, or rename branches
## Prerequisites
The GitHub CLI (`gh`) v2.0+ must be installed and authenticated. Install the extension with:
```bash
gh extension install github/gh-stack
```
Before using `gh stack`, configure git to prevent interactive prompts:
```bash
git config rerere.enabled true # remember conflict resolutions (skips prompt on init)
git config remote.pushDefault origin # if multiple remotes exist (skips remote picker)
```
## Agent rules
**All `gh stack` commands must be run non-interactively.** Every command invocation must include the flags and positional arguments needed to avoid prompts, TUIs, and interactive menus. If a command would prompt for input, it will hang indefinitely.
1. **Always supply branch names as positional arguments** to `init`, `add`, and `checkout`. Running these commands without arguments triggers interactive prompts. Branch names are used exactly as given — a name is never prefixed or transformed, so `gh stack add refactor/foo` creates a branch named `refactor/foo`.
2. **Always use `--auto` with `gh stack submit`** to auto-generate PR titles. Without `--auto`, `submit` prompts for a title for each new PR.
3. **Always use `--json` with `gh stack view`.** Without `--json`, the command launches an interactive TUI that cannot be operated by agents. There is no other appropriate flag — always pass `--json`.
4. **Handle multiple remotes.** If more than one remote is configured, pre-configure `git config remote.pushDefault origin`, or pass `--remote <name>` to the commands that accept it: `push`, `submit`, `sync`, `rebase`, and `link`. `checkout`, `modify`, and `trunk` resolve a remote but have **no `--remote` flag** — they rely on `remote.pushDefault`. With multiple remotes and no configured default, these commands exit with an error in non-interactive mode.
5. **Avoid branches shared across multiple stacks.** If a branch belongs to multiple stacks, commands exit with code 6. Check out a non-shared branch first.
6. **Plan your stack layers by dependency order before writing code.** Foundational changes (models, APIs, shared utilities) go in lower branches; dependent changes (UI, consumers) go in higher branches. Think through the dependency chain before running `gh stack init`.
7. **Use standard `git add` and `git commit` for staging and committing.** This gives you full control over which changes go into each branch. The `-Am` shortcut is available but should not be the default approach—stacked PRs are most effective when each branch contains a deliberate, logical set of changes.
8. **Navigate down the stack when you need to change a lower layer.** If you're working on a frontend branch and realize you need API changes, don't hack around it at the current layer. Navigate to the appropriate branch (`gh stack down`, `gh stack checkout`, or `gh stack bottom`), make and commit the changes there, run `gh stack rebase --upstack`, then navigate back up to continue.
9. **Use `gh stack link` for external tool workflows.** When branches are managed by an external tool (jj, Sapling, etc.), use `gh stack link branch-a branch-b`. `link` does not rely on local tracking state and is intended for API-driven PR and stack management. Provide at least two branches/PRs to create or update a stack, or a stack number followed by the new branches/PRs to append them to the top of an existing stack (e.g. `gh stack link 7 branch-c`).
10. **Use `gh stack merge --yes` to merge stacked PRs.** `gh pr merge` does not work with stacked PRs. In a non-interactive terminal `gh stack merge` runs without prompting and merges the entire stack (bottom to top) atomically; pass `--yes` to be explicit. Scope the merge by passing a pull request number (`gh stack merge 42 --yes` merges everything up to and including PR #42) or a stack number (`gh stack merge 7 --yes`, which needs no local checkout). Choose the method with `--squash`, `--rebase`, `--merge`, or `--merge-method <method>`; without one, the last-used method is used. The merge is all-or-nothing — if any PR can't be merged, none are, and the failure reason is reported. Only basic pull request state is checked before merging (open and not a draft); bypassing merge requirements is not supported for stacks. If the base branch uses a merge queue, the stack is added to the queue instead of merging directly: the queue chooses the merge method (any method you pass is ignored with a warning), and the pull requests are added to the queue together but merge as the queue processes them, so they may land in separate groups rather than all at once.
**Never do any of the following — each triggers an interactive prompt or TUI that will hang:**
- ❌ `gh stack view` or `gh stack view --short` — always use `gh stack view --json`
- ❌ `gh stack submit` without `--auto` — always use `gh stack submit --auto`
- ❌ `gh stack init` without branch arguments — always provide branch names
- ❌ `gh stack add` without a branch name — always provide a branch name
- ❌ `gh stack checkout` without an argument — always provide a PR number or branch name
- ❌ `gh stack checkout <pr-number>` when a different local stack already exists on those branches — this triggers an unbypassable conflict resolution prompt; use `gh stack unstack --local` first to remove the local tracking state (this keeps the stack on GitHub intact), then retry the checkout
## Thinking about stack structure
Each branch in a stack should represent a **discrete, logical unit of work** that can be reviewed independently. The changes within a branch should be cohesive—they belong together and make sense as a single PR.
### Dependency chain
Stacked branches form a dependency chain: each branch builds on the one below it. This means **foundational changes must go in lower (earlier) branches**, and code that depends on them goes in higher (later) branches.
**Plan your layers before writing code.** For example, a full-stack feature might be structured like this (use branch names relevant to your actual task, not these generic ones):
```
main (trunk)
└── data-models ← shared types, database schema
└── api-endpoints ← API routes that use the models
└── frontend-ui ← UI components that call the APIs
└── integration ← tests that exercise the full stack
```
This is illustrative — choose branch names and layer boundaries that reflect the specific work you're doing. The key principle is: if code in one layer depends on code in another, the dependency must be in the same branch or a lower one.
### Branch naming
Choose a clear, descriptive branch name for each layer that reflects the concern it contains (e.g., `auth`, `api-routes`, `frontend`). Branch names are used exactly as you provide them to `init` and `add` — nothing is prepended or transformed. Slashes are allowed and are treated as part of the name (e.g., `gh stack add refactor/foo` creates a branch named `refactor/foo`).
### Staging changes deliberately
The main reason to use `git add` and `git commit` directly is to control **which changes go into which branch**. When you have multiple files in your working tree, you can stage a subset for the current branch, commit them, then create a new branch and stage the rest there:
```bash
# You're on data-models with several new files in your working tree.
# Stage only the model files for this branch:
git add internal/models/user.go internal/models/session.go
git commit -m "Add user and session models"
git add db/migrations/001_create_users.sql
git commit -m "Add user table migration"
# Now create a new branch for the API layer and stage the API files there:
gh stack add api-routes # created & switched to the api-routes branch
git add internal/api/routes.go internal/api/handlers.go
git commit -m "Add user API routes"
```
This keeps each branch focused on one concern. Multiple commits per branch are fine — the key is that all commits in a branch relate to the same logical concern, and changes that belong to a different concern go in a different branch.
### When to create a new branch
Create a new branch (`gh stack add`) when you're starting a **different concern** that depends on what you've built so far. Signs it's time for a new branch:
- You're switching from backend to frontend work
- You're moving from core logic to tests or documentation
- The next set of changes has a different reviewer audience
- The current branch's PR is already large enough to review
### One stack, one story
Think of a stack from the reviewer's perspective: the stack of PRs should **tell a cohesive story** about a feature or project. A reviewer should be able to read the PRs in sequence and understand the progression of changes, with each PR being a small, logical piece of the whole.
**When to use a single stack:** All the branches are part of the same feature, project, or closely related effort. Even if the work spans multiple concerns (models, API, frontend), they're all building toward the same goal.
**When to create a separate stack:** The work is unrelated to your current stack — a different feature, a bug fix in an unrelated area, or an independent refactor. Don't mix unrelated work into a single stack just because you happen to be working on both. Start a new stack with `gh stack init` or switch to an existing stack with `gh stack checkout` for each distinct effort.
Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current stack if they're trivial. But if a change grows into its own project, it deserves its own stack.
## Quick reference
| Task | Command |
|------|---------|
| Create a stack | `gh stack init auth` |
| Create a stack of multiple branches | `gh stack init auth api frontend` |
| Adopt existing branches | `gh stack init existing-branch-a existing-branch-b` |
| Set custom trunk | `gh stack init --base develop branch-a` |
| Add a branch to stack | `gh stack add api-routes` |
| Add branch + stage all + commit | `gh stack add -Am "message" api-routes` |
| Push branches to remote | `gh stack push` |
| Push to specific remote | `gh stack push --remote origin` |
| Push branches + create draft PRs | `gh stack submit --auto` |
| Create PRs as ready for review | `gh stack submit --auto --open` |
| Sync (fetch, rebase, push) | `gh stack sync` |
| Sync with specific remote | `gh stack sync --remote origin` |
| Sync and prune merged branches | `gh stack sync --prune` |
| Rebase entire stack | `gh stack rebase` |
| Rebase upstack only | `gh stack rebase --upstack` |
| Rebase without trunk | `gh stack rebase --no-trunk` |
| Continue after conflict | `gh stack rebase --continue` |
| Abort rebase | `gh stack rebase --abort` |
| View stack details (JSON) | `gh stack view --json` |
| Switch branches up/down in stack | `gh stack up [n]` / `gh stack down [n]` |
| Switch to top/bottom branch | `gh stack top` / `gh stack bottom` |
| Check out by stack number | `gh stack checkout 7` |
| Check out by PR | `gh stack checkout 42` |
| Check out by branch (local only) | `gh stack checkout feature-auth` |
| Tear down the current stack to restructure it | `gh stack unstack` |
| Tear down a specific stack by number | `gh stack unstack 7` |
| Merge the whole current stack | `gh stack merge --yes` |
| Merge a stack by number | `gh stack merge 7 --yes` |
| Merge up to a specific PR | `gh stack merge 42 --yes` |
| Merge with a specific method | `gh stack merge --yes --squash` |
---
## Workflows
### End-to-end: create a stack from scratch
```bash
# 1. Initialize a stack with the first branch
gh stack init auth
# → creates auth and checks it out
# 2. Write code for the first layer (auth)
cat > auth.go << 'EOF'
package auth
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// verify token
next.ServeHTTP(w, r)
})
}
EOF
# 3. Stage and commit using standard git commands
git add auth.go
git commit -m "Add auth middleware"
# You can make multiple commits on the same branch
cat > auth_test.go << 'EOF'
package auth
func TestMiddleware(t *testing.T) {
// test auth middleware
}
EOF
git add auth_test.go
git commit -m "Add auth middleware tests"
# 4. When you're ready for a new concern, add the next branch
gh stack add api-routes
# → creates api-routes
# 5. Write code for the API layer
cat > api.go << 'EOF'
package api
func RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/users", handleUsers)
}
EOF
git add api.go
git commit -m "Add API routes"
# 6. Add a third layer for frontend
gh stack add frontend
# → creates frontend
cat > frontend.go << 'EOF'
package frontend
func RenderDashboard(w http.ResponseWriter) {
// calls the API endpoints from the layer below
}
EOF
git add frontend.go
git commit -m "Add frontend dashboard"
# ── Stack complete: auth → api-routes → frontend ──
# 7. Push everything and create PRs (drafts by default)
gh stack submit --auto
# 8. Verify the stack
gh stack view --json
```
> **Shortcut:** If you prefer a faster flow, `gh stack add -Am "message" branch-name` combines staging, committing, and branch creation into one command. This is useful for single-commit layers but bypasses deliberate staging.
### Making mid-stack changes
This is a critical workflow for agents. When you're working on a higher layer and realize you need to change something in a lower layer (e.g., you're building frontend components but need to add an API endpoint), **navigate down to the correct branch, make the change there, and rebase**.
```bash
# You're on frontend but need to add an API endpoint
# 1. Navigate to the API branch
gh stack down
# or: gh stack checkout api-routes
# 2. Make the change where it belongs
cat > users_api.go << 'EOF'
package api
func handleGetUser(w http.ResponseWriter, r *http.Request) {
// new endpoint the frontend needs
}
EOF
git add users_api.go
git commit -m "Add get-user endpoint"
# 3. Rebase everything above to pick up the change
gh stack rebase --upstack
# 4. Navigate back to where you were working
gh stack top
# or: gh stack checkout frontend
# 5. Continue working — the API changes are now available
```
**Why this matters:** If you make API changes on the frontend branch, those changes will end up in the wrong PR. The API PR won't include them, and the frontend PR will have unrelated API diffs mixed in. Always put changes in the branch where they logically belong.
### Modify a mid-stack branch and sync
When you need to revisit a branch after the initial creation (e.g., responding to review feedback):
```bash
# 1. Navigate to the branch that needs changes
gh stack bottom
# or: gh stack checkout auth
# or: gh stack checkout 42 (by PR number)
# 2. Make changes and commit
cat > auth.go << 'EOF'
package auth
// updated implementation
EOF
git add auth.go
git commit -m "Fix auth token validation"
# 3. Rebase everything above this branch
gh stack rebase --upstack
# 4. Push the updated stack
gh stack push
```
### Routine sync after merges
```bash
# Single command: fetch, rebase, push, sync PR and stack state
gh stack sync
# Sync and automatically clean up local branches for merged PRs
gh stack sync --prune
```
> **Note for agents:** In non-interactive environments, the prune prompt is not shown. Use `--prune` explicitly to delete local branches for merged PRs.
> **Note for agents:** `sync` also mirrors the stack on GitHub locally. If PRs were added to the stack on github.com, their branches are pulled down and appended to the local stack automatically. If the local and remote stacks have **diverged** (you changed the local stack while the remote stack changed differently), sync can only prompt to resolve it in an interactive terminal — in non-interactive environments it aborts the sync (nothing is pushed or updated) and exits successfully with `ℹ Sync aborted`. Resolve a divergence by unstacking and recreating the stack.
### Squash-merge recovery
When a PR is squash-merged on GitHub, the original branch's commits no longer exist in the trunk history. `gh stack` detects this automatically and uses `git rebase --onto` to correctly replay remaining commits.
```bash
# After PR #1 (auth) is squash-merged on GitHub:
gh stack sync
# → fetches latest, detects the merge, fast-forwards trunk
# → rebases api-routes onto updated trunk (skips merged branch)
# → rebases frontend onto api-routes
# → pushes updated branches
# → reports: "Merged: #1"
# Verify the result
gh stack view --json
# → auth shows "isMerged": true, "state": "MERGED"
# → api-routes and frontend show updated heads
```
If `sync` hits a conflict during this process, it restores all branches to their pre-rebase state and exits with code 3. See [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) for the resolution workflow.
### Handle rebase conflicts (agent workflow)
```bash
# 1. Start the rebase
gh stack rebase
# 2. If exit code 3 (conflict):
# - Parse stderr for conflicted file paths
# - Read those files to find <<<<<<< / ======= / >>>>>>> markers
# - Edit files to resolve conflicts
# - Stage resolved files:
git add path/to/resolved-file.go
# 3. Continue the rebase
gh stack rebase --continue
# 4. If another conflict occurs, repeat steps 2-3
# 5. If unable to resolve, abort to restore everything
gh stack rebase --abort
```
### Parsing `--json` output
```bash
# Get stack state as JSON
output=$(gh stack view --json)
# Check if any branch needs a rebase, and rebase if so
needs_rebase=$(echo "$output" | jq '[.branches[] | select(.needsRebase == true)] | length')
if [ "$needs_rebase" -gt 0 ]; then
echo "Branches need rebase, rebasing stack..."
gh stack rebase
fi
# Get all open PR URLs
echo "$output" | jq -r '.branches[] | select(.pr.state == "OPEN") | .pr.url'
# Find merged branches
echo "$output" | jq -r '.branches[] | select(.isMerged == true) | .name'
# Get the current branch
echo "$output" | jq -r '.currentBranch'
# Check if the stack is fully merged (all branches merged)
echo "$output" | jq '[.branches[] | .isMerged] | all'
```
### Restructure a stack (remove a branch, reorder, or rename)
Use `unstack` to tear down the stack, make structural changes, then re-init:
```bash
# 1. Remove the local tracking and the GitHub stack grouping (PRs are NOT deleted)
gh stack unstack
# 2. Make structural changes — e.g. delete a branch, reorder, rename
git branch -m old-branch-1 new-branch-1
# 3. Re-create the stack with the new structure
gh stack init --base main new-branch-1 new-branch-2 new-branch-3
```
---
## Commands
### Initialize a stack — `gh stack init`
Creates a new stack. **Always provide at least one branch name as a positional argument** — running without branch arguments triggers interactive prompts that agents cannot use.
```
gh stack init [flags] <branches...>
```
```bash
# Create a stack with a new branch
gh stack init auth
# → creates auth and checks it out
# Create a stack with new branches
gh stack init branch-a branch-b branch-c
# Use a different trunk branch
gh stack init --base develop branch-a branch-b
# Adopt existing branches into a stack (handled automatically if the branches exist)
gh stack init branch-a branch-b branch-c
```
| Flag | Description |
|------|-------------|
| `-b, --base <branch>` | Trunk branch (defaults to the repo's default branch) |
**Behavior:**
- Branch names are created exactly as given (slashes are allowed and kept as-is)
- Creates any branches that don't already exist (branching from the trunk branch)
- Existing branches are adopted automatically; missing branches are created from the trunk
- Checks out the last branch in the list
- Enables `git rerere` so conflict resolutions are remembered across rebases. On first run in a repo, this may trigger a confirmation prompt — pre-configure with `git config rerere.enabled true` to avoid it
---
### Add a branch — `gh stack add`
Add a new branch on top of the current stack. Must be run while on the topmost branch (or the trunk if the stack has no branches yet). **Always provide a branch name** — running without one triggers an interactive prompt.
```
gh stack add [flags] <branch>
```
**Recommended workflow — create the branch, then use standard git:**
```bash
# Create a new branch and switch to it
gh stack add api-routes
# Write code, stage deliberately, and commit
git add internal/api/routes.go internal/api/handlers.go
git commit -m "Add user API routes"
# Make more commits on the same branch as needed
git add internal/api/middleware.go
git commit -m "Add rate limiting middleware"
```
**Shortcut — stage, commit, and branch in one command:**
```bash
# Create a new branch, stage all changes, and commit
gh stack add -Am "Add API routes" api-routes
# Create a new branch, stage tracked files only, and commit
gh stack add -um "Fix auth bug" auth-fix
```
| Flag | Description |
|------|-------------|
| `-m, --message <string>` | Create a commit with this message |
| `-A, --all` | Stage all changes including untracked files (requires `-m`) |
| `-u, --update` | Stage tracked files only (requires `-m`) |
**Behavior notes:**
- `-A` and `-u` are mutually exclusive.
- When the current branch has no commits (e.g., right after `init`), `add -Am` commits directly on the current branch instead of creating a new one.
- **Branch names are used verbatim.** `gh stack add refactor/foo` creates a branch named `refactor/foo` — names are never prefixed or transformed. When `-m` is given without a branch name, the name is auto-generated from the commit message in date+slug format (e.g., `03-24-add_api_routes`).
- If called from a branch that is not the topmost in the stack, exits with code 5: `"can only add branches on top of the stack"`. Use `gh stack top` to switch first.
- **Uncommitted changes:** When using `gh stack add branch-name` without `-Am`, any uncommitted changes (staged or unstaged) in your working tree carry over to the new branch. This is standard git behavior — the working tree is not touched. Commit or stash changes on the current branch before running `add` if you want a clean starting point on the new branch.
---
### Push branches to remote — `gh stack push`
Push active stack branches to the remote.
```
gh stack push [flags]
```
```bash
# Push all branches
gh stack push
# Push to specific remote
gh stack push --remote upstream
```
| Flag | Description |
|------|-------------|
| `--remote <name>` | Remote to push to (use if multiple remotes exist) |
**Behavior:**
- Pushes all active (non-merged, non-queued) branches in one non-atomic multi-ref push with explicit per-branch `--force-with-lease` checks
- Some branches may update if another is rejected; fix the rejected branch and rerun the command
- Does **not** create or update pull requests — use `gh stack submit` for that
**Output (stderr):**
- `Pushed N branches` summary
---
### Submit branches and create PRs — `gh stack submit`
Push all stack branches and create PRs on GitHub. **Always pass `--auto`** — without it, `submit` prompts for a PR title for each new branch.
```bash
# Submit and auto-title new PRs (required for non-interactive use)
gh stack submit --auto
# Submit and create PRs as ready for review (not drafts)
gh stack submit --auto --open
```
| Flag | Description |
|------|-------------|
| `--auto` | Auto-generate PR titles without prompting (**required** for non-interactive use) |
| `--open` | Mark new and existing PRs as ready for review |
| `--remote <name>` | Remote to push to (use if multiple remotes exist) |
**Behavior:**
- Pushes each active (non-merged, non-queued) branch sequentially with explicit per-branch `--force-with-lease` checks; the overall submit is not atomic
- If a later branch push is rejected, earlier branch pushes and PR updates remain; fix the rejection and rerun the same command
- Creates a new PR for each branch that doesn't have one (base set to the first non-merged ancestor branch)
- After creating PRs, links them together as a **Stack** on GitHub (requires the repository to have stacks enabled)
- If every PR in the stack has already been merged, the stack is complete and can't be extended. `submit` automatically forks your unmerged branches into a **new** stack rooted at the trunk and creates it on GitHub, leaving the merged stack untouched.
- If stacks are not available (exit code 9), the repository does not have stacked PRs enabled. In interactive mode, `submit` offers to create regular (unstacked) PRs instead. In non-interactive mode, it exits with code 9.
- Syncs PR metadata for branches that already have PRs
**PR title auto-generation (`--auto`):**
- Single commit on branch → uses the commit subject as the PR title, commit body as PR body
- Multiple commits on branch → humanizes the branch name (hyphens/underscores → spaces) as the title
**Output (stderr):**
- `Created PR #N for <branch>` for each newly created PR
- `PR #N for <branch> is up to date` for existing PRs
- `Pushed and synced N branches` summary
---
### Link branches as a stack (no local tracking) — `gh stack link`
Link PRs into a stack on GitHub without creating any local tracking state. This is the recommended approach if you are managing stacked branches with other tools (jj, Sapling, git-town) and want to simply create GitHub Stacked PRs via an API.
```
gh stack link [flags] <stack-number | branch-or-pr> <branch-or-pr> [...]
```
```bash
# Link branches into a stack (pushes, creates PRs, creates stack)
gh stack link branch-a branch-b branch-c
# Use a different base branch and mark PRs as ready for review
gh stack link --base develop --open branch-a branch-b branch-c
# Link existing PRs by number
gh stack link 10 20 30
# Add branches to an existing stack of PRs
gh stack link 42 43 feature-auth feature-ui
# Append to the top of an existing stack by its stack number
# (7 is a stack number; only the new PRs/branches are listed)
gh stack link 7 48 feature-auth
```
When the first argument is a stack number, the remaining arguments are appended to the top of that stack, so you don't have to re-list its current PRs. Arguments already in the stack are skipped; arguments in a different stack are rejected. A numeric first argument is treated as a stack only when it matches an existing stack — otherwise it is a PR or branch.
| Flag | Description |
|------|---------|
| `--base <branch>` | Base branch for the bottom of the stack (defaults to the repository's default branch) |
| `--open` | Mark new and existing PRs as ready for review |
| `--remote <name>` | Remote to push to (use if multiple remotes exist) |
**Behavior:**
- Arguments are provided in stack order (bottom to top)
- Each argument can be a branch name or a PR number. Numeric arguments are tried as PR numbers first; if no PR with that number exists, the argument is treated as a branch name
- Branch arguments are pushed to the remote automatically (non-force, atomic)
- For branches without open PRs, new PRs are created with auto-generated titles and the correct base branch chaining (first branch uses `--base`, subsequent branches use the previous branch)
- Existing PRs whose base branch doesn't match the expected chain are corrected automatically
- If the PRs are not yet in a stack, a new stack is created. If some PRs are already in a stack, the stack is updated (additive only — existing PRs are never removed)
- Does **not** create or modify any local state
**Output (stderr):**
- `Pushing N branches to <remote>...`
- `Found PR #N for branch <name>` for branches with existing PRs
- `Created PR #N for <branch> (base: <base>)` for newly created PRs
- `Updated base branch for PR #N to <base>` when base branches are corrected
- `Created stack with N PRs` or `Updated stack to N PRs`
---
### Sync the stack — `gh stack sync`
Fetch, rebase, push, and sync PR state in a single command. This is the recommended command for routine synchronization.
```
gh stack sync [flags]
```
| Flag | Description |
|------|-------------|
| `--remote <name>` | Remote to fetch from and push to (use if multiple remotes exist) |
| `--prune` | Delete local branches for merged PRs |
**What it does (in order):**
1. **Fetch** latest changes from the remote
2. **Reconcile the remote stack** — mirror the GitHub stack locally. If PRs were added to the stack on GitHub, pull their branches down and append them to the local stack. If the local and remote stacks have diverged, aborts the sync in a non-interactive terminal. In an interactive terminal, offers prompts to resolve any divergence (replace local stack with remote version, delete stack on GitHub so it can be recreated, or cancel).
3. **Fast-forward trunk** to match remote (skips if already up to date, warns if diverged)
4. **Cascade rebase** all stack branches onto their updated parents (only if trunk moved). Handles merged PRs automatically. If a conflict is detected, **all branches are restored** to their pre-rebase state and the command exits with code 3 — see [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) for the resolution workflow
5. **Push** all active branches atomically
6. **Sync PR state** from GitHub and report the status of each PR
7. **Sync the stack object** — link the open PRs into a stack on GitHub. If the PRs are not yet in a stack, a new stack is created; if some PRs are already in a stack, it is updated (additive only). This only happens when two or more PRs exist. Sync **never opens PRs** — use `gh stack submit` for that
8. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to skip the prompt. In non-interactive environments, pruning only happens when `--prune` is passed explicitly
**Output (stderr):**
- `✓ Fetched latest changes from origin`
- `Pulling N new branches from the remote stack ...` then `✓ Pulled N new branches into the stack from the remote` (when the remote stack is ahead)
- `⚠ Your local stack has diverged from the stack on GitHub` (with `Local:` / `Remote:` chains) when the stacks have diverged
- `ℹ Sync aborted — no changes were made` when a sync is cancelled
- `✓ Trunk main fast-forwarded to <sha>` or `✓ Trunk main is already up to date`
- `✓ Rebased <branch> onto <base>` per branch (if base moved)
- `✓ Pushed N branches`
- `✓ PR #N (<branch>) — Open` per branch
- `Merged: #N, #M` for merged branches
- `✓ Stack created on GitHub with N PRs` / `✓ Stack updated on GitHub with N PRs` / `✓ Linked to the existing stack on GitHub` (when two or more PRs exist)
- `✓ Pruned <branch> (merged)` per pruned branch (when pruning)
- `✓ Stack synced` when the stack object on GitHub was created/updated to match local, or `✓ Branches synced` when only the branches were synced (fewer than two PRs or stacked PRs unavailable)
---
### Rebase the stack — `gh stack rebase`
Pull from remote and cascade-rebase stack branches. Use this when `sync` reports a conflict or when you need finer control (e.g., rebase only part of the stack).
```
gh stack rebase [flags] [branch]
```
```bash
# Rebase the entire stack
gh stack rebase
# Rebase only branches from trunk to current branch
gh stack rebase --downstack
# Rebase only branches from current branch to top
gh stack rebase --upstack
# Rebase stack branches without pulling from or rebasing with trunk
gh stack rebase --no-trunk
# After resolving a conflict: stage files with `git add`, then:
gh stack rebase --continue
# Abort and restore all branches to pre-rebase state
gh stack rebase --abort
```
| Flag | Description |
|------|-------------|
| `--downstack` | Only rebase branches from trunk to the current branch |
| `--upstack` | Only rebase branches from the current branch to the top |
| `--no-trunk` | Skip trunk — only rebase stack branches onto each other (no fetch, no trunk rebase) |
| `--continue` | Continue after resolving conflicts |
| `--abort` | Abort and restore all branches |
| `--remote <name>` | Remote to fetch from (use if multiple remotes exist) |
| Argument | Description |
|----------|-------------|
| `[branch]` | Target branch (defaults to the current branch) |
**Conflict handling:** See [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) in the Workflows section for the full resolution workflow.
**Merged PR detection:** If a branch's PR was merged on GitHub, the rebase automatically handles this using `--onto` mode and correctly replays commits on top of the merge target.
**Rerere (conflict memory):** `git rerere` is enabled by `init` so previously resolved conflicts are auto-resolved in future rebases.
**No-trunk mode:** Use `--no-trunk` to skip fetching from the remote and rebasing with the trunk branch. Only inter-branch rebases are performed (branch 2 onto branch 1, branch 3 onto branch 2, etc.). Useful when you only need to align stack branches with each other without pulling upstream changes.
---
### View the stack — `gh stack view`
Display the current stack's branches, PR status, and recent commits. **Always pass `--json`** — without it, this command launches an interactive TUI that agents cannot operate.
```bash
# Always use --json
gh stack view --json
```
| Flag | Description |
|------|-------------|
| `--json` | Output stack data as JSON to stdout (**required** for non-interactive use) |
**`--json` output format:**
```json
{
"trunk": "main",
"currentBranch": "api-routes",
"branches": [
{
"name": "auth",
"head": "abc1234...",
"base": "def5678...",
"isCurrent": false,
"isMerged": true,
"isQueued": false,
"needsRebase": false,
"pr": {
"number": 42,
"url": "https://github.com/owner/repo/pull/42",
"state": "MERGED"
}
},
{
"name": "api-routes",
"head": "789abcd...",
"base": "abc1234...",
"isCurrent": true,
"isMerged": false,
"isQueued": false,
"needsRebase": false,
"pr": {
"number": 43,
"url": "https://github.com/owner/repo/pull/43",
"state": "OPEN"
}
}
]
}
```
Fields per branch:
- `name` — branch name
- `head` — current HEAD SHA
- `base` — parent branch's HEAD SHA at last sync
- `isCurrent` — whether this is the checked-out branch
- `isMerged` — whether the PR has been merged
- `isQueued` — whether the PR is queued for merge (in a merge queue)
- `needsRebase` — whether the base branch is not an ancestor (non-linear history)
- `pr` — PR metadata (omitted if no PR exists). `state` is `"OPEN"`, `"MERGED"`, or `"QUEUED"`.
---
### Navigate the stack
Move between branches without remembering branch names. These commands are fully non-interactive.
```bash
gh stack up # Move up one branch (further from trunk)
gh stack up 3 # Move up three branches
gh stack down # Move down one branch (closer to trunk)
gh stack down 2 # Move down two branches
gh stack top # Jump to the top of the stack (furthest from trunk)
gh stack bottom # Jump to the bottom (first non-merged branch above trunk)
gh stack trunk # Jump to the trunk branch (e.g. main)
```
Navigation clamps to stack bounds. Merged branches are skipped when navigating from active branches.
---
### Check out a stack — `gh stack checkout`
Check out a stack by stack number, pull request number, PR URL, or branch name. **Always provide an argument** — running `gh stack checkout` without arguments triggers an interactive selection menu.
```
gh stack checkout <stack-number | pr-number | pr-url | branch>
```
```bash
# By stack number (the identifier shown in the GitHub stack UI)
gh stack checkout 7
# By PR number (pulls from GitHub)
gh stack checkout 42
# By PR URL
gh stack checkout https://github.com/owner/repo/pull/42
# By branch name (local only)
gh stack checkout feature-auth
```
A bare number is resolved as a **stack number first** (the identifier shown in the GitHub stack UI); if no stack has that number it is tried as a PR number, then a branch name. When a stack or PR number (or PR URL) is provided, the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch.
> **⚠️ Agent warning:** If the local and remote stacks have different branch compositions, this command triggers an interactive conflict-resolution prompt that cannot be bypassed with a flag. To avoid this: run `gh stack unstack --local` first to remove the conflicting local tracking state (this keeps the stack on GitHub intact), then retry `gh stack checkout <pr-number>`.
When a branch name is provided, the command resolves it against locally tracked stacks only. This is always safe for non-interactive use.
---
### Remove a stack — `gh stack unstack`
Tear down a stack so you can restructure it — remove a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init` to re-create the stack with the desired structure.
Unstacking only removes the stack grouping (on GitHub and/or locally); it never deletes the underlying pull requests or branches.
With no argument, the command targets the active stack — the one containing the currently checked out branch — unstacking it on GitHub and removing local tracking.
Provide a stack number to unstack a specific stack on GitHub. This works from anywhere in the repository, whether or not the stack is checked out locally — the number is unstacked directly through the GitHub API (like `gh stack link`, no local tracking required). If the stack is also tracked locally, its local tracking is removed as well.
```
gh stack unstack [<stack-number>] [flags]
```
```bash
# Tear down the current stack — removes local tracking and the GitHub grouping (PRs are NOT deleted), then rebuild
gh stack unstack
gh stack init --base main branch-2 branch-1 branch-3 # reordered
# Unstack a specific stack by its number, from anywhere in the repo
gh stack unstack 7
# Only remove local tracking (keep the stack on GitHub)
gh stack unstack --local
```
| Flag | Description |
|------|-------------|
| `--local` | Only remove the stack locally (keep it on GitHub); never contacts GitHub |
> **Note for agents:** `gh stack unstack <number>` is a remote-first API wrapper — it unstacks on GitHub by number from anywhere in the repo, tracked locally or not, and is safe for non-interactive use. `--local` never contacts GitHub; combining `--local` with a number that isn't tracked locally is an error. An unknown stack number returns a "not found on GitHub" error (exit code 2).
---
## Output conventions
- **Status messages** go to **stderr** with emoji prefixes: `✓` (success), `✗` (error), `⚠` (warning), `ℹ` (info).
- **Data output** (e.g., `view --json`) goes to **stdout**.
- When piping output, use `2>/dev/null` to suppress status messages if only data output is needed.
## Exit codes and error recovery
| Code | Meaning | Agent action |
|------|---------|-------------|
| 0 | Success | Proceed normally |
| 1 | Generic error | Read stderr for details; may indicate commit/push failure |
| 2 | Not in a stack | Run `gh stack init` to create a stack first |
| 3 | Rebase conflict | Parse stderr for conflicted file paths, resolve conflicts, run `gh stack rebase --continue` |
| 4 | GitHub API failure | Check `gh auth status`, retry the command |
| 5 | Invalid arguments | Fix the command invocation (check flags and arguments) |
| 6 | Disambiguation required | A branch belongs to multiple stacks. Run `gh stack checkout <specific-branch>` to switch to a non-shared branch first |
| 7 | Rebase already in progress | Run `gh stack rebase --continue` (after resolving conflicts) or `gh stack rebase --abort` to start over |
| 8 | Stack is locked | Another `gh stack` process is writing the stack file. Wait and retry — the lock times out after 5 seconds |
| 9 | Stacked PRs unavailable | The repository does not have stacked PRs enabled. Tell the user that stacks must be enabled on the repository first |
| 10 | Modify recovery required | A `gh stack modify` session was interrupted. This skill does not use `modify`, so agents should not produce this; if the repo is left in this state, run `gh stack modify --abort` to restore the pre-modify state |
## Known limitations
1. **Stacks are strictly linear.** Branching stacks (multiple children on a single parent) are not supported. Each branch has exactly one parent and at most one child. If you need parallel workstreams, use separate stacks.
2. **Stack disambiguation cannot be bypassed.** If the current branch is the trunk of multiple stacks, commands error with code 6. Check out a non-shared branch first.
3. **Multiple remotes require `--remote` or config.** If more than one remote is configured, set `remote.pushDefault` in git config, or pass `--remote <name>` to the commands that accept it (`push`, `submit`, `sync`, `rebase`, `link`). `checkout`, `modify`, and `trunk` have no `--remote` flag and rely on `remote.pushDefault`.
4. **Remote stack checkout requires a stack or PR number.** `checkout` with a branch name only works with locally tracked stacks. Use a stack number or PR number (e.g. `gh stack checkout 7` or `gh stack checkout 123`) to pull a stack from GitHub.
5. **PR title and body are auto-generated.** There is no flag to set a custom PR title or body during `submit`. The title and body are generated from commit messages plus a footer. Use `gh pr edit` to modify PR title and body after creation.

View File

@ -0,0 +1 @@
../../../../.agents/skills/gh-stack

View File

@ -1,7 +1,7 @@
{
"version": 1,
"generated": "2026-06-04T06:55:45.363Z",
"totalViolations": 160,
"generated": "2026-07-22T13:28:07.821Z",
"totalViolations": 188,
"violations": {
"packages/@n8n/ai-workflow-builder.ee/package.json": [
{
@ -33,6 +33,18 @@
"line": 87,
"message": "@types/turndown appears in 3 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "407c8d1b3428"
},
{
"rule": "single-instance-libs",
"line": 60,
"message": "\"@langchain/core\" is a runtime dependency of \"@n8n/ai-workflow-builder\"; it must be a peerDependency.",
"hash": "352bf126d45a"
},
{
"rule": "single-instance-libs",
"line": 86,
"message": "\"zod\" is a runtime dependency of \"@n8n/ai-workflow-builder\"; it must be a peerDependency.",
"hash": "fd6171764eb4"
}
],
"packages/@n8n/json-schema-to-zod/package.json": [
@ -91,6 +103,24 @@
"line": 298,
"message": "mongodb appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "46cb48884e22"
},
{
"rule": "single-instance-libs",
"line": 253,
"message": "\"@langchain/core\" is a runtime dependency of \"@n8n/n8n-nodes-langchain\"; it must be a peerDependency.",
"hash": "a3cb17459555"
},
{
"rule": "single-instance-libs",
"line": 304,
"message": "\"form-data\" is a runtime dependency of \"@n8n/n8n-nodes-langchain\"; it must be a peerDependency.",
"hash": "d3d8fabe4a38"
},
{
"rule": "single-instance-libs",
"line": 330,
"message": "\"zod\" is a runtime dependency of \"@n8n/n8n-nodes-langchain\"; it must be a peerDependency.",
"hash": "6dea0e57951a"
}
],
"packages/@n8n/tournament/package.json": [
@ -267,6 +297,24 @@
"line": 901,
"message": "eslint-plugin-n8n-nodes-base appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "ac254baa61f9"
},
{
"rule": "single-instance-libs",
"line": 938,
"message": "\"@langchain/core\" is a runtime dependency of \"n8n-nodes-base\"; it must be a peerDependency.",
"hash": "9501f20519c4"
},
{
"rule": "single-instance-libs",
"line": 961,
"message": "\"form-data\" is a runtime dependency of \"n8n-nodes-base\"; it must be a peerDependency.",
"hash": "3998c8aa25cb"
},
{
"rule": "single-instance-libs",
"line": 1020,
"message": "\"zod\" is a runtime dependency of \"n8n-nodes-base\"; it must be a peerDependency.",
"hash": "de1e5b4624e0"
}
],
"packages/@n8n/node-cli/package.json": [
@ -333,6 +381,18 @@
"line": 110,
"message": "n8n-workflow appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "ea4fbfff30ba"
},
{
"rule": "single-instance-libs",
"line": 120,
"message": "\"zod\" is a runtime dependency of \"@n8n/ai-utilities\"; it must be a peerDependency.",
"hash": "2ed5d6717e54"
},
{
"rule": "single-instance-libs",
"line": 122,
"message": "\"@langchain/core\" is a runtime dependency of \"@n8n/ai-utilities\"; it must be a peerDependency.",
"hash": "5626f18eefaf"
}
],
"packages/@n8n/mcp-browser/package.json": [
@ -371,6 +431,12 @@
"line": 27,
"message": "@joplin/turndown-plugin-gfm appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "743e3a7dbb32"
},
{
"rule": "single-instance-libs",
"line": 39,
"message": "\"zod\" is a runtime dependency of \"@n8n/mcp-browser\"; it must be a peerDependency.",
"hash": "5045d34d52ae"
}
],
"packages/@n8n/task-runner/package.json": [
@ -411,6 +477,12 @@
"line": 55,
"message": "@joplin/turndown-plugin-gfm appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "a3cf1504b5c2"
},
{
"rule": "single-instance-libs",
"line": 86,
"message": "\"zod\" is a runtime dependency of \"@n8n/instance-ai\"; it must be a peerDependency.",
"hash": "4a6882cb8d62"
}
],
"packages/@n8n/benchmark/package.json": [
@ -455,6 +527,12 @@
"line": 74,
"message": "recast appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "b660317b5f6f"
},
{
"rule": "single-instance-libs",
"line": 83,
"message": "\"form-data\" is a runtime dependency of \"n8n-workflow\"; it must be a peerDependency.",
"hash": "a5f5104e7293"
}
],
"packages/@n8n/eslint-plugin-community-nodes/package.json": [
@ -1134,6 +1212,122 @@
"message": "pg@^8.17.0 should use \"catalog:\" (exists in pnpm-workspace.yaml)",
"hash": "6cac348bdcc8"
}
],
"packages/core/package.json": [
{
"rule": "single-instance-libs",
"line": 61,
"message": "\"@langchain/core\" is a runtime dependency of \"n8n-core\"; it must be a peerDependency.",
"hash": "602aad4587ee"
},
{
"rule": "single-instance-libs",
"line": 81,
"message": "\"form-data\" is a runtime dependency of \"n8n-core\"; it must be a peerDependency.",
"hash": "643d5b8cd4be"
}
],
"packages/@n8n/backend-network/package.json": [
{
"rule": "single-instance-libs",
"line": 68,
"message": "\"form-data\" is a runtime dependency of \"@n8n/backend-network\"; it must be a peerDependency.",
"hash": "6b988cc15b6f"
},
{
"rule": "single-instance-libs",
"line": 77,
"message": "\"zod\" is a runtime dependency of \"@n8n/backend-network\"; it must be a peerDependency.",
"hash": "3af671752aa6"
}
],
"packages/@n8n/blob-storage/package.json": [
{
"rule": "single-instance-libs",
"line": 52,
"message": "\"zod\" is a runtime dependency of \"@n8n/blob-storage\"; it must be a peerDependency.",
"hash": "2beb68b6b1eb"
}
],
"packages/@n8n/config/package.json": [
{
"rule": "single-instance-libs",
"line": 29,
"message": "\"zod\" is a runtime dependency of \"@n8n/config\"; it must be a peerDependency.",
"hash": "0acfb4c2c019"
}
],
"packages/@n8n/db/package.json": [
{
"rule": "single-instance-libs",
"line": 52,
"message": "\"zod\" is a runtime dependency of \"@n8n/db\"; it must be a peerDependency.",
"hash": "67751e0ed127"
}
],
"packages/@n8n/extension-sdk/package.json": [
{
"rule": "single-instance-libs",
"line": 66,
"message": "\"zod\" is a runtime dependency of \"@n8n/extension-sdk\"; it must be a peerDependency.",
"hash": "ab05f1864411"
}
],
"packages/@n8n/expression-runtime/package.json": [
{
"rule": "single-instance-libs",
"line": 48,
"message": "\"zod\" is a runtime dependency of \"@n8n/expression-runtime\"; it must be a peerDependency.",
"hash": "5669d2783982"
}
],
"packages/@n8n/mcp-apps/package.json": [
{
"rule": "single-instance-libs",
"line": 45,
"message": "\"zod\" is a runtime dependency of \"@n8n/mcp-apps\"; it must be a peerDependency.",
"hash": "5e1c42cefa8d"
}
],
"packages/@n8n/permissions/package.json": [
{
"rule": "single-instance-libs",
"line": 26,
"message": "\"zod\" is a runtime dependency of \"@n8n/permissions\"; it must be a peerDependency.",
"hash": "ead6e5815a0f"
}
],
"packages/@n8n/syslog-client/package.json": [
{
"rule": "single-instance-libs",
"line": 26,
"message": "\"zod\" is a runtime dependency of \"@n8n/syslog-client\"; it must be a peerDependency.",
"hash": "d1cff54dc25f"
}
],
"packages/@n8n/workflow-sdk/package.json": [
{
"rule": "single-instance-libs",
"line": 107,
"message": "\"zod\" is a runtime dependency of \"@n8n/workflow-sdk\"; it must be a peerDependency.",
"hash": "e8f5ce9766b4"
}
],
"packages/@n8n/engine/package.json": [
{
"rule": "single-instance-libs",
"line": 34,
"message": "\"zod\" is a runtime dependency of \"@n8n/engine\"; it must be a peerDependency.",
"hash": "a25ac9357fce"
}
],
"packages/@n8n/telemetry/package.json": [
{
"rule": "single-instance-libs",
"line": 26,
"message": "\"zod\" is a runtime dependency of \"@n8n/telemetry\"; it must be a peerDependency.",
"hash": "d61edcda4afe"
}
]
}
}

View File

@ -3,7 +3,7 @@ ARG NODE_VERSION=24
# Debian, not Alpine: Playwright browsers and the agent CLIs need glibc
FROM node:${NODE_VERSION}-bookworm
ARG PLAYWRIGHT_VERSION=1.60.0
ARG PLAYWRIGHT_VERSION=1.62.1
RUN apt-get update && apt-get install -y --no-install-recommends \
tmux sudo less jq ripgrep postgresql-client \
@ -16,6 +16,19 @@ RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
ENV DISABLE_AUTOUPDATER=1
RUN npm install -g @anthropic-ai/claude-code opencode-ai
COPY codespaces-env.sh /usr/local/lib/codespaces-env.sh
COPY codespaces-secrets.sh /etc/profile.d/codespaces-secrets.sh
# git and gh must survive token rotation: these read the token on each call
COPY gitcredential-refresh.sh /usr/local/bin/gitcredential-refresh.sh
COPY gh-shim.sh /usr/local/bin/gh
RUN chmod +x /usr/local/bin/gitcredential-refresh.sh /usr/local/bin/gh
# VS Code terminals are non-login shells: they read bashrc, not profile.d
RUN echo '. /usr/local/lib/codespaces-env.sh' >> /etc/bash.bashrc
COPY tmux.conf /etc/tmux.conf
RUN echo node ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/node && chmod 0440 /etc/sudoers.d/node
RUN mkdir -p /workspaces && chown node:node /workspaces
RUN corepack enable

View File

@ -0,0 +1,243 @@
# Cloud agent sessions (Codespaces)
Run long-lived, human-steered agent sessions (Claude Code / OpenCode) on a
GitHub Codespace instead of your laptop: start a task, close the lid, steer it
from anywhere with a terminal, resume tomorrow.
This is a separate devcontainer config from the laptop one in
`.devcontainer/` — it ships both agent CLIs, `tmux` for session persistence,
Playwright system deps, and Docker-in-Docker (for testcontainers and
`pnpm --filter n8n-containers services`), on the same Postgres sidecar setup.
## One-time setup (~5 min)
1. Add your key at [github.com/settings/codespaces](https://github.com/settings/codespaces)
→ **New secret** → name `ANTHROPIC_API_KEY`, repository access `n8n-io/n8n`.
(Alternative for Max subscriptions: `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`.)
2. Give the GitHub CLI the codespace scope:
```bash
gh auth refresh -h github.com -s codespace
```
## Daily flow
```bash
pnpm session # attach the default agent session (creates everything on first run)
pnpm session fix-flaky # a second, parallel agent in its own git worktree
pnpm session ls # what's running
pnpm session tunnel # forward n8n ports (default 5678, 8080) to localhost; Ctrl-C to stop
pnpm session stop # end of day: billing stops, disk survives
pnpm session rm # delete the codespace
```
- **Detach** with `Ctrl-b d` — the agent keeps working without you.
- **Scroll** with the mouse wheel (tmux mouse mode is on). To use the
terminal's own text selection, hold **Shift** and drag.
- **Reattach** by running the same `pnpm session <name>` from any machine.
- Each named session gets its own worktree (`/workspaces/wt-<name>`, branch
`session/<name>`), so parallel agents never touch each other's tree. Builds
in fresh worktrees are cache-hits via a shared turbo cache.
- First codespace creation takes ~20 min uncached (image + full build). After
that, sessions attach instantly; new worktrees cost a `pnpm install` (~1–2 min).
## Agent worker (drive a session from n8n)
`agent-worker.mjs` lets an n8n workflow drive a Claude Code session on the
codespace. This is how Slack (the Flaky bot) steers a session that runs here.
Each turn is one headless `claude -p`. The session state is on disk. So
`--resume` continues a conversation across turns, and across a stop/start.
**The worker polls outward. Nothing inbound is exposed.** GitHub sets every
forwarded port to private on start. It gives no API to make a port public. So
you cannot reach a codespace from outside reliably. The worker calls out
instead. It asks n8n for a turn addressed to this box's owner (`$GITHUB_USER`).
It runs the turn. It sends the result to the turn's resume URL. It uses no
tunnel, no open port, and no domain.
The worker starts on each container start (`postStartCommand`). It needs two
secrets. Add them at
[github.com/settings/codespaces](https://github.com/settings/codespaces), the
same way as `ANTHROPIC_API_KEY`:
- `AGENT_WORKER_TOKEN` — the shared bearer token. The worker sends it on each poll.
- `N8N_DEQUEUE_URL` — the n8n webhook that returns a pending turn.
The log is at `/tmp/agent-worker.log`. The tmux session is `agent-worker`. To
watch it, run `tmux attach -t agent-worker`. The worker does not start if a
secret or `$GITHUB_USER` is missing.
A turn stops after about 25 minutes (`TURN_TIMEOUT_MS`). This limit is below the
n8n Wait limit. So the worker reports a clear message before n8n reports a
generic timeout. Keep the worker limit below the n8n limit if you change either.
**A turn is atomic, and the worker tells the session so.** The turn ends on the
session's final message, and its children end with it: a background `Bash` task
is killed, `Monitor` events never arrive, `PushNotification` has nowhere to go,
and `ScheduleWakeup` never fires. The session also gets no turn of its own to
report back in — the turn's resume URL continues one waiting n8n execution and is
then spent, so nothing on the box can post to the thread unprompted. A session
that backgrounds a build and signs off with "I'll verify once it finishes" is
therefore describing something that cannot happen. The worker states this in
`--append-system-prompt` on every turn (`turnContract`), together with a pointer
to this file for the box-specific parts. This is only the n8n/Slack path: an
interactive session (`pnpm session`, tmux) is long-lived, so background work,
monitors and notifications behave normally there.
### Build and run the app in a session
The prebuild already installed the dependencies and warmed the build. So a
session rarely needs a cold `pnpm install` or a full `pnpm build`. Both are slow
(often 10–20 minutes cold). Both can outlast a turn's limit.
- **Bring the app up with one command: `pnpm dev:up`.** It installs missing
dependencies, starts the backend, waits for health, and prints the URL. Add
`--build` only when a frontend change must appear (see below).
- **Open the app** at `https://<codespace-name>-5678.app.github.dev`. `dev:up`
makes that port visible to the org, thus any n8n member who is signed into
GitHub can open it. You do not need a tunnel. GitHub makes every forwarded port
private again at each container start, so `dev:up` shares it again on each run.
To see the current state, run `gh codespace ports`. The share command needs `gh`
with the codespace scope (see the one-time setup above). If it fails, `dev:up`
starts the app, prints the reason, and gives you the command to try again. A
private port opens for you only, in a browser that is signed in to GitHub. An
anonymous or server caller gets a 302. That is why the worker polls outward.
- **`pnpm dev` no longer exists.** Use `pnpm dev:be` for the backend (on 5678).
Use `pnpm dev:fe:editor` for the editor UI with hot reload (on 8080).
- **`dev:be` serves the editor from the `dist` build.** So a frontend edit does
not hot-reload there. Run `pnpm dev:up --build` (or `pnpm build`) and restart
to show it. For live frontend hot reload, use `pnpm dev:fe:editor`. That path
needs `pnpm session tunnel 5678 8080` from your laptop. Its API base is set to
`localhost:5678`, so the `-8080.app.github.dev` URL does not work on its own.
- Run `pnpm install` only when the dependencies change. `pnpm build` reuses the
turbo cache and is fast when warm.
- To clear stale build outputs after a branch switch, run `pnpm reset`. Add
`--full` if that does not clear it.
- Run a long build in the foreground and give it its own turn. Backgrounding it
does not help: it is killed when the turn ends (see above). Do not chain an
install and a full build behind other work in one turn either — that is what
runs into the 25-minute limit.
## Flaky tools (MCP)
Claude sessions get the `flaky` MCP server automatically: Currents
flaky/quarantine data, the `qa_*` BigQuery dataset, Sentry RCA, live Linear,
and repo investigation. Login registers it from the repo-level
`FLAKY_MCP_TOKEN` / `FLAKY_MCP_URL` secrets. Forks have no secrets and skip
it. Tell the agent to call `get_flaky_context` first — it returns the rules
the tools assume.
## Quality skills (Claude plugin)
Claude sessions can also load the private quality skills from the
`n8n-io/n8n-agent-skills` repository (bug insights, defect attribution,
mutation testing, and more). `post-start.mjs` installs the `quality` plugin on each
container start, so every session gets the skills with no per-session step.
The private marketplace uses the codespace's own GitHub auth — no extra token.
`devcontainer.json` grants the codespace read access to
`n8n-io/n8n-agent-skills` via `customizations.codespaces.repositories`, and
**each user authorizes that access once when they create the codespace** (GitHub
prompts for it, then remembers). Both repos are in the same org, which is what
lets this work.
The codespace authenticates git over HTTPS and has no SSH key, so `post-start.mjs`
sets `CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1` — Claude Code's plugin loader otherwise
clones `owner/repo` shorthand over SSH and the private clone fails.
If a user does not authorize the grant, the clone fails and the skills step is
skipped (the worker still starts). Existing codespaces created before this change
need a recreate to get the prompt. The log is at `/tmp/post-start.log`: a failed
`skills repo reachable` line means the grant was not authorized; a failed
`marketplace add` after a reachable repo means a loader-auth problem.
## Viewing the dev UI locally
Two terminal windows:
```bash
pnpm session # window 1: attach the agent session
pnpm session tunnel # window 2: forward 5678 + 8080 to localhost
```
Attaching lands you in the agent (Claude), not a shell — open a shell in a new
tmux window with `Ctrl-b c` (or ask the agent), then pick a dev server and open
the matching port:
- `pnpm dev:up` — backend on **5678** (editor served from the `dist` build).
Open http://localhost:5678.
- `pnpm dev:fe:editor` — editor UI with hot reload on **8080**. Open
http://localhost:8080. It needs the backend on 5678 too, so run `pnpm dev:up`
alongside it.
The tunnel prints nothing while forwarding — that's normal. `Connection
refused` means nothing is listening on that port in the codespace yet; it
starts serving as soon as the dev server is up, no restart needed. Pass ports
to override the defaults (`pnpm session tunnel 5678 8080 5679`), but always
forward the pair together with matching numbers: the Vite dev UI points its
API base at `localhost:5678` (the `N8N_PORT` default), so an asymmetric or
partial mapping breaks it.
## What survives what
| Event | Running processes | Disk (checkout, worktrees, chat history) |
|---|---|---|
| Detach / close laptop / network drop | ✅ keep running | ✅ |
| Stop, or idle timeout (default 30 min, max 4 h) | ❌ killed | ✅ |
| Delete (`pnpm session rm`) | ❌ | ❌ (push your branches first) |
After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
`claude --resume` (or `--continue`) inside to restore the conversation from disk.
## Gotchas (learned the hard way)
- **Codespaces clones the repo to `/workspaces/n8n`**, not `/workspaces` —
`workspaceFolder` here differs from the laptop config on purpose.
- **A failing `onCreateCommand` is fatal**: Codespaces discards the container
and drops you into a minimal recovery container (no node, no CLIs). If your
codespace has nothing installed, check
`/workspaces/.codespaces/.persistedshare/creation.log`.
- **`gh codespace ssh` needs sshd inside the container** — provided by the
`sshd` devcontainer feature, don't remove it.
- **User secrets aren't visible in ssh shells by default**: the codespace
agent injects them into VS Code sessions only; they're delivered
base64-encoded to `/workspaces/.codespaces/shared/.env-secrets`. The image
sources `/usr/local/lib/codespaces-env.sh` in login shells (profile.d), in
interactive shells (bashrc), and in the `pnpm session` prelude. If Claude
Code shows `Missing environment variables: FLAKY_MCP_TOKEN`, the shell that
started Claude did not source the file. Run
`. /usr/local/lib/codespaces-env.sh` and start Claude again.
- **Do not read `CODESPACE_NAME` or `GITHUB_USER` from the process env** — use
`scripts/codespace-env.mjs`. Codespaces gives these variables to VS Code
sessions only. Other processes read them from `codespaces-env.sh`, and a
process that tmux starts can get an empty copy: tmux keeps the environment of
its own start, and `update-environment` does not refresh these keys. A worker
polled correctly as its owner while `dev:up` in the same session saw an empty
box name, printed the localhost URL, and did not share the port. The helper
reads `/workspaces/.codespaces/shared`, which is always correct.
- **You cannot paste images into a remote Claude session.** Image paste reads
the clipboard of the machine where `claude` runs — the codespace, not your
laptop. Drag the file into the VS Code explorer (or
`gh codespace cp shot.png remote:/workspaces/n8n/`) and give Claude the
path. The file stays on disk and survives detach and `--resume`.
- **`git push` / `gh` return 401 in tmux and long sessions** — same root
cause as the secrets gotcha, plus rotation: Codespaces refreshes the
on-disk `GITHUB_TOKEN` every few minutes, so a login-time snapshot goes
stale. The image fixes both: a credential helper reads the current token
on each `git push` (`gitcredential-refresh.sh`), and a shim at
`/usr/local/bin/gh` does the same for `gh`. The token is scoped to
`n8n-io/n8n`: fork-based flows do not work, push branches directly. Do
not add SSH keys as a workaround — they have no per-repo granularity.
- Codespaces created by org members on this repo are **org-owned and
org-billed** (organization ownership + a monthly Codespaces budget are
enabled for n8n-io). Codespaces created before that change, or by
non-members, bill to the personal account (free tier: 120 core-hours/month).
If creation unexpectedly falls back to personal billing, the org budget is
exhausted for the period.
## Costs
Compute bills only while the codespace runs: ~$0.72/hr for the 8-core box,
~nothing stopped. Usage draws from a shared monthly org budget, so
`pnpm session stop` when you leave; the idle timeout is the backstop, not the
plan.

View File

@ -0,0 +1,212 @@
#!/usr/bin/env node
// Poll worker for per-turn Claude Code conversations on a codespace.
// You cannot reach a codespace from outside. GitHub keeps forwarded ports
// private. So this worker calls out. It polls n8n for a turn addressed to this
// box's owner. It runs one `claude -p`. It sends the result to the turn's resume
// URL. Every call is outbound HTTPS to n8n. It opens no inbound port.
//
// AGENT_WORKER_TOKEN=… N8N_DEQUEUE_URL=… node agent-worker.mjs
//
// Env:
// N8N_DEQUEUE_URL n8n webhook that hands back one pending turn (required)
// AGENT_WORKER_TOKEN shared bearer sent on every dequeue (required)
// GITHUB_USER box owner's login; the bootstrap route for a new thread (see codespace-env.mjs)
// CODESPACE_NAME stable box id; routes a thread back to the box holding its session (same source)
// TURN_TIMEOUT_MS per-turn limit; keep below the n8n Wait limit (default 25 min)
import { execFile } from 'node:child_process';
import { resolve as resolvePath, sep } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { codespaceEnv } from '../../scripts/codespace-env.mjs';
const DEQUEUE_URL = process.env.N8N_DEQUEUE_URL;
const TOKEN = process.env.AGENT_WORKER_TOKEN;
// Read both identities from the codespace. tmux can give an empty copy of either.
const GITHUB_USER = codespaceEnv('GITHUB_USER');
const BOX_ID = codespaceEnv('CODESPACE_NAME');
const ROOT = '/workspaces';
const POLL_INTERVAL_MS = 3000;
// Warn and use the default on a bad value, so a config mistake cannot silently
// disable the turn limit.
function posNum(name, fallback) {
const raw = process.env[name];
if (raw === undefined) return fallback;
const n = Number(raw);
if (Number.isFinite(n) && n > 0) return n;
console.error(`${name} is not a positive number ("${raw}"); using ${fallback}.`);
return fallback;
}
// Keep this below the n8n Wait-node limit. Then the worker reports a slow turn
// before n8n's Wait ends with a generic message.
const TURN_TIMEOUT_MS = posNum('TURN_TIMEOUT_MS', 25 * 60_000);
for (const [k, v] of Object.entries({
N8N_DEQUEUE_URL: DEQUEUE_URL,
AGENT_WORKER_TOKEN: TOKEN,
GITHUB_USER,
})) {
if (!v) {
console.error(`Refusing to start: ${k} is not set.`);
process.exit(1);
}
}
// Not fatal, but box pinning needs it: without a box id every turn routes by
// owner, so a thread cannot follow the box holding its session.
if (!BOX_ID)
console.error(
'CODESPACE_NAME did not resolve — box pinning disabled; turns route by githubUser only.',
);
// A turn gets a copy of this environment. Put the correct values in it, because
// `pnpm dev:up` and `gh -c $CODESPACE_NAME` in the session need them.
const TURN_ENV = { ...process.env };
if (BOX_ID) TURN_ENV.CODESPACE_NAME = BOX_ID;
if (GITHUB_USER) TURN_ENV.GITHUB_USER = GITHUB_USER;
const CODESPACE_DOCS = '.devcontainer/codespaces/README.md';
// A session cannot be told any of this after its final message, and a system
// prompt is not part of the resumed transcript, so send it on every turn. State
// the turn's hard limits inline: a session that has to read a file to learn them
// can reply before it gets there. Point at the box docs for the rest — they
// already cover dev:up, ports, and build cost, and AGENTS.md does not.
function turnContract(author) {
return [
'# Your runtime',
'You are one turn of a Slack thread, driven by an n8n workflow that runs you as a headless',
'`claude -p` on a GitHub codespace. Your final message is the reply that reaches Slack, so keep',
'it short and skip heavy markdown.',
author ? `You are replying to ${author}.` : '',
'',
'# A turn is atomic',
'The turn ends when you emit your final message, and everything you started ends with it:',
'background Bash tasks are killed, Monitor events never arrive, PushNotification has nowhere to',
'go, and ScheduleWakeup never fires. You get no turn of your own afterwards — you cannot speak',
'again until a human writes again. So run long work (builds, test suites, restarts) in the',
'foreground of this turn and wait for it, or do not start it at all. Never end a turn promising',
`to verify, check back, or follow up. Work that will not fit the turn limit of ~${Math.round(
TURN_TIMEOUT_MS / 60_000,
)} minutes`,
'should be split: do the part that fits, then say what to ask for next.',
'',
'# This box',
`You are on codespace ${BOX_ID ?? '(unknown)'}, not a laptop. Before you build, start, or expose`,
`the app, read ${CODESPACE_DOCS} ("Build and run the app in a session"). It is box-specific and`,
'the repo AGENTS.md does not cover it.',
]
.filter(Boolean)
.join('\n');
}
function runClaude({ message, sessionId, cwd, author }) {
const safeCwd = resolvePath(typeof cwd === 'string' && cwd ? cwd : `${ROOT}/n8n`);
if (safeCwd !== ROOT && !safeCwd.startsWith(ROOT + sep))
throw new Error(`cwd must be under ${ROOT}`);
const args = [
'-p',
'--output-format',
'json',
'--dangerously-skip-permissions',
'--append-system-prompt',
turnContract(typeof author === 'string' ? author : ''),
];
if (sessionId) args.push('--resume', sessionId);
args.push(message);
return new Promise((res, rej) => {
execFile(
'claude',
args,
{ cwd: safeCwd, env: TURN_ENV, timeout: TURN_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 },
(err, stdout, stderr) => {
try {
res(JSON.parse(stdout));
} catch {
if (err?.killed)
rej(
new Error(
`The turn passed the ${Math.round(TURN_TIMEOUT_MS / 60_000)}-minute limit and stopped. It may have been in a build. Do a smaller step, or run a long build in its own turn.`,
),
);
else rej(new Error(stderr?.trim() || err?.message || 'claude produced no output'));
}
},
);
});
}
async function post(url, body) {
return fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
}
async function dequeue() {
const res = await post(DEQUEUE_URL, { githubUser: GITHUB_USER, boxId: BOX_ID, token: TOKEN });
if (!res.ok) throw new Error(`dequeue HTTP ${res.status}`);
const text = await res.text();
if (!text.trim()) return null; // no pending turn
const turn = JSON.parse(text);
return turn?.turnId ? turn : null;
}
async function handle(turn) {
let result;
try {
const r = await runClaude(turn);
result = {
turnId: turn.turnId,
status: 'done',
output: r.result ?? '',
sessionId: r.session_id ?? turn.sessionId ?? '',
boxId: BOX_ID,
};
} catch (error) {
result = {
turnId: turn.turnId,
status: 'error',
output: error.message,
sessionId: turn.sessionId ?? '',
boxId: BOX_ID,
};
}
// Send the result to the turn's resume URL. This continues the waiting n8n
// execution. Retry on a failed status or a network error, so a transient
// failure does not drop the result and leave the turn to time out.
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await post(turn.resumeUrl, result);
if (res.ok) return;
console.error(`turn ${turn.turnId}: result POST got HTTP ${res.status} (attempt ${attempt})`);
} catch (error) {
console.error(
`turn ${turn.turnId}: result POST failed (attempt ${attempt}): ${error.message}`,
);
}
await sleep(2000 * attempt);
}
console.error(`turn ${turn.turnId}: result not delivered after 3 attempts`);
}
console.log(`agent-worker polling as ${GITHUB_USER} every ${POLL_INTERVAL_MS}ms`);
for (;;) {
try {
const turn = await dequeue();
if (turn) {
console.log(
`${new Date().toISOString()} turn ${turn.turnId} by ${turn.author ?? 'unknown'}: ${turn.sessionId ? 'resume' : 'new'}`,
);
await handle(turn);
continue; // get the next turn now, with no delay
}
} catch (error) {
console.error(`poll error: ${error.message}`);
}
await sleep(POLL_INTERVAL_MS);
}

View File

@ -0,0 +1,9 @@
# Codespaces delivers secrets base64-encoded in a shared env file. It injects
# them into VS Code sessions only. Other shells must source this file to get
# them. Tokens rotate every few minutes: read at time of use, not at login.
if [ -f /workspaces/.codespaces/shared/.env-secrets ]; then
while IFS='=' read -r key val; do
case "$key" in '' | *[!A-Za-z0-9_]*) continue ;; esac
dec=$(printf %s "$val" | base64 -d 2>/dev/null) && export "$key=$dec"
done </workspaces/.codespaces/shared/.env-secrets
fi

View File

@ -0,0 +1,26 @@
# Login-shell setup for ssh/tmux sessions: export secrets, then do the
# one-time registrations.
. /usr/local/lib/codespaces-env.sh
# Claude Code keeps its first-run state in ~/.claude.json. Write the defaults
# so a new codespace does not show the theme and trust prompts. The env
# secrets supply the login.
[ -f "$HOME/.claude.json" ] || printf '%s\n' \
'{"hasCompletedOnboarding":true,"theme":"dark","projects":{"/workspaces/n8n":{"hasTrustDialogAccepted":true}}}' \
>"$HOME/.claude.json"
# Register the Flaky MCP server for Claude Code. The config keeps a literal
# ${FLAKY_MCP_TOKEN}; Claude Code expands it at connect time, so the token is
# not written to disk. Forks have no repo secrets and skip this.
if [ -n "$FLAKY_MCP_URL" ] && ! grep -qs '"flaky"' "$HOME/.claude.json" && command -v claude >/dev/null 2>&1; then
claude mcp add --scope user --transport http flaky "$FLAKY_MCP_URL" \
--header 'Authorization: Bearer ${FLAKY_MCP_TOKEN}' >/dev/null 2>&1 || true
fi
# Register the credential helper in the user config, because Codespaces
# regenerates the managed /etc/gitconfig. When the env token is missing, the
# system helper exits 0 without output, and git falls through to ours.
if command -v git >/dev/null 2>&1 &&
! git config --global --get-all credential.helper 2>/dev/null | grep -qs gitcredential-refresh; then
git config --global --add credential.helper /usr/local/bin/gitcredential-refresh.sh
fi

View File

@ -2,24 +2,57 @@
"name": "n8n (Codespaces agent sessions)",
"dockerComposeFile": "docker-compose.yml",
"service": "n8n",
"workspaceFolder": "/workspaces",
// Codespaces clones the repo into /workspaces/<repo>, unlike the laptop config
"workspaceFolder": "/workspaces/n8n",
"hostRequirements": {
"cpus": 8,
"memory": "32gb",
"storage": "64gb"
},
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
"ghcr.io/devcontainers/features/github-cli:1": {},
// gh codespace ssh needs an SSH server inside the container
"ghcr.io/devcontainers/features/sshd:1": {},
// Docker for testcontainers and `pnpm --filter n8n-containers services`;
// needs `privileged: true` on the n8n service (compose-based config)
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"forwardPorts": [8080, 5678],
// Grant the codespace's own GitHub token read access to the private skills
// marketplace (same org), so Claude can install it with the codespace's normal
// git auth — no extra PAT. Each user authorizes this once at codespace creation.
"customizations": {
"codespaces": {
"repositories": {
"n8n-io/n8n-agent-skills": { "permissions": { "contents": "read" } }
}
}
},
// Runs in prebuilds, so a fresh codespace starts with deps installed and the build warm
"onCreateCommand": "corepack install && pnpm install && (pnpm build > /tmp/build.log 2>&1 || tail -n 50 /tmp/build.log) && pnpm --filter=n8n-playwright exec playwright install chromium",
// On each container start: install the private skills marketplace into Claude
// (best-effort) and start the poll worker. `bash -lc` loads the secrets shim,
// so the worker gets its token. Logs: /tmp/post-start.log, /tmp/agent-worker.log.
"postStartCommand": "bash -lc 'node /workspaces/n8n/.devcontainer/codespaces/post-start.mjs >> /tmp/post-start.log 2>&1' || true",
"secrets": {
"ANTHROPIC_API_KEY": {
"description": "Anthropic API key for Claude Code / OpenCode sessions"
},
"AGENT_WORKER_TOKEN": {
"description": "The shared bearer token. The poll worker sends it to n8n on each dequeue. See .devcontainer/codespaces/agent-worker.mjs."
},
"N8N_DEQUEUE_URL": {
"description": "n8n webhook the poll worker calls to claim a pending turn"
},
"CLAUDE_CODE_OAUTH_TOKEN": {
"description": "Alternative to an API key: token from `claude setup-token` (Max subscription)"
},
// Both set at the repository level — team members get them automatically
"FLAKY_MCP_TOKEN": {
"description": "Bearer token for the Flaky (QA assistant) MCP endpoint"
},
"FLAKY_MCP_URL": {
"description": "URL of the Flaky MCP endpoint on the internal n8n instance"
}
}
}

View File

@ -3,10 +3,10 @@ volumes:
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
- postgres-data:/var/lib/postgresql
environment:
- POSTGRES_DB=n8n
- POSTGRES_PASSWORD=password
@ -15,6 +15,8 @@ services:
build:
context: .
dockerfile: Dockerfile
# Required by the docker-in-docker devcontainer feature
privileged: true
volumes:
- ../..:/workspaces:cached
command: sleep infinity
@ -24,3 +26,9 @@ services:
DB_POSTGRESDB_HOST: postgres
DB_TYPE: postgresdb
DB_POSTGRESDB_PASSWORD: password
# Prompt-cache TTL in seconds. 1 hour keeps the cache warm between turns.
ANTHROPIC_PROMPT_CACHE_TTL: "3600"
# Do not add CODESPACE_NAME here. Codespaces does not give it to compose, so
# this file can set an empty value only. An empty value is worse than no
# value, because tmux keeps it for each new pane. The consumers read the name
# with scripts/codespace-env.mjs.

View File

@ -0,0 +1,7 @@
#!/bin/sh
# gh reads its token from the env only. Long sessions hold a stale snapshot of
# the rotated token. Read the current token on each call, then run the real gh
# (the github-cli feature installs it at /usr/bin/gh).
. /usr/local/lib/codespaces-env.sh
[ -n "$GITHUB_TOKEN" ] && export GH_TOKEN="$GITHUB_TOKEN"
exec /usr/bin/gh "$@"

View File

@ -0,0 +1,6 @@
#!/bin/sh
# The stock helper reads $GITHUB_TOKEN from the env. Non-login shells do not
# have it, and the token rotates. Read the current token, then delegate.
[ "$1" = get ] || exit 0
. /usr/local/lib/codespaces-env.sh
exec /.codespaces/bin/gitcredential_github.sh "$@"

View File

@ -0,0 +1,34 @@
#!/usr/bin/env node
// Runs on each codespace start. Installs the skills marketplace, starts the worker.
import { execFileSync } from 'node:child_process';
const MARKETPLACE = 'n8n-io/n8n-agent-skills';
const PLUGIN = 'quality@n8n-agent-skills';
// The codespace clones over HTTPS and has no SSH key. The loader defaults to SSH
// for "owner/repo", so it must prefer HTTPS or the private clone fails.
process.env.CLAUDE_CODE_PLUGIN_PREFER_HTTPS = '1';
process.env.CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE = '1';
function tryRun(label, cmd, args) {
try {
execFileSync(cmd, args, { stdio: 'inherit' });
return true;
} catch (error) {
console.error(`${label}: ${error.message}`);
return false;
}
}
tryRun('skills repo reachable', 'git', ['ls-remote', `https://github.com/${MARKETPLACE}`, 'HEAD']);
if (tryRun('marketplace add', 'claude', ['plugin', 'marketplace', 'add', MARKETPLACE]))
tryRun('plugin install', 'claude', ['plugin', 'install', PLUGIN]);
tryRun('worker start', 'tmux', [
'new-session',
'-d',
'-s',
'agent-worker',
'bash -lc "export CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE=1; node /workspaces/n8n/.devcontainer/codespaces/agent-worker.mjs >> /tmp/agent-worker.log 2>&1"',
]);

View File

@ -0,0 +1,4 @@
# Mouse scroll, a deep scrollback, and correct colors for agent sessions.
set -g mouse on
set -g history-limit 50000
set -g default-terminal "tmux-256color"

View File

@ -3,10 +3,10 @@ volumes:
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
- postgres-data:/var/lib/postgresql
environment:
- POSTGRES_DB=n8n
- POSTGRES_PASSWORD=password

View File

@ -15,6 +15,11 @@ N8N_LOG_LEVEL=debug
N8N_AI_ENABLED=true
N8N_ENABLED_MODULES=instance-ai
# Uncomment when calibrating MCP grouping cases (--build-via-mcp): canvas-group
# support in the MCP tools is PostHog-gated and PostHog is unreachable locally
# (fails closed), so the env var is the only lever. Mirrors test-evals-mcp.yml.
# N8N_MCP_CANVAS_GROUPS_ENABLED=true
# Model key for the builder and the eval helper (mock generation, verifier,
# user-proxy, expectation judge). When the instance runs without the proxy, the
# model client reads ANTHROPIC_API_KEY directly, so export that too if you go

1
.gitattributes vendored
View File

@ -1 +1,2 @@
*.sh text eol=lf
docs/generated/**/* linguist-generated

1
.github/CODEOWNERS vendored
View File

@ -4,3 +4,4 @@ packages/@n8n/db/src/migrations/ @n8n-io/migrations-review
.github/actions @n8n-io/qa-dx
.github/poutine-rules @n8n-io/qa-dx
docker/get-n8n.sh @n8n-io/qa-dx
.code-health-baseline.json @n8n-io/qa-dx

View File

@ -133,14 +133,26 @@ of the breaking removal on `3.x`.
original message and author. Nothing is ever squashed. Merge commits in the range (breaking
PRs merged into `3.x`) are flattened away, and a breaking commit that also landed on
`master` is dropped as empty.
3. On a **conflict**, `3.x` is left **untouched** and a **draft conflict PR** (labeled
`automation:v3-sync`) carrying the conflict markers is opened on `sync/master-to-3x`, plus a
post to the **`#alerts-v3-sync`** Slack channel. **Syncs pause until that PR is merged** —
so conflicts never pile up silently.
3. Conflicts confined to **mechanical files** — tool-generated content with a deterministic
resolution (`pnpm-lock.yaml`, `packages/frontend/editor-ui/data/node-popularity.json`,
`.github/test-metrics/e2e-impact-map.json`) — are **auto-resolved during the replay**,
exactly as a human resolver would: the lockfile is regenerated with
`pnpm install --lockfile-only` (pnpm merges its own conflict markers), bot-maintained data
files take `master`'s side. The resolution is folded into the stalled commit, so this
still adds **no commit and no PR**. The list lives in `MECHANICAL_PATHS` in
[`sync-master-to-3x.mjs`](./scripts/sync-master-to-3x.mjs).
4. On a **real code conflict**, `3.x` is left **untouched** and a **draft conflict PR**
(labeled `automation:v3-sync`) carrying the conflict markers is opened on
`sync/master-to-3x` — with the mechanical files already pre-resolved — plus a post to the
**`#alerts-v3-sync`** Slack channel. **Syncs pause until that PR is merged** — so
conflicts never pile up silently.
Whatever route it takes, the sync verifies that the content it is about to push is **exactly
the tree a merge of `3.x` and `master` produces** (`git merge-tree`), and that no conflict
markers are present. Either check failing fails the run instead of rewriting `3.x`.
markers are present. When mechanical files had to be regenerated, the exactness check applies
to every path **except** those files, and a regenerated lockfile must additionally be
consistent with the manifests in the pushed tree. Any check failing fails the run instead of
rewriting `3.x`.
> **`3.x` is force-pushed daily.** Branch breaking-change PRs off **`master`** and target
> `3.x` — then your merge-base is a `master` commit that survives every rewrite and your
@ -152,7 +164,8 @@ markers are present. Either check failing fails the run instead of rewriting `3.
The conflict branch is `master` merged into `3.x` with the **conflict markers committed**, so
you see exactly what clashed — and the required checks stay red until they're gone, so the PR
can't be merged half-resolved:
can't be merged half-resolved. Mechanical files arrive **pre-resolved** (listed in the PR
under "Auto-resolved for you"), so only the real code conflicts need you:
```bash
git fetch origin sync/master-to-3x && git switch sync/master-to-3x
@ -160,8 +173,13 @@ git fetch origin sync/master-to-3x && git switch sync/master-to-3x
git push origin sync/master-to-3x
```
If the PR says the lockfile was **deferred** (a `package.json` / `pnpm-workspace.yaml` is
conflicted too), resolve the manifests first, then regenerate it with
`pnpm install --lockfile-only` and include the result in your fix commit.
Then **merge the PR with the normal merge button.** `master`'s commits arrive as-is and your
fix stays its own commit.
fix stays its own commit. **Never close a conflict PR unmerged** — closing resolves nothing,
the same conflict reopens on the next sync, and the new PR will call out the abandoned one.
`3.x` never holds markers at its tip, so nightly images keep building; the merge commit that
carries them drops out of `3.x`'s history at the next sync (the replay takes the queue's
@ -170,7 +188,9 @@ commits only).
The next sync then makes `3.x` linear again. The plain replay stalls at that point (a fix
recorded around a merge commit leaves no patch to replay), so it replays a second time with
`3.x`'s side favoured, and your fix commit — which is in the queue — does the real work.
Nothing is squashed, and the tree guard proves the result is exactly the merge of `3.x` and
Stalls that favouring cannot settle on its own (e.g. modify/delete, when `master` touched a
file a breaking commit deletes) are resolved toward `3.x`'s side during the replay. Nothing
is squashed, and the tree guard proves the result is exactly the merge of `3.x` and
`master`.
**Who gets pinged.** The conflict is attributed to the authors of the `3.x` commits behind the
@ -186,8 +206,24 @@ Those authors are **requested as reviewers** on the conflict PR and listed in th
```bash
docker pull n8nio/n8n:v3-nightly # latest v3 nightly
docker pull n8nio/n8n:v3-nightly-20260625 # a specific build date
docker pull n8nio/n8n:v3-rc # latest release candidate
docker pull n8nio/n8n:v3-rc-20260625 # latest RC of that day
docker pull n8nio/n8n:v3-rc-20260625.2 # one exact RC, never overwritten
```
Every Monday's nightly is also retagged as a release candidate, and a maintainer can
publish extra RCs any day (`force_rc` on a manual run). Each publish claims the next
rolling number for the day — `v3-rc-<date>.1`, `.2`, … — and moves `v3-rc` and
`v3-rc-<date>` onto it, so:
- **`v3-rc-<date>.N`** — pin this to hold a build still. Immutable.
- **`v3-rc` / `v3-rc-<date>`** — track the newest RC overall / of that day. These move.
The retag covers the whole set — `n8nio/n8n`, `n8nio/runners` and
`n8nio/runners:v3-rc[-<date>.N]-distroless` — so pinning one RC across a stack gives
images built from one `3.x` commit, unlike `v3-nightly`, which moves daily and can be
mid-build when you pull. The same tags exist on GHCR (`ghcr.io/n8n-io/…`).
Use these to trial v3 in docker/kubernetes before release. Do **not** use them in
production.

112
.github/WORKFLOWS.md vendored
View File

@ -187,7 +187,6 @@ These only run if specific files changed:
| Event | Workflow | Condition |
|----------------------------|-----------------------------|------------------------------------------------------|
| Review approved | `release-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 |
**Why Instance AI evals fire once per PR state-change, not per push:** the
@ -210,7 +209,7 @@ the lab bench.** The gate deliberately exposes only PR re-runs. Anything that
isn't PR gating — baselines, model experiments, arbitrary branch runs — goes
through `test-evals-instance-ai.yml`'s own dispatch form ("Instance AI
Evals: Experiments"): full knob set (branch, filter, tier, suite,
iterations, experiment-name, model), no per-PR cancellation (dispatches run in parallel, e.g. concurrent
iterations, experiment-name, model, model-url, model-key, reasoning-effort, supports-structured-outputs), no per-PR cancellation (dispatches run in parallel, e.g. concurrent
model-comparison arms), and SHA-keyed docker cache hits on master. Evals never
run on fork PRs: the event trigger gates on `head.repo.fork`, and the `pr`
re-run path refuses fork PRs in `resolve` (dispatched runs carry secrets).
@ -248,26 +247,8 @@ parallelism). See the `--build-via-mcp` section in
| 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
@ -287,7 +268,8 @@ ci-pull-requests.yml
ci-master.yml
├──────────────────────────▶ test-unit-reusable.yml
└──────────────────────────▶ test-linting-reusable.yml
├──────────────────────────▶ test-linting-reusable.yml
└──────────────────────────▶ test-single-instance-npm.yml
release-publish.yml
├──────────────────────────▶ docker-build-push.yml
@ -392,6 +374,7 @@ Push to master/1.x
├─ unit-test (matrix: Node 22.23.2, 24.18.1)
│ └─ Coverage only on 24.18.1
├─ lint
├─ verify-single-instance-npm (advisory; packages changed by this push)
└─ notify-on-failure (Slack #alerts-build)
```
@ -401,7 +384,8 @@ Push to master/1.x
| Schedule (UTC) | Workflow | Purpose |
|---------------------------|-----------------------------------|--------------------------|
| Hourly :00 | `sec-sync-public-to-private.yml` | Mirror public → private, refresh bundle branches |
| Hourly :00 | `sec-sync-public-to-private.yml` | Mirror public → private |
| Daily 03:00 | `sec-sync-bundle-branches.yml` | Merge the base into `bundle/*` |
| 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 |
@ -410,6 +394,7 @@ Push to master/1.x
| 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-get-n8n.yml` | get.n8n.io installer health |
| Daily 02:00 | `test-e2e-pc-nightly.yml` | E2E on the `-pc` image |
| Daily 05:00 | `test-benchmark-destroy-nightly.yml`| Cleanup benchmark env |
| Daily 06:00 | `util-sync-master-to-3x.yml` | Replay 3.x onto master (v3) |
| Daily 08:00 | `build-v3-nightly.yml` | Nightly v3 Docker images |
@ -425,13 +410,22 @@ During the v3 release window, `master` carries normal feature work (behind opt-i
flags) and the long-lived `3.x` branch carries breaking changes. `util-sync-master-to-3x.yml`
syncs daily by **replaying the `3.x`-only commits onto `master` and force-pushing `3.x`**, so a
clean sync adds no commit and nothing is squashed. What it pushes is always verified to be
exactly the tree a merge of `3.x` and `master` produces, and marker-free. On a real conflict
`3.x` is left untouched and a draft PR carrying the conflict markers (labeled
`automation:v3-sync`) is opened on `sync/master-to-3x`, requesting the breaking-commit authors
as reviewers via `sync-conflict-owners.mjs`, posting to `#alerts-v3-sync` and pausing further
syncs until it is resolved and merged normally.
exactly the tree a merge of `3.x` and `master` produces, and marker-free. Conflicts confined
to mechanical, tool-generated files (the pnpm lockfile, bot-maintained data files — see
`MECHANICAL_PATHS` in `sync-master-to-3x.mjs`) are auto-resolved during the replay; the tree
check then applies to every path except those files. On a real code conflict `3.x` is left
untouched and a draft PR carrying the conflict markers (labeled `automation:v3-sync`, with
mechanical files pre-resolved) is opened on `sync/master-to-3x`, requesting the
breaking-commit authors as reviewers via `sync-conflict-owners.mjs`, posting to
`#alerts-v3-sync` and pausing further syncs until it is resolved and merged normally.
`build-v3-nightly.yml` publishes `n8nio/n8n:v3-nightly[-<date>]` images from `3.x`
by calling `docker-build-push.yml` with `ref: 3.x` + `date_tag`.
by calling `docker-build-push.yml` with `ref: 3.x` + `date_tag`. On Mondays it also
retags that run's n8n + runners manifests as a release candidate (by digest on GHCR, so
the RC is exactly what was built), giving a self-consistent set to trial. Any manual run
can promote too via the `force_rc` dispatch input, several times a day: each publish
claims the next free `v3-rc-<date>.N` as its immutable tag and moves the floating `v3-rc`
and `v3-rc-<date>` onto it. The counter is derived by probing the registry, and the job
is serialized on a `v3-rc-tagging` concurrency group so two runs can't claim one number.
See **[`DEVELOPING_V3.md`](./DEVELOPING_V3.md)** for the full model.
@ -481,6 +475,7 @@ Workflows with `workflow_call` trigger:
| `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 |
| `test-single-instance-npm.yml` | `scope`, `base-ref`, `base-branch`, `blocking`, `timeout-minutes` | Dependency duplication |
---
@ -511,6 +506,19 @@ Scripts in `.github/scripts/`:
|-------------------------|-------------------|---------------------------|
| `validate-docs-links.js`| Check doc URLs | `util-check-docs-urls.yml`|
| `send-build-stats.mjs` | Build telemetry | `setup-nodejs` action |
| `db-test-matrix.mjs` | DB test matrix from `postgres-versions.json` | `ci-pull-requests.yml` |
### Branch Replay Scripts
Both keep a long-lived branch that is "base + its own commits" in sync by rebasing those
commits onto the base and force-pushing, sharing the merge-tree content guard that makes the
rewrite safe.
| Script | Purpose | Called By |
|----------------------------|----------------------------------------------------------------------|------------------------------------|
| `branch-replay.mjs` | Shared primitives: merge-tree, tree guard, marker scan | the two scripts below |
| `sync-master-to-3x.mjs` | master → `3.x`, rebased; auto-resolves mechanical files, opens a conflict PR | `util-sync-master-to-3x.yml` |
| `sync-bundle-branch.mjs` | base → `bundle/*` in n8n-private, merged; fail-loud, never resolves conflicts | `sec-sync-bundle-branches.yml` |
### Slack Scripts
@ -707,14 +715,44 @@ mirroring public `master` and `1.x` into private with `reset --hard` +
commits when judging "ahead". Fixes are never committed to private `master`/`1.x`
directly: `ci-restrict-private-merges.yml` requires PRs into them to come from the
long-lived integration branches `bundle/2.x` and `bundle/1.x` (a `bundle/2.x` merge is
backported to `bundle/1.x` by `util-backport-bundle.yml`). The sync creates those
branches if missing and then **merges `master` into `bundle/2.x` and `1.x` into
`bundle/1.x`** so they don't drift; on a conflict it aborts the merge, leaves the branch
untouched, and emits a warning annotation while **keeping the run green** — the other
bundle branch still syncs, and a human resolves the conflict by hand. Once a bundle
branch is merged into private `master`/`1.x` as a `chore: Bundle/*` PR,
`sec-publish-fix.yml` / `sec-publish-fix-1x.yml` cherry-pick that merge commit onto a
fresh branch in the public repo and open the PR there.
backported to `bundle/1.x` by `util-backport-bundle.yml`). Once a bundle branch is merged
into private `master`/`1.x` as a `chore: Bundle/*` PR, `sec-publish-fix.yml` /
`sec-publish-fix-1x.yml` cherry-pick that commit onto a fresh branch in the public repo and
open the PR there. That PR **must stay a single-parent squash** — the publish step is a bare
`git cherry-pick` of `HEAD`, which aborts on a merge commit.
`sec-sync-bundle-branches.yml` keeps those branches current, daily plus whenever a PR is
merged into one (and on `workflow_dispatch`). It **merges the base into** the bundle branch
via [`scripts/sync-bundle-branch.mjs`](scripts/sync-bundle-branch.mjs) and pushes without
forcing. Every push is verified to carry exactly the tree a merge of the two sides would
produce (`git merge-tree`); a mismatch, or a conflict marker, fails the run instead of pushing.
**`bundle/*` is append-only — never rebase it, never force-push it.** These branches receive
PRs, and rewriting a branch that receives PRs orphans the copies of its commits that the open
PR branches already contain: every such PR's merge base regresses to an old base commit, so
GitHub shows it carrying everyone else's fixes, in the commit list *and* in the diff (which
can then trip required checks like *PR Size Limit*). It compounds — each refresh between
rewrites picks up another duplicate generation of the same fixes and starts conflicting with
itself. To refresh a fix branch, use GitHub's **Update branch** button or
`git merge origin/bundle/2.x`; squash-merging a fix *into* the bundle branch leaves every
sibling PR's merge base untouched, which is why only a rewrite breaks this.
The costs of merging are deliberate and paid for: a merge commit per run, and fixes that have
already been published staying in the branch's log (the old rebase dropped them as empty
commits). Neither reaches anything downstream, because a bundle publishes as one squashed
commit taken from the tree rather than the history — the `chore: Bundle/*` PR's **diff** stays
exactly the pending fixes even when its commit list does not. For a list of what a bundle
actually carries, read the fix PRs merged into the branch since the last cut, not
`base..bundle`. A lower cadence than the base's is fine too: a base push never re-triggered
CI on the fix PRs, so syncing more often bought them nothing.
There is **one job per bundle branch**. A conflict is detected from the merge tree before the
working tree is touched, so the branch is left exactly as it was, that job **fails** (no
green runs hiding a stalled branch) and `#alerts-security` gets a run link — while the other
branch still syncs. Recovery is deliberate: merge the base into the branch locally, resolve,
push, then re-run the workflow — and that resolution then lives in the merge commit instead of
being re-litigated on every later run. The sync never resolves a conflict itself, unlike
`util-sync-master-to-3x.yml`.
See **[`../AGENTS.md`](../AGENTS.md)** ("Security Fix Hygiene") for the naming rules that
keep the vulnerability out of public branch names, commits, and test descriptions.
@ -772,7 +810,7 @@ Adding a new channel requires inviting the bot first; the first run otherwise fa
| 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_*` |
| AI/Evals | `EVALS_ANTHROPIC_KEY`, `EVALS_OPENAI_KEY`, `EVALS_OPENROUTER_KEY`, `EVALS_XAI_KEY`, `EVALS_BASETEN_KEY`, `EVALS_FIREWORKS_KEY`, `EVALS_TOGETHER_KEY`, `EVALS_DATABRICKS_KEY`, `EVALS_MODAL_KEY`, `EVALS_LYCEUM_KEY`, `EVALS_AZURE_FOUNDRY_KEY`, `EVALS_VERTEX_KEY`, `EVALS_VERTEX_PROJECT_ID`, `EVALS_VERTEX_LOCATION`, `EVALS_LANGSMITH_*` |
### Scoping

View File

@ -5,4 +5,6 @@ self-hosted-runner:
- blacksmith-2vcpu-ubuntu-2204-arm
- blacksmith-8vcpu-ubuntu-2204
- blacksmith-8vcpu-ubuntu-2204-arm
- blacksmith-32vcpu-ubuntu-2204
- blacksmith-32vcpu-ubuntu-2204-arm
- ubuntu-slim

View File

@ -7,9 +7,9 @@ description: 'Configures Node.js with pnpm, installs Aikido SafeChain for supply
inputs:
node-version:
description: 'Node.js version to use. Pinned to 24.18.1 by default for reproducible builds.'
description: 'Node.js version to use. Pinned to 26.5.1 by default for reproducible builds.'
required: false
default: '24.18.1'
default: '26.5.1'
enable-docker-cache:
description: 'Whether to set up Blacksmith Buildx for Docker layer caching (Blacksmith runners only).'
required: false
@ -183,7 +183,7 @@ runs:
mkdir -p "$PNPM_DIAG_DIR/pnpm-logs"
set +o pipefail
timeout --kill-after=30s 300s $INSTALL_COMMAND \
timeout --kill-after=30s 600s $INSTALL_COMMAND \
--config.logs-dir="$PNPM_DIAG_DIR/pnpm-logs" \
--reporter=append-only --config.loglevel=info 2>&1 | tee "$INSTALL_LOG"
rc=${PIPESTATUS[0]}
@ -217,7 +217,7 @@ runs:
find "$PNPM_DIAG_DIR/pnpm-logs" -type f -exec sh -c 'echo "----- $1 -----"; cat "$1"' _ {} \; 2>/dev/null || echo "(none)"
echo "::endgroup::"
case "$rc" in
124) echo "::error::pnpm install timed out after 300s (exit 124)" ;;
124) echo "::error::pnpm install timed out after 600s (exit 124)" ;;
137) echo "::error::pnpm install received SIGKILL (exit 137 — likely OOM or kill-after timeout)" ;;
esac
fi

View File

@ -1,119 +0,0 @@
# 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

View File

@ -1,179 +0,0 @@
# 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
```

View File

@ -1,6 +1,6 @@
services:
postgres:
image: postgres:16
image: ${TEST_IMAGE_POSTGRES:?set it from packages/testing/containers/postgres-versions.json}
restart: always
environment:
- POSTGRES_DB=n8n
@ -8,5 +8,7 @@ services:
- POSTGRES_PASSWORD=password
ports:
- 5432:5432
# Not PGDATA: Postgres 18+ moved it under /var/lib/postgresql/<major>/ and
# refuses to start when /var/lib/postgresql/data is mounted.
tmpfs:
- /var/lib/postgresql/data
- /var/lib/postgresql

View File

@ -11,7 +11,7 @@ A PR title consists of these elements:
| | Capitalized
| | No period at the end.
│ │
│ └─⫸ Scope: API | benchmark | core | editor | * Node
│ └─⫸ Scope: API | benchmark | core | editor | engine | * Node
│
└─⫸ Type: build | ci | chore | docs | feat | fix | perf | refactor | test
```
@ -53,6 +53,7 @@ The scope should specify the place of the commit change as long as the commit cl
- `benchmark` - changes to the benchmark cli
- `core` - changes to the core / private API / backend of n8n
- `editor` - changes to the Editor UI
- `engine` - changes to the new workflow execution engine v2
- `* 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

View File

@ -27,6 +27,7 @@ const ALLOW_REFS = [
export function parseTargets(env) {
return [
{ label: 'n8n', image: env.N8N_IMAGE, digest: env.N8N_DIGEST },
{ label: 'n8n-pc', image: env.N8N_PC_IMAGE, digest: env.N8N_PC_DIGEST },
{ label: 'runners', image: env.RUNNERS_IMAGE, digest: env.RUNNERS_DIGEST },
{ label: 'runners-distroless', image: env.DISTROLESS_IMAGE, digest: env.DISTROLESS_DIGEST },
].filter((t) => t.image && t.digest);

108
.github/scripts/branch-replay.mjs vendored Normal file
View File

@ -0,0 +1,108 @@
/**
* Primitives shared by the flows that sync a long-lived branch which is "base + its own
* commits" with that base, whether by rebasing those commits onto it or by merging it in.
*
* Nothing here knows which branch pair it is working on. The safety property every caller
* relies on lives in `mergeTree` + `assertTreeMatches`: the content pushed must be exactly
* the tree a merge of the two sides would produce, and a mismatch fails the run instead of
* pushing. That invariant is what makes rewriting a shared branch safe, and what proves a
* merge landed on the content it was supposed to.
*
* Callers: `sync-master-to-3x.mjs` (master → 3.x, rebased, with mechanical conflict
* resolution and a conflict PR) and `sync-bundle-branch.mjs` (base → bundle/*, merged
* because those branches receive PRs, fail-loud).
*
* Requires git 2.38+ (`merge-tree --write-tree`).
*/
import { execFileSync } from 'node:child_process';
// Real command runner: takes an args array and returns trimmed stdout, throwing on a
// non-zero exit (mirrors `set -e`). Injectable for tests.
export const runGit = (args, opts = {}) =>
execFileSync('git', args, { encoding: 'utf8', ...opts }).trim();
/**
* Run a command for its exit status: `{ ok, out }` instead of a throw. Used for the git
* commands whose non-zero exit is an expected answer, not a failure. stderr is captured
* rather than inherited, so an expected-to-fail probe doesn't spill into the run log.
*/
export function attempt(run, args, opts = {}) {
try {
return { ok: true, out: run(args, { stdio: ['ignore', 'pipe', 'pipe'], ...opts }) ?? '' };
} catch (error) {
const out = `${error.stdout ?? ''}\n${error.stderr ?? ''}`.trim();
return { ok: false, out };
}
}
export function isAncestor(git, maybeAncestor, descendant) {
return attempt(git, ['merge-base', '--is-ancestor', maybeAncestor, descendant]).ok;
}
/**
* The tree a merge of the two commits would produce — the definition of the content the
* replayed branch should end up with, computed without touching the working tree.
*
* `ok: false` means the two sides genuinely conflict; `conflictedPaths` then names the
* clashing files, and `tree` is still written (conflicted blobs carry their markers) —
* it is the comparison baseline for the relaxed tree guard.
*/
export function mergeTree(git, a, b) {
const res = attempt(git, ['merge-tree', '--write-tree', '--name-only', a, b]);
// Line 0 is the toplevel tree OID (written even on conflict); every line from there to
// the first blank line is a conflicted filename (`--name-only`), and the informational
// messages ("Auto-merging x", "CONFLICT (content): ...") come strictly AFTER that blank
// line. So the blank line is the only delimiter needed — do not also filter lines by
// prefix, or a real path like `CONFLICT.md` or `warning.txt` is dropped from the report.
const lines = res.out.split('\n');
const tree = lines[0]?.trim() ?? '';
let conflictedPaths = [];
if (!res.ok) {
const blank = lines.indexOf('', 1);
const section = lines.slice(1, blank === -1 ? undefined : blank);
conflictedPaths = [...new Set(section.filter((l) => l))];
}
return {
ok: res.ok,
tree: res.ok || conflictedPaths.length ? tree : '',
conflictedPaths,
out: res.out,
};
}
/**
* The content guard for every push: whichever route produced HEAD, its tree must be exactly
* what merging the two sides yields — except on `allowedPaths`, files the merge itself could
* not resolve and whose resolution the caller proves another way (always derived from the
* merge-tree conflict list, never from what the run happened to touch). With no
* `allowedPaths` this is exact equality.
*/
export function assertTreeMatches(git, expectedTree, allowedPaths = []) {
const actual = git(['rev-parse', 'HEAD^{tree}']);
if (actual === expectedTree) return;
if (allowedPaths.length === 0) {
throw new Error(
`Replayed tree ${actual} does not match the merge tree ${expectedTree}; refusing to push.`,
);
}
const out = git(['diff-tree', '-r', '--name-only', '--no-renames', expectedTree, actual]);
const allowed = new Set(allowedPaths);
const violations = (out ? out.split('\n') : []).filter((p) => p && !allowed.has(p));
if (violations.length > 0) {
throw new Error(
`Replayed tree deviates from the merge tree on non-mechanical paths:\n${violations.join('\n')}\nrefusing to push.`,
);
}
}
// Conflict markers must never reach a replayed branch — releases and nightly images build
// from these. git grep exits 0 with matches, 1 with none and >1 on error, so an error must
// not be read as "clean".
export function assertNoMarkers(git, rev = 'HEAD') {
const found = attempt(git, ['grep', '-I', '-l', '-e', '^<<<<<<< ', '-e', '^>>>>>>> ', rev]);
if (found.ok)
throw new Error(`Refusing to continue: conflict markers present in ${rev}:\n${found.out}`);
if (found.out.includes('fatal:'))
throw new Error(`Could not scan ${rev} for conflict markers:\n${found.out}`);
}

46
.github/scripts/branch-replay.test.mjs vendored Normal file
View File

@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { mergeTree } from './branch-replay.mjs';
const fail =
(stdout = '') =>
() => {
const err = new Error('command failed');
err.status = 1;
err.stdout = stdout;
throw err;
};
// Verbatim `git merge-tree --write-tree --name-only` output (git 2.50) for a conflict on four
// root-level files, three of which are named like git's own informational messages. The path
// section runs to the blank line; the messages come after it.
const REAL_OUTPUT = `d40195cb2b4b58a22942313bcdd3e30497d48e37
Auto-merging
CONFLICT.md
normal.txt
warning.txt
Auto-merging Auto-merging
CONFLICT (content): Merge conflict in Auto-merging
Auto-merging CONFLICT.md
CONFLICT (content): Merge conflict in CONFLICT.md
Auto-merging normal.txt
CONFLICT (content): Merge conflict in normal.txt
Auto-merging warning.txt
CONFLICT (content): Merge conflict in warning.txt`;
test('mergeTree reports conflicted paths that look like git informational messages', () => {
const git = () => fail(REAL_OUTPUT)();
const res = mergeTree(git, 'A', 'B');
assert.equal(res.ok, false);
assert.equal(res.tree, 'd40195cb2b4b58a22942313bcdd3e30497d48e37');
// Filtering these by prefix would silently drop three of the four conflicts.
assert.deepEqual(res.conflictedPaths, [
'Auto-merging',
'CONFLICT.md',
'normal.txt',
'warning.txt',
]);
});

View File

@ -5,6 +5,7 @@ import { resolve } from 'path';
import child_process from 'child_process';
import { promisify } from 'util';
import assert from 'assert';
import { getMonorepoProjects } from './pnpm-utils.mjs';
const exec = promisify(child_process.exec);
@ -184,13 +185,7 @@ async function bumpVersions() {
releaseType === 'experimental'
? (await exec('git rev-parse --short=8 HEAD')).stdout.trim()
: undefined;
const packages = JSON.parse(
(
await exec(
`pnpm ls -r --only-projects --json | jq -r '[.[] | { name: .name, version: .version, path: .path, private: .private}]'`,
)
).stdout,
);
const packages = await getMonorepoProjects();
/** @type {Record<string, { path: string, isDirty: boolean, version: string, nextVersion?: string }>} */
const packageMap = {};
@ -290,5 +285,10 @@ async function bumpVersions() {
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
bumpVersions();
try {
await bumpVersions();
} catch (error) {
console.error(error);
process.exit(1);
}
}

View File

@ -1,75 +0,0 @@
#!/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 if (task.startsWith('/')) {
// Task is a skill invocation (e.g. "/n8n:linear-issue CAT-2820").
// Wrap it so the model invokes the Skill tool instead of implementing code.
prompt = `# Skill Invocation
Invoke the following skill using the Skill tool and follow its instructions.
${task}`;
} else {
// List available templates so Claude knows what exists (reads them if needed)
const templateDir = '.github/claude-templates';
let templateSection = '';
try {
const files = readdirSync(templateDir).filter((f) => f.endsWith('.md'));
if (files.length > 0) {
const listing = files.map((f) => ` - ${templateDir}/${f}`).join('\n');
templateSection = `\n# Templates\nThese guides are available if relevant to your task. Read any that match before starting:\n${listing}`;
}
} catch {
// No templates directory, skip
}
prompt = `# Task
${task}
${templateSection}
# Instructions
1. Read any relevant templates listed above before starting
2. Complete the task described above
3. Make commits as you work - the last commit message will be used as the PR title
4. IMPORTANT: End every commit message with: Co-authored-by: Claude <noreply@anthropic.com>
5. Ensure code passes linting and type checks before finishing
# Token Optimization
When running lint/typecheck, suppress verbose output:
pnpm lint 2>&1 | tail -30
pnpm typecheck 2>&1 | tail -30`;
}
// Random delimiter guarantees no collision with user content
const delimiter = `CLAUDE_PROMPT_DELIM_${randomUUID().replace(/-/g, '')}`;
appendFileSync(envFile, `CLAUDE_PROMPT<<${delimiter}\n${prompt}\n${delimiter}\n`);

View File

@ -1,59 +0,0 @@
#!/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);
}

View File

@ -1,6 +1,7 @@
// Creates backport PR's according to labels on merged PR
import {
getEventFromGithubEventPath,
getPullRequestById,
readPrLabels,
resolveRcBranchForTrack,
@ -85,7 +86,19 @@ async function fetchPossiblePullRequestFromEnv() {
export async function getLabels() {
const pullRequest = await fetchPossiblePullRequestFromEnv();
return new Set(readPrLabels(pullRequest));
if (pullRequest) {
return new Set(readPrLabels(pullRequest));
}
const event = getEventFromGithubEventPath();
// On a `labeled` event only the added label counts — other backport labels
// on the PR were already handled when they were added (or on merge), and
// reprocessing them would recreate those backport PRs.
if (event.action === 'labeled' && event.label?.name) {
return new Set([event.label.name]);
}
return new Set(readPrLabels(event.pull_request));
}
async function main() {

View File

@ -1,6 +1,6 @@
import { describe, it, mock, before } from 'node:test';
import assert from 'node:assert/strict';
import { readPrLabels } from './github-helpers.mjs';
import { getEventFromGithubEventPath, readPrLabels } from './github-helpers.mjs';
/**
* Run these tests by running
@ -13,6 +13,7 @@ import { readPrLabels } from './github-helpers.mjs';
mock.module('./github-helpers.mjs', {
namedExports: {
ensureEnvVar: () => {}, // no-op
getEventFromGithubEventPath: getEventFromGithubEventPath,
readPrLabels: readPrLabels,
resolveRcBranchForTrack: mockResolveRcBranchForTrack,
writeGithubOutput: () => {}, //no-op
@ -77,6 +78,15 @@ describe('Compute backport targets', () => {
assert.ok(labels.has('release'));
assert.ok(labels.has('Backport to Stable'));
});
it('Should only use the added label on a labeled event', async () => {
process.env.GITHUB_EVENT_PATH = './fixtures/mock-github-event-labeled.json';
/** @type { Set<string> } */
const labels = await getLabels();
assert.equal(labels.size, 1);
assert.ok(labels.has('Backport to Beta'));
});
it('Should parse labels properly in manual workflow context', async () => {
process.env.PULL_REQUEST_ID = '123';
/** @type { Set<string> } */

95
.github/scripts/db-test-matrix.mjs vendored Normal file
View File

@ -0,0 +1,95 @@
// Builds the job matrix for test-db-reusable.yml. Node builtins only: it runs in a
// job that installs the monorepo's dependencies, not this folder's.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
export const POSTGRES_VERSIONS_PATH = 'packages/testing/containers/postgres-versions.json';
/** The matrix wall is bounded by its slowest leg, so a larger runner buys nothing. */
const RUNNER = 'blacksmith-4vcpu-ubuntu-2204';
/**
* @typedef {Object} PostgresVersions
* @property {string} primary
* @property {Array<{ major: number, image: string, support?: string }>} matrix
*/
/** @returns {PostgresVersions} */
export function readPostgresVersions(repoRoot = REPO_ROOT) {
const file = path.join(repoRoot, POSTGRES_VERSIONS_PATH);
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
/**
* Coverage and the schema-docs check run on the primary Postgres leg only, since
* the committed docs come from that one version.
*
* @param {PostgresVersions} versions
*/
export function buildMatrix(versions) {
const { primary, matrix } = versions;
if (!Array.isArray(matrix) || matrix.length === 0) {
throw new Error(`${POSTGRES_VERSIONS_PATH}: "matrix" must be a non-empty array`);
}
// Checked first: everything below treats the last entry as the newest.
const majors = matrix.map((entry) => entry.major);
for (let i = 1; i < majors.length; i++) {
if (majors[i] <= majors[i - 1]) {
throw new Error(
`${POSTGRES_VERSIONS_PATH}: "matrix" must be sorted by ascending major, got ${majors.join(', ')}`,
);
}
}
const newest = matrix[matrix.length - 1];
if (newest.image !== primary) {
throw new Error(
`${POSTGRES_VERSIONS_PATH}: "primary" (${primary}) must be the newest "matrix" entry's image (${newest.image})`,
);
}
// An exact minor keeps CI reproducible across Postgres point releases.
for (const { major, image } of matrix) {
const pinned = /^postgres:(\d+)\.\d+-alpine$/.exec(image);
if (!pinned) {
throw new Error(
`${POSTGRES_VERSIONS_PATH}: "${image}" must pin an exact minor, e.g. postgres:${major}.4-alpine`,
);
}
if (Number(pinned[1]) !== major) {
throw new Error(`${POSTGRES_VERSIONS_PATH}: "${image}" does not match major ${major}`);
}
}
return [
{
name: 'SQLite Pooled',
runner: RUNNER,
'test-cmd': 'pnpm test:sqlite',
'migration-cmd': 'pnpm test:sqlite:migrations',
'schema-check-cmd': 'pnpm --filter=@n8n/db schema:check:sqlite',
TEST_IMAGE_POSTGRES: undefined,
collectCoverage: 'false',
},
...matrix.map(({ major, image }) => ({
name: `Postgres ${major}`,
runner: RUNNER,
'test-cmd': 'pnpm test:postgres:integration:tc',
'migration-cmd': 'pnpm test:postgres:migrations:tc',
'schema-check-cmd': image === primary ? 'pnpm --filter=@n8n/db schema:check:postgres' : '',
TEST_IMAGE_POSTGRES: image,
collectCoverage: image === primary ? 'true' : 'false',
})),
];
}
// Skipped when imported by the tests.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
console.log(JSON.stringify(buildMatrix(readPostgresVersions())));
}

128
.github/scripts/db-test-matrix.test.mjs vendored Normal file
View File

@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { buildMatrix, readPostgresVersions, POSTGRES_VERSIONS_PATH } from './db-test-matrix.mjs';
// node --test ./.github/scripts/db-test-matrix.test.mjs
const versions = () => ({
primary: 'postgres:18.4-alpine',
matrix: [
{ major: 16, support: 'compatibility', image: 'postgres:16.14-alpine' },
{ major: 17, support: 'supported', image: 'postgres:17.10-alpine' },
{ major: 18, support: 'supported', image: 'postgres:18.4-alpine' },
],
});
describe('postgres-versions.json', () => {
it('is valid, so the DB workflow can build a matrix from it', () => {
assert.doesNotThrow(() => buildMatrix(readPostgresVersions()));
});
it('covers the supported range plus one compatibility major', () => {
const { matrix } = readPostgresVersions();
const supported = matrix.filter((entry) => entry.support === 'supported');
const compatibility = matrix.filter((entry) => entry.support === 'compatibility');
assert.equal(supported.length, 2, `${POSTGRES_VERSIONS_PATH}: expected two supported majors`);
assert.equal(
compatibility.length,
1,
`${POSTGRES_VERSIONS_PATH}: expected one compatibility major`,
);
assert.ok(compatibility[0].major < Math.min(...supported.map((entry) => entry.major)));
});
});
describe('buildMatrix', () => {
it('emits one leg per database', () => {
const legs = buildMatrix(versions());
assert.deepEqual(
legs.map((leg) => leg.name),
['SQLite Pooled', 'Postgres 16', 'Postgres 17', 'Postgres 18'],
);
});
it('passes each Postgres leg its own pinned image', () => {
const legs = buildMatrix(versions());
assert.deepEqual(
legs.filter((leg) => leg.TEST_IMAGE_POSTGRES).map((leg) => leg.TEST_IMAGE_POSTGRES),
['postgres:16.14-alpine', 'postgres:17.10-alpine', 'postgres:18.4-alpine'],
);
assert.equal(legs[0].TEST_IMAGE_POSTGRES, undefined, 'SQLite leg needs no Postgres image');
});
it('runs every leg against both the integration and the migration suite', () => {
for (const leg of buildMatrix(versions())) {
assert.ok(leg['test-cmd'], `${leg.name} has no test command`);
assert.ok(leg['migration-cmd'], `${leg.name} has no migration command`);
}
});
it('collects coverage and checks schema docs on the primary Postgres leg only', () => {
const legs = buildMatrix(versions());
const collecting = legs.filter((leg) => leg.collectCoverage === 'true');
assert.deepEqual(
collecting.map((leg) => leg.name),
['Postgres 18'],
);
const checkingPostgresSchema = legs.filter(
(leg) => leg['schema-check-cmd'] === 'pnpm --filter=@n8n/db schema:check:postgres',
);
assert.deepEqual(
checkingPostgresSchema.map((leg) => leg.name),
['Postgres 18'],
);
const skipping = legs.filter((leg) => leg['schema-check-cmd'] === '');
assert.deepEqual(
skipping.map((leg) => leg.name),
['Postgres 16', 'Postgres 17'],
);
});
it('still checks the SQLite schema docs', () => {
const legs = buildMatrix(versions());
assert.equal(legs[0]['schema-check-cmd'], 'pnpm --filter=@n8n/db schema:check:sqlite');
});
it('rejects a primary that is not the newest major', () => {
const stale = { ...versions(), primary: 'postgres:17.10-alpine' };
assert.throws(() => buildMatrix(stale), /"primary".*must be the newest/);
});
it('rejects majors that are not in ascending order', () => {
const unsorted = versions();
[unsorted.matrix[0], unsorted.matrix[1]] = [unsorted.matrix[1], unsorted.matrix[0]];
assert.throws(() => buildMatrix(unsorted), /ascending major, got 17, 16, 18/);
});
it('rejects a floating image tag', () => {
const floating = {
primary: 'postgres:18-alpine',
matrix: [{ major: 18, image: 'postgres:18-alpine' }],
};
assert.throws(() => buildMatrix(floating), /must pin an exact minor/);
});
it('rejects an image whose major disagrees with its entry', () => {
const mismatched = {
primary: 'postgres:17.10-alpine',
matrix: [{ major: 18, image: 'postgres:17.10-alpine' }],
};
assert.throws(() => buildMatrix(mismatched), /does not match major 18/);
});
it('rejects an empty matrix', () => {
assert.throws(() => buildMatrix({ primary: 'postgres:18.4-alpine', matrix: [] }), /non-empty/);
});
});

View File

@ -14,19 +14,10 @@
* 1 – One or more public packages have never been published
*/
import child_process from 'child_process';
import { promisify } from 'util';
import { writeGithubOutput } from './github-helpers.mjs';
import { getMonorepoProjects } from './pnpm-utils.mjs';
const exec = promisify(child_process.exec);
const packages = JSON.parse(
(
await exec(
`pnpm ls -r --only-projects --json | jq -r '[.[] | { name:.name, private: .private}]'`,
)
).stdout,
);
const packages = await getMonorepoProjects();
const newPackages = [];

View File

@ -18,6 +18,11 @@ class TagGenerator {
versionSuffix = '-distroless';
}
if (image === 'n8n-pc') {
imageName = 'n8n';
versionSuffix = '-pc';
}
const platformSuffix = platform ? `-${platform.split('/').pop()}` : '';
const fullVersion = `${version}${versionSuffix}${platformSuffix}`;
@ -82,12 +87,12 @@ class TagGenerator {
}
generateAll({ version, platform, includeDockerHub = false, sha = '', date = '' }) {
const images = ['n8n', 'runners', 'runners-distroless'];
const images = ['n8n', 'n8n-pc', 'runners', 'runners-distroless'];
const results = {};
for (const image of images) {
const tags = this.generate({ image, version, platform, includeDockerHub, sha, date });
const prefix = image.replace('-distroless', '_distroless');
const prefix = image.replaceAll('-', '_');
results[prefix] = tags;
if (this.githubOutput) {

View File

@ -7,6 +7,7 @@
*
* Environment variables:
* N8N_TAG - Full image reference for n8n image
* N8N_PC_TAG - Full image reference for the pointer-compressed 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)
@ -36,17 +37,21 @@ function setOutput(name, value) {
}
const n8nTag = process.env.N8N_TAG || '';
const n8nPcTag = process.env.N8N_PC_TAG || '';
const runnersTag = process.env.RUNNERS_TAG || '';
const distrolessTag = process.env.DISTROLESS_TAG || '';
const results = {
n8n: { digest: getDigest(n8nTag), image: getImageName(n8nTag) },
n8n_pc: { digest: getDigest(n8nPcTag), image: getImageName(n8nPcTag) },
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('n8n_pc_digest', results.n8n_pc.digest);
setOutput('n8n_pc_image', results.n8n_pc.image);
setOutput('runners_digest', results.runners.digest);
setOutput('runners_image', results.runners.image);
setOutput('runners_distroless_digest', results.runners_distroless.digest);
@ -54,10 +59,12 @@ setOutput('runners_distroless_image', results.runners_distroless.image);
console.log('=== Manifest Digests ===');
console.log(`n8n: ${results.n8n.digest || 'N/A'}`);
console.log(`n8n-pc: ${results.n8n_pc.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(`n8n-pc: ${results.n8n_pc.image || 'N/A'}`);
console.log(`runners: ${results.runners.image || 'N/A'}`);
console.log(`runners-distroless: ${results.runners_distroless.image || 'N/A'}`);

View File

@ -14,7 +14,9 @@ const REQUIRED_FEATURES = ['ssl', 'sasl_scram'];
const COMPRESSION_CODECS = ['gzip', 'snappy', 'lz4', 'zstd'];
const n8nInstallDir = process.env.N8N_INSTALL_DIR || '/usr/local/lib/node_modules/n8n';
const nodesBasePackageJson = path.join(n8nInstallDir, 'node_modules/n8n-nodes-base/package.json');
const nodesBasePackageJson =
process.env.NODES_BASE_PACKAGE_JSON ||
path.join(n8nInstallDir, 'node_modules/n8n-nodes-base/package.json');
// n8n-nodes-base is a pnpm symlink; resolve it so require() walks up from its real
// location in the pnpm virtual store, where its dependencies actually live.
const require = createRequire(realpathSync(nodesBasePackageJson));

View File

@ -1,21 +1,12 @@
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);
import { getMonorepoProjects } from './pnpm-utils.mjs';
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 | jq -r '[.[] | { name: .name, version: .version, path: .path, private: .private}]'`,
)
).stdout,
);
const packages = await getMonorepoProjects();
for (let { name, path, version, private: isPrivate } of packages) {
if (isPrivate) continue;

View File

@ -0,0 +1,7 @@
{
"action": "labeled",
"label": { "name": "Backport to Beta" },
"pull_request": {
"labels": ["release", "Backport to Stable", "Backport to Beta"]
}
}

View File

@ -1,7 +1,7 @@
import child_process from 'child_process';
import { promisify } from 'node:util';
const exec = promisify(child_process.exec);
const execFile = promisify(child_process.execFile);
/**
* @typedef PnpmPackage
@ -15,11 +15,28 @@ const exec = promisify(child_process.exec);
* @returns { Promise<PnpmPackage[]> }
* */
export async function getMonorepoProjects() {
return JSON.parse(
(
await exec(
`pnpm ls -r --only-projects --json | jq -r '[.[] | { name: .name, version: .version, path: .path, private: .private}]'`,
)
).stdout,
);
let stdout;
// No shell and no `| jq`: a pipeline hides pnpm's exit code (jq exits 0 on
// empty input), which turns a failing pnpm into an empty package list.
try {
({ stdout } = await execFile('pnpm', ['ls', '-r', '--only-projects', '--json'], {
// Unprojected output is ~600KB and grows with the workspace.
maxBuffer: 64 * 1024 * 1024,
}));
} catch (error) {
const details = error.stderr?.trim() || error.message;
throw new Error(`\`pnpm ls -r --only-projects --json\` failed: ${details}`);
}
if (!stdout.trim()) {
throw new Error('`pnpm ls -r --only-projects --json` produced no output');
}
return JSON.parse(stdout).map(({ name, version, path, private: isPrivate }) => ({
name,
version,
path,
private: Boolean(isPrivate),
}));
}

181
.github/scripts/sync-bundle-branch.mjs vendored Normal file
View File

@ -0,0 +1,181 @@
#!/usr/bin/env node
/**
* Keeps a bundle integration branch (`bundle/2.x`, `bundle/1.x` in n8n-io/n8n-private) in
* sync with its base by MERGING the base into it.
*
* These branches are APPEND-ONLY: fix PRs target them, and rewriting a branch that receives
* PRs orphans the copies of its commits those PR branches already contain — every such PR's
* merge base regresses to an old base commit, and GitHub then shows it carrying everyone
* else's fixes, in the commit list and in the diff. It compounds: each time an author
* refreshes their branch between rewrites, it picks up another generation of the same fixes
* and starts conflicting with itself. Squash-merging a fix INTO the branch is safe, which is
* why only a rewrite breaks this.
*
* The cost of merging is a merge commit per run, plus already-published fixes staying in the
* branch's log (the rebase used to drop them as empty). Neither reaches anything downstream:
* a bundle is published as one squashed, deliberately obfuscated commit, and the squash is
* taken from the tree, not the history. For a list of what a bundle actually carries, read
* the fix PRs merged into the branch since the last cut, not `base..bundle`.
*
* The content pushed is verified to be exactly the tree `git merge-tree` says a merge of the
* two sides produces; a mismatch, or a conflict marker, fails the run instead of pushing.
*
* FAIL LOUD: this script never resolves a conflict. A conflict is detected before the working
* tree is touched, so the branch is left exactly as it was; a human merges the base in
* locally, resolves, and pushes — and that resolution then lives in the merge commit instead
* of being re-litigated on every later run. Contrast `sync-master-to-3x.mjs`, which
* auto-resolves mechanical files and opens a conflict PR.
*
* Runs from a full checkout (fetch-depth 0) of any branch. Assumes credentials are NOT
* persisted by checkout — every fetch and the push go through an explicit token URL, which
* matters because the repository is private.
*
* Env: BUNDLE_BRANCH (e.g. bundle/2.x), BASE_BRANCH (e.g. master),
* GH_TOKEN (installation token with contents:write),
* GITHUB_REPOSITORY (owner/repo, auto-provided by Actions).
* Requires git 2.38+ (`merge-tree --write-tree`).
*/
import {
assertNoMarkers,
assertTreeMatches,
attempt,
isAncestor,
mergeTree,
runGit,
} from './branch-replay.mjs';
const BOT_NAME = 'n8n-assistant[bot]';
const BOT_EMAIL = 'n8n-assistant[bot]@users.noreply.github.com';
function required(env, name) {
const value = env[name];
if (!value) throw new Error(`${name} env var is required`);
return value;
}
// A GitHub annotation is one line: fold anything multi-line into the URL-escaped form so the
// whole message survives in the run's error list.
export function annotation(title, message) {
return `::error title=${title}::${message.replace(/\n/g, '%0A')}`;
}
function createBundleBranch({ git, log, bundle, base, baseSha, remote }) {
log(`${bundle} does not exist yet; creating it at ${base} ${baseSha}.`);
git(['checkout', '--force', '-B', bundle, baseSha]);
const push = attempt(git, ['push', remote, `HEAD:refs/heads/${bundle}`]);
if (!push.ok) {
if (push.out) log(push.out);
return { status: 'rejected' };
}
log(`Created ${bundle} at ${base} ${baseSha}.`);
return { status: 'created' };
}
/**
* One fetch-merge-push cycle. `status: 'rejected'` means the branch moved under us — the
* caller re-runs from a fresh fetch rather than forcing anything.
*/
function mergeBaseIntoBundle({ git, log, bundle, base, remote }) {
// Pin both sides to the fetched SHAs — fetching by URL never updates the origin/* tracking
// refs, so FETCH_HEAD is the only handle. Base first: the second fetch overwrites it.
git(['fetch', remote, base]);
const baseSha = git(['rev-parse', 'FETCH_HEAD']);
const listed = attempt(git, ['ls-remote', '--heads', remote, `refs/heads/${bundle}`]);
if (!listed.ok) {
throw new Error(`Could not check whether ${bundle} exists on the remote:\n${listed.out}`);
}
if (!listed.out) {
return createBundleBranch({ git, log, bundle, base, baseSha, remote });
}
git(['fetch', remote, bundle]);
git(['checkout', '--force', '-B', bundle, 'FETCH_HEAD']);
const preHead = git(['rev-parse', 'HEAD']);
if (isAncestor(git, baseSha, preHead)) {
log(`${bundle} already contains ${base} (${baseSha}); nothing to sync.`);
return { status: 'current' };
}
const pending = git([
'log',
'--no-merges',
'--reverse',
'--format=%h %s',
`${baseSha}..${preHead}`,
]);
log(`Merging ${base} ${baseSha} into ${bundle}. Commits on ${bundle}:\n${pending || '(none)'}`);
// The content the merge must produce — and whether the two sides conflict at all. Computed
// without touching the working tree, so a conflict costs the branch nothing.
const merged = mergeTree(git, preHead, baseSha);
if (!merged.ok) {
const paths = merged.conflictedPaths;
log(
annotation(
`${bundle} is out of sync`,
`${base} conflicts with ${bundle} on: ${paths.join(', ') || '(unknown)'}\n` +
`${bundle} is untouched. Merge ${base} into ${bundle} locally, resolve, push, then re-run this workflow.`,
),
);
throw new Error(`${base} conflicts with ${bundle}; leaving ${bundle} untouched.`);
}
// Fast-forward when the branch carries nothing of its own, so an empty queue records no
// merge. Otherwise the bundle stays the first parent, keeping `--first-parent` a list of
// the fixes. An explicit subject, because merging a raw SHA would otherwise read
// `Merge commit '<sha>'`.
git(['merge', '--no-edit', '-m', `Merge ${base} into ${bundle}`, baseSha]);
assertTreeMatches(git, merged.tree);
assertNoMarkers(git);
const push = attempt(git, ['push', remote, `HEAD:refs/heads/${bundle}`]);
if (!push.ok) {
// Not a force push, so this is git refusing a non-fast-forward: a fix landed mid-run.
if (push.out) log(push.out);
return { status: 'rejected' };
}
log(`Merged ${base} into ${bundle} — nothing rewritten.`);
return { status: 'merged' };
}
export function syncBundleBranch({ git = runGit, env = process.env, log = console.log } = {}) {
const bundle = required(env, 'BUNDLE_BRANCH');
const base = required(env, 'BASE_BRANCH');
const token = env.GH_TOKEN || env.GITHUB_TOKEN;
const repo = required(env, 'GITHUB_REPOSITORY');
if (!token) throw new Error('GH_TOKEN / GITHUB_TOKEN env var is required');
git(['config', 'user.name', BOT_NAME]);
git(['config', 'user.email', BOT_EMAIL]);
// A merge must never sit waiting for an editor.
git(['config', 'core.editor', 'true']);
// Every remote operation goes through this URL: checkout does not persist credentials, and
// the repository is private, so `git fetch origin` on its own is unauthenticated.
const remote = `https://x-access-token:${token}@github.com/${repo}.git`;
const first = mergeBaseIntoBundle({ git, log, bundle, base, remote });
if (first.status !== 'rejected') return first;
log(`${bundle} moved while syncing; retrying from a fresh fetch.`);
const retry = mergeBaseIntoBundle({ git, log, bundle, base, remote });
if (retry.status === 'rejected') {
throw new Error(`${bundle} kept moving while syncing; re-run this workflow.`);
}
return retry;
}
// Only run when executed directly, not when imported by tests.
if (import.meta.url === `file://${process.argv[1]}`) {
try {
syncBundleBranch();
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}

View File

@ -0,0 +1,244 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { annotation, syncBundleBranch } from './sync-bundle-branch.mjs';
// A git stub: routes calls by a matcher, records every invocation.
function makeStub(routes = []) {
const calls = [];
const fn = (args) => {
calls.push(args);
for (const [match, result] of routes) {
if (match(args)) return typeof result === 'function' ? result(args) : result;
}
return '';
};
fn.calls = calls;
return fn;
}
const fail =
(stdout = '') =>
() => {
const err = new Error('command failed');
err.status = 1;
err.stdout = stdout;
throw err;
};
const PRE_HEAD = 'PREHEAD';
const BASE = 'BASESHA';
const MERGE_TREE = 'MERGETREEOID';
const REMOTE = 'https://x-access-token:tok@github.com/n8n-io/n8n-private.git';
const env = {
BUNDLE_BRANCH: 'bundle/2.x',
BASE_BRANCH: 'master',
GH_TOKEN: 'tok',
GITHUB_REPOSITORY: 'n8n-io/n8n-private',
};
const isMerge = (a) => a[0] === 'merge';
const isPush = (a) => a[0] === 'push';
// What `git merge-tree --write-tree --name-only` emits on a conflict: the (marker-carrying)
// tree OID, the conflicted paths, a blank line, then informational messages.
const conflictedMergeTree = (...paths) =>
fail(`${MERGE_TREE}\n${paths.join('\n')}\n\nCONFLICT (content): Merge conflict in ${paths[0]}`);
// Routes shared by every path: base fetched, the bundle branch hasn't absorbed it yet, the
// merge tree is computable, and the merge lands on exactly that tree. git grep exits
// non-zero => no markers.
const baseGitRoutes = [
[(a) => a[0] === 'ls-remote', `${PRE_HEAD}\trefs/heads/bundle/2.x`],
[(a) => a[0] === 'rev-parse' && a[1] === 'FETCH_HEAD', BASE],
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', PRE_HEAD],
[(a) => a[0] === 'merge-base', fail()],
[(a) => a[0] === 'merge-tree', MERGE_TREE],
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', MERGE_TREE],
[(a) => a[0] === 'grep', fail()],
];
const silent = () => {};
test('a missing branch env var fails before any git command runs', () => {
const git = makeStub();
assert.throws(() => syncBundleBranch({ git, env: { ...env, BUNDLE_BRANCH: '' }, log: silent }), {
message: /BUNDLE_BRANCH env var is required/,
});
assert.equal(git.calls.length, 0);
});
test('a clean sync merges the base in and pushes without forcing', () => {
const git = makeStub(baseGitRoutes);
const result = syncBundleBranch({ git, env, log: silent });
assert.deepEqual(result, { status: 'merged' });
assert.deepEqual(
git.calls.find((a) => a[0] === 'checkout'),
['checkout', '--force', '-B', 'bundle/2.x', 'FETCH_HEAD'],
);
assert.deepEqual(git.calls.find(isMerge), [
'merge',
'--no-edit',
'-m',
'Merge master into bundle/2.x',
BASE,
]);
// No lease and no --force: the branch is append-only, so a non-fast-forward must be
// refused by git rather than resolved by overwriting whatever landed.
assert.deepEqual(git.calls.find(isPush), ['push', REMOTE, 'HEAD:refs/heads/bundle/2.x']);
});
test('both fetches are authenticated — the private repo rejects an anonymous origin fetch', () => {
const git = makeStub(baseGitRoutes);
syncBundleBranch({ git, env, log: silent });
assert.deepEqual(
git.calls.filter((a) => a[0] === 'fetch'),
[
['fetch', REMOTE, 'master'],
['fetch', REMOTE, 'bundle/2.x'],
],
);
});
test('a bundle branch that already contains its base is left alone', () => {
const git = makeStub([
[(a) => a[0] === 'merge-base', ''], // --is-ancestor succeeds
...baseGitRoutes,
]);
const result = syncBundleBranch({ git, env, log: silent });
assert.deepEqual(result, { status: 'current' });
assert.equal(git.calls.some(isMerge), false);
assert.equal(git.calls.some(isPush), false);
});
test('a conflicting base fails without touching the branch', () => {
const git = makeStub([
[(a) => a[0] === 'merge-tree', conflictedMergeTree('packages/cli/src/a.ts')],
...baseGitRoutes,
]);
const logs = [];
assert.throws(() => syncBundleBranch({ git, env, log: (m) => logs.push(m) }), {
message: /master conflicts with bundle\/2\.x/,
});
// The conflict is detected from the merge tree alone: no merge, no push.
assert.equal(git.calls.some(isMerge), false);
assert.equal(git.calls.some(isPush), false);
assert.match(logs.join('\n'), /::error title=bundle\/2\.x is out of sync::/);
assert.match(logs.join('\n'), /packages\/cli\/src\/a\.ts/);
});
test('a merged tree that is not the merge tree refuses to push', () => {
const git = makeStub([
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'SOMEOTHERTREE'],
...baseGitRoutes,
]);
assert.throws(() => syncBundleBranch({ git, env, log: silent }), {
message: /does not match the merge tree MERGETREEOID; refusing to push/,
});
assert.equal(git.calls.some(isPush), false);
});
test('conflict markers in the merged tree refuse to push', () => {
const git = makeStub([
[(a) => a[0] === 'grep', 'packages/cli/src/a.ts'], // git grep found markers
...baseGitRoutes,
]);
assert.throws(() => syncBundleBranch({ git, env, log: silent }), {
message: /conflict markers present in HEAD/,
});
assert.equal(git.calls.some(isPush), false);
});
test('a fix landing mid-run is retried from a fresh fetch, never forced', () => {
let pushes = 0;
const git = makeStub([
[
isPush,
() => {
pushes += 1;
if (pushes === 1) throw Object.assign(new Error('rejected'), { status: 1 });
return '';
},
],
...baseGitRoutes,
]);
const result = syncBundleBranch({ git, env, log: silent });
assert.deepEqual(result, { status: 'merged' });
assert.equal(pushes, 2);
// The retry re-fetches rather than reusing the tip it already merged onto.
assert.equal(git.calls.filter((a) => a[0] === 'fetch').length, 4);
assert.equal(
git.calls.every((a) => !a.some((arg) => String(arg).includes('force-with-lease'))),
true,
);
});
test('a branch that keeps moving fails instead of forcing', () => {
const git = makeStub([[isPush, fail('non-fast-forward')], ...baseGitRoutes]);
assert.throws(() => syncBundleBranch({ git, env, log: silent }), {
message: /bundle\/2\.x kept moving while syncing/,
});
});
test('a bundle branch that does not exist yet is created at its base', () => {
const git = makeStub([[(a) => a[0] === 'ls-remote', ''], ...baseGitRoutes]);
const result = syncBundleBranch({ git, env, log: silent });
assert.deepEqual(result, { status: 'created' });
assert.deepEqual(
git.calls.find((a) => a[0] === 'checkout'),
['checkout', '--force', '-B', 'bundle/2.x', BASE],
);
assert.equal(git.calls.some(isMerge), false);
assert.deepEqual(
git.calls.filter((a) => a[0] === 'fetch'),
[['fetch', REMOTE, 'master']],
);
assert.deepEqual(git.calls.find(isPush), ['push', REMOTE, 'HEAD:refs/heads/bundle/2.x']);
});
test('a remote that cannot be listed fails instead of re-creating the branch', () => {
const git = makeStub([[(a) => a[0] === 'ls-remote', fail()], ...baseGitRoutes]);
assert.throws(() => syncBundleBranch({ git, env, log: silent }), {
message: /Could not check whether bundle\/2\.x exists on the remote/,
});
assert.equal(git.calls.some(isPush), false);
});
test('a branch created under us mid-run is merged into on the retry', () => {
let lsRemotes = 0;
let pushes = 0;
const git = makeStub([
[
(a) => a[0] === 'ls-remote',
() => (++lsRemotes === 1 ? '' : `${PRE_HEAD}\trefs/heads/bundle/2.x`),
],
[
isPush,
() => {
if (++pushes === 1) throw Object.assign(new Error('rejected'), { status: 1 });
return '';
},
],
...baseGitRoutes,
]);
const result = syncBundleBranch({ git, env, log: silent });
assert.deepEqual(result, { status: 'merged' });
assert.equal(pushes, 2);
assert.equal(git.calls.filter(isMerge).length, 1);
});
test('annotation keeps a multi-line message on one line', () => {
assert.equal(annotation('t', 'a\nb'), '::error title=t::a%0Ab');
});

View File

@ -80,16 +80,35 @@ export async function resolveLogins(repo, shas, token, fetchFn = fetch) {
}
// Build the conflict-PR body, reviewer CSV, and Slack owner line.
export function buildOutputs({ syncBranch, targetBranch = '3.x', files, owners }) {
export function buildOutputs({
syncBranch,
targetBranch = '3.x',
files,
owners,
preResolved = [],
lockfileDeferred = false,
abandoned = [],
}) {
const filesMd = files.map((f) => `- \`${f}\``).join('\n') || '_none detected_';
const ownersMd = owners.length
? owners.map((o) => `- @${o}`).join('\n')
: '_Could not auto-attribute — review the conflicted files manually._';
const slack = owners.length
? `Likely owners (GitHub): ${owners.map((o) => `@${o}`).join(' ')}`
: 'Could not auto-attribute owners.';
const abandonedWarning = abandoned.length
? `A previous PR for this recurring conflict (${abandoned.map((pr) => `#${pr.number}`).join(', ')}) was closed without being merged. Closing resolves nothing — the conflict comes back on the next sync. **Merge, don't close.**`
: '';
const slack = [
owners.length
? `Likely owners (GitHub): ${owners.map((o) => `@${o}`).join(' ')}`
: 'Could not auto-attribute owners.',
abandoned.length
? `⚠️ ${abandoned.map((pr) => `<${pr.url}|#${pr.number}>`).join(', ')} was closed without merging and the conflict is back — merge this one, don't close it.`
: '',
]
.filter(Boolean)
.join(' ');
const body = [
`Automated \`master\`→\`${targetBranch}\` sync hit a conflict.`,
...(abandonedWarning ? ['', '> [!WARNING]', `> ${abandonedWarning}`] : []),
'',
`**\`${targetBranch}\` was not touched.** This branch is \`master\` merged into \`${targetBranch}\` with the **conflict markers committed**, so you can see exactly what clashed. The required checks stay red until they are resolved, so this PR cannot be merged half-done.`,
'',
@ -97,12 +116,27 @@ export function buildOutputs({ syncBranch, targetBranch = '3.x', files, owners }
`1. \`git fetch origin ${syncBranch} && git switch ${syncBranch}\``,
'2. Fix the conflict markers and commit them **in one commit of your own** (rather than amending the merge).',
`3. \`git push origin ${syncBranch}\``,
`4. **Merge this PR with the normal merge button.** \`master\`'s commits come in as-is and your fix stays its own commit — nothing is squashed.`,
`4. **Merge this PR with the normal merge button.** \`master\`'s commits come in as-is and your fix stays its own commit — nothing is squashed. Never close this PR unmerged: closing resolves nothing and the same conflict reopens on the next sync.`,
'',
`**Daily syncs are paused until this PR is merged.** The next sync then replays \`${targetBranch}\` onto master linearly again — which also drops this merge commit and its markers out of \`${targetBranch}\`'s history — and verifies the result is exactly what a merge would produce.`,
'',
'### Conflicted files',
filesMd,
...(preResolved.length
? [
'',
'### Auto-resolved for you',
'These tool-generated files also conflicted and were resolved mechanically — no action needed:',
preResolved.map((f) => `- \`${f}\``).join('\n'),
]
: []),
...(lockfileDeferred
? [
'',
'> [!NOTE]',
'> `pnpm-lock.yaml` still carries its conflict markers because a manifest (`package.json` / `pnpm-workspace.yaml`) is conflicted too, or its regeneration failed. After resolving the manifests, regenerate it with `pnpm install --lockfile-only` and commit the result.',
]
: []),
'',
'### Likely owners',
`Authors of the ${targetBranch} commits behind the conflicted files, requested as reviewers:`,

View File

@ -93,8 +93,51 @@ test('buildOutputs formats reviewers, slack line, and PR body with owners', () =
});
test('buildOutputs degrades gracefully when nothing could be attributed', () => {
const out = buildOutputs({ syncBranch: 'sync/master-to-3x', syncBase: 'abc1234', files: ['x.ts'], owners: [] });
const out = buildOutputs({
syncBranch: 'sync/master-to-3x',
syncBase: 'abc1234',
files: ['x.ts'],
owners: [],
});
assert.equal(out.ownersCsv, '');
assert.equal(out.slack, 'Could not auto-attribute owners.');
assert.match(out.body, /Could not auto-attribute/);
});
test('buildOutputs lists mechanically pre-resolved files apart from the code conflicts', () => {
const out = buildOutputs({
syncBranch: 'sync/master-to-3x',
files: ['packages/cli/x.ts'],
owners: ['alice'],
preResolved: ['pnpm-lock.yaml'],
});
assert.match(out.body, /### Conflicted files\n- `packages\/cli\/x\.ts`/);
assert.match(out.body, /### Auto-resolved for you/);
assert.match(out.body, /resolved mechanically — no action needed/);
assert.ok(out.body.indexOf('pnpm-lock.yaml') > out.body.indexOf('Auto-resolved'));
});
test('buildOutputs carries the regen instruction when the lockfile was deferred', () => {
const out = buildOutputs({
syncBranch: 'sync/master-to-3x',
files: ['packages/cli/package.json'],
owners: [],
lockfileDeferred: true,
});
assert.match(out.body, /still carries its conflict markers/);
assert.match(out.body, /pnpm install --lockfile-only/);
});
test('buildOutputs warns about conflict PRs that were closed without merging', () => {
const abandoned = [{ number: 42, url: 'https://github.com/n8n-io/n8n/pull/42' }];
const out = buildOutputs({
syncBranch: 'sync/master-to-3x',
files: ['x.ts'],
owners: ['alice'],
abandoned,
});
assert.match(out.body, /#42\) was closed without being merged/);
assert.match(out.body, /Merge, don't close/);
assert.match(out.slack, /<https:\/\/github\.com\/n8n-io\/n8n\/pull\/42\|#42>/);
assert.match(out.slack, /merge this one, don't close it/);
});

View File

@ -11,24 +11,39 @@
*
* Whatever route it takes, the content 3.x receives is verified to be exactly the tree that
* merging 3.x with master produces (`git merge-tree`); a mismatch fails the run instead of
* pushing. That invariant is what makes the rewrite safe.
* pushing. That invariant is what makes the rewrite safe. When MECHANICAL files (see
* `MECHANICAL_PATHS`) had to be regenerated, the verification demands exactness everywhere
* BUT those files, which get their own checks instead (no markers, lockfile consistent
* with the pushed tree's manifests).
*
* Three cases:
* Four cases:
* 1. The replay is clean → force-push. No commit.
* 2. The replay stalls, but 3.x's content still reconciles with master → an earlier
* conflict was resolved in a merge, which leaves no patch to replay. Replay again
* favouring the 3.x side (`-X theirs`), mirroring the side the resolved merge kept;
* the human's fix commit is in the queue and does the real work. Tree is then proven.
* 3. The content does NOT reconcile → a genuinely new conflict. 3.x is left UNTOUCHED and a
* draft PR is opened on the sync branch carrying the conflict markers, attributed to the
* authors of the breaking commits behind the conflicted files. Syncs pause until it is
* merged.
* the human's fix commit is in the queue and does the real work. Stalls the strategy
* option cannot settle (modify/delete — `-X` never resolves those) are resolved in
* place toward the queue commit's side. Tree is then proven.
* 3. master conflicts with 3.x, but ONLY on mechanical files — tool-generated content
* with a deterministic resolution (the pnpm lockfile, bot-maintained data files).
* These are resolved in place while the replay is stopped, exactly as a human
* resolver would (regenerate the lockfile, take master's blob), and folded into the
* stalled commit — still no commit of its own and no PR. When the `-X theirs` route
* resolves lockfile hunks without stalling, the lockfile is reconciled at the tip
* instead (folded into the tip commit by amending — never a commit of its own).
* 4. The content does NOT reconcile on a real code path → a genuinely new conflict. 3.x
* is left UNTOUCHED and a draft PR is opened on the sync branch carrying the conflict
* markers — with the mechanical files pre-resolved, so the resolver only deals with
* real code — attributed to the authors of the breaking commits behind the conflicted
* files. Syncs pause until it is merged.
*
* The conflict branch carries the conflict markers, so the resolver sees exactly what clashed
* and the required checks stay red until they fix it in a commit of their own. That PR is
* merged with the normal GitHub merge button — master's commits arrive as-is and the fix stays
* its own commit. 3.x itself never has markers at its tip (nightly images build from it), and
* the merge commit holding them is dropped from its history by the next replay.
* its own commit. NEVER close a conflict PR unmerged: closing resolves nothing and the same
* conflict reopens on the next sync. 3.x itself never has markers at its tip (nightly images
* build from it), and the merge commit holding them is dropped from its history by the next
* replay.
*
* Runs from a checkout of the target branch (fetch-depth 0). Assumes credentials are NOT
* persisted by checkout — pushes go through an explicit token URL.
@ -39,7 +54,8 @@
* Env: GH_TOKEN (installation token with contents/pull-requests/issues write),
* GITHUB_REPOSITORY (owner/repo, auto-provided by Actions),
* SYNC_TARGET_BRANCH (optional — override 3.x to rehearse against a scratch branch).
* Requires Node 18+ (global fetch), git 2.38+ (`merge-tree --write-tree`) and `gh` on PATH.
* Requires Node 18+ (global fetch), git 2.38+ (`merge-tree --write-tree`), `gh` on PATH,
* and `pnpm` on PATH (lockfile conflict resolution).
*
* See .github/DEVELOPING_V3.md for the full v3 development model.
*/
@ -47,19 +63,54 @@
import { execFileSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { conflictedFiles, breakingShas, resolveLogins, buildOutputs } from './sync-conflict-owners.mjs';
import {
attempt,
assertNoMarkers,
assertTreeMatches,
isAncestor,
mergeTree,
runGit,
} from './branch-replay.mjs';
import {
conflictedFiles,
breakingShas,
resolveLogins,
buildOutputs,
} from './sync-conflict-owners.mjs';
// Re-exported so this module stays the single entry point for the master→3.x flow, tests
// included. The implementations are branch-pair-agnostic and shared with the bundle replay.
export { attempt, assertNoMarkers, assertTreeMatches, isAncestor, mergeTree };
export const TARGET_BRANCH = '3.x';
export const SYNC_BRANCH = 'sync/master-to-3x';
export const CONFLICT_LABEL = 'automation:v3-sync';
export const LOCKFILE = 'pnpm-lock.yaml';
/**
* Paths whose conflicts are MECHANICAL: tool-generated files with a deterministic
* resolution, so no human judgement is lost by resolving them automatically.
* Keys are exact repo-relative paths; values pick the resolution strategy:
* - 'pnpm-regen': pnpm natively merges a conflicted lockfile when regenerating
* (`pnpm install --lockfile-only`).
* - 'take-master': bots regenerate the file on master, so master's side wins.
*/
export const MECHANICAL_PATHS = {
[LOCKFILE]: 'pnpm-regen',
'packages/frontend/editor-ui/data/node-popularity.json': 'take-master',
'.github/test-metrics/e2e-impact-map.json': 'take-master',
};
const BOT_NAME = 'n8n-assistant[bot]';
const BOT_EMAIL = 'n8n-assistant[bot]@users.noreply.github.com';
// Real command runners. Each takes an args array and returns trimmed stdout,
// throwing on a non-zero exit (mirrors `set -e`). Injectable for tests.
const runGit = (args, opts = {}) => execFileSync('git', args, { encoding: 'utf8', ...opts }).trim();
// throwing on a non-zero exit (mirrors `set -e`). Injectable for tests. (`runGit` is
// shared — see branch-replay.mjs.)
const runGh = (args, opts = {}) => execFileSync('gh', args, { encoding: 'utf8', ...opts }).trim();
const runPnpm = (args, opts = {}) =>
execFileSync('pnpm', args, { encoding: 'utf8', ...opts }).trim();
// The branch to sync into. Overridable so the whole flow can be rehearsed end-to-end
// against a throwaway branch before it is pointed at the real 3.x.
@ -67,64 +118,173 @@ export function targetBranch(env = process.env) {
return env.SYNC_TARGET_BRANCH || TARGET_BRANCH;
}
// Run a command for its exit status: `{ ok, out }` instead of a throw. Used for the git
// commands whose non-zero exit is an expected answer, not a failure. stderr is captured
// rather than inherited, so an expected-to-fail probe doesn't spill into the run log.
export function attempt(run, args, opts = {}) {
try {
return { ok: true, out: run(args, { stdio: ['ignore', 'pipe', 'pipe'], ...opts }) ?? '' };
} catch (error) {
const out = `${error.stdout ?? ''}\n${error.stderr ?? ''}`.trim();
return { ok: false, out };
}
}
// True when a previous conflict PR is still open — the halt gate.
export function hasOpenConflictPr(gh, label = CONFLICT_LABEL) {
const out = gh(['pr', 'list', '--state', 'open', '--label', label, '--json', 'number']);
return JSON.parse(out || '[]').length > 0;
}
export function isAncestor(git, maybeAncestor, descendant) {
return attempt(git, ['merge-base', '--is-ancestor', maybeAncestor, descendant]).ok;
// Split conflicted paths into mechanically-resolvable ones and real code conflicts.
export function classifyPaths(paths) {
const mechanical = [];
const code = [];
for (const p of paths) (MECHANICAL_PATHS[p] ? mechanical : code).push(p);
return { mechanical, code };
}
// A conflicted manifest makes lockfile regeneration meaningless until it is resolved
// (catalogs live in pnpm-workspace.yaml, so it counts as a manifest too).
export function blocksLockfileRegen(codePaths) {
return codePaths.some(
(p) => p === 'package.json' || p.endsWith('/package.json') || p === 'pnpm-workspace.yaml',
);
}
/**
* The tree a merge of the two commits would produce — the definition of the content 3.x
* should end up with, computed without touching the working tree.
*
* `ok: false` means the two sides genuinely conflict: there is a new conflict that no
* existing commit on 3.x resolves.
* Resolve one mechanical path in the working tree (valid mid-merge and mid-rebase alike)
* and stage the result.
*/
export function mergeTree(git, a, b) {
const res = attempt(git, ['merge-tree', '--write-tree', a, b]);
return { ok: res.ok, tree: res.ok ? res.out.split('\n')[0].trim() : '', out: res.out };
}
// Replay the 3.x-only commits onto master. Merge commits in the range (breaking PR merges,
// past sync merges) are flattened; commits already applied to master are dropped as empty.
// Individual commits, messages and authors are preserved — nothing is squashed.
export function tryRebase(git, masterSha, log = console.log, extraArgs = []) {
const { ok, out } = attempt(git, ['rebase', ...extraArgs, masterSha]);
if (!ok && out) log(out);
return ok;
}
// The content guard for every push: whichever route produced HEAD, its tree must be exactly
// what merging 3.x with master yields.
export function assertTree(git, expectedTree) {
const actual = git(['rev-parse', 'HEAD^{tree}']);
if (actual !== expectedTree) {
throw new Error(`Replayed tree ${actual} does not match the merge tree ${expectedTree}; refusing to push.`);
export function resolveMechanicalPath({ git, pnpm, path, masterSha, log = console.log }) {
const strategy = MECHANICAL_PATHS[path];
if (strategy === 'pnpm-regen') {
// pnpm merges the conflict markers itself while regenerating. `--no-frozen-lockfile`
// is required: pnpm flips `--frozen-lockfile` on by default when CI=true.
log(`Regenerating ${path} with pnpm...`);
pnpm(['install', '--lockfile-only', '--no-frozen-lockfile']);
git(['add', '--', path]);
return;
}
log(`Resolving ${path} with master's version...`);
if (attempt(git, ['cat-file', '-e', `${masterSha}:${path}`]).ok) {
git(['checkout', masterSha, '--', path]);
} else {
git(['rm', '--force', '--', path]); // master deleted it
}
}
// Conflict markers must never reach 3.x — nightly images build from it. git grep exits 0
// with matches, 1 with none and >1 on error, so an error must not be read as "clean".
export function assertNoMarkers(git, rev = 'HEAD') {
const found = attempt(git, ['grep', '-I', '-l', '-e', '^<<<<<<< ', '-e', '^>>>>>>> ', rev]);
if (found.ok) throw new Error(`Refusing to continue: conflict markers present in ${rev}:\n${found.out}`);
if (found.out.includes('fatal:')) throw new Error(`Could not scan ${rev} for conflict markers:\n${found.out}`);
/**
* Resolve one stalled path by taking the side of the queue commit being replayed
* ("theirs" while a rebase is stopped). Exists for the stalls `-X theirs` cannot settle
* itself — modify/delete, which strategy options never resolve: when the queue commit
* deleted the file there is no stage 3, so the deletion wins. Only meaningful on the
* favoured replay passes, where the callers' tree guard proves the final content — a
* wrong pick here fails the run instead of pushing.
*/
export function resolveQueueSidePath({ git, path, log = console.log }) {
// `ls-files -u` lines are `<mode> <oid> <stage>\t<path>`; stage 3 is the queue side.
const stages = git(['ls-files', '-u', '--', path]);
const queueSideExists = stages.split('\n').some((line) => /^\S+ \S+ 3\t/.test(line));
if (queueSideExists) {
log(`Resolving ${path} with the replayed commit's version...`);
git(['checkout', '--theirs', '--', path]);
git(['add', '--', path]);
} else {
log(`Resolving ${path} with the replayed commit's deletion...`);
git(['rm', '--force', '--', path]);
}
}
/**
* Replay the 3.x-only commits onto master, resolving stalls that involve ONLY mechanical
* paths in place — folded into the stalled commit exactly as a human's `rebase --continue`
* would, so no commit is added. Bails (leaving the rebase stopped, for the caller to
* abort) as soon as a stall touches a real code path — unless `favourQueue` is set, in
* which case code-path stalls are resolved with the queue commit's side too. That flag
* belongs only on the favoured (`-X theirs`) passes, whose tree guard proves the outcome.
*
* Merge commits in the range (breaking PR merges, past conflict-PR merges) are flattened;
* commits already applied to master — or fully absorbed by a mechanical resolution — are
* dropped as empty. Individual commits, messages and authors are preserved.
*/
export function rebaseResolvingMechanical({
git,
pnpm,
masterSha,
log = console.log,
extraArgs = [],
favourQueue = false,
maxStalls = 50,
}) {
const resolved = new Set();
let res = attempt(git, ['rebase', ...extraArgs, masterSha]);
let stalls = 0;
while (!res.ok) {
if (++stalls > maxStalls) {
log(`Giving up after ${maxStalls} rebase stalls.`);
return { ok: false, resolved: [...resolved] };
}
const { mechanical, code } = classifyPaths(conflictedFiles(git));
// No conflicted files means the rebase failed for some other reason — don't guess.
if (mechanical.length + code.length === 0 || (code.length > 0 && !favourQueue)) {
if (res.out) log(res.out);
return { ok: false, resolved: [...resolved], conflictedCode: code };
}
for (const path of mechanical) {
resolveMechanicalPath({ git, pnpm, path, masterSha, log });
resolved.add(path);
}
for (const path of code) {
resolveQueueSidePath({ git, path, log });
}
// The resolution may have absorbed the whole commit — skip it rather than
// letting `--continue` refuse an empty commit.
const empty = attempt(git, ['diff-index', '--cached', '--quiet', 'HEAD']).ok;
res = attempt(git, empty ? ['rebase', '--skip'] : ['rebase', '--continue']);
}
return { ok: true, resolved: [...resolved] };
}
/**
* Fold any residual deviation between the replayed tip and the proven merge tree into the
* tip commit. `-X theirs` resolves overlapping hunks toward the queue side without
* stalling — silently dropping master-side changes (lockfile hunks are the usual case)
* that no fix commit in the queue reasserts. The merge tree is the definition of the
* correct content, so its blobs are taken verbatim. `excludePaths` names files the merge
* tree itself could not resolve (marker-carrying mechanical blobs), which get their own
* reconciliation. Never a commit of its own, and never a rewrite of a master commit.
*/
export function reconcileWithMergeTreeAtTip({
git,
mergedTree,
masterSha,
excludePaths = [],
log = console.log,
}) {
if (git(['rev-parse', 'HEAD^{tree}']) === mergedTree) return [];
const out = git(['diff-tree', '-r', '--name-only', '--no-renames', mergedTree, 'HEAD']);
const excluded = new Set(excludePaths);
const paths = (out ? out.split('\n') : []).filter((p) => p && !excluded.has(p));
if (paths.length === 0) return [];
if (git(['rev-parse', 'HEAD']) === masterSha) {
throw new Error('Merge-tree reconciliation would amend a master commit; refusing.');
}
log(`Folding the merge tree's content for ${paths.join(', ')} into the tip commit.`);
for (const path of paths) {
if (attempt(git, ['cat-file', '-e', `${mergedTree}:${path}`]).ok) {
git(['checkout', mergedTree, '--', path]);
} else {
git(['rm', '--force', '--', path]); // the merge deleted it
}
}
git(['commit', '--amend', '--no-edit', '--no-verify']);
return paths;
}
/**
* Make the lockfile at the replayed tip consistent with the tip's own manifests. A regen
* at a stall usually already did this, but the `-X theirs` route resolves lockfile hunks
* without stalling — so the reconciliation happens here, folded into the tip commit by
* amending. Never a commit of its own, and never a rewrite of a master commit.
*/
export function reconcileLockfileAtTip({ git, pnpm, masterSha, log = console.log }) {
pnpm(['install', '--lockfile-only', '--no-frozen-lockfile']);
if (attempt(git, ['diff', '--quiet', '--', LOCKFILE]).ok) return;
if (git(['rev-parse', 'HEAD']) === masterSha) {
throw new Error('Lockfile reconciliation would amend a master commit; refusing.');
}
log('Folding a lockfile reconciliation into the tip commit.');
git(['add', '--', LOCKFILE]);
git(['commit', '--amend', '--no-edit', '--no-verify']);
}
// Append key=value lines to $GITHUB_OUTPUT (no-op when running outside Actions).
@ -139,32 +299,86 @@ export function writeGithubOutput(obj, env = process.env) {
/**
* Build the conflict branch: master merged into 3.x with the conflict markers committed as
* they are. The markers are the review surface — the resolver sees exactly what clashed, and
* the required checks stay red until they fix it, so the PR cannot be merged half-resolved
* (an auto-resolved branch would be green with master's change silently dropped).
* they are — except mechanical files, which are pre-resolved so the resolver only deals
* with real code conflicts. The remaining markers are the review surface: the resolver sees
* exactly what clashed, and the required checks stay red until they fix it, so the PR
* cannot be merged half-resolved (an auto-resolved branch would be green with master's
* change silently dropped).
*
* 3.x never carries them at its tip, and not for long in its history either: this merge
* commit is dropped by the next replay, which takes the queue's commits only.
* The lockfile is left with its markers when a manifest is among the code conflicts
* (regenerating is meaningless until the manifests are resolved) or when the regen fails
* transiently — flagged via `lockfileDeferred` so the PR body carries the instruction.
*
* @returns {string[]} the conflicted files
* 3.x never carries the markers at its tip, and not for long in its history either: this
* merge commit is dropped by the next replay, which takes the queue's commits only.
*
* @returns {{ files: string[], preResolved: string[], lockfileDeferred: boolean }}
* `files` is the list the PR reports and attributes owners for: the code conflicts,
* or every conflict when none are code (a fallback after a failed auto-resolution).
*/
export function buildConflictBranch({ git, masterSha, log = console.log }) {
export function buildConflictBranch({ git, pnpm, masterSha, log = console.log }) {
const merge = attempt(git, ['merge', '--no-edit', masterSha]);
if (merge.ok) {
// merge-tree said these conflict; if a real merge disagrees, don't guess.
throw new Error('Expected a merge conflict but the merge succeeded; aborting to avoid guessing.');
throw new Error(
'Expected a merge conflict but the merge succeeded; aborting to avoid guessing.',
);
}
if (merge.out) log(merge.out);
const files = conflictedFiles(git);
const all = conflictedFiles(git);
const { mechanical, code } = classifyPaths(all);
const preResolved = [];
let lockfileDeferred = false;
for (const path of mechanical) {
const needsManifests = MECHANICAL_PATHS[path] === 'pnpm-regen' && blocksLockfileRegen(code);
if (needsManifests) {
lockfileDeferred = true;
continue;
}
try {
resolveMechanicalPath({ git, pnpm, path, masterSha, log });
preResolved.push(path);
} catch (error) {
// Degrade gracefully (e.g. a transient registry failure): leave the markers
// for the resolver rather than failing the PR-opening path.
log(`warning: could not pre-resolve ${path}: ${error.message}`);
if (MECHANICAL_PATHS[path] === 'pnpm-regen') lockfileDeferred = true;
}
}
git(['add', '-A']);
git(['commit', '--no-edit', '--no-verify']);
return files;
return { files: code.length > 0 ? code : all, preResolved, lockfileDeferred };
}
// Conflict PRs that were recently closed WITHOUT being merged — closing resolves nothing,
// so the same conflict is about to come back; the new PR and Slack message call it out.
export function recentAbandonedConflictPrs(
gh,
{ label = CONFLICT_LABEL, sinceDays = 14, now = Date.now() } = {},
) {
const out = gh([
'pr',
'list',
'--state',
'closed',
'--label',
label,
'--json',
'number,url,mergedAt,closedAt',
'--limit',
'10',
]);
return JSON.parse(out || '[]').filter(
(pr) => !pr.mergedAt && pr.closedAt && now - Date.parse(pr.closedAt) < sinceDays * 86_400_000,
);
}
/**
* Push the marker-free conflict branch and open a draft PR, attributing it to the authors of
* the breaking commits behind the conflicted files. 3.x is left untouched.
* Push the marker-carrying conflict branch and open a draft PR, attributing it to the
* authors of the breaking commits behind the conflicted files. 3.x is left untouched.
*
* @returns {Promise<{ prUrl: string, ownersSlack: string }>}
*/
@ -178,6 +392,8 @@ export async function openConflictPr({
pushUrl,
target = TARGET_BRANCH,
files = [],
preResolved = [],
lockfileDeferred = false,
fetchFn = fetch,
log = console.log,
}) {
@ -193,24 +409,50 @@ export async function openConflictPr({
log(`warning: could not resolve owners: ${error.message}`);
}
let abandoned = [];
try {
abandoned = recentAbandonedConflictPrs(gh);
} catch (error) {
log(`warning: could not check for abandoned conflict PRs: ${error.message}`);
}
const { ownersCsv, slack, body } = buildOutputs({
syncBranch: SYNC_BRANCH,
targetBranch: target,
files,
owners,
preResolved,
lockfileDeferred,
abandoned,
});
git(['push', '--force', pushUrl, `HEAD:refs/heads/${SYNC_BRANCH}`]);
// Ensure the label exists (idempotent), then open the draft conflict PR.
gh(['label', 'create', CONFLICT_LABEL, '--color', 'B60205', '--description', 'master→3.x sync conflict', '--force']);
gh([
'label',
'create',
CONFLICT_LABEL,
'--color',
'B60205',
'--description',
'master→3.x sync conflict',
'--force',
]);
const prUrl = gh([
'pr', 'create', '--draft',
'--base', target,
'--head', SYNC_BRANCH,
'--label', CONFLICT_LABEL,
'--title', 'chore: Resolve master→3.x sync conflict',
'--body', body,
'pr',
'create',
'--draft',
'--base',
target,
'--head',
SYNC_BRANCH,
'--label',
CONFLICT_LABEL,
'--title',
'chore: Resolve master→3.x sync conflict',
'--body',
body,
]);
// Request owners as reviewers (best-effort: the API rejects the PR author
@ -229,6 +471,7 @@ export async function openConflictPr({
export async function sync({
git = runGit,
gh = runGh,
pnpm = runPnpm,
env = process.env,
fetchFn = fetch,
log = console.log,
@ -244,7 +487,12 @@ export async function sync({
const pushUrl = `https://x-access-token:${token}@github.com/${repo}.git`;
// The lease pins the tip we replayed from, so a concurrent push is never overwritten.
const pushReplay = (from) =>
git(['push', `--force-with-lease=refs/heads/${target}:${from}`, pushUrl, `HEAD:refs/heads/${target}`]);
git([
'push',
`--force-with-lease=refs/heads/${target}:${from}`,
pushUrl,
`HEAD:refs/heads/${target}`,
]);
// Halt gate: if a previous conflict PR is still open, do nothing until it is merged.
if (hasOpenConflictPr(gh)) {
@ -254,6 +502,8 @@ export async function sync({
git(['config', 'user.name', BOT_NAME]);
git(['config', 'user.email', BOT_EMAIL]);
// `rebase --continue` insists on an editor for the commit message; keep it headless.
git(['config', 'core.editor', 'true']);
git(['fetch', 'origin', 'master']);
// Pin to the fetched SHA — a command-line refspec doesn't reliably update the
@ -266,43 +516,130 @@ export async function sync({
return;
}
const queue = git(['log', '--no-merges', '--reverse', '--format=%h %s', `${masterSha}..${preHead}`]);
const queue = git([
'log',
'--no-merges',
'--reverse',
'--format=%h %s',
`${masterSha}..${preHead}`,
]);
log(`Replaying onto master ${masterSha}:\n${queue || '(none)'}`);
// What the content must end up as, and whether a new conflict exists at all.
const merged = mergeTree(git, preHead, masterSha);
if (!merged.ok) {
log(`master conflicts with ${target} — leaving ${target} untouched and opening a conflict PR.`);
const files = buildConflictBranch({ git, masterSha, log });
const { mechanical, code } = classifyPaths(merged.conflictedPaths);
if (code.length === 0 && mechanical.length > 0) {
log(
`master conflicts with ${target} only on mechanical files (${mechanical.join(', ')}); auto-resolving.`,
);
let replay = rebaseResolvingMechanical({ git, pnpm, masterSha, log });
if (!replay.ok) {
// An intermediate stall on a code file whose endpoints still reconcile —
// same "resolved in a merge" situation as the clean-tree second pass.
attempt(git, ['rebase', '--abort']);
replay = rebaseResolvingMechanical({
git,
pnpm,
masterSha,
log,
extraArgs: ['-X', 'theirs'],
favourQueue: true,
});
}
if (replay.ok) {
// The favoured retry may have drifted from the merge tree on cleanly-merging
// paths; fold those back before the mechanical files get their own treatment.
reconcileWithMergeTreeAtTip({
git,
mergedTree: merged.tree,
masterSha,
excludePaths: mechanical,
log,
});
if (mechanical.includes(LOCKFILE)) reconcileLockfileAtTip({ git, pnpm, masterSha, log });
assertTreeMatches(git, merged.tree, mechanical);
assertNoMarkers(git);
pushReplay(preHead);
log(
`Replayed ${target} onto master, auto-resolving ${mechanical.join(', ')} — no PR, no sync commit.`,
);
return;
}
attempt(git, ['rebase', '--abort']);
log('Mechanical auto-resolution did not complete; falling back to a conflict PR.');
} else {
log(
`master conflicts with ${target} — leaving ${target} untouched and opening a conflict PR.`,
);
}
const { files, preResolved, lockfileDeferred } = buildConflictBranch({
git,
pnpm,
masterSha,
log,
});
const { prUrl, ownersSlack } = await openConflictPr({
git, gh, repo, token, masterSha, preHead, pushUrl, target, files, fetchFn, log,
git,
gh,
repo,
token,
masterSha,
preHead,
pushUrl,
target,
files,
preResolved,
lockfileDeferred,
fetchFn,
log,
});
writeGithubOutput({ conflict_pr: prUrl, conflict_owners: ownersSlack }, env);
return;
}
if (tryRebase(git, masterSha, log)) {
assertTree(git, merged.tree);
const plainReplay = attempt(git, ['rebase', masterSha]);
if (plainReplay.ok) {
assertTreeMatches(git, merged.tree);
assertNoMarkers(git);
pushReplay(preHead);
log(`Replayed ${target} onto master — no sync commit created.`);
return;
}
if (plainReplay.out) log(plainReplay.out);
// The content reconciles but the patches no longer apply on their own: an earlier
// conflict was resolved in a merge, and a merge resolution leaves no patch to replay.
// Replay favouring the 3.x side — the same side that resolved merge kept — and let the
// resolver's own fix commit, which is in the queue, do the real work. Nothing is
// squashed; the tree guard below proves the outcome.
// resolver's own fix commit, which is in the queue, do the real work. Stalls the
// strategy option cannot settle (modify/delete — `-X` never resolves those) are
// resolved in place toward the queue commit's side. Nothing is squashed; the tree
// guard below proves the outcome exactly, since the merge was clean.
git(['rebase', '--abort']);
log(`Patches no longer apply individually; replaying with ${target}'s side favoured.`);
if (!tryRebase(git, masterSha, log, ['-X', 'theirs'])) {
const favoured = rebaseResolvingMechanical({
git,
pnpm,
masterSha,
log,
extraArgs: ['-X', 'theirs'],
favourQueue: true,
});
if (!favoured.ok) {
git(['rebase', '--abort']);
throw new Error(`Could not replay ${target} onto master even with its own side favoured; needs a human.`);
throw new Error(
`Could not replay ${target} onto master even with its own side favoured; needs a human.`,
);
}
assertTree(git, merged.tree);
// Favouring resolves overlapping hunks toward 3.x without stalling, which can drop
// master-side changes no fix commit reasserts; the merge was clean, so the merge tree
// is the correct content — fold any drift back into the tip.
reconcileWithMergeTreeAtTip({ git, mergedTree: merged.tree, masterSha, log });
assertTreeMatches(git, merged.tree);
assertNoMarkers(git);
pushReplay(preHead);
log(`Replayed ${target} onto master — no sync commit created.`);

View File

@ -4,8 +4,15 @@ import { test } from 'node:test';
import {
hasOpenConflictPr,
mergeTree,
tryRebase,
assertTree,
classifyPaths,
blocksLockfileRegen,
resolveMechanicalPath,
resolveQueueSidePath,
rebaseResolvingMechanical,
reconcileWithMergeTreeAtTip,
reconcileLockfileAtTip,
recentAbandonedConflictPrs,
assertTreeMatches,
assertNoMarkers,
buildConflictBranch,
openConflictPr,
@ -14,9 +21,10 @@ import {
CONFLICT_LABEL,
SYNC_BRANCH,
TARGET_BRANCH,
LOCKFILE,
} from './sync-master-to-3x.mjs';
// A git/gh stub: routes calls by a matcher, records every invocation.
// A git/gh/pnpm stub: routes calls by a matcher, records every invocation.
function makeStub(routes = []) {
const calls = [];
const fn = (args) => {
@ -30,24 +38,39 @@ function makeStub(routes = []) {
return fn;
}
const fail = (stdout = '') => () => {
const err = new Error('command failed');
err.status = 1;
err.stdout = stdout;
throw err;
};
const fail =
(stdout = '') =>
() => {
const err = new Error('command failed');
err.status = 1;
err.stdout = stdout;
throw err;
};
const okFetch = (logins) => async () => ({
ok: true,
json: async () => ({ data: { repository: Object.fromEntries(logins.map((l, i) => [`c${i}`, { author: { user: { login: l } } }])) } }),
json: async () => ({
data: {
repository: Object.fromEntries(
logins.map((l, i) => [`c${i}`, { author: { user: { login: l } } }]),
),
},
}),
});
const PRE_HEAD = 'PREHEAD';
const MASTER = 'MASTERSHA';
const MERGE_TREE = 'MERGETREEOID';
const POPULARITY = 'packages/frontend/editor-ui/data/node-popularity.json';
const isRebase = (a) => a[0] === 'rebase' && a[1] !== '--abort';
const favouringOwnSide = (a) => a[0] === 'rebase' && a.includes('-X') && a.includes('theirs');
const isConflictedFiles = (a) => a[0] === 'diff' && a.includes('--diff-filter=U');
// What `git merge-tree --write-tree --name-only` emits on a conflict: the (marker-carrying)
// tree OID, the conflicted paths, a blank line, then informational messages.
const conflictedMergeTree = (...paths) =>
fail(`${MERGE_TREE}\n${paths.join('\n')}\n\nCONFLICT (content): Merge conflict in ${paths[0]}`);
// Routes shared by every sync path: master fetched, 3.x hasn't absorbed it yet, and the
// merge tree is computable (no new conflict). git grep exits non-zero => no markers.
@ -71,45 +94,373 @@ test('targetBranch defaults to 3.x and honours the rehearsal override', () => {
test('hasOpenConflictPr reflects the open-PR count from gh', () => {
const empty = makeStub([[() => true, '[]']]);
assert.equal(hasOpenConflictPr(empty), false);
assert.deepEqual(empty.calls[0], ['pr', 'list', '--state', 'open', '--label', CONFLICT_LABEL, '--json', 'number']);
assert.deepEqual(empty.calls[0], [
'pr',
'list',
'--state',
'open',
'--label',
CONFLICT_LABEL,
'--json',
'number',
]);
const one = makeStub([[() => true, JSON.stringify([{ number: 42 }])]]);
assert.equal(hasOpenConflictPr(one), true);
});
test('mergeTree reports the tree when clean and not-ok when the sides conflict', () => {
test('mergeTree reports the tree when clean, and the conflicted paths when the sides conflict', () => {
const clean = makeStub([[() => true, `${MERGE_TREE}\nsome extra output`]]);
assert.deepEqual(mergeTree(clean, 'A', 'B'), { ok: true, tree: MERGE_TREE, out: `${MERGE_TREE}\nsome extra output` });
assert.deepEqual(clean.calls[0], ['merge-tree', '--write-tree', 'A', 'B']);
assert.deepEqual(mergeTree(clean, 'A', 'B'), {
ok: true,
tree: MERGE_TREE,
conflictedPaths: [],
out: `${MERGE_TREE}\nsome extra output`,
});
assert.deepEqual(clean.calls[0], ['merge-tree', '--write-tree', '--name-only', 'A', 'B']);
const conflicting = makeStub([[() => true, fail('CONFLICT (content): x.ts')]]);
assert.equal(mergeTree(conflicting, 'A', 'B').ok, false);
const conflicting = makeStub([
[
() => true,
fail(
`${MERGE_TREE}\n${LOCKFILE}\npackages/cli/x.ts\n\nAuto-merging ${LOCKFILE}\nCONFLICT (content): Merge conflict in ${LOCKFILE}`,
),
],
]);
const res = mergeTree(conflicting, 'A', 'B');
assert.equal(res.ok, false);
// The conflicted tree is still written — it is the baseline for the relaxed guard.
assert.equal(res.tree, MERGE_TREE);
assert.deepEqual(res.conflictedPaths, [LOCKFILE, 'packages/cli/x.ts']);
});
test('tryRebase replays onto master and can favour a side', () => {
const git = makeStub([[(a) => a[0] === 'rebase', '']]);
assert.equal(tryRebase(git, MASTER, () => {}), true);
assert.deepEqual(git.calls[0], ['rebase', MASTER]);
test('classifyPaths and blocksLockfileRegen split mechanical from code conflicts', () => {
const { mechanical, code } = classifyPaths([
LOCKFILE,
'packages/cli/x.ts',
'.github/test-metrics/e2e-impact-map.json',
]);
assert.deepEqual(mechanical, [LOCKFILE, '.github/test-metrics/e2e-impact-map.json']);
assert.deepEqual(code, ['packages/cli/x.ts']);
assert.equal(tryRebase(git, MASTER, () => {}, ['-X', 'theirs']), true);
assert.deepEqual(git.calls[1], ['rebase', '-X', 'theirs', MASTER]);
assert.equal(blocksLockfileRegen(['packages/cli/package.json']), true);
assert.equal(blocksLockfileRegen(['package.json']), true);
assert.equal(blocksLockfileRegen(['pnpm-workspace.yaml']), true);
assert.equal(blocksLockfileRegen(['packages/cli/x.ts']), false);
});
test('assertTree and assertNoMarkers are the push guards', () => {
assert.doesNotThrow(() => assertTree(makeStub([[() => true, MERGE_TREE]]), MERGE_TREE));
assert.throws(() => assertTree(makeStub([[() => true, 'OTHER']]), MERGE_TREE), /does not match the merge tree/);
test('resolveMechanicalPath takes the blob from master, or the deletion when master removed the file', () => {
const present = makeStub([[(a) => a[0] === 'cat-file', '']]);
resolveMechanicalPath({
git: present,
pnpm: makeStub(),
path: POPULARITY,
masterSha: MASTER,
log: () => {},
});
assert.ok(
present.calls.some((a) => a[0] === 'checkout' && a[1] === MASTER && a.includes(POPULARITY)),
);
const deleted = makeStub([[(a) => a[0] === 'cat-file', fail()]]);
resolveMechanicalPath({
git: deleted,
pnpm: makeStub(),
path: POPULARITY,
masterSha: MASTER,
log: () => {},
});
assert.ok(
deleted.calls.some((a) => a[0] === 'rm' && a.includes('--force') && a.includes(POPULARITY)),
);
});
test('resolveMechanicalPath regenerates the lockfile with pnpm and stages it', () => {
const git = makeStub();
const pnpm = makeStub();
resolveMechanicalPath({ git, pnpm, path: LOCKFILE, masterSha: MASTER, log: () => {} });
// `--no-frozen-lockfile` is load-bearing: pnpm defaults to frozen when CI=true.
assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']);
assert.ok(git.calls.some((a) => a[0] === 'add' && a.includes(LOCKFILE)));
});
test('rebaseResolvingMechanical resolves mechanical stalls in place and skips emptied commits', () => {
const git = makeStub([
[(a) => a[0] === 'rebase' && a[1] === '--skip', ''],
[isRebase, fail(`CONFLICT (content): Merge conflict in ${POPULARITY}`)],
[isConflictedFiles, POPULARITY],
[(a) => a[0] === 'cat-file', ''],
[(a) => a[0] === 'diff-index', ''], // resolution emptied the commit
]);
const res = rebaseResolvingMechanical({
git,
pnpm: makeStub(),
masterSha: MASTER,
log: () => {},
});
assert.equal(res.ok, true);
assert.deepEqual(res.resolved, [POPULARITY]);
assert.ok(git.calls.some((a) => a[0] === 'checkout' && a[1] === MASTER));
assert.ok(git.calls.some((a) => a[0] === 'rebase' && a[1] === '--skip'));
});
test('rebaseResolvingMechanical bails as soon as a stall touches a code path', () => {
const git = makeStub([
[isRebase, fail('CONFLICT (content): Merge conflict in packages/cli/x.ts')],
[isConflictedFiles, `packages/cli/x.ts\n${LOCKFILE}`],
]);
const pnpm = makeStub();
const res = rebaseResolvingMechanical({ git, pnpm, masterSha: MASTER, log: () => {} });
assert.equal(res.ok, false);
assert.deepEqual(res.conflictedCode, ['packages/cli/x.ts']);
// Nothing may be auto-resolved when human judgement is needed for the same stall.
assert.equal(pnpm.calls.length, 0);
assert.equal(
git.calls.some((a) => a[0] === 'checkout'),
false,
);
});
test('resolveQueueSidePath takes the queue commit side, or its deletion when there is no stage 3', () => {
const FILE = 'packages/cli/x.ts';
const bothSides = makeStub([
[(a) => a[0] === 'ls-files', `100644 aaa 2\t${FILE}\n100644 bbb 3\t${FILE}`],
]);
resolveQueueSidePath({ git: bothSides, path: FILE, log: () => {} });
assert.ok(bothSides.calls.some((a) => a[0] === 'checkout' && a[1] === '--theirs'));
assert.ok(bothSides.calls.some((a) => a[0] === 'add' && a.includes(FILE)));
// modify/delete with the queue commit deleting: stages 1 and 2 only.
const queueDeleted = makeStub([
[(a) => a[0] === 'ls-files', `100644 aaa 1\t${FILE}\n100644 bbb 2\t${FILE}`],
]);
resolveQueueSidePath({ git: queueDeleted, path: FILE, log: () => {} });
assert.ok(queueDeleted.calls.some((a) => a[0] === 'rm' && a.includes(FILE)));
assert.equal(
queueDeleted.calls.some((a) => a[0] === 'checkout'),
false,
);
});
test('rebaseResolvingMechanical with favourQueue resolves a modify/delete code stall and continues', () => {
const FILE = 'packages/nodes-base/nodes/Function/Function.node.ts';
const git = makeStub([
[(a) => a[0] === 'rebase' && a[1] === '--continue', ''],
[isRebase, fail(`CONFLICT (modify/delete): ${FILE} deleted in 0ff923a066`)],
[isConflictedFiles, FILE],
[(a) => a[0] === 'ls-files', `100644 aaa 1\t${FILE}\n100644 bbb 2\t${FILE}`],
[(a) => a[0] === 'diff-index', fail()], // staged deletion -> continue, not skip
]);
const pnpm = makeStub();
const res = rebaseResolvingMechanical({
git,
pnpm,
masterSha: MASTER,
favourQueue: true,
log: () => {},
});
assert.equal(res.ok, true);
assert.ok(git.calls.some((a) => a[0] === 'rm' && a.includes(FILE)));
assert.ok(git.calls.some((a) => a[0] === 'rebase' && a[1] === '--continue'));
// The stall carried no mechanical file, so nothing mechanical may be touched.
assert.equal(pnpm.calls.length, 0);
});
test('assertTreeMatches is exact by default and scoped to the allowed paths otherwise', () => {
assert.doesNotThrow(() => assertTreeMatches(makeStub([[() => true, MERGE_TREE]]), MERGE_TREE));
assert.throws(
() => assertTreeMatches(makeStub([[() => true, 'OTHER']]), MERGE_TREE),
/does not match the merge tree/,
);
const onlyLockfile = makeStub([
[(a) => a[0] === 'rev-parse', 'OTHER'],
[(a) => a[0] === 'diff-tree', LOCKFILE],
]);
assert.doesNotThrow(() => assertTreeMatches(onlyLockfile, MERGE_TREE, [LOCKFILE]));
const alsoCode = makeStub([
[(a) => a[0] === 'rev-parse', 'OTHER'],
[(a) => a[0] === 'diff-tree', `${LOCKFILE}\npackages/cli/x.ts`],
]);
assert.throws(() => assertTreeMatches(alsoCode, MERGE_TREE, [LOCKFILE]), /non-mechanical paths/);
});
test('assertNoMarkers is a push guard', () => {
assert.doesNotThrow(() => assertNoMarkers(makeStub([[() => true, fail()]])));
assert.throws(() => assertNoMarkers(makeStub([[() => true, 'packages/cli/x.ts']])), /conflict markers present/);
assert.throws(
() => assertNoMarkers(makeStub([[() => true, 'packages/cli/x.ts']])),
/conflict markers present/,
);
// git grep failing for any other reason must not read as "clean".
assert.throws(() => assertNoMarkers(makeStub([[() => true, fail('fatal: bad object')]])), /Could not scan/);
assert.throws(
() => assertNoMarkers(makeStub([[() => true, fail('fatal: bad object')]])),
/Could not scan/,
);
});
test('reconcileWithMergeTreeAtTip folds favoured-replay drift into the tip commit, never a master commit', () => {
// No drift: nothing to do.
const clean = makeStub([[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', MERGE_TREE]]);
assert.deepEqual(
reconcileWithMergeTreeAtTip({
git: clean,
mergedTree: MERGE_TREE,
masterSha: MASTER,
log: () => {},
}),
[],
);
assert.equal(
clean.calls.some((a) => a[0] === 'commit'),
false,
);
// Lockfile drift: take the merge tree's blob and amend the tip.
const drifted = makeStub([
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'],
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', PRE_HEAD],
[(a) => a[0] === 'diff-tree', LOCKFILE],
[(a) => a[0] === 'cat-file', ''],
]);
assert.deepEqual(
reconcileWithMergeTreeAtTip({
git: drifted,
mergedTree: MERGE_TREE,
masterSha: MASTER,
log: () => {},
}),
[LOCKFILE],
);
assert.ok(
drifted.calls.some((a) => a[0] === 'checkout' && a[1] === MERGE_TREE && a.includes(LOCKFILE)),
);
assert.ok(drifted.calls.some((a) => a[0] === 'commit' && a.includes('--amend')));
// Excluded (mechanical) paths are left to their own reconciliation.
const excluded = makeStub([
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'],
[(a) => a[0] === 'diff-tree', LOCKFILE],
]);
assert.deepEqual(
reconcileWithMergeTreeAtTip({
git: excluded,
mergedTree: MERGE_TREE,
masterSha: MASTER,
excludePaths: [LOCKFILE],
log: () => {},
}),
[],
);
assert.equal(
excluded.calls.some((a) => a[0] === 'commit'),
false,
);
// A path the merge deleted is removed, not checked out.
const deleted = makeStub([
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'],
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', PRE_HEAD],
[(a) => a[0] === 'diff-tree', 'packages/cli/gone.ts'],
[(a) => a[0] === 'cat-file', fail()],
]);
reconcileWithMergeTreeAtTip({
git: deleted,
mergedTree: MERGE_TREE,
masterSha: MASTER,
log: () => {},
});
assert.ok(deleted.calls.some((a) => a[0] === 'rm' && a.includes('packages/cli/gone.ts')));
// Never rewrite a master commit.
const atMasterTip = makeStub([
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'],
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', MASTER],
[(a) => a[0] === 'diff-tree', LOCKFILE],
]);
assert.throws(
() =>
reconcileWithMergeTreeAtTip({
git: atMasterTip,
mergedTree: MERGE_TREE,
masterSha: MASTER,
log: () => {},
}),
/amend a master commit/,
);
});
test('reconcileLockfileAtTip folds an inconsistent lockfile into the tip commit, never a master commit', () => {
const consistent = makeStub([[(a) => a[0] === 'diff', '']]);
reconcileLockfileAtTip({ git: consistent, pnpm: makeStub(), masterSha: MASTER, log: () => {} });
assert.equal(
consistent.calls.some((a) => a[0] === 'commit'),
false,
);
const inconsistent = makeStub([
[(a) => a[0] === 'diff', fail()],
[(a) => a[0] === 'rev-parse', PRE_HEAD],
]);
const pnpm = makeStub();
reconcileLockfileAtTip({ git: inconsistent, pnpm, masterSha: MASTER, log: () => {} });
assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']);
assert.ok(inconsistent.calls.some((a) => a[0] === 'commit' && a.includes('--amend')));
const atMasterTip = makeStub([
[(a) => a[0] === 'diff', fail()],
[(a) => a[0] === 'rev-parse', MASTER],
]);
assert.throws(
() =>
reconcileLockfileAtTip({
git: atMasterTip,
pnpm: makeStub(),
masterSha: MASTER,
log: () => {},
}),
/amend a master commit/,
);
});
test('recentAbandonedConflictPrs keeps only recently closed-unmerged conflict PRs', () => {
const now = Date.parse('2026-08-10T00:00:00Z');
const gh = makeStub([
[
() => true,
JSON.stringify([
{
number: 1,
url: 'u1',
mergedAt: '2026-08-01T00:00:00Z',
closedAt: '2026-08-01T00:00:00Z',
},
{ number: 2, url: 'u2', mergedAt: null, closedAt: '2026-07-01T00:00:00Z' }, // too old
{ number: 3, url: 'u3', mergedAt: null, closedAt: '2026-08-08T00:00:00Z' },
]),
],
]);
const abandoned = recentAbandonedConflictPrs(gh, { now });
assert.deepEqual(
abandoned.map((pr) => pr.number),
[3],
);
assert.equal(gh.calls[0][gh.calls[0].indexOf('--state') + 1], 'closed');
});
test('sync replays and force-pushes with a lease, creating no commit', async () => {
const git = makeStub([...baseGitRoutes, [isRebase, '']]);
const gh = makeStub(noOpenPr);
await sync({ git, gh, env, log: () => {} });
await sync({ git, gh, pnpm: makeStub(), env, log: () => {} });
const push = git.calls.find((a) => a[0] === 'push');
assert.deepEqual(push, [
@ -119,16 +470,31 @@ test('sync replays and force-pushes with a lease, creating no commit', async ()
`HEAD:refs/heads/${TARGET_BRANCH}`,
]);
// The point of the change: no merge, no squash, no PR on the clean path.
assert.equal(git.calls.some((a) => a[0] === 'merge'), false);
assert.equal(git.calls.some((a) => a[0] === 'commit'), false);
assert.equal(gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'), false);
assert.equal(
git.calls.some((a) => a[0] === 'merge'),
false,
);
assert.equal(
git.calls.some((a) => a[0] === 'commit'),
false,
);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
false,
);
});
test('sync targets the rehearsal branch when SYNC_TARGET_BRANCH is set', async () => {
const git = makeStub([...baseGitRoutes, [isRebase, '']]);
const gh = makeStub(noOpenPr);
await sync({ git, gh, env: { ...env, SYNC_TARGET_BRANCH: '3x-sync-test' }, log: () => {} });
await sync({
git,
gh,
pnpm: makeStub(),
env: { ...env, SYNC_TARGET_BRANCH: '3x-sync-test' },
log: () => {},
});
assert.equal(git.calls.find((a) => a[0] === 'push').at(-1), 'HEAD:refs/heads/3x-sync-test');
});
@ -141,10 +507,18 @@ test('sync does nothing when 3.x already contains master', async () => {
]);
const gh = makeStub(noOpenPr);
await sync({ git, gh, env, log: () => {} });
await sync({ git, gh, pnpm: makeStub(), env, log: () => {} });
assert.equal(git.calls.some((a) => a[0] === 'rebase'), false, 'must not rebase');
assert.equal(git.calls.some((a) => a[0] === 'push'), false, 'must not push');
assert.equal(
git.calls.some((a) => a[0] === 'rebase'),
false,
'must not rebase',
);
assert.equal(
git.calls.some((a) => a[0] === 'push'),
false,
'must not push',
);
});
test('sync refuses to push when the replayed tree is not the merge tree', async () => {
@ -155,15 +529,22 @@ test('sync refuses to push when the replayed tree is not the merge tree', async
]);
const gh = makeStub(noOpenPr);
await assert.rejects(() => sync({ git, gh, env, log: () => {} }), /does not match the merge tree/);
assert.equal(git.calls.some((a) => a[0] === 'push'), false, 'must not push a suspect rewrite');
await assert.rejects(
() => sync({ git, gh, pnpm: makeStub(), env, log: () => {} }),
/does not match the merge tree/,
);
assert.equal(
git.calls.some((a) => a[0] === 'push'),
false,
'must not push a suspect rewrite',
);
});
test('sync halts (no fetch/rebase) when a conflict PR is already open', async () => {
const git = makeStub();
const gh = makeStub([[(a) => a[0] === 'pr' && a[1] === 'list', JSON.stringify([{ number: 7 }])]]);
await sync({ git, gh, env, log: () => {} });
await sync({ git, gh, pnpm: makeStub(), env, log: () => {} });
assert.equal(git.calls.length, 0, 'must not touch git while halted');
});
@ -178,55 +559,160 @@ test('sync replays favouring 3.x when the patches no longer apply on their own',
]);
const gh = makeStub(noOpenPr);
await sync({ git, gh, env, log: () => {} });
await sync({ git, gh, pnpm: makeStub(), env, log: () => {} });
assert.ok(git.calls.some((a) => a[0] === 'rebase' && a[1] === '--abort'), 'stalled rebase must be aborted');
assert.ok(
git.calls.some((a) => a[0] === 'rebase' && a[1] === '--abort'),
'stalled rebase must be aborted',
);
assert.ok(git.calls.some(favouringOwnSide), 'expected the second replay to favour 3.x');
assert.ok(git.calls.some((a) => a[0] === 'push'), 'expected the replay to be pushed');
assert.ok(
git.calls.some((a) => a[0] === 'push'),
'expected the replay to be pushed',
);
// Still no new commit and no PR: the resolver's own fix commit is already in the queue.
assert.equal(git.calls.some((a) => a[0] === 'commit'), false);
assert.equal(gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'), false);
assert.equal(
git.calls.some((a) => a[0] === 'commit'),
false,
);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
false,
);
});
test('sync recovers when the favoured replay stalls on a modify/delete conflict', async () => {
// The endpoints reconcile (merge-tree is clean) because a fix commit in the queue
// re-deletes the file — but replaying the deleting commit itself stalls on
// modify/delete, which `-X theirs` never settles on its own.
const FILE = 'packages/nodes-base/nodes/Function/Function.node.ts';
const git = makeStub([
...baseGitRoutes,
[(a) => a[0] === 'rebase' && a[1] === '--continue', ''],
[isRebase, fail(`CONFLICT (modify/delete): ${FILE} deleted in 0ff923a066`)],
[isConflictedFiles, FILE],
[(a) => a[0] === 'ls-files', `100644 aaa 1\t${FILE}\n100644 bbb 2\t${FILE}`],
[(a) => a[0] === 'diff-index', fail()],
]);
const gh = makeStub(noOpenPr);
await sync({ git, gh, pnpm: makeStub(), env, log: () => {} });
assert.ok(
git.calls.some((a) => a[0] === 'rm' && a.includes(FILE)),
'expected the queue-side deletion to be taken',
);
assert.ok(
git.calls.some((a) => a[0] === 'push'),
'expected the replay to be pushed',
);
// Still no new commit and no PR — the fix commit in the queue does the real work.
assert.equal(
git.calls.some((a) => a[0] === 'commit'),
false,
);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
false,
);
});
test('sync folds favoured-replay drift back to the merge tree before pushing', async () => {
// The favoured replay finishes but its tip drifts from the merge tree (the lockfile
// hunks `-X theirs` resolved toward 3.x): the drift is folded back, then pushed.
let treeReads = 0;
const git = makeStub([
...baseGitRoutes.filter((r) => !r[0](['rev-parse', 'HEAD^{tree}'])),
[
(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}',
() => (treeReads++ === 0 ? 'DRIFTED' : MERGE_TREE),
],
[(a) => a[0] === 'diff-tree', LOCKFILE],
[(a) => a[0] === 'cat-file', ''],
[favouringOwnSide, ''],
[isRebase, fail('CONFLICT (content): packages/cli/x.ts')],
]);
const gh = makeStub(noOpenPr);
await sync({ git, gh, pnpm: makeStub(), env, log: () => {} });
assert.ok(git.calls.some((a) => a[0] === 'checkout' && a[1] === MERGE_TREE));
assert.ok(git.calls.some((a) => a[0] === 'commit' && a.includes('--amend')));
assert.ok(
git.calls.some((a) => a[0] === 'push'),
'expected the reconciled replay to be pushed',
);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
false,
);
});
test('sync fails without pushing when even the favoured replay cannot finish', async () => {
const git = makeStub([...baseGitRoutes, [isRebase, fail('CONFLICT')]]);
const git = makeStub([
...baseGitRoutes,
[isRebase, fail('rebase failed for a non-conflict reason')],
// No conflicted files: nothing the driver may resolve — don't guess.
[isConflictedFiles, ''],
]);
const gh = makeStub(noOpenPr);
await assert.rejects(() => sync({ git, gh, env, log: () => {} }), /needs a human/);
assert.equal(git.calls.some((a) => a[0] === 'push'), false);
await assert.rejects(
() => sync({ git, gh, pnpm: makeStub(), env, log: () => {} }),
/needs a human/,
);
assert.equal(
git.calls.some((a) => a[0] === 'push'),
false,
);
assert.equal(git.calls.filter((a) => a[0] === 'rebase' && a[1] === '--abort').length, 2);
});
test('buildConflictBranch commits the conflicted state, markers and all', () => {
const git = makeStub([
[(a) => a[0] === 'merge', fail('CONFLICT (content): packages/cli/x.ts')],
[(a) => a[0] === 'diff', 'packages/cli/x.ts'],
]);
const files = buildConflictBranch({ git, masterSha: MASTER, log: () => {} });
assert.deepEqual(files, ['packages/cli/x.ts']);
assert.deepEqual(git.calls[0], ['merge', '--no-edit', MASTER]);
assert.ok(git.calls.some((a) => a[0] === 'add' && a[1] === '-A'));
assert.ok(git.calls.some((a) => a[0] === 'commit' && a.includes('--no-edit')));
// The markers ARE the review surface here, so nothing may auto-resolve them.
assert.equal(git.calls.some((a) => a.includes('-X')), false);
assert.equal(git.calls.some((a) => a[0] === 'merge' && a[1] === '--abort'), false);
});
test('buildConflictBranch refuses to guess when the merge unexpectedly succeeds', () => {
const git = makeStub([[(a) => a[0] === 'merge', '']]);
assert.throws(() => buildConflictBranch({ git, masterSha: MASTER, log: () => {} }), /Expected a merge conflict/);
assert.equal(git.calls.some((a) => a[0] === 'commit'), false);
});
test('sync opens a draft conflict PR and leaves 3.x untouched on a real conflict', async () => {
test('sync auto-resolves a lockfile-only conflict during the replay — no PR, no commit', async () => {
const git = makeStub([
...baseGitRoutes.filter((r) => !r[0](['merge-tree'])),
[(a) => a[0] === 'merge-tree', fail('CONFLICT (content): packages/cli/x.ts')],
[(a) => a[0] === 'merge', fail('CONFLICT (content): packages/cli/x.ts')],
[(a) => a[0] === 'diff', 'packages/cli/x.ts'],
[(a) => a[0] === 'merge-tree', conflictedMergeTree(LOCKFILE)],
[(a) => a[0] === 'rebase' && a[1] === '--continue', ''],
[isRebase, fail(`CONFLICT (content): Merge conflict in ${LOCKFILE}`)],
[isConflictedFiles, LOCKFILE],
[(a) => a[0] === 'diff' && a.includes('--quiet'), ''], // tip lockfile already consistent
[(a) => a[0] === 'diff-index', fail()], // staged resolution -> continue, not skip
]);
const gh = makeStub(noOpenPr);
const pnpm = makeStub();
await sync({ git, gh, pnpm, env, log: () => {} });
// The stall regen plus the tip reconciliation check.
assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']);
assert.equal(pnpm.calls.length, 2);
assert.ok(git.calls.some((a) => a[0] === 'add' && a.includes(LOCKFILE)));
assert.ok(git.calls.some((a) => a[0] === 'rebase' && a[1] === '--continue'));
const push = git.calls.find((a) => a[0] === 'push');
assert.equal(push[1], `--force-with-lease=refs/heads/${TARGET_BRANCH}:${PRE_HEAD}`);
// No human surface: no merge commit, no amend, no conflict PR.
assert.equal(
git.calls.some((a) => a[0] === 'merge'),
false,
);
assert.equal(
git.calls.some((a) => a[0] === 'commit'),
false,
);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
false,
);
});
test('sync falls back to a conflict PR when mechanical auto-resolution cannot complete', async () => {
const git = makeStub([
...baseGitRoutes.filter((r) => !r[0](['merge-tree'])),
[(a) => a[0] === 'merge-tree', conflictedMergeTree(LOCKFILE)],
[isRebase, fail('CONFLICT')],
// Both replay attempts stall on a code file the endpoints do not reconcile.
[isConflictedFiles, 'packages/cli/x.ts'],
[(a) => a[0] === 'merge', fail('CONFLICT (content): Merge conflict in packages/cli/x.ts')],
[(a) => a[0] === 'log', 'breaking-sha'],
]);
const gh = makeStub([
@ -234,7 +720,142 @@ test('sync opens a draft conflict PR and leaves 3.x untouched on a real conflict
[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/99'],
]);
await sync({ git, gh, env, fetchFn: okFetch(['alice']), log: () => {} });
await sync({ git, gh, pnpm: makeStub(), env, fetchFn: okFetch(['alice']), log: () => {} });
assert.ok(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
'expected the fallback conflict PR',
);
// Only the sync branch is pushed — 3.x must not move after a failed auto-resolution.
const pushes = git.calls.filter((a) => a[0] === 'push');
assert.equal(pushes.length, 1);
assert.equal(pushes[0].at(-1), `HEAD:refs/heads/${SYNC_BRANCH}`);
});
test('buildConflictBranch commits the conflicted state, markers and all', () => {
const git = makeStub([
[(a) => a[0] === 'merge', fail('CONFLICT (content): packages/cli/x.ts')],
[isConflictedFiles, 'packages/cli/x.ts'],
]);
const { files, preResolved, lockfileDeferred } = buildConflictBranch({
git,
pnpm: makeStub(),
masterSha: MASTER,
log: () => {},
});
assert.deepEqual(files, ['packages/cli/x.ts']);
assert.deepEqual(preResolved, []);
assert.equal(lockfileDeferred, false);
assert.deepEqual(git.calls[0], ['merge', '--no-edit', MASTER]);
assert.ok(git.calls.some((a) => a[0] === 'add' && a[1] === '-A'));
assert.ok(git.calls.some((a) => a[0] === 'commit' && a.includes('--no-edit')));
// The markers ARE the review surface here, so nothing may auto-resolve them.
assert.equal(
git.calls.some((a) => a.includes('-X')),
false,
);
assert.equal(
git.calls.some((a) => a[0] === 'merge' && a[1] === '--abort'),
false,
);
});
test('buildConflictBranch pre-resolves mechanical files so only code conflicts remain', () => {
const git = makeStub([
[(a) => a[0] === 'merge', fail('CONFLICT')],
[isConflictedFiles, `packages/cli/x.ts\n${LOCKFILE}\n${POPULARITY}`],
[(a) => a[0] === 'cat-file', ''],
]);
const pnpm = makeStub();
const { files, preResolved, lockfileDeferred } = buildConflictBranch({
git,
pnpm,
masterSha: MASTER,
log: () => {},
});
assert.deepEqual(files, ['packages/cli/x.ts']);
assert.deepEqual(preResolved, [LOCKFILE, POPULARITY]);
assert.equal(lockfileDeferred, false);
assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']);
assert.ok(
git.calls.some((a) => a[0] === 'checkout' && a[1] === MASTER && a.includes(POPULARITY)),
);
});
test('buildConflictBranch defers the lockfile when a manifest is conflicted too', () => {
const git = makeStub([
[(a) => a[0] === 'merge', fail('CONFLICT')],
[isConflictedFiles, `packages/cli/package.json\n${LOCKFILE}`],
]);
const pnpm = makeStub();
const { files, preResolved, lockfileDeferred } = buildConflictBranch({
git,
pnpm,
masterSha: MASTER,
log: () => {},
});
assert.deepEqual(files, ['packages/cli/package.json']);
assert.deepEqual(preResolved, []);
assert.equal(lockfileDeferred, true);
assert.equal(pnpm.calls.length, 0, 'regen is meaningless until the manifests are resolved');
});
test('buildConflictBranch degrades to a deferred lockfile when the regen fails', () => {
const git = makeStub([
[(a) => a[0] === 'merge', fail('CONFLICT')],
[isConflictedFiles, `packages/cli/x.ts\n${LOCKFILE}`],
]);
const pnpm = makeStub([[() => true, fail('ERR_PNPM_REGISTRY unreachable')]]);
const { files, preResolved, lockfileDeferred } = buildConflictBranch({
git,
pnpm,
masterSha: MASTER,
log: () => {},
});
assert.deepEqual(files, ['packages/cli/x.ts']);
assert.deepEqual(preResolved, []);
assert.equal(lockfileDeferred, true);
assert.ok(
git.calls.some((a) => a[0] === 'commit'),
'the conflict branch must still be committed',
);
});
test('buildConflictBranch refuses to guess when the merge unexpectedly succeeds', () => {
const git = makeStub([[(a) => a[0] === 'merge', '']]);
assert.throws(
() => buildConflictBranch({ git, pnpm: makeStub(), masterSha: MASTER, log: () => {} }),
/Expected a merge conflict/,
);
assert.equal(
git.calls.some((a) => a[0] === 'commit'),
false,
);
});
test('sync opens a draft conflict PR and leaves 3.x untouched on a real conflict', async () => {
const git = makeStub([
...baseGitRoutes.filter((r) => !r[0](['merge-tree'])),
[(a) => a[0] === 'merge-tree', conflictedMergeTree('packages/cli/x.ts')],
[(a) => a[0] === 'merge', fail('CONFLICT (content): packages/cli/x.ts')],
[isConflictedFiles, 'packages/cli/x.ts'],
[(a) => a[0] === 'log', 'breaking-sha'],
]);
const gh = makeStub([
...noOpenPr,
[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/99'],
]);
await sync({ git, gh, pnpm: makeStub(), env, fetchFn: okFetch(['alice']), log: () => {} });
const create = gh.calls.find((a) => a[0] === 'pr' && a[1] === 'create');
assert.ok(create.includes('--draft'));
@ -253,12 +874,52 @@ test('sync opens a draft conflict PR and leaves 3.x untouched on a real conflict
const pushes = git.calls.filter((a) => a[0] === 'push');
assert.equal(pushes.length, 1);
assert.equal(pushes[0].at(-1), `HEAD:refs/heads/${SYNC_BRANCH}`);
assert.equal(git.calls.some((a) => a[0] === 'rebase'), false, 'no replay attempt while a conflict is unresolved');
assert.equal(
git.calls.some((a) => a[0] === 'rebase'),
false,
'no replay attempt while a code conflict is unresolved',
);
});
test('sync reports only the code conflicts on a mixed conflict, with mechanical files pre-resolved', async () => {
const git = makeStub([
...baseGitRoutes.filter((r) => !r[0](['merge-tree'])),
[(a) => a[0] === 'merge-tree', conflictedMergeTree(LOCKFILE, 'packages/cli/x.ts')],
[(a) => a[0] === 'merge', fail('CONFLICT')],
[isConflictedFiles, `packages/cli/x.ts\n${LOCKFILE}`],
[(a) => a[0] === 'log' && a.includes('--format=%H'), 'breaking-sha'],
]);
const gh = makeStub([
...noOpenPr,
[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/99'],
]);
const pnpm = makeStub();
await sync({ git, gh, pnpm, env, fetchFn: okFetch(['alice']), log: () => {} });
assert.equal(pnpm.calls.length, 1, 'the lockfile is regenerated for the conflict branch');
const create = gh.calls.find((a) => a[0] === 'pr' && a[1] === 'create');
const body = create[create.indexOf('--body') + 1];
assert.match(body, /### Conflicted files\n- `packages\/cli\/x\.ts`/);
assert.match(body, /### Auto-resolved for you/);
assert.ok(
body.indexOf(LOCKFILE) > body.indexOf('Auto-resolved'),
'the lockfile belongs to the auto-resolved section',
);
// Owner attribution is scoped to the real code conflicts only.
const attributions = git.calls.filter((a) => a[0] === 'log' && a.includes('--format=%H'));
assert.equal(attributions.length, 1);
assert.equal(attributions[0].at(-1), 'packages/cli/x.ts');
});
test('openConflictPr degrades gracefully when owner resolution fails', async () => {
const git = makeStub([[(a) => a[0] === 'log', 'sha1']]);
const gh = makeStub([[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/1']]);
const gh = makeStub([
[(a) => a[0] === 'pr' && a[1] === 'list', '[]'],
[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/1'],
]);
const failingFetch = async () => ({ ok: false, status: 500, json: async () => ({}) });
const { prUrl, ownersSlack } = await openConflictPr({
@ -277,5 +938,41 @@ test('openConflictPr degrades gracefully when owner resolution fails', async ()
assert.equal(prUrl, 'https://github.com/n8n-io/n8n/pull/1');
assert.equal(ownersSlack, 'Could not auto-attribute owners.');
// No reviewer request when there are no owners.
assert.equal(gh.calls.some((a) => a[0] === 'pr' && a[1] === 'edit'), false);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'edit'),
false,
);
});
test('openConflictPr calls out a recently abandoned conflict PR', async () => {
const git = makeStub([[(a) => a[0] === 'log', 'sha1']]);
const closedAt = new Date(Date.now() - 2 * 86_400_000).toISOString();
const gh = makeStub([
[
(a) => a[0] === 'pr' && a[1] === 'list' && a.includes('closed'),
JSON.stringify([
{ number: 42, url: 'https://github.com/n8n-io/n8n/pull/42', mergedAt: null, closedAt },
]),
],
[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/43'],
]);
const { ownersSlack } = await openConflictPr({
git,
gh,
repo: 'n8n-io/n8n',
token: 't',
masterSha: MASTER,
preHead: PRE_HEAD,
pushUrl: 'https://push',
files: ['x.ts'],
fetchFn: okFetch(['alice']),
log: () => {},
});
const create = gh.calls.find((a) => a[0] === 'pr' && a[1] === 'create');
const body = create[create.indexOf('--body') + 1];
assert.match(body, /#42\) was closed without being merged/);
assert.match(body, /Merge, don't close/);
assert.match(ownersSlack, /<https:\/\/github\.com\/n8n-io\/n8n\/pull\/42\|#42>/);
});

View File

@ -2,17 +2,24 @@ const { writeFileSync } = require('fs');
const { resolve } = require('path');
const baseDir = resolve(__dirname, '../..');
const trimPackageJson = (packageName) => {
/**
* `keepRuntimeDeps` packages publish a `dist` their consumers resolve, so npm
* has to install what that `dist` imports — dropping the declarations ships
* code that cannot resolve. Packages published only as a bundled app keep
* nothing.
*/
const trimPackageJson = (packageName, { keepRuntimeDeps = false } = {}) => {
const filePath = resolve(baseDir, 'packages', packageName, 'package.json');
const { scripts, peerDependencies, devDependencies, dependencies, ...packageJson } = require(
filePath,
);
if (packageName === 'frontend/@n8n/chat') {
packageJson.dependencies = dependencies;
if (keepRuntimeDeps) {
if (dependencies) packageJson.dependencies = dependencies;
if (peerDependencies) packageJson.peerDependencies = peerDependencies;
}
writeFileSync(filePath, JSON.stringify(packageJson, null, 2) + '\n', 'utf-8');
};
trimPackageJson('frontend/@n8n/chat');
trimPackageJson('frontend/@n8n/design-system');
trimPackageJson('frontend/@n8n/chat', { keepRuntimeDeps: true });
trimPackageJson('frontend/@n8n/design-system', { keepRuntimeDeps: true });
trimPackageJson('frontend/editor-ui');

View File

@ -27,10 +27,12 @@ const changelogStream = new ConventionalChangelog()
releaseCount: 1,
transformCommit(commit) {
const hasNoChangelogInHeader = commit.header?.includes('(no-changelog)');
const isBenchmarkScope = commit.scope === 'benchmark';
// 'engine' is ignored for now. We will show it in the changelog when the engine is stable.
const ignoredScopes = ['benchmark', 'engine'];
const hasIgnoredScope = ignoredScopes.includes(commit.scope);
// Ignore commits that have 'benchmark' scope or '(no-changelog)' in the header
if (hasNoChangelogInHeader || isBenchmarkScope) return null;
// Ignore commits that have an ignored scope or '(no-changelog)' in the header
if (hasNoChangelogInHeader || hasIgnoredScope) return null;
// Strip backport information from commit subject, e.g.:
// "Fix something (backport to release-candidate/2.12.x) (#123)" → "Fix something (#123)"

File diff suppressed because one or more lines are too long

View File

@ -26,11 +26,17 @@ jobs:
strategy:
fail-fast: false
matrix:
# Node 26 requires alpine 3.23 on DHI (3.22 pairing isn't published).
# While we're on alpine 3.22 awaiting NODE-4184 (graphicsmagick → sharp
# migration that unblocks 3.23), Node 26 base builds can't succeed.
# Restore '26' to this matrix once 3.23 is back.
node_version: ['22', '24.18.1']
# One entry per published Node version: `node_version` is the
# `n8nio/base:<node_version>` tag, `dhi_ref` the DHI base it builds on,
# pinned to a multi-arch index digest. Bump tag and digest together
# when updating.
include:
- node_version: '22'
dhi_ref: dhi.io/node:22.23.2-alpine3.24-dev@sha256:a6ac21cfbc4bb746c1cfc6cf1501ec13c961ae71558ff2ac7e021caaaf91a224
- node_version: '24.18.1'
dhi_ref: dhi.io/node:24.18.1-alpine3.24-dev@sha256:074b5aa92e1ce74cd575214211bbcb8d1550710aec59558da734a67d84d31679
- node_version: '26.5.1'
dhi_ref: dhi.io/node:26.5.1-alpine3.24-dev@sha256:c4062f85acd1ca91ffb7d15048dcc5f15a922d630e65eb3c3c0dcdcef6ea36d8
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -63,7 +69,7 @@ jobs:
context: .
file: ./docker/images/n8n-base/Dockerfile
build-args: |
NODE_VERSION=${{ matrix.node_version }}
DHI_REF=${{ matrix.dhi_ref }}
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) }}

View File

@ -0,0 +1,130 @@
name: 'Build: Node PC Image'
# Publishes n8nio/node-pc, the pointer-compressed Node runtime consumed by the
# n8n `-pc` image variant. Compiling Node from source is too slow under QEMU,
# so each architecture builds on its native runner and a manifest job stitches
# the per-arch images, following docker-build-push.yml.
on:
push:
branches:
- master
paths:
- 'docker/images/node-pc/Dockerfile'
- '.github/workflows/build-node-pc-image.yml'
pull_request:
paths:
- 'docker/images/node-pc/Dockerfile'
- '.github/workflows/build-node-pc-image.yml'
workflow_dispatch:
inputs:
push:
description: 'Push to registries'
required: false
default: false
type: boolean
env:
NODE_VERSION: '26.5.1'
jobs:
build:
runs-on: ${{ matrix.runner }}
timeout-minutes: 150
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: blacksmith-32vcpu-ubuntu-2204
arch: amd64
- platform: linux/arm64
runner: blacksmith-32vcpu-ubuntu-2204-arm
arch: arm64
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker registries (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 runtime target
uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2
with:
context: .
file: ./docker/images/node-pc/Dockerfile
target: runtime
platforms: ${{ matrix.platform }}
build-args: |
NODE_VERSION=${{ env.NODE_VERSION }}
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 }}/node-pc:${{ env.NODE_VERSION }}-${{ matrix.arch }}
${{ secrets.DOCKER_USERNAME }}/node-pc:${{ env.NODE_VERSION }}-${{ github.sha }}-${{ matrix.arch }}
ghcr.io/${{ github.repository_owner }}/node-pc:${{ env.NODE_VERSION }}-${{ matrix.arch }}
ghcr.io/${{ github.repository_owner }}/node-pc:${{ env.NODE_VERSION }}-${{ github.sha }}-${{ matrix.arch }}
no-cache: true
# No no-cache here: reuses the compile layers from the runtime build
# above, so Node compiles once per architecture
- name: Build and push dev target
uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2
with:
context: .
file: ./docker/images/node-pc/Dockerfile
target: dev
platforms: ${{ matrix.platform }}
build-args: |
NODE_VERSION=${{ env.NODE_VERSION }}
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 }}/node-pc:${{ env.NODE_VERSION }}-dev-${{ matrix.arch }}
${{ secrets.DOCKER_USERNAME }}/node-pc:${{ env.NODE_VERSION }}-${{ github.sha }}-dev-${{ matrix.arch }}
ghcr.io/${{ github.repository_owner }}/node-pc:${{ env.NODE_VERSION }}-dev-${{ matrix.arch }}
ghcr.io/${{ github.repository_owner }}/node-pc:${{ env.NODE_VERSION }}-${{ github.sha }}-dev-${{ matrix.arch }}
create-multi-arch-manifest:
needs: build
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker registries
uses: ./.github/actions/docker-registry-login
with:
login-ghcr: 'true'
login-dockerhub: 'true'
dockerhub-username: ${{ secrets.DOCKER_USERNAME }}
dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create multi-arch manifests
run: |
for REPO in \
"${{ secrets.DOCKER_USERNAME }}/node-pc" \
"ghcr.io/${{ github.repository_owner }}/node-pc" \
; do
for DEV in "" "-dev"; do
AMD64="$REPO:${{ env.NODE_VERSION }}-${{ github.sha }}$DEV-amd64"
ARM64="$REPO:${{ env.NODE_VERSION }}-${{ github.sha }}$DEV-arm64"
for TAG in \
"$REPO:${{ env.NODE_VERSION }}$DEV" \
"$REPO:${{ env.NODE_VERSION }}-${{ github.sha }}$DEV" \
; do
echo "Creating manifest $TAG"
docker buildx imagetools create --tag "$TAG" "$AMD64" "$ARM64"
done
done
done

View File

@ -5,6 +5,9 @@
# before release. Reuses the shared docker-build-push.yml pipeline, pointing it
# at the 3.x branch via the `ref` input.
#
# On Mondays the same manifests are additionally retagged v3-rc / v3-rc-<date>, so
# there is a weekly, self-consistent set to run a stack against.
#
# Lives on master so the schedule fires (scheduled runs only trigger on the
# default branch), but builds the 3.x branch's code.
@ -15,6 +18,12 @@ on:
# 08:00 UTC — after the master→3.x sync (06:00) so the image reflects the latest sync
- cron: '0 8 * * *'
workflow_dispatch:
inputs:
force_rc:
description: 'Also tag this build as a release candidate (normally Mondays only)'
required: false
type: boolean
default: false
jobs:
prepare:
@ -24,10 +33,14 @@ jobs:
permissions: {}
outputs:
date_tag: ${{ steps.date.outputs.date }}
day_of_week: ${{ steps.date.outputs.day_of_week }}
steps:
- name: Compute date tag
id: date
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
# Both values read the same UTC clock, so they can't disagree across a day boundary.
run: |
echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
echo "day_of_week=$(date -u +%u)" >> "$GITHUB_OUTPUT"
build:
name: Build and push v3-nightly images
@ -44,3 +57,143 @@ jobs:
date_tag: ${{ needs.prepare.outputs.date_tag }}
push_enabled: true
secrets: inherit
# On Mondays (or on demand via force_rc), promote the whole set (n8n + both runners
# images) to v3-rc so a stack pulling `v3-rc` gets images that were built from the same
# 3.x commit. Pure retag of manifests the build already pushed — nothing is rebuilt.
#
# RCs can be published several times a day: each publish claims the next free rolling
# number for today (v3-rc-<date>.1, .2, …) as its immutable reference, and also moves
# the floating v3-rc and v3-rc-<date> tags onto it.
tag_release_candidate:
name: Tag release candidate
needs: [prepare, build]
if: ${{ needs.prepare.outputs.day_of_week == '1' || inputs.force_rc }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# Serialize promotion only (not the build) so two runs can't claim the same number.
concurrency:
group: v3-rc-tagging
cancel-in-progress: false
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker registries
uses: ./.github/actions/docker-registry-login
with:
login-ghcr: true
login-dockerhub: true
dockerhub-username: ${{ secrets.DOCKER_USERNAME }}
dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}
- name: Claim rolling RC number for today
id: rc
env:
RC_VERSION: v3-rc
DATE_TAG: ${{ needs.prepare.outputs.date_tag }}
N8N_IMAGE: ${{ needs.build.outputs.n8n_image }}
MAX_PER_DAY: '50'
run: |
set -euo pipefail
# The n8n GHCR repo is the single source of truth for the counter; all images
# then share the number, so one RC is always one consistent set.
tag_exists() {
local ref=$1 out
if out=$(docker buildx imagetools inspect "$ref" --raw 2>&1); then
return 0
fi
# Only a genuine absence frees the number. Auth/network/rate-limit failures
# must not be read as "free" — that would overwrite an immutable tag.
if grep -qiE 'not found|manifest unknown|no such manifest|MANIFEST_UNKNOWN|404' <<<"$out"; then
return 1
fi
echo "::error::Cannot determine whether ${ref} exists: ${out}"
exit 1
}
RC_NUMBER=''
for n in $(seq 1 "$MAX_PER_DAY"); do
if ! tag_exists "${N8N_IMAGE}:${RC_VERSION}-${DATE_TAG}.${n}"; then
RC_NUMBER=$n
break
fi
echo "${RC_VERSION}-${DATE_TAG}.${n} already published"
done
if [[ -z "$RC_NUMBER" ]]; then
echo "::error::Already published ${MAX_PER_DAY} release candidates for ${DATE_TAG}"
exit 1
fi
echo "Claiming ${RC_VERSION}-${DATE_TAG}.${RC_NUMBER}"
echo "number=${RC_NUMBER}" >> "$GITHUB_OUTPUT"
- name: Promote nightly manifests to release candidate
env:
NIGHTLY_VERSION: v3-nightly
RC_VERSION: v3-rc
DATE_TAG: ${{ needs.prepare.outputs.date_tag }}
RC_NUMBER: ${{ steps.rc.outputs.number }}
DOCKER_BASE: ${{ secrets.DOCKER_USERNAME }}
N8N_IMAGE: ${{ needs.build.outputs.n8n_image }}
N8N_DIGEST: ${{ needs.build.outputs.n8n_digest }}
RUNNERS_IMAGE: ${{ needs.build.outputs.runners_image }}
RUNNERS_DIGEST: ${{ needs.build.outputs.runners_digest }}
DISTROLESS_IMAGE: ${{ needs.build.outputs.runners_distroless_image }}
DISTROLESS_DIGEST: ${{ needs.build.outputs.runners_distroless_digest }}
run: |
set -euo pipefail
# Each image gets three tags: the numbered one is the immutable reference for this
# publish; v3-rc and v3-rc-<date> float onto the newest RC overall / of the day.
RC_ROLLING="${RC_VERSION}-${DATE_TAG}.${RC_NUMBER}"
# Retag one image as RC on both registries. GHCR is sourced by digest (immutable,
# can't drift); Docker Hub is sourced by its dated tag because `imagetools create`
# only retags within a repository — it can't pull GHCR blobs into Docker Hub.
promote() {
local ghcr_repo=$1 digest=$2 docker_repo=$3 suffix=$4
if [[ -z "$ghcr_repo" || -z "$digest" ]]; then
echo "::error::Missing image/digest for ${docker_repo}${suffix} - nightly manifest not published"
return 1
fi
echo "Promoting ${ghcr_repo}@${digest} -> ${RC_ROLLING}${suffix}"
docker buildx imagetools create \
--tag "${ghcr_repo}:${RC_ROLLING}${suffix}" \
--tag "${ghcr_repo}:${RC_VERSION}${suffix}" \
--tag "${ghcr_repo}:${RC_VERSION}-${DATE_TAG}${suffix}" \
"${ghcr_repo}@${digest}"
docker buildx imagetools create \
--tag "${docker_repo}:${RC_ROLLING}${suffix}" \
--tag "${docker_repo}:${RC_VERSION}${suffix}" \
--tag "${docker_repo}:${RC_VERSION}-${DATE_TAG}${suffix}" \
"${docker_repo}:${NIGHTLY_VERSION}-${DATE_TAG}${suffix}"
{
echo "- \`${ghcr_repo}:${RC_ROLLING}${suffix}\`"
echo "- \`${docker_repo}:${RC_ROLLING}${suffix}\`"
} >> "$GITHUB_STEP_SUMMARY"
}
{
echo "## v3 release candidate \`${RC_ROLLING}\`"
echo ""
echo "Immutable references (\`${RC_VERSION}\` and \`${RC_VERSION}-${DATE_TAG}\` now point here too):"
} >> "$GITHUB_STEP_SUMMARY"
promote "$N8N_IMAGE" "$N8N_DIGEST" "${DOCKER_BASE}/n8n" ""
promote "$RUNNERS_IMAGE" "$RUNNERS_DIGEST" "${DOCKER_BASE}/runners" ""
promote "$DISTROLESS_IMAGE" "$DISTROLESS_DIGEST" "${DOCKER_BASE}/runners" "-distroless"

View File

@ -16,6 +16,6 @@ jobs:
timeout-minutes: 5
steps:
- name: Validate PR title
uses: n8n-io/validate-n8n-pull-request-title@c3b6fd06bda12eebd57a592c0cf3b747d5b73569 # v2.4.0
uses: n8n-io/validate-n8n-pull-request-title@8aad0456a34b6f487c421539210529304ae8042c # v2.5.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -24,13 +24,16 @@ jobs:
# The default is build without typechecking, we want both
build-command: pnpm build
- name: Report build critical path
run: node scripts/turbo-critical-path.mjs >> "$GITHUB_STEP_SUMMARY"
unit-test:
name: Unit tests
uses: ./.github/workflows/test-unit-reusable.yml
strategy:
fail-fast: false
matrix:
node-version: [22.23.2, 24.18.1]
node-version: [24.18.1, 26.5.1]
with:
ref: ${{ github.sha }}
nodeVersion: ${{ matrix.node-version }}
@ -49,6 +52,16 @@ jobs:
with:
ref: ${{ github.sha }}
# Scoped to the packages this push touched, so it stays cheap enough to run on every commit.
verify-single-instance-npm:
name: Verify single-instance deps (npm-install)
uses: ./.github/workflows/test-single-instance-npm.yml
permissions:
contents: read
with:
scope: changed
base-ref: ${{ github.event.before }}
notify-on-failure:
name: Notify Slack on failure
runs-on: ubuntu-latest

View File

@ -33,6 +33,7 @@ jobs:
commit_sha: ${{ steps.commit-sha.outputs.sha }}
merge_base: ${{ steps.ci-filter.outputs.merge-base }}
matrix: ${{ steps.generate-matrix.outputs.matrix }}
db_test_matrix: ${{ steps.generate-db-test-matrix.outputs.db_test_matrix }}
skip_tests: ${{ steps.generate-matrix.outputs.skip-tests }}
affected_packages: ${{ steps.affected-packages.outputs.list }}
changed_files: ${{ steps.ci-filter.outputs.changed-files }}
@ -70,8 +71,10 @@ jobs:
!**/*.spec.ts
!packages/testing/playwright/**
!packages/frontend/@n8n/storybook/**
!.devcontainer/**
!scripts/agent-setup.mjs
!scripts/backend-module/**
!scripts/cloud-session.mjs
!scripts/licenses/**
!scripts/mutation-health/**
!scripts/sync-agent-skill-links.mjs
@ -83,8 +86,10 @@ jobs:
!**/*.md
!**/LICENSE
!**/CHANGELOG.md
!.devcontainer/**
!scripts/agent-setup.mjs
!scripts/backend-module/**
!scripts/cloud-session.mjs
!scripts/licenses/**
!scripts/mutation-health/**
!scripts/sync-agent-skill-links.mjs
@ -97,8 +102,11 @@ jobs:
packages/testing/containers/**
dev-server-smoke:
packages/frontend/editor-ui/vite.config.mts
# If this job becomes too slow, use a smaller glob. Do not delete the assertion.
packages/frontend/editor-ui/src/**
pnpm-workspace.yaml
packages/@n8n/*/package.json
packages/frontend/@n8n/frontend-vite-config/**
packages/testing/playwright/tests/dev-server-smoke/**
packages/testing/playwright/playwright.config.ts
packages/testing/playwright/playwright-projects.ts
@ -140,7 +148,10 @@ jobs:
packages/@n8n/scheduler/src/**
packages/cli/**/__tests__/**
packages/testing/containers/services/postgres.ts
packages/testing/containers/postgres-versions.json
packages/testing/containers/test-containers.ts
.github/workflows/test-db-reusable.yml
.github/scripts/db-test-matrix.mjs
docs/generated/**
.tbls.sqlite.yml
.tbls.postgres.yml
@ -151,6 +162,10 @@ jobs:
with:
build-command: ${{ fromJSON(steps.ci-filter.outputs.results).ci && 'pnpm build' || 'pnpm turbo run build --filter=@n8n/playwright-janitor' }}
- name: Report build critical path
if: fromJSON(steps.ci-filter.outputs.results).ci
run: node scripts/turbo-critical-path.mjs >> "$GITHUB_STEP_SUMMARY"
- name: Run format check
if: fromJSON(steps.ci-filter.outputs.results).ci
run: pnpm format:check
@ -167,6 +182,12 @@ jobs:
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
echo "skip-tests=$(node -e "process.stdout.write(JSON.parse(process.argv[1])[0]?.skip === true ? 'true' : 'false')" "$MATRIX")" >> "$GITHUB_OUTPUT"
# Generated here because a reusable workflow cannot resolve its own matrix.
- name: Generate DB test matrix
id: generate-db-test-matrix
if: fromJSON(steps.ci-filter.outputs.results).db
run: echo "db_test_matrix=$(node .github/scripts/db-test-matrix.mjs)" >> "$GITHUB_OUTPUT"
- name: Compute affected packages
id: affected-packages
if: fromJSON(steps.ci-filter.outputs.results).unit
@ -314,6 +335,7 @@ jobs:
uses: ./.github/workflows/test-db-reusable.yml
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
matrix: ${{ needs.install-and-build.outputs.db_test_matrix }}
performance:
name: Performance

View File

@ -8,7 +8,7 @@ run-name: "${{ github.event_name == 'workflow_dispatch' && format('Docker: Build
env:
NODE_OPTIONS: '--max-old-space-size=7168'
NODE_VERSION: '24.18.1'
NODE_VERSION: '26.5.1'
on:
schedule:
@ -40,6 +40,28 @@ on:
required: false
type: string
default: ''
outputs:
# Repo name + multi-arch manifest digest per image, so callers can promote what
# was actually pushed (retag by digest) instead of re-deriving tag strings.
# Empty when push_enabled is false — the manifest job doesn't run.
n8n_image:
description: 'GHCR repo for the n8n image (no tag)'
value: ${{ jobs.create_multi_arch_manifest.outputs.n8n_image }}
n8n_digest:
description: 'Multi-arch manifest digest of the n8n image'
value: ${{ jobs.create_multi_arch_manifest.outputs.n8n_digest }}
runners_image:
description: 'GHCR repo for the runners image (no tag)'
value: ${{ jobs.create_multi_arch_manifest.outputs.runners_image }}
runners_digest:
description: 'Multi-arch manifest digest of the runners image'
value: ${{ jobs.create_multi_arch_manifest.outputs.runners_digest }}
runners_distroless_image:
description: 'GHCR repo for the distroless runners image (no tag; same repo as runners)'
value: ${{ jobs.create_multi_arch_manifest.outputs.runners_distroless_image }}
runners_distroless_digest:
description: 'Multi-arch manifest digest of the distroless runners image'
value: ${{ jobs.create_multi_arch_manifest.outputs.runners_distroless_digest }}
workflow_dispatch:
inputs:
@ -109,6 +131,9 @@ jobs:
outputs:
image_ref: ${{ steps.determine-tags.outputs.n8n_primary_tag }}
primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.n8n_primary_tag }}
n8n_pc_primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.n8n_pc_primary_tag }}
n8n_pc_sha_manifest_tag: ${{ steps.determine-tags.outputs.n8n_pc_sha_primary_tag }}
n8n_pc_date_manifest_tag: ${{ steps.determine-tags.outputs.n8n_pc_date_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 }}
n8n_sha_manifest_tag: ${{ steps.determine-tags.outputs.n8n_sha_primary_tag }}
@ -127,6 +152,8 @@ jobs:
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
with:
# The image copies native bindings from the host build, so both need the same Node.
node-version: ${{ env.NODE_VERSION }}
build-command: pnpm build:n8n
enable-docker-cache: 'true'
env:
@ -153,7 +180,7 @@ jobs:
${{ 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
grep "_tags=" "$GITHUB_OUTPUT" | while IFS='=' read -r key value; do
echo "${key}: ${value%%,*}..." # Show first tag for brevity
done
@ -182,6 +209,26 @@ jobs:
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-tags.outputs.n8n_tags }}
- name: Build and push n8n -pc Docker image
id: build-n8n-pc
if: needs.determine-build-context.outputs.push_enabled == 'true'
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 }}
BUILDER_IMAGE=n8nio/node-pc:26.5.1-dev@sha256:9ecfe9d5c7ea835bccc604d06c3cb76a837b64496d4a999338cb8b53a50bb0bd
RUNTIME_IMAGE=n8nio/node-pc:26.5.1@sha256:e535408e7815904c98f81fb0203f7f657047d7db3d4edc77de44d2ea72c6bf18
IMAGE_DESCRIPTION=Workflow Automation Tool (pointer-compressed variant, internal to n8n Cloud, no support or stability guarantees)
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_pc_tags }}
- name: Kafka native binding smoke check
# `load: true` isn't an option here: with sbom:true, buildx produces a manifest
# list even for a single platform, and the docker exporter (what --load uses)
@ -240,6 +287,8 @@ jobs:
outputs:
n8n_digest: ${{ steps.get-digests.outputs.n8n_digest }}
n8n_image: ${{ steps.get-digests.outputs.n8n_image }}
n8n_pc_digest: ${{ steps.get-digests.outputs.n8n_pc_digest }}
n8n_pc_image: ${{ steps.get-digests.outputs.n8n_pc_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 }}
@ -262,6 +311,7 @@ jobs:
- name: Create GHCR multi-arch manifests
env:
N8N_DATE_MANIFEST_TAG: ${{ needs.build-and-push-docker.outputs.n8n_date_manifest_tag }}
N8N_PC_DATE_MANIFEST_TAG: ${{ needs.build-and-push-docker.outputs.n8n_pc_date_manifest_tag }}
RUNNERS_DATE_MANIFEST_TAG: ${{ needs.build-and-push-docker.outputs.runners_date_manifest_tag }}
RUNNERS_DISTROLESS_DATE_MANIFEST_TAG: ${{ needs.build-and-push-docker.outputs.runners_distroless_date_manifest_tag }}
run: |
@ -294,11 +344,13 @@ jobs:
# Create manifests for all images
create_manifest "n8n" "${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}"
create_manifest "n8n-pc" "${{ needs.build-and-push-docker.outputs.n8n_pc_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 }}"
# Create SHA-tagged manifests (immutable references for deployments)
create_manifest "n8n (sha)" "${{ needs.build-and-push-docker.outputs.n8n_sha_manifest_tag }}"
create_manifest "n8n-pc (sha)" "${{ needs.build-and-push-docker.outputs.n8n_pc_sha_manifest_tag }}"
create_manifest "runners (sha)" "${{ needs.build-and-push-docker.outputs.runners_sha_manifest_tag }}"
create_manifest "runners-distroless (sha)" "${{ needs.build-and-push-docker.outputs.runners_distroless_sha_manifest_tag }}"
@ -306,6 +358,7 @@ jobs:
# date_tag input was set (e.g. nightly), and create_manifest skips empty tags —
# so these calls are no-ops on non-dated builds.
create_manifest "n8n (date)" "$N8N_DATE_MANIFEST_TAG"
create_manifest "n8n-pc (date)" "$N8N_PC_DATE_MANIFEST_TAG"
create_manifest "runners (date)" "$RUNNERS_DATE_MANIFEST_TAG"
create_manifest "runners-distroless (date)" "$RUNNERS_DISTROLESS_DATE_MANIFEST_TAG"
@ -322,13 +375,15 @@ jobs:
# Create manifests for each image type
declare -A images=(
["n8n"]="${VERSION}"
["n8n-pc"]="${VERSION}-pc"
["runners"]="${VERSION}"
["runners-distroless"]="${VERSION}-distroless"
)
for image in "${!images[@]}"; do
TAG_SUFFIX="${images[$image]}"
IMAGE_NAME="${image//-distroless/}" # Remove -distroless from image name
IMAGE_NAME="${image//-distroless/}" # Remove the variant suffix from the image name
IMAGE_NAME="${IMAGE_NAME//-pc/}"
echo "Creating Docker Hub manifest for $image"
docker buildx imagetools create \
@ -341,6 +396,8 @@ jobs:
# to match docker-tags.mjs format: nightly-abc1234-distroless (not nightly-distroless-abc1234)
if [[ "$image" == *"-distroless"* ]]; then
SHA_SUFFIX="${VERSION}-${SHORT_SHA}-distroless"
elif [[ "$image" == *"-pc"* ]]; then
SHA_SUFFIX="${VERSION}-${SHORT_SHA}-pc"
else
SHA_SUFFIX="${TAG_SUFFIX}-${SHORT_SHA}"
fi
@ -355,6 +412,8 @@ jobs:
if [[ -n "$DATE_TAG" ]]; then
if [[ "$image" == *"-distroless"* ]]; then
DATE_SUFFIX="${VERSION}-${DATE_TAG}-distroless"
elif [[ "$image" == *"-pc"* ]]; then
DATE_SUFFIX="${VERSION}-${DATE_TAG}-pc"
else
DATE_SUFFIX="${TAG_SUFFIX}-${DATE_TAG}"
fi
@ -370,6 +429,7 @@ jobs:
id: get-digests
env:
N8N_TAG: ${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}
N8N_PC_TAG: ${{ needs.build-and-push-docker.outputs.n8n_pc_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
@ -408,6 +468,25 @@ jobs:
secrets:
registry-password: ${{ secrets.GITHUB_TOKEN }}
provenance-n8n-pc:
name: SLSA Provenance (n8n-pc)
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_pc_digest != ''
permissions:
id-token: write
packages: write
actions: read
# SLSA L3 Provenance - Must use version tags (@vX.Y.Z), NOT SHAs
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0
with:
image: ${{ needs.create_multi_arch_manifest.outputs.n8n_pc_image }}
digest: ${{ needs.create_multi_arch_manifest.outputs.n8n_pc_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]
@ -455,6 +534,7 @@ jobs:
build-and-push-docker,
create_multi_arch_manifest,
provenance-n8n,
provenance-n8n-pc,
provenance-runners,
provenance-runners-distroless,
]
@ -490,6 +570,14 @@ jobs:
--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 n8n-pc image
if: needs.create_multi_arch_manifest.outputs.n8n_pc_digest != ''
run: |
cosign attest --yes \
--type openvex \
--predicate security/vex.openvex.json \
${{ needs.create_multi_arch_manifest.outputs.n8n_pc_image }}@${{ needs.create_multi_arch_manifest.outputs.n8n_pc_digest }}
- name: Attest VEX to runners image
if: needs.create_multi_arch_manifest.outputs.runners_digest != ''
run: |
@ -520,6 +608,7 @@ jobs:
build-and-push-docker,
create_multi_arch_manifest,
provenance-n8n,
provenance-n8n-pc,
provenance-runners,
provenance-runners-distroless,
]
@ -559,6 +648,8 @@ jobs:
env:
N8N_IMAGE: ${{ needs.create_multi_arch_manifest.outputs.n8n_image }}
N8N_DIGEST: ${{ needs.create_multi_arch_manifest.outputs.n8n_digest }}
N8N_PC_IMAGE: ${{ needs.create_multi_arch_manifest.outputs.n8n_pc_image }}
N8N_PC_DIGEST: ${{ needs.create_multi_arch_manifest.outputs.n8n_pc_digest }}
RUNNERS_IMAGE: ${{ needs.create_multi_arch_manifest.outputs.runners_image }}
RUNNERS_DIGEST: ${{ needs.create_multi_arch_manifest.outputs.runners_digest }}
DISTROLESS_IMAGE: ${{ needs.create_multi_arch_manifest.outputs.runners_distroless_image }}
@ -578,6 +669,19 @@ jobs:
image_ref: ${{ needs.build-and-push-docker.outputs.image_ref }}
secrets: inherit
security-scan-pc:
name: Security Scan (n8n-pc)
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.n8n_pc_primary_ghcr_manifest_tag }}
secrets: inherit
security-scan-runners:
name: Security Scan (runners)
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest]
@ -595,7 +699,7 @@ jobs:
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'
if: always() && needs.build-and-push-docker.result == 'failure' && github.event_name == 'schedule'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:

View File

@ -8,6 +8,12 @@ name: 'Docker Build Smoke Test'
# build base image (no Docker cache) →
# build n8n + runners images (no Docker cache)
env:
# The images copy native bindings from the host build, so both have to use the same
# Node. dockerize-n8n.mjs reads this env for the image builds. Keep in sync with
# NODE_VERSION in docker-build-push.yml, which is what the published images use.
NODE_VERSION: '26.5.1'
on:
schedule:
- cron: '0 3 * * *' # 3:00 AM UTC, after the nightly Docker build at midnight
@ -44,6 +50,7 @@ jobs:
- name: Build full chain (no cache)
uses: ./.github/actions/setup-nodejs
with:
node-version: ${{ env.NODE_VERSION }}
build-command: 'pnpm build:docker:clean'
enable-docker-cache: true

View File

@ -83,14 +83,17 @@ jobs:
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
cache-dependency-path: .github/scripts/pnpm-lock.yaml
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.33
corepack enable
- name: Bump package versions
# Assign first: a command substitution nested in another command's
# arguments (e.g. `echo "X=$(node …)"`) discards the script's exit code.
shell: bash
run: |
echo "NEXT_RELEASE=$(node .github/scripts/bump-versions.mjs)" >> "$GITHUB_ENV"
NEXT_RELEASE="$(node .github/scripts/bump-versions.mjs)"
if [ -z "$NEXT_RELEASE" ]; then
echo "::error::bump-versions.mjs produced no version"
exit 1
fi
echo "NEXT_RELEASE=$NEXT_RELEASE" >> "$GITHUB_ENV"
env:
RELEASE_TYPE: ${{ inputs.release-type }}
@ -141,3 +144,15 @@ jobs:
secrets: inherit
with:
pull-request-number: ${{ needs.create-release-pr.outputs.pull-request-number }}
# Packs every publishable package, so it only runs at release prep, in parallel with the PR.
verify-single-instance-npm-full:
name: Verify single-instance deps (npm-install, full)
uses: ./.github/workflows/test-single-instance-npm.yml
permissions:
contents: read
with:
scope: all
# Profile the release being prepared, not this workflow's triggering revision.
base-branch: ${{ inputs.base-branch }}
timeout-minutes: 30

View File

@ -0,0 +1,112 @@
# Keeps the bundle integration branches in sync with their base, in n8n-io/n8n-private.
#
# Fixes are integrated on bundle/2.x and bundle/1.x and eventually merged into private
# master/1.x as a squashed `chore: Bundle/*` PR. Those branches must not drift from their
# base, or the bundle cut turns into a conflict-resolution session.
#
# The base is MERGED IN, never replayed: these branches receive PRs, and rewriting a branch
# that receives PRs orphans the commits its PR branches already hold — each such PR then shows
# everyone else's fixes in its commit list and its diff, and picks up another duplicate
# generation every time its author refreshes. The merge commits cost nothing downstream: a
# bundle publishes as one squashed, obfuscated commit taken from the tree, not the history.
# Every push is verified to carry exactly the tree a merge would produce. See
# sync-bundle-branch.mjs.
#
# Daily rather than hourly on purpose: a base force-push never re-triggered CI on the fix PRs,
# so a faster cadence bought them nothing while re-running the full suite on the cut PR (whose
# head is the bundle branch). A merged fix and a manual dispatch before a cut are the moments
# that matter.
#
# One job per bundle branch: a conflict on one FAILS THAT JOB and leaves its branch untouched,
# while the other still syncs. Recovery is manual and deliberate — merge the base in locally,
# resolve, push, then re-run this workflow. That resolution then lives in the merge commit,
# instead of being re-litigated on every later run.
name: 'Security: Sync Bundle Branches'
on:
schedule:
- cron: '0 3 * * *'
pull_request: # a fix landing on a bundle branch is when freshness matters most
types: [closed]
branches:
- 'bundle/2.x'
- 'bundle/1.x'
workflow_dispatch:
# Least privilege by default; the sync job opts into exactly what it needs.
permissions: {}
jobs:
sync:
name: Sync ${{ matrix.bundle }}
if: |
github.repository == 'n8n-io/n8n-private' &&
(github.event_name != 'pull_request' || github.event.pull_request.merged == true)
runs-on: ubuntu-latest
permissions:
contents: write
strategy:
fail-fast: false # a conflict on one bundle branch must not hold back the other
matrix:
include:
- bundle: 'bundle/2.x'
base: 'master'
- bundle: 'bundle/1.x'
base: '1.x'
concurrency: # serialize per branch — never two syncs of the same one at once
group: sync-${{ matrix.bundle }}
cancel-in-progress: false
steps:
- name: Generate GitHub 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 }}
# Scope the installation token to only what the sync needs.
permission-contents: write # push the bundle branch
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Always run the script from the default branch, never from a pull_request merge
# ref — the sync holds a contents:write token and fetches what it needs itself,
# so the checked-out branch is irrelevant beyond supplying trusted code.
ref: master
fetch-depth: 0
persist-credentials: false # we push with an explicit token URL instead
- name: Merge the base into the bundle branch
# On a merged bundle PR, only the branch that received it needs syncing.
# (`matrix` is not available in a job-level `if`, so the filter lives here.)
if: github.event_name != 'pull_request' || github.event.pull_request.base.ref == matrix.bundle
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BUNDLE_BRANCH: ${{ matrix.bundle }}
BASE_BRANCH: ${{ matrix.base }}
run: node .github/scripts/sync-bundle-branch.mjs
notify-on-failure:
name: Notify Slack on failure
needs: [sync]
if: ${{ always() && needs.sync.result == 'failure' }}
runs-on: ubuntu-latest
permissions:
contents: read # checkout the slack scripts
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/scripts/slack
sparse-checkout-cone-mode: false
persist-credentials: false
# Branch names and a run link only: conflicted paths and commit subjects hint at the
# vulnerability, and Slack reaches a wider audience than the private repo.
- name: Notify Slack
env:
SLACK_TOKEN: ${{ secrets.QBOT_SLACK_TOKEN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
node .github/scripts/slack/notify.mjs \
--channel '#alerts-security' \
--text "<${RUN_URL}|A bundle branch could not be synced with its base>. The branch is untouched; see the run for which one and why."

View File

@ -5,6 +5,8 @@
#
# Scheduled runs only sync if private is not ahead of public.
# Manual runs always sync (for conflict recovery after failed cherry-pick).
#
# The bundle/* integration branches are kept current by sec-sync-bundle-branches.yml.
name: 'Security: Sync from Public'
run-name: "${{ github.event_name == 'workflow_dispatch' && format('Security: Sync from Public (force={0})', inputs.force) || '' }}"
@ -25,6 +27,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
actions: write
steps:
- name: Generate App Token
@ -91,57 +94,23 @@ jobs:
git reset --hard public-1.x
git push origin 1.x --force-with-lease
- name: Ensure bundle/2.x exists
- name: Re-create missing bundle branches
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if git ls-remote --exit-code origin refs/heads/bundle/2.x; then
echo "bundle/2.x already exists, skipping"
else
echo "bundle/2.x not found, creating from master"
git checkout master
git checkout -b bundle/2.x
git push origin bundle/2.x
fi
EXISTING=$(git ls-remote --heads origin 'refs/heads/bundle/*')
- name: Ensure bundle/1.x exists
run: |
if git ls-remote --exit-code origin refs/heads/bundle/1.x; then
echo "bundle/1.x already exists, skipping"
else
echo "bundle/1.x not found, creating from 1.x"
git checkout 1.x
git checkout -b bundle/1.x
git push origin bundle/1.x
fi
MISSING=()
for BRANCH in bundle/2.x bundle/1.x; do
if ! grep -qF "refs/heads/$BRANCH" <<<"$EXISTING"; then
MISSING+=("$BRANCH")
fi
done
- name: Update bundle/2.x from master
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin bundle/2.x
git checkout -B bundle/2.x FETCH_HEAD
if git merge --no-edit master; then
git push origin bundle/2.x
else
# A conflict needs a human; keep the run green so the rest of the sync still reports success
git merge --abort || true
echo "::warning title=bundle/2.x is out of sync::Merging master into bundle/2.x hit a conflict. Resolve it manually, then re-run this workflow."
if [ ${#MISSING[@]} -eq 0 ]; then
echo 'Both bundle branches exist; nothing to dispatch.'
exit 0
fi
- name: Update bundle/1.x from 1.x
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin bundle/1.x
git checkout -B bundle/1.x FETCH_HEAD
if git merge --no-edit 1.x; then
git push origin bundle/1.x
else
git merge --abort || true
echo "::warning title=bundle/1.x is out of sync::Merging 1.x into bundle/1.x hit a conflict. Resolve it manually, then re-run this workflow."
exit 0
fi
echo "Missing: ${MISSING[*]} - dispatching Security: Sync Bundle Branches"
gh workflow run sec-sync-bundle-branches.yml --ref master

View File

@ -32,6 +32,9 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
# @codspeed/core prebuilds do not load on Node 26 (removed V8 symbol).
node-version: '24.18.1'
- name: Run benchmarks
uses: CodSpeedHQ/action@281164b0f014a4e7badd2c02cecad9b595b70537 # v4.11.1

View File

@ -7,6 +7,10 @@ on:
required: false
type: string
default: ''
matrix:
description: 'Job matrix JSON, from .github/scripts/db-test-matrix.mjs.'
required: true
type: string
env:
NODE_OPTIONS: '--max-old-space-size=6144'
@ -19,24 +23,7 @@ jobs:
strategy:
fail-fast: false
matrix:
include:
- name: SQLite Pooled
runner: blacksmith-4vcpu-ubuntu-2204
test-cmd: pnpm test:sqlite
migration-cmd: pnpm test:sqlite:migrations
schema-check-cmd: pnpm --filter=@n8n/db schema:check:sqlite
collectCoverage: 'false'
- name: Postgres 16
# 4vcpu is enough since persistent-worker + template-DB make PG16
# complete in ~480s, which is now close to the SQLite leg (~360s)
# — the matrix wall is bounded by the slower leg, so the larger
# 8vcpu runner buys headroom we can't cash in anywhere.
runner: blacksmith-4vcpu-ubuntu-2204
test-cmd: pnpm test:postgres:integration:tc
migration-cmd: pnpm test:postgres:migrations:tc
schema-check-cmd: pnpm --filter=@n8n/db schema:check:postgres
TEST_IMAGE_POSTGRES: 'postgres:16'
collectCoverage: 'true'
include: ${{ fromJSON(inputs.matrix) }}
env:
TEST_IMAGE_POSTGRES: ${{ matrix.TEST_IMAGE_POSTGRES }}
COVERAGE_ENABLED: ${{ matrix.collectCoverage }}
@ -49,7 +36,7 @@ jobs:
uses: ./.github/actions/setup-nodejs
- name: Pre-pull Test Container Images
run: pnpm tsx packages/testing/containers/pull-test-images.ts || true
run: pnpm --filter n8n-containers exec tsx pull-test-images.ts || true
- name: Run Tests
working-directory: packages/cli
@ -64,6 +51,7 @@ jobs:
# postgres testcontainer) and fails if they differ from what's committed.
# tbls runs as a Docker image in CI.
- name: Verify schema docs are up to date
if: matrix.schema-check-cmd != ''
run: ${{ matrix.schema-check-cmd }}
- name: Upload coverage to Codecov

View File

@ -114,7 +114,7 @@ jobs:
- name: Pre-pull Test Container Images
if: matrix.pre-pull-containers
run: pnpm tsx packages/testing/containers/pull-test-images.ts || true
run: pnpm --filter n8n-containers exec tsx pull-test-images.ts || true
- name: Run tests
working-directory: ${{ matrix.working-directory || github.workspace }}
@ -387,3 +387,31 @@ jobs:
secrets: inherit
with:
pull-request-number: ${{ needs.commit-impact-map.outputs.pull-request-number }}
notify-on-failure:
name: Notify Slack on failure
runs-on: ubuntu-latest
needs:
[
build,
prepare-docker,
generate-matrix,
e2e,
unit-coverage,
python-coverage,
aggregate,
commit-impact-map,
]
if: ${{ always() && contains(needs.*.result, 'failure') }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/scripts/slack
sparse-checkout-cone-mode: false
- name: Notify Slack
env:
SLACK_TOKEN: ${{ secrets.QBOT_SLACK_TOKEN }}
run: |
node .github/scripts/slack/notify.mjs \
--channel C04BBTDJAP5 \
--text "E2E coverage nightly failed - ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"

View File

@ -0,0 +1,76 @@
name: 'Test: E2E on -pc image'
# Boots the pointer-compressed n8n image and runs the e2e suite against it.
on:
schedule:
- cron: '0 2 * * *' # after the nightly Docker build at midnight
workflow_dispatch:
inputs:
image:
description: 'n8n image to test'
required: false
default: 'n8nio/n8n:nightly-pc'
type: string
permissions:
contents: read
env:
TEST_IMAGE_N8N: ${{ inputs.image || 'n8nio/n8n:nightly-pc' }}
jobs:
e2e-pc:
name: E2E on -pc image (${{ matrix.shard }}/4)
runs-on: blacksmith-4vcpu-ubuntu-2204
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1
with:
persist-credentials: false
- name: Pull image and assert pointer compression
run: |
docker pull --quiet "$TEST_IMAGE_N8N"
docker run --rm --entrypoint node "$TEST_IMAGE_N8N" -e "
if (!process.config.variables.v8_enable_pointer_compression) {
throw new Error('image is not pointer-compressed');
}
console.log(process.version, 'pointer compression: on');
"
- name: Build the test harness
uses: ./.github/actions/setup-nodejs
with:
build-command: 'pnpm turbo build --filter=n8n-playwright...'
- name: Install Playwright browsers
working-directory: packages/testing/playwright
run: npx playwright install chromium --with-deps
- name: Run e2e against the image
working-directory: packages/testing/playwright
run: pnpm test:container:sqlite:e2e -- --shard=${{ matrix.shard }}/4
notify-on-failure:
name: Notify Cats on -pc e2e failure
runs-on: ubuntu-latest
needs: [e2e-pc]
if: always() && needs.e2e-pc.result == 'failure' && github.event_name == 'schedule'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
sparse-checkout: .github/scripts/slack
sparse-checkout-cone-mode: false
- name: Notify Slack
env:
SLACK_TOKEN: ${{ secrets.QBOT_SLACK_TOKEN }}
run: |
node .github/scripts/slack/notify.mjs \
--channel '#team-catalysts' \
--text 'Nightly e2e on the -pc image failed - ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'

View File

@ -60,7 +60,37 @@ on:
type: string
default: ''
model:
description: 'Model override for all Instance AI agents in the lane backends, provider/model format. Empty = backend default.'
description: 'Model override (provider/model). Empty = anthropic/claude-opus-4-8. Vertex Claude: google-vertex-anthropic/<model>. OpenAI-compat experiments use custom/<model> + model-url + model-key. See evaluations/README.md.'
required: false
type: string
default: ''
model-url:
description: 'Optional N8N_INSTANCE_AI_MODEL_URL (required for custom/* OpenAI-compatible /v1; also anthropic/* Azure Foundry). See evaluations/README.md.'
required: false
type: string
default: ''
model-key:
description: 'Which EVALS_* secret to use for custom/* (or anthropic/* + model-url). One of: baseten, fireworks, together, modal, databricks, azure, lyceum. Empty = keyless custom, or native openai/openrouter/xai/anthropic prefix routing.'
required: false
type: string
default: ''
reasoning-effort:
description: 'Optional custom/* reasoning effort override (none|minimal|low|medium|high|xhigh|max). Empty = runtime known-model map; still empty = omit.'
required: false
type: string
default: ''
supports-structured-outputs:
description: 'Optional custom/* supportsStructuredOutputs override (true|false). Empty = runtime known-model map; still empty = omit.'
required: false
type: string
default: ''
lanes:
description: 'Parallel n8n lane containers (1-11). Empty = 10, or 1 when model-key=baseten (fits Baseten Basic verified ~500k TPM).'
required: false
type: string
default: ''
eval-concurrency:
description: 'Concurrent scenario executions. Empty = 32, or 2 when model-key=baseten (~0.5M TPM / ~12 RPM — fits Baseten Basic verified).'
required: false
type: string
default: ''
@ -95,7 +125,31 @@ on:
required: false
default: ''
model:
description: 'Model for all Instance AI agents (provider/model). Empty = backend default (anthropic/claude-opus-4-8). For experiments: anthropic/claude-sonnet-4-6, anthropic/claude-sonnet-5.'
description: 'Model for all Instance AI agents (provider/model). Empty = backend default (anthropic/claude-opus-4-8). Vertex Claude: google-vertex-anthropic/<model>. OpenAI-compat experiments: custom/<model> + model-url + model-key.'
required: false
default: ''
model-url:
description: 'Base URL (N8N_INSTANCE_AI_MODEL_URL). Required for custom/*; optional for anthropic/* Azure Foundry.'
required: false
default: ''
model-key:
description: 'EVALS_* secret alias for custom/*: baseten | fireworks | together | modal | databricks | azure | lyceum. Empty = keyless custom or native provider prefix.'
required: false
default: ''
reasoning-effort:
description: 'Optional custom/* reasoning effort override (none|minimal|low|medium|high|xhigh|max). Empty = runtime known-model map; still empty = omit.'
required: false
default: ''
supports-structured-outputs:
description: 'Optional custom/* supportsStructuredOutputs override (true|false). Empty = runtime known-model map; still empty = omit.'
required: false
default: ''
lanes:
description: 'Parallel n8n lane containers (1-11). Empty = 10, or 1 when model-key=baseten (fits Baseten Basic verified ~500k TPM).'
required: false
default: ''
eval-concurrency:
description: 'Concurrent scenarios. Empty = 32, or 2 when model-key=baseten (~0.5M TPM / ~12 RPM — fits Baseten Basic verified).'
required: false
default: ''
@ -105,16 +159,19 @@ jobs:
# 8vcpu/32GB: full-suite N=10 baselines need ~150 min of eval time and
# did not fit the 4vcpu tier at any timeout.
runs-on: blacksmith-8vcpu-ubuntu-2204
# Long ceiling only for high-N baseline captures; regular runs keep a
# tight guard so a wedged run fails fast. Plain comparison (not fromJSON):
# it coerces the string input to a number, and a malformed dispatch value
# (NaN) falls back to the 90-minute guard instead of erroring the job.
timeout-minutes: ${{ (inputs.iterations || '3') >= 5 && 240 || 90 }}
# Long ceiling for high-N baselines (240) and for Baseten (600: auto
# 1 lane / c=2 is ~16× slower than the Anthropic default, so a ~18 min
# full suite becomes ~5 h; +2 h buffer for provider backoff). Plain
# comparison (not fromJSON): coerces the string input to a number;
# malformed NaN falls back to the 90-minute guard instead of erroring.
timeout-minutes: ${{ (inputs['model-key'] || '') == 'baseten' && 600 || ((inputs.iterations || '3') >= 5 && 240 || 90) }}
env:
# Each port hosts an independent n8n container. The eval CLI's
# work-stealing allocator dispatches builds across them, capped per-lane.
# 10 lanes x 2.5 GB caps = 25 GB, leaving ~7 GB for sandbox + CLI + OS.
LANE_PORTS: '5678,5679,5680,5681,5682,5683,5684,5685,5686,5687'
# Contiguous ports from 5678; 5678..5688 avoids Node fetch()'s blocked-
# port list (max 11 lanes). Defaults: 10 lanes / c=32 for Anthropic;
# model-key=baseten auto-throttles to 1 / 2 to stay under Baseten Basic
# verified (~500k TPM).
LANES: ${{ inputs.lanes != '' && inputs.lanes || ((inputs['model-key'] || '') == 'baseten' && '1' || '10') }}
EVAL_CONCURRENCY: ${{ inputs.eval-concurrency != '' && inputs.eval-concurrency || ((inputs['model-key'] || '') == 'baseten' && '2' || '32') }}
permissions:
contents: read
pull-requests: write
@ -174,7 +231,20 @@ jobs:
- name: Start n8n containers
env:
EVALS_ANTHROPIC_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
EVALS_OPENAI_KEY: ${{ secrets.EVALS_OPENAI_KEY }}
INSTANCE_AI_BRAVE_SEARCH_API_KEY: ${{ secrets.INSTANCE_AI_BRAVE_SEARCH_API_KEY }}
EVALS_OPENROUTER_KEY: ${{ secrets.EVALS_OPENROUTER_KEY }}
EVALS_XAI_KEY: ${{ secrets.EVALS_XAI_KEY }}
EVALS_BASETEN_KEY: ${{ secrets.EVALS_BASETEN_KEY }}
EVALS_FIREWORKS_KEY: ${{ secrets.EVALS_FIREWORKS_KEY }}
EVALS_TOGETHER_KEY: ${{ secrets.EVALS_TOGETHER_KEY }}
EVALS_DATABRICKS_KEY: ${{ secrets.EVALS_DATABRICKS_KEY }}
EVALS_MODAL_KEY: ${{ secrets.EVALS_MODAL_KEY }}
EVALS_LYCEUM_KEY: ${{ secrets.EVALS_LYCEUM_KEY }}
EVALS_AZURE_FOUNDRY_KEY: ${{ secrets.EVALS_AZURE_FOUNDRY_KEY }}
EVALS_VERTEX_KEY: ${{ secrets.EVALS_VERTEX_KEY }}
EVALS_VERTEX_PROJECT_ID: ${{ secrets.EVALS_VERTEX_PROJECT_ID }}
EVALS_VERTEX_LOCATION: ${{ secrets.EVALS_VERTEX_LOCATION }}
N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }}
N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }}
N8N_ENCRYPTION_KEY: ${{ secrets.N8N_ENCRYPTION_KEY }}
@ -187,6 +257,10 @@ jobs:
SANDBOX_NAME_PREFIX: evals-ci-${{ github.head_ref || inputs.branch || github.ref_name }}
SANDBOX_PROVIDER: ${{ inputs.sandbox-provider || 'n8n-sandbox' }}
INSTANCE_AI_MODEL: ${{ inputs.model }}
INSTANCE_AI_MODEL_URL: ${{ inputs.model-url }}
INSTANCE_AI_MODEL_KEY: ${{ inputs.model-key }}
INSTANCE_AI_REASONING_EFFORT: ${{ inputs.reasoning-effort }}
INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS: ${{ inputs.supports-structured-outputs }}
run: |
# Build provider-specific env args
SANDBOX_ARGS=()
@ -216,8 +290,112 @@ jobs:
if [ -n "$INSTANCE_AI_MODEL" ]; then
MODEL_ARGS+=(-e N8N_INSTANCE_AI_MODEL="$INSTANCE_AI_MODEL")
fi
# Anthropic-compatible custom base (Azure Foundry Claude). Drop /v1/messages
# if pasted from the Foundry Details "Endpoint" field — the Anthropic SDK
# appends /v1/messages itself.
if [ -n "$INSTANCE_AI_MODEL_URL" ]; then
MODEL_ARGS+=(-e N8N_INSTANCE_AI_MODEL_URL="$INSTANCE_AI_MODEL_URL")
fi
IFS=',' read -ra PORTS <<< "$LANE_PORTS"
# custom/* experiment knobs: pass through workflow inputs only.
# When unset, the n8n runtime looks up custom-model-defaults.ts and
# omits the field if still unresolved — do not re-implement that map here.
if [ -n "$INSTANCE_AI_REASONING_EFFORT" ]; then
MODEL_ARGS+=(-e N8N_INSTANCE_AI_REASONING_EFFORT="$INSTANCE_AI_REASONING_EFFORT")
echo "custom reasoning effort override: $INSTANCE_AI_REASONING_EFFORT"
fi
if [ -n "$INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS" ]; then
MODEL_ARGS+=(-e N8N_INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS="$INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS")
echo "custom supportsStructuredOutputs override: $INSTANCE_AI_SUPPORTS_STRUCTURED_OUTPUTS"
fi
# EvalMock / pin-data / in-product eval LLMs always use Anthropic Sonnet,
# independent of the builder experiment model (custom/Kimi, openai/*, …).
# ANTHROPIC_API_KEY is the provider-native key resolveEvalModelConfig prefers
# when N8N_INSTANCE_AI_EVAL_MODEL differs from N8N_INSTANCE_AI_MODEL.
MODEL_ARGS+=(
-e N8N_INSTANCE_AI_EVAL_MODEL=anthropic/claude-sonnet-4-6
-e ANTHROPIC_API_KEY="$EVALS_ANTHROPIC_KEY"
)
# Lane builds use the provider matching N8N_INSTANCE_AI_MODEL.
# The eval CLI step keeps EVALS_ANTHROPIC_KEY for Sonnet verifier/mocks.
MODEL_API_KEY="$EVALS_ANTHROPIC_KEY"
MODEL_PROVIDER="${INSTANCE_AI_MODEL%%/*}"
require_secret() {
local name="$1" value="$2"
if [ -z "$value" ]; then
echo "::error::${name} secret is empty (model=${INSTANCE_AI_MODEL:-default} model-key=${INSTANCE_AI_MODEL_KEY:-})"
exit 1
fi
}
resolve_model_key() {
case "$INSTANCE_AI_MODEL_KEY" in
baseten) require_secret EVALS_BASETEN_KEY "$EVALS_BASETEN_KEY"; MODEL_API_KEY="$EVALS_BASETEN_KEY" ;;
fireworks) require_secret EVALS_FIREWORKS_KEY "$EVALS_FIREWORKS_KEY"; MODEL_API_KEY="$EVALS_FIREWORKS_KEY" ;;
together) require_secret EVALS_TOGETHER_KEY "$EVALS_TOGETHER_KEY"; MODEL_API_KEY="$EVALS_TOGETHER_KEY" ;;
modal) require_secret EVALS_MODAL_KEY "$EVALS_MODAL_KEY"; MODEL_API_KEY="$EVALS_MODAL_KEY" ;;
databricks) require_secret EVALS_DATABRICKS_KEY "$EVALS_DATABRICKS_KEY"; MODEL_API_KEY="$EVALS_DATABRICKS_KEY" ;;
azure) require_secret EVALS_AZURE_FOUNDRY_KEY "$EVALS_AZURE_FOUNDRY_KEY"; MODEL_API_KEY="$EVALS_AZURE_FOUNDRY_KEY" ;;
lyceum) require_secret EVALS_LYCEUM_KEY "$EVALS_LYCEUM_KEY"; MODEL_API_KEY="$EVALS_LYCEUM_KEY" ;;
'') ;;
*)
echo "::error::unknown model-key '${INSTANCE_AI_MODEL_KEY}' (expected baseten|fireworks|together|modal|databricks|azure|lyceum)"
exit 1
;;
esac
}
if [[ "$INSTANCE_AI_MODEL" == custom/* ]]; then
if [ -z "$INSTANCE_AI_MODEL_URL" ]; then
echo "::error::model custom/* requires model-url (OpenAI-compatible base URL ending in /v1)"
exit 1
fi
# model-key selects EVALS_*; empty model-key = keyless custom router.
if [ -n "$INSTANCE_AI_MODEL_KEY" ]; then
resolve_model_key
else
MODEL_API_KEY=""
fi
elif [ "$MODEL_PROVIDER" = "google-vertex-anthropic" ]; then
if [ -n "$INSTANCE_AI_MODEL_URL" ]; then
echo "::error::google-vertex-anthropic/* does not use model-url (leave it empty)"
exit 1
fi
require_secret EVALS_VERTEX_KEY "$EVALS_VERTEX_KEY"
require_secret EVALS_VERTEX_PROJECT_ID "$EVALS_VERTEX_PROJECT_ID"
MODEL_API_KEY=""
MODEL_ARGS+=(
-e N8N_INSTANCE_AI_VERTEX_PROJECT_ID="$EVALS_VERTEX_PROJECT_ID"
-e N8N_INSTANCE_AI_VERTEX_LOCATION="${EVALS_VERTEX_LOCATION:-global}"
-e N8N_INSTANCE_AI_VERTEX_SERVICE_ACCOUNT_JSON="$EVALS_VERTEX_KEY"
)
elif [ -n "$INSTANCE_AI_MODEL_URL" ]; then
# anthropic/* + model-url = Azure Foundry Claude (or model-key=azure).
if [ -n "$INSTANCE_AI_MODEL_KEY" ]; then
resolve_model_key
else
require_secret EVALS_AZURE_FOUNDRY_KEY "$EVALS_AZURE_FOUNDRY_KEY"
MODEL_API_KEY="$EVALS_AZURE_FOUNDRY_KEY"
fi
else
case "$MODEL_PROVIDER" in
openai) require_secret EVALS_OPENAI_KEY "$EVALS_OPENAI_KEY"; MODEL_API_KEY="$EVALS_OPENAI_KEY" ;;
openrouter) require_secret EVALS_OPENROUTER_KEY "$EVALS_OPENROUTER_KEY"; MODEL_API_KEY="$EVALS_OPENROUTER_KEY" ;;
xai) require_secret EVALS_XAI_KEY "$EVALS_XAI_KEY"; MODEL_API_KEY="$EVALS_XAI_KEY" ;;
esac
fi
if [ "$LANES" -lt 1 ] || [ "$LANES" -gt 11 ]; then
echo "::error::lanes must be 1-11 (got $LANES)"
exit 1
fi
PORTS=()
for i in $(seq 0 $((LANES - 1))); do
PORTS+=($((5678 + i)))
done
# Reuse across later steps (Create test users / Assert / Run Evals).
echo "LANE_PORTS=${PORTS[*]}" >> "$GITHUB_ENV"
echo "Starting $LANES lane(s) on ports ${PORTS[*]} (eval concurrency $EVAL_CONCURRENCY)"
for i in "${!PORTS[@]}"; do
port="${PORTS[$i]}"
# Bounded and self-healing: a lane that exhausts its capped heap is
@ -242,8 +420,8 @@ jobs:
-e E2E_TESTS=true \
-e N8N_ENABLED_MODULES=instance-ai \
-e N8N_AI_ENABLED=true \
-e N8N_INSTANCE_AI_MODEL_API_KEY="$EVALS_ANTHROPIC_KEY" \
-e INSTANCE_AI_BRAVE_SEARCH_API_KEY="$INSTANCE_AI_BRAVE_SEARCH_API_KEY" \
-e N8N_INSTANCE_AI_MODEL_API_KEY="$MODEL_API_KEY" \
-e N8N_AI_ASSISTANT_BASE_URL="" \
-e N8N_INSTANCE_AI_SANDBOX_ENABLED=true \
"${SANDBOX_ARGS[@]}" \
@ -283,7 +461,7 @@ jobs:
- name: Create test users
run: |
IFS=',' read -ra PORTS <<< "$LANE_PORTS"
read -ra PORTS <<< "$LANE_PORTS"
for port in "${PORTS[@]}"; do
curl -sf -X POST "http://localhost:$port/rest/e2e/reset" \
-H "Content-Type: application/json" \
@ -304,7 +482,7 @@ jobs:
SANDBOX_PROVIDER: ${{ inputs.sandbox-provider || 'n8n-sandbox' }}
INSTANCE_AI_MODEL: ${{ inputs.model }}
run: |
IFS=',' read -ra PORTS <<< "$LANE_PORTS"
read -ra PORTS <<< "$LANE_PORTS"
bad=0
for i in "${!PORTS[@]}"; do
port="${PORTS[$i]}"
@ -323,13 +501,15 @@ jobs:
echo " lane $lane: sandboxEnabled=true sandboxProvider=$SANDBOX_PROVIDER ok"
fi
# /preferences returns the effective model name (user pref || config),
# i.e. the name part of provider/model.
# i.e. everything after the first provider/ segment (openrouter/moonshotai/kimi-k3
# → moonshotai/kimi-k3). Use #*/ not ##*/ so nested OpenRouter ids match.
if [ -n "$INSTANCE_AI_MODEL" ]; then
expected_model="${INSTANCE_AI_MODEL#*/}"
effective=$(curl -sf -b "/tmp/cookies-$port.txt" \
"http://localhost:$port/rest/instance-ai/preferences" \
| jq -r '.data.modelName')
if [ "$effective" != "${INSTANCE_AI_MODEL##*/}" ]; then
echo "::error::lane $lane (port $port): expected model '${INSTANCE_AI_MODEL##*/}', got '$effective'"
if [ "$effective" != "$expected_model" ]; then
echo "::error::lane $lane (port $port): expected model '$expected_model', got '$effective'"
bad=$((bad+1))
else
echo " lane $lane: model=$effective ok"
@ -364,7 +544,10 @@ jobs:
continue-on-error: true
working-directory: packages/@n8n/instance-ai
env:
# Host-side verifier/judges always use Anthropic — never the builder experiment model.
N8N_INSTANCE_AI_EVAL_MODEL: anthropic/claude-sonnet-4-6
N8N_INSTANCE_AI_MODEL_API_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
LANGSMITH_TRACING: 'true'
LANGSMITH_ENDPOINT: ${{ secrets.EVALS_LANGSMITH_ENDPOINT }}
LANGSMITH_API_KEY: ${{ secrets.EVALS_LANGSMITH_API_KEY }}
@ -383,13 +566,14 @@ jobs:
EXPERIMENT_NAME: ${{ inputs.experiment-name }}
EVAL_PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }}
run: |
IFS=',' read -ra PORTS <<< "$LANE_PORTS"
read -ra PORTS <<< "$LANE_PORTS"
URLS=()
for port in "${PORTS[@]}"; do
URLS+=("http://localhost:$port")
done
BASE_URLS=$(IFS=,; printf '%s' "${URLS[*]}")
ARGS=(--base-url "$BASE_URLS" --concurrency 32 --verbose --iterations "${ITERATIONS:-3}")
echo "Lanes: $LANES | eval concurrency: $EVAL_CONCURRENCY"
ARGS=(--base-url "$BASE_URLS" --concurrency "$EVAL_CONCURRENCY" --verbose --iterations "${ITERATIONS:-3}")
# LangTracer is the only CI case source (no disk fallback by design).
ARGS+=(--source langtracer --suite "${SUITE:-baseline}")
# Pin the LangSmith cohort: langtracer mode otherwise derives a
@ -420,6 +604,17 @@ jobs:
if: ${{ always() }}
env:
EVALS_ANTHROPIC_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
EVALS_OPENAI_KEY: ${{ secrets.EVALS_OPENAI_KEY }}
EVALS_OPENROUTER_KEY: ${{ secrets.EVALS_OPENROUTER_KEY }}
EVALS_XAI_KEY: ${{ secrets.EVALS_XAI_KEY }}
EVALS_BASETEN_KEY: ${{ secrets.EVALS_BASETEN_KEY }}
EVALS_FIREWORKS_KEY: ${{ secrets.EVALS_FIREWORKS_KEY }}
EVALS_TOGETHER_KEY: ${{ secrets.EVALS_TOGETHER_KEY }}
EVALS_DATABRICKS_KEY: ${{ secrets.EVALS_DATABRICKS_KEY }}
EVALS_MODAL_KEY: ${{ secrets.EVALS_MODAL_KEY }}
EVALS_LYCEUM_KEY: ${{ secrets.EVALS_LYCEUM_KEY }}
EVALS_AZURE_FOUNDRY_KEY: ${{ secrets.EVALS_AZURE_FOUNDRY_KEY }}
EVALS_VERTEX_KEY: ${{ secrets.EVALS_VERTEX_KEY }}
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }}
N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }}
@ -430,9 +625,13 @@ jobs:
# ::add-mask:: is a single-line workflow command. Multi-line secrets
# (e.g. N8N_LICENSE_CERT is PEM-encoded) must be masked one line at
# a time, otherwise only the first line is registered.
for v in "$EVALS_ANTHROPIC_KEY" "$DAYTONA_API_KEY" \
"$N8N_LICENSE_ACTIVATION_KEY" "$N8N_LICENSE_CERT" \
"$N8N_ENCRYPTION_KEY" "$EVALS_LANGSMITH_API_KEY"; do
for v in "$EVALS_ANTHROPIC_KEY" "$EVALS_OPENAI_KEY" "$EVALS_OPENROUTER_KEY" \
"$EVALS_XAI_KEY" "$EVALS_BASETEN_KEY" "$EVALS_FIREWORKS_KEY" \
"$EVALS_TOGETHER_KEY" "$EVALS_DATABRICKS_KEY" "$EVALS_MODAL_KEY" \
"$EVALS_LYCEUM_KEY" "$EVALS_AZURE_FOUNDRY_KEY" "$EVALS_VERTEX_KEY" \
"$DAYTONA_API_KEY" \
"$N8N_LICENSE_ACTIVATION_KEY" "$N8N_LICENSE_CERT" "$N8N_ENCRYPTION_KEY" \
"$EVALS_LANGSMITH_API_KEY"; do
[ -z "$v" ] && continue
while IFS= read -r line; do
[ -n "$line" ] && echo "::add-mask::$line"

View File

@ -192,11 +192,14 @@ jobs:
# agents registers the agent-builder MCP tools so the suite measures
# workflow building with the production tool surface, catching
# tool-choice regressions the extra tools could introduce.
# MCP canvas-group support is PostHog-gated and PostHog is unreachable
# here (fails closed), so the env var force-enables it;
for i in "${!PORTS[@]}"; do
port="${PORTS[$i]}"
docker run -d --name "n8n-eval-mcp-$((i + 1))" \
-e E2E_TESTS=true \
-e N8N_ENABLED_MODULES=instance-ai,agents \
-e N8N_MCP_CANVAS_GROUPS_ENABLED=true \
-e N8N_AI_ENABLED=true \
-e N8N_INSTANCE_AI_MODEL_API_KEY="$EVALS_ANTHROPIC_KEY" \
-e N8N_AI_ASSISTANT_BASE_URL="" \

View File

@ -12,7 +12,7 @@ on:
description: Version of node to use.
required: false
type: string
default: 24.18.1
default: 26.5.1
env:
NODE_OPTIONS: --max-old-space-size=7168

View File

@ -0,0 +1,128 @@
name: 'Test: Single-instance deps (npm-install)'
# An npm-install graph exposes curated-lib duplicates that pnpm dev/deploy hides, because root
# `pnpm.overrides` don't travel in published tarballs.
on:
workflow_call:
inputs:
scope:
description: '"changed" verifies only packages touched since base-ref; "all" packs every publishable package.'
required: true
type: string
base-ref:
description: 'Commit to diff against. Required when scope is "changed", rejected otherwise.'
required: false
type: string
default: ''
base-branch:
description: 'Branch to check out before verifying, so the run profiles that branch rather than the caller''s triggering revision. Only valid when scope is "all".'
required: false
type: string
default: ''
blocking:
description: 'Fail the calling workflow on a finding. Defaults to advisory while the curated-lib backlog is worked down.'
required: false
type: boolean
default: false
timeout-minutes:
description: 'Job timeout. The "all" scope packs every publishable package, so it needs longer than the scoped one.'
required: false
type: number
default: 20
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout-minutes }}
permissions:
contents: read
env:
# Advisory runs downgrade findings to `::warning::` annotations and exit 0, so the known
# third-party splits don't leave a permanently red job that everyone learns to ignore.
# This deliberately replaces `continue-on-error`, which also swallowed the failures we do
# want — a usage error, a rejected input, or a crash that verified nothing.
REPORT_ONLY: ${{ !inputs.blocking && '--report-only' || '' }}
steps:
# Every rejected combination below otherwise degrades into a run that verifies nothing and
# still exits 0. These fail the job even in advisory mode — `--report-only` downgrades
# findings, not misconfiguration.
- name: Validate inputs
env:
SCOPE: ${{ inputs.scope }}
BASE_REF: ${{ inputs.base-ref }}
BASE_BRANCH: ${{ inputs.base-branch }}
run: |
case "$SCOPE" in
changed)
[ -n "$BASE_REF" ] || { echo "::error::scope=changed requires base-ref."; exit 1; }
# A base-branch checkout would move HEAD away from the pushed commit, so the diff
# would silently describe the wrong range.
[ -z "$BASE_BRANCH" ] || { echo "::error::scope=changed cannot be combined with base-branch."; exit 1; }
;;
all)
[ -z "$BASE_REF" ] || { echo "::error::scope=all does not use base-ref."; exit 1; }
;;
*)
echo "::error::Unknown scope \"$SCOPE\" (expected \"changed\" or \"all\")."
exit 1
;;
esac
# Checking out `base-branch` directly (rather than fetching everything and switching
# afterwards) keeps that case to a single-commit fetch. scope=changed does need history back
# to `base-ref`, but only to diff it: `git diff --name-only --no-renames` reads trees, so the
# blobs can stay on the server. Nothing after this step reads an object outside the working
# tree, which the checkout materializes in full.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.base-branch }}
fetch-depth: ${{ inputs.base-branch != '' && 1 || 0 }}
filter: blob:none
persist-credentials: false
# A `base-branch` can point at a release line that predates this tooling (the v1 patch track
# is the live case), where there is simply nothing to verify. Without a base-branch the ref is
# the caller's own revision, on which the package must exist — a move or rename has to fail
# loudly rather than quietly turn this into a green job that verified nothing.
- name: Check the verifier is present on this ref
id: verifier
env:
BASE_BRANCH: ${{ inputs.base-branch }}
run: |
if [ -f packages/testing/code-health/package.json ]; then
echo 'present=true' >> "$GITHUB_OUTPUT"
elif [ -n "$BASE_BRANCH" ]; then
echo 'present=false' >> "$GITHUB_OUTPUT"
echo "::notice::packages/testing/code-health is absent on \"$BASE_BRANCH\"; nothing to verify."
else
echo '::error::packages/testing/code-health is missing (moved or renamed?).'
exit 1
fi
# Only the verifier's own closure needs compiling. The check reads package.json out of the
# npm-install graph, so the packed tarballs' contents don't matter — packing skips lifecycle
# scripts, and the workspace install exists so `pnpm pack` can resolve `workspace:` specs.
- name: Setup and Build
if: steps.verifier.outputs.present == 'true'
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm turbo run build --filter=@n8n/code-health...
# `--dir` rather than `--filter`: a filter matching nothing exits 0, so a renamed or moved
# package would turn this into a green job that verified nothing.
- name: Verify npm-install closure (changed packages)
if: steps.verifier.outputs.present == 'true' && inputs.scope == 'changed'
env:
BASE_REF: ${{ inputs.base-ref }}
run: |
# REPORT_ONLY is a single flag or empty. Quoting it would pass an empty argument, which
# the CLI reads as an (unknown) package name and rejects.
# shellcheck disable=SC2086
pnpm --dir packages/testing/code-health exec tsx src/cli.ts verify-npm-install --changed="$BASE_REF" $REPORT_ONLY
- name: Verify npm-install closure (all packages)
if: steps.verifier.outputs.present == 'true' && inputs.scope == 'all'
run: |
# shellcheck disable=SC2086 # see the scoped step above
pnpm --dir packages/testing/code-health exec tsx src/cli.ts verify-npm-install --all $REPORT_ONLY

View File

@ -12,7 +12,7 @@ on:
description: Version of node to use.
required: false
type: string
default: 24.18.1
default: 26.5.1
collectCoverage:
required: false
default: false
@ -54,6 +54,13 @@ jobs:
with:
node-version: ${{ inputs.nodeVersion }}
# Confluent publishes prebuilds up to Node 24 only.
- name: Verify Kafka native binding installed
if: startsWith(inputs.nodeVersion, '24.')
env:
NODES_BASE_PACKAGE_JSON: packages/nodes-base/package.json
run: node .github/scripts/docker/kafka-native-smoke-check.mjs
# cli is the only backend package wired into janitor scoping today, so
# the workspace bulk run excludes it and a dedicated step invokes the
# scoped variant. This avoids turbo silently no-op'ing `test:unit:changed`
@ -66,7 +73,7 @@ jobs:
# @n8n/vitest-config/node), peak forks ≈ 2 × 50% ≈ the core count, instead
# of ~10 concurrent suites each spawning a core-sized pool and pegging CPU.
- name: Test Unit (backend, except cli and nodes-base)
run: pnpm turbo test:unit --continue --concurrency=2 --filter='!./packages/frontend/**' --filter='!n8n' --filter='!n8n-nodes-base' --summarize
run: pnpm turbo test:unit --continue --concurrency=2 --filter='!./packages/frontend/**' --filter='!./packages/modules/**' --filter='!n8n' --filter='!n8n-nodes-base' --summarize
- name: Test Unit (cli, scoped)
if: ${{ !cancelled() }}
@ -126,7 +133,7 @@ jobs:
# run serially to avoid cross-package interference. Unit jobs above
# don't need this because they isolate per-process.
- name: Test Integration (backend, except cli)
run: pnpm turbo test:integration --continue --concurrency=1 --filter='!./packages/frontend/**' --filter='!n8n' --summarize
run: pnpm turbo test:integration --continue --concurrency=1 --filter='!./packages/frontend/**' --filter='!./packages/modules/**' --filter='!n8n' --summarize
- name: Test Integration (cli, scoped)
if: ${{ !cancelled() }}

View File

@ -12,7 +12,7 @@ on:
description: Version of node to use.
required: false
type: string
default: 24.18.1
default: 26.5.1
env:
NODE_OPTIONS: --max-old-space-size=7168

View File

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

View File

@ -68,6 +68,12 @@ jobs:
if: fromJSON(steps.paths-filter.outputs.results).db
uses: ./.github/actions/setup-nodejs
- name: Resolve Postgres image
if: fromJSON(steps.paths-filter.outputs.results).db
run: |
IMAGE=$(node -p "require('./packages/testing/containers/postgres-versions.json').primary")
echo "TEST_IMAGE_POSTGRES=$IMAGE" >> "$GITHUB_ENV"
- name: Start Postgres
if: fromJSON(steps.paths-filter.outputs.results).db
uses: isbang/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0

View File

@ -0,0 +1,22 @@
name: 'Util: Notify Merge Queue Removal'
on:
pull_request:
types: [dequeued]
permissions:
pull-requests: write
jobs:
notify:
runs-on: ubuntu-latest
# MERGE/ALREADY_MERGED are successful exits, MANUAL is the author's own doing
if: ${{ !contains(fromJSON('["MERGE", "ALREADY_MERGED", "MANUAL"]'), github.event.reason) }}
steps:
# A failing PR can dequeue others behind it, so the reason may not be this PR's fault
- run: |
gh pr comment "$PR_URL" --body "This PR was removed from the merge queue (reason: \`$REASON\`). Check the merge-queue run's failing checks and re-queue when ready."
env:
GH_TOKEN: ${{ github.token }}
PR_URL: ${{ github.event.pull_request.html_url }}
REASON: ${{ github.event.reason }}

View File

@ -5,14 +5,19 @@
# true by REPLAYING the 3.x-only commits onto master and force-pushing 3.x — so a clean
# sync adds no commit at all, and every replayed commit is kept as-is (nothing squashed,
# authors preserved). The content pushed is always verified to be exactly what a merge of
# 3.x and master would produce.
# 3.x and master would produce. Per-commit stalls the replay cannot settle with a strategy
# option alone (e.g. modify/delete) are resolved toward 3.x's side, under that same
# verification.
#
# When master genuinely conflicts with 3.x, 3.x is left untouched: a draft conflict PR
# carrying the conflict markers is opened on the sync branch and #alerts-v3-sync is notified.
# The resolver fixes the markers in a commit of their own and merges that PR with the normal
# merge button. No further syncs run until it is merged. 3.x itself never has markers at its
# tip (nightly images build from it) and the merge commit holding them drops out of its
# history at the next replay.
# Conflicts confined to mechanical, tool-generated files (the pnpm lockfile, bot-maintained
# data files) are resolved in place during the replay — no PR, no commit, no human. When
# master genuinely conflicts with 3.x on real code, 3.x is left untouched: a draft conflict
# PR carrying the conflict markers (mechanical files pre-resolved) is opened on the sync
# branch and #alerts-v3-sync is notified. The resolver fixes the markers in a commit of their
# own and merges that PR with the normal merge button — never closes it, since closing
# resolves nothing and the conflict returns. No further syncs run until it is merged. 3.x
# itself never has markers at its tip (nightly images build from it) and the merge commit
# holding them drops out of its history at the next replay.
#
# See .github/DEVELOPING_V3.md for the full v3 development model.
@ -61,6 +66,14 @@ jobs:
fetch-depth: 0
persist-credentials: false # we push with an explicit token URL instead
# Node + pnpm toolchain for the mechanical lockfile resolution
# (`pnpm install --lockfile-only`). No dependency install, no build.
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
install-command: ''
build-command: ''
- name: Sync master into 3.x
id: sync
env:

View File

@ -255,8 +255,16 @@ What we use for testing and writing tests:
- For E2E tests we use Playwright. Run with `pnpm --filter=n8n-playwright test:local`.
See `packages/testing/playwright/README.md` for details.
- **To iterate on a feature without docker rebuilds**, boot service containers
and run `pnpm dev` locally — `pnpm --filter n8n-containers services --services postgres,redis,mailpit,proxy`
then `pnpm dev`. See [Develop against running containers](packages/testing/playwright/README.md#develop-against-running-containers-avoid-docker-rebuilds).
and run the dev servers locally — `pnpm --filter n8n-containers services --services postgres,redis,mailpit,proxy`
then `pnpm dev:be` (backend on 5678). For frontend hot reload, also run
`pnpm dev:fe:editor` (8080). The root `pnpm dev` does not exist: it prints a
notice and exits with code 0, thus `pnpm dev && …` looks successful but no
server runs. See
[Develop against running containers](packages/testing/playwright/README.md#develop-against-running-containers-avoid-docker-rebuilds).
- **In a codespace agent session**, use `pnpm dev:up`. It installs the missing
dependencies, starts the backend, waits for health, shares the port with the
org, and prints the URL. See
[.devcontainer/codespaces/README.md](.devcontainer/codespaces/README.md).
- **For Playwright test maintenance/cleanup**, see `packages/testing/playwright/AGENTS.md` (includes janitor tool for static analysis, dead code removal, architecture enforcement, and TCR workflows).
### Common Development Tasks

View File

@ -1,3 +1,286 @@
# [2.36.0](https://github.com/n8n-io/n8n/compare/n8n@2.35.0...n8n@2.36.0) (2026-08-18)
### Bug Fixes
* **ai-builder:** Align HITL approval resume schemas with confirm envelope ([#36047](https://github.com/n8n-io/n8n/issues/36047)) ([be1fde1](https://github.com/n8n-io/n8n/commit/be1fde19f46d6a9f59ba1a2aab0ef8810e9bc68c))
* **ai-builder:** Enable Save in agent tool modal when credential auth is selected ([#36012](https://github.com/n8n-io/n8n/issues/36012)) ([4fe94d8](https://github.com/n8n-io/n8n/commit/4fe94d8f5e6bff234dbe95a2cadac3034280a26c))
* **ai-builder:** Prevent agent from claiming integration tool call success early ([#36196](https://github.com/n8n-io/n8n/issues/36196)) ([547cfb9](https://github.com/n8n-io/n8n/commit/547cfb90020535a28d2a3233b2be0d29569a92e1))
* **ai-builder:** Recover stuck tool-call UI and MCP timeout progressive update ([#36162](https://github.com/n8n-io/n8n/issues/36162)) ([e1cd757](https://github.com/n8n-io/n8n/commit/e1cd75734b7f7422e8c67b47896af1d2f311f5a3))
* **ai-builder:** Surface existing LLM-provider credentials when a filtered lookup finds none ([#35833](https://github.com/n8n-io/n8n/issues/35833)) ([d73d625](https://github.com/n8n-io/n8n/commit/d73d625d439df8336f734d9d4ace897a4a4c33f1))
* **ai-builder:** Surface skill save validation errors and stop reverting skill renames ([#36055](https://github.com/n8n-io/n8n/issues/36055)) ([b08cde6](https://github.com/n8n-io/n8n/commit/b08cde668ffa45901b02945a6c43720650ef422f))
* Avoid false port-in-use error in get-n8n script on Windows/WSL ([#36026](https://github.com/n8n-io/n8n/issues/36026)) ([6f2033f](https://github.com/n8n-io/n8n/commit/6f2033f0e1562014e064ae7181a84981e439667f))
* **core:** Accept Always allow scope on data-tables resume ([#36070](https://github.com/n8n-io/n8n/issues/36070)) ([560455f](https://github.com/n8n-io/n8n/commit/560455faf1089808a4ec4064a95f09a77041148a))
* **core:** Allow `details` field in `continueErrorOutput` mode ([#35939](https://github.com/n8n-io/n8n/issues/35939)) ([55927c4](https://github.com/n8n-io/n8n/commit/55927c4af5ab1b80653b79c567794b525cb14301))
* **core:** Apply TLS options per hop when requests go through a proxy ([#35518](https://github.com/n8n-io/n8n/issues/35518)) ([68bcdfe](https://github.com/n8n-io/n8n/commit/68bcdfe80c68387878e4710ee8eeb491bee35c8e))
* **core:** Bound database pool teardown so connection recovery always completes ([#36013](https://github.com/n8n-io/n8n/issues/36013)) ([da314d3](https://github.com/n8n-io/n8n/commit/da314d302a84b5c07c42e9961a558ba3f14678cf))
* **core:** Bound workflow publication outbox record processing with an abort deadline ([#36197](https://github.com/n8n-io/n8n/issues/36197)) ([c0278b0](https://github.com/n8n-io/n8n/commit/c0278b07a03ae04dc809f28ca8b03eac233713b3))
* **core:** Crash enqueued executions with unreadable data ([#36010](https://github.com/n8n-io/n8n/issues/36010)) ([fd67bf8](https://github.com/n8n-io/n8n/commit/fd67bf8b78ee9522637b79adbaf6cf15f05f995b))
* **core:** Detect node settings changes when comparing workflow versions ([#36066](https://github.com/n8n-io/n8n/issues/36066)) ([e4f0c33](https://github.com/n8n-io/n8n/commit/e4f0c33584a8b32e1f47e48a7a8a8525326a111d))
* **core:** Execute tools in the order the root requested them ([#36009](https://github.com/n8n-io/n8n/issues/36009)) ([acd4fda](https://github.com/n8n-io/n8n/commit/acd4fdacc9daa2acbcc5864405c0a6d005f6a6c6))
* **core:** Explain Browser Use failures caused by other browser extensions ([#36011](https://github.com/n8n-io/n8n/issues/36011)) ([f66a0e8](https://github.com/n8n-io/n8n/commit/f66a0e8bc36fd291bd43684151c2c4a190bfac5c))
* **core:** Expose incoming request headers to MCP trigger tools ([#35236](https://github.com/n8n-io/n8n/issues/35236)) ([64ed864](https://github.com/n8n-io/n8n/commit/64ed86431516fc88f39fd82ee90ce60e412bf8df))
* **core:** Fix non existent workflow id pathway for AIA ([#35984](https://github.com/n8n-io/n8n/issues/35984)) ([6ab2569](https://github.com/n8n-io/n8n/commit/6ab25697e4040ee0b3b21b1829db092d10c17194))
* **core:** Fix z-indexing of expression modal over agent NDV ([#36233](https://github.com/n8n-io/n8n/issues/36233)) ([1fa6ef0](https://github.com/n8n-io/n8n/commit/1fa6ef0a0c59ee6712db6a5bc495a4397bb53246))
* **core:** Gate AI Assistant workflow publish offers on live verification ([#36394](https://github.com/n8n-io/n8n/issues/36394)) ([602e6a0](https://github.com/n8n-io/n8n/commit/602e6a05f5376a0bd56e7e9ab7b7483b224a4c11))
* **core:** Handle dropped AI provider connections as transient ([#35587](https://github.com/n8n-io/n8n/issues/35587)) ([72235d3](https://github.com/n8n-io/n8n/commit/72235d32f9b558326412016a4b93ec1369ed12e5))
* **core:** Handle oversized chat attachments gracefully ([#36061](https://github.com/n8n-io/n8n/issues/36061)) ([de8b358](https://github.com/n8n-io/n8n/commit/de8b358c0134a7c25ad024e6dabd1965fe3ad958))
* **core:** Harden agent HTTP Request tool configuration ([#36463](https://github.com/n8n-io/n8n/issues/36463)) ([0791359](https://github.com/n8n-io/n8n/commit/0791359ffd70d1ce65129328cba4489afac6ebe6))
* **core:** Improve agent builder skill and channel handling ([#35980](https://github.com/n8n-io/n8n/issues/35980)) ([a723e79](https://github.com/n8n-io/n8n/commit/a723e791f12d7497d93430953f67f07231c99166))
* **core:** Keep node IDs stable when the AI Assistant edits a workflow ([#36236](https://github.com/n8n-io/n8n/issues/36236)) ([a71e45a](https://github.com/n8n-io/n8n/commit/a71e45acdda7c0c281672f92c7d86250cb6d0910))
* **core:** Keep resolving end-user credentials after a trigger stops listening ([#35973](https://github.com/n8n-io/n8n/issues/35973)) ([a447ac4](https://github.com/n8n-io/n8n/commit/a447ac422ed9bbce322767451f2a08e2a82c01c4))
* **core:** Keep the account panel on hosted forms once every account is connected ([#36429](https://github.com/n8n-io/n8n/issues/36429)) ([a8754b4](https://github.com/n8n-io/n8n/commit/a8754b40406a74052a3ba12bbd244f5b5bbac89a))
* **core:** Let Instance AI run a specific trigger's flow ([#36410](https://github.com/n8n-io/n8n/issues/36410)) ([1a7425d](https://github.com/n8n-io/n8n/commit/1a7425d62bfb59d670a95ea863e6d07512957d2a))
* **core:** Make published agent channel add, replace, and remove failure-safe ([#36100](https://github.com/n8n-io/n8n/issues/36100)) ([79c1393](https://github.com/n8n-io/n8n/commit/79c1393cfb5d497b230da8190e762c5cdd0604af))
* **core:** Prefer managed OAuth auth types when the AI builder resolves credentials ([#35929](https://github.com/n8n-io/n8n/issues/35929)) ([1b4c0ba](https://github.com/n8n-io/n8n/commit/1b4c0bab8ea35acf02f521618950f7f73c6e3fda))
* **core:** Preserve Anthropic thinking history across agent turns ([#36185](https://github.com/n8n-io/n8n/issues/36185)) ([5f6d734](https://github.com/n8n-io/n8n/commit/5f6d734620e6603f319778013770f5fb85c0ed3b))
* **core:** Rebuild the node registry without emptying it first ([#36387](https://github.com/n8n-io/n8n/issues/36387)) ([86facbd](https://github.com/n8n-io/n8n/commit/86facbdcaf4a7df3e2ed18a1ac730ffab67b7504))
* **core:** Recover model turns that end without usable output ([#36423](https://github.com/n8n-io/n8n/issues/36423)) ([bae82bd](https://github.com/n8n-io/n8n/commit/bae82bdad558326bd490a8e2cc2ad669f45706ff))
* **core:** Remember skipped Instance AI credential setup for the rest of a thread ([#36308](https://github.com/n8n-io/n8n/issues/36308)) ([6ace2a5](https://github.com/n8n-io/n8n/commit/6ace2a5b4fb1bd70feb7881eca147f304dc1f2fa))
* **core:** Report the real activation mode for triggers activated via the publication outbox ([#36123](https://github.com/n8n-io/n8n/issues/36123)) ([1a12af6](https://github.com/n8n-io/n8n/commit/1a12af601b52eb9ee9f4313310646ddf83128115))
* **core:** Report the workflow's parent folder in get_workflow_details ([#36234](https://github.com/n8n-io/n8n/issues/36234)) ([9b97fb5](https://github.com/n8n-io/n8n/commit/9b97fb5b63d70f27d949d01b448ce4c8f9cde93a))
* **core:** Require trigger node name for MCP workflow executions ([#36341](https://github.com/n8n-io/n8n/issues/36341)) ([7ea9d54](https://github.com/n8n-io/n8n/commit/7ea9d542e3f7ae98edd14e0e36190950b4cb739b))
* **core:** Resolve agent credentials through the node decryption path so external secrets work ([#36275](https://github.com/n8n-io/n8n/issues/36275)) ([9cbbd59](https://github.com/n8n-io/n8n/commit/9cbbd59a5c79de9194c82641f0589e581c011a8e))
* **core:** Resolve AI Assistant sticky note placement after layout ([#36320](https://github.com/n8n-io/n8n/issues/36320)) ([b671cf4](https://github.com/n8n-io/n8n/commit/b671cf4276e12517b86f45cabadbcf30539a7839))
* **core:** Resolve Custom API Call support through inherited credential types ([#35820](https://github.com/n8n-io/n8n/issues/35820)) ([88efdb9](https://github.com/n8n-io/n8n/commit/88efdb975e9c124fb6977a0fec6bb8afc11fa081))
* **core:** Resolve operation defaults per resource in generated node schemas ([#35952](https://github.com/n8n-io/n8n/issues/35952)) ([79a7bd2](https://github.com/n8n-io/n8n/commit/79a7bd251c7e92893e5e85d7a4619b7602726181))
* **core:** Respect the editor write lock when Instance AI writes workflows ([#36335](https://github.com/n8n-io/n8n/issues/36335)) ([f19c452](https://github.com/n8n-io/n8n/commit/f19c452ba17b262188472bee656c2b0ce5897b54))
* **core:** Return final responses from workflow agents ([#36127](https://github.com/n8n-io/n8n/issues/36127)) ([5e2779e](https://github.com/n8n-io/n8n/commit/5e2779e0be2ffb8aadb18430f6c666fe25fa1d42))
* **core:** Scope AI Assistant credential setup cards to the thread's project ([#36425](https://github.com/n8n-io/n8n/issues/36425)) ([b50ea9c](https://github.com/n8n-io/n8n/commit/b50ea9cd42e1d3dc1741092d7685e3cdbd1a0873))
* **core:** Stop auto-selecting the only generic auth credential ([#35963](https://github.com/n8n-io/n8n/issues/35963)) ([237c503](https://github.com/n8n-io/n8n/commit/237c50314bd80d9b346cfbefba1d1081535b534b))
* **core:** Stop duplicated workflows from inheriting the original's static data ([#35921](https://github.com/n8n-io/n8n/issues/35921)) ([25c1521](https://github.com/n8n-io/n8n/commit/25c1521591088625548c85a432264c289b525997))
* **core:** Stop routing unrelated pre-existing nodes into workflow setup after AI Assistant edit ([#36280](https://github.com/n8n-io/n8n/issues/36280)) ([c89dba8](https://github.com/n8n-io/n8n/commit/c89dba81db36e2ea41ef4371537ccd57824c666b))
* **core:** Surface model discovery authentication failures to users ([#35979](https://github.com/n8n-io/n8n/issues/35979)) ([c44c7de](https://github.com/n8n-io/n8n/commit/c44c7de59b2aa78dcfc2a2d3916be863a7778381))
* Default all navigations to domcontentloaded ([#36238](https://github.com/n8n-io/n8n/issues/36238)) ([5a1e186](https://github.com/n8n-io/n8n/commit/5a1e1867fd274c15e55e4f2e3ad6d86e7f5c4bcb))
* Don't detach child process on Windows so external programs write ([#36327](https://github.com/n8n-io/n8n/issues/36327)) ([de6f3d9](https://github.com/n8n-io/n8n/commit/de6f3d91dcae26f5414073790a971e20bcf4334c))
* **editor:** Clarify external secret expression previews ([#36064](https://github.com/n8n-io/n8n/issues/36064)) ([7792fdb](https://github.com/n8n-io/n8n/commit/7792fdbbc7695898137984bfda2ca15e105f1ba0))
* **editor:** Display tool execution data in Agent sessions ([#36085](https://github.com/n8n-io/n8n/issues/36085)) ([a94ffe9](https://github.com/n8n-io/n8n/commit/a94ffe922edbc5c69dd9101fd5877ade2d41c454))
* **editor:** Improve agent error handoff to AI Assistant ([#36076](https://github.com/n8n-io/n8n/issues/36076)) ([598c1f3](https://github.com/n8n-io/n8n/commit/598c1f3357f45381eb31bfb4b2eae954801e2d6a))
* **editor:** Keep agent nodes inside canvas group borders ([#35972](https://github.com/n8n-io/n8n/issues/35972)) ([174d0d8](https://github.com/n8n-io/n8n/commit/174d0d8d3999a66a5c5992b9856876c6d90fe41c))
* **editor:** Keep agent tool schemas aligned with saved configuration ([#36329](https://github.com/n8n-io/n8n/issues/36329)) ([8d3e266](https://github.com/n8n-io/n8n/commit/8d3e2667a34b545ef214ff7caf2e765275553f98))
* **editor:** Keep cursor position when clicking into an expression editor ([#36411](https://github.com/n8n-io/n8n/issues/36411)) ([5fa9aaa](https://github.com/n8n-io/n8n/commit/5fa9aaa1cda0d5233c27e6d4b37e57928e831390))
* **editor:** Name the AI Assistant browser tab after the conversation ([#35958](https://github.com/n8n-io/n8n/issues/35958)) ([e4a414d](https://github.com/n8n-io/n8n/commit/e4a414dc18b066aae778c6208699630906562261))
* **editor:** Never autosave a read-only preview canvas ([#36023](https://github.com/n8n-io/n8n/issues/36023)) ([56a2d20](https://github.com/n8n-io/n8n/commit/56a2d200954e306bd1d3f32cdf02bd39bb395b94))
* **editor:** Only show Markdown placeholder when empty ([#36336](https://github.com/n8n-io/n8n/issues/36336)) ([95515d7](https://github.com/n8n-io/n8n/commit/95515d70600f8c4bbafc790e12039dfb292e03ea))
* **editor:** Open OAuth sign-in popup before network calls and report blocked pop-ups clearly ([#35948](https://github.com/n8n-io/n8n/issues/35948)) ([0f9bff0](https://github.com/n8n-io/n8n/commit/0f9bff0b1494e5f98508a8b968ecece1c88671f9))
* **editor:** Polish workflow header actions menu and production checklist placement ([#36209](https://github.com/n8n-io/n8n/issues/36209)) ([f3f075a](https://github.com/n8n-io/n8n/commit/f3f075aa5019b7dab83563f0fd84659f9d056e54))
* **editor:** Preserve saved credential data after connect-save to prevent false disconnect prompt ([#35598](https://github.com/n8n-io/n8n/issues/35598)) ([59ea1f4](https://github.com/n8n-io/n8n/commit/59ea1f4c922b5e0e00078a650bde84c7b43ce75a))
* **editor:** Redirect to sign-in when the user's session expires ([#35507](https://github.com/n8n-io/n8n/issues/35507)) ([a78ff67](https://github.com/n8n-io/n8n/commit/a78ff67f598572b3b67937425182e578f8bb06b1))
* **editor:** Refresh chat history when switching executions ([#35312](https://github.com/n8n-io/n8n/issues/35312)) ([36f2b68](https://github.com/n8n-io/n8n/commit/36f2b68c8b6f125eb479a9b815f7c006e483af85))
* **editor:** Remove misleading reset zoom control ([#35843](https://github.com/n8n-io/n8n/issues/35843)) ([cce5401](https://github.com/n8n-io/n8n/commit/cce5401ed29df9ada99be29b4a67892c389b8dab))
* **editor:** Restore the connection test for Kafka and Odoo credentials ([#36195](https://github.com/n8n-io/n8n/issues/36195)) ([40a5901](https://github.com/n8n-io/n8n/commit/40a5901f25af7e885fae3a494f0d0f4c39f586ee))
* **editor:** Settle navigation instead of hanging when auth-init guard errors ([#36190](https://github.com/n8n-io/n8n/issues/36190)) ([ddd5494](https://github.com/n8n-io/n8n/commit/ddd54948099751cc7a6cacbb15d9a8ad86c8aebd))
* **editor:** Show agent context after assistant handoff ([#36310](https://github.com/n8n-io/n8n/issues/36310)) ([d810315](https://github.com/n8n-io/n8n/commit/d810315f11fce0205f35d81922060b16395dbe11))
* **editor:** Show placeholder while MCP settings counts load ([#35894](https://github.com/n8n-io/n8n/issues/35894)) ([870099f](https://github.com/n8n-io/n8n/commit/870099f6f1dd7d70e99ec78544ae13206e3f4673))
* **editor:** Show the connected provider account, not the n8n account email ([#35553](https://github.com/n8n-io/n8n/issues/35553)) ([ee03e02](https://github.com/n8n-io/n8n/commit/ee03e023d21d263d7fc43696002590d3d6c29f5e))
* **editor:** Stop the workflow actions menu shifting when a new workflow is saved ([#36077](https://github.com/n8n-io/n8n/issues/36077)) ([a914319](https://github.com/n8n-io/n8n/commit/a914319ed108569edaaa8c6ac72c90bbf474dae3))
* **editor:** Stop write-lock polling from re-arming after session expiry ([#34750](https://github.com/n8n-io/n8n/issues/34750)) ([26be86d](https://github.com/n8n-io/n8n/commit/26be86daa2bc20840325ce76b8d4b03a31a4730f))
* **editor:** Update ready-to-run demo templates to a current OpenAI model ([#36189](https://github.com/n8n-io/n8n/issues/36189)) ([a27c90c](https://github.com/n8n-io/n8n/commit/a27c90cceca8cc32e754e3d96dd2e51e2d66f459))
* Fix decompress size calculation for zip64 archives ([#35856](https://github.com/n8n-io/n8n/issues/35856)) ([d094b6a](https://github.com/n8n-io/n8n/commit/d094b6ad5ef6911e4e6adaf234d0de0054336624))
* **Google Ads Node:** Migrate from sunset v21 API to v25 ([#36255](https://github.com/n8n-io/n8n/issues/36255)) ([bab2649](https://github.com/n8n-io/n8n/commit/bab26492bcda654561ca0d9f2dd9fd42a4bd2440))
* **Google Ads Node:** Select the renamed video views metric on API v25 ([#36347](https://github.com/n8n-io/n8n/issues/36347)) ([7b64b94](https://github.com/n8n-io/n8n/commit/7b64b9478e778961cc3c5e649e704c04826a6d1e))
* Handle exponential-notation numbers in smartDecimal ([#35967](https://github.com/n8n-io/n8n/issues/35967)) ([08052c1](https://github.com/n8n-io/n8n/commit/08052c19ebe44006285d360c58dc284f22a1e228))
* **Kafka Trigger Node:** Fail v2 activation when the consumer group cannot be joined ([#36046](https://github.com/n8n-io/n8n/issues/36046)) ([67a458e](https://github.com/n8n-io/n8n/commit/67a458ecbbe9f70ddb905bdec4b6e9b33da05510))
* **Kafka Trigger Node:** Refuse activation when the topic does not exist ([#36044](https://github.com/n8n-io/n8n/issues/36044)) ([b025ff0](https://github.com/n8n-io/n8n/commit/b025ff01d2fb798bb9cd982caec6b0ff6c92f2d2))
* **MCP Server Trigger Node:** Keep sessions working when the caller's end-user credentials are unconnected ([#36221](https://github.com/n8n-io/n8n/issues/36221)) ([5044810](https://github.com/n8n-io/n8n/commit/50448105d7de0674faeba10ecf4f9b7fc4545e65))
* **Microsoft SharePoint Node:** Require subdomain in OAuth2 credential ([#35680](https://github.com/n8n-io/n8n/issues/35680)) ([620c40c](https://github.com/n8n-io/n8n/commit/620c40c02c83c05ca573b920ab7f6ab6d313a27f))
* **Microsoft Teams Node:** Restore Group.ReadWrite.All OAuth2 scope ([#36005](https://github.com/n8n-io/n8n/issues/36005)) ([653dfc6](https://github.com/n8n-io/n8n/commit/653dfc6cf7a0d07855aeed8a70464e8fe8242e41))
* **Microsoft Teams Node:** Surface Graph errors instead of returning an empty chat list ([#36200](https://github.com/n8n-io/n8n/issues/36200)) ([1cc20d5](https://github.com/n8n-io/n8n/commit/1cc20d55e283ac367fa6720a3ad1ef0d4017cc49))
* Remove default model from one-script docker compose setup ([#36034](https://github.com/n8n-io/n8n/issues/36034)) ([a91e012](https://github.com/n8n-io/n8n/commit/a91e012de6900afc0aadee56f2c4231095b78499))
* Rethrow unreachable-element errors in getElementValue ([#36199](https://github.com/n8n-io/n8n/issues/36199)) ([1ad64b8](https://github.com/n8n-io/n8n/commit/1ad64b8a766956fe0f76e08c71ede9b773297848))
* Surface Zoho API error message on duplicate record ([#35840](https://github.com/n8n-io/n8n/issues/35840)) ([0a30c03](https://github.com/n8n-io/n8n/commit/0a30c037e91bc7015e816a872c5a61b28cd0d75f))
* Wait for task runner registration before executing in e2e test ([#36212](https://github.com/n8n-io/n8n/issues/36212)) ([2bca080](https://github.com/n8n-io/n8n/commit/2bca0807a898499d312f89254af3c995d87b95c8))
* **Wordpress Node:** Send no-cache header on write requests ([#20473](https://github.com/n8n-io/n8n/issues/20473)) ([077370f](https://github.com/n8n-io/n8n/commit/077370ff56ceabb8a99393778ed174c23a403f1b))
### Features
* Adapt the OAuth consent screen for first-party clients ([#36261](https://github.com/n8n-io/n8n/issues/36261)) ([2e97fa5](https://github.com/n8n-io/n8n/commit/2e97fa52bff5fef57f3486fe4ccccdece234f873))
* Add LangSmith agent session debug export ([#36309](https://github.com/n8n-io/n8n/issues/36309)) ([6cd9a2b](https://github.com/n8n-io/n8n/commit/6cd9a2bfdb8ac16534feb88972f584ef738d9e40))
* Add snapshot option to every browser use interaction tool ([#36111](https://github.com/n8n-io/n8n/issues/36111)) ([7f37137](https://github.com/n8n-io/n8n/commit/7f3713702aaa73a5ab474cb3ba08a3e8bf673203))
* **ai-builder:** Add MCP server as allowed tools in skills ([#35969](https://github.com/n8n-io/n8n/issues/35969)) ([d0424b4](https://github.com/n8n-io/n8n/commit/d0424b444f6c51cfb322eafca69ac0f0d44ea062))
* **ai-builder:** Auto-select a sensible default model for new agents ([#36058](https://github.com/n8n-io/n8n/issues/36058)) ([30aff5d](https://github.com/n8n-io/n8n/commit/30aff5d740ff3bb4e15aa0e0b6b88b2a02faaad1))
* **ai-builder:** Offer agent building in assistant greetings ([#36073](https://github.com/n8n-io/n8n/issues/36073)) ([e2e719c](https://github.com/n8n-io/n8n/commit/e2e719c4ad7d075a0e0d252b750bb555d38514ca))
* **API:** Add endpoint to create role-mapping rules ([#36032](https://github.com/n8n-io/n8n/issues/36032)) ([40a0f2a](https://github.com/n8n-io/n8n/commit/40a0f2a016c1fb74e5507aa027c05242c57472e4))
* **API:** Add endpoint to get a single role ([#36279](https://github.com/n8n-io/n8n/issues/36279)) ([7bd7dac](https://github.com/n8n-io/n8n/commit/7bd7dac6fb07b1c63aa09b56081aefdacf27304b))
* **API:** Add endpoint to list all roles ([#35609](https://github.com/n8n-io/n8n/issues/35609)) ([48893ee](https://github.com/n8n-io/n8n/commit/48893eebf08a33213071de2a170bf61c3f14de64))
* **API:** Add endpoint to update a custom role ([#36213](https://github.com/n8n-io/n8n/issues/36213)) ([28bf536](https://github.com/n8n-io/n8n/commit/28bf536d857a6560b63e230169db70549ee78519))
* **API:** Migrate workflow tags endpoints to decorator pattern ([#35829](https://github.com/n8n-io/n8n/issues/35829)) ([6ab3f5e](https://github.com/n8n-io/n8n/commit/6ab3f5e83eb0c621dd6493df05a53c5f0c4c23c8))
* **Confluence Node:** Add Confluence Cloud OAuth2 credential ([#35732](https://github.com/n8n-io/n8n/issues/35732)) ([f9fd8a8](https://github.com/n8n-io/n8n/commit/f9fd8a83e45349667d5ad054f1f7f0ff398b5fee))
* **Confluence Node:** Add page get operation with full sub-tree support ([#36217](https://github.com/n8n-io/n8n/issues/36217)) ([d9fa966](https://github.com/n8n-io/n8n/commit/d9fa966ac5a46d1d69ea3a14f0ad799d3a56d0e2))
* **core:** Add AI Assistant setup funnel telemetry ([#36391](https://github.com/n8n-io/n8n/issues/36391)) ([490b368](https://github.com/n8n-io/n8n/commit/490b368d901f166277ea556c4d36ca5a5737eb1b))
* **core:** Add an inline Available tools card to connect MCP servers from the assistant chat ([#35332](https://github.com/n8n-io/n8n/issues/35332)) ([fb430aa](https://github.com/n8n-io/n8n/commit/fb430aab05e3e64ba93a7a08ac6ce4b6600eb965))
* **core:** Add env var to skip workflow history compaction on startup ([#36099](https://github.com/n8n-io/n8n/issues/36099)) ([5c61888](https://github.com/n8n-io/n8n/commit/5c618883b96eae4afb8b30d514ef107308ce6ab8))
* **core:** Add get_workflow_versions_diff MCP tool ([#35492](https://github.com/n8n-io/n8n/issues/35492)) ([ff7955f](https://github.com/n8n-io/n8n/commit/ff7955f66830820f32bf809ef5b8b1240aba1ee5))
* **core:** Add MCP support for folder creation and workflow moves ([#35964](https://github.com/n8n-io/n8n/issues/35964)) ([d4dcc1c](https://github.com/n8n-io/n8n/commit/d4dcc1cb915af1e011614ef3143c720ede1e422b))
* **core:** Add MCP tool to read data table rows ([#35956](https://github.com/n8n-io/n8n/issues/35956)) ([de944d2](https://github.com/n8n-io/n8n/commit/de944d21d1532b07f349edef8dbbe18c611efae7))
* **core:** Add n8n Sandbox support to Agent Knowledge ([#35811](https://github.com/n8n-io/n8n/issues/35811)) ([6a30736](https://github.com/n8n-io/n8n/commit/6a307368413b2225282f52e19b2e574a142a363a))
* **core:** Add new agent slack integration ([#35591](https://github.com/n8n-io/n8n/issues/35591)) ([ca82fd9](https://github.com/n8n-io/n8n/commit/ca82fd9827382bc5a2ccbd54e52beec7b2494bb4))
* **core:** Add one-off operation flow and insertion-semantics rules to AI Assistant ([#36184](https://github.com/n8n-io/n8n/issues/36184)) ([51aa6fb](https://github.com/n8n-io/n8n/commit/51aa6fb5197f7282444b24ec76ec1d65e8866f41))
* **core:** Add writable workspaces to agents ([#36033](https://github.com/n8n-io/n8n/issues/36033)) ([01ce449](https://github.com/n8n-io/n8n/commit/01ce4490d0df8ff659d54cc5cd571de8c1ac85ac))
* **core:** Advertise the MCP 2026-07-28 routing headers in CORS ([#35471](https://github.com/n8n-io/n8n/issues/35471)) ([a14ab0b](https://github.com/n8n-io/n8n/commit/a14ab0b086e9decbe03f2425709bc5fbae265e10))
* **core:** Auto-expose new workflows to MCP from settings via toggle ([#35828](https://github.com/n8n-io/n8n/issues/35828)) ([a74fbab](https://github.com/n8n-io/n8n/commit/a74fbab2bf3f59d148bae4b63e76a7e454eae72f))
* **core:** Coalesce a Schedule Trigger node's catch-up runs after downtime ([#35524](https://github.com/n8n-io/n8n/issues/35524)) ([026cee3](https://github.com/n8n-io/n8n/commit/026cee35074691d0e7839bab3b3e927f884a1b70))
* **core:** Deprecate Array.merge from expressions, and add Array.mergeIntoObject ([#36161](https://github.com/n8n-io/n8n/issues/36161)) ([04e76a3](https://github.com/n8n-io/n8n/commit/04e76a389058915e8b0bc451dbab7d115e1f6d8c))
* **core:** Enable Agent sandboxes through Instance AI setup ([#36056](https://github.com/n8n-io/n8n/issues/36056)) ([5f9602b](https://github.com/n8n-io/n8n/commit/5f9602b19e4b513c939af44cea5ea7ede58b0555))
* **core:** Isolate Agent sandboxes by principal ([#36203](https://github.com/n8n-io/n8n/issues/36203)) ([ce1b521](https://github.com/n8n-io/n8n/commit/ce1b52177e84b7bd953da8a455ea6538202cd959))
* **core:** Let agent builder inspect and update schedules ([#36449](https://github.com/n8n-io/n8n/issues/36449)) ([f7459c8](https://github.com/n8n-io/n8n/commit/f7459c88cc54fa75f0123e406a3d8afa58bcb8fe))
* **core:** Make n8n User Auth (OAuth2) generally available ([#35900](https://github.com/n8n-io/n8n/issues/35900)) ([5bfcde5](https://github.com/n8n-io/n8n/commit/5bfcde593de1aece695bb2f4778157a3c6f195b8))
* **core:** Redesign the account connect panel on hosted forms ([#36344](https://github.com/n8n-io/n8n/issues/36344)) ([bf28af6](https://github.com/n8n-io/n8n/commit/bf28af68e9f24fbda9c05b0a1f4c75b64a340276))
* **core:** Refuse durable pollers when an active workflow has duplicate trigger node ids ([#36340](https://github.com/n8n-io/n8n/issues/36340)) ([faf4dc3](https://github.com/n8n-io/n8n/commit/faf4dc35dfd1e855fb3554d7445e8836e98fd456))
* **core:** Restrict end-user credential creation to team projects ([#36025](https://github.com/n8n-io/n8n/issues/36025)) ([e243656](https://github.com/n8n-io/n8n/commit/e24365676c4adefcf57fabc2274e0e5b9d0e7aba))
* **core:** Restrict workflow review decisions to assigned reviewers ([#36040](https://github.com/n8n-io/n8n/issues/36040)) ([64dcebb](https://github.com/n8n-io/n8n/commit/64dcebb649fb8f27590578c1fb04bf98d8dc300a))
* **core:** Send projectId to the AI Gateway for usage attribution ([#35885](https://github.com/n8n-io/n8n/issues/35885)) ([3300833](https://github.com/n8n-io/n8n/commit/33008330de1933f56144226ccfd910d98e7ac540))
* **core:** Skip update approval for workflows created in the same Instance AI session ([#36054](https://github.com/n8n-io/n8n/issues/36054)) ([dfa841a](https://github.com/n8n-io/n8n/commit/dfa841a040f07d9e06e9e05d7fcdab035e5b800f))
* **core:** Support workflow version policies in package exports ([#35962](https://github.com/n8n-io/n8n/issues/35962)) ([aecd164](https://github.com/n8n-io/n8n/commit/aecd1649fef137c50b18ffb800e6e5feddae9477))
* **core:** Tell the assistant which services are connected and what tools they expose ([#35931](https://github.com/n8n-io/n8n/issues/35931)) ([3571c09](https://github.com/n8n-io/n8n/commit/3571c09d9b2df31b1028af318fac2d02dbc4a8c6))
* Create workflow tools from the agent builder ([#36460](https://github.com/n8n-io/n8n/issues/36460)) ([35434b0](https://github.com/n8n-io/n8n/commit/35434b03a02c037f71bde71ee787d36dd63423b6))
* **Discord Node:** Add member moderation actions ([#33486](https://github.com/n8n-io/n8n/issues/33486)) ([6bd31e5](https://github.com/n8n-io/n8n/commit/6bd31e5e1e7e42232638cf2d7163502ff0ec1efd))
* **editor:** Add docked agent preview chat ([#35773](https://github.com/n8n-io/n8n/issues/35773)) ([42130c5](https://github.com/n8n-io/n8n/commit/42130c53620bec26bfe1494fd4a68bc001631253))
* **editor:** Add preview chat to SessionTimeline view ([#36128](https://github.com/n8n-io/n8n/issues/36128)) ([dbb28af](https://github.com/n8n-io/n8n/commit/dbb28af5bff1d803ce74d607f8919f171595a2e4))
* **editor:** Display authors on the review details view ([#35897](https://github.com/n8n-io/n8n/issues/35897)) ([d35bb3e](https://github.com/n8n-io/n8n/commit/d35bb3e98071dafee66fa25b77c0d1f86b12555b))
* **editor:** Improve agent session tool call feedback ([#36346](https://github.com/n8n-io/n8n/issues/36346)) ([b27a53e](https://github.com/n8n-io/n8n/commit/b27a53e60f8d9950e781d1fa91000e19f779ad6c))
* **editor:** Integrate n8n credits into the node credential picker ([#35758](https://github.com/n8n-io/n8n/issues/35758)) ([b2df4c8](https://github.com/n8n-io/n8n/commit/b2df4c8855f97a7fd905861e91a9fb94bc1933a1))
* **editor:** Let users update review descriptions ([#36069](https://github.com/n8n-io/n8n/issues/36069)) ([cbb5577](https://github.com/n8n-io/n8n/commit/cbb55770537b0874f684dab9dda992d461bd577a))
* **editor:** Link review success toasts ([#35888](https://github.com/n8n-io/n8n/issues/35888)) ([3a4f356](https://github.com/n8n-io/n8n/commit/3a4f35652c36704775ecb77b82669e208c986091))
* **editor:** Show character counters on review title and description inputs ([#36177](https://github.com/n8n-io/n8n/issues/36177)) ([998fc85](https://github.com/n8n-io/n8n/commit/998fc85bfa1b360da2b84e57e34cef7fbdb5a681))
* **editor:** Split workflow review inbox sections ([#36216](https://github.com/n8n-io/n8n/issues/36216)) ([dd82601](https://github.com/n8n-io/n8n/commit/dd82601f6442af58d4b6b8d2071fb7585ef92220))
* **editor:** Switch credits pill after a top-up or depleted allowance ([#36239](https://github.com/n8n-io/n8n/issues/36239)) ([516c19e](https://github.com/n8n-io/n8n/commit/516c19e2eda86f132d9d025711fdcaf9cc19e63c))
* **editor:** Update NumberInput styling ([#35937](https://github.com/n8n-io/n8n/issues/35937)) ([a319c76](https://github.com/n8n-io/n8n/commit/a319c7626496fb46442462d257e9537546ba8be2))
* **editor:** Update SegmentControl component ([#34447](https://github.com/n8n-io/n8n/issues/34447)) ([60736dc](https://github.com/n8n-io/n8n/commit/60736dc2f06b6d46899f52ab5ce1c1479107f071))
* Gate Browser Use setup steps on extension detection ([#36235](https://github.com/n8n-io/n8n/issues/36235)) ([7025284](https://github.com/n8n-io/n8n/commit/70252844046b3bcb30acec80625938f4af383123))
* Hide n8n-connect usage when aiGatewayCloudUbb is enabled ([#35978](https://github.com/n8n-io/n8n/issues/35978)) ([e7fa252](https://github.com/n8n-io/n8n/commit/e7fa25217eb9caf60df938875daad6f5adf8eca3))
* Improve Browser Use connect conditions ([#36215](https://github.com/n8n-io/n8n/issues/36215)) ([fee8450](https://github.com/n8n-io/n8n/commit/fee845099902c4b901c30397a97198addb145b81))
* **lmChatOpenRouter Node:** Add provider routing options ([#24822](https://github.com/n8n-io/n8n/issues/24822)) ([d7dcba5](https://github.com/n8n-io/n8n/commit/d7dcba5c803578ee663ffd43f570ab1503fe407c))
* **MCP Server Trigger Node:** Add server instructions option ([#35945](https://github.com/n8n-io/n8n/issues/35945)) ([bfd2746](https://github.com/n8n-io/n8n/commit/bfd2746ba7d4be2632b3c0078f2cbbc9e8e388fe))
* **MiniMax Node:** Add M3 and H3 model support ([#35935](https://github.com/n8n-io/n8n/issues/35935)) ([aee23f7](https://github.com/n8n-io/n8n/commit/aee23f7b749876c70e062137553a2400529e0878))
* **Schedule Trigger Node:** Add "If Execution Is Missed" option ([#35626](https://github.com/n8n-io/n8n/issues/35626)) ([619410f](https://github.com/n8n-io/n8n/commit/619410fdcb189e2746f26999cdf9e685aa995f5a))
* **Schedule Trigger Node:** Add a per-node missed execution grace period ([#35771](https://github.com/n8n-io/n8n/issues/35771)) ([dc55ed1](https://github.com/n8n-io/n8n/commit/dc55ed1e9e9f380e2bbeeea705b415814f65b8b6))
* Show scopes for managed OAuth credentials with N8N_MANAGED_OAUTH_SHOW_SCOPES ([#36053](https://github.com/n8n-io/n8n/issues/36053)) ([5e5bb31](https://github.com/n8n-io/n8n/commit/5e5bb317d8292c0a560de87ba91963470b574264))
* Simplify user flow for Browser Use connection ([#36316](https://github.com/n8n-io/n8n/issues/36316)) ([f44bd48](https://github.com/n8n-io/n8n/commit/f44bd4827fbfd6476ac8060a0207d569e3a96f77))
* Track AI created credentials success signals ([#35812](https://github.com/n8n-io/n8n/issues/35812)) ([8b19044](https://github.com/n8n-io/n8n/commit/8b19044f252c8e625bd651cd1ee55b62ba003922))
# [2.35.0](https://github.com/n8n-io/n8n/compare/n8n@2.34.0...n8n@2.35.0) (2026-08-11)
### Bug Fixes
* **AI Agent Node:** Stop pre-tool-call text leaking into agent responses ([#35212](https://github.com/n8n-io/n8n/issues/35212)) ([d5845df](https://github.com/n8n-io/n8n/commit/d5845df4fcbb52f1e90696aa8f4ed1520f9dad1e))
* **core:** Apply redaction policy to Code node console output ([#35121](https://github.com/n8n-io/n8n/issues/35121)) ([242da02](https://github.com/n8n-io/n8n/commit/242da024ac04d26f58208400e97e94a95a4b2538))
* **core:** Bound oversized agent tool results ([#35759](https://github.com/n8n-io/n8n/issues/35759)) ([8fbcd7e](https://github.com/n8n-io/n8n/commit/8fbcd7efe95c32a48e571d9551b45af56d212ab4))
* **core:** Close review requests when a source control pull archives or cascade-deletes a workflow ([#35798](https://github.com/n8n-io/n8n/issues/35798)) ([fd366ce](https://github.com/n8n-io/n8n/commit/fd366ceba13468c41490cb359beaa092f5a5ed1d))
* **core:** Compile MCP tool schemas on their declared JSON Schema dialect ([#35610](https://github.com/n8n-io/n8n/issues/35610)) ([eeaa425](https://github.com/n8n-io/n8n/commit/eeaa425056bd350cb7a91fb8202b84630811aa86))
* **core:** Deduplicate OAuth2 authorization URL query parameters ([#35949](https://github.com/n8n-io/n8n/issues/35949)) ([0b4b044](https://github.com/n8n-io/n8n/commit/0b4b044fb201672b5456174f5af7fe8d6b7d9c1a))
* **core:** Detect node parameter values the connected credential cannot use ([#35633](https://github.com/n8n-io/n8n/issues/35633)) ([5925f5b](https://github.com/n8n-io/n8n/commit/5925f5bb65ef5c2b030159320a53603be5ff3928))
* **core:** Fail stalled Instance AI model streams fast instead of hanging ([#35943](https://github.com/n8n-io/n8n/issues/35943)) ([c04825d](https://github.com/n8n-io/n8n/commit/c04825d4c4762da827318d7df795728b2ef1133e))
* **core:** Fix task runner health check failing ([#35808](https://github.com/n8n-io/n8n/issues/35808)) ([9c4a613](https://github.com/n8n-io/n8n/commit/9c4a613b448acd059eac7ee2bbf86a9351f2ae01))
* **core:** Gate error workflows behind primary readiness ([#35634](https://github.com/n8n-io/n8n/issues/35634)) ([c79bd88](https://github.com/n8n-io/n8n/commit/c79bd8871e5e67da25f5bd4fd07f763b31e3b607))
* **core:** Handle errors from triggered and polled workflow executions ([#35689](https://github.com/n8n-io/n8n/issues/35689)) ([1deee72](https://github.com/n8n-io/n8n/commit/1deee723a333aaf9619ec5e0e4d940d9ad29f236))
* **core:** Handle out-of-scope display option dependencies ([#35845](https://github.com/n8n-io/n8n/issues/35845)) ([9241a72](https://github.com/n8n-io/n8n/commit/9241a72b9aa021d34790245ca6bd6a921f544638))
* **core:** Honor dry run on data table row delete ([#35713](https://github.com/n8n-io/n8n/issues/35713)) ([76fb3bc](https://github.com/n8n-io/n8n/commit/76fb3bcfacce38b1c5900124aa237b6980b1dea1))
* **core:** Ignore undeclared fields in agent resume payloads ([#35607](https://github.com/n8n-io/n8n/issues/35607)) ([42754a8](https://github.com/n8n-io/n8n/commit/42754a82b3013bb9ca67ba2ed609695a6f84144b))
* **core:** Keep agent workflow tools linked after renames ([#35690](https://github.com/n8n-io/n8n/issues/35690)) ([b4736b7](https://github.com/n8n-io/n8n/commit/b4736b738cf4385fabecf5e5e6b3ab54a3093da2))
* **core:** Keep database ping loop alive across connection recovery ([#35752](https://github.com/n8n-io/n8n/issues/35752)) ([3f2d479](https://github.com/n8n-io/n8n/commit/3f2d479d0abe22fedc857d8682229c8c2fd6181b))
* **core:** Keep n8n-docs always loaded so n8n questions do not route to web search ([#35537](https://github.com/n8n-io/n8n/issues/35537)) ([e73f0a4](https://github.com/n8n-io/n8n/commit/e73f0a46543d16f9a76ef9ca3ffdbb07dced608a))
* **core:** Keep valid resume payloads intact when ignoring undeclared fields ([#35751](https://github.com/n8n-io/n8n/issues/35751)) ([a2c9578](https://github.com/n8n-io/n8n/commit/a2c9578294bfb8e7969dc6e51ce296ba8b07c4d6))
* **core:** Nest agent traces under the calling workflow node ([#35457](https://github.com/n8n-io/n8n/issues/35457)) ([5d0a424](https://github.com/n8n-io/n8n/commit/5d0a424947abef88cb894b9b39cedf10c52c071b))
* **core:** Normalize the $fromAI type argument before coercing defaults ([#35641](https://github.com/n8n-io/n8n/issues/35641)) ([c0a7674](https://github.com/n8n-io/n8n/commit/c0a767434c0c415c45c5f4d3a7d096ac9cecb0c5))
* **core:** Probe azure blob with list instead of exists() ([#35719](https://github.com/n8n-io/n8n/issues/35719)) ([8bbc988](https://github.com/n8n-io/n8n/commit/8bbc988a9a3cf9b639dd5b17338d77efcc19add8))
* **core:** Reattach n8n-sandbox thread sandboxes after restart, idle eviction, and across mains ([#35953](https://github.com/n8n-io/n8n/issues/35953)) ([0e7e49b](https://github.com/n8n-io/n8n/commit/0e7e49bdc955f5b55ba77e46206705ca410ce99e))
* **core:** Recover unresponsive task runners ([#35456](https://github.com/n8n-io/n8n/issues/35456)) ([7a79486](https://github.com/n8n-io/n8n/commit/7a7948690b237845ad034f378038902373e11f2e))
* **core:** Reject stale Instance AI confirmations instead of hijacking the live run ([#35733](https://github.com/n8n-io/n8n/issues/35733)) ([4555145](https://github.com/n8n-io/n8n/commit/45551451acf6c4fafb6e6f94bdb5aabd345f0322))
* **core:** Report failed publishing when imported workflows share a webhook path ([#35525](https://github.com/n8n-io/n8n/issues/35525)) ([32f38a3](https://github.com/n8n-io/n8n/commit/32f38a3541235552508c647d8774baae7ce3b6b7))
* **core:** Require row read scope when data table writes return rows ([#35651](https://github.com/n8n-io/n8n/issues/35651)) ([ae6b256](https://github.com/n8n-io/n8n/commit/ae6b25624154bc111b766c445826ee6b824896d7))
* **core:** Resolve expressions in ephemeral agent tools ([#35876](https://github.com/n8n-io/n8n/issues/35876)) ([65083ab](https://github.com/n8n-io/n8n/commit/65083ab1b7768d6ef140954608f0e6bb5af9726c))
* **core:** Return a clear error for a node type with an unknown version ([#35196](https://github.com/n8n-io/n8n/issues/35196)) ([a9cde3f](https://github.com/n8n-io/n8n/commit/a9cde3f9f998c88e70e40fe67ad4a2ee68bd8391))
* **core:** Run published agent workflow tools as production ([#35784](https://github.com/n8n-io/n8n/issues/35784)) ([6cc9b28](https://github.com/n8n-io/n8n/commit/6cc9b287ede63d6e2b1327c8e0eb6fde65467c7e))
* **core:** Show Agent references in credential usage ([#35409](https://github.com/n8n-io/n8n/issues/35409)) ([a74c6fd](https://github.com/n8n-io/n8n/commit/a74c6fd11f8a1db2d3fef26bc5b651dcfc4d5601))
* **core:** Show Instance AI model overload errors clearly ([#35498](https://github.com/n8n-io/n8n/issues/35498)) ([40ba0dd](https://github.com/n8n-io/n8n/commit/40ba0ddb20e8b6f25deb81a0e6d99f1402d90d98))
* **core:** Strip leading BOM before parsing webhook request bodies ([#35933](https://github.com/n8n-io/n8n/issues/35933)) ([aa03666](https://github.com/n8n-io/n8n/commit/aa03666bc69e5d642865ba221ecb33d0655c4559))
* **editor:** Derive workflow home project from shared relation when absent ([#35810](https://github.com/n8n-io/n8n/issues/35810)) ([62e6862](https://github.com/n8n-io/n8n/commit/62e6862f5c428242d0a052ef91bab138cdd4d170))
* **editor:** Disable no-op workflow review updates ([#35890](https://github.com/n8n-io/n8n/issues/35890)) ([bf860f3](https://github.com/n8n-io/n8n/commit/bf860f349c310f4a72ad784978b0b08adf541859))
* **editor:** Fix janky spacing between agent capability chips with long labels ([#35493](https://github.com/n8n-io/n8n/issues/35493)) ([007400d](https://github.com/n8n-io/n8n/commit/007400dfc1feb0abc4c262f74956ad2592ebde6a))
* **editor:** Focus Markdown editor input before toolbar ([#35529](https://github.com/n8n-io/n8n/issues/35529)) ([698d137](https://github.com/n8n-io/n8n/commit/698d137c183f2459361d20f0e00136ab0af9d771))
* **editor:** Keep NDV panels spanning the full container width ([#35818](https://github.com/n8n-io/n8n/issues/35818)) ([561af4c](https://github.com/n8n-io/n8n/commit/561af4c567f62acd9fc15ec0a3c28a987cfa953d))
* **editor:** Keep Publish button visible in narrow workflow headers ([#35502](https://github.com/n8n-io/n8n/issues/35502)) ([1de80ac](https://github.com/n8n-io/n8n/commit/1de80acc0236cba8447c099bf997b3226a008a44))
* **editor:** Lock the AI Assistant canvas while the agent is working ([#35339](https://github.com/n8n-io/n8n/issues/35339)) ([1206780](https://github.com/n8n-io/n8n/commit/120678011d6e60656c442b3a97f52802a8a93304))
* **editor:** Make DropdownMenus more obviously scrollable ([#35726](https://github.com/n8n-io/n8n/issues/35726)) ([d3e49b2](https://github.com/n8n-io/n8n/commit/d3e49b21d980ba71bb7cc95916e9b040ad172008))
* **editor:** Preserve new agent artifact lifecycle ([#35703](https://github.com/n8n-io/n8n/issues/35703)) ([caeb40e](https://github.com/n8n-io/n8n/commit/caeb40ecf358845739d06cee917af65546e6e6b1))
* **editor:** Remove unsupported button type attributes ([#35578](https://github.com/n8n-io/n8n/issues/35578)) ([980c9bd](https://github.com/n8n-io/n8n/commit/980c9bd6ea8229386fe0daf5a3b1ea7ec889a868))
* **editor:** Require an explicit answer or Skip on Instance AI questions ([#35700](https://github.com/n8n-io/n8n/issues/35700)) ([6f765e9](https://github.com/n8n-io/n8n/commit/6f765e993456699219ee1c05c61580dfefbad2d4))
* **editor:** Show agent tool credentials above configuration ([#35590](https://github.com/n8n-io/n8n/issues/35590)) ([1ca0708](https://github.com/n8n-io/n8n/commit/1ca0708ba8965cfc0b981400a9ae0da2d56fdad7))
* **editor:** Show dependency pills on views listing more than 100 resources ([#35825](https://github.com/n8n-io/n8n/issues/35825)) ([926aa1a](https://github.com/n8n-io/n8n/commit/926aa1a73ba14a58a767ff532238c79e9a92080f))
* **Execute Workflow Node:** Prevent invalid input mappings ([#35767](https://github.com/n8n-io/n8n/issues/35767)) ([34ca40b](https://github.com/n8n-io/n8n/commit/34ca40bde06734fcf3450f47ec45b994ca66be4a))
* Fix token data updates and refresh for DCR oauth credentials ([#35535](https://github.com/n8n-io/n8n/issues/35535)) ([abe2959](https://github.com/n8n-io/n8n/commit/abe2959de808548e65953e9468f077d0b5eb623b))
* **Kafka Trigger Node:** Stop consumers reliably when a workflow is deactivated or updated ([#35069](https://github.com/n8n-io/n8n/issues/35069)) ([03213e7](https://github.com/n8n-io/n8n/commit/03213e746641732a01556d7202691021d3aece64))
* Preserve Agent build intent and refresh artifact names ([#35823](https://github.com/n8n-io/n8n/issues/35823)) ([daba594](https://github.com/n8n-io/n8n/commit/daba5946549120da2eea4680961bcf9a1202258e))
* **Schedule Node:** Apply declared interval defaults to stored rules ([#35271](https://github.com/n8n-io/n8n/issues/35271)) ([28d929d](https://github.com/n8n-io/n8n/commit/28d929dc2eae050cc85a7887049ea5080e048165))
* Send n8n version on backend posthog flag retrieval ([#35621](https://github.com/n8n-io/n8n/issues/35621)) ([1ade7a2](https://github.com/n8n-io/n8n/commit/1ade7a26c09859f800b7586d820fd6052dcf7822))
* **SharePoint Node:** Point delegated Sites.Selected users at URL or ID mode ([#35369](https://github.com/n8n-io/n8n/issues/35369)) ([305ed29](https://github.com/n8n-io/n8n/commit/305ed296f45504d7751fcf6c98aff0dcb0fd1755))
* Skip setup cards for credentials the workflow already settled ([#35701](https://github.com/n8n-io/n8n/issues/35701)) ([4493e5a](https://github.com/n8n-io/n8n/commit/4493e5a8b82e16e7a62a8dd264a083304290b1f1))
* Use caller's timezone for Insights date range boundaries ([#33989](https://github.com/n8n-io/n8n/issues/33989)) ([279bde3](https://github.com/n8n-io/n8n/commit/279bde3be6b42067e7d24306f2e026d2b514ee5e))
* **X (Formerly Twitter) Node:** Update OAuth2 and API endpoints to `x.com` ([#32699](https://github.com/n8n-io/n8n/issues/32699)) ([8ac5a60](https://github.com/n8n-io/n8n/commit/8ac5a606163bd8ab0748d7093cbaddfa7c99b54b))
### Features
* Add self-hosted AI Assistant onboarding ([#35579](https://github.com/n8n-io/n8n/issues/35579)) ([4ae4cc3](https://github.com/n8n-io/n8n/commit/4ae4cc3602d2ea6637c95bb452dbcb146d154d07))
* Add Simplified Custom Auth credential with recipe-guided setup in the AI Assistant ([#35056](https://github.com/n8n-io/n8n/issues/35056)) ([53841f1](https://github.com/n8n-io/n8n/commit/53841f1b0e6bf5517526415d87860a633fff7d9e))
* Add task-runners sidecar to the get.n8n.io stack ([#35426](https://github.com/n8n-io/n8n/issues/35426)) ([9493631](https://github.com/n8n-io/n8n/commit/94936315840d08981a461a9bb6307ded87c23b99))
* **API:** Add @Deprecated decorator to the decorated controller platform ([#35725](https://github.com/n8n-io/n8n/issues/35725)) ([897ce6a](https://github.com/n8n-io/n8n/commit/897ce6ab9a0cfad4d4bbb2f2e029f21b29912da5))
* **API:** Add endpoint to create custom roles ([#35522](https://github.com/n8n-io/n8n/issues/35522)) ([d225d03](https://github.com/n8n-io/n8n/commit/d225d0317b97f5574690b461664031ac2bb07512))
* **API:** Add publishIfActive option to workflow update endpoint ([#35813](https://github.com/n8n-io/n8n/issues/35813)) ([0e22fdf](https://github.com/n8n-io/n8n/commit/0e22fdfbc121efdbaad0f713880ed26ca4d81d1b))
* **core:** Add Agent Builder test runs ([#35716](https://github.com/n8n-io/n8n/issues/35716)) ([b660ebe](https://github.com/n8n-io/n8n/commit/b660ebef3894e80dade73c87db486e23d2b64cb1))
* **core:** Add backend form-trigger submission gate for end-user credentials (no changelog) ([#35539](https://github.com/n8n-io/n8n/issues/35539)) ([ddcc94f](https://github.com/n8n-io/n8n/commit/ddcc94f896b052530e4ca63f0ccf4ca9a2c69a1e))
* **core:** Add detailLevel option to MCP get_workflow_details ([#35497](https://github.com/n8n-io/n8n/issues/35497)) ([3773c31](https://github.com/n8n-io/n8n/commit/3773c31e47217dd5ac61fc85ad748f448dfb642a))
* **core:** Add Discord as an agent chat channel ([#35331](https://github.com/n8n-io/n8n/issues/35331)) ([4e6fc65](https://github.com/n8n-io/n8n/commit/4e6fc65f55ed2501f590c280359c423a0f4da6ad))
* **core:** Add HITL to agent builder test runs ([#35731](https://github.com/n8n-io/n8n/issues/35731)) ([0df0041](https://github.com/n8n-io/n8n/commit/0df0041019fcd035f5c610ff5f08ddea7af56e70))
* **core:** Add local agent token counting ([#35836](https://github.com/n8n-io/n8n/issues/35836)) ([ae1252a](https://github.com/n8n-io/n8n/commit/ae1252a7ade832b3e6887b712a4e4c010c702e96))
* **core:** Add projectConflictPolicy to package import ([#35488](https://github.com/n8n-io/n8n/issues/35488)) ([881a14f](https://github.com/n8n-io/n8n/commit/881a14fa4d29c45f3071f9ec2d8d1782937d7b26))
* **core:** Add telemetry for Simplified Custom Auth templated credentials ([#35832](https://github.com/n8n-io/n8n/issues/35832)) ([c485cea](https://github.com/n8n-io/n8n/commit/c485cea340a7167081cc63f8f3e4a1437fa278c1))
* **core:** Auto-close workflow review requests when a workflow is archived or moved ([#35772](https://github.com/n8n-io/n8n/issues/35772)) ([a044c0d](https://github.com/n8n-io/n8n/commit/a044c0d1d8e40db29c89bb4e46e1a674b1ca96e8))
* **core:** Enable 'instance-ai' module by default and remove startup warning ([#35670](https://github.com/n8n-io/n8n/issues/35670)) ([fcc4ef5](https://github.com/n8n-io/n8n/commit/fcc4ef59d6af9cc250fadf455e167835c488a91e))
* **core:** Expose agent test execution through MCP ([#35744](https://github.com/n8n-io/n8n/issues/35744)) ([6f761ad](https://github.com/n8n-io/n8n/commit/6f761ad91734e9f7f06c276ca7dcf0540e897d8e))
* **core:** Expose MCP registry search to the assistant as a tool ([#35318](https://github.com/n8n-io/n8n/issues/35318)) ([59278a7](https://github.com/n8n-io/n8n/commit/59278a791f769f64f2e873a146a6c669aa9a0645))
* **core:** Extend n8n Connect to Agent Builder node tools ([#35402](https://github.com/n8n-io/n8n/issues/35402)) ([104bc5c](https://github.com/n8n-io/n8n/commit/104bc5c8d92ab5ba05aac1eeaf3dfe17b7c2d520))
* **core:** Gate n8n Connect with instance config ([#35007](https://github.com/n8n-io/n8n/issues/35007)) ([935cfb9](https://github.com/n8n-io/n8n/commit/935cfb912e418ce312d6ebc77517883435465c0e))
* **core:** Include SDK language rules in the MCP workflow SDK reference ([#35183](https://github.com/n8n-io/n8n/issues/35183)) ([1c6e992](https://github.com/n8n-io/n8n/commit/1c6e99272a0698f86cf500e2b1f57ae4c8bf3a44))
* **core:** Make Azure Key Vault provider endpoints configurable ([#35214](https://github.com/n8n-io/n8n/issues/35214)) ([82e22c9](https://github.com/n8n-io/n8n/commit/82e22c954c768ae195909a168e589bdee01404b2))
* **core:** Make the vm expression engine the default ([#35738](https://github.com/n8n-io/n8n/issues/35738)) ([98e0952](https://github.com/n8n-io/n8n/commit/98e0952c367e848cd6318a7683729b3941508771))
* **core:** Migrate the instance MCP server to the MCP TypeScript SDK v2 ([#35467](https://github.com/n8n-io/n8n/issues/35467)) ([74631f7](https://github.com/n8n-io/n8n/commit/74631f72c056cbf090e892f8c85c7bb86869425f))
* **core:** Persist agent session timelines during execution ([#35490](https://github.com/n8n-io/n8n/issues/35490)) ([1546c95](https://github.com/n8n-io/n8n/commit/1546c954856cbdd8f9e6f30f9b58a60bce46bf11))
* **core:** Reconcile tag ids on name collision when importing packages ([#35224](https://github.com/n8n-io/n8n/issues/35224)) ([c9eeb2e](https://github.com/n8n-io/n8n/commit/c9eeb2e180c4c5c91affba90265ae06a364dbbd6))
* **core:** Support the MCP 2026-07-28 discovery handshake on the instance server ([#35470](https://github.com/n8n-io/n8n/issues/35470)) ([145863c](https://github.com/n8n-io/n8n/commit/145863c58e56bd886feee110e202091649e643c9))
* **core:** Surface decision eligibility on the review detail ([#35391](https://github.com/n8n-io/n8n/issues/35391)) ([5e4a20d](https://github.com/n8n-io/n8n/commit/5e4a20d031196b78365881a5d2c7e87372a0663f))
* **core:** Teach the AI assistant to organise workflows into node groups ([#35646](https://github.com/n8n-io/n8n/issues/35646)) ([5bf5f38](https://github.com/n8n-io/n8n/commit/5bf5f385fd675e719702187b6de0a5421a0c0388))
* **core:** Warn on startup when Postgres is below the supported range ([#35495](https://github.com/n8n-io/n8n/issues/35495)) ([c72b14f](https://github.com/n8n-io/n8n/commit/c72b14f69cea5b249ec28528b3a0f98408cd5e6c))
* **editor:** Add Changes diff tab to workflow review detail pane ([#35431](https://github.com/n8n-io/n8n/issues/35431)) ([6c8197e](https://github.com/n8n-io/n8n/commit/6c8197e379eebe8096773fbdeb730ec1a9b1323b))
* **editor:** Add ChatMessage to @n8n/design-system ([#35461](https://github.com/n8n-io/n8n/issues/35461)) ([38f0691](https://github.com/n8n-io/n8n/commit/38f069172c7815fdcb8f2e8dde688e4999c9303a))
* **editor:** Add more Markdown editor toolbar options ([#35630](https://github.com/n8n-io/n8n/issues/35630)) ([32db83c](https://github.com/n8n-io/n8n/commit/32db83c6b6cab046180adefadf7bcd0e8c884722))
* **editor:** Add review detail metadata ([#35638](https://github.com/n8n-io/n8n/issues/35638)) ([cf13bc0](https://github.com/n8n-io/n8n/commit/cf13bc02d24b79dd8b1d5823e21cd595fa30db03))
* **editor:** Allow adding a workflow version description when submitting for review ([#35640](https://github.com/n8n-io/n8n/issues/35640)) ([5f0e464](https://github.com/n8n-io/n8n/commit/5f0e46463097a7c0cf8455f55713fa953f688eda))
* **editor:** Disable command bar in canvas-only mode ([#35542](https://github.com/n8n-io/n8n/issues/35542)) ([fda3301](https://github.com/n8n-io/n8n/commit/fda3301317a4813424a07a28d0468babb780ccf1))
* **editor:** Enable enhanced HITL Slack/Telegram ([#35411](https://github.com/n8n-io/n8n/issues/35411)) ([5d501e3](https://github.com/n8n-io/n8n/commit/5d501e370a15619f50f0ab67728d8b1e5416de72))
* **editor:** Improve agent file upload visibility ([#35513](https://github.com/n8n-io/n8n/issues/35513)) ([75a0cdf](https://github.com/n8n-io/n8n/commit/75a0cdfd2016b3ca0ab11460e1052dcc4719532e))
* **editor:** Name the pinned workflow version when submitting for review ([#35566](https://github.com/n8n-io/n8n/issues/35566)) ([fe37fe3](https://github.com/n8n-io/n8n/commit/fe37fe3b97b0751136f4e3c2b29acaf2aee4ac20))
* **editor:** Restore one-click connector for ChatGPT MCP client ([#35396](https://github.com/n8n-io/n8n/issues/35396)) ([2058a16](https://github.com/n8n-io/n8n/commit/2058a165203edb378c0e9893a2a3bd144b01818b))
* Extract the get-n8n stack definition into a versioned standalone file ([#35620](https://github.com/n8n-io/n8n/issues/35620)) ([8a1645a](https://github.com/n8n-io/n8n/commit/8a1645aab664bdf454ec8124ba51ce06639d78dc))
* **Form Trigger Node:** Add credential connect UI for end-user credentials ([#35347](https://github.com/n8n-io/n8n/issues/35347)) ([a89dc5d](https://github.com/n8n-io/n8n/commit/a89dc5dc353d0d7bb72086a029059fa0e3ace9b6))
* **GitHub Node:** Add timeout option to Dispatch and Wait for Completion ([#35837](https://github.com/n8n-io/n8n/issues/35837)) ([a6f07db](https://github.com/n8n-io/n8n/commit/a6f07dbba00c6a31758cc3ac8ba2a5e29039f475))
* **Kafka Node:** Add compression options to version 2 ([#35727](https://github.com/n8n-io/n8n/issues/35727)) ([21b52fb](https://github.com/n8n-io/n8n/commit/21b52fbddf6acc639b872fd1a9109e4275dcc3b9))
* **Kafka Node:** Add version 2 to send messages via the new Kafka library ([#35450](https://github.com/n8n-io/n8n/issues/35450)) ([d2901bd](https://github.com/n8n-io/n8n/commit/d2901bd02d6163afc49ed35dc07351fdaf155c2f))
* Load Instance AI models dynamically ([#35927](https://github.com/n8n-io/n8n/issues/35927)) ([269a45d](https://github.com/n8n-io/n8n/commit/269a45d7dd1b9c0a50ab1335bfb455b09475f87d))
* **Merge Node:** Add "Append" and "Combine" search aliases ([#35709](https://github.com/n8n-io/n8n/issues/35709)) ([cc98292](https://github.com/n8n-io/n8n/commit/cc98292be0826e67b508d8b26c6e37e20a7eae00))
* **Salesforce Trigger Node:** Add OAuth2 JWT authentication support ([#32257](https://github.com/n8n-io/n8n/issues/32257)) ([eda2496](https://github.com/n8n-io/n8n/commit/eda249642c574d2f6b4ed4c20a98de969b12831e))
* **Slack Node:** Use Real-time Search API for message search ([#35291](https://github.com/n8n-io/n8n/issues/35291)) ([204f346](https://github.com/n8n-io/n8n/commit/204f3467206ecd1efb5ac854a2607051dcd3391a))
### Performance Improvements
* **core:** Resolve webhook description fields via declared native resolvers ([#35404](https://github.com/n8n-io/n8n/issues/35404)) ([18c2d71](https://github.com/n8n-io/n8n/commit/18c2d715e36f26cc8dcae0c409b3d075bb07b433))
# [2.34.0](https://github.com/n8n-io/n8n/compare/n8n@2.33.0...n8n@2.34.0) (2026-08-04)

View File

@ -19,6 +19,10 @@ Great that you are here and you want to contribute to n8n
- [Actual n8n setup](#actual-n8n-setup)
- [Start](#start)
- [Development cycle](#development-cycle)
- [Stacked pull requests](#stacked-pull-requests)
- [Enabling `gh stack`](#enabling-gh-stack)
- [Splitting work into a stack yourself](#splitting-work-into-a-stack-yourself)
- [Asking an agent to do it](#asking-an-agent-to-do-it)
- [Community PR Guidelines](#community-pr-guidelines)
- [**1. Change Request/Comment**](#1-change-requestcomment)
- [**2. General Requirements**](#2-general-requirements)
@ -224,9 +228,10 @@ pnpm exec dotenvx run -f .env.local -- pnpm dev:be
## Development cycle
While iterating on n8n modules code, you can run `pnpm dev`. It will then
automatically build your code, restart the backend and refresh the frontend
(editor-ui) on every change you make.
While iterating on n8n modules code, run `pnpm dev:be` for the backend and
`pnpm dev:fe:editor` for the editor UI. They build your code, restart the
backend, and refresh the frontend on each change you make. The root `pnpm dev`
does not exist: it prints a notice and exits with code 0.
Given the size of the code base and the number of modules, we recommend only watching the modules you're
actively working on.
@ -337,7 +342,7 @@ packages/cli$ N8N_USER_FOLDER=~/.n8n4/ pnpm run dev
When developing custom nodes or credentials, you can enable hot reload to automatically detect changes without restarting the server by setting
```bash
N8N_DEV_RELOAD=true pnpm dev
N8N_DEV_RELOAD=true pnpm dev:be
```
**Performance considerations:**
@ -417,7 +422,8 @@ For manual testing of the event bus with syslog (TCP or UDP), see [packages/cli/
### Performance Considerations
The full development mode (`pnpm dev`) runs multiple processes in parallel:
Full development mode (`pnpm dev:be` with `pnpm dev:fe:editor`) runs multiple
processes in parallel:
1. **TypeScript compilation** for each package
2. **File watchers** monitoring source files
@ -440,6 +446,75 @@ The full development mode (`pnpm dev`) runs multiple processes in parallel:
---
### Stacked pull requests
A large change is easier to review as a chain of small PRs, each building on the
one below it — a *stack*. The bottom PR targets `master`, every PR above it
targets the branch below, so each review shows only that layer's diff. GitHub
links them together, and the stack merges bottom to top.
Reach for a stack when the work has natural layers (schema → API → UI), or when
a single PR would grow past the size guidance in
[Community PR Guidelines](#community-pr-guidelines).
#### Enabling `gh stack`
Stacked PRs are enabled on `n8n-io/n8n`. On your machine you need the
[GitHub CLI](https://cli.github.com/) v2+, authenticated (`gh auth login`), plus:
```bash
gh extension install github/gh-stack
git config rerere.enabled true # remember conflict resolutions across rebases
git config remote.pushDefault origin # only needed if you have several remotes
```
#### Splitting work into a stack yourself
```bash
gh stack init ligo-123-db # first branch, off master
git add -p && git commit # commit only what belongs in this layer
gh stack add ligo-123-api # next branch, stacked on the previous one
git add -p && git commit
gh stack add ligo-123-ui
git add -p && git commit
gh stack submit --auto # push every branch and open a draft PR per branch
```
From there:
- `gh stack up` / `down` / `top` / `bottom` move between layers.
- Need a change in a lower layer? Go there, commit it, then
`gh stack rebase --upstack` to replay the layers above.
- `gh stack sync` after `master` moves or a lower PR merges (`--prune` also
deletes local branches whose PR is merged).
- `gh stack merge --yes --squash` merges the stack — `gh pr merge` does not work
on stacked PRs.
Without the extension you can do the same by hand: create the branches yourself
and open each PR with `gh pr create --base <branch-below>`. You then own the
rebasing every time a lower branch changes.
Two things to watch for in this repo:
- `--auto` builds PR titles from your commit subjects (or the branch name), which
usually will not pass our
[PR title conventions](.github/pull_request_title_conventions.md). Write
conventional commit subjects, or fix the titles with `gh pr edit` before asking
for review. Each PR still needs its own description and tests — it is reviewed
on its own.
- Every PR in the stack runs its own CI, so make each layer worth a full run.
#### Asking an agent to do it
The repo ships a [`gh-stack` skill](.agents/skills/gh-stack/SKILL.md) that
teaches agents the non-interactive command rules, so you can just ask:
*"split this branch into a stack: db layer, API layer, UI layer"*, or
*"add the frontend changes as a new layer on top and resubmit"*. The setup above
is still on you — the extension and the git config are per-machine, and the
agent cannot install them for you.
---
### Community PR Guidelines
We read and review every pull request by hand. These guidelines keep that sustainable, and they help you avoid building something we cannot accept. The fastest route to a merge is to agree on the change with us before you write code.

View File

@ -42,6 +42,14 @@
]
},
"organizeImports": { "enabled": false },
// Every `tsconfig*.json` is JSONC. Biome only infers that from the exact name `tsconfig.json`,
// so variants like `tsconfig.frontend-module.json` need it spelled out.
"overrides": [
{
"include": ["**/tsconfig*.json"],
"json": { "parser": { "allowComments": true } }
}
],
"linter": {
"enabled": false
},

View File

@ -94,9 +94,11 @@ parse_args() {
check_deps() {
command -v docker >/dev/null 2>&1 || fail "Docker is not installed.
Install it first:
Linux: https://docs.docker.com/engine/install/
Linux / WSL: curl -fsSL https://get.docker.com | sh
then: sudo usermod -aG docker \$USER && re-open your shell
macOS: https://docs.docker.com/desktop/setup/install/mac-install/
Windows (WSL): https://docs.docker.com/desktop/setup/install/windows-install/
Windows: https://docs.docker.com/desktop/setup/install/windows-install/
(enable your distro under Settings > Resources > WSL integration)
Podman, Colima and other Docker-compatible engines work too: install the
'docker' CLI with the compose plugin and point DOCKER_HOST at their socket."
@ -141,19 +143,22 @@ http_get() {
}
port_in_use() {
# In use only when something actually answers. Refusals AND timeouts count
# as free: Windows firewalls/WSL drop instead of refusing, which made the
# old "anything but refused" check a false positive. --noproxy keeps a
# corporate proxy from answering on 127.0.0.1's behalf. Ambiguous cases
# fall through to the container healthcheck, which surfaces real conflicts.
if command -v curl >/dev/null 2>&1; then
# Any HTTP response (even an error status) means something is listening;
# only "connection refused" (rc 7) means the port is free.
rc=0
curl -s -o /dev/null --max-time 2 "http://127.0.0.1:${N8N_PORT}/" 2>/dev/null || rc=$?
[ "$rc" -ne 7 ]
curl -s -o /dev/null --noproxy '*' --max-time 2 "http://127.0.0.1:${N8N_PORT}/" 2>/dev/null || rc=$?
# rc 0 = HTTP response; 52/56 = connection accepted but no/broken reply.
[ "$rc" -eq 0 ] || [ "$rc" -eq 52 ] || [ "$rc" -eq 56 ]
elif command -v wget >/dev/null 2>&1; then
# wget rc 4 = network failure (connection refused) = port free.
rc=0
wget -q -O /dev/null -T 2 "http://127.0.0.1:${N8N_PORT}/" 2>/dev/null || rc=$?
[ "$rc" -ne 4 ]
wget -q -O /dev/null --no-proxy -T 2 "http://127.0.0.1:${N8N_PORT}/" 2>/dev/null || rc=$?
# rc 0 = OK response; 8 = server issued an error response.
[ "$rc" -eq 0 ] || [ "$rc" -eq 8 ]
else
# No way to check; let the healthcheck surface conflicts later.
return 1
fi
}
@ -207,18 +212,12 @@ N8N_RUNNERS_MODE=external
N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
N8N_RUNNERS_AUTH_TOKEN=$(gen_secret)
# Instance AI (optional): fill this in to enable the AI assistant in n8n.
# n8n works fine without it. Setup guide:
# https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-ai-assistant
N8N_INSTANCE_AI_MODEL_API_KEY=
# Web search for the AI assistant runs through the bundled SearXNG service.
# Optionally set a Brave Search API key instead — it takes priority.
INSTANCE_AI_BRAVE_SEARCH_API_KEY=
N8N_INSTANCE_AI_SEARXNG_URL=http://searxng:8080
SEARXNG_SECRET=$(gen_secret)
N8N_INSTANCE_AI_MODEL=anthropic/claude-opus-4-8
N8N_INSTANCE_AI_SANDBOX_ENABLED=true
N8N_INSTANCE_AI_SANDBOX_PROVIDER=n8n-sandbox
N8N_INSTANCE_AI_SANDBOX_IMAGE=ghcr.io/n8n-io/n8n-sandbox-service-sandbox:latest
@ -372,9 +371,6 @@ To uninstall: docker compose -f ${N8N_DIR}/compose.yml down -v # -v DELETES al
Security notes:
- Only port ${N8N_PORT} (n8n) should ever be reachable from the internet.
- The sandbox runner is privileged Docker-in-Docker — never publish its ports.
- The AI assistant stays disabled until you set N8N_INSTANCE_AI_MODEL_API_KEY
in ${N8N_DIR}/.env — see
https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-ai-assistant
This setup is meant to try n8n locally. For production (TLS, Postgres, queue
mode) see ${DOCS_HOSTING_URL}

View File

@ -1,7 +1,7 @@
# Pinned to a multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds.
# The tag is the human-readable version; Docker resolves by the digest. Bump the tag and
# digest together when updating the base image.
FROM dhi.io/node:24.18.1-alpine3.24-dev@sha256:074b5aa92e1ce74cd575214211bbcb8d1550710aec59558da734a67d84d31679
# CI passes one DHI reference per published Node version (see the matrix in
# build-base-image.yml). The default keeps plain `docker build` working.
ARG DHI_REF=dhi.io/node:24.18.1-alpine3.24-dev@sha256:074b5aa92e1ce74cd575214211bbcb8d1550710aec59558da734a67d84d31679
FROM ${DHI_REF}
# Install all dependencies in a single layer to minimize image size
RUN apk add --no-cache busybox-binsh && \
@ -22,7 +22,8 @@ RUN apk add --no-cache busybox-binsh && \
tini \
tzdata \
ca-certificates \
libc6-compat && \
libc6-compat \
librdkafka && \
# Cleanup
rm -rf /tmp/* /root/.npm /root/.cache/node /opt/yarn* && \
apk del apk-tools

View File

@ -1,38 +1,55 @@
ARG NODE_VERSION=24.18.1
ARG NODE_VERSION=26.5.1
ARG N8N_VERSION=snapshot
# Builder stage exists because the runtime base image has no toolchain.
# Pinned to multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds.
# Bump the digest together with the tag when updating the base image.
# Digest pins to node:24.18.1-alpine3.24 (Node 24.18.1, Alpine 3.24).
FROM node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS builder
COPY ./compiled /usr/local/lib/node_modules/n8n
RUN apk add --no-cache python3 make g++ && \
cd /usr/local/lib/node_modules/n8n && \
npm rebuild sqlite3 && \
# Rebuild isolated-vm from source. node-gyp-build's musl detection relies
# on /etc/alpine-release which DHI Alpine omits, so the bundled prebuilt
# loader picks the glibc binary and segfaults in musl pthread paths.
# Removing prebuilds and invoking node-gyp directly avoids npm rebuild's
# double-build behavior (built-in node-gyp + install-script node-gyp)
# which races on dep-file state.
# Use npm's bundled node-gyp rather than `npx node-gyp` so the toolchain
# version is fixed by the pinned image digest instead of being fetched
# from the registry at build time.
rm -rf node_modules/isolated-vm/prebuilds && \
cd node_modules/isolated-vm && \
# The digest pins node:26.5.1-alpine3.24 for amd64 and arm64. Change the tag and the digest together.
ARG BUILDER_IMAGE=node:26.5.1-alpine3.24@sha256:233761595746769ebfdb6090f44fc7cdf818ae0ce62d2b37e0367723b9823e36
ARG RUNTIME_IMAGE=n8nio/base:26.5.1@sha256:1b0bca5c94bbd04ad2120b9e9892a8bc717a1d88efa76fefe452c59bdc611d25
# The runtime base has no compiler, so this stage supplies one.
FROM ${BUILDER_IMAGE} AS toolchain
RUN apk add --no-cache python3 make g++
# Build isolated-vm in a separate stage. The only cache input is the module source.
# Below `COPY ./compiled`, each application change starts a new compile.
FROM toolchain AS native-builder
# `node_modules/isolated-vm` is a pnpm symlink, so copy the target directory.
# The wildcard tracks the version in the catalog.
COPY ./compiled/node_modules/.pnpm/isolated-vm@*/node_modules/isolated-vm /build/isolated-vm
WORKDIR /build/isolated-vm
# node-gyp-build reads /etc/alpine-release to detect musl. DHI Alpine has no such file,
# so the loader takes the glibc prebuild and crashes. Build from source instead.
# Call node-gyp directly: `npm rebuild` starts two builds that conflict, and
# `npx node-gyp` downloads a different version at build time.
RUN rm -rf prebuilds && \
node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild --release -j max
# Pinned to multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds.
# n8nio/base is republished by a separate workflow (docker/images/n8n-base) on every
# base change, so a base rebuild does not reach this image until the digest is
# manually re-pinned here. Bump the digest together with the tag whenever the base
# image is intentionally updated.
# Digest pins to n8nio/base:24.18.1 (Node 24.18.1, Alpine 3.24).
FROM n8nio/base:24.18.1@sha256:f7a23e449f093b8b203467919e969ca35d12ba959442aadc1f69bb174b160ad2
FROM toolchain AS builder
COPY ./compiled /usr/local/lib/node_modules/n8n
# Call node-gyp directly rather than `npm rebuild sqlite3`: sqlite3 is reachable through
# several symlinks in the pnpm tree, and npm runs one install script per link
# concurrently in the same store directory, where they collide on build/node_gyp_bins
# and on each other's make output. npm's bundled node-gyp also fixes the toolchain
# version to the pinned image digest instead of fetching it at build time.
RUN cd /usr/local/lib/node_modules/n8n/node_modules/sqlite3 && \
node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild --release
# The workspace install cannot build this binding, because CI installs on a Node version
# Confluent ships no prebuild for, so `./compiled` arrives without one. Compile it here
# and link against the runtime base's librdkafka. See: https://github.com/confluentinc/confluent-kafka-javascript/issues/397
RUN apk add --no-cache librdkafka-dev && \
cd /usr/local/lib/node_modules/n8n/node_modules/.pnpm/@confluentinc+kafka-javascript@*/node_modules/@confluentinc/kafka-javascript && \
BUILD_LIBRDKAFKA=0 node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild --release
COPY --from=native-builder /build/isolated-vm/build/Release/isolated_vm.node \
/usr/local/lib/node_modules/n8n/node_modules/isolated-vm/build/Release/isolated_vm.node
# node-gyp-build finds build/Release first. Delete the prebuilds to remove the glibc fallback.
RUN rm -rf /usr/local/lib/node_modules/n8n/node_modules/isolated-vm/prebuilds
# A separate workflow builds the base image, so a new base reaches this image only
# when you change the reference here (see BUILDER_IMAGE/RUNTIME_IMAGE above).
FROM ${RUNTIME_IMAGE}
ARG N8N_VERSION
ARG N8N_RELEASE_TYPE=dev
ARG IMAGE_DESCRIPTION="Workflow Automation Tool"
ENV NODE_ENV=production
ENV N8N_RELEASE_TYPE=${N8N_RELEASE_TYPE}
ENV SHELL=/bin/sh
@ -42,7 +59,7 @@ WORKDIR /home/node
COPY --from=builder /usr/local/lib/node_modules/n8n /usr/local/lib/node_modules/n8n
COPY docker/images/n8n/docker-entrypoint.sh /
# The Alpine 3.24 base has no /usr/local/bin (node lives in /usr/bin); create it for the symlink.
# The base keeps node in /usr/bin and has no /usr/local/bin. Make that directory for the symlink.
RUN mkdir -p /usr/local/bin && \
ln -s /usr/local/lib/node_modules/n8n/bin/n8n /usr/local/bin/n8n && \
mkdir -p /home/node/.n8n && \
@ -54,7 +71,7 @@ USER node
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
LABEL org.opencontainers.image.title="n8n" \
org.opencontainers.image.description="Workflow Automation Tool" \
org.opencontainers.image.description="${IMAGE_DESCRIPTION}" \
org.opencontainers.image.source="https://github.com/n8n-io/n8n" \
org.opencontainers.image.url="https://n8n.io" \
org.opencontainers.image.version=${N8N_VERSION}

View File

@ -146,6 +146,10 @@ docker pull docker.n8n.io/n8nio/n8n:0.220.1
docker pull docker.n8n.io/n8nio/n8n:next
```
### About `-pc` tags
Tags with a `-pc` suffix (for example `n8nio/n8n:2.36.0-pc`) are a pointer-compressed variant, internal to n8n Cloud. They carry no support or stability guarantees and can change or disappear without notice. Use the regular tags instead.
Stop the container and start it again:
1. Get the container ID:

View File

@ -0,0 +1,103 @@
# Node.js compiled with V8 pointer compression, for the n8n `-pc` image variant.
#
# No official pointer-compressed Node binaries exist (nodejs.org publishes none,
# and Docker Hub node images are stock), so this image compiles Node from the
# GPG-verified source tarball, following the source-build path of the official
# docker-node Alpine recipe with one added configure flag.
#
# Two published targets:
# runtime (default) - n8nio/base with our compiled Node swapped in
# dev - plain Alpine with apk usable, for the n8n builder
# stage, which must compile native addons against this
# Node's headers
ARG NODE_VERSION=26.5.1
# Compiling V8 needs 1.5-2 GB of memory per parallel job. Lower this on
# builders whose memory-to-core ratio cannot cover all cores.
ARG MAKE_JOBS=
# Pinned to a multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds.
# Bump the tag and digest together when updating.
FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS builder
ARG NODE_VERSION
ARG MAKE_JOBS
RUN apk add --no-cache --virtual .build-deps \
binutils-gold \
curl \
g++ \
gcc \
gnupg \
libgcc \
linux-headers \
make \
python3 \
py-setuptools \
rust \
cargo \
&& OPENSSL_ARCH= && alpineArch="$(apk --print-arch)" \
&& case "${alpineArch##*-}" in \
x86_64) OPENSSL_ARCH=linux-x86_64;; \
aarch64) OPENSSL_ARCH=linux-aarch64;; \
*) echo "unsupported architecture: $alpineArch" && exit 1;; \
esac \
&& export GNUPGHOME="$(mktemp -d)" \
# gpg keys listed at https://github.com/nodejs/node#release-keys
&& for key in \
5BE8A3F6C8A5C01D106C0AD820B1A390B168D356 \
DD792F5973C6DE52C432CBDAC77ABFA00DDBF2B7 \
CC68F5A3106FF448322E48ED27F5E38D5B0A215F \
8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600 \
890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4 \
C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C \
108F52B48DB57BB0CC439B2997B01419BD92F80A \
A363A499291CBBC940DD62E41F10027AF002F8B0 \
655F3B5C1FB3FA8D1A0CA6BDE4A7D232B936D2FD \
; do \
{ gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$key" && gpg --batch --fingerprint "$key"; } || \
{ gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" && gpg --batch --fingerprint "$key"; } ; \
done \
&& curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION.tar.xz" \
&& curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \
&& gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \
&& gpgconf --kill all \
&& rm -rf "$GNUPGHOME" \
&& grep " node-v$NODE_VERSION.tar.xz\$" SHASUMS256.txt | sha256sum -c - \
&& tar -xf "node-v$NODE_VERSION.tar.xz" \
&& cd "node-v$NODE_VERSION" \
&& ./configure --experimental-enable-pointer-compression \
&& make -j"${MAKE_JOBS:-$(getconf _NPROCESSORS_ONLN)}" V= \
&& make install DESTDIR=/node-install \
# Remove unused OpenSSL headers to save ~34MB. See https://github.com/nodejs/node/issues/46451
&& find /node-install/usr/local/include/node/openssl/archs -mindepth 1 -maxdepth 1 ! -name "$OPENSSL_ARCH" -exec rm -rf {} \;
FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS base
RUN addgroup -g 1000 node \
&& adduser -u 1000 -G node -s /bin/sh -D node \
&& apk add --no-cache libstdc++
COPY --from=builder /node-install/usr/local /usr/local
RUN node --version && npm --version \
&& node -e "if (!process.config.variables.v8_enable_pointer_compression) { throw new Error('pointer compression is not enabled') }"
FROM base AS dev
# node-gyp must compile native addons against this build's headers so they pick
# up the pointer compression defines, never against downloaded stock headers.
ENV npm_config_nodedir=/usr/local
# ---------------------------------------------------------------------------
# `n8nio/base:26.x` with one change, namely the stock Node files (node, npm,
# npx, corepack) are swapped for the pointer-compressed build's files.
# Pinned to a multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds.
# Bump the tag and digest together when updating.
FROM n8nio/base:26.5.1@sha256:1b0bca5c94bbd04ad2120b9e9892a8bc717a1d88efa76fefe452c59bdc611d25 AS runtime
RUN rm -f /usr/bin/node /usr/bin/nodejs /usr/bin/npm /usr/bin/npx /usr/bin/corepack /usr/local/bin/node
COPY --from=builder /node-install/usr/local /usr/local
RUN node --version \
&& node -e "if (!process.config.variables.v8_enable_pointer_compression) { throw new Error('pointer compression is not enabled') }"
# The compiled build lives at /usr/local/bin, which is what the cloud launch
# and AppArmor profile expect. Keep the stock /usr/bin path working for
# anything that references it.
RUN ln -sf /usr/local/bin/node /usr/bin/node

View File

@ -1,4 +1,5 @@
ARG NODE_VERSION=24.18.1
ARG NODE_VERSION=26.5.1
ARG PNPM_VERSION=10.32.1
ARG PYTHON_VERSION=3.13
# ==============================================================================
@ -10,16 +11,15 @@ COPY ./dist/task-runner-javascript /app/task-runner-javascript
WORKDIR /app/task-runner-javascript
# Pin pnpm to the repo's packageManager version. `pnpm deploy` drops the
# packageManager field from the deployed package.json, so a bare
# `corepack enable pnpm` resolves to corepack's bundled latest (pnpm 11.x).
# pnpm 11 defaults minimumReleaseAge to 1440 (24h), so the `pnpm add` below
# re-resolves the deployed graph and rejects any dependency published less
# than a day ago — e.g. a first-party @n8n_io/ai-assistant-sdk bump landed
# within hours of its npm publish. This container step only re-resolves an
# already-vetted, already-pinned dependency set; the real supply-chain age
# policy is enforced by the host install (pnpm-workspace.yaml + SafeChain).
ARG PNPM_VERSION=10.32.1
RUN corepack enable pnpm && corepack prepare "pnpm@${PNPM_VERSION}" --activate
# packageManager field from the deployed package.json, so nothing else here pins it,
# and pnpm 11.x defaults minimumReleaseAge to 1440 (24h), which would make the
# `pnpm add` below reject any dependency published less than a day ago — e.g. a
# first-party @n8n_io/ai-assistant-sdk bump landed within hours of its npm publish.
# This container step only re-resolves an already-vetted, already-pinned dependency
# set; the real supply-chain age policy is enforced by the host install
# (pnpm-workspace.yaml + SafeChain).
ARG PNPM_VERSION
RUN npm i -g "pnpm@${PNPM_VERSION}"
# Remove `catalog` and `workspace` references from package.json to allow `pnpm add` in extended images
RUN node -e "const pkg = require('./package.json'); \
@ -113,6 +113,8 @@ RUN set -e; \
# STAGE 4: Node alpine base for JS task runner
# ==============================================================================
FROM node:${NODE_VERSION}-alpine3.24 AS node-alpine
ARG PNPM_VERSION
RUN npm i -g "pnpm@${PNPM_VERSION}"
# ==============================================================================
# STAGE 5: Runtime
@ -138,10 +140,10 @@ RUN apk add --no-cache ca-certificates tini libstdc++ libc6-compat && \
pip install --no-cache-dir "pip>=26.0" && \
apk del apk-tools
# Bring corepack and pnpm over, to make the image easier to extend
COPY --from=node-alpine /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/corepack
RUN ln -s ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack && \
ln -s ../lib/node_modules/corepack/dist/pnpm.js /usr/local/bin/pnpm
# Bring pnpm over, to make the image easier to extend. Node no longer bundles
# corepack, and this runtime has no npm, so pnpm is copied in as a package.
COPY --from=node-alpine /usr/local/lib/node_modules/pnpm /usr/local/lib/node_modules/pnpm
RUN ln -s ../lib/node_modules/pnpm/bin/pnpm.cjs /usr/local/bin/pnpm
RUN addgroup -g 1000 -S runner \
&& adduser -u 1000 -S -G runner -h /home/runner -D runner

View File

@ -12,7 +12,8 @@
# - Uses distroless nonroot user (UID 65532)
# ==============================================================================
ARG NODE_VERSION=24.18.1
ARG NODE_VERSION=26.5.1
ARG PNPM_VERSION=10.32.1
ARG PYTHON_VERSION=3.13
@ -25,16 +26,15 @@ COPY ./dist/task-runner-javascript /app/task-runner-javascript
WORKDIR /app/task-runner-javascript
# Pin pnpm to the repo's packageManager version. `pnpm deploy` drops the
# packageManager field from the deployed package.json, so a bare
# `corepack enable pnpm` resolves to corepack's bundled latest (pnpm 11.x).
# pnpm 11 defaults minimumReleaseAge to 1440 (24h), so the `pnpm add` below
# re-resolves the deployed graph and rejects any dependency published less
# than a day ago — e.g. a first-party @n8n_io/ai-assistant-sdk bump landed
# within hours of its npm publish. This container step only re-resolves an
# already-vetted, already-pinned dependency set; the real supply-chain age
# policy is enforced by the host install (pnpm-workspace.yaml + SafeChain).
ARG PNPM_VERSION=10.32.1
RUN corepack enable pnpm && corepack prepare "pnpm@${PNPM_VERSION}" --activate
# packageManager field from the deployed package.json, so nothing else here pins it,
# and pnpm 11.x defaults minimumReleaseAge to 1440 (24h), which would make the
# `pnpm add` below reject any dependency published less than a day ago — e.g. a
# first-party @n8n_io/ai-assistant-sdk bump landed within hours of its npm publish.
# This container step only re-resolves an already-vetted, already-pinned dependency
# set; the real supply-chain age policy is enforced by the host install
# (pnpm-workspace.yaml + SafeChain).
ARG PNPM_VERSION
RUN npm i -g "pnpm@${PNPM_VERSION}"
# Remove `catalog` and `workspace` references from package.json to allow `pnpm add`
RUN node -e "const pkg = require('./package.json'); \

View File

@ -16,14 +16,14 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.agent_eval_rating](public.agent_eval_rating.md) | 8 | | BASE TABLE |
| [public.agent_eval_result](public.agent_eval_result.md) | 15 | | BASE TABLE |
| [public.agent_eval_run](public.agent_eval_run.md) | 14 | | BASE TABLE |
| [public.agent_execution](public.agent_execution.md) | 20 | | BASE TABLE |
| [public.agent_execution](public.agent_execution.md) | 21 | | BASE TABLE |
| [public.agent_execution_threads](public.agent_execution_threads.md) | 17 | | BASE TABLE |
| [public.agent_files](public.agent_files.md) | 10 | | BASE TABLE |
| [public.agent_history](public.agent_history.md) | 9 | | BASE TABLE |
| [public.agent_task_definition](public.agent_task_definition.md) | 7 | | BASE TABLE |
| [public.agent_task_definition](public.agent_task_definition.md) | 8 | | BASE TABLE |
| [public.agent_task_run_lock](public.agent_task_run_lock.md) | 6 | | BASE TABLE |
| [public.agent_task_snapshot](public.agent_task_snapshot.md) | 8 | | BASE TABLE |
| [public.agents](public.agents.md) | 13 | | BASE TABLE |
| [public.agent_task_snapshot](public.agent_task_snapshot.md) | 9 | | BASE TABLE |
| [public.agents](public.agents.md) | 14 | | BASE TABLE |
| [public.agents_memory_entries](public.agents_memory_entries.md) | 13 | | BASE TABLE |
| [public.agents_memory_entry_cursors](public.agents_memory_entry_cursors.md) | 6 | | BASE TABLE |
| [public.agents_memory_entry_locks](public.agents_memory_entry_locks.md) | 6 | | BASE TABLE |
@ -63,6 +63,8 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.execution_metadata](public.execution_metadata.md) | 4 | | BASE TABLE |
| [public.folder](public.folder.md) | 6 | | BASE TABLE |
| [public.folder_tag](public.folder_tag.md) | 2 | | BASE TABLE |
| [public.git_connection](public.git_connection.md) | 13 | | BASE TABLE |
| [public.git_connection_project](public.git_connection_project.md) | 4 | | BASE TABLE |
| [public.insights_by_period](public.insights_by_period.md) | 6 | | BASE TABLE |
| [public.insights_metadata](public.insights_metadata.md) | 5 | | BASE TABLE |
| [public.insights_raw](public.insights_raw.md) | 5 | | BASE TABLE |
@ -92,6 +94,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.oauth_clients](public.oauth_clients.md) | 10 | | BASE TABLE |
| [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) | 7 | | BASE TABLE |
| [public.oauth_user_consents](public.oauth_user_consents.md) | 5 | | BASE TABLE |
| [public.poller_state](public.poller_state.md) | 7 | | BASE TABLE |
| [public.processed_data](public.processed_data.md) | 5 | | BASE TABLE |
| [public.project](public.project.md) | 9 | | BASE TABLE |
| [public.project_relation](public.project_relation.md) | 5 | | BASE TABLE |
@ -122,14 +125,16 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.workflow_dependency](public.workflow_dependency.md) | 9 | | BASE TABLE |
| [public.workflow_entity](public.workflow_entity.md) | 20 | | BASE TABLE |
| [public.workflow_history](public.workflow_history.md) | 11 | | BASE TABLE |
| [public.workflow_publication_outbox](public.workflow_publication_outbox.md) | 7 | | BASE TABLE |
| [public.workflow_publication_outbox](public.workflow_publication_outbox.md) | 8 | | BASE TABLE |
| [public.workflow_publication_trigger_status](public.workflow_publication_trigger_status.md) | 8 | | BASE TABLE |
| [public.workflow_publish_history](public.workflow_publish_history.md) | 6 | | BASE TABLE |
| [public.workflow_published_version](public.workflow_published_version.md) | 4 | | BASE TABLE |
| [public.workflow_review_activity](public.workflow_review_activity.md) | 7 | | BASE TABLE |
| [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) | 8 | | BASE TABLE |
| [public.workflow_review_request](public.workflow_review_request.md) | 12 | | BASE TABLE |
| [public.workflow_review_request_authors](public.workflow_review_request_authors.md) | 2 | | BASE TABLE |
| [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) | 2 | | BASE TABLE |
| [public.workflow_review_request_workflow](public.workflow_review_request_workflow.md) | 4 | | BASE TABLE |
| [public.workflow_review_request_workflow](public.workflow_review_request_workflow.md) | 5 | | BASE TABLE |
| [public.workflow_statistics](public.workflow_statistics.md) | 7 | | BASE TABLE |
| [public.workflow_statistics_delta](public.workflow_statistics_delta.md) | 6 | | BASE TABLE |
| [public.workflows_tags](public.workflows_tags.md) | 2 | | BASE TABLE |
@ -244,6 +249,8 @@ erDiagram
"public.folder" }o--o| "public.folder" : "FOREIGN KEY (#quot;parentFolderId#quot;) REFERENCES folder(id) ON DELETE CASCADE"
"public.folder_tag" }o--|| "public.tag_entity" : "FOREIGN KEY (#quot;tagId#quot;) REFERENCES tag_entity(id) ON DELETE CASCADE"
"public.folder_tag" }o--|| "public.folder" : "FOREIGN KEY (#quot;folderId#quot;) REFERENCES folder(id) ON DELETE CASCADE"
"public.git_connection_project" |o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.git_connection_project" }o--|| "public.git_connection" : "FOREIGN KEY (#quot;gitConnectionId#quot;) REFERENCES git_connection(id) ON DELETE CASCADE"
"public.insights_by_period" }o--|| "public.insights_metadata" : "FOREIGN KEY (#quot;metaId#quot;) REFERENCES insights_metadata(#quot;metaId#quot;) ON DELETE CASCADE"
"public.insights_metadata" }o--o| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE SET NULL"
"public.insights_metadata" }o--o| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE SET NULL"
@ -278,6 +285,7 @@ erDiagram
"public.oauth_refresh_tokens" }o--|| "public.oauth_clients" : "FOREIGN KEY (#quot;clientId#quot;) REFERENCES oauth_clients(id) ON DELETE CASCADE"
"public.oauth_user_consents" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.oauth_user_consents" }o--|| "public.oauth_clients" : "FOREIGN KEY (#quot;clientId#quot;) REFERENCES oauth_clients(id) ON DELETE CASCADE"
"public.poller_state" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.processed_data" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.project" }o--o| "public.user" : "FOREIGN KEY (#quot;creatorId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.project_relation" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
@ -320,6 +328,10 @@ erDiagram
"public.workflow_publish_history" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;versionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.workflow_published_version" |o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE RESTRICT"
"public.workflow_published_version" }o--|| "public.workflow_history" : "FOREIGN KEY (#quot;publishedVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE RESTRICT"
"public.workflow_review_activity" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_activity" }o--|| "public.workflow_review_request" : "FOREIGN KEY (#quot;workflowReviewRequestId#quot;) REFERENCES workflow_review_request(id) ON DELETE CASCADE"
"public.workflow_review_activity_comment" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_activity_comment" }o--|| "public.workflow_review_activity" : "FOREIGN KEY (#quot;activityId#quot;) REFERENCES workflow_review_activity(id) ON DELETE CASCADE"
"public.workflow_review_request" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_request" }o--o| "public.user" : "FOREIGN KEY (#quot;updatedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.workflow_review_request" }o--o| "public.user" : "FOREIGN KEY (#quot;closedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
@ -330,6 +342,7 @@ erDiagram
"public.workflow_review_request_reviewers" }o--|| "public.workflow_review_request" : "FOREIGN KEY (#quot;workflowReviewRequestId#quot;) REFERENCES workflow_review_request(id) ON DELETE CASCADE"
"public.workflow_review_request_workflow" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflow_review_request_workflow" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;workflowVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.workflow_review_request_workflow" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;baselineVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE SET NULL"
"public.workflow_review_request_workflow" }o--|| "public.workflow_review_request" : "FOREIGN KEY (#quot;workflowReviewRequestId#quot;) REFERENCES workflow_review_request(id) ON DELETE CASCADE"
"public.workflows_tags" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE"
"public.workflows_tags" }o--|| "public.tag_entity" : "FOREIGN KEY (#quot;tagId#quot;) REFERENCES tag_entity(id) ON DELETE CASCADE"
@ -431,6 +444,7 @@ erDiagram
timestamp_3__with_time_zone createdAt
integer duration
text error
json failureSummary
varchar_16_ hitlStatus
varchar_36_ id
varchar_255_ model
@ -495,6 +509,7 @@ erDiagram
varchar_32_ id
varchar_128_ name
text objective
varchar_64_ timezone
timestamp_3__with_time_zone updatedAt
}
"public.agent_task_run_lock" {
@ -512,6 +527,7 @@ erDiagram
varchar_128_ name
text objective
varchar_32_ taskId
varchar_64_ timezone
timestamp_3__with_time_zone updatedAt
varchar_36_ versionId FK
}
@ -523,6 +539,7 @@ erDiagram
json integrations
varchar_128_ name
varchar_255_ projectId FK
integer revision
json schema
timestamp_3__with_time_zone setupCompletedAt
json skills
@ -895,6 +912,27 @@ erDiagram
varchar_36_ folderId FK
varchar_36_ tagId FK
}
"public.git_connection" {
varchar_64_ baseCommit
varchar_255_ branchName
varchar_16_ connectionType
timestamp_3__with_time_zone createdAt
text encryptedPassword
text encryptedPrivateKey
text encryptedUsername
varchar_36_ id
varchar_16_ keyGeneratorType
varchar_128_ name
text publicKey
text repositoryUrl
timestamp_3__with_time_zone updatedAt
}
"public.git_connection_project" {
timestamp_3__with_time_zone createdAt
varchar_36_ gitConnectionId FK
varchar_36_ projectId FK
timestamp_3__with_time_zone updatedAt
}
"public.insights_by_period" {
integer id
integer metaId FK
@ -1172,6 +1210,15 @@ erDiagram
json scope
uuid userId FK
}
"public.poller_state" {
timestamp_3__with_time_zone backoffUntil
integer consecutiveErrors
timestamp_3__with_time_zone createdAt
json cursor
varchar_36_ nodeId
timestamp_3__with_time_zone updatedAt
varchar_36_ workflowId FK
}
"public.processed_data" {
varchar_255_ context
timestamp_3__with_time_zone createdAt
@ -1478,6 +1525,7 @@ erDiagram
text errorMessage
integer id
varchar_36_ publishedVersionId
varchar_20_ reason
varchar_20_ status
timestamp_3__with_time_zone updatedAt
varchar_36_ workflowId
@ -1506,6 +1554,25 @@ erDiagram
timestamp_3__with_time_zone updatedAt
varchar_36_ workflowId FK
}
"public.workflow_review_activity" {
timestamp_3__with_time_zone createdAt
uuid createdById FK
json data
integer id
varchar_64_ type
integer typeVersion
varchar_36_ workflowReviewRequestId FK
}
"public.workflow_review_activity_comment" {
integer activityId FK
text body
timestamp_3__with_time_zone createdAt
uuid createdById FK
timestamp_3__with_time_zone deletedAt
json history
integer id
timestamp_3__with_time_zone updatedAt
}
"public.workflow_review_request" {
timestamp_3__with_time_zone approvedAt
uuid closedById FK
@ -1529,6 +1596,7 @@ erDiagram
varchar_36_ workflowReviewRequestId FK
}
"public.workflow_review_request_workflow" {
varchar_36_ baselineVersionId FK
varchar_36_ id
varchar_36_ workflowId FK
varchar_36_ workflowReviewRequestId FK

View File

@ -73,6 +73,7 @@ erDiagram
json integrations
varchar_128_ name
varchar_255_ projectId FK
integer revision
json schema
timestamp_3__with_time_zone setupCompletedAt
json skills

View File

@ -54,6 +54,7 @@ erDiagram
json integrations
varchar_128_ name
varchar_255_ projectId FK
integer revision
json schema
timestamp_3__with_time_zone setupCompletedAt
json skills

View File

@ -52,6 +52,7 @@ erDiagram
json integrations
varchar_128_ name
varchar_255_ projectId FK
integer revision
json schema
timestamp_3__with_time_zone setupCompletedAt
json skills

View File

@ -47,6 +47,7 @@ erDiagram
json integrations
varchar_128_ name
varchar_255_ projectId FK
integer revision
json schema
timestamp_3__with_time_zone setupCompletedAt
json skills

View File

@ -67,6 +67,7 @@ erDiagram
json integrations
varchar_128_ name
varchar_255_ projectId FK
integer revision
json schema
timestamp_3__with_time_zone setupCompletedAt
json skills

View File

@ -10,6 +10,7 @@
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| duration | integer | 0 | false | | | |
| error | text | | true | | | |
| failureSummary | json | | true | | | Execution failure projection as {count, latest} for session list queries |
| hitlStatus | varchar(16) | | true | | | |
| id | varchar(36) | | false | | | |
| model | varchar(255) | | true | | | |
@ -64,6 +65,7 @@ erDiagram
timestamp_3__with_time_zone createdAt
integer duration
text error
json failureSummary
varchar_16_ hitlStatus
varchar_36_ id
varchar_255_ model

View File

@ -88,6 +88,7 @@ erDiagram
json integrations
varchar_128_ name
varchar_255_ projectId FK
integer revision
json schema
timestamp_3__with_time_zone setupCompletedAt
json skills
@ -102,6 +103,7 @@ erDiagram
timestamp_3__with_time_zone createdAt
integer duration
text error
json failureSummary
varchar_16_ hitlStatus
varchar_36_ id
varchar_255_ model

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