Merge branch 'master' into ADO-5792-groups-positioning

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Angel Moreno 2026-09-17 14:28:55 +02:00
commit c67600ca8a
8014 changed files with 664880 additions and 414576 deletions

View File

@ -0,0 +1,104 @@
# Review rules
Rules the AI reviewer (cubic) enforces on pull requests. One rule per file,
grouped by the agent that loads it. `cubic.yaml` links these files via
`file_paths`; the prose in `cubic.yaml` stays thin so the rules are reviewable
as normal markdown.
These files hold *what to look for*. How to review — the bar a comment must
clear, when to stay silent, what never to say — lives once in
`custom_instructions` in `cubic.yaml`, because it reaches every agent.
## Layout
| Directory | Agent | Scope |
|-------------|----------|-----------------------------------------------------|
| `security/` | Security | backend packages, nodes, and the frontend render surface |
| `backend/` | Backend | `cli`, `@n8n/db`, `core`, `workflow`, node packages |
| `db-migrations/` | DB migrations | `@n8n/db` migrations + their tests in `cli` |
| `frontend/` | Frontend | `packages/frontend` |
| `qa-dx/` | QA & DX | `.github`, `docker`, `scripts`, `patches`, `packages/testing`, the lint/test/TS config packages, baselines |
| `testing/` | Backend + Frontend | any package with a test suite |
A directory maps to one agent unless, like `testing/`, the policy is identical
across domains — then it is one file listed in several agents' `file_paths`,
never a copy per directory. Security and QA & DX deliberately don't link
`testing/`: coverage nagging on a credential fix or a Dockerfile is noise those
agents shouldn't be able to produce.
All five slots are used, so a new domain now merges into an existing agent. QA &
DX covers the build, test, and CI surface — the same paths `.github/OWNERS`
assigns to `@n8n-io/qa-dx`. Code-quality rules that happen to apply broadly
(error classes, `any`, lazy imports) are backend rules, not QA & DX ones. DB
migrations is split out of Backend because a migration is permanent and runs
unattended on every instance, so it is judged against a different bar.
## Limits that bite
cubic fails silently on all three of these, which is why `pnpm check:cubic-config`
enforces them in CI:
- **5 enabled agents per repository.** Rules past the fifth never run and cubic
says nothing. All five are in use.
- **10,000 characters per agent**, counting the `description`, every linked
file, and `custom_instructions` — concatenated in the listed order.
Everything past the limit is dropped from the review prompt. The shared block
is prepended to every agent, so a line added there is spent against all of
their ceilings, not one; `check:cubic-config` prints the own/shared split.
- **Repo-relative file paths only.** Globs, directories, parent-directory
traversal, and absolute paths are all rejected — list each file explicitly.
The schema caps `file_paths` at 10 entries per agent.
## Adding a rule
1. Pick the level of reach first. Guidance about *how* to review goes in
`custom_instructions`, not here. A policy that is word-for-word the same in
two domains becomes one file linked by both agents. Everything else goes in
the directory of the agent that owns it.
2. Open with a one-line "Applies to:" so the reviewer skips it on unrelated
files — a backend PR still loads the node rules, since include globs are
per-agent.
3. Add its path to every `file_paths` that should load it in `cubic.yaml`.
Keep each agent's list ordered by how often the class it covers actually
shows up — the ceiling truncates from the end, so the last file listed is the
one that silently disappears first.
4. Run `pnpm check:cubic-config`. It validates `cubic.yaml` against cubic's
published JSON schema, then fails on a missing path, an over-budget agent, or
a rule file nobody links, and warns at 80% of the ceiling.
The schema is vendored at `.github/scripts/quality/cubic-config.schema.json` so
the check needs no network. `util-refresh-cubic-schema.yml` re-pulls it on the 1st
of each month and opens a PR when it changed — review that diff for new cubic
options worth adopting. To refresh by hand:
`node .github/scripts/quality/check-cubic-config.mjs --refresh`.
Don't restate something CI already fails on — see the "Don't repeat what CI
already fails on" section in `cubic.yaml`. Write what static analysis cannot
see.
## Working with cubic on a PR
Tag `@cubic-dev-ai` in a PR comment (GitHub autocomplete won't offer it — type it).
Replying to one of cubic's own comments needs no tag, but a bare reply never
authorises code changes.
| Ask | Comment |
|-----|---------|
| Re-review everything | `@cubic-dev-ai review this PR` |
| Review only what changed since its last pass | `@cubic-dev-ai incremental review` |
| Deeper pass with the stronger models | `@cubic-dev-ai ultrareview` — or `ultrareview: focus on <topic>` |
| Fix a finding on this branch | `@cubic-dev-ai fix this issue in this branch` |
| One-off context for this run only | `@cubic-dev-ai review this and use <url>` |
| Ask about a finding | reply in the thread |
Two things worth knowing:
- Ultrareview is billed at 3× a normal review, which is why it is manual rather
than automatic here.
- A PR over 10,000 changed lines, or carrying one of the ignored automation
labels, gets no automatic review. `@cubic-dev-ai review this PR` overrides
that when you want it.
Disagreeing in a thread is not wasted: cubic turns feedback into team-scoped
learnings, so a well-argued "this is fine because X" shapes later reviews. Vague
replies ("done", "thanks") don't.

View File

@ -0,0 +1,59 @@
# Controller request bodies must use a DTO
Applies to: `*.controller.ts` outside `packages/cli/src/public-api` (the public
API has its own lint rules).
Controller endpoints that accept a request body must use the `@Body` decorator
with a DTO class. TypeScript types alone provide no runtime validation — only
`@Body` with a Zod-based DTO (extending `Z.class`) validates incoming data.
This fails quietly rather than loudly: `controller.registry.ts` validates only
when the parameter type exposes `safeParse`, and otherwise pushes nothing into
the handler's arguments at all.
Only flag when there is positive evidence the developer intended to accept a
body but didn't use the pattern:
1. A parameter named `payload`, `body`, or `data` without `@Body`
2. Direct `req.body` access in a controller method
3. `@Body` with a type not ending in `Dto` — suggests unawareness of the pattern
Do NOT flag:
- `@Body` with a type ending in `Dto` — the developer knows the pattern
- POST/PUT/PATCH endpoints with no body parameter; they may legitimately have no body
- Webhook controllers
Violation:
```typescript
@Post('/')
async create(req: AuthenticatedRequest, payload: CreateUser) {
// payload is undefined - missing @Body decorator
}
@Post('/')
async create(req: AuthenticatedRequest) {
const data = req.body; // No runtime validation
}
@Post('/')
async create(@Body payload: CreateUser) {
// Type doesn't end in Dto - likely missing Zod validation
}
```
Allowed:
```typescript
@Post('/')
async create(@Body payload: CreateUserDto) { ... }
@Post('/activate')
async activate(req: AuthenticatedRequest) {
// Legitimately no body needed
}
```
Use a DTO from `@n8n/api-types` or a local `dto/` directory. DTOs must extend
`Z.class` from `zod-class` for runtime validation.

View File

@ -0,0 +1,15 @@
# Throw a typed error, not a plain Error
Applies to: backend packages (`cli`, `@n8n/db`, `core`, `workflow`) and the node packages.
The `no-plain-errors` lint rule is switched off repo-wide, so nothing catches
this. Flag `throw new Error(...)` in new code and pick by cause:
- `UserError` — the user caused it: invalid input, unauthorized action,
business-rule violation
- `OperationalError` — transient and expected: a failing network request, a DB
timeout; something to handle gracefully
- `UnexpectedError` — a bug: logic mistake, unhandled case, failed assertion
Do not flag `ApplicationError`; the `no-application-error` ESLint rule already
fails the build for it.

View File

@ -0,0 +1,10 @@
# `any` where a real type is available
Applies to: `packages/cli`, `packages/workflow`, `packages/nodes-base`,
`packages/@n8n/nodes-langchain`.
`@typescript-eslint/no-explicit-any` is downgraded to a warning in these
packages, and `pnpm lint` runs `--quiet`, so warnings never surface.
Flag a new `any` there when `unknown` plus a type guard, or an existing
interface, would do. Do not flag `any` in test code.

View File

@ -0,0 +1,24 @@
# Hand-rolled delays the linter cannot see
Applies to: backend packages (`cli`, `@n8n/db`, `core`, `workflow`) and the node packages.
`no-restricted-sleep-definition` catches only helpers literally named `sleep` or
`sleepWithAbort`, so it misses the same thing under another name.
Flag a delay re-implemented as `wait`, `delay`, `pause`, `waitFor`, … whose
entire body is a promise wrapping `setTimeout`, and an inline awaited
`new Promise((resolve) => setTimeout(resolve, ms))`.
Use `sleep(ms, abortSignal?)` from `@n8n/utils/sleep`. It takes the abort signal,
so an abort-aware local waiter is a violation too.
Do NOT flag:
- Wrappers that do more than wait: racing another promise, rejecting with a
timeout error, retry backoff, debounce/throttle
- Bare `setTimeout` used for scheduling rather than awaited as a delay
- Fake timer helpers such as `vi.advanceTimersByTime`
- Helpers literally named `sleep`/`sleepWithAbort`, and `sleep` imported from
`n8n-workflow` — ESLint already fails the build for both
- `packages/@n8n/node-cli/**` and `packages/@n8n/typeorm/**`, which cannot depend
on `@n8n/utils`

View File

@ -0,0 +1,11 @@
# Lazy-load heavy or native modules
Applies to: backend packages (`cli`, `@n8n/db`, `core`, `workflow`) and the node packages.
A top-level `import` of a module used only on a specific code path loads it into
every process at startup, raising baseline memory. Native modules (e.g.
`isolated-vm`) can crash instances that lack the binary. Large parsers (e.g.
`jsdom`, ~16 MB heap) waste memory when the path is rarely hit.
Fix: `await import()` at point of use. For barrel files, use `export type`
instead of a value re-export when consumers only need the type.

View File

@ -0,0 +1,14 @@
# License enforcement
Applies to: `packages/cli`, `*.ee.ts` files, licensed modules.
Nothing fails the build when a paid feature ships ungated, so this is review-only.
- Endpoints in `*.ee.ts` reaching licensed features without `@Licensed` — a
scope decorator is a permission check, not a license check
- `@Licensed` not matching the feature (`LICENSE_FEATURES` in `@n8n/constants`)
- Bypassed quota checks, or missing `FeatureNotLicensedError` on an unlicensed
path
- Custom licensing middleware where the `@Licensed` decorator fits
- Decorator order: route decorator → `@Licensed` → scope decorators
- Enterprise code reachable outside `*.ee.ts` files or licensed modules

View File

@ -0,0 +1,32 @@
# Migration conventions and tests
Applies to: `packages/@n8n/db/src/migrations/**`, `packages/cli/test/migration/**`.
`.agents/skills/db-migrations/SKILL.md` is the standard; prefer it over your
priors. Read a recent migration for the local idiom.
Flag:
- a hand-written table prefix or quoted identifier. Use `escape.tableName()` and
`escape.columnName()`;
- `queryRunner.query()` where `runQuery()` belongs, or `console.log` where the
context `logger` belongs;
- a value import of an entity, or a cross-package import;
- a timestamp that is not above every existing migration;
- a hand-edited generated index file;
- an 80-line `up()` that needs named private methods.
Precedent beats generic style opinion, but it is not a defence. An older migration
doing it wrong makes the finding informational.
## Tests
A data migration needs an integration test. Reading cannot verify its assumptions
about row shape, JSON and NULLs. Say so when there is none. A good test sets up
with `initDbUpToMigration`, runs with `runSingleMigration`, and covers both
engines, `down()`, and dirty rows. A happy-path test, on a migration that claims
to handle bad data, is worth a comment. For a schema-only migration the DSL calls
are enough, so a missing test is informational.
Regenerate the migration index files and the schema docs in `docs/generated/` when
the schema changes. Check the changed file list before you call one stale.

View File

@ -0,0 +1,31 @@
# Postgres and SQLite compatibility
Applies to: `packages/@n8n/db/src/migrations/**`.
A migration in `common/` runs on both engines. Judge them separately. A change
that is safe on Postgres can lose data on SQLite.
## The CASCADE trap on SQLite
Six DSL helpers recreate the table on SQLite: `addColumns`, `dropColumns`,
`addNotNull`, `dropNotNull`, `addEnumCheck`, `dropEnumCheck`. TypeORM copies the
rows to a temp table, drops the original and renames. If another table has an
incoming `ON DELETE CASCADE` foreign key, that `DROP` **deletes its rows**.
This is the most valuable check on any SQLite migration. The
`{ recreatesOnSqlite: true }` argument is an acknowledgement, not a fix. Check the
target table for incoming CASCADE foreign keys. If it has them, the migration
needs a `sqlite/` subclass with `withFKsDisabled = true as const`, or raw
`ALTER TABLE ADD COLUMN` when every new column is nullable or has a default. Say
so if you cannot check.
## Everything else
In a `common/` migration, flag Postgres-only SQL — `ALTER COLUMN … TYPE`,
expression indexes, `gen_random_uuid()` — raw SQL that assumes one engine's
boolean literal or quoting, and `INSERT OR REPLACE` where `ON CONFLICT DO NOTHING`
was meant. `varchar(N)` enforcement, transactional DDL, JSON and timestamp
handling, and NULL in unique constraints all differ.
A dialect migration belongs in `postgresdb/` or `sqlite/`. Read the DSL source
before you claim how a helper behaves.

View File

@ -0,0 +1,27 @@
# Data safety
Applies to: `packages/@n8n/db/src/migrations/**`.
Think of a database last upgraded two years ago. It holds dirty rows, unexpected
NULLs, retired enum values and malformed JSON.
Flag:
- a drop in the same release the code stopped writing the column. Expand-contract
must finish first;
- rows deleted with no fallback copy;
- a table copy with no row-count check;
- a backfill that overwrites the old value with no way back, in a
`ReversibleMigration`;
- `JSON.parse` with no `try`/`catch`;
- iteration with no `Array.isArray`, or a property read that assumes the property
exists;
- a backfill that cannot be retried after a partial failure;
- ordering that is assumed, not enforced;
- a `down()` that is empty, or does not restore the previous state.
`IrreversibleMigration` is correct only when `up()` destroys data that `down()`
would need, and the reason is stated. It is not a way to skip a tedious `down()`.
Do not flag a hazard the code handles. A guarded parse, a batched update or a
`withFKsDisabled` subclass is the fix.

View File

@ -0,0 +1,41 @@
# Necessity and schema design
Applies to: `packages/@n8n/db/src/migrations/**`.
## Should it exist?
Flag:
- a column or table that duplicates one that exists;
- state that application code or a read-time value could hold instead;
- a backfill that lazy computation would avoid, or a denormalised copy with no
read pattern that needs it;
- a drop before expand-contract is complete;
- two unrelated changes in one file;
- an edit to an already-merged migration. Add a new one instead.
## Is the shape right?
Use the narrowest type that fits the value:
- a numeric type for numbers and byte counts, never `varchar`;
- native `uuid`, not `varchar(36)`;
- `timestampTimezone()` or `timestampNoTimezone()`. Plain `.timestamp()` is
deprecated;
- `json` for structured data, `text` for unbounded user strings;
- never `double` for a version field.
Also flag:
- a reference column with no foreign key, a foreign key with no `onDelete`, or
`ON DELETE SET NULL` on a `NOT NULL` column;
- a missing primary key, or one unlike the adjacent tables;
- a default that is wrong for existing rows;
- an enum-like string with no CHECK constraint;
- a standalone index on a boolean, or one that repeats a unique constraint. A
partial index with a `WHERE` clause is correct. Do not flag it;
- an ID type that does not match the column it joins to.
Read the entity in `packages/@n8n/db/src/entities/`. Its type, nullability,
default, `@Index` and FK must agree with the migration. If you cannot read it, say
parity is unverified rather than guess.

View File

@ -0,0 +1,25 @@
# Performance and scale
Applies to: `packages/@n8n/db/src/migrations/**`.
`execution_entity` and `workflow_entity` hold millions of rows. Never assume a
table is small.
Flag:
- an unbounded `SELECT` into Node. Use `runInBatches`;
- row-by-row updates where one `UPDATE … FROM` does the work;
- a dataset in memory that grows with the table;
- a table rewrite on a hot table: `ALTER … TYPE`, `SET NOT NULL`, SQLite
recreation;
- an index built on a huge table;
- a `LIKE '%…%'` scan over a JSON column;
- a large blob column added to a hot row.
Every index slows every write, so an index with no query to serve is a finding.
But grep for the table and column first. Without that grep, say the query pattern
is unverified.
A nullable column with no backfill is cheap on Postgres. Do not comment on it.
On SQLite it is cheap only when added with raw `ALTER TABLE ADD COLUMN`. The DSL
`addColumns` recreates the whole table. On a hot table, flag that.

View File

@ -0,0 +1,21 @@
# Wrapping a third-party component
Applies to: `packages/frontend`.
`@n8n/design-system` wraps reka-ui, Element Plus, TipTap and CodeMirror. The
recurring defect is assuming the wrapper passes something through, or that a
library default is what you want.
- **Attributes are forwarded only if the wrapper forwards them.** `N8nSelect`
binds just `$props` and `on*` listeners to `ElSelect`, so an `aria-label`,
`data-test-id` or native attribute set on it lands on the outer `<div>` and
never reaches the control. Check the wrapper's `v-bind`, and prefer its own
prop where one exists.
- **Library defaults are behaviour.** A dialog focus trap focusing the first
tabbable child opens a filterable select on mount. Flag a handler
suppressing a library behaviour unconditionally when one state needs it,
e.g. a blanket `@interact-outside` `preventDefault`.
- **Vendor class names are contracts.** A `:global()` selector against one
stops matching silently when the library renames it.
- An accessible name computing to empty for an icon-only control, or a
`<button>` with no explicit `type`, which is `submit` inside a form.

View File

@ -0,0 +1,42 @@
# Design system
Applies to: `packages/frontend`.
Stylelint checks the *grammar* of a custom-property name, at warning severity.
Nothing checks that a token exists or is the right one, and `var(--gone)`
resolves to nothing rather than failing. Wrong tokens are the most common
shipped frontend regression in this repo.
Three layers, narrowest first; reaching past the narrowest that fits is the
defect:
1. component-scoped: `--button--*`, `--callout--*`, `--input--*`, `--tag--*`,
`--node--*`, `--canvas-node--*`, `--sticky--*`
2. semantic: `--background--*`, `--text-color--*`, `--border-color`
3. primitives, in px: `--spacing--` 5xs 2, 4xs 4, 3xs 6, 2xs 8, xs 12, sm 16,
md 20, lg 24, xl 32, 2xl 48, 3xl 64, 4xl 128, 5xl 256; `--radius--` 4xs 2,
3xs 4, 2xs 6, xs 8, sm 12, md 16, lg 20, xl 24, 2xl 32, `--radius--full`
Flag, strongest first:
- A `var()` naming a token that no longer exists, e.g. a legacy `--color--*`
kept through a refactor. It renders as nothing, silently.
- A hard-coded px, rem, hex, `rgb()`, ms or `z-index` literal where a token
covers it. Name the token.
- A generic token where a component-scoped one exists, e.g. a `--color--*`
background on an input that has `--input--color--background`.
- A px target stated in the diff or a comment that the chosen token misses.
The scales share suffixes at different values: `--radius--xs` is 8px,
`--spacing--xs` is 12px. "Design calls for 8px" beside `--radius--lg` (20px)
is a real defect.
Soft warning on a plain token swap: ask the intent, since a deliberate change
and a mistake look identical.
Flag a hand-rolled control duplicating an `N8n*` component (icon button,
tooltip, dialog, select), which arrives missing hover, focus and disabled
states, and a NEW direct `element-plus` or `reka-ui` import in `editor-ui`.
No lint rule covers those and many files predate the convention, so judge
only the import being added.
Do NOT comment on Storybook story titles, args, or doc cross-references.

View File

@ -0,0 +1,18 @@
# i18n
Applies to: `packages/frontend`.
`baseText(key, { interpolate })` takes an untyped record and vue-i18n leaves
an unmatched placeholder in the output. Nothing checks those names against
`en.json`.
- An `interpolate` key not matching the `{placeholder}` in that string,
including a case difference such as `docUrl` against `{docURL}`. The user
sees the raw placeholder or nothing.
- `i18n.baseText` passed as a bare function reference. It is a class method
reading `this`, so detaching it returns the key instead of the translation.
Pass the `i18n` object.
- Punctuation, separators or brackets hardcoded around a translated string in
a template. Composition differs per locale; put them in the string.
Do NOT flag a missing non-`en` translation, or wording itself unless it
contradicts copy the same PR adds.

View File

@ -0,0 +1,43 @@
# Vue correctness
Applies to: `packages/frontend`.
These fail silently: `vue-tsc` and ESLint pass, the app runs, the value or the
DOM is wrong.
## List identity
The editor patches long-lived lists in place, so `:key` decides whether Vue
reuses the wrong row. Flag an index `:key` on a list that can be reordered,
filtered or shortened, where revealed fields, inputs and refs then follow the
position instead of the item; a key omitting part of what identifies the
instance, e.g. a session key without the execution id, so switching
executions reuses the old component's state; and a key regenerated every
render. Index keys are fine on an append-only list.
## Reactivity
- A `ref` or `computed` read without `.value` outside a template. Templates
unwrap, script does not, so you hold the wrapper: `activeNode?.type` reads
`undefined` instead of the type, without throwing.
- A reactive object spread, cloned or deep-merged. `{ ...store }`,
`structuredClone` and recursive merge helpers detach it, and a merge into a
shared defaults object leaks one call into the next.
- An `injectStrict` helper such as `injectNDVStore` called outside `setup()`.
## State written after an await
Navigation swaps the active workflow document, and dialogs, threads and NDV
panels unmount mid-request. Flag a continuation writing to a store,
`localStorage` or the route without re-checking what it captured before the
`await`: navigating first and persisting afterwards what the destination was
meant to read, clearing a selection the user has since changed, toasting for a
closed dialog. Ask for a revalidated reference or a request sequence number,
never a lock.
## Payload shape
Backend responses and `/rest/settings` omit optional sections entirely. Flag a
new getter or computed walking more than one level into a response without
optional chaining, e.g. `settings.value.license.planName`. It throws during
boot and takes the view with it.

View File

@ -0,0 +1,32 @@
# Base images and pinning
Applies to: `docker/images/**/Dockerfile*`.
## A tag and its digest change together
Base images are pinned as `tag@sha256:...`. The tag is documentation; the digest
is what actually resolves. Flag a diff that edits one without the other — a
bumped tag with a stale digest silently keeps building the old image, and a
bumped digest under an old tag makes the Dockerfile lie about what it runs.
```dockerfile
ARG BUILDER_IMAGE=node:26.7.0-alpine3.24@sha256:aadf41...
ARG RUNTIME_IMAGE=n8nio/base:26.7.0@sha256:336873...
```
Also flag a base image reference that loses its digest entirely, and a
`FROM`/`ARG` that introduces a floating tag such as `latest`, `alpine`, or a
major-only version.
## Node version consistency
`ARG NODE_VERSION`, the builder tag, and the runtime base tag describe the same
Node release. Flag a bump that moves one and leaves another behind.
## The runtime base only changes here
A separate workflow builds `n8nio/base`; a new base reaches the n8n image only
when `RUNTIME_IMAGE` changes in this file. Flag a PR that expects a base change
to arrive on its own, and flag a runtime stage that installs a compiler or
build dependency — the runtime base ships without one deliberately, and build
tooling belongs in a builder stage that is discarded.

View File

@ -0,0 +1,46 @@
# Native module builds in the Docker images
Applies to: `docker/images/**/Dockerfile*`.
n8n compiles `sqlite3`, `isolated-vm`, and `@confluentinc/kafka-javascript`
inside the image. Each line of that setup exists because a specific build broke.
Flag a change that undoes one.
## Never `npm rebuild` a native module in a pnpm tree
A pnpm `node_modules` reaches the same package through several symlinks, and
`npm rebuild` runs one install script per link, concurrently, in the same store
directory. They collide on `build/node_gyp_bins` and on each other's make
output. This produced an intermittent Docker build failure twice, for sqlite3
and for isolated-vm.
Call node-gyp once, directly:
```dockerfile
node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild --release
```
Flag `npm rebuild <pkg>` or `npm install` in a Dockerfile stage that builds a
native module.
## Use npm's bundled node-gyp, not `npx node-gyp`
`npx node-gyp` resolves a version at build time, so the toolchain drifts from
the pinned image digest and builds stop being reproducible. The path above
pins node-gyp to whatever the base image ships. Flag `npx node-gyp`.
## Do not reintroduce prebuilds
`node-gyp-build` prefers `prebuilds/` over `build/Release`, and reads
`/etc/alpine-release` to detect musl. The hardened Alpine base has no such
file, so the loader picks the glibc prebuild and the module crashes at runtime.
The `rm -rf prebuilds` steps are load-bearing — flag their removal, and flag a
new native module added without one.
## Keep the native compile above `COPY ./compiled`
`isolated-vm` builds in its own stage whose only cache input is the module
source, specifically so an application change does not trigger a recompile.
Flag a `COPY` of application code moved above a native build step, or a native
build folded into the stage that copies `./compiled` — it silently turns a
cached layer into a multi-minute compile on every build.

View File

@ -0,0 +1,44 @@
# Ratchets and allowlists
Applies to: baseline files, `packages/cli/eslint.config.mjs`, lint configs.
Several checks are ratchets: they permit what already exists and fail only on
growth. That design has one blind spot — adding the new violation to the
baseline is indistinguishable, to the tool, from fixing it. Only review catches
it.
Flag a diff that **adds** entries to any of these, and ask for the fix instead:
| File | Ratchet |
|------|---------|
| `.code-health-baseline.json` | `@n8n/code-health` violations |
| `.boundaries-baseline.json` | `turbo boundaries` issue count |
| `packages/testing/playwright/.janitor-baseline.json` | Playwright janitor findings |
| `packages/cli/eslint.config.mjs` | the `misplaced-n8n-typeorm-import` and public-API allowlists, each captioned "NEVER add to this list" |
| `.code-health-baseline.json` (`lint-config-layering`) | package-wide rule downgrades left in package ESLint configs |
Removals are the healthy direction and need no comment.
Two specifics:
- `.boundaries-baseline.json` holds a single integer, not a fingerprinted list.
A fixed issue and a new one at the same count cancel out and the ratchet stays
green. Treat any change to that number as worth explaining.
- Regenerating a baseline wholesale, rather than appending to it, hides growth
inside a large diff. If a PR rewrites a baseline, ask what the net change to
the violation count is.
## Downgrading a rule is the same move
Flag a lint rule moved from `'error'` to `'warn'` or `'off'`, and a new
`eslint-disable` for one of the guarded rules. Most packages run
`eslint . --quiet`, so a warning never fails CI — downgrading to `'warn'` is
functionally deleting the rule while appearing to keep it.
In a package `eslint.config.mjs` the `lint-config-layering` code-health rule
catches this for you: a package-wide downgrade fails Static Analysis unless it
is already in the baseline. What it cannot judge is a downgrade dressed as a
scoped block. Check that a new `files` glob names the paths that actually need
the exception, and that a rule retired in `@n8n/eslint-config/src/configs/base.ts`
comes with the count of packages that had already stopped enforcing it
(`node scripts/lint-parity/majority.mjs`).

View File

@ -0,0 +1,46 @@
# GitHub Actions safety
Applies to: `.github/workflows/**`, `.github/actions/**`, `.poutine.yml`.
Poutine and Zizmor already fail CI for the classic hazards — `pull_request_target`
(also an error-level `@n8n/code-health` rule), actions unpinned from a full commit
SHA, untrusted checkout execution, self-hosted runner exposure. Do not repeat
them. What follows is what the scanners cannot see.
## Silencing a scanner is the change to review
`.poutine.yml` carries two allowlists — `github_action_from_unverified_creator_used`
(by purl) and `untrusted_checkout_exec` (by path). The file says "Add new
entries only after security review". Nothing enforces that.
Flag any addition to `.poutine.yml`'s `skip:` block, any new `zizmor: ignore`
comment, and any new entry in `.github/poutine-rules/`'s exceptions. Ask what
was reviewed and why the finding is a false positive rather than a real one.
The entry needs a comment giving the reason, matching the ones already there.
## A gate that cannot fail is not a gate
Required checks are assembled by the `ci-filter` action in `mode: validate`,
fed from a `needs:` list. Flag:
- `continue-on-error: true` on a job that appears in a required-checks `needs:`
list, or on the final validate step. It makes the gate green regardless of
outcome. `continue-on-error` on a nightly or a notification step is fine.
- A job removed from a required-checks `needs:` list without the PR saying why.
- `if:` conditions on a required job that can silently skip it. A skipped
required job and a passing one are hard to tell apart in the merge queue.
## Path filters decide whether a test runs at all
Jobs gated on a `ci-filter` filter only run when a matching file changed, so a
filter that is too narrow means the test quietly stops covering new code. When
a PR adds a directory that an existing filter was meant to cover — a new package
under `packages/testing/`, a new script under `.github/scripts/` — check the
filter still matches it.
## Least privilege
Every workflow declares a top-level `permissions:` block; without one it runs
with the repository's broad default token. Flag a new workflow that omits it,
and a job that widens `permissions` beyond what its steps use. A job calling a
reusable workflow must grant at least what that workflow declares.

View File

@ -0,0 +1,18 @@
# Authentication and access control
Applies to: `packages/cli`, `packages/@n8n/db`.
## Authentication and sessions
- JWT validation or expiration weakened; cookie flags (HttpOnly, Secure, SameSite) missing or disabled; MFA bypassed; SSO/SAML/OIDC validation gaps; OAuth 2.0 without PKCE or a weak redirect check
## Authorization
`@n8n/code-health`'s `endpoint-scope-coverage` rule is disabled, so an undecorated route ships unless a reviewer catches it.
- Every authenticated route needs `@GlobalScope` or `@ProjectScope`, unless it declares `skipAuth`, `allowUnauthenticated`, or `apiKeyAuth`
- A decorator proves the caller may do this somewhere, not that the row is theirs. Ownership belongs in the query — a `find…ForUser` finder, or the project filter alongside the id — never a comparison afterwards
- An endpoint querying a repository directly while its siblings use a project-aware service — the public API reaching past it is the usual shape
- Middleware treating an authenticated session as sufficient (`if (req.user) return next()`), skipping the ownership check behind it
- A scope parameter defaulting to the weakest value, or a role map gaining write or execute: mutating call sites must ask for the stronger scope
- Subworkflow execution bypassing caller policies

View File

@ -0,0 +1,17 @@
# Code execution and sandboxing
Applies to: `packages/cli`, `packages/core`, `packages/workflow`, Code and Merge nodes.
Every sandbox n8n has shipped was eventually escaped, so the bar is reachability: if sandboxed code can reach it, assume it can climb out.
Flag changes that:
- Give a sandboxed context new access to Node.js builtins, `process.env`, dynamic `require`/`import`, the filesystem, or the network
- Add a global, helper, or bridge callable from inside the sandbox that hands back a host-realm value — one returned object, thrown error, or callback is enough to walk `constructor` back out
- Weaken a prototype sanitizer, a blocklist, or the allowed globals
- Weaken file access restriction enforcement, or let unsanitized input reach a file path or an n8n internal directory
Two guards that did not hold before:
- `typeof value === 'string'` before a blocklist check: `['__proto__']` reports `'object'` yet coerces to `__proto__` as a key. Compare `String(value)`
- An AST check covering computed member expressions only — the same name also arrives as a call argument or a template literal, neither a string literal

View File

@ -0,0 +1,13 @@
# Credentials and secrets
Applies to: all backend packages.
Flag:
- Credential values reaching logs, error messages, response headers, or a REST response — a decrypted field returned "because the UI needs it" is the shape
- Hardcoded secrets, API keys, or tokens
- Credential encryption weakened, or `N8N_ENCRYPTION_KEY` reused for token signing, OAuth state, or anything but credential encryption
- OAuth state/CSRF validation bypassed, or webhook requests keeping auth cookies
- Credential access ignoring scope boundaries (instance/project/user), or a credential resolved by name and type with no ownership filter
A caught integration error interpolated into a message is a common way credential material reaches a status field or an API response.

View File

@ -0,0 +1,34 @@
# Node input safety
Applies to: `packages/nodes-base`, `packages/@n8n/nodes-langchain`. Skip this
file for other packages.
## Prototype pollution via node parameters
ESLint does not cover this: `no-prototype-builtins` is only a warning here and `pnpm lint` runs with `--quiet`.
Flag NEW code where a value from `this.getNodeParameter(...)` — directly or via a variable, a callback param, or a helper — is a computed key BUILDING A NESTED OR CONTAINER STRUCTURE on a plain object (`IDataObject` or object literal, not a `Map` or `Object.create(null)`):
1. Container creation: `obj[key] = {}` or `obj[key] = []`
2. Nested write where the untrusted value is a key: `obj[k1][k2] = value`
3. The same via `obj[key] ??= {}` / `obj[key] ||= []`
The carrier is usually a shared accumulator; a deep merge of user options into a config object is the same defect.
Do NOT flag:
- Reads: `const x = obj[key]`, `if (obj[key] === undefined)` — reads don't pollute
- Single-level writes of a concrete (non-object) value, e.g. `item.json[field] = value` — a primitive assigned to `__proto__` is a no-op. This is the common, benign case in nodes; flagging it is noise
- Keys that are literals, or validated by `isSafeObjectProperty(key)`
- Writes routed through `setSafeObjectProperty(...)`
Use `setSafeObjectProperty` / `isSafeObjectProperty` from `n8n-workflow`, or a `Map`, and coerce with `String(key)` before any check.
## Injection through node parameters
Flag NEW code where a parameter reaches a sink without escaping:
- SQL values concatenated or expression-interpolated instead of bound. An expression is interpolation, not parameterisation
- SQL identifiers — table, column, `ORDER BY`, a cast suffix after `name:`. They cannot be bound, so they need an allowlist; numeric fields a numeric cast
- A SQL dialect reaching JavaScript (AlaSQL in the Merge node) gaining a new construct that can call a function
- Command execution with unsanitized shell arguments

View File

@ -0,0 +1,15 @@
# Outbound requests
Applies to: `packages/cli`, `packages/core`, `packages/nodes-base`,
`packages/@n8n/nodes-langchain`.
A URL is user-controlled unless the code shows otherwise. The recurring finding is not a weak allowlist but a working check the new path never calls: `n8n-workflow` exports `assertUrlAllowed`, `assertCredentialAllowsUrl` and `getCredentialAllowedDomains`, and a request surface reaching none of them is the finding.
Flag NEW code where:
- A request built from user input reaches an HTTP client unchecked
- A later hop inherits the first hop's approval — pagination `next` links, redirects, `.well-known` discovery, webhook callbacks. Per-hop checks need the allowlist `getCredentialAllowedDomains` returns
- Credential material (`Authorization`, cookies, signed headers) reaches a host the check did not cover
- The URL arrives in a field that reads like configuration but the user writes: `requestTokenUrl`, `accessTokenUrl`, OAuth `serverUrl`, SAML `metadataUrl`, a region or host fragment reaching an SDK endpoint
- A URL is checked by regex rather than `new URL()`; regexes miss `@`-userinfo, `#` fragments and IPv6 literals
- Certificate validation is dropped (`rejectUnauthorized: false`)

View File

@ -0,0 +1,15 @@
# Rendering untrusted content
Applies to: `packages/frontend`, and backend paths serving HTML or user bytes.
Workflow names, node parameters, and execution data are author-controlled
strings, rendered in a session with full REST access.
Flag NEW code that:
- Passes user-derived content to `v-html`, `innerHTML`, `insertAdjacentHTML`, or a render function concatenating markup, with no sanitizer in front of it
- Widens a sanitizer allowlist — tags, attributes, `on*` handlers, `srcdoc` — or drops a sanitize call from an existing render path
- Puts a user-supplied value into `href`, `src`, or a `window.open` target without rejecting non-`http(s)` schemes. `javascript:` and `data:` survive naive checks; parse with `new URL()`, never a regex with the `m` flag, which makes `^` match any line start
- Renders markdown or HTML returned by a node, an AI response, or a webhook body as trusted markup
- Serves user-controlled bytes from the n8n origin without `Content-Disposition: attachment` or an equivalent inline-render guard
- Loosens an iframe `sandbox` attribute or a CSP directive on a view or response carrying user content

View File

@ -0,0 +1,21 @@
# Test coverage
Applies to: any package with a test suite.
Flag new behaviour that ships with no test — a service method, controller
route, node operation, store action, or composable. Coverage does not need to
be complete; core functionality and the critical path do.
Also flag a new or edited test whose assertion cannot tell the fixed behaviour
from the broken one, since the suite going green is then the only signal
anyone gets: a name stating the opposite of what is asserted, an assertion
that holds whether or not the invariant it names does, or a case duplicating a
sibling on the same path but for a mock the code never reads.
Do NOT flag:
- Exports, types, configuration objects, metadata, version files
- A percentage — the number is not the bar, the critical path is
- Edge cases a human reviewer is better placed to ask for
- A skipped test — `no-skipped-tests` already fails the build
- A change breaking an existing test elsewhere; the suite runs on every PR

View File

@ -54,7 +54,7 @@ Use the canonical owners script at `.github/scripts/owners.mjs`. It parses `.git
| `design` | Design | `team:design` |
| `iam` | Identity & Access | `team:identity` |
| `ligo` | Lifecycle & Governance | `team:lifecycle` |
| `instance-ai` | AI Assistant | `team:instance-ai` |
| `ai-assistant` | AI Assistant | `team:instance-ai` |
| `frontend` | Adore | `team:adore` |
| `qa-dx` | Developer Platform | `team:qa-dx` |
| `migrations-review` | Catalysts | `team:cats` |

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)
@ -36,6 +40,7 @@ throw new UnexpectedError('message', { extra: { context } });
- Dependency injection via `@n8n/di`
- Config via `@n8n/config`
- Zod schemas for validation
- Pagination args: use `offset` + `limit` in controllers and services; translate to TypeORM `skip`/`take` only inside repositories
**Testing:**
- Vitest (unit), Playwright (E2E)
@ -64,7 +69,8 @@ pnpm lint # Before commit
| Package | Purpose |
|---------|---------|
| `packages/cli` | Backend API |
| `packages/frontend/editor-ui` | Vue 3 frontend |
| `packages/frontend/editor-ui` | Vue 3 frontend shell |
| `packages/modules/<name>/frontend` | Frontend feature modules. Guide: `packages/@n8n/module-cli/frontend-module-guide.md` |
| `packages/@n8n/api-types` | Shared types |
| `packages/@n8n/db` | TypeORM entities |
| `packages/workflow` | Core interfaces |

View File

@ -0,0 +1,190 @@
---
name: n8n:create-agent-builder-eval
description: >-
Authors and calibrates Instance AI evaluations that build standalone n8n
Agents through Agent Builder. Use when a change under
packages/cli/src/modules/agents affects build-agent routing, Agent setup,
model or credential selection, tools, MCP servers, integrations, skills,
tasks, testing, or user-facing build responses. Requires LangTracer access
before authoring so each finished case can be published.
---
# Create an Agent Builder eval
Use the shared Instance AI eval harness. Agent cases use an Agent-specific
authoring directory, dataset, and LangTracer suite.
## Required LangTracer preflight
Run this check before sourcing, drafting, or writing an eval. Run it from
`packages/@n8n/instance-ai`:
```bash
pnpm exec dotenvx run -f ../../../.env.local -- \
sh -c 'test -n "${LANGTRACER_URL:-}" && test -n "${LANGTRACER_API_KEY:-}"'
```
If the check fails, stop before creating an eval file. Ask the user to:
1. Generate a key on the
[LangTracer API page](https://lang-tracer.n8n-maintenance.workers.dev/account?section=api).
2. Add these variables to the repository root `.env.local` file:
```env
LANGTRACER_URL=https://lang-tracer.n8n-maintenance.workers.dev
LANGTRACER_API_KEY=<generated-key>
```
3. Confirm when the environment is ready.
Do not ask the user to paste the key into chat. Do not print or inspect its
value. Rerun the check after the user confirms. Continue only when it passes.
## Non-negotiable routing
- Author the case at
`packages/@n8n/instance-ai/evaluations/data/agents/<slug>.json`.
- Set `"datasets": ["agents"]`.
- Push general Agent Builder cases to
[Instance AI capabilities — agents](https://lang-tracer.n8n-maintenance.workers.dev/suites/10).
Its suite slug is `agents`.
- Do not commit the case JSON. LangTracer is the durable source of truth.
- Commit changes to this skill, the harness, and CI when applicable.
The disk runner loads both `data/agents/` and `data/workflows/`. A misplaced
Agent case can therefore pass locally. That does not make the location correct.
## Decide what the case proves
Write the smallest user request that exercises the changed behavior.
- Use `processExpectations` for the Instance AI conversation and final response.
- Use `outcomeExpectations` for the created Agent artifact and its configuration.
- Use `executionScenarios` only when the built Agent must run to prove the behavior.
- Declare only the credentials that the build must see.
The harness captures the Agent configuration and authored skills. It supplies
them to the expectation judge. A scenario-less Agent case is valid when process
or outcome expectations can prove the behavior.
Use the substitution test for every expectation. A correct alternative build
must pass. A build that misses the requested behavior must fail.
For multi-turn, seeded, or capability-gap cases, follow the case-shape and
calibration rules in [create-instance-ai-eval](../create-instance-ai-eval/SKILL.md).
This skill overrides its workflow directory and suite guidance for Agent cases.
## Draft the case
Start with this shape:
```json
{
"description": "The Agent Builder behavior this case guards.",
"conversation": [
{ "role": "user", "text": "Build me an Agent that ..." }
],
"complexity": "simple",
"tags": ["agent", "agent-build", "<capability>"],
"credentials": [{ "type": "<credentialType>", "name": "<display name>" }],
"processExpectations": [
"The final response ..."
],
"outcomeExpectations": [
"A standalone Agent was created and no workflow was created.",
"The Agent ..."
],
"datasets": ["agents"]
}
```
Keep the prompt in the user's voice. Do not tell Instance AI which internal
tools or configuration fields to use unless that choice is the behavior under
test.
## Validate and run locally
Read [local-setup.md](local-setup.md) when the machine does not already have an
eval instance and environment file.
From `packages/@n8n/instance-ai`:
```bash
pnpm exec tsx -e "import { loadAgentEvalTestCasesWithFiles } from './evaluations/data/agents/index.ts'; const matches = loadAgentEvalTestCasesWithFiles('<slug>'); if (matches.length !== 1) throw new Error('Expected exactly one Agent eval case, found ' + matches.length); console.log(matches[0].fileSlug)"
pnpm eval:instance-ai \
--base-url http://localhost:5680 \
--filter <slug> \
--tier agents \
--concurrency 1 \
--keep-workflows \
--verbose
```
Use `eval:instance-ai` for a new disk case. `eval:agents` reads the published
LangTracer suite and is for running cases that are already there.
Inspect the transcript, the rendered Agent artifact, and each judge reason.
Do not accept a green result when a conditional expectation never occurred.
Do not weaken an expectation to hide a real Agent Builder defect.
## CI coverage
The Instance AI PR gate checks the files changed by the PR. A change under
`packages/cli/src/modules/agents/` selects the `Instance AI capabilities — agents`
suite through its `agents` slug. It also selects the `agents` dataset and uses
an absolute pass gate. The run uses a suite-scoped LangSmith cohort. It does not
write to the workflow dataset or compare against the workflow baseline. Other
Instance AI changes select the `baseline` suite and its `pr` dataset.
The gate runs when a PR opens, reopens, or becomes ready for review. It does not
run for each new push. Use the PR gate's manual dispatch after a later push.
## Credential behavior
Declared credentials are real n8n credential records with placeholder data.
The eval thread limits the builder to those credential IDs.
Agent Builder model catalog requests return deterministic fake models during an
eval. They do not decrypt the placeholder model credential or call its provider.
Production model catalog requests remain live.
This mock covers catalog lookup only. A builder `call_agent` action and an Agent
`executionScenario` run the target Agent model. They need a working provider
credential such as `EVAL_OPENAI_API_KEY`. A build-only case does not need one.
## Calibrate and publish
1. Run the case once with `--concurrency 1`.
2. Confirm that Instance AI called `build-agent`.
3. Confirm that the expected Agent artifact was captured.
4. Classify each red as a product gap, harness limitation, or non-determinism.
5. Use `--iterations 5` before adding a case to a gating tier.
6. Preview the LangTracer change.
7. Push it to `agents`.
```bash
pnpm exec dotenvx run -f ../../../.env.local -- \
pnpm eval:langtracer-push --suite agents --dry-run --changed
pnpm exec dotenvx run -f ../../../.env.local -- \
pnpm eval:langtracer-push --suite agents --changed
```
The push needs `LANGTRACER_URL` and `LANGTRACER_API_KEY`. Generate a key on the
[LangTracer API page](https://lang-tracer.n8n-maintenance.workers.dev/account?section=api).
Report the case and suite as clickable LangTracer links. Delete the local JSON
after a successful push.
## Completion checklist
- The LangTracer environment preflight passed before authoring.
- The case is in `data/agents/` and uses the `agents` dataset.
- The strict loader accepts it.
- A local run captures a standalone Agent.
- The transcript proves each process expectation was exercised.
- The rendered Agent artifact proves each outcome expectation.
- Repeated runs are stable enough for the selected tier.
- The case is pushed to `Instance AI capabilities — agents` with suite slug `agents`.
- Agent Builder PR changes select the `agents` suite in CI.
- The local case JSON is not committed.

View File

@ -0,0 +1,110 @@
# Local Agent Builder eval setup
## Required LangTracer access
Generate a key on the
[LangTracer API keys page](https://lang-tracer.n8n-maintenance.workers.dev/account?section=api).
Add it to `.env.local` at the repository root:
```env
LANGTRACER_URL=https://lang-tracer.n8n-maintenance.workers.dev
LANGTRACER_API_KEY=<generated-key>
```
Do not paste the key into chat or put it in a command. The main skill checks
that both variables exist before it creates an eval.
## Remaining environment
Create `.env.local` at the repository root. Use one Instance AI model provider
and one sandbox provider.
```env
N8N_INSTANCE_AI_MODEL=anthropic/claude-sonnet-4-6
N8N_INSTANCE_AI_MODEL_API_KEY=<model-key>
ANTHROPIC_API_KEY=<same-model-key>
N8N_AI_ASSISTANT_BASE_URL=
N8N_INSTANCE_AI_SANDBOX_ENABLED=true
N8N_INSTANCE_AI_SANDBOX_PROVIDER=daytona
DAYTONA_API_URL=https://app.daytona.io/api
DAYTONA_API_KEY=<daytona-key>
N8N_EVAL_EMAIL=nathan@n8n.io
N8N_EVAL_PASSWORD=PlaywrightTest123
```
Use the n8n sandbox service instead of Daytona when it is available. Set
`N8N_INSTANCE_AI_SANDBOX_PROVIDER=n8n-sandbox`,
`N8N_SANDBOX_SERVICE_URL`, and, when required,
`N8N_SANDBOX_SERVICE_API_KEY`.
LangSmith is optional. Unset `LANGSMITH_API_KEY` and `LANGSMITH_TRACING` for a
throwaway direct run. This keeps the run out of the shared LangSmith dataset.
## Build after code changes
From the repository root:
```bash
(
pnpm --filter n8n... build > build.log 2>&1
build_status=$?
tail -n 20 build.log
exit "$build_status"
)
```
Restart the instance after each build. A running process keeps the old code in
memory.
## Start an isolated instance
Create a temporary user directory:
```bash
mktemp -d /tmp/n8n-agent-eval.XXXXXX
```
Use the printed path as `N8N_USER_FOLDER`. Start the built CLI from
`packages/cli`:
```bash
N8N_PORT=5680 \
N8N_RUNNERS_BROKER_PORT=5681 \
N8N_USER_FOLDER=<printed-temp-directory> \
E2E_TESTS=true \
N8N_ENABLED_MODULES=instance-ai,agents \
N8N_AI_ENABLED=true \
pnpm exec dotenvx run -f ../../.env.local -- pnpm start
```
Use different ports when 5680 or 5681 is busy.
## Seed the owner
Run this once after the isolated instance starts:
```bash
curl -sf -X POST http://localhost:5680/rest/e2e/reset \
-H 'Content-Type: application/json' \
-d '{"owner":{"email":"nathan@n8n.io","password":"PlaywrightTest123","firstName":"Eval","lastName":"Owner"},"admin":{"email":"admin@n8n.io","password":"PlaywrightTest123","firstName":"Admin","lastName":"User"},"members":[],"chat":{"email":"chat@n8n.io","password":"PlaywrightTest123","firstName":"Chat","lastName":"User"}}'
```
## Run a local disk case
From `packages/@n8n/instance-ai`:
```bash
pnpm exec dotenvx run -f ../../../.env.local -- \
env -u LANGSMITH_API_KEY -u LANGSMITH_TRACING \
pnpm eval:instance-ai \
--base-url http://localhost:5680 \
--filter <slug> \
--tier agents \
--concurrency 1 \
--verbose
```
The run writes `eval-results.json`, an HTML report, and a PR-comment preview.
The warnings about disabled run-debug capture are benign.

View File

@ -1,19 +1,21 @@
---
name: n8n:create-instance-ai-eval
description: >-
Authors a new Instance AI workflow eval case — written locally as JSON,
calibrated against a real build, then pushed to the LangTracer suite CI runs
— build cases, behaviour/process cases, credential cases, and seeded
Authors a new Instance AI workflow or Agent eval case — written locally as
JSON, calibrated against a real build, then pushed to the LangTracer suite CI
runs — build cases, behaviour/process cases, credential cases, and seeded
(mid-conversation) cases — with intent-driven expectations. Use when adding
or changing an Instance AI workflow eval, or debugging why one is flaky.
or changing an Instance AI eval, or debugging why one is flaky.
---
# Create an Instance AI workflow eval
# Create an Instance AI eval
Each eval is **one JSON case** — authored locally as a file in
`packages/@n8n/instance-ai/evaluations/data/workflows/` (the disk loader
auto-discovers `*.json`, no registration step), with a LangTracer suite as its
durable home. Cases validate against
Each eval is **one JSON case**. Author workflow cases in
`packages/@n8n/instance-ai/evaluations/data/workflows/`. Author standalone Agent
cases in `packages/@n8n/instance-ai/evaluations/data/agents/` and follow the
[`create-agent-builder-eval` skill](../create-agent-builder-eval/SKILL.md).
The disk loader auto-discovers `*.json` in both directories. A LangTracer suite
is the durable home. Cases validate against
[`harness/schema.ts`](../../../packages/@n8n/instance-ai/evaluations/harness/schema.ts)
(`.strict()` — unknown keys fail at load). The eval
[README](../../../packages/@n8n/instance-ai/evaluations/README.md) is the
@ -53,7 +55,7 @@ trim), **calibration** (classify each red and resolve keep/loosen/drop), and
| Level | Who decides when to stop | Behaviour |
|---|---|---|
| **autonomous** | agent | Runs all four gates start-to-finish; reports a **decision log** at the end for the driver to review. |
| **autonomous** | agent | Runs all four gates start-to-finish; reports a **decision log** at the end for the driver to review — with the pushed case, its suite, and the source thread as **links** ([Share links, never bare ids](#share-links-never-bare-ids)); includes the observation id when the driver opted to record one; and includes a Linear ticket **proposal** for any kept capability-gap red ([Capability gap → propose a Linear ticket](#capability-gap--propose-a-linear-ticket)). |
| **checkpoint** | driver, per gate | Stops at each gate with a compact **proposal + recommendation**; driver says "go" or redirects. At the **calibration** gate, hands the driver a link to the just-built thread on the live instance plus login credentials so they can review the real conversation and workflow themselves before confirming (below). |
**Calibration is special-cased at both levels.** A calibration verdict that
@ -63,6 +65,15 @@ any loosening that would let a known-bad build pass — is **surfaced explicitly
committed. It's the one call where a quiet mistake corrupts the suite, so it
never fully auto-commits.
**Recording a source observation is optional, not a fifth gate.** After a real
thread passes selection, offer to save why it was selected and what the developer
observed with LangTracer's `create_observation` tool (see
[`sourcing-cases.md`](sourcing-cases.md#optional-record-the-selection-as-an-observation)).
If the driver declines, has not stated a preference in autonomous mode, or the
write fails, continue with the eval. Never block drafting, calibration, or push
on an observation. `create_observation` and `update_observation` write only to
LangTracer; do not add a LangSmith feedback or sync step.
**Checkpoint calibration — review the real thread on the instance.** Because the
calibration verdict is trust-critical, in checkpoint mode you don't ask the
driver to trust your reading of the run. You built the case against a live
@ -82,6 +93,39 @@ keep/loosen/drop, and the review link. The driver logs in, reads the thread and
the workflow, and confirms or redirects before you write the verdict back into
the case `description`.
## Share links, never bare ids
Every lang-tracer entity has a shareable web page, but the CLI and the MCP hand
you **numeric ids** — `eval:langtracer-push` prints `+ created <slug> (#621)`,
`get_eval_run` returns a run number, `list_conversations` returns thread ids.
An id is unclickable: the driver has to go find it. **Whenever you name a case,
suite, thread, cluster, or run in anything a human reads** — a checkpoint
proposal, the end-of-run decision log, a PR description, a Linear ticket, a Slack
message — render it as a link, keeping the id in the label:
```
pushed as [#621](https://lang-tracer.n8n-maintenance.workers.dev/test-cases/621)
```
Build links off the **web base** (`LANGTRACER_URL`, in production
`https://lang-tracer.n8n-maintenance.workers.dev`). Never off the API bases —
`${LANGTRACER_URL}/api/v1` and `/api/mcp` are machine endpoints, and a link into
either 404s for the driver or dumps JSON.
| Entity | URL | Where the id comes from |
|---|---|---|
| Test case | `<base>/test-cases/<id>` | push output `(#<id>)`; `create_test_case` / `search_test_cases` |
| Suite | `<base>/suites/<suiteId>` | push header `Suite "<slug>" (#<id>)`; `list_suites` |
| Source conversation | `<base>/conversations/<threadId>` | `list_conversations` / `get_conversation` |
| Cluster report | `<base>/clusters/<id>` | `list_cluster_runs` / `get_latest_cluster_run` |
| Eval run (sweep) | `<base>/results?sweep=<runId>` | `list_eval_runs` / `get_eval_run` (`runId` *is* `sweeps.id`, the "run #N") |
Two links that are **not** lang-tracer and don't take this base: the built thread
(`<base-url>/assistant/<threadId>`) and workflow (`<base-url>/workflow/<id>`) live
on the **n8n instance** the eval ran against. When both are relevant — reviewing a
calibration red, writing a capability-gap ticket — give both, labelled, so nobody
has to guess which host a link points at.
## Where the best cases come from
The strongest cases encode a **real** failure, not an invented premise. Two
@ -165,8 +209,9 @@ the combinatorial bulk. A case that never builds tests nothing.
## Workflow
These steps map to the four gates from [Set the autonomy level first](#set-the-autonomy-level-first):
sourcing (before step 1) is the **selection** gate; steps 1–2 are the **shape +
expectations** gate; steps 5–6 are the **calibration** gate; step 7 is the
sourcing (before step 1) is the **selection** gate, including the optional offer
to record a source observation; steps 1–2 are the **shape +
expectations** gate; steps 5–6 are the **calibration** gate; steps 7–8 are the
**push** gate. In *autonomous* mode you flow through all of them and summarize in
a decision log; in *checkpoint* mode you pause at each with a proposal, and at
calibration you hand the driver the thread link + login to review the real build
@ -201,17 +246,26 @@ calibration you hand the driver the thread link + login to review the real build
build has a real gap, or because the harness can't exercise it, **that red is
the result — keep it and surface why** (see "A red is signal", below). Never
delete a scenario, weaken an assertion, or drop to build-only just to make the
run green.
run green. And when the case comes back **green**, that is a result to earn,
not to accept: confirm the precondition actually fired, then re-derive it from
the raw thread before calling the case a regression guard (see
[First reproduce, then reclassify](#first-reproduce-then-reclassify)).
7. **Push to the suite — do NOT commit the JSON.** Once calibrated, push the case
into its curated lang-tracer suite with `eval:langtracer-push` (see
[Push to a lang-tracer suite](#push-to-a-lang-tracer-suite)); the suite is the
case's home, not the repo. Leave the `data/workflows/*.json` file uncommitted
case's home, not the repo. Leave the `data/{workflows,agents}/*.json` file uncommitted
(or delete it once it's in the suite). Committing new case JSONs into the repo
is no longer the approach. (An `inline` seed pushes with the case; only a
`replay` case is refused — it's a local throwaway; see
[`case-shapes.md`](case-shapes.md).) For a sourced case,
finish by **linking it to its source thread/finding** over the MCP — see
[Link the pushed case to its source](#link-the-pushed-case-to-its-source-provenance-step--always-do-this).
8. **Hand back links, and a ticket proposal if the case found a gap.** Report the
pushed case as `<base>/test-cases/<id>`, not `#<id>` ([Share links, never bare
ids](#share-links-never-bare-ids)), and if calibration kept a real
capability-gap red, propose a Linear ticket for it ([Capability gap → propose a
Linear ticket](#capability-gap--propose-a-linear-ticket)) rather than leaving the
gap as a red case nobody owns.
`--iterations N` is available to measure flakiness (pass@k / pass^k) — reach for
it when you suspect a case is non-deterministic or before promoting it to a
@ -233,13 +287,80 @@ and verify X actually materialised — the direct-loop `eval-results.json` does
persist per-expectation judge reasoning, so pass/fail alone can't tell you which
reading you got.
**A sourced failure that no longer reproduces is still worth keeping — it's now a
regression guard.** When you encode a real failure and calibration shows the
current build handling it correctly (behaviour drifts across versions), the case
doesn't lose value: it flips from *capability-gap* (currently red) to *regression
guard* (currently green, catches a re-introduction). Keep it — but only after the
non-vacuous check above proves it *would* turn red on the bad behaviour, else the
"guard" guards nothing.
**The negative form is the easiest to fool yourself with.** An assertion phrased
as "the agent did NOT call `X` with a bad argument" passes when the agent called
`X` correctly *and* when **it never called `X` at all**. Those are opposite
results and the judge reports the same green. So for any assertion about tool
misuse, confirm the tool was actually invoked before believing the pass: parse
`testCases[].transcriptPerRun[][].steps[]` for the call. Note the transcript
groups multi-action tools under a bare `toolName` (`nodes`, `workflows`,
`credentials`), so read `args.action` to get the real one — filtering on
`nodes[explore-resources]` finds nothing and looks like a clean pass. Measured on
this corpus: a batch of five tool-misuse cases scored 100% on its first
calibration run, and three of them were passing vacuously because the tool under
test was never called.
**A sourced failure that does not reproduce is not yet a regression guard — first
re-derive the precondition.** Behaviour does drift across versions, and a case
that flips from *capability-gap* (red) to *regression guard* (green, catches a
re-introduction) is a legitimate and valuable outcome. But reach it by
elimination, not by default: a green far more often means *your case never set up
the situation* than *the builder improved*. See below.
## First reproduce, then reclassify
A case built from a real failure that comes back green is the most common
outcome of a first calibration run, and "the build must have improved" is the
most common wrong conclusion. The usual cause is that you authored from a
*summary* of the thread — the theme label, the observation description, your own
one-line note — and the trigger you assumed is not the trigger that fired. Go
back to the raw thread before you downgrade anything.
**Find the turn, not the topic.** Locate the exact tool call that failed, then
read the assistant text immediately before it. The agent usually states its
intent in the open, and that sentence is the precondition. Then ask what *state*
made that call necessary — not what the conversation was about.
Worked example from this corpus. Sourced finding: "the agent invents
`nodes[explore-resources]` method names." Assumed trigger: the user swaps model
provider. A case built on a clean provider swap came back green — the agent set
the model id directly and never called the tool at all. The raw thread said it
plainly:
> "The **Groq Chat Model** has an invalid model (`llama3-8b-8192` isn't offered
> by your Groq credential). **Let me list valid models and fix it.**"
The precondition was never the swap. It was *an existing model id that the
provider rejects at runtime, with a credential already connected* — that is the
state that makes enumerating models necessary. Rebuilt on it, the same case
reproduced the failure on the first run, with the agent inventing two method
names in a row.
Three moves turn a non-reproducing case into a reproducing one. Try them in
order before settling for a guard:
1. **Fix the precondition.** Rebuild the seed and the live turn to recreate the
state the source thread was in, not the subject it was discussing.
2. **Move the assertion to the first call.** A mechanism where the agent
*self-corrects* grades green on the end state and is still a real defect — the
wasted round-trip and the guessed schema are the finding. Grading first-call
correctness turned a mechanism previously dismissed as "self-corrects, weak
eval" into a gap that reproduced in 2 of 2 runs.
3. **Keep what the attempt actually caught.** A reproduction run often reds on a
*different* real defect than the one you targeted. That is still a
capability-gap finding — keep the red, retarget the description, and say
plainly in it that the originally targeted mechanism did not reproduce.
**Know when to stop.** Some mechanisms are structurally unreachable in this
harness, and no amount of re-deriving fixes that. The clearest example: a seed
restores a **fresh** workspace file at the start of the graded turn, so the
agent's first `old_str` always comes from a file it just read. Failures that need
*accumulated drift* across many turns — the `str_replace` byte-fidelity family,
the largest agent-caused tool-call failure in production at 15% of threads — do
not reproduce even in a multi-turn chain of edits, each followed by a build. Cap
the effort at about three attempts, then write the negative result into the case
`description` as the finding it is, and ask whether the real defect belongs in a
ticket rather than an eval.
## A red is signal — surface it, don't work around it
@ -284,7 +405,9 @@ failures are excluded do the three categories below apply:
something the user asked for (a miswired branch, a missing retry, wrong field
keys). This is exactly what the eval is for. **Keep it red.** Don't loosen the
assertion or drop the scenario; a currently-red gap is the capability signal
today, and a re-introduction guard once the builder improves.
today, and a re-introduction guard once the builder improves. Then **propose a
Linear ticket** for the gap — see [Capability gap → propose a Linear
ticket](#capability-gap--propose-a-linear-ticket).
- **Harness limitation** — the build is correct but the mock/execution layer
can't exercise the path (see "Known harness limitations", below). **Keep the
scenario and say so in its `description`** — that this red is harness-caused,
@ -321,6 +444,62 @@ classification is stated in the open, never silently committed — misreading a
harness red as a real gap (or the reverse) is the one calibration mistake that
quietly corrupts the suite.
### Capability gap → propose a Linear ticket
A kept capability-gap red is a **product bug you just characterised better than
any bug report would**. But a red case in a suite doesn't assign itself to anyone:
without a ticket the gap sits in CI as permanent noise, and the next person to
read the run assumes someone already owns it. So once a red is classified as a
real gap (and the driver has confirmed it, per the autonomy level), **propose a
Linear ticket for it.**
**Propose, don't create.** Per [AGENTS.md](../../../AGENTS.md), never open a
Linear ticket unasked. Put the draft in front of the driver — interactively in
checkpoint mode, in the decision log in autonomous mode — with a title, a team,
and the body, and let them say go. Skip the proposal in two cases:
- **The gap already has a ticket.** Check the case's linked issues on its page,
and run `get_linear_ticket_context <TEAM-N>` on any candidate identifier the
driver or the source thread mentions, before you draft a duplicate.
- **The red isn't a capability gap.** A `Harness note:` red is a
lang-tracer/harness issue, and genuine non-determinism is a case-hygiene chore.
Neither belongs in the builder's queue.
The draft body should carry what makes the gap actionable, all of it already in
hand from calibration:
- **The eval case**, as a link — `<base>/test-cases/<id>` (see [Share links, never
bare ids](#share-links-never-bare-ids)). This is the reproducer; it's the most
valuable line in the ticket.
- **What failed, verbatim** — the failing `outcomeExpectation` /
`processExpectation` or scenario name, plus the judge's stated reason. Not a
paraphrase: the exact text is what the fixer will grep for.
- **What the build did instead** — the specific defect (miswired branch, wrong
field key, missing gate), and links to the real evidence: the source conversation
(`<base>/conversations/<threadId>`) and the built thread + workflow on the eval
instance (`<base-url>/assistant/<threadId>`, `<base-url>/workflow/<id>`).
- **Blast radius, if you know it** — the cluster theme or the number of real
conversations behind the gap (`<base>/clusters/<id>`) is what turns "one red
case" into a prioritisable bug.
**File it from the case page so the link is made.** The `<base>/test-cases/<id>`
page has a *Create Linear issue* dialog that creates the ticket **and** links it to
the case; that link is what makes `get_linear_ticket_context <TEAM-N>` later return
the case, its scenarios, the source conversation, and its analysis in one call. The
case↔ticket link is only writable from that UI — lang-tracer's MCP and `/api/v1`
are read-only for it — so if the ticket gets created some other way (a Linear MCP,
if your harness has one, or Linear directly), say plainly that it isn't linked, ask
the driver to link it on the case page, and meanwhile put the identifier + URL in
the case `description` via `update_test_case` so the provenance isn't lost.
Then extend the description prefix with the ticket, so the corpus stays greppable
in both directions: `Capability-gap finding: current build reds because <X> — a
real builder bug (flips to a regression guard once fixed). Tracked in
[<TEAM-123>](<ticket url>)`. And when the *build itself* is wrong — not just a
scenario red under a correct build — push it with `--set-kind capability_gap` into
a suite of that kind (see
[Push to a lang-tracer suite](#push-to-a-lang-tracer-suite)).
## Example
Minimal build case:
@ -626,7 +805,10 @@ concluding whether the failure is your case, the build, or the harness.
```bash
cd packages/@n8n/instance-ai
npx tsx -e "import {loadWorkflowTestCasesWithFiles} from './evaluations/data/workflows/index.ts'; console.log(loadWorkflowTestCasesWithFiles('<slug>')[0].fileSlug)"
pnpm exec tsx -e "import {loadWorkflowTestCasesWithFiles} from './evaluations/data/workflows/index.ts'; console.log(loadWorkflowTestCasesWithFiles('<slug>')[0].fileSlug)"
# For a standalone Agent case:
pnpm exec tsx -e "import {loadAgentEvalTestCasesWithFiles} from './evaluations/data/agents/index.ts'; console.log(loadAgentEvalTestCasesWithFiles('<slug>')[0].fileSlug)"
```
## Push to a lang-tracer suite
@ -639,24 +821,24 @@ drifted, leaves the rest unchanged, and never prunes. It's the inverse of
```bash
cd packages/@n8n/instance-ai
# preview first — no writes (use `npx dotenvx`; the bare `dotenvx` binary is usually not on PATH):
npx dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite baseline --dry-run --changed
# preview first — no writes:
pnpm exec dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite baseline --dry-run --changed
# then push (drop --dry-run):
npx dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite baseline --changed
pnpm exec dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite baseline --changed
```
- **Selectors** (at least one required — no accidental push-all): positional
`<slugs...>` (exact file slugs), `--changed` (new/untracked + staged + modified
`data/workflows/*.json`, ideal right after authoring an uncommitted case),
`data/{workflows,agents}/*.json`, ideal right after authoring an uncommitted case),
`--filter`/`--tier` (with `--exclude` as a modifier).
- **Multiple positional slugs? Skip pnpm — call the script directly.** `pnpm
eval:langtracer-push … slugA slugB` forwards the slugs as one joined argument
(`"slugA slugB"`), so no case file matches and nothing is pushed. Either use a
no-positional selector through pnpm (`--changed`), or run the script directly so
each slug is its own argv: `npx dotenvx run -f .env.eval -- npx tsx
each slug is its own argv: `pnpm exec dotenvx run -f .env.eval -- pnpm exec tsx
evaluations/cli/langtracer-push.ts --suite <slug> <slug1> <slug2> …`.
- **Env:** `LANGTRACER_URL` + `LANGTRACER_API_KEY` (an `lt_` bearer; one key works
for MCP + REST) — put them in `.env.eval` and run under `npx dotenvx`.
for MCP + REST) — put them in `.env.eval` and run under `pnpm exec dotenvx`.
- **Options:** `--set-kind regression|capability_gap` (default `regression`, must
match the suite's kind), `--contains-user-data` (default is `synthetic`). A case
whose **build is correct** (outcome expectations green) but that carries a
@ -668,6 +850,12 @@ npx dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite baseline --cha
lang-tracer #48), so scenario edits re-push like any other field. A lang-tracer
deployment predating that change silently ignores the key; if a pushed scenario
edit doesn't land, update the scenario in the lang-tracer UI.
- **Report what was pushed as links, not `#ids`.** The CLI prints `+ created
<slug> (#621)` / `~ updated <slug> (#621, rev 3)` and a suite header — that's the
id, and nothing more. Turn each one into `<base>/test-cases/<id>` (and the suite
into `<base>/suites/<suiteId>`) in whatever you hand the driver, so they can open
the case they just authored instead of hunting for it. See [Share links, never
bare ids](#share-links-never-bare-ids).
- **An `inline` seed pushes with the case:** the case-write API stores it
verbatim, so a seeded case lives in a suite like any other. Only a `replay`
case is refused — the push lists it under `skipped:`, because it's
@ -720,8 +908,9 @@ in *checkpoint* mode calibration this is how the driver opens the built thread
## Other eval harnesses (not this skill)
This skill is for `data/workflows/` cases. Three siblings exist with their own
data dirs and CLIs: **`eval:subagent`** (workflow-build compatibility corpus,
Use the [`create-agent-builder-eval` skill](../create-agent-builder-eval/SKILL.md)
for standalone Agent cases. Three other harnesses have their own data dirs and
CLIs: **`eval:subagent`** (workflow-build compatibility corpus,
binary-check scored), **`eval:discovery`** (asserts first-hop tool/dispatch
routing, no n8n server), **`eval:pairwise`** (head-to-head build comparison vs
`ai-workflow-builder.ee`). Authoring them is out of scope here — see the README

View File

@ -103,6 +103,7 @@ recognises this vocabulary:
| Dismiss a setup card / skip a value | `[When the setup card asks for the API base URL, dismiss it — the user hasn't decided yet.]` (proxy dismisses via `approve_or_reject(false)`) |
| Reject a plan that misses a requirement | `[When the agent shows its plan, reject it unless it sorts descending by count.]` |
| Iterate change-by-change, in order | `[Send each change below in order, waiting for the build after each; keep bundled changes in one message.]` |
| Run the workflow themselves and report back | `[When the agent asks you to test the workflow, run it yourself, then tell it you ran it.]` Set `allowUserExecution: true` on the case. The proxy then sets `runWorkflowId` to the intended saved workflow. The harness uses normal execution without mocks. This action supports credential-free manual or schedule workflows without pinned data. |
The `text` of a turn may be an **array of strings** (joined with newlines) so a
long director note stays readable in JSON. A note governs only what it covers;
@ -567,6 +568,56 @@ it on a dev instance, fetch both, scrub, paste. Things worth knowing:
- **Requires the agents module.** A seeded agent restore fails loudly (as a
framework issue) on an instance where agents are disabled, rather than running
the case unseeded.
#### `folders` — "look at the ODW folder"
A folder case grades how the agent finds the contents of a folder the user names
(CONTEXT-86: the workflow list tool takes `folderPath` or `folderId`). Declare the
folders and place the seed workflows in them:
```json
"seed": {
"mode": "inline",
"folders": [{ "id": "odwFolder0001", "name": "ODW" }],
"workflows": [
{ "id": "odwSignal1Wf", "name": "Odds Watch - 1", "parentFolderId": "odwFolder0001", "nodes": [], "connections": {} },
{ "id": "rootWorkflow1", "name": "Voice Agent", "nodes": [], "connections": {} }
]
}
```
Things worth knowing:
- **Leave decoys at the root.** A folder case is only informative when the folder's
contents differ from what a name search would return. Keep workflows outside the
folder, and pick names a `query` filter for the folder name would not match.
- **Grade the mechanism and the result.** One expectation for the `folderPath` or
`folderId` call, one for reporting exactly the folder's workflows, one for not asking
the user what the folder holds. The miss path (no folder exists) is a separate case:
LangTracer case 699 grades it, and it needs no `folders` slot.
- **Names are verbatim and leftovers are evicted**, like seeded projects. The live turn
names the folder, so the created name has to match exactly; a root folder of the same
name that existed before the run started is deleted before the restore, with every
workflow and subfolder in it. There is no seed marker on the name, so a same-named
folder a human made on that instance goes too: use an eval instance, and a name a
real project would not use. A previous iteration's folder, created during the run,
is left alone.
- **Run it alone.** A folder case and its miss-path sibling (case 699) share the
project, so one invocation per case. With several iterations the previous folder can
still be live for a few seconds while it is judged; keep the name distinctive.
- **Nesting works.** A folder's own `parentFolderId` names another seed folder. Parents
are created first. The `folderPath` for a nested folder is `Parent/Child`, so a folder
name cannot contain `/`.
- **Rules are checked at case load**: ids of at least 8 characters, unique; every
`parentFolderId` names a declared folder; no cycles; trimmed names that pass n8n's
folder-name rules; at most 20 folders.
- **Needs a licensed instance** (`feat:folders`) with
`N8N_INSTANCE_AI_FOLDER_EXPLORATION_ENABLED=true`. An unlicensed instance fails the
restore with a hint rather than running the case without its folder. Locally, after
`/rest/e2e/reset`: `PATCH /rest/e2e/feature {"feature":"feat:folders","enabled":true}`.
- **Keep it on disk for now.** The LangTracer case-write API does not store
`seed.folders` or `workflows[].parentFolderId`, so the push refuses the case until
`n8n-io/lang-tracer` adds both.
#### Which opening shape — the agent is handed the workflow, or it has to find it
Two real conversations look the same in a case file but test different things, and

View File

@ -36,6 +36,12 @@ claude mcp add --scope local --transport http langtracer-hosted \
session start, so reconnect to pick it up. (The LangSmith MCP is usually already
connected.)
The MCP returns ids; the driver needs pages. Every thread, cluster, and case you
cite while sourcing has a web URL off the same base — `<base>/conversations/<threadId>`,
`<base>/clusters/<id>`, `<base>/test-cases/<id>` — so cite them as links, not bare
ids. Full table in
[Share links, never bare ids](SKILL.md#share-links-never-bare-ids).
## Discover → verify → encode
1. **Scan cluster themes** — `list_cluster_runs` / `get_latest_cluster_run`
@ -56,18 +62,68 @@ connected.)
content-dependent claim ("invented ID", "missing node") can be wrong when it
couldn't see the built workflow. Confirm against the raw trace before building
a case around it.
4. **Encode a durable synthetic case** — turn the confirmed failure into an
4. **Optionally record the selection as an observation.** Offer to save why this
thread was selected and what the developer observed. This is a best-effort
LangTracer note, not a gate: skip it when the developer declines or has not
stated a preference, and continue if the write fails. See
[Optional: record the selection as an observation](#optional-record-the-selection-as-an-observation).
5. **Encode a durable synthetic case** — turn the confirmed failure into an
authored case ([SKILL.md](SKILL.md), [`case-shapes.md`](case-shapes.md)). The
failure mode is the anchor; the conversation is yours to write, in the user's
voice.
5. **Push it to a curated suite** (don't commit the JSON) with
voice. **Write down the precondition, not just the failure mode** — the state
the thread was in when the call went wrong, which is usually narrower than the
theme label suggests and is often stated outright in the assistant text just
before the failing call. Get this wrong and the case grades green because it
never set the situation up; see
[First reproduce, then reclassify](SKILL.md#first-reproduce-then-reclassify).
6. **Push it to a curated suite** (don't commit the JSON) with
`eval:langtracer-push` — see
[Push to a lang-tracer suite](SKILL.md#push-to-a-lang-tracer-suite). An `inline`
seed rides along with the case. Exception: a `replay` case is refused and listed
under `skipped:` — it's reconstructed from a trace at run time, so it dies when
that trace is pruned and has no durable home; that's exactly why step 4 turns the
that trace is pruned and has no durable home; that's exactly why step 5 turns the
confirmed failure into a durable synthetic case.
## Optional: record the selection as an observation
After the raw trace confirms the finding, offer the developer one optional
record before drafting:
- **Why did you choose this thread?** Capture why it is useful eval coverage,
such as impact, recurrence, reproducibility, or a gap in the current suite.
- **What did you observe?** Capture the concrete behavior, the expected behavior,
and the run or turn that contains the strongest evidence.
If the developer opts in, call LangTracer's `create_observation` tool. Map the
record like this:
- `sourceThreadId`: the selected imported thread.
- `sourceRunId` and `anchorRunId`: the evidence run, when the finding is narrower
than the whole conversation.
- `kind`: `capability_gap` for a confirmed failure, `regression` for successful
behavior worth protecting, or `binary_check` when that is the intended output.
- `description`: why the developer selected this thread.
- `openCodeNote`: what the developer observed, in reviewer voice.
- `expectedBehavior`, `failurePattern`, and `proposedCheck`: the rule, the
observed deviation, and the check the eval should enforce.
- `severity` and `failureClass`: include them only when the evidence supports the
classification.
- `isTestCaseCandidate`: `true` because the developer selected it for eval
authoring.
This record is optional. Do not pause an autonomous run to request it when the
driver has not already opted in. In checkpoint mode, offer it once with the
selection proposal. A declined offer, unavailable MCP tool, or failed write must
not stop the eval workflow. Report a successful observation by id with the source
conversation link; otherwise omit it from the handoff.
Calibration can sharpen the note. If an observation exists, use
`update_observation` only for mutable details such as the expected behavior,
failure pattern, proposed check, severity, failure class, or reviewer note. Its
`kind` and source provenance are immutable, so classify the source evidence
before creation. Both observation tools are LangTracer-local. Do not create or
update LangSmith feedback for this record.
## Was the workflow handed over? (sourcing an `attach` opening)
Whether the opening turn carries `attach` is a **fact about the thread**, not a
@ -254,14 +310,17 @@ what catch a clean-up that kept the shape but lost the point.
You don't need to do these by hand:
- **Credential references on nodes are dropped as the seed loads.** The case's own
`credentials[]` decides what the assistant can see, and a credential's display name
goes with the reference.
- **Credential references on nodes resolve against the case's `credentials[]` as the
seed loads.** A reference is kept only when exactly one seeded credential has that
type and display name; any other reference is dropped. On a `published: true`
workflow an unresolved reference fails the restore instead.
- **Data tables are columns only.** The seed format has no place for rows, and a
`rows` key is rejected rather than quietly removed — so table contents can't come
along by accident.
- **A seed workflow is only `id`, `name`, `nodes` and `connections`.** Pinned example
data, instance metadata and settings never travel.
- **A seed workflow is only `id`, `name`, `nodes`, `connections` and an optional
`published` flag.** `published: true` activates the workflow on restore, the way the
user's publish left it. Pinned example data, instance metadata and settings never
travel.
- **Ids and workflow names are per run.** Ids are replaced with fresh ones and names
get a `[seed <8hex>]` suffix, with mentions updated in the prose — though not inside
node definitions or recorded tool calls.

View File

@ -14,6 +14,9 @@ compatibility:
Create a Linear ticket or GitHub issue for: **$ARGUMENTS**
Write all titles and descriptions in ASD-STE100 Simplified Technical English:
use short sentences, the active voice, and one instruction for each sentence.
## Determine Target
Decide where the issue should be created based on user input:
@ -73,17 +76,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 +115,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

@ -1,43 +0,0 @@
---
name: n8n:design-system
description: Guidelines on using Design System styles and components. Use when working on .vue files in packages/frontend. Triggers for tasks that include component architecture, styling, UI changes, or feature work.
---
# Design System
Comprehensive guide for building, styling, and using components in the frontend.
## When to Apply
Reference these guidelines when:
- Working on `.{vue|css|scss}` files in `packages/frontend`
- Adding new components to `packages/frontend/@n8n/design-system`
- Refactoring styles for Vue components
- Implementing new UI components or features
- Reviewing changes to UI
## Rules
- Follow guidelines in `packages/frontend/@n8n/design-system/src/styleguide/*.mdx`
- ALWAYS use CSS variables for styles from `packages/frontend/@n8n/design-system/src/css/_tokens.scss` or `packages/frontend/@n8n/design-system/src/css/_primitives.scss`. Use hard-coded values only when no suitable tokens.
- ALWAYS prefer using existing components from `packages/frontend/@n8n/design-system/src/components`. Prefer components that aren't marked `@deprecated`.
- Use `light-dark()` when alternating colors for ligh/dark mode
- If you need to add hover/active alpha behavior to solid components, prefer `color-mix()` with explicit percentages.
- When working with animations or transitions, ALWAYS prefer using mixins from `packages/frontend/@n8n/design-system/src/css/mixins/motion.scss`
- When reviewing animations, follow the guides in `rules/web-animation-guidelines.md`
- When reviewing UI changes or adding new components, follow `rules/web-interface-guidelines.md`
## Storybook Stories
- ALWAYS add new stories to `packages/frontend/@n8n/design-system`.
- Every story must use one of these title categories:
- `Style guide`: styles, tokens, and utilities
- `Core`: components used across the app
- `Areas/<Product area>`: patterns and components for a specific product area, for example `Areas/Settings` or `Areas/Assistant`
- `Experimental`: beta components that require caution
## Examples
- "Add a modal dialog for confirming workflow deletion" → Use `N8nDialog`
- "Add a dropdown to select workflow status" → Use `N8nDropdown` or `N8nSelect`
- "Add button with + icon to add new tiem" → Wrap `N8nButton` with `iconOnly` prop with `N8nTooltip` and wrap in `N8nTooltip`. Use `N8nIcon` and proper aria-label.
- "Add a destructive action button" → use `N8nButton` with `variant="destructive"`
- "Make background color white/black" → Use `var(--background--surface)` for white on light mode and "black" on dark mode
- "Animate the title in gracefully" -> Use `fade-in-up` mixin from `motion.scss` with `var(--duration--base)`

View File

@ -20,4 +20,4 @@ Start with the relevant mode in [reference.md](reference.md):
- `Review` for auditing experiment changes.
- `Retire` for cleaning up completed or abandoned experiments.
When experiment work touches Vue components or user-facing copy, also follow `n8n:design-system` and `n8n:content-design`.
When experiment work touches Vue components or user-facing copy, also follow `n8n:ui-design` and `n8n:content-design`.

View File

@ -66,7 +66,7 @@ Use this guide for code under `packages/frontend/editor-ui/src/experiments/` and
- Wire experiments at the narrowest host surface that owns the decision.
- Keep experiment components inside the experiment folder unless they become generally reusable.
- All user-facing text in Vue code must use i18n. Follow `n8n:content-design` when writing or revising copy.
- Follow `n8n:design-system` for Vue component structure, n8n design-system components, and CSS variables.
- Follow `n8n:ui-design` for Vue component structure, n8n design-system components, and CSS variables.
- At the end of scaffold or wiring work, tell the user what remains manual, such as PostHog configuration, route guards, modal registration, or extra workflow payload files.
## Test

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

@ -43,6 +43,11 @@ add to it.
- List endpoints: cursor-based pagination (internal API uses both cursor- and
page-based — don't copy an internal endpoint's model).
- Pagination args are always `offset` and `limit` — on service methods, handler
calls, and repository methods you add. Never `skip`/`take` (TypeORM names).
Translate to `skip`/`take` only inside a repository, at the TypeORM `find`
call. The public query string is still `cursor` + `limit`; `offset` is the
decoded cursor field passed into the service, never a client-facing param.
- Updates: full-object `PUT`, not `PATCH`. A successful `GET` body should be
acceptable as a `PUT` body for the same resource (round-trip), aside from
server-managed/immutable fields.
@ -113,9 +118,15 @@ model; reuse only what applies. Decorators, all from `@n8n/decorators`:
- `@ApiKeyScope` (what the API key is granted) and `@ProjectScope`/`@GlobalScope`
(what the user may do) are independent. Use both when the model needs both.
- `@ProjectScope` reads `req.params` as-is and does not remap `id` — name the path
param what the resolver expects (`workflowId`, `credentialId`, `projectId`,
`dataTableId`, …). A generic `id` often fails.
- Name every path param `{resource}Id` (e.g. `workflowId`, `credentialId`,
`projectId`, …) — never a generic `:id` / `{id}`. This is the Public API's
naming convention: it keeps the API self-documenting and gives typed SDK
codegen a real argument name instead of `id`. `@ProjectScope` also reads
`req.params` as-is and does not remap `id` — it resolves authorization by
exact key name (`workflowId`, `credentialId`, `projectId`, `dataTableId`,
…), so a generic `id` on a `@ProjectScope` route often fails outright; a
`@GlobalScope` or unscoped route won't fail the same way, but still follow
the convention.
- `@ApiKeyScope` takes a string, `{ anyOf: [...] }`, or `{ allOf: [...] }` — never
a bare array. The scope must exist in the permissions registry
(`API_KEY_RESOURCES` in `@n8n/permissions`); `scope-parity.test.ts` fails on an
@ -127,17 +138,29 @@ model; reuse only what applies. Decorators, all from `@n8n/decorators`:
on `@ApiResponse` stripping to hide fields.
- Treat the output DTO as an allowlist. Re-check nested relations, ownership
fields, tokens, and encrypted values.
- Make input DTOs strict so unknown/partial fields aren't silently accepted.
- An output DTO restricts which fields you return, not which values they may hold.
The registry parses the handler's return value against it, so a value the schema
rejects becomes a `500`. Keep the schema loose enough for anything an existing
row may contain.
- Build the response from the relations the route loaded, not from the entity type.
TypeORM relations are opt-in, so two routes over the same entity can return
different shapes.
- Make input DTOs strict so unknown/partial fields aren't silently accepted:
`Z.class(shape, { strict: true })`.
- Secrets: never return a real secret; use the resource's sentinel/placeholder
(or omit). See [Updates and write-only secrets](reference.md#updates-and-write-only-secrets).
## List endpoints (cursor pagination)
Copy the cursor flow from `tags.public.controller.ts`. Use `publicApiPaginationSchema`
plus `decodeCursor` / `encodeNextCursor` from the shared pagination service; the
Copy the cursor flow from `tags.public.controller.ts`. The input DTO takes
`limit: publicApiPaginationSchema.limit` plus `cursor: z.string().optional()` —
pick `limit` off the schema, never spread the whole `publicApiPaginationSchema`
(it also exports `offset`, which must never be a Public API query param). Use
`decodeCursor` / `encodeNextCursor` from the shared pagination service; the
cursor is opaque; return `{ data, nextCursor }` (never a bare array) with
`nextCursor: null` on the last page; an invalid cursor is a `400`. Preserve an
existing endpoint's pagination as-is. Detail:
existing endpoint's cursor semantics as-is — but an `offset` param is a
defect to remove, not a contract to preserve. Detail:
[List endpoints and cursor pagination](reference.md#list-endpoints-and-cursor-pagination).
## Wiring checklist
@ -177,3 +200,5 @@ existing tests.
- [Errors](reference.md#errors)
- [Testing matrix](reference.md#testing-matrix)
- [Migrating legacy EOV endpoints](reference.md#migrating-legacy-eov-endpoints)
- [Verifying a migration](reference.md#verifying-a-migration)
- [CI and merging](reference.md#ci-and-merging)

View File

@ -13,18 +13,29 @@ Copy the working flow from `v1/controllers/tags.public.controller.ts` (and
`workflows.public.controller.ts` for a `@Param` list) rather than pasting a
snippet here — a copy would drift. The moving parts:
- Input DTO composes `publicApiPaginationSchema` from `@n8n/api-types` (a `limit`
plus an opaque `cursor`).
- Input DTO takes `limit: publicApiPaginationSchema.limit` plus an opaque
`cursor: z.string().optional()` — cherry-pick `limit` but never spread the whole
`publicApiPaginationSchema`. That schema also exports `offset`, used by
internal-API-style page params elsewhere; a Public API list DTO must never
expose it as a query param. See `ListTagsQueryDto` for the shape to copy.
- `decodeCursor` / `encodeNextCursor` live in
`v1/shared/services/pagination.service.ts`. Decode the incoming cursor to
`{ offset, limit }`, guard the decoded shape, and pass `offset`/`limit` to the
service.
service — never `{ skip, take }`. `offset` is an internal implementation
detail of the cursor here, never a client-facing query param. TypeORM's
`skip`/`take` stay inside the repository, at the `find` call.
- Treat the cursor as opaque; never hand-encode a token.
- Return an envelope `{ data, nextCursor }` — never a bare array.
- `encodeNextCursor(...)` returns `null` when there is no further page; surface
that as `nextCursor: null`.
- An invalid/undecodable cursor is a `400` via the existing bad-request error.
- For an existing list endpoint, keep its current pagination semantics unchanged.
- For an existing list endpoint, keep its current *cursor* semantics unchanged.
A leaked `offset` query param (DTO spreading `publicApiPaginationSchema`
instead of picking `limit`) is a defect to remove, not a contract to
preserve — decorator-routed DTOs validate via a plain `z.object()`, which
silently strips unknown query keys rather than rejecting them, so removing
`offset` from the DTO makes it inert rather than erroring for existing
callers.
The output DTO wraps the list as `{ data, nextCursor }` and is declared with
`@ApiResponse(...)` so the registry strips undeclared fields.
@ -89,7 +100,8 @@ scope, RBAC denial. Add whichever apply, matching the nearest existing tests:
`PUT` with the exact sentinel keeps the secret; any other value replaces it;
the sentinel is never persisted as a real secret.
- Migration: path, method, status codes, scope, and response contract are
unchanged.
unchanged. Tests alone cannot show this — see
[Verifying a migration](#verifying-a-migration).
## Migrating legacy EOV endpoints
@ -136,3 +148,36 @@ not templates.
— never extend them).
- For complex legacy-only, multipart, or non-standard endpoints, study the
nearest existing handler first.
- Keep each field in its original position when you extract a request shape shared
by two routes, and destructure out the ones a route doesn't take. The generator
emits properties in shape order, so a moved field rewrites the `*.generated.yml`
of a route the PR wasn't changing.
## Verifying a migration
Tests are written against the new code, so they can't show that the old behavior
survived. These checks can:
- Diff live responses against master. Run the route on an instance per branch and
compare status, body, and error message for the same requests. Reading the old
YAML beside the new Zod schema doesn't find the differences.
- Check whether a field is absent or `null`. `activeVersion` omitted is not the
same response as `activeVersion: null`; use a conditional spread to omit it.
- Check query-param coercion at the edges (`""`, `"0"`, `"false"`, absent). A Zod
DTO and the old validator don't coerce identically.
- Request a route the PR didn't migrate. A shared DTO change can stop the spec
bundle compiling at startup, which turns every legacy route into a `500` without
failing a test.
## CI and merging
Two failures that a migration hits outside the code itself:
- Merge master in before merging. A generator change on master leaves every
branch's committed `*.generated.yml` stale, and `generated-spec-drift.test.ts`
then fails only on the merge commit, so the PR itself stays green and
`MERGEABLE`. `gh pr checks` hides `merge_group` runs; look for the run on
`gh-readonly-queue/master/pr-<number>-<sha>`.
- Ask a maintainer for a `/size-limit-override` early. Generated YAML counts
toward the 1,000-line PR size limit, so a single-route migration can exceed it
on generator output alone.

View File

@ -0,0 +1,39 @@
---
name: n8n:ui-design
description: Guidelines on designing and building UI. Use when working in editor-ui or design-system packages. Triggers for tasks that include refactoring components, styling changes, or feature work.
---
# UI Design
Comprehensive guide for building, styling, and using components in the frontend.
## References
- When styling components, use `packages/frontend/@n8n/design-system/src/styleguide/*.mdx`
- For animations, use `rules/web-animation-guidelines.md`
- When reviewing UI changes, use `rules/web-interface-guidelines.md`
## Best practices
- ALWAYS use CSS variables from `_tokens.scss` or `_primitives.scss` in
`packages/frontend/@n8n/design-system/src/css/`. Look up every length, color,
font-size and duration before you write it as a literal. Each spacing token
carries its px value in a comment: `--spacing--5xs: 0.125rem; /** 2px **/`.
Grep that comment to find the token for a px value. The grep gives one hit for
each token family, so pick the family that matches the property: `--spacing--*`
for margin and padding, `--radius--*` for border-radius, `--height--*` for
height. The styleguide
`packages/frontend/@n8n/design-system/src/styleguide/spacing-size.mdx` lists the
same scale as a table. The spacing scale starts at 2px, so a small value is not
a reason to hardcode. Hardcode only if the lookup finds no token. Give the
reason in a comment.
- ALWAYS prefer using existing components from `packages/frontend/@n8n/design-system/src/components`. Prefer components that aren't marked `@deprecated`.
- If you need to add hover/active alpha behavior to solid components, prefer `color-mix()` with explicit percentages.
- When working with animations or transitions, ALWAYS prefer using mixins from `packages/frontend/@n8n/design-system/src/css/mixins/motion.scss`
## Components
Use existing `design-system` components over creating custom implementations:
- "Add a modal dialog for confirming workflow deletion" → Use `N8nDialog`
- "Add a dropdown to select workflow status" → Use `N8nDropdown` or `N8nSelect`
- "Add button with + icon to add new tiem" → Wrap `N8nButton` with `iconOnly` prop with `N8nTooltip` and wrap in `N8nTooltip`. Use `N8nIcon` and proper aria-label.
- "Add a destructive action button" → use `N8nButton` with `variant="destructive"`
- "Make background color white/black" → Use `var(--background--surface)` for white on light mode and "black" on dark mode
- "Animate the title in gracefully" -> Use `fade-in-up` mixin from `motion.scss` with `var(--duration--base)`

View File

@ -1,3 +1,3 @@
{
"issues": 169
"issues": 163
}

View File

@ -0,0 +1 @@
../../../../.agents/skills/create-agent-builder-eval

View File

@ -1 +0,0 @@
../../../../.agents/skills/design-system

View File

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

View File

@ -0,0 +1 @@
../../../../.agents/skills/ui-design

File diff suppressed because it is too large Load Diff

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 \
@ -24,8 +24,8 @@ 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
# VS Code terminals load secrets here because they do not read profile.d.
RUN echo '[ "${N8N_SKIP_CODESPACE_SECRETS:-}" = "1" ] || . /usr/local/lib/codespaces-env.sh' >> /etc/bash.bashrc
COPY tmux.conf /etc/tmux.conf

View File

@ -11,8 +11,9 @@ Playwright system deps, and Docker-in-Docker (for testcontainers and
## 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`.
1. Add provider keys at [github.com/settings/codespaces](https://github.com/settings/codespaces).
Add `ANTHROPIC_API_KEY` for Claude Code. Add `OPENROUTER_API_KEY` for OpenCode.
Give both secrets access to `n8n-io/n8n`.
(Alternative for Max subscriptions: `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`.)
2. Give the GitHub CLI the codespace scope:
@ -23,30 +24,101 @@ Playwright system deps, and Docker-in-Docker (for testcontainers and
## 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
pnpm session # attach Claude Code (creates everything on first run)
pnpm session:shell # open a shell in the default checkout
pnpm session:opencode # attach OpenCode in auto mode
pnpm session:opencode fix-flaky # OpenCode in a separate worktree
pnpm session fix-flaky # Claude Code in a separate worktree
pnpm session ls # what's running
pnpm session tunnel # forward n8n ports (default 5678, 8080); Ctrl-C to stop
pnpm session stop # end of day: billing stops, disk survives
pnpm session rm # delete the codespace
```
The dev container supports Codespaces with 2, 4, or 8 cores. The session commands
create an 8-core Codespace by default.
- **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.
- **Reattach** by running the same session command 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).
## Configure the local OpenCode harness
Install the pinned shared harness:
```bash
pnpm agent:harness
```
The command verifies the release checksum. It caches the bundle under
`~/.cache/n8n-agent-harness`. It then links the bundle plugin into
`~/.config/opencode/plugins`. Run the command again after the lock changes.
Restart OpenCode after the command completes.
## PR previews (a running instance of someone else's PR)
A preview is a codespace that serves one pull request, so a reviewer can use the
PR instead of reading it. It is a different box from a `pnpm session`: it uses
`.devcontainer/preview/devcontainer.json`, and its display name is
`preview/pr-<number>`.
**From GitHub:** for a PR targeting master, add the `codespace-preview` label to the PR.
[util-codespace-preview.yml](../../.github/workflows/util-codespace-preview.yml)
creates the box, serves the PR head, shares port 5678 with the org, and comments
the URL on the PR. A later push serves the new head in the same box. Remove the
label, or close the PR, to delete the box. The same workflow runs by hand from
the Actions tab: give it a PR number and `up`, `refresh` or `down`.
**From your laptop:** `pnpm preview up <pr>` does the same thing, plus
`pnpm preview refresh <pr>`, `pnpm preview down <pr>` and `pnpm preview ls`. It
needs `gh` with the codespace scope, the same as `pnpm session`.
- **Watch it come up on the PR.** The comment appears before the box work starts
and updates about once a minute with a checklist of the phases, so you can see
which step a slow preview is on. `pnpm preview up <pr>` prints the same phases as
plain progress — the markers the comment reads are `--json` only.
- **Sign in with one click** at `<url>/preview-signin`. It logs you in as the
seeded owner and sends you to the editor. The credentials are
`preview@n8n.io` / `PreviewInstance1`. They are not secrets: the boundary is
the org-visible forwarded port, which needs a GitHub sign-in and n8n org
membership.
- **Configure the instance with `preview:*` labels.** `preview:enterprise` serves
it with a licence, so enterprise features such as SSO and source control are
present. `preview:debug` sets `N8N_LOG_LEVEL=debug`. Adding or removing one
re-serves the box; it never creates or deletes one. From a laptop the labels
apply the same way — `pnpm preview refresh <pr>` reads them from the PR. The
toggles are defined in `scripts/preview-labels.mjs`; add new ones there.
- **Configure the instance from a webhook.** A preview also reads extra
environment from an n8n webhook, so a value can change without a commit. It
needs the `CODESPACE_ENV_URL`, `CODESPACE_ENV_USER` and `CODESPACE_ENV_PASSWORD`
codespace secrets. Every key the webhook returns becomes an environment
variable, so editing that workflow runs code in the box. Without the secrets
the preview serves as usual. See [WORKFLOWS.md](../../.github/WORKFLOWS.md).
- **A preview sleeps after 2 hours** of no use and GitHub deletes it after 24
hours. A box that slept serves nothing and its port is private again, so wake
it with `pnpm preview up <pr>` or a manual run of
[util-codespace-preview.yml](../../.github/workflows/util-codespace-preview.yml)
with `up`. Removing and adding the label works too, but it deletes the box and
builds a new one.
- **A PR from a fork gets no preview.** A codespace's token is scoped to
`n8n-io/n8n`, so it cannot check out a fork head.
- **A PR that predates this tooling has no `scripts/preview-serve.mjs`.** The
serve step says so and stops; rebase the PR on master and retry.
## Agent worker (drive a session from n8n)
`agent-worker.mjs` lets an n8n workflow drive a Claude Code session on the
`agent-worker.mjs` lets an n8n workflow drive an OpenCode 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.
Each turn runs `openrouter/openai/gpt-5.6-sol` through
`opencode run --format json --auto`. The session state remains on disk. The
returned session ID continues a conversation across turns and a Codespace
restart.
**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
@ -55,22 +127,62 @@ 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
The post-start command installs the pinned OpenCode harness before it starts the
worker. It verifies a valid cached bundle before reuse. The worker does not start
if the harness is unavailable. `/tmp/post-start-status.json` contains the harness
and worker status.
The worker needs three
secrets and uses three optional 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.
- `OPENROUTER_API_KEY` — the model provider key for Sol.
- `SLACK_BOT_TOKEN` — optional bot token for progress messages. It needs `chat:write` only.
- `FLAKY_MCP_URL` and `FLAKY_MCP_TOKEN` — optional Flaky MCP connection for OpenCode.
The worker explicitly reloads `/usr/local/lib/codespaces-env.sh` when its tmux
session starts. An existing tmux server can otherwise retain an environment
that did not load the Codespaces secrets.
The dequeue payload can include `slack.channel` and `slack.thread_ts`. The
worker posts one placeholder in that thread. It coalesces completed tool calls.
It updates the message at most once every 1.5 seconds. It does not send reasoning
text. The worker replaces the placeholder with the final answer.
If the Slack API fails, the turn still completes through the n8n resume URL.
The worker does not export its dequeue or Slack credentials to OpenCode. It uses
the harness `sandbox` runtime and `slack` profile. The profile supplies the
atomic-turn instruction.
Interactive sessions remove all worker-only credentials before they start.
Interactive OpenCode uses the global harness plugin with the `sandbox` runtime.
It does not set a profile. It keeps the dynamic OpenRouter configuration.
An idle worker starts with a 3-second poll interval. After each empty dequeue,
it doubles the interval and limits it to 30 seconds. Work resets the interval
to 3 seconds. Queued turns run without a delay between them.
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.
required 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 harness 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 harness `slack` profile
states this contract. 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
@ -80,10 +192,15 @@ session rarely needs a cold `pnpm install` or a full `pnpm build`. Both are slow
- **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`. The port
is private. It opens for you in a browser that is signed in to GitHub. You do
not need a tunnel. An anonymous or server caller gets a 302. That is why the
worker polls outward instead.
- **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
@ -95,28 +212,41 @@ session rarely needs a cold `pnpm install` or a full `pnpm build`. Both are slow
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.
- Give a long build its own turn. Do not chain an install and a full build
behind other work in one turn.
- 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
Claude sessions and the headless OpenCode worker 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
and repo investigation. The worker keeps the token in its environment and puts
only an environment reference in the OpenCode config. Claude login registers
the same server without writing the token to disk. 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)
## Quality and security skills (Claude plugins)
Claude sessions can also load the private quality skills from the
`n8n-io/n8n-claude-skills` plugin marketplace (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.
Claude sessions can also load the private skills from the
`n8n-io/n8n-agent-skills` repository. `post-start.mjs` installs both plugins on
each container start, so every session gets the skills with no per-session step:
- `quality` — bug insights, defect attribution, flaky test investigation,
mutation and property testing, PR council, and more.
- `security` — security code review, adversarial review of security-fix PRs,
regression test generation, and Security Hub report triage.
Together they add roughly 5k always-on tokens to every session. Drop a plugin
from `PLUGINS` in `plugins.mjs` if that budget matters more than the skills.
`post-start.mjs` and the `pnpm session` prelude in `scripts/cloud-session.mjs`
both read that list, so a session that races the container start still gets
every plugin.
The private marketplace uses the codespace's own GitHub auth — no extra token.
`devcontainer.json` grants the codespace read access to
`n8n-io/n8n-claude-skills` via `customizations.codespaces.repositories`, and
`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.
@ -127,9 +257,34 @@ 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.
need a recreate to get the prompt.
### When the skills are missing
`/tmp/post-start-status.json` lists what installed and what did not, and
`/tmp/post-start.log` has the detail. Reading the log:
- A failed `skills repo reachable` line means the grant was not authorized.
- A `marketplace add` failure mentioning `File exists` is a clone that died
partway through `~/.claude/plugins/marketplaces/n8n-io-n8n-agent-skills`, the
path the loader stages into before renaming it to the cache. This has been
seen once as a transient failure, so the script removes that path and retries
the add once.
- Any other `marketplace add` failure after a reachable repo means a
loader-auth problem.
A failure that survives the retry needs a human — the container still starts and
the worker still runs, only the skills are missing.
Both `marketplace add` and `plugin install` are idempotent, so re-running the
script by hand is safe:
```bash
node /workspaces/n8n/.devcontainer/codespaces/post-start.mjs
```
Verify with `claude plugin list`, then restart the session (or `/reload-plugins`)
to pull the skills into context.
## Viewing the dev UI locally
@ -187,6 +342,14 @@ After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
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
@ -213,3 +376,7 @@ 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.
Previews draw from the same budget on a 2-core box. Remove the
`codespace-preview` label when the review is done instead of waiting for the
24-hour retention.

View File

@ -1,32 +1,43 @@
#!/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 (codespaces set this)
// CODESPACE_NAME stable box id; routes a thread back to the box holding its session (codespaces set this)
// TURN_TIMEOUT_MS per-turn limit; keep below the n8n Wait limit (default 25 min)
import { execFile } from 'node:child_process';
// GitHub keeps Codespaces ports private, so inbound delivery is not available.
import { spawn } from 'node:child_process';
import { resolve as resolvePath, sep } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { pathToFileURL } from 'node:url';
import { codespaceEnv } from '../../scripts/codespace-env.mjs';
const DEQUEUE_URL = process.env.N8N_DEQUEUE_URL;
const TOKEN = process.env.AGENT_WORKER_TOKEN;
const GITHUB_USER = process.env.GITHUB_USER;
const BOX_ID = process.env.CODESPACE_NAME;
const SLACK_TOKEN = process.env.SLACK_BOT_TOKEN;
// tmux can retain empty identity values, but the Codespaces files stay current.
const GITHUB_USER = codespaceEnv('GITHUB_USER');
const BOX_ID = codespaceEnv('CODESPACE_NAME');
const ROOT = '/workspaces';
const POLL_INTERVAL_MS = 3000;
const INITIAL_POLL_INTERVAL_MS = 3000;
const MAX_POLL_INTERVAL_MS = 30_000;
const SLACK_UPDATE_INTERVAL_MS = 1500;
const SLACK_TEXT_LIMIT = 3900;
export function openCodeConfig(environment) {
const config = {
provider: { openrouter: { options: { apiKey: '{env:OPENROUTER_API_KEY}' } } },
};
if (environment.FLAKY_MCP_URL && environment.FLAKY_MCP_TOKEN) {
config.mcp = {
flaky: {
type: 'remote',
url: environment.FLAKY_MCP_URL,
enabled: true,
oauth: false,
headers: { Authorization: 'Bearer {env:FLAKY_MCP_TOKEN}' },
},
};
}
return config;
}
// 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;
@ -36,47 +47,169 @@ function posNum(name, 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);
function nextIdlePollInterval(interval) {
return Math.min(interval * 2, MAX_POLL_INTERVAL_MS);
}
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);
export async function pollOnce(
interval,
{ dequeueTurn = dequeue, handleTurn = handle, wait = sleep, logError = console.error } = {},
) {
let turn;
try {
turn = await dequeueTurn();
} catch (error) {
logError(`poll error: ${error.message}`);
await wait(interval);
return nextIdlePollInterval(interval);
}
if (!turn) {
await wait(interval);
return nextIdlePollInterval(interval);
}
try {
await handleTurn(turn);
return INITIAL_POLL_INTERVAL_MS;
} catch (error) {
logError(`poll error: ${error.message}`);
await wait(INITIAL_POLL_INTERVAL_MS);
return nextIdlePollInterval(INITIAL_POLL_INTERVAL_MS);
}
}
function runClaude({ message, sessionId, cwd }) {
// This limit expires before n8n's Wait node so that the user receives a specific error.
const TURN_TIMEOUT_MS = posNum('TURN_TIMEOUT_MS', 25 * 60_000);
function turnTimeoutMessage(timeout) {
const duration = timeout % 60_000 === 0 ? `${timeout / 60_000}-minute` : `${timeout}-millisecond`;
return `The turn passed the ${duration} limit and stopped. It may have been in a build. Do a smaller step, or run a long build in its own turn.`;
}
export function openCodeEnvironment(environment) {
const childEnvironment = { ...environment };
delete childEnvironment.AGENT_WORKER_TOKEN;
delete childEnvironment.N8N_DEQUEUE_URL;
delete childEnvironment.SLACK_BOT_TOKEN;
childEnvironment.OPENCODE_CONFIG_CONTENT = JSON.stringify(openCodeConfig(childEnvironment));
childEnvironment.N8N_AGENT_RUNTIME = 'sandbox';
childEnvironment.N8N_AGENT_PROFILE = 'slack';
return childEnvironment;
}
// OpenCode does not need the credentials that control the broker.
const TURN_ENV = openCodeEnvironment(process.env);
if (BOX_ID) TURN_ENV.CODESPACE_NAME = BOX_ID;
if (GITHUB_USER) TURN_ENV.GITHUB_USER = GITHUB_USER;
function safeCwd(cwd) {
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'];
if (sessionId) args.push('--resume', sessionId);
args.push(message);
return new Promise((res, rej) => {
execFile(
'claude',
args,
{ cwd: safeCwd, 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'));
}
},
);
return safeCwd;
}
function stopProcessTree(child, signal) {
if (!child.pid) return;
try {
process.kill(-child.pid, signal);
} catch {
child.kill(signal);
}
}
function eventError(event) {
return event.error?.data?.message ?? event.error?.message ?? event.error?.name;
}
export function runOpenCode(
{ message, sessionId, cwd },
onEvent,
onSession,
{
spawnProcess = spawn,
stopProcess = stopProcessTree,
timeout = TURN_TIMEOUT_MS,
killDelay = 5000,
} = {},
) {
const directory = safeCwd(cwd);
const args = ['run', '--model', 'openrouter/openai/gpt-5.6-sol', '--format', 'json', '--auto'];
if (sessionId) args.push('--session', sessionId);
return new Promise((resolve, reject) => {
const child = spawnProcess('opencode', args, {
cwd: directory,
detached: true,
env: TURN_ENV,
stdio: ['pipe', 'pipe', 'pipe'],
});
let activeSessionId = sessionId ?? '';
let buffer = '';
let stderr = '';
let error = '';
const text = [];
let timedOut = false;
let forceTimer;
const timer = setTimeout(() => {
timedOut = true;
stopProcess(child, 'SIGTERM');
forceTimer = setTimeout(() => stopProcess(child, 'SIGKILL'), killDelay);
forceTimer.unref?.();
}, timeout);
const consume = (line) => {
if (!line.trim()) return;
let event;
try {
event = JSON.parse(line);
} catch {
console.error(`OpenCode output ignored: ${line.slice(0, 200)}`);
return;
}
if (!activeSessionId && typeof event.sessionID === 'string') {
activeSessionId = event.sessionID;
onSession(activeSessionId);
}
if (event.sessionID !== activeSessionId) return;
onEvent(event);
if (event.type === 'text' && typeof event.part?.text === 'string') text.push(event.part.text);
if (event.type === 'error') error = eventError(event) || 'OpenCode failed';
};
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
buffer += chunk;
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? '';
for (const line of lines) consume(line);
});
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk) => (stderr = `${stderr}${chunk}`.slice(-4000)));
child.once('error', (processError) => {
clearTimeout(timer);
if (forceTimer) clearTimeout(forceTimer);
reject(processError);
});
child.once('close', (code) => {
clearTimeout(timer);
if (forceTimer) clearTimeout(forceTimer);
consume(buffer);
if (timedOut) {
reject(new Error(turnTimeoutMessage(timeout)));
return;
}
if (code !== 0 || error) {
const failureMessage = error || stderr.trim() || `OpenCode exited with ${code}`;
reject(new Error(failureMessage));
return;
}
if (!activeSessionId) {
reject(new Error('OpenCode did not return a session id'));
return;
}
resolve({ result: text.join('\n').trim(), session_id: activeSessionId });
});
child.stdin.end(message);
});
}
@ -93,20 +226,136 @@ 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
if (!text.trim()) return null;
const turn = JSON.parse(text);
return turn?.turnId ? turn : null;
}
async function handle(turn) {
let result;
async function slackApi(method, body) {
const res = await fetch(`https://slack.com/api/${method}`, {
method: 'POST',
headers: { authorization: `Bearer ${SLACK_TOKEN}`, 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
const result = await res.json();
if (!res.ok || !result.ok) throw new Error(result.error || `HTTP ${res.status}`);
return result;
}
function progressText(tools) {
const progress = [...tools.values()].slice(-6).map(({ status, title }) => {
if (status === 'error') return `Failed: ${title}`;
return `Done: ${title}`;
});
return (progress.length ? progress.join('\n') : 'Flaky is working…').slice(0, SLACK_TEXT_LIMIT);
}
function finalSlackText(text, sessionId, boxId) {
const metadata = [sessionId && `⟳session:${sessionId}`, boxId && `⟳box:${boxId}`]
.filter(Boolean)
.join(' ');
const suffix = metadata ? `\n\n${metadata}` : '';
const body = text || 'Flaky completed the turn';
return `${body.slice(0, SLACK_TEXT_LIMIT - suffix.length)}${suffix}`;
}
const NO_SLACK_PROGRESS = { event() {}, async finish() {} };
export async function startSlackProgress(
turn,
{
callSlack = SLACK_TOKEN ? slackApi : undefined,
updateInterval = SLACK_UPDATE_INTERVAL_MS,
} = {},
) {
const channel = turn.slack?.channel;
const threadTs = turn.slack?.thread_ts;
if (!callSlack || typeof channel !== 'string' || typeof threadTs !== 'string')
return NO_SLACK_PROGRESS;
let message;
try {
const r = await runClaude(turn);
message = await callSlack('chat.postMessage', {
channel,
thread_ts: threadTs,
text: 'Flaky is working…',
});
} catch (error) {
console.error(`turn ${turn.turnId}: Slack placeholder failed: ${error.message}`);
return NO_SLACK_PROGRESS;
}
const tools = new Map();
let timer;
let inFlight;
let pending;
let lastUpdate = 0;
let stopped = false;
const update = (text) =>
callSlack('chat.update', { channel, ts: message.ts, text }).catch((error) =>
console.error(`turn ${turn.turnId}: Slack update failed: ${error.message}`),
);
const flush = () => {
if (stopped || timer || inFlight || pending === undefined) return;
const delay = Math.max(0, lastUpdate + updateInterval - Date.now());
timer = setTimeout(() => {
timer = undefined;
const text = pending;
pending = undefined;
lastUpdate = Date.now();
inFlight = update(text).finally(() => {
inFlight = undefined;
flush();
});
}, delay);
};
const schedule = () => {
pending = progressText(tools);
flush();
};
return {
event(event) {
if (!event.part?.id) return;
if (event.type === 'tool_use') {
tools.set(event.part.id, {
status: event.part.state?.status,
title: event.part.state?.title || event.part.tool,
});
schedule();
}
},
async finish(text, sessionId, boxId) {
stopped = true;
pending = undefined;
if (timer) {
clearTimeout(timer);
timer = undefined;
}
if (inFlight) await inFlight;
const delay = Math.max(0, lastUpdate + updateInterval - Date.now());
if (delay) await sleep(delay);
await update(finalSlackText(text, sessionId, boxId));
},
};
}
async function handle(turn) {
console.log(
`${new Date().toISOString()} turn ${turn.turnId} by ${turn.author ?? 'unknown'}: ${turn.sessionId ? 'resume' : 'new'}`,
);
let result;
let activeSessionId = turn.sessionId ?? '';
const progress = await startSlackProgress(turn);
try {
const r = await runOpenCode(turn, progress.event, (sessionId) => (activeSessionId = sessionId));
result = {
turnId: turn.turnId,
status: 'done',
output: r.result ?? '',
sessionId: r.session_id ?? turn.sessionId ?? '',
output: r.result,
sessionId: r.session_id,
boxId: BOX_ID,
};
} catch (error) {
@ -114,13 +363,12 @@ async function handle(turn) {
turnId: turn.turnId,
status: 'error',
output: error.message,
sessionId: turn.sessionId ?? '',
sessionId: activeSessionId,
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.
await progress.finish(result.output, result.sessionId, result.boxId);
// The resume POST is the delivery contract. Retry transient failures.
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await post(turn.resumeUrl, result);
@ -136,19 +384,33 @@ async function handle(turn) {
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
async function main() {
for (const [key, value] of Object.entries({
N8N_DEQUEUE_URL: DEQUEUE_URL,
AGENT_WORKER_TOKEN: TOKEN,
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
GITHUB_USER,
})) {
if (!value) {
console.error(`Refusing to start: ${key} is not set.`);
process.exit(1);
}
} catch (error) {
console.error(`poll error: ${error.message}`);
}
await sleep(POLL_INTERVAL_MS);
if (!BOX_ID)
console.error(
'CODESPACE_NAME did not resolve — box pinning disabled; turns route by githubUser only.',
);
if (!SLACK_TOKEN)
console.error(
'SLACK_BOT_TOKEN is not set. Turns will complete without Slack progress updates.',
);
console.log(
`agent-worker polling as ${GITHUB_USER} every ${INITIAL_POLL_INTERVAL_MS}-${MAX_POLL_INTERVAL_MS}ms`,
);
let pollInterval = INITIAL_POLL_INTERVAL_MS;
for (;;) pollInterval = await pollOnce(pollInterval);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();

View File

@ -0,0 +1,383 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { readFileSync } from 'node:fs';
import { PassThrough } from 'node:stream';
import { test } from 'node:test';
import { setTimeout as sleep } from 'node:timers/promises';
import {
openCodeConfig,
openCodeEnvironment,
pollOnce,
runOpenCode,
startSlackProgress,
} from './agent-worker.mjs';
const turn = {
turnId: 'turn-1',
slack: { channel: 'C123', thread_ts: '123.456' },
};
test('reloads Codespaces secrets before the worker starts', () => {
const postStart = readFileSync(new URL('./post-start.mjs', import.meta.url), 'utf8');
assert.match(
postStart,
/bash -lc "\. \/usr\/local\/lib\/codespaces-env\.sh; .*node \/workspaces\/n8n\/\.devcontainer\/codespaces\/agent-worker\.mjs/,
);
assert.match(postStart, /installAgentHarness\(\)/);
assert.match(postStart, /harness\.status === 'active' &&\s+tryRun\('worker start'/);
assert.match(postStart, /JSON\.stringify\(\{ installed, failed, harness, workerStarted \}/);
});
function slackRecorder() {
const calls = [];
return {
calls,
async call(method, body) {
calls.push({ method, body });
return method === 'chat.postMessage' ? { ok: true, ts: '456.789' } : { ok: true };
},
};
}
async function waitFor(check) {
for (let attempt = 0; attempt < 100; attempt++) {
if (check()) return;
await sleep(5);
}
throw new Error('Condition was not met');
}
function childProcess() {
const child = new EventEmitter();
child.pid = 123;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = () => {};
return child;
}
function completedChild(lines) {
const child = childProcess();
setImmediate(() => {
for (const line of lines) child.stdout.write(`${line}\n`);
child.stdout.end();
child.stderr.end();
child.emit('close', 0);
});
return child;
}
test('backs off idle polls and resets when work arrives', async () => {
let interval = 3000;
const waits = [];
const turns = [null, null, null, null, null, { turnId: '1' }, { turnId: '2' }];
const handled = [];
for (let attempt = 0; attempt < 5; attempt++) {
interval = await pollOnce(interval, {
dequeueTurn: async () => turns.shift(),
handleTurn: async (turn) => handled.push(turn.turnId),
wait: async (delay) => waits.push(delay),
});
}
assert.deepEqual(waits, [3000, 6000, 12_000, 24_000, 30_000]);
assert.equal(interval, 30_000);
interval = await pollOnce(interval, {
dequeueTurn: async () => turns.shift(),
handleTurn: async (turn) => handled.push(turn.turnId),
wait: async (delay) => waits.push(delay),
});
interval = await pollOnce(interval, {
dequeueTurn: async () => turns.shift(),
handleTurn: async (turn) => handled.push(turn.turnId),
wait: async (delay) => waits.push(delay),
});
assert.equal(interval, 3000);
assert.deepEqual(handled, ['1', '2']);
assert.equal(waits.length, 5);
});
test('backs off after a dequeue error', async () => {
const waits = [];
const errors = [];
const interval = await pollOnce(3000, {
dequeueTurn: async () => {
throw new Error('unavailable');
},
wait: async (delay) => waits.push(delay),
logError: (error) => errors.push(error),
});
assert.equal(interval, 6000);
assert.deepEqual(waits, [3000]);
assert.deepEqual(errors, ['poll error: unavailable']);
});
test('resets the interval before handling work', async () => {
const waits = [];
const errors = [];
const interval = await pollOnce(30_000, {
dequeueTurn: async () => ({ turnId: '1' }),
handleTurn: async () => {
throw new Error('failed');
},
wait: async (delay) => waits.push(delay),
logError: (error) => errors.push(error),
});
assert.equal(interval, 6000);
assert.deepEqual(waits, [3000]);
assert.deepEqual(errors, ['poll error: failed']);
});
test('streams tool progress but excludes reasoning', async () => {
const slack = slackRecorder();
const progress = await startSlackProgress(turn, {
callSlack: slack.call,
updateInterval: 50,
});
progress.event({
type: 'reasoning',
sessionID: 'ses_1',
part: { id: 'prt_reason', type: 'reasoning', text: 'private reasoning' },
});
progress.event({
type: 'tool_use',
sessionID: 'ses_1',
part: {
id: 'prt_tool',
type: 'tool',
tool: 'read',
state: { status: 'completed', title: 'Read worker source' },
},
});
await sleep(100);
const streamed = slack.calls.find((call) => call.method === 'chat.update')?.body.text;
assert.match(streamed, /Done: Read worker source/);
assert.doesNotMatch(streamed, /private reasoning/);
});
test('coalesces Slack updates within the configured interval', async () => {
const slack = slackRecorder();
const progress = await startSlackProgress(turn, {
callSlack: slack.call,
updateInterval: 50,
});
for (const [id, title] of ['One', 'Two', 'Three'].entries()) {
progress.event({
type: 'tool_use',
sessionID: 'ses_1',
part: {
id: `prt_${id}`,
type: 'tool',
tool: 'read',
state: { status: 'completed', title },
},
});
}
await sleep(100);
assert.equal(slack.calls.filter((call) => call.method === 'chat.update').length, 1);
progress.event({
type: 'tool_use',
sessionID: 'ses_1',
part: {
id: 'prt_4',
type: 'tool',
tool: 'read',
state: { status: 'completed', title: 'Four' },
},
});
assert.equal(slack.calls.filter((call) => call.method === 'chat.update').length, 1);
await sleep(100);
assert.equal(slack.calls.filter((call) => call.method === 'chat.update').length, 2);
});
test('replaces progress with the final answer', async () => {
const calls = [];
let releaseProgress;
const callSlack = async (method, body) => {
calls.push({ method, body });
if (method === 'chat.postMessage') return { ok: true, ts: '456.789' };
if (calls.filter((call) => call.method === 'chat.update').length === 1) {
return await new Promise((resolve) => (releaseProgress = () => resolve({ ok: true })));
}
return { ok: true };
};
const progress = await startSlackProgress(turn, {
callSlack,
updateInterval: 0,
});
progress.event({
type: 'tool_use',
sessionID: 'ses_1',
part: {
id: 'prt_tool',
type: 'tool',
tool: 'read',
state: { status: 'completed', title: 'Read worker source' },
},
});
await waitFor(() => calls.filter((call) => call.method === 'chat.update').length === 1);
for (const title of ['Second tool', 'Third tool', 'Fourth tool']) {
progress.event({
type: 'tool_use',
sessionID: 'ses_1',
part: {
id: title,
type: 'tool',
tool: 'read',
state: { status: 'completed', title },
},
});
}
const finish = progress.finish('Final answer', 'ses_1', 'box-1');
await sleep(10);
assert.equal(calls.filter((call) => call.method === 'chat.update').length, 1);
releaseProgress();
await finish;
assert.deepEqual(calls.at(-1), {
method: 'chat.update',
body: {
channel: 'C123',
ts: '456.789',
text: 'Final answer\n\n⟳session:ses_1 ⟳box:box-1',
},
});
});
test('streams OpenCode CLI events and resumes an OpenCode session', async () => {
let invocation;
let prompt = '';
const events = [];
const result = await runOpenCode(
{
message: 'Test message',
sessionId: 'ses_existing',
cwd: '/workspaces/n8n',
author: 'Tester',
},
(event) => events.push(event),
() => {},
{
spawnProcess(command, args, options) {
invocation = { command, args, options };
const child = completedChild([
JSON.stringify({ type: 'step_start', sessionID: 'ses_existing', part: {} }),
JSON.stringify({
type: 'tool_use',
sessionID: 'ses_existing',
part: { id: 'prt_1', type: 'tool', tool: 'read', state: { status: 'completed' } },
}),
JSON.stringify({
type: 'text',
sessionID: 'ses_existing',
part: { id: 'prt_2', type: 'text', text: 'Completed response' },
}),
]);
child.stdin.on('data', (chunk) => (prompt += chunk));
return child;
},
},
);
assert.equal(result.result, 'Completed response');
assert.equal(result.session_id, 'ses_existing');
assert.deepEqual(invocation.args, [
'run',
'--model',
'openrouter/openai/gpt-5.6-sol',
'--format',
'json',
'--auto',
'--session',
'ses_existing',
]);
assert.equal(invocation.options.detached, true);
assert.equal(prompt, 'Test message');
assert.deepEqual(
events.map((event) => event.type),
['step_start', 'tool_use', 'text'],
);
});
test('stops the OpenCode process group at the turn limit', async () => {
const signals = [];
let child;
const run = runOpenCode(
{ message: 'Test message', cwd: '/workspaces/n8n', author: 'Tester' },
() => {},
() => {},
{
spawnProcess() {
child = childProcess();
return child;
},
stopProcess(_child, signal) {
signals.push(signal);
if (signal === 'SIGTERM') setImmediate(() => child.emit('close', null));
},
killDelay: 5,
timeout: 1,
},
);
await assert.rejects(run, /The turn passed the 1-millisecond limit/);
assert.deepEqual(signals, ['SIGTERM']);
});
test('keeps broker credentials out of the OpenCode process', () => {
const environment = openCodeEnvironment({
AGENT_WORKER_TOKEN: 'worker',
N8N_DEQUEUE_URL: 'https://example.com',
SLACK_BOT_TOKEN: 'slack',
ANTHROPIC_API_KEY: 'model',
OPENROUTER_API_KEY: 'openrouter',
GITHUB_TOKEN: 'github',
});
assert.deepEqual(JSON.parse(environment.OPENCODE_CONFIG_CONTENT), {
provider: { openrouter: { options: { apiKey: '{env:OPENROUTER_API_KEY}' } } },
});
delete environment.OPENCODE_CONFIG_CONTENT;
assert.deepEqual(environment, {
ANTHROPIC_API_KEY: 'model',
OPENROUTER_API_KEY: 'openrouter',
GITHUB_TOKEN: 'github',
N8N_AGENT_RUNTIME: 'sandbox',
N8N_AGENT_PROFILE: 'slack',
});
});
test('configures Flaky MCP without serializing its token', () => {
const token = 'flaky-secret';
const environment = openCodeEnvironment({
FLAKY_MCP_URL: 'https://example.com/mcp',
FLAKY_MCP_TOKEN: token,
OPENROUTER_API_KEY: 'openrouter',
});
assert.deepEqual(JSON.parse(environment.OPENCODE_CONFIG_CONTENT), {
provider: { openrouter: { options: { apiKey: '{env:OPENROUTER_API_KEY}' } } },
mcp: {
flaky: {
type: 'remote',
url: 'https://example.com/mcp',
enabled: true,
oauth: false,
headers: { Authorization: 'Bearer {env:FLAKY_MCP_TOKEN}' },
},
},
});
assert.equal(environment.FLAKY_MCP_TOKEN, token);
assert.equal(environment.OPENCODE_CONFIG_CONTENT.includes(token), false);
});
test('omits Flaky MCP when either setting is missing', () => {
assert.equal(openCodeConfig({ FLAKY_MCP_URL: 'https://example.com/mcp' }).mcp, undefined);
assert.equal(openCodeConfig({ FLAKY_MCP_TOKEN: 'flaky-secret' }).mcp, undefined);
});

View File

@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { after, before, test } from 'node:test';
import { fileURLToPath } from 'node:url';
const repoRoot = fileURLToPath(new URL('../../', import.meta.url));
const sessionScript = join(repoRoot, 'scripts/cloud-session.mjs');
let fixtureDir;
let invocation = 0;
before(() => {
fixtureDir = mkdtempSync(join(tmpdir(), 'cloud-session-'));
const mockGh = join(fixtureDir, 'gh');
writeFileSync(
mockGh,
`#!/usr/bin/env node
const { writeFileSync } = require('node:fs');
const args = process.argv.slice(2);
if (args[0] === 'codespace' && args[1] === 'list') {
process.stdout.write('[{"name":"test-codespace","state":"Available"}]\\n');
} else {
writeFileSync(process.env.GH_CAPTURE, JSON.stringify(args));
}
`,
);
chmodSync(mockGh, 0o755);
});
after(() => rmSync(fixtureDir, { recursive: true, force: true }));
function runSession(args) {
const capture = join(fixtureDir, `invocation-${invocation++}.json`);
const result = spawnSync(process.execPath, [sessionScript, ...args], {
encoding: 'utf8',
env: {
...process.env,
GH_CAPTURE: capture,
PATH: `${fixtureDir}:${process.env.PATH}`,
},
});
assert.equal(result.status, 0, result.stderr || result.stdout);
return JSON.parse(readFileSync(capture, 'utf8'));
}
function remoteCommand(args) {
const invocationArgs = runSession(args);
assert.deepEqual(invocationArgs.slice(0, 6), [
'codespace',
'ssh',
'-c',
'test-codespace',
'--',
'-t',
]);
return invocationArgs[6];
}
test('starts a named OpenCode session in a worktree', () => {
const command = remoteCommand(['--opencode', 'fix-flaky', '--model', 'test']);
assert.match(command, /tmux new -As fix-flaky-opencode/);
assert.match(command, /unset AGENT_WORKER_TOKEN N8N_DEQUEUE_URL SLACK_BOT_TOKEN/);
assert.match(command, /OPENCODE_CONFIG_CONTENT/);
assert.match(command, /export N8N_AGENT_RUNTIME=sandbox; unset N8N_AGENT_PROFILE/);
assert.match(command, /git -C \/workspaces\/n8n worktree add "\/workspaces\/wt-fix-flaky"/);
assert.match(command, /cd "\/workspaces\/wt-fix-flaky" && opencode --auto --model test/);
assert.doesNotMatch(command, /unset OPENROUTER_API_KEY/);
assert.doesNotMatch(command, /claude plugin/);
});
test('keeps removed credentials out of the login shell', () => {
const command = remoteCommand(['--shell']);
const profile = readFileSync(new URL('./codespaces-secrets.sh', import.meta.url), 'utf8');
const dockerfile = readFileSync(new URL('./Dockerfile', import.meta.url), 'utf8');
assert.match(command, /tmux new -As agent-shell/);
assert.match(
command,
/unset AGENT_WORKER_TOKEN N8N_DEQUEUE_URL SLACK_BOT_TOKEN; unset OPENROUTER_API_KEY/,
);
assert.match(command, /export N8N_SKIP_CODESPACE_SECRETS=1; exec "\$\{SHELL:-\/bin\/bash\}" -l/);
assert.ok(profile.includes('[ "${N8N_SKIP_CODESPACE_SECRETS:-}" = "1" ] && return'));
assert.ok(
dockerfile.includes(
'RUN echo \'[ "${N8N_SKIP_CODESPACE_SECRETS:-}" = "1" ] || . /usr/local/lib/codespaces-env.sh\'',
),
);
assert.doesNotMatch(command, /claude plugin|OPENCODE_CONFIG_CONTENT/);
});
test('keeps the existing Claude session behavior', () => {
const command = remoteCommand(['agent', '--model', 'test']);
assert.match(command, /tmux new -As agent/);
assert.match(command, /unset OPENROUTER_API_KEY/);
assert.match(command, /claude plugin marketplace add/);
assert.match(command, /cd \/workspaces\/n8n && claude --model test/);
assert.doesNotMatch(command, /OPENCODE_CONFIG_CONTENT|N8N_SKIP_CODESPACE_SECRETS/);
});

View File

@ -1,3 +1,6 @@
# Interactive shell sessions set this guard after they remove worker credentials.
[ "${N8N_SKIP_CODESPACE_SECRETS:-}" = "1" ] && return
# Login-shell setup for ssh/tmux sessions: export secrets, then do the
# one-time registrations.
. /usr/local/lib/codespaces-env.sh

View File

@ -5,9 +5,9 @@
// Codespaces clones the repo into /workspaces/<repo>, unlike the laptop config
"workspaceFolder": "/workspaces/n8n",
"hostRequirements": {
"cpus": 8,
"memory": "32gb",
"storage": "64gb"
"cpus": 2,
"memory": "8gb",
"storage": "32gb"
},
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
@ -18,13 +18,13 @@
"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.
// Grant the Codespace token read access to the private skills and harness repositories.
// Each user authorizes this access once when they create the Codespace.
"customizations": {
"codespaces": {
"repositories": {
"n8n-io/n8n-claude-skills": { "permissions": { "contents": "read" } }
"n8n-io/n8n-agent-skills": { "permissions": { "contents": "read" } },
"n8n-io/cat-bot": { "permissions": { "contents": "read" } }
}
}
},
@ -38,12 +38,18 @@
"ANTHROPIC_API_KEY": {
"description": "Anthropic API key for Claude Code / OpenCode sessions"
},
"OPENROUTER_API_KEY": {
"description": "OpenRouter API key for Flaky's 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"
},
"SLACK_BOT_TOKEN": {
"description": "Slack bot token for agent progress messages. It needs the chat:write scope."
},
"CLAUDE_CODE_OAUTH_TOKEN": {
"description": "Alternative to an API key: token from `claude setup-token` (Max subscription)"
},

View File

@ -27,4 +27,8 @@ services:
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"
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,5 @@
// Shared by post-start.mjs (installs at container start) and scripts/cloud-session.mjs
// (re-installs in the session prelude). Both must agree or a session boots with a
// partial skill set.
export const MARKETPLACE = 'n8n-io/n8n-agent-skills';
export const PLUGINS = ['quality@n8n-agent-skills', 'security@n8n-agent-skills'];

View File

@ -1,9 +1,22 @@
#!/usr/bin/env node
// Runs on each codespace start. Installs the skills marketplace, starts the worker.
// Runs on each Codespace start. Installs the skills and harness, then starts the worker.
import { execFileSync } from 'node:child_process';
import { rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { installAgentHarness } from '../../scripts/agent-harness.mjs';
import { MARKETPLACE, PLUGINS } from './plugins.mjs';
const MARKETPLACE = 'n8n-io/n8n-claude-skills';
const PLUGIN = 'quality@n8n-claude-skills';
const STATUS_FILE = '/tmp/post-start-status.json';
// Claude Code stages the marketplace clone here before renaming it to the cache dir.
// A clone that dies partway has been seen to leave the staging path in a state that
// fails the next clone ("cannot copy ... File exists"), so clear it before retrying.
const MARKETPLACE_STAGING = join(
homedir(),
'.claude/plugins/marketplaces',
MARKETPLACE.replace('/', '-'),
);
// 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.
@ -20,15 +33,62 @@ function tryRun(label, cmd, args) {
}
}
// Both commands are idempotent and exit 0 when the marketplace or plugin is already
// present, so a healthy start re-runs them cheaply and only a real failure retries.
function addMarketplace() {
const add = (label) => tryRun(label, 'claude', ['plugin', 'marketplace', 'add', MARKETPLACE]);
if (add('marketplace add')) return true;
console.error(`marketplace add: clearing ${MARKETPLACE_STAGING} and retrying once`);
try {
rmSync(MARKETPLACE_STAGING, { recursive: true, force: true });
} catch (error) {
console.error(`marketplace staging cleanup: ${error.message}`);
return false;
}
return add('marketplace add (retry)');
}
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]);
const installed = [];
const failed = [];
if (addMarketplace()) {
for (const plugin of PLUGINS) {
const ok = tryRun(`plugin install ${plugin}`, 'claude', ['plugin', 'install', plugin]);
(ok ? installed : failed).push(plugin);
}
} else {
failed.push(...PLUGINS);
}
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"',
]);
// A skipped install is otherwise invisible until someone misses a skill mid-session.
if (failed.length > 0) {
console.error(`\n!! SKILLS NOT INSTALLED: ${failed.join(', ')}`);
console.error('!! Sessions start without them. Retry with:');
console.error('!! node /workspaces/n8n/.devcontainer/codespaces/post-start.mjs\n');
}
let harness;
try {
const result = installAgentHarness();
harness = { status: 'active', version: result.version, cacheHit: result.cacheHit };
} catch (error) {
harness = { status: 'unavailable', error: error.message };
console.error(`agent harness: ${error.message}`);
}
const workerStarted =
harness.status === 'active' &&
tryRun('worker start', 'tmux', [
'new-session',
'-d',
'-s',
'agent-worker',
'bash -lc ". /usr/local/lib/codespaces-env.sh; 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"',
]);
if (!workerStarted && harness.status !== 'active') {
console.error('worker start: skipped because the pinned agent harness is unavailable');
}
writeFileSync(STATUS_FILE, JSON.stringify({ installed, failed, harness, workerStarted }, null, 2));

View File

@ -0,0 +1,32 @@
ARG NODE_VERSION=24
# Debian to match the agent image's toolchain. No Playwright, no agent CLIs and no
# Docker here: a preview only builds the repo and serves it.
FROM node:${NODE_VERSION}-bookworm
# tmux holds the backend between `preview refresh` runs. The rest is for triage.
RUN apt-get update && apt-get install -y --no-install-recommends \
tmux sudo less jq \
&& rm -rf /var/lib/apt/lists/*
# Shared with the agent config, so there is one copy of each shim. The build context
# is .devcontainer/, set in devcontainer.json, which is why these paths start with
# codespaces/. git and gh must survive Codespaces' token rotation; the stock helpers
# read a snapshot of $GITHUB_TOKEN and go stale in ssh and tmux shells.
COPY codespaces/codespaces-env.sh /usr/local/lib/codespaces-env.sh
COPY codespaces/gitcredential-refresh.sh /usr/local/bin/gitcredential-refresh.sh
COPY codespaces/gh-shim.sh /usr/local/bin/gh
RUN chmod +x /usr/local/bin/gitcredential-refresh.sh /usr/local/bin/gh
# Interactive shells for a human debugging the box.
RUN echo '. /usr/local/lib/codespaces-env.sh' >> /etc/bash.bashrc
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
ENV PNPM_HOME=/home/node/.local/share/pnpm
ENV PATH=$PNPM_HOME/bin:$PATH
USER node
RUN mkdir -p $PNPM_HOME ~/.pnpm-store && pnpm config set store-dir ~/.pnpm-store --global

View File

@ -0,0 +1,54 @@
{
"name": "n8n (PR preview)",
// No compose: a preview needs no Postgres sidecar. n8n defaults to sqlite
// (packages/@n8n/config/src/configs/database.config.ts), which is what a
// throwaway instance wants. Context is .devcontainer/ so the Dockerfile can
// reuse the token-rotation shims in codespaces/ instead of copying them.
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
// Codespaces clones the repo into /workspaces/<repo>
"workspaceFolder": "/workspaces/n8n",
// The floor, so the 2-core machine stays available. This config exists mainly to
// ask for less than the agent one: the measured footprint is ~15gb, the
// editor-ui build peaks near 3gb, and scripts/agent-setup.mjs already builds the
// whole repo inside 6gb.
"hostRequirements": {
"cpus": 2,
"memory": "8gb",
"storage": "32gb"
},
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
// gh codespace ssh needs an SSH server inside the container
"ghcr.io/devcontainers/features/sshd:1": {}
},
"forwardPorts": [5678],
// Runs in prebuilds, so a fresh preview starts with deps installed and the build
// warm. Concurrency and heap are capped for the 2-core box, matching the
// reasoning in scripts/agent-setup.mjs. A failing onCreateCommand is fatal —
// Codespaces discards the container — so the build only ever tails its log.
"onCreateCommand": "corepack install && pnpm install && (NODE_OPTIONS=--max-old-space-size=6144 pnpm exec turbo run build --concurrency=2 > /tmp/build.log 2>&1 || tail -n 50 /tmp/build.log)",
// Codespaces regenerates the managed /etc/gitconfig on each start, and the ssh
// command shells `pnpm preview` uses are neither login nor interactive, so
// profile.d and bashrc never fire for them. Register the helper here instead.
"postStartCommand": "git config --global --get-all credential.helper 2>/dev/null | grep -qs gitcredential-refresh || git config --global --add credential.helper /usr/local/bin/gitcredential-refresh.sh",
// Declarations only. All of these are set at the repository level, so Codespaces
// injects them into every preview box whether or not they are listed here.
// scripts/preview-serve.mjs reads them with codespaceSecret().
"secrets": {
"N8N_LICENSE_ACTIVATION_KEY": {
"description": "Sandbox licence key. The preview:enterprise label decides whether it reaches n8n."
},
"CODESPACE_ENV_URL": {
"description": "n8n webhook that returns extra environment for a preview, as a flat JSON object"
},
"CODESPACE_ENV_USER": {
"description": "Basic auth user for CODESPACE_ENV_URL. Defaults to `preview`."
},
"CODESPACE_ENV_PASSWORD": {
"description": "Basic auth password for CODESPACE_ENV_URL"
}
}
}

View File

@ -0,0 +1,81 @@
/**
* One-click sign-in for preview instances. Loaded through EXTERNAL_HOOK_FILES by
* scripts/preview-serve.mjs, so it is never part of a packaged n8n.
*
* The route performs an ordinary login on the visitor's behalf: it calls
* /rest/login with the preview instance's own seeded credentials and forwards the
* session cookie that endpoint returns. The visitor gets exactly the session a
* normal sign-in would give them, so this is a convenience over the login form
* rather than a different way in.
*
* Only enable this on a throwaway instance whose port is org-visible. It refuses to
* register unless N8N_PREVIEW_SIGNIN is exactly "1", so loading the file by
* accident does nothing.
*
* Calling /rest/login rather than AuthService keeps this working across changes to
* n8n's internals, and means it can never grant more than a real login would.
*
* Two things make the route reachable:
* - `n8n.ready` fires after configure(), so the SPA catch-all is already
* registered. N8N_ADDITIONAL_NON_UI_ROUTES must list this path, or
* historyApiHandler answers with index.html before the route is reached.
* - the handler is registered on server.app, which AbstractServer exposes.
*/
const ROUTE = '/preview-signin';
module.exports = {
n8n: {
ready: [
async function previewSignin(server) {
if (process.env.N8N_PREVIEW_SIGNIN !== '1') return;
const email = process.env.PREVIEW_OWNER_EMAIL;
const password = process.env.PREVIEW_OWNER_PASSWORD;
if (!email || !password) {
console.warn(
`[preview-signin] PREVIEW_OWNER_EMAIL or PREVIEW_OWNER_PASSWORD is unset — ${ROUTE} not registered`,
);
return;
}
const port = process.env.N8N_PORT ?? '5678';
server.app.get(ROUTE, async (_req, res) => {
const fallback = `Sign in as ${email} instead.`;
try {
const login = await fetch(`http://127.0.0.1:${port}/rest/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ emailOrLdapLoginId: email, password }),
signal: AbortSignal.timeout(15_000),
});
if (!login.ok) {
res
.status(502)
.type('text')
.send(`Preview sign-in failed: /rest/login returned ${login.status}. ${fallback}`);
return;
}
// Hand the browser exactly the cookies the login endpoint issued.
const cookies = login.headers.getSetCookie();
if (cookies.length === 0) {
res
.status(502)
.type('text')
.send(`Preview sign-in failed: no session cookie was issued. ${fallback}`);
return;
}
res.setHeader('set-cookie', cookies);
res.redirect('/');
} catch (error) {
res.status(502).type('text').send(`Preview sign-in failed: ${error.message}. ${fallback}`);
}
});
console.log(`[preview-signin] ${ROUTE} is live — it signs the visitor in as ${email}`);
},
],
},
};

1
.gitattributes vendored
View File

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

13
.githooks/check-pnpm.sh Normal file
View File

@ -0,0 +1,13 @@
#!/bin/sh
# .githooks/check-pnpm — symlink or call from post-merge, post-rewrite, post-checkout
WANTED=$(sed -n 's/.*"packageManager": *"pnpm@\([0-9]*\).*/\1/p' package.json)
CURRENT=$(cd ~ && pnpm --version 2>/dev/null | cut -d. -f1)
[ -z "$WANTED" ] || [ "$WANTED" = "$CURRENT" ] && exit 0
echo ""
echo "⚠ This repo now pins pnpm $WANTED (you have ${CURRENT:-none})."
echo " pnpm should auto-download it on the next install command."
echo " If that fails (some pnpm 10 versions can't install v12), reinstall:"
echo " curl -fsSL https://get.pnpm.io/install.sh | sh -"
echo ""
exit 0

View File

@ -19,6 +19,39 @@ All telemetry uses the same format:
}
```
Container startup payloads also include:
```json
{
"attempt_id": "uuid",
"correlation": {
"profile": "sqlite",
"shard": "3",
"worker": "2",
"retry": 1,
"restartReason": null
},
"stages": [
{
"name": "n8n-startup",
"source": "local",
"startedAt": "2026-03-16T12:00:00.000Z",
"elapsedMs": 4200,
"outcome": "failure",
"errorMessage": "readiness failed"
}
],
"failure_phase": "n8n-startup"
}
```
`attempt_id` identifies one stack startup. Join all stage records and attached
Playwright startup evidence with this value. `outcome` is `success`, `failure`,
or `cancelled`. Stage durations are also sent as `stack-startup-stage` metrics
with `attempt_id`, `stage`, `source`, and `outcome` dimensions. `profile` is
resolved from the configured profile or stack shape. Other unavailable
correlation values stay `null`.
## Standard Context Fields
```typescript
@ -49,6 +82,7 @@ return 'blacksmith';
| Telemetry | Source | Metrics |
|-----------|--------|---------|
| Playwright perf/benchmark | `packages/testing/playwright/reporters/metrics-reporter.ts` | Any metric attached via `attachMetric()` |
| Accessibility buckets | `packages/testing/playwright/reporters/a11y-reporter.ts` | Per-bucket axe score, violated rules, violating elements |
| Build stats | `.github/scripts/send-build-stats.mjs` | Per-package build duration, cache hit/miss, run total |
| Docker stats | `.github/scripts/send-docker-stats.mjs` | Image size per platform, docker build duration |
| Container stack | `packages/testing/containers/telemetry.ts` | E2E stack startup times per service |

2
.github/CLAUDE.md vendored
View File

@ -13,7 +13,7 @@ This folder contains n8n's GitHub Actions infrastructure.
| `workflows/` | GitHub Actions workflows |
| `actions/` | Reusable composite actions |
| `scripts/` | Release & Docker automation |
| `CODEOWNERS` | Team review ownership |
| `scripts/owners/` | Team review ownership scripts; the OWNERS file is at the repo root (`required` entries gate merges) |
### Workflow Naming

5
.github/CODEOWNERS vendored
View File

@ -1,6 +1,11 @@
# Temporary: kept side by side with the top-level OWNERS file while the
# "Required Reviews" enforcement is on trial. Do not add entries here;
# add them to OWNERS. See "Transition from CODEOWNERS" in WORKFLOWS.md.
packages/@n8n/db/src/migrations/ @n8n-io/migrations-review
/OWNERS @n8n-io/qa-dx
.github/workflows @n8n-io/qa-dx
.github/scripts @n8n-io/qa-dx
.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

@ -135,6 +135,7 @@ of the breaking removal on `3.x`.
`master` is dropped as empty.
3. Conflicts confined to **mechanical files** — tool-generated content with a deterministic
resolution (`pnpm-lock.yaml`, `packages/frontend/editor-ui/data/node-popularity.json`,
`packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json`, and
`.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
@ -177,9 +178,17 @@ If the PR says the lockfile was **deferred** (a `package.json` / `pnpm-workspace
conflicted too), resolve the manifests first, then regenerate it with
`pnpm install --lockfile-only` and include the result in your fix commit.
Watch for the **"Deleted on one side, changed on the other"** section. Git leaves no markers
for a delete/modify, so the branch looks clean where it is not: the merge keeps `3.x`'s side
(its deletion, or its file when `master` deleted it). Confirm that is right, and check
whether `master`'s change has to be carried over by hand — when a breaking commit re-recorded
or renamed a file, `master`'s edit to the old one usually belongs on the replacement, and no
automation can find that for you. The PR names the `master` commit behind each conflicted
path so you can see what the change was.
Then **merge the PR with the normal merge button.** `master`'s commits arrive as-is and your
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.
fix stays its own commit. **Never close a conflict PR unmerged** — closing resolves nothing and
the same conflict reopens on the next sync.
`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
@ -194,9 +203,10 @@ is squashed, and the tree guard proves the result is exactly the merge of `3.x`
`master`.
**Who gets pinged.** The conflict is attributed to the authors of the `3.x` commits behind the
conflicted files (`.github/scripts/sync-conflict-owners.mjs`, mapped to GitHub accounts).
Those authors are **requested as reviewers** on the conflict PR and listed in the
`#alerts-v3-sync` message.
conflicted files, plus the `master` commits that touched the same files
(`.github/scripts/sync-conflict-owners.mjs`, mapped to GitHub accounts). Both sides are named
in the PR body and the `#alerts-v3-sync` message. **Nobody is requested as a reviewer** — the
resolver picks the PR up themselves.
## Trialing v3

560
.github/WORKFLOWS.md vendored
View File

@ -10,7 +10,7 @@ Complete reference for n8n's `.github/` folder.
.github/
├── WORKFLOWS.md # This document
├── CI-TELEMETRY.md # Telemetry & metrics guide
├── CODEOWNERS # Team ownership for PR reviews
├── CODEOWNERS # Temporary, side by side with OWNERS during the trial
├── pull_request_template.md # PR description template
├── pull_request_title_conventions.md # Title format rules (Angular)
├── actionlint.yml # Workflow linter config
@ -21,6 +21,7 @@ Complete reference for n8n's `.github/` folder.
│ ├── config.yml # Routes to community/security
│ └── 01-bug.yml # Structured bug report form
├── scripts/ # Automation scripts
│ ├── owners/ # Owners scripts (the OWNERS file lives at the repo root)
│ ├── bump-versions.mjs # Calculate next version
│ ├── update-changelog.mjs # Generate CHANGELOG
│ ├── trim-fe-packageJson.js # Strip frontend devDeps
@ -230,10 +231,11 @@ parallelism). See the `--build-via-mcp` section in
### On PR Close/Merge
| Event | Workflow |
|----------------------------|-----------------------------|
| PR closed (any) | `util-notify-pr-status.yml` |
| PR merged to `release/*` | `release-publish.yml` |
| Event | Workflow |
|------------------------------------|--------------------------------|
| PR closed (any) | `util-notify-pr-status.yml` |
| PR merged to `release/*` | `release-publish.yml` |
| PR closed with `codespace-preview` | `util-codespace-preview.yml` |
### Manual Triggers (PR Comments)
@ -243,11 +245,182 @@ parallelism). See the `--build-via-mcp` section in
**Why:** Re-run tests without pushing commits. Useful for flaky test investigation.
### Label Triggers
| Label | Workflow | Effect |
|-----------------------|------------------------------|----------------------------------------------------|
| `codespace-preview` | `util-codespace-preview.yml` | Runs the PR in a Codespace, comments the URL |
| `preview:enterprise` | `util-codespace-preview.yml` | Re-serves the instance with an enterprise licence |
| `preview:debug` | `util-codespace-preview.yml` | Re-serves the instance with `N8N_LOG_LEVEL=debug` |
**Why:** A reviewer gets a running instance of the PR without a Docker build or a
cloud deploy. The workflow calls `scripts/preview.mjs`, which keeps one codespace
for each PR (display name `preview/pr-<number>`) and shares port 5678 with the
organization. A later push serves the new head in the same box. Removing the
label, or closing the PR, deletes the box.
Only a PR from a branch in this repository is eligible: a codespace token is
scoped to `n8n-io/n8n` and cannot check out a fork head.
#### Live progress on the PR
`up` and `refresh` take minutes, and an absent comment looks the same as a broken
preview. So the comment goes up before the box work starts, as a checklist of the
phases in `scripts/preview-phases.mjs`, and is edited for each phase and once a
minute after that. The final URL replaces it in place.
A comment **edit sends no notification** — only a create does. That is what makes a
heartbeat on a sticky comment acceptable at all.
A `refresh` keeps the URL of the previous run in the checklist. The URL does not
change between runs, and taking a working link off the PR for several minutes is
worse than saying it is briefly down.
A cancelled job — including the 45-minute `timeout-minutes` — kills the script while
its checklist is up, so a last `if: cancelled()` step writes the outcome instead. It
writes **only over a checklist**: a run cancelled while it was queued never started
work, and must leave the previous run's URL alone.
#### Running it by hand
A box sleeps after 2 hours of no use, and GitHub makes every forwarded port
private again at each start. So a preview that slept reaches nobody until
something shares port 5678 again, and its backend is gone with the container.
**Actions → Util: Codespace Preview → Run workflow** does both without a commit
and without a label toggle:
| Input | Meaning |
|---|---|
| `pr_number` | The pull request to act on. |
| `operation` | `up` (default) creates the box if it is gone, starts it if it sleeps, serves the head and shares the port again. `refresh` re-serves the head in a box that already exists. `down` deletes the box. |
Prefer `up` unless you mean to delete: it covers create, wake and re-share.
The button needs write access, and it appears only once the trigger is on
`master` — GitHub lists dispatchable workflows from the default branch. A manual
run takes the branch picked in the dropdown, which is the branch GitHub read the
workflow from, so a branch can test a change to the preview scripts. A fork head
is still refused, by `preview.mjs` rather than by the job's `if`. There is no
`ls` operation: it needs no PR and posts no comment — run `pnpm preview ls`
locally.
#### Preview toggles
A `preview:*` label configures an instance that already exists, so adding or
removing one re-serves the box instead of creating or deleting it. It does
nothing on a PR without `codespace-preview`.
The vocabulary lives in `scripts/preview-labels.mjs`, which both ends import:
`preview.mjs` turns the PR's labels into slugs, and `preview-serve.mjs` turns
those slugs into environment inside the box. Add a toggle there, in one place.
Two things cross the gap, and both are shape-checked rather than trusted: a
`preview:*` slug, and a phase key from `scripts/preview-phases.mjs`. The `gh
codespace ssh` command is a shell string that appears in the box's process list, so
a value is never passed through it — `preview:enterprise` resolves to a licence key
inside the box, not on the runner.
`preview:enterprise` needs a **Codespaces** secret named
`N8N_LICENSE_ACTIVATION_KEY`, scoped to `n8n-io/n8n`. That is a Codespaces
secret, not an Actions secret, and it is unrelated to `CODESPACE_PREVIEW_TOKEN`.
Use the sandbox key: the preview sets tenant `1001` to match, and the default
tenant (`1`) rejects it. Without the secret the preview still serves, unlicensed,
and says so in the log.
Note what the label does and does not control. Codespaces injects the secret into
**every** preview box, so the label decides whether the licence reaches n8n, not
whether the key reaches the box. Anyone who can run PR-head code can read
`/workspaces/.codespaces/shared/.env-secrets`. Previews are limited to branches
in this repository, so that is the set of people who already have write access.
#### Preview environment from a webhook
A preview can also take environment from an n8n webhook we control, so a value
can change without a commit and a merge. `scripts/preview-remote-env.mjs` fetches
it, and `preview-serve.mjs` hands the result to the backend.
It needs three **Codespaces** secrets on `n8n-io/n8n`, again not Actions secrets:
| Secret | Purpose |
| ------------------------ | ------------------------------------------------- |
| `CODESPACE_ENV_URL` | The webhook URL |
| `CODESPACE_ENV_USER` | Basic auth user. Optional, defaults to `preview`. |
| `CODESPACE_ENV_PASSWORD` | Basic auth password |
The webhook answers with a flat JSON object. Its keys become environment
variables and its values are used as-is, so a number or a boolean is stringified
and a nested object is dropped. The request carries the PR number and the head
SHA as query parameters, so one endpoint can answer per PR.
The fetch runs in the box, not on the runner. Codespaces secrets are unreadable
from Actions, so neither the password nor a returned value can reach a CI log.
The log prints key names only.
**Every key is passed through.** A response containing `NODE_OPTIONS`,
`EXTERNAL_HOOK_FILES` or `PATH` runs code inside the preview box, so whoever can
edit that workflow can run code there. The one exception is the preview's own
wiring — the sign-in hook and the owner credentials — which is applied last and
wins. The `.env-secrets` note above applies to these secrets too.
Nothing here is required. Without the secrets, an unreachable webhook or a
rejected password, the preview serves as usual and says so in the log. A wrong
password is not retried: it cannot fix itself.
#### The `CODESPACE_PREVIEW_TOKEN` secret
The job needs `CODESPACE_PREVIEW_TOKEN`, a **fine-grained** personal access token,
held in the `codespaces` environment. Set the resource owner to `n8n-io` and limit
repository access to `n8n-io/n8n`. Grant these repository permissions:
| Permission | Level | What it unlocks |
|---|---|---|
| Metadata | Read | Mandatory, selected for you |
| Codespaces | Read and write | Create, list and delete a box |
| Codespaces metadata | Read | `GET .../codespaces/machines`, which resolves the machine type |
| Codespaces lifecycle admin | Read and write | Start a stopped box, which is what `gh codespace ssh` does |
| Contents | Read | Read the repository |
| Pull requests | Read | `gh pr view`, to resolve the head ref and SHA |
`Codespaces metadata` is a **different permission** from `Codespaces`. Without it
the run fails with `HTTP 403: Resource not accessible by personal access token` on
the `machines` endpoint, after the org billing check has already printed a tick —
so the failure looks unrelated to permissions.
An organization owner may have to approve the token. It stays pending until then.
No other credential can do this:
- `GITHUB_TOKEN` has no Codespaces access.
- A GitHub App **installation** token cannot create a codespace at all. The
Codespaces API belongs to a user, not to an installation.
- A **classic** token is refused by org policy:
`` `n8n-io` forbids access via a personal access token (classic) ``. So no
combination of classic scopes works, whatever the API reference says about the
`codespace` scope.
The environment keeps the token away from every other workflow: only a job that
names `codespaces` can read it. **The environment must allow every branch.** A
`pull_request` run has the ref `refs/pull/<n>/merge`, which no deployment branch
policy matches, so a branch rule would block every preview. Add required
reviewers only if a click for each preview is acceptable.
The codespace belongs to whoever owns the token, and shows up in that account's
codespace list. Billing still goes to the organization, because the repository is
organization-owned and has a Codespaces budget. A service account is therefore
better than a person's account for quota attribution, though the token is scoped
to one repository either way.
The job checks out the base branch, never the PR head, so a PR cannot supply the
script that reads that token. A manual run checks out the branch chosen in the
Run-workflow dropdown, which only a user with write access can pick.
### Other Manual Workflows
| Workflow | Purpose |
|---------------------------|---------------------------------------------------------|
| `util-data-tooling.yml` | SQLite/PostgreSQL export/import validation (manual) |
| Workflow | Purpose |
|-----------------------------|---------------------------------------------------------|
| `util-codespace-preview.yml`| Wake, re-serve or delete a PR preview instance by hand |
| `util-data-tooling.yml` | SQLite/PostgreSQL export/import validation (manual) |
| `util-probe-registry.yml` | Diagnose slow npm metadata fetches (temporary) |
---
@ -268,13 +441,17 @@ 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
│ └──────────▶ security-trivy-scan-callable.yml
└──────────────────────────▶ sbom-generation-callable.yml
test-sbom-nightly.yml
└──────────────────────────▶ sbom-validation-callable.yml
test-workflows-nightly.yml (manual dispatch only — nightly schedule disabled, DEVP-544)
└──────────────────────────▶ test-workflows-callable.yml
@ -321,7 +498,10 @@ test-workflows-pr-comment.yml
│ │ ├─ ensure-provenance-fields.mjs ───▶ Add license fields │
│ │ └─ npm publish (tag: rc or latest) │
│ ├─ publish-to-docker-hub ────────▶ docker-build-push.yml │
│ │ └─ Multi-arch: amd64 + arm64 │
│ │ ├─ Build the application once on amd64 │
│ │ ├─ Build/push images on native amd64 + arm64 runners │
│ │ └─ Merge manifests, then add release provenance │
│ │ └─ Release VEX/SBOM attestations follow provenance │
│ ├─ create-github-release │
│ ├─ create-sentry-release (sourcemaps) │
│ ├─ generate-sbom ────────────────▶ sbom-generation-callable.yml │
@ -338,12 +518,42 @@ test-workflows-pr-comment.yml
└────────────────────────────────────────────────────────────────────────────┘
```
### Recovering a failed release
If the pipeline publishes `n8n@X.Y.Z` to npm and then fails, that version is
burned — npm versions are immutable. Recovery depends on how far it got:
| Failure point | Recovery |
|---|---|
| Before `n8n` reached npm | **Re-run failed jobs** on the original run. Sub-packages publish before `n8n` and `pnpm publish -r` skips versions already on npm, so a retry is safe. |
| After `n8n` reached npm | Dispatch **`release-recreate-failed-release.yml`** with `failed-version: X.Y.Z`. |
`release-recreate-failed-release.yml` checks out `release/X.Y.Z`, bumps only the
root and `packages/cli` versions to `X.Y.(Z+1)`, pushes `release/X.Y.(Z+1)` from
the same commit, and opens an auto-merging `release-pr/X.Y.(Z+1)`. Merging it
fires `release-publish.yml` as normal — `release-publish.yml` has no
`workflow_dispatch`, so a release PR is the only way to re-drive it.
Only those two `package.json` files move: the root version drives every publish
output (git tag, Docker tags, GitHub Release, SBOM) and `packages/cli` drives
the runtime `N8N_VERSION`. Every other package keeps its version, so the publish
step skips the ones already on npm and publishes whichever ones the failed run
never reached.
It refuses to run unless `n8n@X.Y.Z` is on npm and `n8n@X.Y.(Z+1)` is not. That
check fails closed: if the registry can't be reached, the run stops rather than
guessing. Dispatch with `force: true` to skip it.
The burned version stays on npm. Deprecate it by hand once the re-release is
out: `npm deprecate n8n@X.Y.Z "Failed release, use X.Y.(Z+1)"`.
### Other Release Workflows
| Workflow | Trigger | Purpose |
|----------------------------------|--------------------|------------------------------------------------|
| `release-standalone-package.yml` | Manual dispatch | Release individual packages (@n8n/codemirror-lang, @n8n/create-node, etc.) |
| `create-patch-release-branch.yml`| Manual dispatch | Cherry-pick commits for patch releases |
| Workflow | Trigger | Purpose |
|-------------------------------------------|-----------------|------------------------------------------------|
| `release-standalone-package.yml` | Manual dispatch | Release individual packages (@n8n/codemirror-lang, @n8n/create-node, etc.) |
| `release-create-patch-pr.yml` | Manual dispatch | Open a patch release PR for one track |
| `release-recreate-failed-release.yml` | Manual dispatch | Re-release a version whose publish failed after it reached npm |
---
@ -373,6 +583,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)
```
@ -382,7 +593,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 |
@ -391,12 +603,15 @@ 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 04:00 | `test-sbom-nightly.yml` | Release and image SBOM license validation |
| 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 |
| Monday 00:00 | `util-update-node-popularity.yml` | Node usage stats |
| Monday 02:00 | `test-e2e-coverage-weekly.yml` | Weekly E2E coverage |
| Saturday 22:00 | `test-evals-ai.yml` | AI workflow evals |
| 1st of month 04:00 | `util-refresh-cubic-schema.yml` | Refresh vendored cubic schema |
---
@ -411,9 +626,11 @@ to mechanical, tool-generated files (the pnpm lockfile, bot-maintained data file
`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.
mechanical files pre-resolved) is opened on `sync/master-to-3x`, naming both ends of the
conflict — the breaking-commit authors and the `master` commits that touched the same files
— via `sync-conflict-owners.mjs`, posting to `#alerts-v3-sync` and pausing further syncs
until it is resolved and merged normally. Delete/modify conflicts have no markers to carry,
so they are resolved toward `3.x` and listed as an explicit decision in the PR body.
`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`. On Mondays it also
retags that run's n8n + runners manifests as a release candidate (by digest on GHCR, so
@ -434,17 +651,67 @@ Composite actions in `.github/actions/`:
| Action | Purpose | Used By |
|--------------------------|----------------------------------------------|--------------------|
| `setup-nodejs` | pnpm + Node.js + Turbo cache + Docker (opt) | Most CI workflows |
| `run-workflow-script` | Run a `.github/scripts` module with no setup or install | Owners and PR quality checks |
| `docker-registry-login` | GHCR + DockerHub + DHI authentication | Docker workflows |
### setup-nodejs
```yaml
inputs:
node-version: # default: '24.18.1'
node-version: # default: '26.5.1'
enable-docker-cache: # default: 'false' (Blacksmith Buildx)
docker-cache-key: # required when enable-docker-cache is true
build-command: # default: 'pnpm build'
```
The pnpm version comes from the `packageManager` field in the root
`package.json`, through `resolve-pnpm-version.mjs`. There is no version input to
keep in sync: a bump in `package.json` moves the setup step, the cache key and
the version check together.
The action caches the pnpm executable in `~/setup-pnpm` by OS, architecture and
version. A cache miss runs `pnpm/setup` and retries once if the registry request
fails. The action verifies the version and saves the cache before the rest of
the job can fail.
Concurrent jobs can all miss a new key before the first save completes. Jobs
that start after the save use the cached executable. Windows keeps the standard
`pnpm/setup` path because its runner cannot activate the cached POSIX home path.
The existing `actions/setup-node` cache continues to store the pnpm package
store.
The Blacksmith layer cache lives on a sticky disk identified by
`docker-cache-key`, and commits are last-writer-wins. Splitting the key per
image would avoid that, but Blacksmith currently never populates a
newly created sticky disk - it stays at 0 bytes however many runs commit to it,
while the build reports a successful commit. Every job therefore shares the
`n8n-io/n8n` key, which is the only disk that actually retains layers. Revisit
once new-disk retention works.
### run-workflow-script
```yaml
inputs:
script: # path of the module, relative to the repository root
github-token: # token for the Octokit client, also exported as GITHUB_TOKEN
```
Runs one `.github/scripts` module through `actions/github-script`. That action
brings its own Node.js and an Octokit client, so the job needs no
`setup-nodejs` step and no dependency install. The action loads
`github-helpers.mjs`, hands the client to `setOctokit`, then imports the module
and awaits its exported `main()`.
Use it for a module that imports only node builtins and other `.github/scripts`
modules. `github-helpers.mjs` loads `@actions/github` and `semver` only when
they are present, so it works in both modes. A module that needs an npm
package (`semver`, `yaml`, `minimatch`, ...) keeps the `setup-nodejs` path with
the `.github/scripts` install command.
Pair it with a sparse checkout of `.github` when the module reads nothing else
from the tree. Cone mode always includes the root files, so `OWNERS` and
`package.json` stay available.
### docker-registry-login
```yaml
@ -454,6 +721,18 @@ inputs:
login-dhi: # default: 'false'
```
### External actions
Actions consumed from other n8n-io repositories, SHA-pinned like any third-party
action:
| Action | Purpose | Used By |
|-----------------------------------|-------------------------------------------------------------------------------|--------------------|
| `n8n-io/github-actions/cla-check` | CLA signature check: `CLA Check` commit status, in-place PR comment, `cla-signed` label | `ci-cla-check.yml` |
Behaviour changes belong in that repo; bumping the pin here is what picks them up.
A `/cla-check` comment on a PR re-runs the check without a push.
---
## Reusable Workflows
@ -466,11 +745,14 @@ Workflows with `workflow_call` trigger:
| `test-linting-reusable.yml` | `ref`, `nodeVersion` | ESLint |
| `test-e2e-reusable.yml` | `branch`, `test-mode`, `shards`, `runner` | Core E2E executor |
| `test-workflows-callable.yml` | `git_ref`, `compare_schemas` | Workflow tests |
| `docker-build-push.yml` | `n8n_version`, `release_type`, `push_enabled`, `ref`, `date_tag` | Docker build |
| `docker-build-push.yml` | `n8n_version`, `release_type`, `push_enabled`, `ref`, `date_tag`, `create_attestations` | Docker build |
| `sec-ci-reusable.yml` | `ref` | Security orchestrator |
| `sec-poutine-reusable.yml` | `ref` | Poutine scanner |
| `sec-sync-retarget-prs.yml` | none | Move bundle PRs back onto `bundle/*` |
| `security-trivy-scan-callable.yml` | `image_ref` | Trivy scan |
| `sbom-generation-callable.yml` | `n8n_version`, `release_tag_ref` | SBOM generation |
| `sbom-validation-callable.yml` | `sha` | Read-only SBOM validation |
| `test-single-instance-npm.yml` | `scope`, `base-ref`, `base-branch`, `blocking`, `timeout-minutes` | Dependency duplication |
---
@ -484,6 +766,7 @@ Scripts in `.github/scripts/`:
|-------------------------------|----------------------------|-------------------------|
| `bump-versions.mjs` | Calculate next version | `release-create-pr.yml` |
| `update-changelog.mjs` | Generate CHANGELOG | `release-create-pr.yml` |
| `prepare-rerelease.mjs` | Bump root + cli for a re-release | `release-recreate-failed-release.yml` |
| `trim-fe-packageJson.js` | Strip frontend devDeps | `release-publish.yml` |
| `ensure-provenance-fields.mjs`| Add license/author fields | `release-publish.yml` |
@ -493,7 +776,10 @@ Scripts in `.github/scripts/`:
|-------------------------|-------------------|------------------------|
| `docker/docker-config.mjs`| Build context | `docker-build-push.yml`|
| `docker/docker-tags.mjs` | Image tags | `docker-build-push.yml`|
| `docker/kafka-native-smoke-check.mjs`| Verify librdkafka binary loads in built image | `docker-build-push.yml`|
| `docker/kafka-native-smoke-check.mjs`| Verify librdkafka binary loads in built image | `docker-build-smoke.yml`|
| `docker/assert-manifest-format.mjs`| Assert a merged manifest is an OCI image index with the expected platforms | `docker-build-push.yml`|
| `docker/should-smoke-build.mjs`| Narrow the `pnpm-workspace.yaml` smoke trigger to native dependency pins | `docker-build-smoke.yml`|
| `attest-image-sbom.mjs` | Generate, validate, and optionally attest image SBOMs | `docker-build-push.yml`, `test-sbom-nightly.yml` |
### Validation Scripts
@ -501,7 +787,39 @@ 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 |
| `resolve-pnpm-version.mjs` | Publish the pinned pnpm version and its executable cache key | `setup-nodejs` action |
| `nightly-sbom-context.mjs` | Resolve the source SHA and image tag for nightly SBOM validation | `test-sbom-nightly.yml` |
| `db-test-matrix.mjs` | DB test matrix from `postgres-versions.json` | `ci-pull-requests.yml` |
| `quality/check-cubic-config.mjs` | Validate `cubic.yaml` against the vendored cubic schema; enforce its silent agent/character limits. `--refresh` re-pulls the schema | `test-workflow-scripts-reusable.yml`, `util-refresh-cubic-schema.yml` |
| `glob.mjs` | Builtin-only glob matcher for changed-file paths (`**`, `*`, dot segments) | `quality/check-pr-size.mjs` |
| `probe-registry.mjs` | Registry path throughput probe (temporary) | `util-probe-registry.yml` |
### Preview Scripts
| Script | Purpose | Called By |
|---------------------------------|-------------------------------------------------------------------------|--------------------------------|
| `codespace-preview.mjs` | Map a `pull_request` event or a manual operation onto a preview operation, comment the result | `util-codespace-preview.yml` |
| `../../scripts/preview.mjs` | One codespace for each PR: `up`, `refresh`, `down`, `ls`. `--json` for CI | `codespace-preview.mjs`, developers |
| `../../scripts/preview-remote-env.mjs` | Fetch extra environment for a preview from the webhook, inside the box | `../../scripts/preview-serve.mjs` |
| `../../scripts/preview-phases.mjs` | The phase vocabulary and its one-line marker, so the runner, the box and the comment cannot drift | `codespace-preview.mjs`, `../../scripts/preview.mjs`, `../../scripts/preview-serve.mjs` |
`scripts/preview.mjs` is also the developer entry point (`pnpm preview up <pr>`).
In `--json` mode stdout carries one line for each phase and then the report object,
and all human progress goes to stderr. So a workflow can follow a run that also
streams an in-box build log. The reader tells the two apart by the `url` field: the
report has one, a phase line never does.
### 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; skips while the base is unpublished; fail-loud, never resolves conflicts | `sec-sync-bundle-branches.yml` |
### Slack Scripts
@ -526,13 +844,66 @@ See **[CI-TELEMETRY.md](CI-TELEMETRY.md)** for:
---
## CODEOWNERS
## OWNERS
Team ownership mappings in `CODEOWNERS`:
Team ownership lives in the top-level `OWNERS` file (this replaces the
GitHub-native `CODEOWNERS` file; see the transition note below). The scripts
that consume it live in `.github/scripts/owners/`. Line format:
| Path Pattern | Team |
|--------------------------------------------------------------|----------------------------|
| `packages/@n8n/db/src/migrations/` | @n8n-io/migrations-review |
```
<pattern> <@org/team> [required]
```
Patterns are a catch-all (`*`), a directory prefix (`packages/x/`), or an
exact file path. Matching is last-match-wins, so specific rules must come
after general rules.
The format is strict, enforced by `node .github/scripts/owners/owners.mjs --check`:
- Tokens on a line come in a fixed order: pattern, one team, then options
such as `required`.
- Directory patterns end with `/` and must be existing directories; all other
patterns must be existing files. Duplicate patterns are rejected.
Team existence is not checked by `--check` (it needs an org read token, which
fork PRs do not have); a separate workflow covers it (DEVP-891).
The file drives four workflows:
| Workflow | Purpose |
|-----------------------------------|--------------------------------------------------------------------------|
| `ci-owners-validation.yml` | Validates OWNERS (syntax, dead paths) via `owners.mjs --check` |
| `ci-owners-review-recommendations.yml` | Advisory PR comment: reviewer teams, line stats, required reviews |
| `ci-owners-assign-reviewers.yml` | Opt-in reviewer auto-assignment (label-triggered) |
| `ci-owners-required-reviews.yml` | Enforces `required` entries via the "Required Reviews" commit status |
### Required reviews
An entry with the `required` option makes team approval mandatory: when a PR
changes a file whose winning entry carries `required`, a member of each listed
team must approve the PR. `ci-owners-required-reviews.yml` evaluates this on
PR changes and review events, and reports a commit status
named **Required Reviews** on the head SHA. A missing approval reports
`pending` ("Waiting for approval from: …"), not `failure`, so an unreviewed PR
does not show red CI; any non-success state blocks the merge equally. The
ruleset for `master` must list
that status as a required check for the block to take effect. Merge-queue runs
report success on the queue head without re-evaluating: a PR cannot enter the
queue unless the status is green on its head, and the queue does not change
approvals.
The workflow reads OWNERS and its scripts from the base branch only, so a PR
cannot lift its own review requirement.
### Transition from CODEOWNERS
During a trial period, `.github/CODEOWNERS` stays in place next to OWNERS:
GitHub's native code-owner enforcement keeps gating merges while the
"Required Reviews" status runs side by side. The two must agree — CODEOWNERS
holds exactly the `required` entries of OWNERS (plus the OWNERS file itself)
and must not gain new entries; new ownership goes into OWNERS. After the
trial, delete `.github/CODEOWNERS`, remove "Require review from Code Owners"
from the master ruleset, and delete this section (tracked in DEVP-887).
---
@ -617,10 +988,50 @@ Supply chain security ensures artifacts haven't been tampered with. We provide t
### SBOM
- **Runs on:** release-publish
- **Format:** CycloneDX JSON
- **Signing:** GitHub Attestation API
- **Attached to:** GitHub Release
There are two, with different subjects and different consumers. They are not duplicates.
| | Release SBOM | Image SBOM |
|---|---|---|
| **Job** | `generate-and-attach-sbom` (`sbom-generation-callable.yml`) | `sbom-attestation` (`docker-build-push.yml`) |
| **Scans** | the deployed npm closure in `compiled/` (`cdxgen -t pnpm`) | each pushed image, by digest (`syft`) |
| **Covers** | npm only | OS packages **and** npm, as laid down in the image |
| **Signing** | GitHub Attestation API, subject `./package.json` | `cosign attest`, subject = image digest |
| **Output** | `sbom-source.cdx.json`, `THIRD_PARTY_LICENSES.md`, `vex.openvex.json` on the GitHub Release | attestation in the registry beside the image |
| **Consumer** | humans — legal/license compliance; backs `/third-party-licenses` | machines — `cosign verify-attestation`, admission control |
Format is CycloneDX JSON 1.6 for both. Each pipeline pins the schema version, so a
scanner upgrade cannot change the shape of a signed artifact without a visible diff.
The two use different scanners on purpose. The release SBOM runs `cdxgen -t pnpm` over the
resolved pnpm closure with `FETCH_LICENSE=true`, because a lockfile scan has no package files
to read licenses from. The image SBOM runs `syft` over the pushed image, which resolves
licenses from the LICENSE files on disk and so needs no network at all.
The image job used to run `cdxgen -t docker --profile license-compliance`. That profile sets
`FETCH_LICENSE=true` and nothing else, so it made one sequential npm registry call per
component — roughly 3,700 per release, about half the job's runtime. syft resolves the same
licenses locally in a fraction of the time, and catalogues more of the image besides.
A/B any scanner change against the current output before shipping it. The gate only enforces
`pkg:npm/`, so a change can silently degrade PyPI or OS license coverage while CI stays green.
Compare the licenses resolved per component, not just the component counts.
`enrich-sbom.mjs --drop-phantom-npm` removes scan artefacts that would otherwise assert
components the image does not contain: nested test/fixture `package.json` and `exports`
subpaths. It reads the component's source path from either scanner's property name (`SrcFile`
for cdxgen, `syft:location:0:path` for syft) and treats syft's `version: "UNKNOWN"` the same as
a missing version.
Packages whose license cannot be resolved from disk go in
`scripts/licenses/license-overrides.json` with a verified `source` citation — the upstream
LICENSE file, not registry metadata.
`test-sbom-nightly.yml` runs at 04:00 UTC. It waits up to two hours for the current scheduled
Docker build to complete. It builds the production deployment closure at that run's SHA and
validates the release SBOM. It also resolves the four immutable SHA image tags from that
build and validates each image SBOM. The validation uses the same enrichment and SPDX gates
as a release. It does not publish, attest, or upload an artifact. A failure reports to the
Developer Platform Slack channel.
### SLSA L3 Provenance
@ -695,17 +1106,82 @@ Embargoed security work happens in `n8n-io/n8n-private`. `sec-sync-public-to-pri
runs hourly there (and on `workflow_dispatch` with `force` for conflict recovery),
mirroring public `master` and `1.x` into private with `reset --hard` +
`--force-with-lease` — skipping a branch when private is ahead, ignoring `chore: Bundle`
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.
commits when judging "ahead". A skipped branch, or a hard failure, is reported to
`#alerts-build`: a non-`chore: Bundle` commit on private `master`/`1.x` leaves the mirror
stuck every hour until it is removed or a `force` dispatch overwrites it. 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`). 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. A `chore: Bundle/*` PR whose
*Required Checks* go red holds back every fix batched into it, so `ci-pull-requests.yml`
posts to `#alerts-build` when that gate fails on a PR opened *from* `bundle/2.x` or
`bundle/1.x` (link only, no PR title, since the branch is embargoed).
`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.
**A bundle branch is only ever built on published history.** Before it creates or merges
anything, the sync fetches the same-named branch from `https://github.com/n8n-io/n8n.git`
(anonymously — its token is scoped to the private repo) and checks that the private base tip is
contained in it. This matters because the `chore: Bundle/*` squash on private `master` is
*private-only*: the mirror above discards it in favour of the public cherry-pick of the same
changes. A sync in that window would root the branch on a commit that is about to disappear,
leaving it carrying two commits for one set of fixes — and every fix PR cut from it inherits the
dead one. The window is real: this workflow's daily cron and the mirror's hourly cron both fire
at `:00` with nothing ordering them, which is why the check lives in the script rather than in
step ordering.
An unpublished cut is a **skip**, not a failure: the mirror discards that commit every hour
regardless, so the run exits green with the reason in the log and the next one proceeds. A
missing branch also self-heals, because the mirror re-dispatches this workflow while one is
absent. A base ahead of public for **any other reason** fails the run — the mirror is stuck and
will not clear it on its own, so fix that first (remove the commit, or dispatch **Security: Sync
from Public** with `force`) and re-run. To sync a bundle branch immediately, dispatch the mirror
and then this workflow. The blocking commits are listed in the run log; the failure annotation
carries only a count, since a subject hints at the fix.
Deleting a bundle branch takes its open PRs with it: GitHub moves each one onto the deleted
branch's own base, and re-creating the branch does not move them back. So the sync then calls
[`sec-sync-retarget-prs.yml`](workflows/sec-sync-retarget-prs.yml), which moves every open PR
on `master` onto `bundle/2.x` and every one on `1.x` onto `bundle/1.x`. It skips a bundle
branch that does not exist, and skips PRs whose *head* is `bundle/*` — those are the
`chore: Bundle/*` cut PRs, which target the base on purpose. It is also dispatchable on its
own. `sec-sync-public-to-private.yml` only *dispatches* the bundle sync when it finds a branch
missing; the retarget runs inside that dispatched run, once the branch exists.
**`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.
@ -746,7 +1222,7 @@ If notify is a step inside an existing checked-out job, skip the `checkout` and
| `QBOT_SLACK_TOKEN` | QBot | Default — engineering / build / security |
| `RELEASE_HELPER_SLACK_TOKEN` | Release Helper | `#releases` (C036AELNMV0) |
Adding a new channel requires inviting the bot first; the first run otherwise fails loudly with `not_in_channel`. Private-repo workflows (`sec-publish-fix*.yml`) need `QBOT_SLACK_TOKEN` set in `n8n-io/n8n-private`; the scripts themselves are mirrored by `sec-sync-public-to-private.yml`.
Adding a new channel requires inviting the bot first; the first run otherwise fails loudly with `not_in_channel`. Private-repo workflows (`sec-publish-fix*.yml`, `sec-sync-public-to-private.yml`, and the bundle-PR alert in `ci-pull-requests.yml`) need `QBOT_SLACK_TOKEN` set in `n8n-io/n8n-private`; the scripts themselves are mirrored by `sec-sync-public-to-private.yml`.
---

View File

@ -12,7 +12,7 @@ description: 'Builds n8nio/n8n + n8nio/runners test images and publishes them as
inputs:
build-variant:
description: 'standard or coverage. Coverage uses build:docker:coverage.'
description: 'standard, coverage, or pc. Coverage uses build:docker:coverage. pc builds n8n on the pointer-compressed Node base images.'
required: false
default: 'standard'
@ -21,10 +21,10 @@ runs:
steps:
- name: Check cache for existing image
id: cache-check
uses: actions/cache/restore@640a1c2554105b57832a23eea0b4672fc7a790d5 # v4.2.3
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: n8n-docker-image-${{ inputs.build-variant }}-${{ github.sha }}
path: /tmp/n8n-image.tar.zst
key: n8n-docker-image-v2-${{ inputs.build-variant }}-${{ github.sha }}
path: /tmp/n8n-images
lookup-only: true
- name: Build Docker image
@ -33,19 +33,32 @@ runs:
with:
build-command: ${{ inputs.build-variant == 'coverage' && 'pnpm build:docker:coverage' || 'pnpm build:docker' }}
enable-docker-cache: true
docker-cache-key: n8n-io/n8n
env:
INCLUDE_TEST_CONTROLLER: 'true'
DOCKER_BUILD_TARBALL_DIR: /tmp/n8n-images
DOCKER_BUILD_PC: ${{ inputs.build-variant == 'pc' }}
- name: Save image tarball
if: steps.cache-check.outputs.cache-hit != 'true'
- name: Assert pointer compression
if: steps.cache-check.outputs.cache-hit != 'true' && inputs.build-variant == 'pc'
shell: bash
run: |
docker save n8nio/n8n:local n8nio/runners:local | zstd -T0 -3 -o /tmp/n8n-image.tar.zst
ls -lh /tmp/n8n-image.tar.zst
docker load -i /tmp/n8n-images/n8n-pc.tar
docker run --rm --entrypoint node n8nio/n8n:local -e "
if (!process.config.variables.v8_enable_pointer_compression) {
throw new Error('pc variant image is not pointer-compressed');
}
console.log(process.version, 'pointer compression: on');
"
- name: Report tarball sizes
if: steps.cache-check.outputs.cache-hit != 'true'
shell: bash
run: ls -lh /tmp/n8n-images
- name: Publish image tarball to cache
if: steps.cache-check.outputs.cache-hit != 'true'
uses: actions/cache/save@640a1c2554105b57832a23eea0b4672fc7a790d5 # v4.2.3
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: n8n-docker-image-${{ inputs.build-variant }}-${{ github.sha }}
path: /tmp/n8n-image.tar.zst
key: n8n-docker-image-v2-${{ inputs.build-variant }}-${{ github.sha }}
path: /tmp/n8n-images

View File

@ -13,6 +13,7 @@ import {
getChangedFiles,
getAddedFiles,
getMergeBase,
resolveHeadRef,
} from '../ci-filter.mjs';
// --- matchGlob ---
@ -395,6 +396,100 @@ describe('getChangedFiles', () => {
});
});
// --- resolveHeadRef ---
describe('resolveHeadRef', () => {
it('diffs the PR head (second parent of the test merge) on pull request events', () => {
assert.equal(resolveHeadRef('pull_request'), 'HEAD^2');
assert.equal(resolveHeadRef('pull_request_review'), 'HEAD^2');
});
it('diffs HEAD on every other event', () => {
assert.equal(resolveHeadRef('merge_group'), 'HEAD');
assert.equal(resolveHeadRef('workflow_dispatch'), 'HEAD');
assert.equal(resolveHeadRef(undefined), 'HEAD');
});
});
// --- getChangedFiles on a stacked PR's test-merge commit (shallow clone) ---
describe('getChangedFiles (stacked PR test merge)', () => {
const builderDir = mkdtempSync(join(tmpdir(), 'ci-filter-build-stack-'));
const remoteDir = mkdtempSync(join(tmpdir(), 'ci-filter-remote-stack-'));
const repoDir = mkdtempSync(join(tmpdir(), 'ci-filter-stack-'));
const originalCwd = process.cwd();
const originalStep = process.env.CI_FILTER_DEEPEN_STEP;
const git = (args: string[], cwd: string) =>
execFileSync('git', args, { cwd, stdio: 'pipe' }).toString().trim();
before(() => {
execFileSync('git', ['init', '--bare', '-b', 'main', remoteDir], { stdio: 'pipe' });
git(['init', '-b', 'main'], builderDir);
git(['config', 'user.email', 'test@test.local'], builderDir);
git(['config', 'user.name', 'test'], builderDir);
git(['remote', 'add', 'origin', remoteDir], builderDir);
writeFileSync(join(builderDir, 'shared.ts'), 'shared\n');
git(['add', '.'], builderDir);
git(['commit', '-m', 'root'], builderDir);
git(['push', 'origin', 'main'], builderDir);
// Bottom of the stack: parent PR branch off root
git(['checkout', '-b', 'parent-branch'], builderDir);
writeFileSync(join(builderDir, 'parent.ts'), 'parent\n');
git(['add', '.'], builderDir);
git(['commit', '-m', 'parent PR change'], builderDir);
git(['push', 'origin', 'parent-branch'], builderDir);
// Top of the stack: this PR branches off the parent PR
git(['checkout', '-b', 'pr-branch'], builderDir);
writeFileSync(join(builderDir, 'pr-only.ts'), 'pr\n');
git(['add', '.'], builderDir);
git(['commit', '-m', 'PR change'], builderDir);
// main drifts after the stack was created
git(['checkout', 'main'], builderDir);
writeFileSync(join(builderDir, 'shared.ts'), 'shared\ndrift\n');
git(['commit', '-am', 'main moves'], builderDir);
git(['push', 'origin', 'main'], builderDir);
// GitHub builds the parent PR's test merge against main, then builds
// this PR's test merge on top of it instead of on parent-branch.
git(['checkout', '--detach', 'main'], builderDir);
git(['merge', '--no-ff', '-m', 'Merge parent-branch into main', 'parent-branch'], builderDir);
git(['merge', '--no-ff', '-m', 'Merge pr-branch into parent merge', 'pr-branch'], builderDir);
git(['push', 'origin', 'HEAD:refs/pull/1/merge'], builderDir);
// Mirror actions/checkout: depth-1 fetch of the test-merge ref
git(['init'], repoDir);
git(['remote', 'add', 'origin', `file://${remoteDir}`], repoDir);
git(['fetch', '--depth=1', 'origin', '+refs/pull/1/merge:refs/remotes/pull/1/merge'], repoDir);
git(['checkout', '--force', 'refs/remotes/pull/1/merge'], repoDir);
process.env.CI_FILTER_DEEPEN_STEP = '1';
process.chdir(repoDir);
});
after(() => {
process.chdir(originalCwd);
if (originalStep === undefined) delete process.env.CI_FILTER_DEEPEN_STEP;
else process.env.CI_FILTER_DEEPEN_STEP = originalStep;
rmSync(builderDir, { recursive: true, force: true });
rmSync(remoteDir, { recursive: true, force: true });
rmSync(repoDir, { recursive: true, force: true });
});
it('diffing HEAD reports the base-branch drift carried by the parent test merge', () => {
assert.equal(git(['rev-parse', '--is-shallow-repository'], repoDir), 'true');
assert.deepEqual(getChangedFiles('parent-branch', 'HEAD').sort(), ['pr-only.ts', 'shared.ts']);
});
it('diffing HEAD^2 reports only the files this PR changes', () => {
assert.deepEqual(getChangedFiles('parent-branch', 'HEAD^2'), ['pr-only.ts']);
assert.deepEqual(getAddedFiles('parent-branch', 'HEAD^2'), ['pr-only.ts']);
assert.equal(getMergeBase('HEAD^2'), git(['rev-parse', 'origin/parent-branch'], repoDir));
});
});
// --- getChangedFiles deep merge-base (shallow clone, stale base) ---
describe('getChangedFiles (shallow clone, stale base)', () => {

View File

@ -34,7 +34,7 @@ outputs:
description: 'Resolved base ref used for the diff (filter mode only)'
value: ${{ steps.run.outputs.base-ref }}
merge-base:
description: 'Merge-base SHA between FETCH_HEAD and HEAD (filter mode only)'
description: 'Merge-base SHA between the base ref and the PR head (filter mode only)'
value: ${{ steps.run.outputs.merge-base }}
runs:

View File

@ -95,7 +95,19 @@ export function parseFilters(input) {
const SAFE_REF = /^[a-zA-Z0-9_./-]+$/;
export function getChangedFiles(baseRef) {
/**
* Ref to diff against the base branch.
*
* On pull_request events checkout lands on GitHub's test-merge commit. For a
* stacked PR its first parent is the parent PR's test merge, which already
* carries every base-branch commit the stack lacks, so a diff against HEAD
* reports that drift as changed. The second parent is the PR head itself.
*/
export function resolveHeadRef(eventName = process.env.GITHUB_EVENT_NAME) {
return eventName === 'pull_request' || eventName === 'pull_request_review' ? 'HEAD^2' : 'HEAD';
}
export function getChangedFiles(baseRef, headRef = 'HEAD') {
if (!SAFE_REF.test(baseRef)) {
throw new Error(`Unsafe base ref: "${baseRef}"`);
}
@ -104,8 +116,8 @@ export function getChangedFiles(baseRef) {
// direction, so files added to base-branch after the PR diverged show up as
// "changed" — spuriously triggering path-filtered jobs. The merge base
// scopes the diff to PR-only changes.
fetchUntilMergeBase(baseRef);
const output = execSync('git diff --name-only --no-renames --merge-base FETCH_HEAD HEAD', {
fetchUntilMergeBase(baseRef, headRef);
const output = execSync(`git diff --name-only --no-renames --merge-base FETCH_HEAD ${headRef}`, {
encoding: 'utf-8',
});
return output
@ -116,12 +128,12 @@ export function getChangedFiles(baseRef) {
// Files added (not merely modified) in this PR. Same merge-base scoping as
// getChangedFiles; assumes the fetch it performs has already run.
export function getAddedFiles(baseRef) {
export function getAddedFiles(baseRef, headRef = 'HEAD') {
if (!SAFE_REF.test(baseRef)) {
throw new Error(`Unsafe base ref: "${baseRef}"`);
}
const output = execSync(
'git diff --name-only --no-renames --diff-filter=A --merge-base FETCH_HEAD HEAD',
`git diff --name-only --no-renames --diff-filter=A --merge-base FETCH_HEAD ${headRef}`,
{ encoding: 'utf-8' },
);
return output
@ -132,7 +144,7 @@ export function getAddedFiles(baseRef) {
/**
* Deepen the shallow clone until the merge base between the fetched base ref
* (FETCH_HEAD) and HEAD is reliably reachable.
* (FETCH_HEAD) and headRef is reliably reachable.
*
* A single fixed deepen is not enough for stale PRs whose divergence point is
* older than the shallow boundary. We fetch with an exponentially growing
@ -144,15 +156,15 @@ export function getAddedFiles(baseRef) {
* git's signed int32 `--deepen` limit), switch to `--unshallow` instead of
* passing an ever-larger integer that git would reject.
*/
function fetchUntilMergeBase(baseRef) {
function fetchUntilMergeBase(baseRef, headRef) {
let step = Number(process.env.CI_FILTER_DEEPEN_STEP) || 200;
const maxDeepen = Number(process.env.CI_FILTER_MAX_DEEPEN) || 20_000;
deepenFetch(baseRef, step, maxDeepen);
while (!hasReliableMergeBase()) {
while (!hasReliableMergeBase(headRef)) {
if (!isShallow()) {
throw new Error(
`No merge base between FETCH_HEAD and HEAD after fetching the full history of "${baseRef}" (unrelated histories).`,
`No merge base between FETCH_HEAD and ${headRef} after fetching the full history of "${baseRef}" (unrelated histories).`,
);
}
step *= 2;
@ -168,9 +180,7 @@ function deepenFetch(baseRef, step, maxDeepen) {
}
function isShallow() {
return (
execSync('git rev-parse --is-shallow-repository', { encoding: 'utf-8' }).trim() === 'true'
);
return execSync('git rev-parse --is-shallow-repository', { encoding: 'utf-8' }).trim() === 'true';
}
/**
@ -179,12 +189,12 @@ function isShallow() {
* sub-history is truncated; that result is unreliable, so we must deepen further
* before trusting it.
*/
function hasReliableMergeBase() {
function hasReliableMergeBase(headRef) {
let base;
try {
base = execSync('git merge-base FETCH_HEAD HEAD', { encoding: 'utf-8' }).trim();
base = execSync(`git merge-base FETCH_HEAD ${headRef}`, { encoding: 'utf-8' }).trim();
} catch {
return false; // no common ancestor reachable yet
return false; // no common ancestor reachable yet, or headRef still hidden by the shallow graft
}
if (!base) return false;
return !readShallowBoundaries().has(base);
@ -208,12 +218,12 @@ function readShallowBoundaries() {
}
/**
* Resolve the merge-base SHA between FETCH_HEAD and HEAD.
* Resolve the merge-base SHA between FETCH_HEAD and headRef.
* Used to give downstream tools (e.g. janitor's AST diff) a stable, PR-only
* comparison point that doesn't drift when the base branch moves forward.
*/
export function getMergeBase() {
return execSync('git merge-base FETCH_HEAD HEAD', { encoding: 'utf-8' }).trim();
export function getMergeBase(headRef = 'HEAD') {
return execSync(`git merge-base FETCH_HEAD ${headRef}`, { encoding: 'utf-8' }).trim();
}
// --- Filter evaluation ---
@ -290,10 +300,12 @@ export function runFilter() {
}
const filters = parseFilters(filtersInput);
const changedFiles = getChangedFiles(baseRef);
const addedFiles = getAddedFiles(baseRef);
const mergeBase = getMergeBase();
const headRef = resolveHeadRef();
const changedFiles = getChangedFiles(baseRef, headRef);
const addedFiles = getAddedFiles(baseRef, headRef);
const mergeBase = getMergeBase(headRef);
console.log(`Diffing ${headRef} against ${baseRef}`);
console.log(`Merge base: ${mergeBase}`);
console.log(`Changed files (${changedFiles.length}):`);
for (const f of changedFiles) {

View File

@ -11,11 +11,11 @@
# the tarball to cache as a side effect.
name: 'Load n8n Docker images from cache'
description: 'Restores the zstd-compressed n8n + runners image tarball from the variant+SHA-keyed GHA cache and loads both images into the local docker daemon. Falls back to rebuilding on cache miss.'
description: 'Restores the n8n + runners image tarballs from the variant+SHA-keyed GHA cache and loads both images into the local docker daemon. Falls back to rebuilding on cache miss.'
inputs:
build-variant:
description: 'Variant of the image to load. Must match what build-n8n-docker used (standard or coverage).'
description: 'Variant of the image to load. Must match what build-n8n-docker used (standard, coverage, or pc).'
required: false
default: 'standard'
cache-sha:
@ -28,15 +28,26 @@ runs:
steps:
- name: Restore image tarball from cache
id: restore
uses: actions/cache/restore@640a1c2554105b57832a23eea0b4672fc7a790d5 # v4.2.3
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: n8n-docker-image-${{ inputs.build-variant }}-${{ inputs.cache-sha || github.sha }}
path: /tmp/n8n-image.tar.zst
key: n8n-docker-image-v2-${{ inputs.build-variant }}-${{ inputs.cache-sha || github.sha }}
path: /tmp/n8n-images
- name: Load n8n and runners images into docker
if: steps.restore.outputs.cache-hit == 'true'
shell: bash
run: zstd -d -c /tmp/n8n-image.tar.zst | docker load
# A restore reports a hit whatever it wrote, and writes to the paths
# recorded at save time. Fail loudly rather than letting an unexpanded
# glob reach `docker load`.
run: |
set -euo pipefail
shopt -s nullglob
files=(/tmp/n8n-images/*.tar)
if [ ${#files[@]} -eq 0 ]; then
echo "::error::cache hit but /tmp/n8n-images holds no archives"
exit 1
fi
for f in "${files[@]}"; do docker load -i "$f"; done
- name: Warn on cache miss
if: steps.restore.outputs.cache-hit != 'true'
@ -45,10 +56,25 @@ runs:
BUILD_VARIANT: ${{ inputs.build-variant }}
GITHUB_SHA: ${{ inputs.cache-sha || github.sha }}
run: |
echo "::warning::Cache miss for n8n-docker-image-$BUILD_VARIANT-$GITHUB_SHA (SHA $GITHUB_SHA); falling back to rebuild via build-n8n-docker."
echo "::warning::Cache miss for n8n-docker-image-v2-$BUILD_VARIANT-$GITHUB_SHA (SHA $GITHUB_SHA); falling back to rebuild via build-n8n-docker."
- name: Rebuild image on cache miss
if: steps.restore.outputs.cache-hit != 'true'
uses: ./.github/actions/build-n8n-docker
with:
build-variant: ${{ inputs.build-variant }}
# build-n8n-docker writes archives rather than loading them, so the fallback
# has to load them here or downstream steps find no images.
- name: Load rebuilt images into docker
if: steps.restore.outputs.cache-hit != 'true'
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
files=(/tmp/n8n-images/*.tar)
if [ ${#files[@]} -eq 0 ]; then
echo "::error::rebuild produced no archives in /tmp/n8n-images"
exit 1
fi
for f in "${files[@]}"; do docker load -i "$f"; done

View File

@ -0,0 +1,42 @@
# Runs one `.github/scripts` module without a Node.js setup or a dependency
# install. actions/github-script brings its own Node.js and Octokit client;
# the module receives that client through `setOctokit` in github-helpers.mjs.
#
# The module must export an async `main()` function and must import only
# node builtins and other `.github/scripts` modules. A module that needs an
# npm package (semver, yaml, ...) keeps using `setup-nodejs` with an
# install command instead.
name: 'Run workflow script'
description: 'Runs a .github/scripts module through actions/github-script, without a Node.js setup or dependency install.'
inputs:
script:
description: 'Path of the module, relative to the repository root. It must export an async main() function.'
required: true
github-token:
description: 'Token for the Octokit client. Also exported as GITHUB_TOKEN for the module.'
required: true
runs:
using: 'composite'
steps:
- name: Run workflow script
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
SCRIPT: ${{ inputs.script }}
GITHUB_TOKEN: ${{ inputs.github-token }}
with:
github-token: ${{ inputs.github-token }}
script: |
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const load = (file) => import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, file)).href);
const { setOctokit } = await load('.github/scripts/github-helpers.mjs');
setOctokit(github);
const { main } = await load(process.env.SCRIPT);
if (typeof main !== 'function') {
throw new Error(`${process.env.SCRIPT} does not export main()`);
}
await main();

View File

@ -7,31 +7,102 @@ 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
default: 'false'
docker-cache-key:
description: 'Sticky-disk identity for the Docker layer cache. Required when enable-docker-cache is true. Use n8n-io/n8n: Blacksmith only retains disks that already exist, and a newly created key stays empty across runs, so a per-image key silently disables caching entirely.'
required: false
default: ''
build-command:
description: 'Command to execute for building the project or an optional command. Leave empty to skip build step.'
required: false
default: 'pnpm build'
install-command:
description: 'Command to execute for installing project dependencies. Leave empty to skip install step.'
description: 'Command to install project dependencies. The action trusts the committed lockfile because SafeChain enforces the CI supply-chain policy. Leave empty to skip the install step.'
required: false
default: 'pnpm install --frozen-lockfile'
cache-dependency-path:
description: 'Path(s) to the lockfile(s) used to compute the pnpm-store cache key. Scope this down (e.g. to `.github/scripts/pnpm-lock.yaml`) when only installing a subset, to avoid restoring the full workspace store.'
description: 'Path(s) to the lockfile(s) used to compute the pnpm store and metadata cache keys. Scope this down (e.g. to `.github/scripts/pnpm-lock.yaml`) when only installing a subset.'
required: false
default: 'pnpm-lock.yaml'
runs:
using: 'composite'
steps:
# An empty key makes the builder use a local, uncached one. The job stays
# green and loses the cache. Fail here instead.
- name: Verify Docker cache key is set
if: ${{ inputs.enable-docker-cache == 'true' && inputs.docker-cache-key == '' }}
shell: bash
run: |
echo "::error::enable-docker-cache is true but docker-cache-key is empty"
exit 1
# Use one pin for setup, cache lookup and verification.
- name: Resolve pnpm version
id: pnpm-version
shell: bash
run: node .github/scripts/resolve-pnpm-version.mjs
# Windows cannot activate a POSIX home path from the restored cache.
- name: Restore pnpm executable
if: runner.os != 'Windows'
id: cache-pnpm-exe
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/setup-pnpm
key: ${{ steps.pnpm-version.outputs.cache-key }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
id: setup-pnpm
if: steps.cache-pnpm-exe.outputs.cache-hit != 'true'
continue-on-error: true
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
with:
install: false
version: ${{ steps.pnpm-version.outputs.version }}
- name: Retry pnpm setup
if: steps.setup-pnpm.outcome == 'failure'
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
with:
install: false
version: ${{ steps.pnpm-version.outputs.version }}
# Cache hits restore files but not the per-job environment.
- name: Activate cached pnpm executable
if: steps.cache-pnpm-exe.outputs.cache-hit == 'true'
shell: bash
run: | # zizmor: ignore[github-env] Fixed paths contain no external input.
PNPM_DEST="$HOME/setup-pnpm"
echo "PNPM_HOME=$PNPM_DEST" >> "$GITHUB_ENV"
echo "$PNPM_DEST" >> "$GITHUB_PATH"
echo "$PNPM_DEST/bin" >> "$GITHUB_PATH"
# A stale or truncated cache entry must fail here, not later in the install.
- name: Verify pnpm Version
shell: bash
env:
EXPECTED_PNPM_VERSION: ${{ steps.pnpm-version.outputs.version }}
run: |
ACTUAL_PNPM_VERSION="$(pnpm --version)"
if [ "$ACTUAL_PNPM_VERSION" != "$EXPECTED_PNPM_VERSION" ]; then
echo "::error::pnpm ${EXPECTED_PNPM_VERSION} is not active (got ${ACTUAL_PNPM_VERSION})"
exit 1
fi
# Save before later job failures can prevent cache creation.
- name: Save pnpm executable
if: runner.os != 'Windows' && steps.cache-pnpm-exe.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/setup-pnpm
key: ${{ steps.pnpm-version.outputs.cache-key }}
# Cache the Node toolcache so setup-node skips the ~33s nodejs.org download
# on every subsequent job. First fresh runner pays the download; later jobs
@ -47,7 +118,7 @@ runs:
# alongside the toolchain. Key bumped to v2 to invalidate stale entries.
- name: Restore Node.js Toolcache
if: runner.os == 'Linux' && runner.arch == 'X64'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /opt/hostedtoolcache/node/${{ inputs.node-version }}
key: node-toolcache-v2-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}
@ -56,7 +127,11 @@ runs:
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ inputs.node-version }}
cache: 'pnpm'
# A job that installs nothing must not participate in the pnpm-store
# cache. Its post step would save an empty store, and cache keys are
# write-once — so if it lost the race to a real installer, every other
# job on that key would restore nothing and silently re-download.
cache: ${{ inputs.install-command != '' && 'pnpm' || '' }}
cache-dependency-path: ${{ inputs.cache-dependency-path }}
# Fail fast if setup-node silently fell through to the runner's baked-in
@ -83,6 +158,24 @@ runs:
mkdir -p "$PNPM_STORE_PATH"
fi
# pnpm keeps registry metadata and its lockfile verification log outside the package store.
- name: Resolve pnpm metadata cache directory
if: ${{ inputs.install-command != '' }}
id: pnpm-metadata-cache
shell: bash
run: |
PNPM_CACHE_PATH="$(pnpm cache path --silent)"
mkdir -p "$PNPM_CACHE_PATH"
echo "path=$PNPM_CACHE_PATH" >> "$GITHUB_OUTPUT"
echo "version=$(pnpm --version)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm metadata cache
if: ${{ inputs.install-command != '' }}
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.pnpm-metadata-cache.outputs.path }}
key: pnpm-metadata-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.pnpm-metadata-cache.outputs.version }}-${{ hashFiles(inputs.cache-dependency-path) }}
- name: Configure SafeChain
shell: bash
run: |
@ -98,10 +191,10 @@ runs:
# CDN must be hit. Keep the version in sync with the download step below.
- name: Restore Aikido SafeChain Binary
id: cache-safe-chain
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.safe-chain/bin
key: safe-chain-1.5.7-${{ runner.os }}-${{ runner.arch }}
key: safe-chain-1.5.20-${{ runner.os }}-${{ runner.arch }}
# Download + install the SafeChain binary. This is the expensive,
# CDN-backed part and the only part worth gating on the cache. The install
@ -110,8 +203,8 @@ runs:
- name: Download Aikido SafeChain
if: steps.cache-safe-chain.outputs.cache-hit != 'true'
run: |
VERSION="1.5.7"
EXPECTED_SHA256="07ab512fd8795ce41b2275be369aced4c9a93cc7bca9b397951507891a955239"
VERSION="1.5.20"
EXPECTED_SHA256="0ad25efe15d1fa56105157a454d647223e78eb0c53d1f85e3d10afcd722e7bfd"
node .github/scripts/retry.mjs --attempts 3 --delay 10 -- \
curl -fsSL -o install-safe-chain.sh "https://github.com/AikidoSec/safe-chain/releases/download/${VERSION}/install-safe-chain.sh"
echo "${EXPECTED_SHA256} install-safe-chain.sh" | sha256sum -c -
@ -164,8 +257,14 @@ runs:
# 4. Everything is uploaded as an artifact by the next step, so the full log
# survives even when the live console stream is truncated by the runner.
#
# `SAFE_CHAIN_LOGGING=verbose` surfaces safe-chain's proxy decisions, which
# are otherwise buffered (and lost on failure) while pnpm is in flight.
# SafeChain's proxy decisions go to a file rather than the console. They are
# emitted via `writeVerbose`, which always writes to `SAFE_CHAIN_LOG_FILE` but
# only reaches the console at `SAFE_CHAIN_LOGGING=verbose` — and there is one
# line per HTTP request, so verbose put ~9.5k `Finished proxying request to`
# lines in every install (60-76% of the whole job log). Omitting the variable
# leaves the console at safe-chain's `normal` default; the file keeps the full
# detail (its own default verbosity is `verbose`) and lands in the diagnostics
# dir that the next step uploads on install failure.
- name: Install Dependencies
if: ${{ inputs.install-command != '' }}
id: install-deps
@ -173,7 +272,8 @@ runs:
INSTALL_COMMAND: ${{ inputs.install-command }}
INSTALL_LOG: ${{ runner.temp }}/pnpm-install.log
PNPM_DIAG_DIR: ${{ runner.temp }}/pnpm-diagnostics
SAFE_CHAIN_LOGGING: verbose
SAFE_CHAIN_LOG_FILE: ${{ runner.temp }}/pnpm-diagnostics/safe-chain.log
SAFE_CHAIN_LOG_FILE_FORMAT: plain
GITHUB_TOKEN: ${{ github.token }}
run: |
# Stable, artifact-name-safe id for the upload step (written first so it
@ -183,7 +283,8 @@ 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 \
--trust-lockfile \
--config.logs-dir="$PNPM_DIAG_DIR/pnpm-logs" \
--reporter=append-only --config.loglevel=info 2>&1 | tee "$INSTALL_LOG"
rc=${PIPESTATUS[0]}
@ -217,7 +318,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
@ -260,13 +361,15 @@ runs:
# build job stays green; Linux/Blacksmith jobs keep remote caching.
- name: Configure Turborepo Cache
if: steps.turbo-server.outputs.running != 'true' && runner.os != 'Windows'
uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2
uses: rharkor/caching-for-turbo@2238fae6eb9a9936f92356f54cb3660200d105e7 # v2.5.1
with:
server-port: 0
- name: Setup Docker Builder for Docker Cache (Blacksmith)
if: ${{ inputs.enable-docker-cache == 'true' && contains(runner.name, 'blacksmith') }}
uses: useblacksmith/setup-docker-builder@ef12d5b165b596e3aa44ea8198d8fde563eab402 # v1.4.0
uses: useblacksmith/setup-docker-builder@a5256a73e30f09e37e3eceb8ca36043d17621d24 # v2.1.0
with:
cache-key: ${{ inputs.docker-cache-key }}
- name: Setup Docker Builder (GitHub fallback)
if: ${{ inputs.enable-docker-cache == 'true' && !contains(runner.name, 'blacksmith') }}

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

@ -1,22 +1,23 @@
#!/usr/bin/env node
/**
* Per-image SBOM attestation for the release Docker images. For each built image:
* cdxgen scans it (OS + npm), enrich-sbom resolves licenses, check-sbom-licenses
* syft scans it (OS + npm), enrich-sbom resolves licenses, check-sbom-licenses
* gates the npm components, and the result is attested to the image digest via
* cosign — the same mechanism as the VEX/provenance attestations.
*
* Image refs + digests come from the environment (set by docker-build-push.yml).
* An image with no digest (not built for this release type) is skipped.
*
* Usage: node .github/scripts/attest-image-sbom.mjs (run from the repo root)
* Usage: node .github/scripts/attest-image-sbom.mjs [--validate-only]
* (run from the repo root)
*/
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(scriptDir, '..', '..');
const CDXGEN = path.join(scriptDir, 'node_modules', '.bin', 'cdxgen');
const ENRICH = path.join(REPO_ROOT, 'scripts', 'licenses', 'enrich-sbom.mjs');
const CHECK = path.join(REPO_ROOT, 'scripts', 'licenses', 'check-sbom-licenses.mjs');
const ALLOW_REFS = [
@ -27,6 +28,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);
@ -39,44 +41,118 @@ function run(cmd, args, extraEnv) {
});
}
function attest({ label, image, digest }) {
/**
* The gate only inspects components it can see, so it passes on an SBOM that
* catalogued nothing. Check the shape before signing a near-empty SBOM.
*/
export function assertSbomIsUsable(sbomPath, label) {
const components = JSON.parse(readFileSync(sbomPath, 'utf-8')).components ?? [];
const npm = components.filter((c) => c.purl?.startsWith('pkg:npm/')).length;
if (npm === 0) {
throw new Error(`${label}: SBOM has no npm components. The scanner catalogued nothing.`);
}
// Warn rather than block. Downstream scanners want this to pick a distro
// vulnerability feed, but it is not a property these bases are known to
// hold: the runtime base runs `apk del apk-tools` and the distroless runners
// image carries no package manager at all. Blocking on an unverified
// assumption would fail every release rather than catch a bad scan.
if (!components.some((c) => c.type === 'operating-system')) {
console.log(
`::warning::${label}: SBOM has no operating-system component, so distro CVE feeds cannot be selected for it.`,
);
}
}
export function processTarget(
{ label, image, digest },
{ shouldAttest = true, runCommand = run, assertUsable = assertSbomIsUsable } = {},
) {
const ref = `${image}@${digest}`;
const out = path.join(REPO_ROOT, `sbom-${label}.cdx.json`);
console.log(`::group::SBOM for ${label} (${ref})`);
// Pull the (host-arch) image and scan its filesystem: OS packages + npm.
run('docker', ['pull', ref]);
// FETCH_LICENSE=true would make cdxgen call the npm registry for every package
// to resolve missing license data. In practice it resolves nothing — packages
// without a license field in their tarball also have no license in the registry —
// and adds hundreds of sequential HTTP requests. License gaps are covered by
// enrich-sbom.mjs (license-overrides.json + first-party detection) below.
run(
CDXGEN,
['-t', 'docker', '--no-install-deps', '--profile', 'license-compliance', '--spec-version', '1.6', '-o', out, ref],
{ CDXGEN_NO_BANNER: '1' },
);
// finally, so a throw still closes the group — otherwise the error that
// names the failing image renders inside a collapsed section.
try {
// Pull the (host-arch) image and scan its filesystem: OS packages + npm.
runCommand('docker', ['pull', ref]);
// `docker:` pins the scan to the image just pulled. A bare ref lets syft's
// own provider order decide, and it may resolve the multi-arch index from
// the registry instead — describing a different manifest than the one
// cosign then attests to.
// syft reads licenses from the LICENSE files on disk, so this scan makes no
// registry requests. `-file` excludes its per-file catalogue, ~4000 entries.
runCommand('syft', [
`docker:${ref}`,
'-o',
`cyclonedx-json@1.6=${out}`,
'--select-catalogers',
'-file',
'-q',
]);
// Resolve first-party + override licenses (lenient: this image holds only a
// subset of the npm closure, so absent overrides are not stale pins) and drop
// cdxgen filesystem-scan phantoms.
run(process.execPath, [ENRICH, out, '--lenient-config', '--drop-phantom-npm']);
// Resolve first-party + override licenses (lenient: this image holds only a
// subset of the npm closure, so absent overrides are not stale pins) and drop
// scanner filesystem phantoms.
runCommand(process.execPath, [ENRICH, out, '--lenient-config', '--drop-phantom-npm']);
// Release-blocking gate, scoped to npm — OS packages carry upstream-distro
// license strings we don't control, so they're inventoried but not gated.
run(process.execPath, [CHECK, out, ...ALLOW_REFS, '--enforce-prefix=pkg:npm/']);
// Release-blocking gate, scoped to npm — OS packages carry upstream-distro
// license strings we don't control, so they're inventoried but not gated.
runCommand(process.execPath, [CHECK, out, ...ALLOW_REFS, '--enforce-prefix=pkg:npm/']);
assertUsable(out, label);
run('cosign', ['attest', '--yes', '--type', 'cyclonedx', '--predicate', out, ref]);
console.log('::endgroup::');
// --replace, so re-running after a mid-loop failure does not leave the
// digest carrying two CycloneDX attestations.
if (shouldAttest) {
runCommand('cosign', [
'attest',
'--yes',
'--replace',
'--type',
'cyclonedx',
'--predicate',
out,
ref,
]);
}
} finally {
console.log('::endgroup::');
}
}
function main() {
const targets = parseTargets(process.env);
export function processTargets(targets, options = {}) {
const action = options.shouldAttest === false ? 'validation' : 'attestation';
const process = options.processTarget ?? processTarget;
// Attempt every image, then report. Aborting on the first failure leaves the
// later images silently unvalidated and hides whether they would have passed.
const failed = [];
for (const target of targets) {
try {
process(target, options);
} catch (err) {
failed.push(`${target.label}: ${err.message}`);
console.log(`::error title=SBOM ${action}::${target.label}: ${err.message}`);
}
}
if (failed.length > 0) {
throw new Error(
`${failed.length} of ${targets.length} image(s) failed:\n ${failed.join('\n ')}`,
);
}
}
export function main({
env = process.env,
args = process.argv.slice(2),
processAll = processTargets,
} = {}) {
const shouldAttest = !args.includes('--validate-only');
const targets = parseTargets(env);
if (targets.length === 0) {
console.log('No images with digests to attest — skipping.');
console.log(`No images with digests to ${shouldAttest ? 'attest' : 'validate'} - skipping.`);
return;
}
for (const target of targets) attest(target);
processAll(targets, { shouldAttest });
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {

View File

@ -1,6 +1,15 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { parseTargets } from './attest-image-sbom.mjs';
import { mkdtempSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
assertSbomIsUsable,
main,
parseTargets,
processTarget,
processTargets,
} from './attest-image-sbom.mjs';
describe('parseTargets', () => {
it('builds a target per image when both ref and digest are present', () => {
@ -35,3 +44,133 @@ describe('parseTargets', () => {
assert.deepEqual(parseTargets({}), []);
});
});
describe('assertSbomIsUsable', () => {
const tmp = mkdtempSync(path.join(os.tmpdir(), 'sbom-assert-'));
const write = (name, components) => {
const p = path.join(tmp, name);
writeFileSync(p, JSON.stringify({ components }));
return p;
};
const OS = { type: 'operating-system', name: 'alpine', version: '3.24' };
it('accepts an SBOM with npm components and an operating system', () => {
assert.doesNotThrow(() =>
assertSbomIsUsable(write('ok.json', [{ purl: 'pkg:npm/a@1' }, OS]), 'n8n'),
);
});
it('rejects an SBOM the scanner failed to populate', () => {
const p = write('empty.json', [{ purl: 'pkg:apk/alpine/busybox@1.0' }, OS]);
assert.throws(() => assertSbomIsUsable(p, 'n8n'), /no npm components/);
});
// Warns rather than throws: the distroless runners image carries no package
// manager and the runtime base strips apk-tools, so an absent OS component
// is not known to be a fault. Blocking on it would fail every release.
it('warns but accepts an SBOM with no operating-system component', () => {
const logged = [];
const original = console.log;
console.log = (msg) => logged.push(String(msg));
try {
assert.doesNotThrow(() =>
assertSbomIsUsable(write('no-os.json', [{ purl: 'pkg:npm/a@1' }]), 'runners'),
);
} finally {
console.log = original;
}
assert.ok(logged.some((l) => /^::warning::runners: .*no operating-system component/.test(l)));
});
it('names the image in the failure so a four-image run says which one broke', () => {
assert.throws(() => assertSbomIsUsable(write('named.json', [OS]), 'runners-distroless'), {
message: /^runners-distroless:/,
});
});
});
describe('processTarget', () => {
const target = {
label: 'n8n-pc',
image: 'ghcr.io/n8n-io/n8n',
digest: 'sha256:aaa',
};
it('runs the complete validation chain without attesting in validation-only mode', () => {
const calls = [];
let asserted = false;
processTarget(target, {
shouldAttest: false,
runCommand: (command, args) => calls.push([command, args]),
assertUsable: (_sbomPath, label) => {
assert.equal(label, 'n8n-pc');
asserted = true;
},
});
assert.deepEqual(
calls.map(([command]) => path.basename(command)),
['docker', 'syft', 'node', 'node'],
);
assert.equal(calls[0][1][0], 'pull');
assert.equal(calls[1][1][0], 'docker:ghcr.io/n8n-io/n8n@sha256:aaa');
assert.ok(calls[2][1].includes('--drop-phantom-npm'));
assert.ok(calls[3][1].includes('--enforce-prefix=pkg:npm/'));
assert.equal(asserted, true);
});
it('attests after validation by default', () => {
const calls = [];
processTarget(target, {
runCommand: (command, args) => calls.push([command, args]),
assertUsable: () => {},
});
assert.equal(calls.at(-1)[0], 'cosign');
assert.deepEqual(calls.at(-1)[1].slice(0, 6), [
'attest',
'--yes',
'--replace',
'--type',
'cyclonedx',
'--predicate',
]);
});
});
describe('processTargets', () => {
it('validates every image before reporting aggregated failures', () => {
const attempted = [];
assert.throws(
() =>
processTargets([{ label: 'n8n' }, { label: 'n8n-pc' }, { label: 'runners' }], {
shouldAttest: false,
processTarget: (target) => {
attempted.push(target.label);
if (target.label !== 'n8n-pc') throw new Error('missing license');
},
}),
/2 of 3 image\(s\) failed/,
);
assert.deepEqual(attempted, ['n8n', 'n8n-pc', 'runners']);
});
});
describe('main', () => {
it('passes validation-only mode to image processing', () => {
let received;
main({
env: {
N8N_IMAGE: 'ghcr.io/n8n-io/n8n',
N8N_DIGEST: 'sha256:aaa',
},
args: ['--validate-only'],
processAll: (targets, options) => {
received = { targets, options };
},
});
assert.equal(received.targets[0].label, 'n8n');
assert.equal(received.options.shouldAttest, false);
});
});

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);
@ -22,11 +23,17 @@ export function generateExperimentalVersion(currentVersion, sha) {
}
/**
* Overrides live in `pnpm-workspace.yaml` since pnpm 11; older tags still carry them in
* package.json. Both are merged so a comparison that straddles the move sees the same set
* on either side, rather than reading every override as added and removed.
*
* @param {{ pnpm?: { overrides?: Record<string, string> }, overrides?: Record<string, string> }} pkg
* @param {Record<string, unknown>} [workspace] parsed pnpm-workspace.yaml
* @returns {Record<string, string>}
*/
export function getOverrides(pkg) {
return { ...pkg.pnpm?.overrides, ...pkg.overrides };
export function getOverrides(pkg, workspace) {
const fromWorkspace = /** @type {Record<string, string> | undefined} */ (workspace?.overrides);
return { ...pkg.pnpm?.overrides, ...pkg.overrides, ...fromWorkspace };
}
/**
@ -184,13 +191,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 = {};
@ -218,19 +219,14 @@ async function bumpVersions() {
// that package also needs a bump (e.g. design-system → editor-ui → cli).
// Detect root-level changes that affect resolved dep versions without touching individual
// package.json files: pnpm.overrides (applies to all specifiers)
// and pnpm-workspace.yaml catalog entries (applies only to deps using a "catalog:…" specifier).
// package.json files: overrides (apply to all specifiers) and catalog entries
// (apply only to deps using a "catalog:…" specifier).
const rootPkgJson = JSON.parse(await readFile(resolve(rootDir, 'package.json'), 'utf-8'));
const rootPkgJsonAtTag = await exec(`git show ${lastTag}:package.json`)
.then(({ stdout }) => JSON.parse(stdout))
.catch(() => ({}));
const changedOverrides = computeChangedOverrides(
getOverrides(rootPkgJson),
getOverrides(rootPkgJsonAtTag),
);
const workspaceYaml = parseWorkspaceYaml(
await readFile(resolve(rootDir, 'pnpm-workspace.yaml'), 'utf-8').catch(() => ''),
);
@ -239,6 +235,12 @@ async function bumpVersions() {
.then(({ stdout }) => stdout)
.catch(() => ''),
);
const changedOverrides = computeChangedOverrides(
getOverrides(rootPkgJson, workspaceYaml),
getOverrides(rootPkgJsonAtTag, workspaceYamlAtTag),
);
const changedCatalogEntries = computeChangedCatalogEntries(
getCatalogs(workspaceYaml),
getCatalogs(workspaceYamlAtTag),
@ -290,5 +292,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

@ -72,6 +72,26 @@ describe('getOverrides', () => {
{ lodash: '^4.0.0', underscore: '^1.0.0' },
);
});
it('returns workspace overrides when package.json has none', () => {
assert.deepEqual(getOverrides({}, { overrides: { lodash: '^4.0.0' } }), { lodash: '^4.0.0' });
});
it('lets workspace overrides win over package.json for the same key', () => {
assert.deepEqual(
getOverrides(
{ pnpm: { overrides: { lodash: '^3.0.0', underscore: '^1.0.0' } } },
{ overrides: { lodash: '^4.0.0' } },
),
{ lodash: '^4.0.0', underscore: '^1.0.0' },
);
});
it('sees no change when an override only moves from package.json to the workspace file', () => {
const atTag = getOverrides({ pnpm: { overrides: { lodash: '^4.0.0' } } }, {});
const current = getOverrides({}, { overrides: { lodash: '^4.0.0' } });
assert.deepEqual(computeChangedOverrides(current, atTag), new Set());
});
});
describe('parseWorkspaceYaml', () => {

View File

@ -1,114 +0,0 @@
// Invoked from .github/workflows/ci-cla-check.yml via actions/github-script.
//
// Collects unique commit authors for the PR (or for the commits a merge
// queue is about to land) and asks the n8n CLA service whether each one
// has signed. Surfaces three buckets to subsequent steps:
// - signed : verified contributors
// - unsigned : verified non-contributors (block the merge)
// - errored : CLA lookup failed (block the merge — fail-closed so we
// never green-light an unverified contribution)
//
// Commits whose author email is not linked to a GitHub account can't be
// looked up by login; they're surfaced separately as `unlinked`.
/**
* @typedef { InstanceType<typeof import("@actions/github/lib/utils").GitHub> } GitHubInstance
* @typedef { import("@actions/github/lib/context").Context } Context
* @typedef { typeof import("@actions/core") } Core
*/
/**
* @param {{ github: GitHubInstance, context: Context, core: Core }} params
*/
export default async function checkSignatures ({ github, context, core }) {
const { owner, repo } = context.repo;
const prNumber = process.env.PR_NUMBER;
const headSha = process.env.HEAD_SHA;
const baseSha = process.env.BASE_SHA;
const isMergeGroup = process.env.IS_MERGE_GROUP === 'true';
/** @type {Set<string>} */
const authors = new Set();
/** @type {Array<{sha: string, name: string, email: string}>} */
const unlinkedCommits = [];
/**
* @param {Array<any>} commits
*/
const collect = (commits) => {
for (const c of commits) {
// Bot-authored commits don't need a CLA; skip before the linked/unlinked split
// so they don't fall through to `unlinkedCommits` and fail `all_signed`.
if (c.author && c.author.type === 'Bot') continue;
if (c.author && c.author.login) {
authors.add(c.author.login);
} else if (c.commit && c.commit.author) {
unlinkedCommits.push({
sha: c.sha,
name: c.commit.author.name,
email: c.commit.author.email,
});
}
}
};
if (isMergeGroup) {
const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${baseSha}...${headSha}`,
});
collect(comparison.commits || []);
} else if (prNumber) {
const commits = await github.paginate(github.rest.pulls.listCommits, {
owner,
repo,
pull_number: Number(prNumber),
per_page: 100,
});
collect(commits);
}
const loginList = [...authors];
core.info(`Contributors to check: ${loginList.join(', ') || '(none)'}`);
if (unlinkedCommits.length > 0) {
core.warning(
`${unlinkedCommits.length} commit(s) have an author email not linked to a GitHub account ` +
'and cannot be verified against the CLA service.',
);
}
/** @type {string[]} */
const signed = [];
/** @type {string[]} */
const unsigned = [];
/** @type {string[]} */
const errored = [];
for (const login of loginList) {
const url = `${process.env.CLA_API}?checkContributor=${encodeURIComponent(login)}`;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data && data.isContributor === true) {
signed.push(login);
} else {
unsigned.push(login);
}
} catch (e) {
core.warning(`CLA lookup failed for @${login}: ${e instanceof Error ? e.message : String(e)}`);
errored.push(login);
}
}
const blocking = [...unsigned, ...errored];
const allSigned = blocking.length === 0 && unlinkedCommits.length === 0;
core.setOutput('signed', signed.join(','));
core.setOutput('unsigned', unsigned.join(','));
core.setOutput('errored', errored.join(','));
core.setOutput('unlinked', JSON.stringify(unlinkedCommits));
core.setOutput('all_signed', String(allSigned));
}

View File

@ -1,83 +0,0 @@
// Invoked from .github/workflows/ci-cla-check.yml via actions/github-script.
//
// Adds the `cla-signed` label when every contributor has signed, and
// removes it otherwise. Idempotent: re-runs safely without duplicating
// the label or erroring if it's already in the desired state. Creates
// the label on first use so the workflow is self-contained.
/**
* @typedef { InstanceType<typeof import("@actions/github/lib/utils").GitHub> } GitHubInstance
* @typedef { import("@actions/github/lib/context").Context } Context
* @typedef { typeof import("@actions/core") } Core
*/
const LABEL_NAME = 'cla-signed';
const LABEL_COLOR = '0e8a16'; // GitHub's standard green
const LABEL_DESCRIPTION = 'All contributors on this PR have signed the CLA';
/**
* @param {{ github: GitHubInstance, context: Context, core: Core }} params
*/
export default async function manageClaLabel({ github, context, core }) {
const { owner, repo } = context.repo;
const issue_number = Number(process.env.PR_NUMBER);
const allSigned = process.env.ALL_SIGNED === 'true';
if (allSigned) {
// Make sure the label exists before trying to apply it — addLabels
// errors if the label is missing from the repo.
try {
await github.rest.issues.getLabel({ owner, repo, name: LABEL_NAME });
} catch (e) {
if (errorStatus(e) === 404) {
try {
await github.rest.issues.createLabel({
owner,
repo,
name: LABEL_NAME,
color: LABEL_COLOR,
description: LABEL_DESCRIPTION,
});
} catch (createErr) {
// 422 = race with a parallel run that just created it. Fine.
if (errorStatus(createErr) !== 422) throw createErr;
}
} else {
throw e;
}
}
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [LABEL_NAME],
});
core.info(`Applied "${LABEL_NAME}" label to PR #${issue_number}`);
} else {
// 404 just means the label wasn't on the PR — nothing to undo.
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: LABEL_NAME,
});
core.info(`Removed "${LABEL_NAME}" label from PR #${issue_number}`);
} catch (e) {
if (errorStatus(e) !== 404) throw e;
}
}
}
/**
* Octokit's request errors carry an HTTP `status` field, but TypeScript
* sees catch parameters as `unknown`. This guard narrows safely.
* @param {unknown} e
* @returns {number | undefined}
*/
function errorStatus(e) {
return typeof e === 'object' && e !== null && 'status' in e && typeof e.status === 'number'
? e.status
: undefined;
}

View File

@ -1,66 +0,0 @@
// Invoked from .github/workflows/ci-cla-check.yml via actions/github-script.
//
// Translates the buckets emitted by check-signatures.mjs into a single
// commit status on the head SHA. The status `context` name is what a
// repository ruleset gates on; description and target_url are best-effort
// human signals.
//
// State mapping:
// - success: every contributor is signed and every commit author is linked
// - error : only failures were API lookup errors (transient)
// - failure: at least one contributor is verified unsigned, or commits
// have author emails not linked to a GitHub account
/**
* @typedef { InstanceType<typeof import("@actions/github/lib/utils").GitHub> } GitHubInstance
* @typedef { import("@actions/github/lib/context").Context } Context
* @typedef { typeof import("@actions/core") } Core
*/
/**
* @param {{ github: GitHubInstance, context: Context, core: Core }} params
*/
export default async function postFinalClaStatus({ github, context }) {
const allSigned = process.env.ALL_SIGNED === 'true';
const unsigned = (process.env.UNSIGNED ?? '').split(',').filter(Boolean);
const errored = (process.env.ERRORED ?? '').split(',').filter(Boolean);
const unlinked = JSON.parse(process.env.UNLINKED || '[]');
/** @type {'success' | 'failure' | 'error' | 'pending'} */
let state;
let description;
if (allSigned) {
state = 'success';
description = 'All contributors have signed the CLA';
} else if (errored.length > 0 && unsigned.length === 0 && unlinked.length === 0) {
state = 'error';
description = `Could not verify: ${errored.join(', ')}`;
} else {
state = 'failure';
const parts = [];
if (unsigned.length > 0) parts.push(`unsigned: ${unsigned.join(', ')}`);
if (errored.length > 0) parts.push(`errored: ${errored.join(', ')}`);
if (unlinked.length > 0) parts.push(`${unlinked.length} unlinked commit(s)`);
description = parts.join(' | ');
}
// GitHub commit status description is capped at 140 chars.
if (description.length > 140) {
description = description.slice(0, 137) + '…';
}
const prNumber = process.env.PR_NUMBER;
const target_url = prNumber
? `${context.payload.repository?.html_url}/pull/${prNumber}`
: process.env.CLA_SIGN_URL;
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: /** @type {string} */ (process.env.HEAD_SHA),
state,
context: /** @type {string} */ (process.env.STATUS_CONTEXT),
description,
target_url,
});
}

View File

@ -1,76 +0,0 @@
// Invoked from .github/workflows/ci-cla-check.yml via actions/github-script.
//
// Reads the triggering event (pull_request_target, issue_comment, or
// merge_group) and emits the head/base SHA and PR number that the rest of
// the workflow needs. For /cla-check comments, also leaves an "eyes"
// reaction so the commenter sees we picked it up.
/**
* @typedef { InstanceType<typeof import("@actions/github/lib/utils").GitHub> } GitHubInstance
* @typedef { import("@actions/github/lib/context").Context } Context
* @typedef { typeof import("@actions/core") } Core
*/
/**
* @param {{ github: GitHubInstance, context: Context, core: Core }} params
*/
export default async function resolveClaContext({ github, context, core }) {
const { owner, repo } = context.repo;
const event = context.eventName;
let prNumber = '';
let headSha = '';
let baseSha = '';
let isMergeGroup = false;
if (event === 'pull_request_target' && context.payload.pull_request) {
const pr = context.payload.pull_request;
prNumber = String(pr.number);
headSha = pr.head.sha;
baseSha = pr.base.sha;
} else if (event === 'issue_comment' && context.payload.issue) {
prNumber = String(context.payload.issue.number);
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: Number(prNumber),
});
headSha = pr.head.sha;
baseSha = pr.base.sha;
// Acknowledge the command so the commenter sees we received it.
try {
await github.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: context.payload.comment?.id || -1,
content: 'eyes',
});
} catch (e) {
core.info(`Could not react to comment: ${e instanceof Error ? e.message : String(e)}`);
}
} else if (event === 'merge_group') {
isMergeGroup = true;
headSha = context.payload.merge_group.head_sha;
baseSha = context.payload.merge_group.base_sha;
} else if (event === 'workflow_dispatch') {
const input = context.payload.inputs?.pr_number;
if (!input) {
core.setFailed('workflow_dispatch requires the pr_number input');
return;
}
prNumber = String(input);
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: Number(prNumber),
});
headSha = pr.head.sha;
baseSha = pr.base.sha;
}
core.setOutput('pr_number', prNumber);
core.setOutput('head_sha', headSha);
core.setOutput('base_sha', baseSha);
core.setOutput('is_merge_group', String(isMergeGroup));
}

View File

@ -1,104 +0,0 @@
// Invoked from .github/workflows/ci-cla-check.yml via actions/github-script.
//
// Maintains a single CLA comment per PR, keyed by an HTML marker so the
// same comment is edited in place across re-runs instead of spammed.
// A clean PR that has never been flagged gets no comment at all — only
// PRs that needed a nudge get the eventual "thanks" follow-up.
/**
* @typedef { InstanceType<typeof import("@actions/github/lib/utils").GitHub> } GitHubInstance
* @typedef { import("@actions/github/lib/context").Context } Context
* @typedef { typeof import("@actions/core") } Core
*/
/**
* @param {{ github: GitHubInstance, context: Context, core: Core }} params
*/
export default async function updatePRComment({ github, context }) {
const { owner, repo } = context.repo;
const issue_number = Number(process.env.PR_NUMBER);
const allSigned = process.env.ALL_SIGNED === 'true';
const unsigned = (process.env.UNSIGNED ?? '').split(',').filter(Boolean);
const errored = (process.env.ERRORED ?? '').split(',').filter(Boolean);
const unlinked = JSON.parse(process.env.UNLINKED || '[]');
const MARKER = /** @type {string} */ (process.env.COMMENT_MARKER);
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
// Only adopt the comment as ours if it's bot-authored — otherwise a user
// who copies our marker into their own comment would either hijack the
// thread or make updateComment 403 with insufficient permissions.
const existing = comments.find(
(c) => c.body && c.body.includes(MARKER) && c.user && c.user.type === 'Bot',
);
let body;
if (allSigned) {
// Only leave a "thanks" trail if we already nudged once. Avoids
// pinging every clean PR with a CLA comment.
if (!existing) {
return;
}
body = [
MARKER,
'✅ **CLA Check passed.** All contributors on this PR have signed the n8n CLA — thank you!',
].join('\n');
} else {
const lines = [MARKER, '## CLA signatures required', ''];
lines.push(`Thank you for your submission! We really appreciate it.
Like many open source projects, we ask that you sign our [Contributor License Agreement](${process.env.CLA_SIGN_URL}) before we can accept your contribution.`);
lines.push('');
if (unsigned.length > 0) {
lines.push('**Contributors who still need to sign:**');
for (const u of unsigned) {
lines.push(`- @${u}`);
}
lines.push('');
}
if (errored.length > 0) {
lines.push('**Could not verify (will retry on next push):**');
for (const u of errored) {
lines.push(`- @${u}`);
}
lines.push('');
}
if (unlinked.length > 0) {
lines.push('**Commits authored by an email not linked to a GitHub account:**');
for (const c of unlinked) {
lines.push(`- \`${c.sha.slice(0, 7)}\` — ${c.name} <${c.email}>`);
}
lines.push('');
lines.push(
'Add the email to your GitHub account ' +
'([instructions](https://docs.github.com/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/adding-an-email-address-to-your-github-account)) ' +
'or amend the commits to use a linked email, then push again.',
);
lines.push('');
}
lines.push('Once signed, comment `/cla-check` on this PR to re-run verification.');
body = lines.join('\n');
}
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
}

545
.github/scripts/codespace-preview.mjs vendored Normal file
View File

@ -0,0 +1,545 @@
#!/usr/bin/env node
// Drives a PR preview instance from CI. `scripts/preview.mjs` does the work; this
// script maps the pull_request event onto one of its operations and reports the
// result back to the PR as a single, edited-in-place comment.
//
// labeled -> up create or start the box, then serve the PR head
// synchronize -> refresh re-serve the new head in the box that already exists
// unlabeled -> down delete the box
// closed -> down
//
// A manual run sets PREVIEW_OPERATION instead, which wins over the event mapping.
//
// `refresh` never creates a box. A box that GitHub already deleted (24 h
// retention) is reported as expired, not as a failure.
//
// `up` and `refresh` take minutes, so the comment goes up before the work starts
// and is edited for each phase and once a minute after that. `preview.mjs --json`
// prints one JSON line per phase on the channel this already parses; the report is
// the line that carries a `url`.
//
// With `--report-cancelled` it posts one body and nothing else. The workflow runs
// that on a cancelled or timed-out job, which kills this process mid-checklist.
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { PREVIEW_LABEL_PREFIX } from '../../scripts/preview-labels.mjs';
import { PREVIEW_PHASES, createLineSplitter, phaseIndex } from '../../scripts/preview-phases.mjs';
import {
ensureEnvVar,
findCommentByMarker,
postOrUpdateComment,
updateCommentById,
} from './github-helpers.mjs';
export const BOT_MARKER = '<!-- codespace-preview -->';
// Only a progress body carries this. The cancelled-run step checks for it before it
// writes, so a run cancelled while it was still queued cannot replace a good ready
// comment left by the run before it.
export const PROGRESS_MARKER = '<!-- codespace-preview:progress -->';
export const PREVIEW_LABEL = 'codespace-preview';
// Copied from `preview.mjs`, which owns them, so they move together. They cannot be
// imported: that module runs its command switch on import. They appear in the comment
// so a reviewer knows how long the instance lasts without reading the script.
const IDLE_TIMEOUT = '2 hours';
const RETENTION_PERIOD = '24 hours';
const CODESPACE_ENV_VARIABLE_URL = "https://internal.users.n8n.cloud/form/codespace-environments";
// A slept box comes back with a private port, so every recovery hint points here.
export const WORKFLOW_URL =
'https://github.com/n8n-io/n8n/actions/workflows/util-codespace-preview.yml';
// What a manual run may ask for. `ls` is absent: it needs no PR and posts no comment.
export const DISPATCH_OPERATIONS = ['up', 'refresh', 'down'];
// Resolved against this file, so the script runs the same from any directory.
const PREVIEW_SCRIPT = fileURLToPath(new URL('../../scripts/preview.mjs', import.meta.url));
// Often enough to look alive, rarely enough that the edit history stays readable.
const HEARTBEAT_MS = 60_000;
/**
* A `preview:*` label configures an instance that already exists, so toggling one
* re-serves the box rather than creating or deleting it.
*
* @param {string} action The pull_request event action.
* @param {string} [label] `github.event.label.name`, for a labeled/unlabeled event.
* @returns {'up' | 'refresh' | 'down' | undefined}
*/
export function operationFor(action, label) {
switch (action) {
case 'labeled':
case 'unlabeled':
if (label?.startsWith(PREVIEW_LABEL_PREFIX)) return 'refresh';
if (label !== PREVIEW_LABEL) return undefined;
return action === 'labeled' ? 'up' : 'down';
case 'synchronize':
return 'refresh';
case 'closed':
return 'down';
default:
return undefined;
}
}
/**
* A manual run says what to do, so its operation wins over the event mapping. An
* operation that is not one of ours stops here: `preview.mjs` would only print
* usage and exit 1, which reads as a broken preview rather than a bad input.
*
* @param {{action?: string, label?: string, operation?: string}} context
* @returns {'up' | 'refresh' | 'down' | undefined}
*/
export function resolveOperation({ action, label, operation }) {
if (operation) return DISPATCH_OPERATIONS.includes(operation) ? operation : undefined;
return operationFor(action ?? '', label);
}
/**
* `preview.mjs --json` keeps stdout clean, but a truncated or empty run still has
* to be told apart from a good one. Read the last JSON object that carries a url.
*
* @param {string} stdout
* @returns {{pr: number, sha: string, codespace: string, url: string, orgVisible: boolean} | undefined}
*/
export function parsePreviewJson(stdout) {
for (const line of stdout.split('\n').reverse()) {
const trimmed = line.trim();
if (!trimmed.startsWith('{')) continue;
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed.url === 'string') return parsed;
} catch {}
}
return undefined;
}
/**
* `preview.mjs ls` prints one tab-separated row for each box:
* `preview/pr-<pr>\t<state>\t<name>\tlast used <iso>`. Compare the whole first
* field, or PR 3 would match the box for PR 37.
*
* @param {string} stdout
* @param {string | number} pr
*/
export function hasPreviewBox(stdout, pr) {
return stdout.split('\n').some((line) => line.split('\t')[0]?.trim() === `preview/pr-${pr}`);
}
/**
* A forwarded Codespaces URL is `https://<codespace>-<port>.app.github.dev`. The
* trailing dot anchors the match, so a codespace name that ends in digits is safe.
*
* @param {string} url
*/
export function portFromUrl(url) {
return new URL(url).hostname.match(/-(\d+)\./)?.[1];
}
/** @param {{url: string, codespace: string, sha: string, orgVisible: boolean, pr: string | number}} preview */
export function readyComment({ url, codespace, sha, orgVisible, pr }) {
// A failed port share is not fatal in preview.mjs, so the box can be up while
// the URL still answers 302 to everyone. Say so instead of implying it works.
const port = portFromUrl(url);
const access = orgVisible
? 'Every n8n org member who is signed in to GitHub can open it.'
: [
`**Port ${port} is still private.** The instance runs, but only its owner can`,
'open the URL. To share it, run:',
'',
'```',
`gh codespace ports visibility ${port}:org -c ${codespace}`,
'```',
].join('\n');
return [
BOT_MARKER,
`### Preview instance for \`${sha.slice(0, 7)}\``,
'',
`**[Open the preview](${url}/preview-signin)** — one click signs you in.`,
'',
`If you need to modify the environment variables of this instance, navigate to ${CODESPACE_ENV_VARIABLE_URL}?pr=${pr} and submit them.`,
'The instance will refresh and apply your variables.',
'',
'| | |',
'| --- | --- |',
`| URL | ${url} |`,
`| Codespace | \`preview/pr-${pr}\` |`,
`| Sign in | \`preview@n8n.io\` / \`PreviewInstance1\` |`,
'',
access,
'',
`The instance sleeps after ${IDLE_TIMEOUT} of no use and is deleted after ${RETENTION_PERIOD}.`,
`A box that slept comes back private, so wake it with [the preview workflow](${WORKFLOW_URL})`,
'(`Run workflow` → this PR number → `up`), or by pushing a commit.',
`Remove the \`${PREVIEW_LABEL}\` label to delete it now.`,
].join('\n');
}
/** @param {{pr: string | number}} context */
export function downComment({ pr }) {
return [
BOT_MARKER,
`### Preview instance deleted`,
'',
`The preview box for PR #${pr} is gone. Add the \`${PREVIEW_LABEL}\` label to get a new one.`,
].join('\n');
}
/** @param {{pr: string | number}} context */
export function expiredComment({ pr }) {
return [
BOT_MARKER,
`### Preview instance expired`,
'',
`The preview box for PR #${pr} no longer exists — GitHub deletes one after ${RETENTION_PERIOD}.`,
`Run [the preview workflow](${WORKFLOW_URL}) with \`up\` to get a new one.`,
].join('\n');
}
/**
* `4m 12s`, for a comment that is read while it counts. Anything unusable reads as
* `0s`: a wrong duration in a status line is worse than a boring one.
*
* @param {number} ms
*/
export function formatElapsed(ms) {
const total = Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0;
const seconds = total % 60;
const minutes = Math.floor(total / 60) % 60;
const hours = Math.floor(total / 3600);
if (hours) return `${hours}h ${minutes}m`;
if (minutes) return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
return `${seconds}s`;
}
/**
* The instance URL out of a body a previous run left. A refresh replaces the ready
* comment with a checklist, so without this the PR loses the URL for several
* minutes. Reads the URL row `readyComment` writes.
*
* @param {string} body
*/
export function previewUrlFromBody(body) {
return body.match(/^\| URL \| (\S+) \|$/m)?.[1];
}
/**
* One line of a `preview.mjs --json` run that reports a phase rather than the final
* result. A report carries a `url`; a progress line never does.
*
* @param {string} line
* @returns {{phase: string, detail?: string, sha?: string, codespace?: string} | undefined}
*/
export function parseProgressLine(line) {
const trimmed = line.trim();
if (!trimmed.startsWith('{')) return undefined;
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed.phase === 'string' && parsed.url === undefined) return parsed;
} catch {}
return undefined;
}
/**
* The checklist, while the box is being set up. Pure: the caller passes the clock,
* so this renders the same body twice.
*
* @param {{
* pr: string | number,
* operation: 'up' | 'refresh',
* runUrl: string,
* sha?: string,
* phase?: string,
* detail?: string,
* previousUrl?: string,
* startedAt: number,
* phaseStartedAt: number,
* now: number,
* }} state
*/
export function progressComment({
pr,
operation,
runUrl,
sha,
phase,
detail,
previousUrl,
startedAt,
phaseStartedAt,
now,
}) {
// Everything before the current phase is done. Going by position rather than by
// the phases actually seen keeps the list right when one never reports — an old
// PR head has no in-box emitter.
const current = phaseIndex(phase);
const inPhaseMs = now - phaseStartedAt;
const steps = PREVIEW_PHASES.map((step, index) => {
if (index < current) return `- [x] ${step.label}`;
if (index !== current) return `- [ ] ${step.label}`;
return `- [ ] **${step.label}**${detail ? ` — ${detail}` : ''} · ${formatElapsed(inPhaseMs)}`;
});
const slow = PREVIEW_PHASES[current]?.slowAfterMs;
const target = sha ? `\`${sha.slice(0, 7)}\`` : `PR #${pr}`;
return [
BOT_MARKER,
PROGRESS_MARKER,
`### Preview instance ${operation === 'refresh' ? 'updating to' : 'starting for'} ${target}`,
'',
'Setting up the box. This usually takes a few minutes.',
'',
...steps,
...(slow && inPhaseMs > slow
? ['', `> ${PREVIEW_PHASES[current].label} is taking longer than usual.`]
: []),
// A refresh stops the old backend before it rebuilds, so the URL is down for
// the rest of this run. Keep it on the PR anyway: it is the same URL when the
// run finishes, and losing it for several minutes is worse than saying so.
...(previousUrl
? ['', `The URL does not change: ${previousUrl}. It stops answering until this finishes.`]
: []),
'',
`Elapsed ${formatElapsed(now - startedAt)} · updated ${new Date(now).toISOString().slice(11, 19)} UTC · [live log](${runUrl})`,
].join('\n');
}
/** @param {{pr: string | number, operation: string, runUrl: string}} context */
export function cancelledComment({ pr, operation, runUrl }) {
return [
BOT_MARKER,
`### Preview instance run stopped`,
'',
`The \`preview ${operation}\` run for PR #${pr} was cancelled or timed out, so the`,
'instance is in an unknown state. The box can still exist.',
'',
`Run [the preview workflow](${WORKFLOW_URL}) with \`up\` to finish the job, or \`down\` to delete the box.`,
`See [the workflow run](${runUrl}) for how far it got.`,
].join('\n');
}
/** @param {{operation: string, runUrl: string, message: string}} context */
export function failureComment({ operation, runUrl, message }) {
return [
BOT_MARKER,
`### Preview instance failed`,
'',
`\`preview ${operation}\` did not finish: ${message}`,
'',
`See [the workflow run](${runUrl}) for the full log.`,
`Run [the preview workflow](${WORKFLOW_URL}) with \`${operation}\` to try again.`,
].join('\n');
}
/**
* Human progress and the in-box build log go to stderr, so they stream into the job
* log live. Only stdout is captured, and its phase lines are handed over as they
* arrive — a run this long has to report itself while it runs, not at the end.
*
* @param {readonly string[]} args
* @param {{onProgress?: (progress: {phase: string, detail?: string, sha?: string}) => void}} [hooks]
* @returns {Promise<{status: number | null, stdout: string}>}
*/
function runPreview(args, { onProgress } = {}) {
return new Promise((resolve, reject) => {
const child = spawn('node', [PREVIEW_SCRIPT, ...args], {
stdio: ['ignore', 'pipe', 'inherit'],
});
const splitLines = createLineSplitter();
let stdout = '';
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
// Kept whole for `parsePreviewJson`, which reads the report off the end.
stdout += chunk;
if (!onProgress) return;
for (const line of splitLines(chunk)) {
const progress = parseProgressLine(line);
if (progress) onProgress(progress);
}
});
child.on('error', reject);
child.on('close', (status) => resolve({ status, stdout }));
});
}
/**
* Keeps the checklist on the PR current while `preview.mjs` runs: one edit for each
* phase, plus a heartbeat so a long phase still looks alive.
*
* @param {{pr: string, operation: 'up' | 'refresh', runUrl: string, previousUrl?: string, commentId?: number}} context
*/
function createProgressReporter({ pr, operation, runUrl, previousUrl, commentId }) {
const startedAt = Date.now();
let phaseStartedAt = startedAt;
let id = commentId;
let phase;
let detail;
let sha;
let writes = Promise.resolve();
// One chain, so two edits never overlap, and every failure is swallowed: this
// Octokit carries no retry plugin, and a status edit must not fail a preview that
// is otherwise fine. Resetting the chain keeps one failure from poisoning the next.
function push() {
writes = writes
.then(async () => {
const body = progressComment({
pr,
operation,
runUrl,
sha,
phase,
detail,
previousUrl,
startedAt,
phaseStartedAt,
now: Date.now(),
});
// Hold the id from the first write: paginating every comment on the PR
// once a minute would be the expensive part of a heartbeat.
if (id === undefined) id = await postOrUpdateComment(Number(pr), body, BOT_MARKER);
else await updateCommentById(id, body);
})
.catch((error) => {
console.log(`::warning::Could not update the preview comment: ${error.message}`);
});
return writes;
}
const timer = setInterval(push, HEARTBEAT_MS);
timer.unref();
return {
start: push,
/** @param {{phase: string, detail?: string, sha?: string}} progress */
onProgress(progress) {
if (progress.sha) sha = progress.sha;
if (progress.phase === phase && progress.detail === detail) return;
phase = progress.phase;
detail = progress.detail;
phaseStartedAt = Date.now();
push();
},
/** Drains the queue, so the final body of the run is always the last write. */
async stop() {
clearInterval(timer);
await writes;
},
};
}
/**
* A cancelled or timed-out job kills this process mid-checklist, so the workflow
* runs it again just to say so. Only over a progress body: a run cancelled while it
* was queued never started work, and must leave a ready comment alone.
*
* @param {string} pr
* @param {string} operation
* @param {string} runUrl
*/
async function reportCancelled(pr, operation, runUrl) {
const existing = await findCommentByMarker(Number(pr), BOT_MARKER);
if (!existing?.body.includes(PROGRESS_MARKER)) {
console.log('The preview comment is not a checklist — leaving it as it is.');
return;
}
await updateCommentById(existing.id, cancelledComment({ pr, operation, runUrl }));
}
async function main() {
const pr = ensureEnvVar('PULL_REQUEST_NUMBER');
const runUrl = ensureEnvVar('RUN_URL');
const requested = process.env.PREVIEW_OPERATION;
// A manual run carries no event action, so it cannot be required there.
const action = requested ? '' : ensureEnvVar('EVENT_ACTION');
const label = process.env.LABEL_NAME;
// The dispatch input is free text. Refuse a non-number before anything tries to
// comment on it: postOrUpdateComment(NaN) 404s inside the catch below and
// reports that instead of the real cause.
if (!/^\d+$/.test(pr)) {
console.error(`::error::PULL_REQUEST_NUMBER must be a number, got "${pr}".`);
process.exitCode = 1;
return;
}
const operation = resolveOperation({ action, label, operation: requested });
if (!operation) {
console.log(
`No preview operation for action="${action}" label="${label ?? ''}" operation="${requested ?? ''}" — nothing to do.`,
);
return;
}
if (process.argv.includes('--report-cancelled')) {
await reportCancelled(pr, operation, runUrl);
return;
}
try {
if (operation === 'refresh') {
const list = await runPreview(['ls']);
if (list.status !== 0) throw new Error(`\`preview ls\` exited ${list.status}`);
if (!hasPreviewBox(list.stdout, pr)) {
console.log(`No preview box for PR #${pr} — reporting it as expired.`);
await postOrUpdateComment(Number(pr), expiredComment({ pr }), BOT_MARKER);
return;
}
}
if (operation === 'down') {
const { status } = await runPreview([operation, pr, '--json']);
if (status !== 0) throw new Error(`\`preview ${operation}\` exited ${status}`);
await postOrUpdateComment(Number(pr), downComment({ pr }), BOT_MARKER);
return;
}
// A checklist only earns its place in front of work that takes minutes. It also
// goes up after the `refresh` check above, so a box that is already gone never
// flashes a checklist before the expired body replaces it.
const existing = await findCommentByMarker(Number(pr), BOT_MARKER);
const reporter = createProgressReporter({
pr,
operation,
runUrl,
previousUrl: previewUrlFromBody(existing?.body ?? ''),
commentId: existing?.id,
});
await reporter.start();
let status;
let stdout;
try {
({ status, stdout } = await runPreview([operation, pr, '--json'], {
onProgress: reporter.onProgress,
}));
} finally {
await reporter.stop();
}
if (status !== 0) throw new Error(`\`preview ${operation}\` exited ${status}`);
const preview = parsePreviewJson(stdout);
if (!preview) throw new Error(`\`preview ${operation}\` printed no preview details`);
console.log(`Preview for PR #${pr}: ${preview.url}`);
await postOrUpdateComment(Number(pr), readyComment({ ...preview, pr }), BOT_MARKER);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`::error::Preview ${operation} failed for PR #${pr}: ${message}`);
// Report the failure on the PR too: a label that looks inert is worse than a
// label that says what went wrong.
await postOrUpdateComment(
Number(pr),
failureComment({ operation, runUrl, message }),
BOT_MARKER,
);
process.exitCode = 1;
}
}
// Importable for tests without running the orchestration.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
await main();
}

View File

@ -0,0 +1,358 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
BOT_MARKER,
DISPATCH_OPERATIONS,
PROGRESS_MARKER,
WORKFLOW_URL,
cancelledComment,
downComment,
expiredComment,
failureComment,
formatElapsed,
hasPreviewBox,
operationFor,
parsePreviewJson,
parseProgressLine,
portFromUrl,
previewUrlFromBody,
progressComment,
readyComment,
resolveOperation,
} from './codespace-preview.mjs';
const PREVIEW = {
pr: 1234,
sha: 'abcdef1234567890abcdef1234567890abcdef12',
codespace: 'psychic-umbrella-q7w6gwx',
url: 'https://psychic-umbrella-q7w6gwx-5678.app.github.dev',
orgVisible: true,
};
const START = Date.parse('2026-09-17T09:35:00Z');
const PROGRESS = {
pr: 1234,
operation: 'up',
runUrl: 'https://github.com/n8n-io/n8n/actions/runs/1',
sha: PREVIEW.sha,
phase: 'build',
startedAt: START,
phaseStartedAt: START + 111_000,
now: START + 363_000,
};
describe('operationFor', () => {
const LABEL = 'codespace-preview';
it('creates and deletes on the trigger label', () => {
assert.equal(operationFor('labeled', LABEL), 'up');
assert.equal(operationFor('unlabeled', LABEL), 'down');
});
it('re-serves on a push or a preview: toggle, without creating a box', () => {
assert.equal(operationFor('synchronize'), 'refresh');
// A toggle configures an instance that exists; it must never create or delete one.
assert.equal(operationFor('labeled', 'preview:enterprise'), 'refresh');
assert.equal(operationFor('unlabeled', 'preview:enterprise'), 'refresh');
assert.equal(operationFor('labeled', 'preview:debug'), 'refresh');
});
it('deletes when the PR closes', () => {
assert.equal(operationFor('closed'), 'down');
});
it('ignores a label that is not ours', () => {
assert.equal(operationFor('labeled', 'bug'), undefined);
assert.equal(operationFor('unlabeled', 'Do Not Merge'), undefined);
// A near-miss must not be read as the trigger label.
assert.equal(operationFor('labeled', 'codespace-preview-2'), undefined);
assert.equal(operationFor('labeled', undefined), undefined);
});
it('returns undefined for an action the workflow does not handle', () => {
assert.equal(operationFor('opened'), undefined);
assert.equal(operationFor('reopened'), undefined);
assert.equal(operationFor(''), undefined);
});
});
describe('resolveOperation', () => {
it('falls back to the event mapping when no operation is requested', () => {
assert.equal(resolveOperation({ action: 'labeled', label: 'codespace-preview' }), 'up');
assert.equal(resolveOperation({ action: 'synchronize' }), 'refresh');
assert.equal(resolveOperation({ action: 'labeled', label: 'bug' }), undefined);
// The workflow sets the variable to '' on a pull_request run, not undefined.
assert.equal(resolveOperation({ action: 'closed', operation: '' }), 'down');
});
it('lets a manual run choose, over the event', () => {
for (const operation of DISPATCH_OPERATIONS) {
assert.equal(resolveOperation({ action: '', operation }), operation);
}
assert.equal(resolveOperation({ action: 'synchronize', operation: 'down' }), 'down');
});
it('rejects an operation that is not one of ours', () => {
// `ls` posts no comment and needs no PR, so the workflow does not offer it.
assert.equal(resolveOperation({ action: '', operation: 'ls' }), undefined);
assert.equal(resolveOperation({ action: '', operation: 'UP' }), undefined);
assert.equal(resolveOperation({ action: '', operation: 'up; rm -rf /' }), undefined);
});
});
describe('parsePreviewJson', () => {
it('finds the JSON line among progress and in-box build output', () => {
const stdout = [
'Creating a preview box for PR #1234 (my-branch)…',
'Waiting for psychic-umbrella-q7w6gwx to accept ssh…',
'> n8n@1.0.0 build /workspaces/n8n',
'Tasks: 112 successful, 112 total',
'Ready: the backend answers /healthz on port 5678.',
JSON.stringify(PREVIEW),
].join('\n');
assert.deepEqual(parsePreviewJson(stdout), PREVIEW);
});
it('takes the last preview object when several are printed', () => {
const stale = { ...PREVIEW, sha: '0'.repeat(40) };
const stdout = `${JSON.stringify(stale)}\n${JSON.stringify(PREVIEW)}\n`;
assert.equal(parsePreviewJson(stdout).sha, PREVIEW.sha);
});
// The phase lines share the channel with the report. This is the contract that
// makes that safe.
it('ignores the phase lines that precede the report', () => {
const stdout = [
'{"phase":"resolve"}',
'{"sha":"abcdef1","phase":"build"}',
JSON.stringify(PREVIEW),
].join('\n');
assert.deepEqual(parsePreviewJson(stdout), PREVIEW);
assert.equal(parsePreviewJson('{"phase":"build"}'), undefined);
});
it('ignores JSON that is not a preview report', () => {
// A build tool can print its own JSON on the same stream.
assert.equal(parsePreviewJson('{"level":"info","msg":"built"}'), undefined);
});
it('returns undefined for output with no JSON at all', () => {
assert.equal(parsePreviewJson(''), undefined);
assert.equal(parsePreviewJson('Serving abcdef1 on psychic-umbrella-q7w6gwx…'), undefined);
});
});
describe('hasPreviewBox', () => {
const ls = [
'preview/pr-37\tAvailable\tpsychic-umbrella-q7w6gwx\tlast used 2026-09-04T09:00:00Z',
'preview/pr-1234\tShutdown\tvigilant-broccoli-x9x8x9x\tlast used 2026-09-03T18:20:00Z',
].join('\n');
it('finds a box by its exact display name', () => {
assert.equal(hasPreviewBox(ls, 37), true);
assert.equal(hasPreviewBox(ls, '1234'), true);
});
it('does not treat a shorter PR number as a prefix match', () => {
// `preview/pr-3` must not match the row for `preview/pr-37`.
assert.equal(hasPreviewBox(ls, 3), false);
assert.equal(hasPreviewBox(ls, 123), false);
});
it('handles the empty listing preview.mjs prints when nothing exists', () => {
assert.equal(hasPreviewBox('No preview boxes on n8n-io/n8n.', 37), false);
assert.equal(hasPreviewBox('', 37), false);
});
});
describe('portFromUrl', () => {
it('reads the forwarded port', () => {
assert.equal(portFromUrl(PREVIEW.url), '5678');
});
it('is not confused by a codespace name that ends in digits', () => {
assert.equal(portFromUrl('https://opulent-broccoli-9936x9x-5678.app.github.dev'), '5678');
});
});
describe('comment bodies', () => {
const bodies = {
ready: readyComment(PREVIEW),
readyPrivate: readyComment({ ...PREVIEW, orgVisible: false }),
down: downComment({ pr: PREVIEW.pr }),
expired: expiredComment({ pr: PREVIEW.pr }),
failure: failureComment({
operation: 'up',
runUrl: 'https://github.com/n8n-io/n8n/actions/runs/1',
message: '`preview up` exited 1',
}),
progress: progressComment(PROGRESS),
cancelled: cancelledComment({
pr: PREVIEW.pr,
operation: 'up',
runUrl: 'https://github.com/n8n-io/n8n/actions/runs/1',
}),
};
it('every body starts with the marker, so postOrUpdateComment edits in place', () => {
for (const [name, body] of Object.entries(bodies)) {
assert.ok(body.startsWith(BOT_MARKER), `${name} must start with the bot marker`);
}
});
it('the ready comment links the one-click sign-in path', () => {
assert.match(bodies.ready, /\/preview-signin\)/);
assert.match(bodies.ready, /abcdef1/);
assert.match(bodies.ready, /preview\/pr-1234/);
});
it('the ready comment does not claim org access when the port share failed', () => {
assert.match(bodies.ready, /Every n8n org member/);
assert.doesNotMatch(bodies.readyPrivate, /Every n8n org member/);
assert.match(
bodies.readyPrivate,
/gh codespace ports visibility 5678:org -c psychic-umbrella-q7w6gwx/,
);
});
it('the failure comment carries the cause and the run link', () => {
assert.match(bodies.failure, /exited 1/);
assert.match(bodies.failure, /actions\/runs\/1/);
});
it('quotes the idle timeout preview.mjs actually sets', () => {
// preview.mjs creates a box with --idle-timeout 2h.
assert.match(bodies.ready, /sleeps after 2 hours/);
});
it('points a slept, expired or failed box at the manual run', () => {
for (const name of ['ready', 'expired', 'failure']) {
assert.ok(bodies[name].includes(WORKFLOW_URL), `${name} must link the workflow`);
}
});
// The cancelled-run step writes only over a checklist, so no settled body may
// carry the progress marker.
it('carries the progress marker on the checklist and nowhere else', () => {
assert.ok(bodies.progress.includes(PROGRESS_MARKER));
for (const name of ['ready', 'down', 'expired', 'failure', 'cancelled']) {
assert.ok(!bodies[name].includes(PROGRESS_MARKER), `${name} must not look in progress`);
}
});
it('the cancelled body says the box can still exist, and links both runs', () => {
assert.match(bodies.cancelled, /can still exist/);
assert.ok(bodies.cancelled.includes(WORKFLOW_URL));
assert.ok(bodies.cancelled.includes('actions/runs/1'));
});
});
describe('formatElapsed', () => {
it('reads as a duration a reviewer can scan', () => {
assert.equal(formatElapsed(0), '0s');
assert.equal(formatElapsed(59_000), '59s');
assert.equal(formatElapsed(60_000), '1m 00s');
assert.equal(formatElapsed(252_000), '4m 12s');
assert.equal(formatElapsed(3_780_000), '1h 3m');
});
// A wrong duration in a status line is worse than a boring one.
it('falls back to 0s for a value it cannot use', () => {
assert.equal(formatElapsed(-1), '0s');
assert.equal(formatElapsed(NaN), '0s');
assert.equal(formatElapsed(undefined), '0s');
});
});
describe('progressComment', () => {
it('ticks off the phases before the current one and leaves the rest open', () => {
const body = progressComment(PROGRESS);
assert.match(body, /- \[x\] Resolve the PR head/);
assert.match(body, /- \[x\] Install dependencies/);
assert.match(body, /- \[ \] \*\*Build the monorepo\*\* · 4m 12s/);
assert.match(body, /- \[ \] Share the port with the org/);
});
it('names the commit and the elapsed time, and links the run', () => {
const body = progressComment(PROGRESS);
assert.match(body, /starting for `abcdef1`/);
assert.match(body, /Elapsed 6m 03s/);
assert.ok(body.includes(PROGRESS.runUrl));
});
it('says what the phase is doing when the phase says so', () => {
const body = progressComment({ ...PROGRESS, phase: 'box', detail: 'reusing psychic-umbrella' });
assert.match(body, /\*\*Prepare the box\*\* — reusing psychic-umbrella/);
});
// A workflow_dispatch run reports the sha only once preview.mjs resolves it.
it('falls back to the PR number before the sha is known', () => {
const body = progressComment({ ...PROGRESS, sha: undefined });
assert.match(body, /starting for PR #1234/);
});
it('leaves every phase open before the first one reports', () => {
const body = progressComment({ ...PROGRESS, phase: undefined });
assert.ok(!body.includes('- [x]'));
assert.match(body, /- \[ \] Resolve the PR head/);
});
it('says a refresh is updating, not starting', () => {
assert.match(progressComment({ ...PROGRESS, operation: 'refresh' }), /updating to `abcdef1`/);
});
it('warns only once a phase passes the time it usually takes', () => {
const slow = { ...PROGRESS, phaseStartedAt: START - 600_001 };
assert.match(progressComment(slow), /Build the monorepo is taking longer than usual/);
assert.ok(!progressComment(PROGRESS).includes('longer than usual'));
});
// Replacing a ready comment with a checklist would otherwise take a working URL
// off the PR for several minutes.
it('keeps a URL a previous run reported', () => {
const body = progressComment({ ...PROGRESS, previousUrl: PREVIEW.url });
assert.ok(body.includes(PREVIEW.url));
assert.match(body, /stops answering/);
assert.ok(!progressComment(PROGRESS).includes('stops answering'));
});
});
describe('previewUrlFromBody', () => {
it('reads the URL back out of a ready body', () => {
assert.equal(previewUrlFromBody(readyComment(PREVIEW)), PREVIEW.url);
});
it('finds nothing in a body that reports no instance', () => {
assert.equal(previewUrlFromBody(downComment({ pr: 1234 })), undefined);
assert.equal(previewUrlFromBody(expiredComment({ pr: 1234 })), undefined);
assert.equal(previewUrlFromBody(progressComment(PROGRESS)), undefined);
assert.equal(previewUrlFromBody(''), undefined);
});
});
describe('parseProgressLine', () => {
it('reads a phase line', () => {
assert.deepEqual(parseProgressLine('{"phase":"build","sha":"abc"}'), {
phase: 'build',
sha: 'abc',
});
});
it('ignores the report and anything that is not a phase line', () => {
assert.equal(parseProgressLine(JSON.stringify(PREVIEW)), undefined);
assert.equal(parseProgressLine('Serving abcdef1 on psychic-umbrella-q7w6gwx…'), undefined);
assert.equal(parseProgressLine('{"level":"info","msg":"built"}'), undefined);
assert.equal(parseProgressLine('{not json'), undefined);
});
});

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

View File

@ -24,13 +24,25 @@ export function readPostgresVersions(repoRoot = REPO_ROOT) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
const SCOPES = new Set(['pr', 'full']);
/**
* Coverage and the schema-docs check run on the primary Postgres leg only, since
* the committed docs come from that one version.
* The schema-docs check runs on the primary Postgres leg only, since the
* committed docs come from that one version. PRs collect coverage on that leg.
* Merge groups use the full scope and skip duplicate coverage collection.
*
* Scope 'pr' keeps SQLite plus the primary Postgres leg. Scope 'full' keeps
* every Postgres major. The merge queue generates with 'full', so every merge
* still gates on the older majors.
*
* @param {PostgresVersions} versions
* @param {'pr' | 'full'} scope
*/
export function buildMatrix(versions) {
export function buildMatrix(versions, scope = 'full') {
if (!SCOPES.has(scope)) {
throw new Error(`Unknown scope "${scope}", expected "pr" or "full"`);
}
const { primary, matrix } = versions;
if (!Array.isArray(matrix) || matrix.length === 0) {
@ -67,6 +79,9 @@ export function buildMatrix(versions) {
}
}
// Validation always runs on the full list; scope only trims the output.
const postgresLegs = scope === 'pr' ? matrix.filter(({ image }) => image === primary) : matrix;
return [
{
name: 'SQLite Pooled',
@ -77,19 +92,21 @@ export function buildMatrix(versions) {
TEST_IMAGE_POSTGRES: undefined,
collectCoverage: 'false',
},
...matrix.map(({ major, image }) => ({
...postgresLegs.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',
collectCoverage: scope === 'pr' && image === primary ? 'true' : 'false',
})),
];
}
// Skipped when imported by the tests.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
console.log(JSON.stringify(buildMatrix(readPostgresVersions())));
const scopeArg = process.argv.find((arg) => arg.startsWith('--scope='));
const scope = scopeArg ? scopeArg.slice('--scope='.length) : 'full';
console.log(JSON.stringify(buildMatrix(readPostgresVersions(), scope)));
}

View File

@ -62,14 +62,27 @@ describe('buildMatrix', () => {
}
});
it('collects coverage and checks schema docs on the primary Postgres leg only', () => {
const legs = buildMatrix(versions());
it('collects coverage on the primary Postgres leg in pr scope only', () => {
const legs = buildMatrix(versions(), 'pr');
const collecting = legs.filter((leg) => leg.collectCoverage === 'true');
assert.deepEqual(
collecting.map((leg) => leg.name),
['Postgres 18'],
);
});
it('does not collect coverage in full scope', () => {
const legs = buildMatrix(versions(), 'full');
assert.equal(
legs.some((leg) => leg.collectCoverage === 'true'),
false,
);
});
it('checks schema docs on the primary Postgres leg only', () => {
const legs = buildMatrix(versions());
const checkingPostgresSchema = legs.filter(
(leg) => leg['schema-check-cmd'] === 'pnpm --filter=@n8n/db schema:check:postgres',
@ -125,4 +138,32 @@ describe('buildMatrix', () => {
it('rejects an empty matrix', () => {
assert.throws(() => buildMatrix({ primary: 'postgres:18.4-alpine', matrix: [] }), /non-empty/);
});
it('keeps only SQLite and the primary Postgres leg in pr scope', () => {
const legs = buildMatrix(versions(), 'pr');
assert.deepEqual(
legs.map((leg) => leg.name),
['SQLite Pooled', 'Postgres 18'],
);
});
it('keeps every Postgres leg in full scope', () => {
const legs = buildMatrix(versions(), 'full');
assert.deepEqual(
legs.map((leg) => leg.name),
['SQLite Pooled', 'Postgres 16', 'Postgres 17', 'Postgres 18'],
);
});
it('validates the full version list even in pr scope', () => {
const stale = { ...versions(), primary: 'postgres:17.10-alpine' };
assert.throws(() => buildMatrix(stale, 'pr'), /"primary".*must be the newest/);
});
it('rejects an unknown scope', () => {
assert.throws(() => buildMatrix(versions(), 'nightly'), /Unknown scope/);
});
});

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

@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* Asserts that a pushed image is an OCI image index with only real platform
* manifests.
*
* n8n 2.26.0 shipped as a Docker manifest list, not an OCI index. Older
* containerd on AKS then read the attestation manifests as image manifests, and
* every pull failed (#31997). This check verifies the format directly.
*
* Usage: node assert-manifest-format.mjs <image-ref> [--expect-platforms n]
*/
import { execFileSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
export const OCI_INDEX = 'application/vnd.oci.image.index.v1+json';
/**
* Partitions an index's manifests and collects format failures.
*
* Pure so it can be unit-tested without a registry. `platform` is optional in
* the OCI spec, so a descriptor without one is a failure rather than a crash.
*
* @param {object} manifest parsed image index
* @param {number|null} expectPlatforms distinct platform count to require
*/
export function checkManifestFormat(manifest, expectPlatforms = null) {
const failures = [];
if (manifest.mediaType !== OCI_INDEX) {
failures.push(`mediaType is ${manifest.mediaType}, expected ${OCI_INDEX}`);
}
const entries = Array.isArray(manifest.manifests) ? manifest.manifests : [];
const unplaced = entries.filter((m) => !m.platform);
if (unplaced.length > 0) {
failures.push(`${unplaced.length} manifest(s) carry no platform descriptor`);
}
const placed = entries.filter((m) => m.platform);
const platforms = placed.filter((m) => m.platform.architecture !== 'unknown');
const attestations = placed.filter((m) => m.platform.architecture === 'unknown');
const distinct = new Set(
platforms.map((m) =>
[m.platform.os, m.platform.architecture, m.platform.variant ?? ''].join('/'),
),
);
// Distinct, not length: a duplicated platform entry would otherwise pass.
if (expectPlatforms !== null && distinct.size !== expectPlatforms) {
failures.push(
`${distinct.size} distinct platforms (${[...distinct].join(', ')}), expected ${expectPlatforms}`,
);
}
if (platforms.length !== distinct.size) {
failures.push(`${platforms.length} platform manifests but only ${distinct.size} distinct`);
}
return { failures, platforms, attestations, distinct };
}
function main() {
const [ref, ...rest] = process.argv.slice(2);
if (!ref) {
console.error('usage: assert-manifest-format.mjs <image-ref> [--expect-platforms n]');
process.exit(2);
}
const expectIdx = rest.indexOf('--expect-platforms');
const expectPlatforms = expectIdx === -1 ? null : Number(rest[expectIdx + 1]);
const raw = execFileSync('docker', ['buildx', 'imagetools', 'inspect', '--raw', ref], {
encoding: 'utf-8',
});
const manifest = JSON.parse(raw);
const { failures, platforms, attestations } = checkManifestFormat(manifest, expectPlatforms);
console.log(`ref: ${ref}`);
console.log(`mediaType: ${manifest.mediaType}`);
for (const m of platforms) {
console.log(` platform ${m.platform.os}/${m.platform.architecture}`);
}
for (const _ of attestations) {
console.log(' attestation (unknown/unknown)');
}
// Not fatal. `--sbom=true` adds these, and they caused the 2.26.0 pull failure.
// Report the count and let the caller decide.
console.log(`\nplatforms: ${platforms.length}, attestations: ${attestations.length}`);
if (failures.length > 0) {
for (const f of failures) console.error(`::error::${f}`);
process.exit(1);
}
console.log('OK: manifest is an OCI image index');
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}

View File

@ -0,0 +1,96 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { checkManifestFormat, OCI_INDEX } from './assert-manifest-format.mjs';
const DOCKER_LIST = 'application/vnd.docker.distribution.manifest.list.v2+json';
const platform = (os, architecture, variant) => ({ platform: { os, architecture, variant } });
const attestation = () => ({ platform: { os: 'unknown', architecture: 'unknown' } });
describe('checkManifestFormat', () => {
it('passes a two-platform OCI index', () => {
const { failures, platforms, attestations } = checkManifestFormat(
{ mediaType: OCI_INDEX, manifests: [platform('linux', 'amd64'), platform('linux', 'arm64')] },
2,
);
assert.deepEqual(failures, []);
assert.equal(platforms.length, 2);
assert.equal(attestations.length, 0);
});
it('rejects a Docker manifest list', () => {
const { failures } = checkManifestFormat({
mediaType: DOCKER_LIST,
manifests: [platform('linux', 'amd64')],
});
assert.equal(failures.length, 1);
assert.match(failures[0], /expected application\/vnd\.oci\.image\.index/);
});
it('counts attestations separately from platforms', () => {
const { platforms, attestations, failures } = checkManifestFormat(
{
mediaType: OCI_INDEX,
manifests: [platform('linux', 'amd64'), platform('linux', 'arm64'), attestation()],
},
2,
);
assert.deepEqual(failures, []);
assert.equal(platforms.length, 2);
assert.equal(attestations.length, 1);
});
it('fails when the distinct platform count does not match', () => {
const { failures } = checkManifestFormat(
{ mediaType: OCI_INDEX, manifests: [platform('linux', 'amd64')] },
2,
);
assert.equal(failures.length, 1);
assert.match(failures[0], /1 distinct platforms/);
});
it('does not let a duplicated platform satisfy the expected count', () => {
const { failures } = checkManifestFormat(
{ mediaType: OCI_INDEX, manifests: [platform('linux', 'amd64'), platform('linux', 'amd64')] },
2,
);
assert.ok(failures.some((f) => /1 distinct platforms/.test(f)));
assert.ok(failures.some((f) => /2 platform manifests but only 1 distinct/.test(f)));
});
it('treats a descriptor with no platform as a failure, not a crash', () => {
const { failures } = checkManifestFormat(
{ mediaType: OCI_INDEX, manifests: [platform('linux', 'amd64'), { digest: 'sha256:x' }] },
1,
);
assert.ok(failures.some((f) => /carry no platform descriptor/.test(f)));
});
it('treats a missing or non-array manifests field as empty', () => {
assert.equal(checkManifestFormat({ mediaType: OCI_INDEX }).platforms.length, 0);
assert.equal(
checkManifestFormat({ mediaType: OCI_INDEX, manifests: {} }).platforms.length,
0,
);
});
it('distinguishes platforms that differ only by variant', () => {
const { platforms, failures } = checkManifestFormat(
{
mediaType: OCI_INDEX,
manifests: [platform('linux', 'arm', 'v6'), platform('linux', 'arm', 'v7')],
},
2,
);
assert.deepEqual(failures, []);
assert.equal(platforms.length, 2);
});
it('skips the platform-count check when no expectation is given', () => {
const { failures } = checkManifestFormat({
mediaType: OCI_INDEX,
manifests: [platform('linux', 'amd64')],
});
assert.deepEqual(failures, []);
});
});

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