Merge branch 'master' into n8n-10005-chore-re-export-reka-ui-collapsible-primitives-from
Some checks failed
CI: Python / Checks (push) Has been cancelled

This commit is contained in:
Csaba Tuncsik 2026-06-18 10:28:22 +02:00 committed by GitHub
commit 0f3ec22a1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3897 changed files with 229873 additions and 93309 deletions

View File

@ -1 +0,0 @@
../.claude/plugins/n8n/skills

39
.agents/skills/AGENTS.md Normal file
View File

@ -0,0 +1,39 @@
# Agent Skills
Shared n8n skills live in `.agents/skills`. These are the canonical source for
skills that should work across Claude Code, OpenCode, and future agent harnesses.
## Layout
- Shared skills: `.agents/skills/<name>/SKILL.md`
- Claude-specific skills and overrides: `.claude/plugins/n8n/skills/<name>/`
- OpenCode-specific skills and overrides: `.opencode/skills/<name>/`
Claude plugin skills should usually be symlinks back to `.agents/skills`.
OpenCode discovers `.agents/skills` directly, so `.opencode/skills` should only
contain OpenCode-specific real-directory overrides.
Real directories in a harness path are treated as intentional overrides and are
preserved by the sync script. The sync script only prunes symlinks that point
back into `.agents/skills` (stale shared-skill links); hand-placed symlinks
pointing elsewhere are left untouched.
The Claude plugin links are git symlinks, so a checkout needs symlink support.
On Windows, enable it (`git config core.symlinks true`, plus Developer Mode or
WSL) before checking out — otherwise git writes the links as plain text stubs
and Claude Code fails to load those skills. `--check` reports stubs with an
actionable error. (OpenCode reads `.agents/skills` directly and is unaffected.)
## Editing
- Edit shared skills under `.agents/skills`, not through symlinked copies.
- Only put a skill in `.agents/skills` if it can work across supported agent
harnesses. Avoid hardcoded harness tool names, commands, or UI flows unless
the skill includes a clear availability check and fallback.
- Keep harness-specific commands out of shared skills unless guarded by clear
tool availability checks.
- Put harness-specific workflows, such as MCP setup commands, in real
directories under the matching harness path.
- Run `pnpm sync:skill-links` after adding or removing shared skills to update
Claude plugin symlinks.
- Run `pnpm check:skill-links` before submitting changes.

View File

@ -1,4 +1,5 @@
---
name: n8n:community-pr-readiness-check
description: >-
Checks if a community pull request is ready for human review. Verifies CLA
signature, PR title format, description completeness, test coverage, and
@ -6,7 +7,13 @@ description: >-
close. Use when given a PR number or branch name to review, or when the user
says /community-pr-readiness-check, or asks to check if a PR is ready for
review.
allowed-tools: Bash(gh:*), Bash(git:*), Bash(node:*), Read, Glob, Grep, AskUserQuestion, mcp__linear-server__save_issue, mcp__linear-server__get_issue, mcp__linear-server__list_teams, mcp__linear-server__list_issue_statuses
allowed-tools: Bash(gh:*), Bash(git:*), Bash(node:*), Read, Glob, Grep
compatibility:
requires:
- mcp: linear
description: Required for reading and updating Linear tickets during triage
- cli: gh
description: Required for PR inspection and triage actions. Must be authenticated (gh auth login)
---
# Community PR Readiness Check
@ -44,8 +51,8 @@ If `author.login` is one of n8n's internal bots — `n8n-cat-bot` / `app/n8n-cat
gh pr edit <number> --repo n8n-io/n8n --remove-label community --add-label "n8n team"
```
2. Update the linked Linear ticket (extract `GHC-XXXX` per step 5):
- **`n8n-cat-bot`** — cancel: `mcp__linear-server__save_issue` with `state: "Canceled"`, no labels.
- **`aikido-autofix`** — route to Dev Platform: `mcp__linear-server__save_issue` with `team: "Developer Platform"`, `state: "Triage"`, no labels.
- **`n8n-cat-bot`** — cancel: use the available Linear MCP issue-update tool with `state: "Canceled"`, no labels.
- **`aikido-autofix`** — route to Dev Platform: use the available Linear MCP issue-update tool with `team: "Developer Platform"`, `state: "Triage"`, no labels.
When reviewing a batch, omit the skipped PR from the output. For a single PR, emit a one-line note (e.g. `Skipped & cleaned up #30591 (n8n-cat-bot): relabeled to n8n team, cancelled GHC-8398.`).
@ -84,7 +91,7 @@ gh api --paginate "repos/n8n-io/n8n/issues/<number>/comments" \
## Step 2.5 — Auto-rejection screen
Per [`CONTRIBUTING.md`](../../../../../CONTRIBUTING.md), two PR patterns should be closed outright rather than reviewed:
Per `CONTRIBUTING.md`, two PR patterns should be closed outright rather than reviewed:
- **Typo-only PR** — diff is entirely spelling/grammar fixes with no logic or tests.
- **New-node PR** — adds a brand-new node, unless the n8n team has explicitly agreed to take it.
@ -96,8 +103,8 @@ If either matches, set `checks.AutoReject` and skip directly to action **D**. Fu
Run when `AutoReject` is `null`. Full rules for each in `reference/checks.md`:
- **A. CLA** — `cla-signed` label present.
- **B. Title** — matches the conventional-commit regex. Authoritative rules in [`.github/pull_request_title_conventions.md`](../../../../../.github/pull_request_title_conventions.md).
- **C. Description** — every section heading and checklist item from [`.github/pull_request_template.md`](../../../../../.github/pull_request_template.md) is present in the PR body. The template is read at check time, so changes to it propagate automatically.
- **B. Title** — matches the conventional-commit regex. Authoritative rules in `.github/pull_request_title_conventions.md`.
- **C. Description** — every section heading and checklist item from `.github/pull_request_template.md` is present in the PR body. The template is read at check time, so changes to it propagate automatically.
- **D. Tests** — source logic changes have matching test files. Skip for `docs/ci/chore/build` PRs.
- **E. cubic-dev-ai** — no unresolved comments (resolved = "Addressed in commit" marker).
@ -111,6 +118,21 @@ n8n-assistant leaves a comment on every community PR containing `This PR has bee
If no n8n-assistant comment exists (older PRs that predate the automation), `linearTicket` is `null`.
## Step 5b — Find Linear tickets for issues this PR claims to fix
The PR body often says `Fixes #NNNN` / `Closes #NNNN` / `Resolves #NNNN` (or links to `https://github.com/n8n-io/n8n/issues/NNNN`). Each of those issues usually has its own GHC ticket (or has already been triaged to a team). Surface those so the assign action can cross-reference them.
1. Extract every issue number from the PR body matching `\b(?:fix(?:es)?|close[sd]?|resolve[sd]?)\s+#?(\d+)\b` (case-insensitive) **or** URLs matching `github\.com/n8n-io/n8n/issues/(\d+)`. Deduplicate.
2. For each issue number, search Linear with the available Linear MCP issue-search tool (query `github.com/n8n-io/n8n/issues/<num>`, limit 50) and filter the result to issues whose `description` contains the exact URL `https://github.com/n8n-io/n8n/issues/<num>`. The n8n-assistant bot embeds that URL in the description of every community-issue ticket it creates, so the match is reliable. Use the default limit of 50 (not a smaller value): the `query` is a substring search ordered by `updatedAt`, so `issues/<num>` also matches longer issue numbers (e.g. searching `123` matches `1234`) and the exact ticket can sit anywhere in the result set — a tight limit would silently drop it. If 50 results come back full, paginate with `cursor` until the exact match is found or results are exhausted.
3. Collect the matching ticket IDs (e.g. `GHC-1234`, or wherever they've been routed since — `NODE-5678`, `CAT-3338`). Include cancelled/duplicate tickets too — the comment is still useful for traceability.
Emit the result as `relatedIssueTickets` in the JSON. During the `assign` action the cross-reference is posted **both ways** so both ends carry the link:
- On each related issue ticket — *"FYI, [community PR #<pr>](https://github.com/n8n-io/n8n/pull/<pr>) claims to fix the issue tracked here; routed to <team> as <linearTicket>."*
- On the PR's own ticket (`linearTicket`), when it is non-null — a note pointing back to each related issue ticket.
If no `Fixes/Closes/Resolves` references exist, return `relatedIssueTickets: []`.
## Step 6 — Output JSON
```json
@ -119,6 +141,7 @@ If no n8n-assistant comment exists (older PRs that predate the automation), `lin
"messageForUser": "<Short message to the contributor listing what they need to address. 'N/A' if ready.>",
"team": "<Linear team name (from reference/teams.md), or 'Engineering' as fallback>",
"linearTicket": "<GHC-XXXX or null>",
"relatedIssueTickets": [<"GHC-1234" | "NODE-5678" | ...>],
"checks": {
"AutoReject": <"typo-only" | "new-node" | null>,
"CLA": <bool>,
@ -137,7 +160,7 @@ Emit the JSON first, then take the appropriate action path below.
## Step 7 — Action paths
Use `AskUserQuestion` for each prompt. Sub-agents called for analysis only should stop after step 6 and let the caller drive step 7.
Ask the user for each prompt (presented as the listed options). Sub-agents called for analysis only should stop after step 6 and let the caller drive step 7.
### A — Minor title fix
@ -164,14 +187,12 @@ Destination state: `Review` for NODES, `Triage` for every other team. Label comp
On `Yes`:
```python
# 1. Linear
mcp__linear-server__save_issue(
id=linearTicket,
team=<team>,
state=<destination>,
labels=<computed labels>,
)
```bash
# 1. Linear — call the available Linear MCP issue-update tool with:
# id = linearTicket
# team = <team>
# state = <destination>
# labels = <computed labels>
# 2. GitHub (only if Linear succeeded) — see reference/label-flow.md
gh pr edit <number> --repo n8n-io/n8n \
--remove-label "triage:in-progress" \
@ -229,7 +250,7 @@ gh pr edit <number> --repo n8n-io/n8n \
--add-label "triage:complete"
```
If `linearTicket` is set, also cancel it: `mcp__linear-server__save_issue(id=linearTicket, state="Canceled")`. If `gh pr close` reports the PR is already closed (contributor beat you to it), proceed with the comment, labels, and ticket cancellation anyway.
If `linearTicket` is set, also cancel it with the available Linear MCP issue-update tool (`id=linearTicket`, `state="Canceled"`). If `gh pr close` reports the PR is already closed (contributor beat you to it), proceed with the comment, labels, and ticket cancellation anyway.
## Notes

View File

@ -18,7 +18,7 @@ The full ruleset for the auto-rejection screen and the five readiness checks. Lo
## Step 2.5 — Auto-rejection screening
Before running the five checks, screen for PRs that the project policy in [`CONTRIBUTING.md`](../../../../../../CONTRIBUTING.md) (section "Community PR Guidelines") says should be rejected outright. If a PR matches one of these patterns, skip the five checks and recommend a polite close instead.
Before running the five checks, screen for PRs that the project policy in `CONTRIBUTING.md` (section "Community PR Guidelines") says should be rejected outright. If a PR matches one of these patterns, skip the five checks and recommend a polite close instead.
### Typo-only PR
@ -54,7 +54,7 @@ Check the PR `labels` returned from `gh pr view`:
### B. PR title format
The authoritative title rules live in [`.github/pull_request_title_conventions.md`](../../../../../../.github/pull_request_title_conventions.md). Read that file at the start of the check — the allowed `type` list and scope rules come from there, not from this skill.
The authoritative title rules live in `.github/pull_request_title_conventions.md`. Read that file at the start of the check — the allowed `type` list and scope rules come from there, not from this skill.
The matching regex below is a cached extraction of those rules. If the conventions file disagrees with the regex (a new type, a different scope syntax), trust the file and flag the divergence in your output.
@ -80,7 +80,7 @@ Quick recap of what the regex enforces (full detail in the conventions file):
### C. PR description completeness
The PR template at [`.github/pull_request_template.md`](../../../../../../.github/pull_request_template.md) is the **source of truth** for what a complete PR description looks like. Read it at the start of the check — sections and checklist items shouldn't be hardcoded here.
The PR template at `.github/pull_request_template.md` is the **source of truth** for what a complete PR description looks like. Read it at the start of the check — sections and checklist items shouldn't be hardcoded here.
Procedure:

View File

@ -63,7 +63,7 @@ The **GitHub team label** column is what gets applied to the PR after a successf
## Linear ticket label rules
When calling `mcp__linear-server__save_issue` to assign a ticket to a team, pass a `labels` array composed of three pieces:
When calling the available Linear MCP issue-update tool to assign a ticket to a team, pass a `labels` array composed of three pieces:
### Always-on label

View File

@ -123,7 +123,7 @@ Structure the description using markdown headers. Use the appropriate template:
If the user provides screenshots, videos, or screen recordings:
- **URLs** — embed directly in the description using markdown image syntax (`![description](url)`)
- **File paths** — if the user provides a local file path, ask them to upload it to a hosting service (e.g., GitHub, Imgur) or use `mcp__linear-server__create_attachment` to attach it to the Linear ticket after creation
- **File paths** — if the user provides a local file path, ask them to upload it to a hosting service (e.g., GitHub, Imgur) or use the available Linear MCP attachment tool to attach it to the Linear ticket after creation
- **Pasted images in conversation** — describe what the image shows in the ticket description and note that a screenshot was provided. You cannot upload binary data directly.
Always mention in the description when visual evidence was provided, even if it cannot be directly embedded.
@ -153,7 +153,7 @@ Always mention in the description when visual evidence was provided, even if it
#### Team
- **Try to fetch up-to-date team areas of responsibility from Notion** using `mcp__notion__notion-search` (search for "areas of responsibility" or similar). Use the fetched data to determine the best team for the issue.
- **Try to fetch up-to-date team areas of responsibility from Notion** using the available Notion MCP search tool (search for "areas of responsibility" or similar). Use the fetched data to determine the best team for the issue.
- **If Notion MCP is unavailable or the lookup fails**, fall back to these common teams: `Engineering` (N8N), `AI`, `NODES`, `Identity & Access` (IAM), `Catalysts` (CAT), `Lifecycle & Governance` (LIGO), `Cloud Platform`, `Docs` (DOC)
- **Always ask the user which team** if not obvious from context or the Notion lookup
- If the issue is node-specific, it likely belongs to `NODES`
@ -218,7 +218,7 @@ Only set an estimate if the user provides one or explicitly asks for one. Use t-
3. **Wait for user confirmation** — do not create until the user approves
4. **Create the ticket** using `mcp__linear-server__save_issue`:
4. **Create the ticket** using the available Linear MCP issue-creation tool:
```
title: <title>
team: <team name>
@ -237,7 +237,7 @@ Only set an estimate if the user provides one or explicitly asks for one. Use t-
- Never apply **triage-state**, **release**, or **docs-automation** labels
- Never set **assignee** unless the user explicitly asks
- Never set a **cycle** or **milestone** unless the user explicitly asks
- Never create **duplicate issues** — if the user describes something that sounds like it may exist, search first with `mcp__linear-server__list_issues`
- Never create **duplicate issues** — if the user describes something that sounds like it may exist, search first with the available Linear MCP issue-search tool
---

View File

@ -53,9 +53,9 @@ Creates GitHub PRs with titles that pass n8n's `check-pr-title` CI validation.
git log origin/master..HEAD --oneline
```
2. **Check for implementation plan**: Look for a plan file in `.claude/plans/`
2. **Check for implementation plan**: Look for a plan file in the repository plan directories (`.claude/plans/` or `.agents/plans/` when present)
that matches the current branch's ticket ID (e.g. if branch is
`scdekov/PAY-1234-some-feature`, check for `.claude/plans/PAY-1234.md`).
`scdekov/PAY-1234-some-feature`, check for `PAY-1234.md`).
If a plan file exists, ask the user whether they want to include it in the
PR description as a collapsible `<details>` section (see Plan Section below).
Only include the plan if the user explicitly approves.
@ -163,7 +163,7 @@ Key validation rules:
## Plan Section
If a matching plan file was found in `.claude/plans/` and the user has approved
If a matching plan file was found in a repository plan directory and the user has approved
including it, add a collapsible section at the end of the PR body (after the
checklist, before `EOF`):

View File

@ -13,13 +13,16 @@ Skills are markdown (plus optional scripts) that teach the agent a focused workf
| Location | When to use |
|----------|-------------|
| **`.claude/plugins/n8n/skills/<name>/`** | Default for n8n: team-shared, versioned, namespaced under `n8n:`. |
| **`.agents/skills/<name>/`** | Default for n8n: team-shared, versioned, agent-neutral source. |
| `.claude/plugins/n8n/skills/<name>/` | Claude-specific override, or a generated symlink to `.agents/skills/<name>/`. |
| `.opencode/skills/<name>/` | OpenCode-specific override only. Shared skills stay in `.agents/skills/<name>/`. |
| `~/.claude/skills/<name>/` | Personal skill for Claude Code across all projects. |
| `~/.config/opencode/skills/<name>/` | Personal skill for OpenCode across all projects. |
| `~/.cursor/skills/<name>/` | Optional personal skill for Cursor only, global to your machine. |
**Do not** put custom skills in `~/.cursor/skills-cursor/`—that is reserved for Cursor’s built-in skills.
Prefer **plugin `.claude/plugins/n8n/skills/`** for anything that should match how the rest of the team works.
Prefer **`.agents/skills/`** for anything that should match how the rest of the team works. Run `pnpm sync:skill-links` after adding or removing shared skills.
## Before you write: gather requirements
@ -29,7 +32,7 @@ Ask (or infer) briefly:
2. **Triggers** — when should the agent apply this skill?
3. **Gaps** — what does the agent *not* already know (project rules, URLs, formats)?
4. **Outputs** — templates, checklists, or strict formats?
5. **Examples** — follow an existing skill in `.claude/plugins/n8n/skills/` if one fits.
5. **Examples** — follow an existing shared skill in `.agents/skills/` if one fits.
Ask the user in plain language when you need more detail.
@ -47,12 +50,16 @@ skill-name/
```yaml
---
name: skill-name # lowercase, hyphens, max 64 chars
name: n8n:skill-name # n8n:<name> — lowercase, hyphens, max 64 chars
description: >- # max 1024 chars, non-empty — see below
...
---
```
**Name** — shared n8n skills use the `n8n:<name>` form so Claude Code namespaces
them under the `n8n` plugin (invoked as `/n8n:<name>`). The `<name>` part must
match the skill's directory name.
**Description** (discovery is everything — third person, WHAT + WHEN, trigger words):
- Good: `Extracts tables from PDFs and fills forms. Use when the user works with PDFs, forms, or document extraction.`
@ -80,7 +87,7 @@ description: >- # max 1024 chars, non-empty — see below
- **MCPs are optional per user** — not everyone has the same servers enabled. If a skill **requires** a specific MCP to work as written, say so explicitly:
- Put a hint in the **frontmatter description** (e.g. “Requires Linear MCP for …”) so mismatches are obvious early.
- Add a short **Prerequisites** (or **Requirements**) block near the top: which integration, what it is used for, and a **fallback** (e.g. web UI, `gh`, or “ask the user to paste …”) when it is missing.
- **Referencing other skills** — use the namespaced invocation name (e.g. `n8n:create-issue`) so the agent resolves the plugin skill. For human-readable links, give the path from the repo root (e.g. `.claude/plugins/n8n/skills/create-issue/SKILL.md`). From a sibling folder, a relative link works too: `[create-issue](../create-issue/SKILL.md)`. Parent skills should delegate steps instead of duplicating long procedures.
- **Referencing other skills** — use the harness-visible invocation name (e.g. `n8n:create-issue` where namespacing is available, otherwise `create-issue`). For human-readable links, give the canonical path from the repo root (e.g. `.agents/skills/create-issue/SKILL.md`). From a sibling folder, a relative link works too: `[create-issue](../create-issue/SKILL.md)`. Parent skills should delegate steps instead of duplicating long procedures.
## Patterns (pick what fits)
@ -109,7 +116,7 @@ description: >- # max 1024 chars, non-empty — see below
```markdown
---
name: my-workflow
name: n8n:my-workflow
description: Does X using project convention Y. Use when the user asks for X or mentions Z.
---

View File

@ -19,7 +19,7 @@ description: Authors n8n database migrations. Use when creating or modifying fil
- [Cross-database Compatibility](#cross-database-compatibility)
- [Tests](#tests)
- [General Design Guidance](#general-design-guidance)
- [After authoring](#after-authoring)
- [Schema documentation](#schema-documentation)
---
@ -117,11 +117,13 @@ Run through this before requesting review. Each item is a real, recurring review
- [ ] **Sparse-unique columns:** use a partial index `WHERE col IS NOT NULL`. — [Index Management](#index-management)
- [ ] **Composite index column order** matches your actual `WHERE` / `ORDER BY` usage. — [Index Management](#index-management)
- [ ] **Entity ↔ migration parity**: column types, `notNull`, defaults, FKs, `@Index` decorators all match. — [Schema/Entity Drift](#schemaentity-drift)
- [ ] **If using `addColumns`, `dropColumns`, `addNotNull`, `dropNotNull`, `addEnumCheck`, or `dropEnumCheck`:** verified whether the target table has incoming FKs. If so, either set `withFKsDisabled = true as const` (in a `sqlite/` subclass if this is a `common/` migration) or use raw `ALTER TABLE ADD COLUMN` for nullable/defaulted columns. — [SQLite table recreation risk](#sqlite-table-recreation-risk)
- [ ] **No live-app value imports** in the migration body. Inline types/utility code locally. — [Never import entities as values](#never-import-entities-as-values)
- [ ] **`async down()` was tested locally**: `pnpm start && pnpm start -- db:revert && pnpm start` on **both** SQLite and Postgres. — [Reversibility](#reversibility)
- [ ] **One logical change per migration**; split unrelated table changes into separate files. — [Don't combine independent schema changes](#dont-combine-independent-schema-changes)
- [ ] **`up()` / `down()` reads as a list of intentions.** If either body grows past a screen or mixes schema operations with a multi-statement raw-SQL data move, extract the data move into a `private async` method on the same class (e.g. `private async backfillFromX(ctx)`). The top-level should orchestrate, not implement.
- [ ] **Precedent is the bar to fix, not perpetuate.** When the checklist conflicts with what an older migration does (e.g. redundant `.primary.notNull`, hand-quoted identifiers, missing `.comment()`), the checklist wins for new code — don't copy the violation forward. Note the old occurrences in the PR if you spotted them.
- [ ] **Regenerated the schema docs** with `pnpm db:schema:docs` and committed the `docs/generated/` changes. The DB Tests CI job fails on stale docs. — [Schema documentation](#schema-documentation)
Treat the checklist as a floor, not a ceiling.
If any item fails, fix it before opening review.
@ -218,7 +220,7 @@ export class MigrateThing1234567890000 implements IrreversibleMigration {
const { schemaBuilder: { addColumns, column, createIndex } } = ctx;
// One-liner DSL calls stay inline — naming them adds no information.
await addColumns('my_table', [column('slug').varchar(255)]);
await addColumns('my_table', [column('slug').varchar(255)], { recreatesOnSqlite: true });
// The non-trivial step gets a named method.
await this.backfillSlugs(ctx);
@ -350,6 +352,51 @@ export class CreateMyTable1234567890000 implements ReversibleMigration {
}
```
### SQLite table recreation risk
Six DSL methods trigger **full table recreation** on SQLite — TypeORM internally creates a temp copy, drops the original, and renames:
| Method | TypeORM internal call |
|---|---|
| `addColumns()` | `queryRunner.addColumns()` |
| `dropColumns()` | `queryRunner.dropColumns()` |
| `addNotNull()` | `queryRunner.changeColumn()` |
| `dropNotNull()` | `queryRunner.changeColumn()` |
| `addEnumCheck()` | `queryRunner.changeColumn()` |
| `dropEnumCheck()` | `queryRunner.changeColumn()` |
All six require a final options parameter with `recreatesOnSqlite: true` — TypeScript rejects calls that omit it.
**The danger:** If the target table has incoming FK constraints with `CASCADE` from other tables, the `DROP TABLE` during recreation fires cascading deletes and **wipes rows from those referencing tables**.
**Decision tree:**
1. Does the target table have incoming FK constraints from other tables?
- **No** → Safe to use the DSL method directly (with the ack parameter).
- **Yes** → Continue to step 2.
2. Is this an `addColumns` call where every new column is nullable or has a default?
- **Yes** → Use raw `ALTER TABLE ADD COLUMN` instead (avoids table recreation entirely):
```typescript
await runQuery(
`ALTER TABLE ${escape.tableName('my_table')} ADD COLUMN ${escape.columnName('col')} TEXT`,
);
```
See `1733133775640-AddMockedNodesColumnToTestDefinition.ts` for a real example.
- **No** → Continue to step 3.
3. Set `withFKsDisabled = true as const` on the migration class. For common migrations, create a SQLite subclass in `sqlite/` that extends the common migration and adds the flag:
```typescript
// sqlite/1234567890000-MyMigration.ts
import { MyMigration1234567890000 as BaseMigration } from '../common/1234567890000-MyMigration';
export class MyMigration1234567890000 extends BaseMigration {
withFKsDisabled = true as const;
}
```
**How `withFKsDisabled` works:** The migration wrapper calls `PRAGMA foreign_keys=OFF` before `up()`/`down()`, runs the migration inside a manual transaction, then re-enables foreign keys. This prevents CASCADE from firing during the internal table drop. It also sets `transaction = false` to avoid TypeORM's default transaction (since SQLite can't nest transactions with PRAGMA changes).
> **Note:** On Postgres, these methods use `ALTER TABLE` directly and don't recreate the table. The risk is SQLite-specific.
### Column types
**Match column type to value semantics.** Never `varchar` as a catch-all for non-string values — storing numbers as strings loses sort order, range queries, and SUM/AVG aggregations.
@ -570,7 +617,11 @@ When a migration both adds a column and backfills data, structure it clearly wit
```typescript
export class AddAndBackfillColumn1234567890000 implements IrreversibleMigration {
async up(ctx: MigrationContext) {
await ctx.schemaBuilder.addColumns('my_table', [ctx.schemaBuilder.column('newCol').text]);
await ctx.schemaBuilder.addColumns(
'my_table',
[ctx.schemaBuilder.column('newCol').text],
{ recreatesOnSqlite: true },
);
await this.backfillNewCol(ctx);
}
@ -633,7 +684,7 @@ Some migrations override with `transaction = false as const` for big DDL on engi
- **Small differences** (a single statement, a CHECK constraint, slightly different syntax): keep one migration in `common/` and branch on `isSqlite` / `isPostgres`.
- **Large differences** (different table recreation strategies, different intermediate steps, fundamentally different SQL): write **separate files** in `postgresdb/` and `sqlite/`. A common migration full of `if (isSqlite) { ... }` blocks is harder to read and review than two focused files.
If only Postgres needs the change, just put the file in `postgresdb/`; don't write a no-op SQLite migration with `if (isPostgres)`. SQLite no longer needs separate migrations for column adds (the recreate-table path was fixed) — verify before duplicating.
If only Postgres needs the change, just put the file in `postgresdb/`; don't write a no-op SQLite migration with `if (isPostgres)`. For SQLite column adds, follow the [SQLite table recreation risk](#sqlite-table-recreation-risk) decision tree before deciding whether a common migration is enough or a SQLite subclass/raw `ALTER TABLE` path is needed.
### SQLite supports modern syntax
@ -729,4 +780,18 @@ A "polymorphic" column pair is one column that points at different tables depend
- **Separate join tables per relation type** (`credential_external_secret_dependency`, `credential_node_dependency`, …). Each has a real FK. Queries that need "all dependencies" become a UNION.
- **One nullable FK per possible target** with a CHECK constraint that exactly one is set. Each column is a real FK.
- **Supertype table**: hoist parents into a single `dependency_target` with its own type column, then have one FK to that table.
-
---
## Schema documentation
The database schema is documented under `docs/generated/` — `docs/generated/sqlite-schema/` and `docs/generated/postgres-schema/`, one Markdown page per table plus a Mermaid ER diagram. These are **auto-generated from the migrations** with [tbls](https://github.com/k1LoW/tbls) (it runs every migration against a throwaway database and introspects the result), so any schema-changing migration makes them stale.
Regenerate and commit them alongside your migration:
```sh
pnpm db:schema:docs # rewrites docs/generated/ — requires Docker (and `brew install tbls` locally)
pnpm db:schema:check # verify only; what the DB Tests CI job runs
```
The DB Tests CI job fails the PR when the committed docs don't match the migrations (each matrix leg verifies its own database). Don't hand-edit anything under `docs/generated/` — it's overwritten on every regeneration.

View File

@ -1,5 +1,5 @@
---
name: experiments
name: n8n:experiments
description: >-
Guides work on `packages/frontend/editor-ui` experiments. Use when creating,
extending, wiring, testing, reviewing, or retiring editor-ui experiments,

View File

@ -19,6 +19,9 @@ Use this guide for code under `packages/frontend/editor-ui/src/experiments/` and
- Use `useStorage` from `@/app/composables/useStorage` for experiment-owned persisted UI state such as dismissed, interacted, or last-seen flags.
- Do not use raw `localStorage` for new experiment-owned state unless the experiment must write an existing non-experiment app key.
- Keep `"User is part of experiment"` tracking centralized. Add only experiment-specific `track*` helpers in experiment code.
- Use `getExperimentTelemetryPayload()` from `@/experiments/utils` for every experiment-specific telemetry helper that may be used as a PostHog experiment exposure or metric event.
- Custom exposure events must include PostHog's `$feature/<experiment-name>` property with the current variant. The helper adds this property and the human-readable `variant` property.
- Do not hand-roll `$feature/<experiment-name>` or instance group metadata in experiment stores. `usePostHog().init()` calls `posthog.group()` centrally so the PostHog web SDK associates subsequent frontend events with the instance group.
## PostHog flag lookup

View File

@ -1,4 +1,5 @@
---
name: n8n:human-like-code-review
description: Reviews a GitHub pull request like a thoughtful human reviewer and writes the feedback to a markdown file. Prioritizes bugs, behavioral regressions, security issues, and missing tests, ordered by severity. Use when given a PR URL to review, or when the user says /human-like-code-review.
allowed-tools: Bash(gh:*), Bash(git:*), Read, Glob, Grep
---

View File

@ -26,12 +26,12 @@ Start work on Linear issue **$ARGUMENTS**
This skill depends on external tools. Before proceeding, verify availability:
**Required:**
- **Linear MCP** (`mcp__linear`): Must be connected. Without it the skill cannot function at all.
- **Linear MCP**: Must be connected. Without it the skill cannot function at all.
- **GitHub CLI** (`gh`): Must be installed and authenticated. Run `gh auth status` to verify. Used to fetch linked PRs and issues.
**Optional (graceful degradation):**
- **Notion MCP** (`mcp__notion`): Needed only if the issue links to Notion docs. If unavailable, note the Notion links in the summary and tell the user to check them manually.
- **Loom transcript skill** (`/loom-transcript`): Needed only if the issue contains Loom videos. If unavailable, note the Loom links in the summary for the user to watch.
- **Notion MCP**: Needed only if the issue links to Notion docs. If unavailable, note the Notion links in the summary and tell the user to check them manually.
- **Loom transcript skill**: Needed only if the issue contains Loom videos. If unavailable, note the Loom links in the summary for the user to watch.
- **curl**: Used to download images. Almost always available; if missing, skip image downloads and note it.
If a required tool is missing, stop and tell the user what needs to be set up before continuing.
@ -42,11 +42,11 @@ Follow these steps to gather comprehensive context about the issue:
### 1. Fetch the Issue and Comments from Linear
Use the Linear MCP tools to fetch the issue details and comments together:
Use the Linear MCP tools available in the active harness to fetch the issue details and comments together:
- Use `mcp__linear__get_issue` with the issue ID to get full details including attachments
- Fetch the issue by ID to get full details including attachments
- Include relations to see blocking/related/duplicate issues
- **Immediately after**, use `mcp__linear__list_comments` with the issue ID to fetch all comments
- **Immediately after**, fetch all comments for the issue ID
Both calls should be made together in the same step to gather the complete context upfront.
@ -88,14 +88,14 @@ After fetching the issue, immediately check its labels:
1. Scan the issue description AND all comments for Loom URLs (loom.com/share/...)
2. For EACH Loom video found (in description or comments):
- Use the `/loom-transcript` skill to fetch the FULL transcript
- Use the Loom transcript skill to fetch the FULL transcript
- Summarize key points, timestamps, and any demonstrated issues
3. Loom videos often contain crucial reproduction steps and context that text alone cannot convey
### 4. Fetch Related Context
**Related Linear Issues:**
- Use `mcp__linear__get_issue` for any issues mentioned in relations (blocking, blocked by, related, duplicates)
- Use the Linear MCP issue-fetching tool for any issues mentioned in relations (blocking, blocked by, related, duplicates)
- Summarize how they relate to the main issue
**GitHub PRs and Issues:**
@ -105,7 +105,7 @@ After fetching the issue, immediately check its labels:
- Download images attached to issues: `curl -H "Authorization: token $(gh auth token)" -L <image-url> -o image.png`
**Notion Documents:**
- If Notion links are present, use `mcp__notion__notion-fetch` with the Notion URL or page ID to retrieve document content
- If Notion links are present, use the Notion MCP fetch tool with the Notion URL or page ID to retrieve document content
- Summarize relevant documentation
### 5. Review Comments

View File

@ -1,11 +1,11 @@
---
name: n8n:spec-driven-development
description: Keeps implementation and specs in sync. Use when working on a feature that has a spec in .claude/specs/, when the user says /spec, or when starting implementation of a documented feature. Also use when the user asks to verify implementation against a spec or update a spec after changes.
description: Keeps implementation and specs in sync. Use when working on a feature that has a spec in .agents/specs/, when the user says /spec, or when starting implementation of a documented feature. Also use when the user asks to verify implementation against a spec or update a spec after changes.
---
# Spec-Driven Development
Specs live in `.claude/specs/`. They are the source of truth for architectural
Specs live in `.agents/specs/`. They are the source of truth for architectural
decisions, API contracts, and implementation scope. Implementation and specs
must stay in sync — neither leads exclusively.
@ -17,10 +17,10 @@ Read spec → Implement → Verify alignment → Update spec or code → Repeat
## Before Starting Work
1. **Find the spec.** Search `.claude/specs/` for files matching the feature:
1. **Find the spec.** Search `.agents/specs/` for files matching the feature:
```bash
ls .claude/specs/
ls .agents/specs/
```
2. **Read the full spec.** Understand scope, decisions, API contracts, and
@ -63,7 +63,7 @@ Run a spec verification pass:
## Spec File Conventions
- One or more markdown files per feature in `.claude/specs/`.
- One or more markdown files per feature in `.agents/specs/`.
- Keep specs concise. Use tables for mappings, code blocks for shapes.
- Use `## Implementation TODO` with checkboxes to track progress.
- Split into multiple files when it helps (e.g. separate backend/frontend),

21
.aikido Normal file
View File

@ -0,0 +1,21 @@
# Aikido scan configuration
# https://help.aikido.dev/code-scanning/scanning-practices/ignore-via-code-with-aikido-files
# Path matching is plain substring against the full file path (no globs),
# so fragment entries below act as suffix/segment wildcards.
exclude:
paths:
# Machine-generated instance-AI expectation traces. Contain ephemeral
# credential reference IDs (nanoids) that re-trip the secrets scanner
# on every regeneration. No secret material.
- packages/testing/playwright/expectations
# Test code repo-wide. Fixtures routinely contain synthetic PEM blocks,
# basic-auth strings, and deliberately fake keys (including the secret
# scrubber's own tests). Verified 0/20 true-positive rate to date.
- .test.ts
- __tests__/
- /test/
# Test fixtures by convention.
- .fixture.ts

View File

@ -2,9 +2,9 @@
This directory contains shared Claude Code configuration for the n8n team.
All skills, agents, and commands live under the `n8n` plugin at
`.claude/plugins/n8n/` for `n8n:` namespacing. See
[plugin README](plugins/n8n/README.md) for full details.
Agents and commands live under the `n8n` plugin at `.claude/plugins/n8n/` for
`n8n:` namespacing. Shared skills are sourced from `.agents/skills/` and linked
into the plugin. See [plugin README](plugins/n8n/README.md) for full details.
## Setup

View File

@ -1,12 +1,12 @@
{
"name": "n8n",
"owner": {
"name": "n8n"
},
"plugins": [
{
"name": "n8n",
"source": "./"
}
]
"name": "n8n",
"owner": {
"name": "n8n"
},
"plugins": [
{
"name": "n8n",
"source": "./"
}
]
}

View File

@ -1,5 +1,5 @@
{
"name": "n8n",
"version": "0.2.0",
"description": "n8n Claude Code plugin — shared skills, commands, and agents for n8n development"
"name": "n8n",
"version": "0.2.0",
"description": "n8n Claude Code plugin — shared skills, commands, and agents for n8n development"
}

View File

@ -1,8 +1,8 @@
# n8n Claude Code Plugin
Shared skills, commands, and agents for n8n development. All items are
namespaced under `n8n:` to avoid collisions with personal or third-party
plugins.
Shared commands and agents for n8n development, plus Claude Code skill links.
All plugin items are namespaced under `n8n:` to avoid collisions with personal
or third-party plugins.
## Usage
@ -15,6 +15,17 @@ plugin directory. Everything gets the `n8n:` namespace prefix automatically.
| Command | `commands/plan.md` | `/n8n:plan PAY-XXX` |
| Agent | `agents/developer.md` | `n8n:developer` |
Shared skill sources live in `.agents/skills/`. Most entries under
`.claude/plugins/n8n/skills/` are symlinks to those shared sources. Claude-only
skills or overrides remain real directories in this plugin path.
> **Requires symlink support.** These shared-skill entries are git symlinks. On
> Windows, check out with symlinks enabled (`git config core.symlinks true`,
> plus Developer Mode or WSL) — otherwise git writes them as plain text stubs
> and Claude Code fails to load the affected skills. `node
> scripts/sync-agent-skill-links.mjs --check` flags stubs with an actionable
> error.
## Plugin Structure
```
@ -27,10 +38,14 @@ plugin directory. Everything gets the `n8n:` namespace prefix automatically.
├── commands/
│ └── <name>.md # → /n8n:<name> command
├── skills/
│ └── <name>/SKILL.md # → n8n:<name> skill
│ ├── <shared> -> ../../../../.agents/skills/<shared>
│ └── <claude-only>/SKILL.md
└── README.md
```
Run `pnpm sync:skill-links` after adding or removing shared skills. Run
`pnpm check:skill-links` before submitting changes.
## Design Decisions
### Why a plugin instead of standalone skills?
@ -39,8 +54,7 @@ To get the `n8n:` namespace prefix, avoiding collisions with personal or
third-party plugins. Claude Code only supports colon-namespaced items through
the plugin system — standalone `.claude/skills/` entries cannot be namespaced.
### Known Issues
### Skill Frontmatter
- Plugin skill namespacing requires omitting the `name` field from SKILL.md
frontmatter due to a [Claude Code bug](https://github.com/anthropics/claude-code/issues/17271).
The directory name is used as the skill identifier instead.
Shared skills keep `name: n8n:<name>` in `SKILL.md` frontmatter for cross-agent
discovery. Do not remove shared skill names when linking them into this plugin.

View File

@ -0,0 +1 @@
../../../../.agents/skills/community-pr-readiness-check

View File

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

View File

@ -0,0 +1 @@
../../../../.agents/skills/conventions

View File

@ -0,0 +1 @@
../../../../.agents/skills/create-community-node-lint-rule

View File

@ -0,0 +1 @@
../../../../.agents/skills/create-issue

View File

@ -0,0 +1 @@
../../../../.agents/skills/create-pr

View File

@ -0,0 +1 @@
../../../../.agents/skills/create-skill

View File

@ -0,0 +1 @@
../../../../.agents/skills/db-migrations

View File

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

View File

@ -0,0 +1 @@
../../../../.agents/skills/experiments

View File

@ -0,0 +1 @@
../../../../.agents/skills/human-like-code-review

View File

@ -0,0 +1 @@
../../../../.agents/skills/linear-issue

View File

@ -0,0 +1 @@
../../../../.agents/skills/loom-transcript

View File

@ -1,126 +0,0 @@
---
description: Run Stryker mutation testing on the source files changed in the current branch (vs origin/master) across vitest packages. One command for "did my work hold up under mutation?" before pushing. Triages which files dropped below threshold and offers to invoke n8n:mutant-fix on them. Use when the user says /mutant-diff, "mutate what I changed", "check my changes", or has just finished writing a feature and wants pre-merge feedback.
---
# Mutate what I changed
Closes the local dev loop. Single command to run Stryker against every changed source file the current branch touched (vs `origin/master`) in a mutation-eligible package, then point at any reds that need strengthening.
## When to use
- User says `/mutant-diff`, "mutate the files I changed", "check my changes", "did my tests stick"
- Mid-feature: dev wants pre-merge feedback before pushing
- Pre-PR: cheaper than waiting for the nightly cron
**Don't** use:
- For a single specific file (`/n8n:mutant-score <path>` is faster)
- After the user already ran `/n8n:mutant-fix` (which calls mutant-score internally for verification — running both again is wasted compute)
Changed files in **jest** packages (`nodes-base`, `cli`, `db`, …) and in `@n8n/expression-runtime` are skipped automatically — see step 1.
## Inputs
- **Default base**: `origin/master`. Override with `--base <ref>` if comparing against another branch (e.g. `--base HEAD~5`).
- **Default scope**: `packages/**/src/**/*.ts`, narrowed to mutation-eligible (vitest) packages in step 1.
## Steps
### 1. Identify changed, eligible source files
```bash
git diff --name-only origin/master...HEAD -- 'packages/**/src/**/*.ts'
```
(`...` is correct — three-dot means "since the branch diverged from base," which is what we want.)
If `git fetch` hasn't been run recently, suggest the user `git fetch origin master` first; otherwise the base ref is stale.
Filter out:
- `**/*.d.ts` (declarations, no behaviour)
- `**/*.stories.ts` (Storybook scaffolding)
- `index.ts` files (barrels)
- `interfaces.ts`, `types.ts`, `constants.ts` (low-value, same filter the picker uses)
- **files in non-eligible packages**: for each changed file, find its package (nearest ancestor dir with a `package.json`). Keep the file only if that package has a `vitest.config.*` **and** is not `@n8n/expression-runtime` (it's the isolated-vm engine — blocked on DEVP-257). Drop jest packages with a one-line note: `skipped (jest): packages/cli/src/...`.
### 2. Surface the plan to the user
Print the filtered list before running anything. Each Stryker run is 1–5 minutes; the user should confirm if there are many.
```
Found N changed source files to mutate (J skipped: jest / expression-runtime):
- packages/workflow/src/foo.ts
- packages/@n8n/crdt/src/bar.ts
...
Estimated runtime: ~M-K minutes (M minutes minimum if every Stryker run is fast).
Proceed? (skill default: yes if N ≤ 3, ask if N > 3)
```
If the filtered list is empty: report "No mutation-eligible source files changed vs $base — nothing to mutate." and stop. Exit cleanly.
If N > 8: refuse and ask the user to narrow scope (a different base ref, or invoke per-file). Running 8+ mutations sequentially is a 30+ minute session that should be a deliberate choice.
### 3. Run mutation testing per file
For each file in the plan, invoke `pnpm mutate <repo-relative-path>` (the package is inferred from the path). Capture the score per file from the `✓ / ✗` summary line mutate.mjs prints to stderr — don't re-read `summary.json` (it's overwritten per run, and lives in that file's own package).
After each run completes, print one line:
```
✓ packages/workflow/src/foo.ts 95.12% (39/41 killed) GREEN
✗ packages/@n8n/crdt/src/bar.ts 54.83% (17/31 killed) RED — 13 survivors, top: ConditionalExpression, EqualityOperator
```
If a Stryker run hard-fails (exit 3, no `summary.json`), print `! <file> Stryker failed — see stderr` and continue to the next file. Don't abort the whole batch.
### 4. Summary table
After all files have been mutated, print one compact table:
```
=== Mutation results: N files, M green, K red, J failed ===
| File | Score | Verdict | Survivors |
|---------------------------------|---------|---------|-----------|
| packages/workflow/src/foo.ts | 95.12% | GREEN | 2 |
| packages/@n8n/crdt/src/bar.ts | 54.83% | RED | 13 |
| packages/workflow/src/baz.ts | n/a | FAILED | - |
```
### 5. Offer the strengthen step on the worst red file
If any file came back red:
> The lowest-score red file is `packages/@n8n/crdt/src/bar.ts` (54.83%, 13 survivors). Run `/n8n:mutant-fix <file>` to triage them and write assertion changes? (suggesting; don't auto-invoke)
Only suggest one file at a time — `n8n:mutant-fix` caps at 5 survivors per invocation, and re-running this skill after edits is cheap.
If everything is green: report it and stop. No follow-up needed.
## Output shape
Three deliverable sections per invocation:
1. **Plan** (before running) — list of eligible files (+ skipped count), estimated runtime
2. **Per-file progress** (during) — one line per file as it completes
3. **Summary table + recommendation** (after) — compact view
Don't dump full `summary.json` payloads — each mutate run writes one to `<package>/reports/mutation/` (overwritten per run). The user can read the latest one if they want detail.
## Constraints
- **Vitest packages only.** Jest packages and `@n8n/expression-runtime` are filtered out in step 1, not mutated.
- **Max 8 files per invocation.** Above that, ask user to narrow.
- **Don't auto-invoke `/n8n:mutant-fix`.** Suggest, don't act. Same reasoning as the other skills: each pass should be a deliberate human-approved step.
- **No commits.** Edits land in working tree; user reviews.
- **No fabricated scores.** If a Stryker run fails, mark FAILED in the table — never guess a value.
## Common follow-ups
- "strengthen them all" → loop the user through `/n8n:mutant-fix`, one file at a time
- "what changed?" → `git diff origin/master...HEAD -- <file>` for the file in question
- "ignore <pattern>" → re-run with the user's exclude added to the filter
## Related
- `n8n:mutant-score` — single-file version of this skill
- `n8n:mutant-fix` — the natural next step when reds show up

View File

@ -1,151 +0,0 @@
---
description: Take a Stryker summary.json (from n8n:mutant-score), triage the surviving mutants by user-reachable-behaviour risk, write minimal assertion changes to kill the top 3-5 highest-leverage survivors, then verify by re-running n8n:mutant-score. Use when the user has just run mutation testing and wants to strengthen the test suite, or says "kill the survivors / strengthen tests / fix the red." Pairs with n8n:mutant-score as the inner write side of a single iteration.
---
# Strengthen tests — kill the highest-leverage survivors
The other half of the local mutation-testing loop. `n8n:mutant-score` reports which mutations escaped the tests; this skill picks the ones that matter and writes minimal assertion changes to kill them.
## When to use
- User has just run `/n8n:mutant-score <file>` and the verdict was `red`
- User says: "strengthen tests", "kill the survivors", "fix the red", "iterate on the tests for X"
- Mid-loop: this skill's verify step calls `n8n:mutant-score` again, so the loop closes here
**Don't** use this skill:
- For a `green` verdict — there's nothing to strengthen; if user insists, push back and ask which file actually needs work
- To bulk-kill every survivor — explicitly capped at 5 per invocation. Re-invoke for more.
## Inputs
Accepts **either** a source file or an existing summary — whichever you have:
- **A source file** (a repo-relative path, e.g. `packages/workflow/src/workflow-checksum.ts` — package inferred): self-bootstrapping — there's no summary yet, so step 1 runs `n8n:mutant-score` to produce one, then proceeds. This is the entry point for unattended callers (e.g. cat-bot acting on a ledger gap).
- **An existing summary**: `--summary <path>`. A prior `n8n:mutant-score` run wrote it to that package's `reports/mutation/summary.json` (e.g. `packages/workflow/reports/mutation/summary.json`). Skips the bootstrap.
## Steps
### 1. Get a summary (bootstrap if needed)
- **Given a source file** (or no usable summary on disk): run `n8n:mutant-score` on the file first to generate `<package-dir>/reports/mutation/summary.json`. Then read it.
- **Given/defaulting to a summary path**: read it directly.
Read the summary (already compact, ~50 KB) and pull:
- `files[0].file` — the source file under test
- `files[0].score` — current mutation score
- `files[0].survivors[]` — every surviving (and no-coverage) mutant with location, replacement, covering test names
If the verdict is already `green`, stop — nothing to strengthen.
### 2. Read the source under test, sparingly
Read the source file referenced in `summary.json`. Read **once**, the whole file (typical source files are 50-500 lines; the cost is bounded). This is the only file read; don't load test files yet.
### 3. Triage the survivors
Categorise each survivor into one of three buckets. Use the rubric below — don't apply it mechanically, but lean on it.
**HIGH leverage — "real regression vector":** mutant guards behaviour that real users actually hit through the public API surface.
- Type checks against shapes user data routinely takes: `null`, `undefined`, `Date`, `Buffer`, `Uint8Array`, `Array`, plain objects with arbitrary keys
- Conditional branches that gate critical fall-through (e.g. "if this returns early, the wrong code path runs")
- Code paths that handle user-controlled flow: expressions, Code-node behaviour, binary item handling, deep-clone semantics
- Mutants on guard clauses that prevent crashes (`if (value == null) return ...`)
**MODERATE leverage — "user-observable invariant":** real but lower-frequency user impact.
- Proxy traps that change `Object.keys` / `in` / `hasOwnProperty` semantics after mutation
- Set-then-delete / re-set-after-delete sequences (Code-node assignment patterns)
- Treating `undefined` assignment as deletion (`obj.foo = undefined` → key removed)
- Edge cases that only fire under specific iteration patterns
**LOW leverage — "refactor insurance" or noise:** skip these. Document the skip in the output but don't write tests for them.
- Constructor property checks (`arr.constructor === Array`) unless production code is known to rely on them
- Idempotency invariants only triggered by other library code (Lodash, native spread)
- Equivalent mutants — mutations that produce semantically identical code (e.g. swapping an unused conditional)
- Mutants on internal helpers users can't reach through any public API
If the summary lists `noCoverage` survivors, treat them as their own bucket: "the test suite doesn't even execute these lines." Triage them by the same rubric, but flag separately since they need a new test case rather than an assertion extension.
### 4. Pick the work set: up to 5 mutants
Order: all HIGH first, then MODERATE if budget remains, never LOW. Hard cap at 5 total. If HIGH alone exceeds 5, pick the 5 most distinct (don't pick 5 mutants on the same line — they probably share a fix; pick representatives across the file).
If fewer than 3 HIGH+MODERATE candidates exist, just do what's there. Don't pad with LOW just to hit a number.
Write up the work set to the user **before editing**:
```
Picked N survivors to address (M skipped as refactor-insurance / low-leverage):
1. [HIGH] location — original → replacement
plan: assert <X> in <covering test name>
2. [HIGH] ...
3. [MODERATE] ...
...
```
This is the user's chance to redirect. Don't write code yet.
### 5. Read covering tests for the picked survivors
For each picked survivor, the summary lists the test names that covered the line. Find those tests in the test file (usually `packages/<pkg>/test/<source-basename>.test.ts`, but check) and read **just the relevant `test('...')` blocks** — not the whole file. Use `Grep` + `Read` with line offsets to keep token cost down.
Goal: understand what the existing test asserts so the new assertion is additive, not contradictory.
### 6. Write the changes
Constraints:
- **Prefer extending an existing covering test** over adding a new one. Lower file churn, easier review.
- **Match the existing style** — same assertion library, same matcher idioms (`.toBe` vs `.toEqual` vs `.toStrictEqual`).
- **Minimal additions** — one or two assertions per mutant, not a new it-block per mutant.
- **No fabrication** — only assert what the source code actually does. If you can't tell from the source, stop and ask the user.
- **For `noCoverage` survivors**, add a new test case named after the behaviour being pinned. Place it next to related tests.
Use `Edit` with exact-string matches. Never rewrite entire test files.
### 7. Verify
Re-invoke `n8n:mutant-score` on the same source file. Report:
```
Before: red 76.74% (28 survivors)
After: green 82.34% (22 survivors)
Killed: 6 of 5 targeted (1 bonus — fix for #77 also killed #78)
Still surviving: 22 — re-invoke /n8n:mutant-fix for another batch.
```
If the score went UP but threshold still not met: the iteration is working, recommend another pass.
If the score went DOWN or stayed the same: at least one new test isn't asserting what we think it asserts. Surface the diff to the user and stop — do not auto-revert.
If a test now fails (not survives — actually fails): we asserted something the code doesn't do. Revert that specific assertion, leave the rest, report which one was wrong.
## Output shape
Each invocation produces:
1. **Work plan** (before edits) — the picked survivors and the plan for each
2. **Diffs** (during edits) — Edit tool calls, visible in transcript
3. **Verify** (after) — re-run + before/after comparison
Keep prose minimal between sections. The plan and verify steps are the structured outputs; everything else is mechanical.
## Constraints
- **5 mutants max per invocation.** Re-invoke for more. Prevents runaway sessions on 30-survivor files.
- **Never fabricate assertions.** If the source doesn't clearly do X, don't claim it does.
- **No new test files unless absolutely necessary.** Extend the existing covering test file.
- **No reverting other people's tests.** Only edit tests in the package being mutated.
- **No re-running mutant-score more than once per invocation.** That's the verify step. Don't loop within a single invocation; let the user re-invoke.
- **No commits.** Edits land in the working tree; user reviews and commits.
## Common follow-ups
- User says "go again" → re-invoke this skill. The summary.json now reflects the post-edit state.
- User says "why was #N classified as LOW?" → explain the rubric application for that specific mutant, no re-triage of others.
- User says "kill #N specifically" → override the triage for that mutant, treat it as picked.
- User says "skip the verify step" → don't; the verify step is the contract that the edits actually moved the score.
## Related
- `n8n:mutant-score` — the read side of this loop
- `scripts/mutation-health/README.md` — the BQ-backed observability story this slots into

View File

@ -1,116 +0,0 @@
---
description: Run Stryker mutation testing on a single source file and return a structured, token-frugal report that's pipeable to a follow-up "strengthen tests" loop. Use when the user says /mutant-score, "mutation test this file", or has just edited tests and wants to verify they actually assert behaviour. Per-file only — full-package mutation runs are out of scope.
---
# Mutation testing — single file
Wraps `pnpm mutate <file>` and parses `summary.json` into a compact, structured shape suitable for downstream "strengthen the surviving mutants" iteration. Works for any vitest package — `pnpm mutate` infers the package from the path.
## When to use
- User explicitly invokes: `/mutant-score <path>`, "mutation test this file", "check my test effectiveness on X"
- User has just edited a test file and wants to know if their assertions are load-bearing
- Follow-up loop after a `red` verdict — feed the structured output back to a "fix" iteration
**Don't** use this skill for:
- Whole-package or whole-repo mutation runs — single file only
- Coverage % questions (use the existing coverage workflow)
- **jest** packages (`nodes-base`, `cli`, `db`) — Stryker's vitest-runner only covers vitest packages
- `@n8n/expression-runtime` — it's the isolated-vm engine (blocked on DEVP-257)
## Inputs
One required argument: the source file to mutate. Prefer a **repo-relative path** — the package is inferred from it:
- `packages/workflow/src/cron.ts` (package inferred)
- `packages/@n8n/crdt/src/utils.ts` (package inferred)
A bare package-relative path (`src/cron.ts`) is ambiguous — pass the repo-relative path, or add `--package-dir <pkg>`. Don't guess the package.
## Steps
1. **Resolve the target.** Any vitest package works; `pnpm mutate` infers the package from a repo-relative path. If the file is in a jest package or `@n8n/expression-runtime`, say so and stop — don't fabricate output.
2. **Run Stryker with trimmed output:**
```bash
pnpm mutate <repo-relative-file> 2>&1 | tail -40
```
`tail -40` discards the Stryker progress bar spam; the relevant numbers + survivor list always land in the last ~30 lines. Exit codes: `0` = pass, `1` = below threshold (still valid, summary.json exists), `2` = usage error, `3` = Stryker failure (no summary.json).
3. **If exit code 3**, surface the trimmed tail to the user, suggest checking that workspace deps are built (`pnpm build`), and stop. Don't fabricate a report.
4. **Read the package's `reports/mutation/summary.json`** (e.g. `packages/workflow/reports/mutation/summary.json`) — never `raw.json`. raw.json is 600KB+ and not needed for the strengthen loop. summary.json already contains every surviving mutant with its location, replacement, mutator name, and the names of tests that covered the line.
5. **Cap covering_tests at 3 per survivor.** If a mutant was covered by more than 3 tests, keep the first 3 and append `+N more` as a count. Names beyond 3 add tokens without adding actionable signal — the strengthen loop only needs to know *which test* to extend, not all of them.
6. **Compute `minimum_kills_needed`** to reach the threshold:
```
killed_now = summary.overall.counts.killed + summary.overall.counts.timeout
valid_total = killed_now + summary.overall.counts.survived + summary.overall.counts.noCoverage
needed = ceil((threshold/100) * valid_total) - killed_now
```
This tells the next loop the minimum number of survivors it has to kill to flip `red` → `green`. Cap at the number of survivors.
7. **Output the structured shape** described below. Keep prose to one headline line; the rest is the JSON block.
## Output shape
One headline line, then a fenced JSON block. Nothing else — no preamble, no per-survivor commentary, no risk triage (that's the next loop's job).
````
[red|green] <score>% (threshold <T>%) — <N> survivors; need to kill ≥<K> to flip green.
```json
{
"verdict": "red",
"target": "packages/workflow/src/augment-object.ts",
"package": "n8n-workflow",
"score": 76.74,
"threshold": 80,
"delta_to_threshold": 3.26,
"minimum_kills_needed": 5,
"counts": {
"killed": 99,
"survived": 28,
"no_coverage": 2,
"timeout": 0
},
"survivors": [
{
"id": "77",
"mutator": "ConditionalExpression",
"location": "src/augment-object.ts:95:6",
"original": "value === null",
"replacement": "false",
"covering_tests": [
"augmentObject should handle null values",
"augmentObject should handle nested nulls"
],
"covering_tests_overflow": 0
}
]
}
```
````
Order the survivors array by `location` (ascending line number, then column) so the strengthen loop processes them top-to-bottom of the file.
## Constraints
- **No raw.json** — never read or surface it. summary.json is the only input.
- **No HTML report** — don't `open` raw.html or paste links to it. If the user wants visual exploration they'll ask.
- **No automatic triage** — don't categorise survivors by "real bug" vs "refactor insurance." That's a separate analysis step that should happen on demand, not by default. Keeps token cost predictable.
- **No "I'll regenerate tests for you now"** — this skill reports the gap. Use `n8n:mutant-fix` if you want assertion edits.
## Common follow-ups (don't do unless asked)
- User says "fix these" → start a strengthen loop using the JSON output as input. Read covering_tests source, propose changes per mutant, run the skill again to verify.
- User says "explain survivor #N" → fetch that mutant from summary.json, show its surrounding ~5 lines from the source file, no analysis beyond what summary.json contains.
- User says "what's the threshold?" → 80% provisional; see `scripts/mutation-health/README.md` for the rationale.
- User says "run it on the changed files" → use `n8n:mutant-diff` (mutates the diff vs origin/master).
## Related
- `scripts/mutation-health/README.md` — the broader BQ-backed observability story
- `scripts/mutation-health/stryker.default.mjs` — the default Stryker config; a package may override with its own `stryker.config.mjs` (e.g. `packages/workflow` carves out the isolated-vm engine)
- `n8n:mutant-fix` — the strengthen-the-survivors counterpart

View File

@ -0,0 +1 @@
../../../../.agents/skills/node-add-oauth

View File

@ -0,0 +1 @@
../../../../.agents/skills/protect-endpoints

View File

@ -0,0 +1 @@
../../../../.agents/skills/reproduce-bug

View File

@ -0,0 +1 @@
../../../../.agents/skills/spec-driven-development

View File

@ -1 +0,0 @@
plugins/n8n/skills

File diff suppressed because it is too large Load Diff

29
.github/WORKFLOWS.md vendored
View File

@ -181,23 +181,26 @@ These only run if specific files changed:
| `packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/**` | `test-evals-python.yml` | any |
| `packages/@n8n/benchmark/**` | `build-benchmark-image.yml` | master |
| `packages/cli/src/public-api/**/*.{css,yaml,yml}` | `util-sync-api-docs.yml` | master |
| `packages/@n8n/instance-ai/src/**`, `packages/@n8n/instance-ai/evaluations/**`, `packages/cli/src/modules/instance-ai/**`, `packages/core/src/execution-engine/eval-mock-helpers.ts` | `ci-instance-ai-evals.yml` | on PR `opened` / `reopened` / `ready_for_review` |
### On PR Review
| Event | Workflow | Condition |
|----------------------------|-----------------------------|------------------------------------------------------|
| Review approved | `test-visual-chromatic.yml` | + design files changed |
| Review approved | `ci-instance-ai-evals.yml` | + Instance AI source/eval paths changed (see below) |
| Comment with `@claude` | `util-claude.yml` | mention in any comment |
| Any review | `util-notify-pr-status.yml` | not community-labeled |
**Why Instance AI evals fire on approval, not push:** the workflow eval is the most
expensive job in PR CI (LLM-bound builds against ~70 unique scenarios). Running it
on every push made cost untenable. With approval-only triggering, the eval acts as
a merge gate — fires when the reviewer approves; if it fails, branch protection blocks
the merge. `dismiss_stale_reviews_on_push: true` on master forces re-approval (and a
fresh eval) if the author pushes between approval and merge, so the gate stays honest.
The lighter `test-evals-discovery.yml` still runs on every push as part of ci-pull-requests.yml.
**Why Instance AI evals fire once per PR state-change, not per push:** the
workflow eval is the most expensive job in PR CI (LLM-bound builds). Running it
on every push made cost untenable; firing on every review approval cascaded
through the dismiss-stale-on-push → re-approve loop, which also blew up.
The current trigger fires once per `opened` / `reopened` / `ready_for_review`
on a PR touching the eval surface, and runs the `pr` test-case dataset (~6
high-reliability, capability-diverse cases) instead of the full ~14. To re-run
after pushing a fix, use the workflow's manual dispatch button — also lets you
override `tier` to `full` for broader coverage on a specific PR. The lighter
`test-evals-discovery.yml` still runs on every push as part of `ci-pull-requests.yml`.
### On PR Close/Merge
@ -362,8 +365,8 @@ Runs on push to `master` or `1.x`:
```
Push to master/1.x
├─ build-github (populate cache)
├─ unit-test (matrix: Node 22.x, 24.15.0, 26.x)
│ └─ Coverage only on 24.15.0
├─ unit-test (matrix: Node 22.x, 24.16.0, 26.x)
│ └─ Coverage only on 24.16.0
├─ lint
└─ notify-on-failure (Slack #alerts-build)
```
@ -403,7 +406,7 @@ Composite actions in `.github/actions/`:
```yaml
inputs:
node-version: # default: '24.15.0'
node-version: # default: '24.16.0'
enable-docker-cache: # default: 'false' (Blacksmith Buildx)
build-command: # default: 'pnpm build'
```
@ -506,7 +509,7 @@ Team ownership mappings in `CODEOWNERS`:
| `blacksmith-2vcpu-ubuntu-2204` | 2 | Standard builds, E2E shards |
| `blacksmith-4vcpu-ubuntu-2204` | 4 | Unit tests, typecheck, lint |
| `blacksmith-8vcpu-ubuntu-2204` | 8 | Heavy parallel workloads |
| `blacksmith-4vcpu-ubuntu-2204-arm` | 4 | ARM64 Docker builds |
| `blacksmith-8vcpu-ubuntu-2204-arm` | 8 | ARM64 Docker builds |
### Selection Guidelines
@ -574,7 +577,7 @@ Supply chain security ensures artifacts haven't been tampered with. We provide t
- **Runs on:** stable/nightly/rc Docker builds
- **Scans:** n8n image, runners image
- **Output:** Slack `#updates-security` when vulnerabilities are detected
- **Output:** GitHub Actions step summary (`$GITHUB_STEP_SUMMARY`) and run logs
### SBOM

View File

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

View File

@ -4,7 +4,14 @@ import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { matchGlob, parseFilters, evaluateFilter, runValidate, getChangedFiles, getMergeBase } from '../ci-filter.mjs';
import {
matchGlob,
parseFilters,
evaluateFilter,
runValidate,
getChangedFiles,
getMergeBase,
} from '../ci-filter.mjs';
// --- matchGlob ---
@ -43,7 +50,9 @@ describe('matchGlob', () => {
});
it('scoped package pattern does not match other packages', () => {
assert.ok(!matchGlob('packages/@n8n/config/src/index.ts', 'packages/@n8n/task-runner-python/**'));
assert.ok(
!matchGlob('packages/@n8n/config/src/index.ts', 'packages/@n8n/task-runner-python/**'),
);
});
it('* matches single-level only', () => {
@ -147,10 +156,7 @@ describe('evaluateFilter', () => {
});
it('mixed python and non-python returns true', () => {
const files = [
'packages/@n8n/task-runner-python/src/main.py',
'packages/cli/src/index.ts',
];
const files = ['packages/@n8n/task-runner-python/src/main.py', 'packages/cli/src/index.ts'];
const patterns = ['**', '!packages/@n8n/task-runner-python/**'];
assert.equal(evaluateFilter(files, patterns), true);
});
@ -186,7 +192,11 @@ describe('evaluateFilter', () => {
it('last matching pattern wins (gitignore semantics)', () => {
const files = ['packages/@n8n/task-runner-python/src/main.py'];
const patterns = ['**', '!packages/@n8n/task-runner-python/**', 'packages/@n8n/task-runner-python/**'];
const patterns = [
'**',
'!packages/@n8n/task-runner-python/**',
'packages/@n8n/task-runner-python/**',
];
assert.equal(evaluateFilter(files, patterns), true);
});
});
@ -321,6 +331,113 @@ describe('getChangedFiles', () => {
});
});
// --- getChangedFiles deep merge-base (shallow clone, stale base) ---
describe('getChangedFiles (shallow clone, stale base)', () => {
const builderDir = mkdtempSync(join(tmpdir(), 'ci-filter-build-'));
const remoteDir = mkdtempSync(join(tmpdir(), 'ci-filter-remote-shallow-'));
const repoDir = mkdtempSync(join(tmpdir(), 'ci-filter-shallow-'));
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);
// Common ancestor (the merge base the PR diverged from)
writeFileSync(join(builderDir, 'shared.ts'), 'shared\n');
git(['add', '.'], builderDir);
git(['commit', '-m', 'root'], builderDir);
git(['push', 'origin', 'main'], builderDir);
// PR branch off root, adds a file
git(['checkout', '-b', 'pr-branch'], builderDir);
writeFileSync(join(builderDir, 'pr-only.ts'), 'pr\n');
git(['add', '.'], builderDir);
git(['commit', '-m', 'PR change'], builderDir);
git(['push', 'origin', 'pr-branch'], builderDir);
// master drifts forward, burying the merge base behind several commits
git(['checkout', 'main'], builderDir);
for (let i = 1; i <= 6; i++) {
writeFileSync(join(builderDir, 'shared.ts'), `shared\ndrift ${i}\n`);
git(['commit', '-am', `drift ${i}`], builderDir);
}
git(['push', 'origin', 'main'], builderDir);
// Shallow checkout of the PR side, mirroring CI's depth-1 clone
git(['clone', '--depth=1', '--branch', 'pr-branch', `file://${remoteDir}`, repoDir], originalCwd);
git(['config', 'user.email', 'test@test.local'], repoDir);
git(['config', 'user.name', 'test'], repoDir);
// Tiny step so the deepen loop has to iterate to reach the merge base
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('resolves PR-only files when the merge base is beyond the shallow boundary', () => {
assert.equal(git(['rev-parse', '--is-shallow-repository'], repoDir), 'true');
const changed = getChangedFiles('main');
assert.deepEqual(changed, ['pr-only.ts']);
});
});
// --- getChangedFiles unrelated histories ---
describe('getChangedFiles (unrelated histories)', () => {
const remoteDir = mkdtempSync(join(tmpdir(), 'ci-filter-remote-unrelated-'));
const repoDir = mkdtempSync(join(tmpdir(), 'ci-filter-unrelated-'));
const originalCwd = process.cwd();
const git = (args: string[], cwd: string = repoDir) =>
execFileSync('git', args, { cwd, stdio: 'pipe' }).toString().trim();
before(() => {
execFileSync('git', ['init', '--bare', '-b', 'main', remoteDir], { stdio: 'pipe' });
git(['init', '-b', 'main'], repoDir);
git(['config', 'user.email', 'test@test.local']);
git(['config', 'user.name', 'test']);
git(['remote', 'add', 'origin', remoteDir]);
// Remote main has its own root
writeFileSync(join(repoDir, 'main.ts'), 'main\n');
git(['add', '.']);
git(['commit', '-m', 'main root']);
git(['push', 'origin', 'main']);
// Local HEAD is an orphan branch sharing no ancestor with main
git(['checkout', '--orphan', 'feature']);
git(['rm', '-q', '-rf', '.']);
writeFileSync(join(repoDir, 'feature.ts'), 'feature\n');
git(['add', '.']);
git(['commit', '-m', 'feature root']);
process.chdir(repoDir);
});
after(() => {
process.chdir(originalCwd);
rmSync(remoteDir, { recursive: true, force: true });
rmSync(repoDir, { recursive: true, force: true });
});
it('throws once history is exhausted with no common ancestor', () => {
assert.throws(() => getChangedFiles('main'), /unrelated histories/);
});
});
// --- runValidate ---
describe('runValidate', () => {
@ -330,7 +447,9 @@ describe('runValidate', () => {
let exitCode: number | null = null;
process.env.INPUT_JOB_RESULTS = JSON.stringify(jobResults);
process.exit = ((code: number) => { exitCode = code; }) as never;
process.exit = ((code: number) => {
exitCode = code;
}) as never;
try {
runValidate();
@ -343,42 +462,57 @@ describe('runValidate', () => {
}
it('passes when all jobs succeed', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'success' },
typecheck: { result: 'success' },
lint: { result: 'success' },
}), null);
assert.equal(
runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'success' },
typecheck: { result: 'success' },
lint: { result: 'success' },
}),
null,
);
});
it('passes when some jobs are skipped (filtered out)', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'success' },
'security-checks': { result: 'skipped' },
}), null);
assert.equal(
runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'success' },
'security-checks': { result: 'skipped' },
}),
null,
);
});
it('fails when a job fails', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'failure' },
typecheck: { result: 'success' },
}), 1);
assert.equal(
runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'failure' },
typecheck: { result: 'success' },
}),
1,
);
});
it('fails when a job is cancelled', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'cancelled' },
}), 1);
assert.equal(
runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'cancelled' },
}),
1,
);
});
it('fails when multiple jobs have problems', () => {
assert.equal(runWithResults({
'unit-test': { result: 'failure' },
typecheck: { result: 'cancelled' },
lint: { result: 'success' },
}), 1);
assert.equal(
runWithResults({
'unit-test': { result: 'failure' },
typecheck: { result: 'cancelled' },
lint: { result: 'success' },
}),
1,
);
});
});

View File

@ -1,5 +1,5 @@
import { execSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { appendFileSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
@ -104,7 +104,7 @@ 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.
execSync(`git fetch --no-tags --prune --deepen=200 origin ${baseRef}`, { stdio: 'pipe' });
fetchUntilMergeBase(baseRef);
const output = execSync('git diff --name-only --merge-base FETCH_HEAD HEAD', {
encoding: 'utf-8',
});
@ -114,6 +114,71 @@ export function getChangedFiles(baseRef) {
.filter(Boolean);
}
/**
* Deepen the shallow clone until the merge base between the fetched base ref
* (FETCH_HEAD) and HEAD 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
* window (doubling each round) until the merge base resolves, or until the full
* history has been fetched — at which point no common ancestor means the
* histories are unrelated.
*/
function fetchUntilMergeBase(baseRef) {
let step = Number(process.env.CI_FILTER_DEEPEN_STEP) || 200;
execSync(`git fetch --no-tags --prune --deepen=${step} origin ${baseRef}`, { stdio: 'pipe' });
while (!hasReliableMergeBase()) {
if (!isShallow()) {
throw new Error(
`No merge base between FETCH_HEAD and HEAD after fetching the full history of "${baseRef}" (unrelated histories).`,
);
}
step *= 2;
execSync(`git fetch --no-tags --prune --deepen=${step} origin ${baseRef}`, { stdio: 'pipe' });
}
}
function isShallow() {
return (
execSync('git rev-parse --is-shallow-repository', { encoding: 'utf-8' }).trim() === 'true'
);
}
/**
* True when a merge base exists AND does not sit on the shallow boundary.
* In a shallow repo `git merge-base` can return a grafted boundary commit whose
* sub-history is truncated; that result is unreliable, so we must deepen further
* before trusting it.
*/
function hasReliableMergeBase() {
let base;
try {
base = execSync('git merge-base FETCH_HEAD HEAD', { encoding: 'utf-8' }).trim();
} catch {
return false; // no common ancestor reachable yet
}
if (!base) return false;
return !readShallowBoundaries().has(base);
}
function readShallowBoundaries() {
try {
const shallowPath = execSync('git rev-parse --git-path shallow', {
encoding: 'utf-8',
}).trim();
const content = readFileSync(shallowPath, 'utf-8');
return new Set(
content
.split('\n')
.map((l) => l.trim())
.filter(Boolean),
);
} catch {
return new Set(); // no shallow file => complete repo
}
}
/**
* Resolve the merge-base SHA between FETCH_HEAD and HEAD.
* Used to give downstream tools (e.g. janitor's AST diff) a stable, PR-only

View File

@ -7,9 +7,9 @@ description: 'Configures Node.js with pnpm, installs Aikido SafeChain for supply
inputs:
node-version:
description: 'Node.js version to use. Pinned to 24.15.0 by default for reproducible builds.'
description: 'Node.js version to use. Pinned to 24.16.0 by default for reproducible builds.'
required: false
default: '24.15.0'
default: '24.16.0'
enable-docker-cache:
description: 'Whether to set up Blacksmith Buildx for Docker layer caching (Blacksmith runners only).'
required: false
@ -95,7 +95,7 @@ runs:
# docker-cluster, unit, lint, typecheck, ...) hit the cache instead of
# the GH release CDN. Layered with retry below — cache reduces blast
# radius across jobs, retry covers the first-job-per-key case where the
# CDN must be hit. Keep the version in sync with the install step below.
# 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
@ -103,7 +103,12 @@ runs:
path: ~/.safe-chain/bin
key: safe-chain-1.5.7-${{ runner.os }}-${{ runner.arch }}
- name: Install Aikido SafeChain
# Download + install the SafeChain binary. This is the expensive,
# CDN-backed part and the only part worth gating on the cache. The install
# script also runs `setup-ci`, so on a cache miss this both downloads AND
# activates; the Activate step below then re-runs `setup-ci` idempotently.
- name: Download Aikido SafeChain
if: steps.cache-safe-chain.outputs.cache-hit != 'true'
run: |
VERSION="1.5.7"
EXPECTED_SHA256="07ab512fd8795ce41b2275be369aced4c9a93cc7bca9b397951507891a955239"
@ -112,48 +117,142 @@ runs:
echo "${EXPECTED_SHA256} install-safe-chain.sh" | sha256sum -c -
# Wrap the install in retry too — it internally fetches the
# safe-chain binary from the GH release CDN, which has hit
# transient 404s. The install script no-ops the binary download
# when the cached binary is already present.
# transient 404s.
node .github/scripts/retry.mjs --attempts 3 --delay 10 -- \
sh install-safe-chain.sh --ci
rm install-safe-chain.sh
shell: bash
# Capture pnpm's combined stdout+stderr through `tee` so the ERR_PNPM_* code
# and offending package survive even when `timeout` terminates the process
# group. Stderr-only capture misses the failure footer because pnpm's
# default reporter routes it through stdout; `--reporter=append-only` is
# set explicitly so we don't drift if pnpm changes its CI default again.
# `--loglevel=debug` and `DEBUG=pnpm:*` add registry/store/fetch traces.
# ALWAYS runs, even on a cache hit. `setup-ci` installs SafeChain's
# executable shims and exposes them to subsequent steps via $GITHUB_PATH.
# That PATH wiring is per-job runtime state — it is NOT captured by the
# binary cache above. On a cache hit the download step is skipped, so this
# is the only thing that puts SafeChain on PATH for the install/build steps;
# without it the package managers run unwrapped and protection is inert.
# Runs the cached binary directly to avoid a redundant CDN round-trip.
- name: Activate Aikido SafeChain
run: |
"$HOME/.safe-chain/bin/safe-chain" setup-ci
shell: bash
# Fail fast if activation did not take effect, mirroring the Node/pnpm
# verify guards above. We use SafeChain's own `safe-chain-verify` command,
# which runs *through* the wrapped package manager and prints
# "OK: Safe-chain works!" only when the shim is active.
# Kept as a step after Activate: `setup-ci` exposes the shims via
# $GITHUB_PATH, which only reaches PATH between steps.
- name: Verify SafeChain is active
shell: bash
run: node .github/scripts/verify-safechain.mjs
# `--loglevel` CLI flag does NOT override `.npmrc` for that path, but
# `--config.loglevel` does — so the failing script's real output reaches us.
# (Confirmed on CI across Blacksmith + GitHub-hosted, through the SafeChain
# shim and the real pnpm binary)
#
# Layered so the cause survives even failure modes pnpm can't report itself:
# 1. `--config.loglevel=info` un-suppresses lifecycle output (the actual fix).
# `--reporter=append-only` is pinned so we don't drift if pnpm changes its
# CI default; combined stdout+stderr is streamed live AND persisted to
# `$INSTALL_LOG` via `tee`; `${PIPESTATUS[0]}` preserves pnpm's exit code.
# 2. `--config.logs-dir` routes pnpm's own `ERR_PNPM_*` diagnostic logs into
# a dir we always collect.
# 3. On failure we dump an environment snapshot written by *this* shell —
# node/pnpm versions, the resolved pnpm binary, SafeChain wiring, free
# memory, disk, and kernel OOM lines — so OOM / wrong-binary deaths that
# produce no pnpm output at all are still diagnosable.
# 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.
# `${PIPESTATUS[0]}` preserves pnpm's real exit code through the pipe.
- name: Install Dependencies
if: ${{ inputs.install-command != '' }}
id: install-deps
env:
INSTALL_COMMAND: ${{ inputs.install-command }}
INSTALL_LOG: ${{ runner.temp }}/pnpm-install.log
PNPM_DIAG_DIR: ${{ runner.temp }}/pnpm-diagnostics
SAFE_CHAIN_LOGGING: verbose
DEBUG: pnpm:*
GITHUB_TOKEN: ${{ github.token }}
run: |
# Stable, artifact-name-safe id for the upload step (written first so it
# exists even if the install dies immediately).
DIAG_ID="$(printf '%s' "${GITHUB_JOB}-${RUNNER_NAME}-${GITHUB_RUN_ATTEMPT}-${RANDOM}" | tr -c 'A-Za-z0-9._-' '-')"
echo "diag-id=$DIAG_ID" >> "$GITHUB_OUTPUT"
mkdir -p "$PNPM_DIAG_DIR/pnpm-logs"
set +o pipefail
timeout --kill-after=30s 300s $INSTALL_COMMAND \
--reporter=append-only --loglevel=debug 2>&1 | tee "$INSTALL_LOG"
--config.logs-dir="$PNPM_DIAG_DIR/pnpm-logs" \
--reporter=append-only --config.loglevel=info 2>&1 | tee "$INSTALL_LOG"
rc=${PIPESTATUS[0]}
set -o pipefail
if [ $rc -ne 0 ]; then
echo "::group::pnpm install full output (captured)"
cat "$INSTALL_LOG" 2>/dev/null || echo "(no captured log)"
if [ "$rc" -ne 0 ]; then
# Self-emitted environment snapshot — cannot be swallowed by pnpm.
{
echo "exit_code=$rc"
echo "date=$(date -u +%FT%TZ)"
echo "install_command=$INSTALL_COMMAND"
echo "node=$(node --version 2>&1)"
echo "pnpm=$(pnpm --version 2>&1)"
echo "pnpm_resolved=$(command -v pnpm 2>&1) -> $(readlink -f "$(command -v pnpm 2>/dev/null)" 2>&1)"
echo "pnpm_on_path=$(which -a pnpm 2>&1 | tr '\n' ' ')"
echo "safe_chain_bin=$(ls -la "$HOME/.safe-chain/bin" 2>&1 | tr '\n' ' ')"
echo "mem_mb=$(free -m 2>/dev/null | tr '\n' ' ' || echo 'free unavailable')"
echo "disk=$(df -h "$PWD" 2>/dev/null | tail -1)"
echo "oom=$(dmesg 2>/dev/null | grep -iE 'killed process|out of memory' | tail -5 || true)"
} > "$PNPM_DIAG_DIR/environment.txt" 2>&1
cp "$INSTALL_LOG" "$PNPM_DIAG_DIR/" 2>/dev/null || true
echo "::error::pnpm install failed (exit $rc). Full output + diagnostics below; also uploaded as artifact 'pnpm-install-logs-${DIAG_ID}'."
echo "::group::Environment diagnostics"
cat "$PNPM_DIAG_DIR/environment.txt"
echo "::endgroup::"
case $rc in
echo "::group::pnpm install combined output ($INSTALL_LOG)"
cat "$INSTALL_LOG" 2>/dev/null || echo "(combined log empty — pnpm produced no capturable output; see environment diagnostics and the uploaded artifact)"
echo "::endgroup::"
echo "::group::pnpm diagnostic logs ($PNPM_DIAG_DIR/pnpm-logs)"
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)" ;;
137) echo "::error::pnpm install received SIGKILL (exit 137 — likely OOM or kill-after timeout)" ;;
esac
fi
exit $rc
exit "$rc"
shell: bash
# Persist the full combined log + diagnostics so the real cause survives even
# when the runner truncates the live console stream. Scoped to the install
# step's own failure so unrelated step failures don't trigger an empty upload.
- name: Upload pnpm install diagnostics
if: ${{ failure() && steps.install-deps.outcome == 'failure' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: pnpm-install-logs-${{ steps.install-deps.outputs.diag-id }}
path: |
${{ runner.temp }}/pnpm-install.log
${{ runner.temp }}/pnpm-diagnostics/**
if-no-files-found: ignore
retention-days: 7
# When setup-nodejs is nested inside another setup-nodejs invocation
# (e.g. load-n8n-docker's rebuild fallback → build-n8n-docker), a turbo
# cache server is already running and its TURBO_API env is already
# exported, so the nested builds can just use it. Starting a second
# server overwrites TURBOGHA_PORT, which both post-runs then read: the
# first post shuts the second server down, and the other post fails the
# job with a bare `fetch failed` — and the first server's caches are
# never saved.
- name: Check for running Turborepo cache server
id: turbo-server
shell: bash
run: echo "running=${TURBOGHA_PORT:+true}" >> "$GITHUB_OUTPUT"
- name: Configure Turborepo Cache
if: steps.turbo-server.outputs.running != 'true'
uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2
with:
server-port: 0

View File

@ -3,6 +3,7 @@
"minimumPackageAgeExclusions": [
"@n8n/*",
"@n8n_io/*",
"eslint-plugin-n8n-nodes-base",
"n8n",
"n8n-containers",
"n8n-core",

89
.github/scripts/attest-image-sbom.mjs vendored Normal file
View File

@ -0,0 +1,89 @@
#!/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
* 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)
*/
import { execFileSync } from 'node:child_process';
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 = [
'--allow-ref=LicenseRef-n8n-sustainable-use',
'--allow-ref=LicenseRef-n8n-enterprise',
];
export function parseTargets(env) {
return [
{ label: 'n8n', image: env.N8N_IMAGE, digest: env.N8N_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);
}
function run(cmd, args, extraEnv) {
execFileSync(cmd, args, {
stdio: 'inherit',
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
});
}
function attest({ label, image, digest }) {
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' },
);
// 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']);
// 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/']);
run('cosign', ['attest', '--yes', '--type', 'cyclonedx', '--predicate', out, ref]);
console.log('::endgroup::');
}
function main() {
const targets = parseTargets(process.env);
if (targets.length === 0) {
console.log('No images with digests to attest — skipping.');
return;
}
for (const target of targets) attest(target);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
main();
} catch (err) {
console.error(err.message);
process.exit(1);
}
}

View File

@ -0,0 +1,37 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { parseTargets } from './attest-image-sbom.mjs';
describe('parseTargets', () => {
it('builds a target per image when both ref and digest are present', () => {
const targets = parseTargets({
N8N_IMAGE: 'ghcr.io/n8n-io/n8n',
N8N_DIGEST: 'sha256:aaa',
RUNNERS_IMAGE: 'ghcr.io/n8n-io/runners',
RUNNERS_DIGEST: 'sha256:bbb',
DISTROLESS_IMAGE: 'ghcr.io/n8n-io/runners',
DISTROLESS_DIGEST: 'sha256:ccc',
});
assert.deepEqual(
targets.map((t) => t.label),
['n8n', 'runners', 'runners-distroless'],
);
});
it('skips an image with no digest (not built for this release type)', () => {
const targets = parseTargets({
N8N_IMAGE: 'ghcr.io/n8n-io/n8n',
N8N_DIGEST: 'sha256:aaa',
RUNNERS_IMAGE: 'ghcr.io/n8n-io/runners',
RUNNERS_DIGEST: '',
});
assert.deepEqual(
targets.map((t) => t.label),
['n8n'],
);
});
it('returns nothing when no digests are present', () => {
assert.deepEqual(parseTargets({}), []);
});
});

View File

@ -260,6 +260,8 @@ async function bumpVersions() {
propagateDirtyTransitively(packageMap, depsByPackage);
// Always mark the `cli` package as dirty, so it's version always gets incremented
packageMap["n8n"].isDirty = true;
// Keep the monorepo version up to date with the released version
packageMap['monorepo-root'].version = packageMap['n8n'].version;

View File

@ -29,9 +29,14 @@ function main() {
.filter((commit) => commit.trim().length > 0);
const actionableCommitList = filterActionableCommits(commitList);
// `force` lets a dispatch create the PR even without actionable commits,
// but never when there are literally no commits between versions.
const force = process.env.FORCE === 'true';
const shouldCreatePr = commitList.length > 0 && (actionableCommitList.length > 0 || force);
const output = {
release_candidate_branch: releaseCandidateBranch,
should_update: actionableCommitList.length > 0 ? 'true' : 'false',
should_create_pr: shouldCreatePr ? 'true' : 'false',
};
console.log(output);

View File

@ -88,7 +88,7 @@ class BuildContext {
buildMatrix(platforms) {
const runners = {
'linux/amd64': 'blacksmith-4vcpu-ubuntu-2204',
'linux/arm64': 'blacksmith-4vcpu-ubuntu-2204-arm',
'linux/arm64': 'blacksmith-8vcpu-ubuntu-2204-arm',
};
const matrix = {

View File

@ -407,6 +407,124 @@ export async function getChangedFiles(pullRequestNumber) {
]);
}
/**
* Returns all files changed in a PR with full metadata including line counts.
*
* @param { number } pullRequestNumber
* @returns { Promise<Array<{ filename: string, additions: number, deletions: number, previous_filename?: string }>> }
* */
export async function getPrFiles(pullRequestNumber) {
const { octokit, owner, repo } = initGithub();
return await octokit.paginate(octokit.rest.pulls.listFiles, {
owner,
repo,
pull_number: pullRequestNumber,
per_page: 100,
});
}
/**
* Post a PR comment, or update the existing one if a previous run already
* left one identified by the provided bot marker.
*
* @param { number } pullRequestNumber
* @param { string } body
* @param { string } botMarker
*/
export async function postOrUpdateComment(pullRequestNumber, body, botMarker) {
const { octokit, owner, repo } = initGithub();
const comments = await octokit.paginate(octokit.rest.issues.listComments, {
owner,
repo,
issue_number: pullRequestNumber,
per_page: 100,
});
const existing = comments.find((c) => c.body?.includes(botMarker));
if (existing) {
await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await octokit.rest.issues.createComment({
owner,
repo,
issue_number: pullRequestNumber,
body,
});
}
}
/**
* Request review from the given GitHub teams on a PR.
*
* Team slugs are the part after the org, e.g. `catalysts` for
* `@n8n-io/catalysts`. Re-requesting an already-requested team is a no-op on
* GitHub's side, so this is safe to call on every PR update.
*
* @param { number } pullRequestNumber
* @param { string[] } teamSlugs
*/
export async function requestTeamReviewers(pullRequestNumber, teamSlugs) {
if (teamSlugs.length === 0) return;
const { octokit, owner, repo } = initGithub();
await octokit.rest.pulls.requestReviewers({
owner,
repo,
pull_number: pullRequestNumber,
team_reviewers: teamSlugs,
});
}
/**
* Add a label to a PR (issue). Adding a label that is already present is a
* no-op on GitHub's side.
*
* @param { number } pullRequestNumber
* @param { string } label
*/
export async function addLabel(pullRequestNumber, label) {
const { octokit, owner, repo } = initGithub();
await octokit.rest.issues.addLabels({
owner,
repo,
issue_number: pullRequestNumber,
labels: [label],
});
}
/**
* Remove a label from a PR (issue). Removing a label that is not present
* returns 404 from GitHub; that case is swallowed so the call is idempotent.
*
* @param { number } pullRequestNumber
* @param { string } label
*/
export async function removeLabel(pullRequestNumber, label) {
const { octokit, owner, repo } = initGithub();
try {
await octokit.rest.issues.removeLabel({
owner,
repo,
issue_number: pullRequestNumber,
name: label,
});
} catch (ex) {
if (ex?.status === 404) return;
throw ex;
}
}
/**
* @param {string} tag
*/

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

@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
/**
* Run these tests by running
*
* node --test --experimental-test-module-mocks ./.github/scripts/github-helpers.test.mjs
* */
let octokitImpl;
mock.module('@actions/github', {
namedExports: {
getOctokit: () => octokitImpl(),
},
});
const { postOrUpdateComment } = await import('./github-helpers.mjs');
const ORIGINAL_ENV = { ...process.env };
describe('postOrUpdateComment', () => {
beforeEach(() => {
process.env.GITHUB_TOKEN = 'token';
process.env.GITHUB_REPOSITORY = 'n8n-io/n8n';
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it('creates a new comment when no existing bot-marker comment is found', async () => {
const createComment = mock.fn(async () => {});
const updateComment = mock.fn(async () => {});
const paginate = mock.fn(async () => [{ id: 1, body: 'unrelated comment' }]);
octokitImpl = () => ({
paginate,
rest: {
issues: {
listComments: {},
createComment,
updateComment,
},
},
});
await postOrUpdateComment(123, 'new body', '<!-- marker -->');
assert.equal(updateComment.mock.calls.length, 0);
assert.equal(createComment.mock.calls.length, 1);
assert.deepEqual(createComment.mock.calls[0].arguments[0], {
owner: 'n8n-io',
repo: 'n8n',
issue_number: 123,
body: 'new body',
});
assert.deepEqual(paginate.mock.calls[0].arguments[1], {
owner: 'n8n-io',
repo: 'n8n',
issue_number: 123,
per_page: 100,
});
});
it('updates the existing comment when a bot-marker comment is found', async () => {
const createComment = mock.fn(async () => {});
const updateComment = mock.fn(async () => {});
octokitImpl = () => ({
paginate: mock.fn(async () => [{ id: 42, body: '<!-- marker -->\nold body' }]),
rest: {
issues: {
listComments: {},
createComment,
updateComment,
},
},
});
await postOrUpdateComment(123, 'updated body', '<!-- marker -->');
assert.equal(createComment.mock.calls.length, 0);
assert.equal(updateComment.mock.calls.length, 1);
assert.deepEqual(updateComment.mock.calls[0].arguments[0], {
owner: 'n8n-io',
repo: 'n8n',
comment_id: 42,
body: 'updated body',
});
});
});

View File

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

View File

@ -0,0 +1,104 @@
/**
* Opt-in reviewer auto-assignment.
*
* When a PR carries the `Auto-assign reviewers` label, request review from
* every team that owns a changed file (per .github/OWNERS). This is strictly
* opt-in: without the label the script is a no-op, so it never adds friction
* to PRs that don't want it.
*
* Assignment happens once: after requesting reviewers the trigger label is
* swapped for the `Reviewers auto-assigned` marker, and any PR already carrying
* that marker is skipped. This stops later `synchronize` events from
* re-requesting (and re-notifying) the same teams.
*
* Complements `owners-review-recommendations.mjs`, which only *suggests*
* reviewer teams in a comment — this one actually requests them.
*/
import {
addLabel,
ensureEnvVar,
getChangedFiles,
readPrLabels,
removeLabel,
requestTeamReviewers,
} from './github-helpers.mjs';
import { assignOwnership, ownershipsToAllocations, parseOwnersFile } from './owners.mjs';
export const AUTO_ASSIGN_LABEL = 'Auto-assign reviewers';
export const ASSIGNED_LABEL = 'Reviewers auto-assigned';
/**
* Convert an OWNERS team handle (`@n8n-io/catalysts`) into the GitHub team slug
* (`catalysts`) expected by the requestReviewers API.
*
* @param { string } team
* @returns { string }
*/
export function teamHandleToSlug(team) {
return team.replace(/^@[^/]+\//, '');
}
/**
* @param { string[] } labels
* @returns { boolean }
*/
export function hasAutoAssignLabel(labels) {
return labels.includes(AUTO_ASSIGN_LABEL);
}
/**
* Determine the team slugs that own files in the changeset, de-duplicated and
* stable-ordered (largest ownership first, matching allocation order).
*
* @param { Set<string> } changedFiles
* @returns { string[] }
*/
export function resolveOwnerTeamSlugs(changedFiles) {
const owners = parseOwnersFile();
const ownerships = assignOwnership(changedFiles, owners);
const allocations = ownershipsToAllocations(ownerships);
return allocations.map((allocation) => teamHandleToSlug(allocation.team));
}
/**
* @param { number } pullRequestNumber
*/
export async function run(pullRequestNumber) {
const labels = readPrLabels();
if (labels.includes(ASSIGNED_LABEL)) {
console.log(
`PR #${pullRequestNumber} already carries the "${ASSIGNED_LABEL}" label; reviewers were already assigned.`,
);
return;
}
if (!hasAutoAssignLabel(labels)) {
console.log(
`PR #${pullRequestNumber} is missing the "${AUTO_ASSIGN_LABEL}" label; skipping reviewer assignment.`,
);
return;
}
const changedFiles = await getChangedFiles(pullRequestNumber);
const teamSlugs = resolveOwnerTeamSlugs(changedFiles);
if (teamSlugs.length === 0) {
console.log(`No owning teams matched for PR #${pullRequestNumber}; nothing to assign.`);
return;
}
console.log(`Requesting review from team(s): ${teamSlugs.join(', ')}`);
await requestTeamReviewers(pullRequestNumber, teamSlugs);
// Swap the trigger label for the done-marker so later runs no-op.
await addLabel(pullRequestNumber, ASSIGNED_LABEL);
await removeLabel(pullRequestNumber, AUTO_ASSIGN_LABEL);
}
if (import.meta.url === `file://${process.argv[1]}`) {
const pullRequestNumber = parseInt(ensureEnvVar('PULL_REQUEST_NUMBER'));
await run(pullRequestNumber);
}

View File

@ -0,0 +1,174 @@
import { beforeEach, describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
/**
* Run these tests by running
*
* node --test --experimental-test-module-mocks ./.github/scripts/owners-assign-reviewers.test.mjs
* */
/** @type {() => string[]} */
let readPrLabelsImpl = () => [];
/** @type {(pullRequestNumber: number) => Promise<Set<string>>} */
let getChangedFilesImpl = async () => new Set();
/** @type {(pullRequestNumber: number, teamSlugs: string[]) => Promise<void>} */
let requestTeamReviewersImpl = async () => {};
/** @type {(pullRequestNumber: number, label: string) => Promise<void>} */
let addLabelImpl = async () => {};
/** @type {(pullRequestNumber: number, label: string) => Promise<void>} */
let removeLabelImpl = async () => {};
mock.module('./github-helpers.mjs', {
namedExports: {
ensureEnvVar: () => {}, // no-op in tests
readPrLabels: () => readPrLabelsImpl(),
getChangedFiles: (n) => getChangedFilesImpl(n),
requestTeamReviewers: (pullRequestNumber, teamSlugs) =>
requestTeamReviewersImpl(pullRequestNumber, teamSlugs),
addLabel: (pullRequestNumber, label) => addLabelImpl(pullRequestNumber, label),
removeLabel: (pullRequestNumber, label) => removeLabelImpl(pullRequestNumber, label),
},
});
/** @type {() => Array<{ filepath: string, team: string }>} */
let parseOwnersFileImpl = () => [];
/** @type {(files: Set<string>, owners: Array<{ filepath: string, team: string }>) => Map<string, string[]>} */
let assignOwnershipImpl = () => new Map();
/** @type {(ownerships: Map<string, string[]>) => Array<{ team: string, fileCount: number }>} */
let ownershipsToAllocationsImpl = () => [];
mock.module('./owners.mjs', {
namedExports: {
parseOwnersFile: () => parseOwnersFileImpl(),
assignOwnership: (files, owners) => assignOwnershipImpl(files, owners),
ownershipsToAllocations: (ownerships) => ownershipsToAllocationsImpl(ownerships),
},
});
const {
AUTO_ASSIGN_LABEL,
ASSIGNED_LABEL,
teamHandleToSlug,
hasAutoAssignLabel,
resolveOwnerTeamSlugs,
run,
} = await import('./owners-assign-reviewers.mjs');
describe('teamHandleToSlug', () => {
it('strips the org prefix from an OWNERS team handle', () => {
assert.equal(teamHandleToSlug('@n8n-io/catalysts'), 'catalysts');
});
it('leaves a bare slug untouched', () => {
assert.equal(teamHandleToSlug('catalysts'), 'catalysts');
});
it('only strips the first segment', () => {
assert.equal(teamHandleToSlug('@n8n-io/migrations-review'), 'migrations-review');
});
});
describe('hasAutoAssignLabel', () => {
it('returns true when the label is present', () => {
assert.equal(hasAutoAssignLabel(['bug', AUTO_ASSIGN_LABEL]), true);
});
it('returns false when the label is absent', () => {
assert.equal(hasAutoAssignLabel(['bug', 'enhancement']), false);
});
it('returns false for an empty label set', () => {
assert.equal(hasAutoAssignLabel([]), false);
});
});
describe('resolveOwnerTeamSlugs', () => {
beforeEach(() => {
parseOwnersFileImpl = () => [];
assignOwnershipImpl = () => new Map();
ownershipsToAllocationsImpl = () => [];
});
it('maps allocations to team slugs in allocation order', () => {
ownershipsToAllocationsImpl = () => [
{ team: '@n8n-io/catalysts', fileCount: 3 },
{ team: '@n8n-io/qa-dx', fileCount: 1 },
];
assert.deepEqual(resolveOwnerTeamSlugs(new Set(['a.ts'])), ['catalysts', 'qa-dx']);
});
it('returns an empty array when no team owns a file', () => {
assert.deepEqual(resolveOwnerTeamSlugs(new Set(['a.ts'])), []);
});
});
describe('run', () => {
let requestTeamReviewers;
let addLabel;
let removeLabel;
beforeEach(() => {
readPrLabelsImpl = () => [AUTO_ASSIGN_LABEL];
getChangedFilesImpl = async () => new Set(['a.ts']);
parseOwnersFileImpl = () => [];
assignOwnershipImpl = () => new Map();
ownershipsToAllocationsImpl = () => [{ team: '@n8n-io/catalysts', fileCount: 1 }];
requestTeamReviewers = mock.fn(async () => {});
requestTeamReviewersImpl = requestTeamReviewers;
addLabel = mock.fn(async () => {});
addLabelImpl = addLabel;
removeLabel = mock.fn(async () => {});
removeLabelImpl = removeLabel;
});
it('requests review from the owning team slugs when the label is present', async () => {
ownershipsToAllocationsImpl = () => [
{ team: '@n8n-io/catalysts', fileCount: 2 },
{ team: '@n8n-io/qa-dx', fileCount: 1 },
];
await run(42);
assert.equal(requestTeamReviewers.mock.calls.length, 1);
assert.equal(requestTeamReviewers.mock.calls[0].arguments[0], 42);
assert.deepEqual(requestTeamReviewers.mock.calls[0].arguments[1], ['catalysts', 'qa-dx']);
});
it('swaps the trigger label for the done-marker after assigning', async () => {
await run(42);
assert.deepEqual(addLabel.mock.calls[0].arguments, [42, ASSIGNED_LABEL]);
assert.deepEqual(removeLabel.mock.calls[0].arguments, [42, AUTO_ASSIGN_LABEL]);
});
it('is a no-op when the trigger label is absent', async () => {
readPrLabelsImpl = () => ['bug'];
await run(42);
assert.equal(requestTeamReviewers.mock.calls.length, 0);
assert.equal(addLabel.mock.calls.length, 0);
assert.equal(removeLabel.mock.calls.length, 0);
});
it('is a no-op when the done-marker label is already present', async () => {
readPrLabelsImpl = () => [AUTO_ASSIGN_LABEL, ASSIGNED_LABEL];
await run(42);
assert.equal(requestTeamReviewers.mock.calls.length, 0);
assert.equal(addLabel.mock.calls.length, 0);
assert.equal(removeLabel.mock.calls.length, 0);
});
it('does not request reviewers or relabel when no team owns a changed file', async () => {
ownershipsToAllocationsImpl = () => [];
await run(42);
assert.equal(requestTeamReviewers.mock.calls.length, 0);
assert.equal(addLabel.mock.calls.length, 0);
assert.equal(removeLabel.mock.calls.length, 0);
});
});

View File

@ -1,104 +1,254 @@
import { ensureEnvVar, getChangedFiles, initGithub } from "./github-helpers.mjs";
import { assignOwnership, ownershipsToAllocations, parseOwnersFile } from "./owners.mjs";
/**
* PR recommendations entry point.
*
* Posts (or updates) a single PR comment that combines:
* - Recommended reviewer teams based on file ownership
* - A breakdown of changed lines by category (source code, test files, misc)
*
* Advisory only — does not gate merging.
*/
import { minimatch } from 'minimatch';
import { ensureEnvVar, getPrFiles, postOrUpdateComment } from './github-helpers.mjs';
import { assignOwnership, ownershipsToAllocations, parseOwnersFile } from './owners.mjs';
import { MISC_PATTERNS, SIZE_LIMIT, TEST_PATTERNS } from './quality/check-pr-size.mjs';
/** @typedef {import('./owners.mjs').Allocation} Allocation */
/**
* @param { number } pullRequestNumber
* */
export async function getReviewRecommendations(pullRequestNumber) {
const changedFiles = await getChangedFiles(pullRequestNumber);
const owners = parseOwnersFile();
* @typedef {{
* sourceCodeAdded: number, sourceCodeRemoved: number,
* testFilesAdded: number, testFilesRemoved: number,
* miscAdded: number, miscRemoved: number,
* }} LineStats
*/
const ownerships = assignOwnership(changedFiles, owners);
const allocations = ownershipsToAllocations(ownerships);
const BOT_MARKER = '<!-- pr-recommendations -->';
const topAllocations = allocations.sort((a, b) => b.fileCount - a.fileCount).slice(0, 3);
await commentOnPrWithRecommendations(pullRequestNumber, topAllocations, changedFiles);
function createEmptyLineStats() {
return {
sourceCodeAdded: 0,
sourceCodeRemoved: 0,
testFilesAdded: 0,
testFilesRemoved: 0,
miscAdded: 0,
miscRemoved: 0,
};
}
const BOT_MARKER = "<!-- owners-review-recommendations -->";
/**
* @param { LineStats } stats
* @param {{ filename: string, additions: number, deletions: number }} file
*/
function addFileToLineStats(stats, file) {
const isTest = TEST_PATTERNS.some((p) => minimatch(file.filename, p));
const isMisc = !isTest && MISC_PATTERNS.some((p) => minimatch(file.filename, p));
if (isTest) {
stats.testFilesAdded += file.additions;
stats.testFilesRemoved += file.deletions;
} else if (isMisc) {
stats.miscAdded += file.additions;
stats.miscRemoved += file.deletions;
} else {
stats.sourceCodeAdded += file.additions;
stats.sourceCodeRemoved += file.deletions;
}
}
/**
* Build the PR comment body listing the top reviewer teams with their share
* of ownership over the changed files.
* Compute line addition and deletion counts categorised as source code,
* test files, or misc.
*
* @param { Array<{ filename: string, additions: number, deletions: number }> } files
* @returns { LineStats }
*/
export function computeLineStats(files) {
const stats = createEmptyLineStats();
for (const file of files) {
addFileToLineStats(stats, file);
}
return stats;
}
/**
* Compute line stats for each owner allocation. Renamed files can be owned by
* either their previous or current filename, but are counted only once per team.
*
* @param { Allocation[] } allocations
* @param { Map<string, string[]> } ownerships
* @param { Array<{ filename: string, previous_filename?: string, additions: number, deletions: number }> } files
* @returns { Map<string, LineStats> }
*/
export function computeAllocationLineStats(allocations, ownerships, files) {
const statsByTeam = new Map();
for (const { team } of allocations) {
const stats = createEmptyLineStats();
const ownedFiles = new Set(ownerships.get(team) ?? []);
for (const file of files) {
if (ownedFiles.has(file.filename) || ownedFiles.has(file.previous_filename)) {
addFileToLineStats(stats, file);
}
}
statsByTeam.set(team, stats);
}
return statsByTeam;
}
/**
* @param { LineStats } lineStats
* @param {'sourceCode' | 'testFiles' | 'misc'} key
* @returns { string }
*/
function formatLineStatsCell(lineStats, key) {
return `+${lineStats[`${key}Added`].toLocaleString()} / -${lineStats[`${key}Removed`].toLocaleString()}`;
}
/**
* @param { LineStats } lineStats
* @returns { number }
*/
function totalLineChanges(lineStats) {
return (
lineStats.sourceCodeAdded +
lineStats.sourceCodeRemoved +
lineStats.testFilesAdded +
lineStats.testFilesRemoved +
lineStats.miscAdded +
lineStats.miscRemoved
);
}
/**
* @param { LineStats } target
* @param { LineStats } source
*/
function addLineStats(target, source) {
target.sourceCodeAdded += source.sourceCodeAdded;
target.sourceCodeRemoved += source.sourceCodeRemoved;
target.testFilesAdded += source.testFilesAdded;
target.testFilesRemoved += source.testFilesRemoved;
target.miscAdded += source.miscAdded;
target.miscRemoved += source.miscRemoved;
}
/**
* @param { Allocation[] } allocations
* @param { Map<string, LineStats> } lineStatsByTeam
* @returns { LineStats }
*/
function aggregateLineStats(allocations, lineStatsByTeam) {
const stats = createEmptyLineStats();
for (const { team } of allocations) {
addLineStats(stats, lineStatsByTeam.get(team) ?? createEmptyLineStats());
}
return stats;
}
/**
* Build an ownership-first overview table with line stats grouped by team.
*
* @param { Allocation[] } allocations
* @param { Set<string> } changedFiles
* @param { LineStats } totalLineStats
* @param { Map<string, LineStats> } lineStatsByTeam
* @param { Allocation[] } [otherAllocations]
* @returns { string }
* */
export function buildRecommendationsBody(allocations, changedFiles) {
*/
export function buildOverviewTable(allocations, changedFiles, totalLineStats, lineStatsByTeam, otherAllocations = []) {
const total = changedFiles.size;
const rows = allocations.length > 0 && total > 0
? allocations.map(({ team, fileCount }) => {
const pct = Math.round((fileCount / total) * 100);
const teamLineStats = lineStatsByTeam.get(team) ?? createEmptyLineStats();
return `| ${team} | ${fileCount} | ${pct}% | ${formatLineStatsCell(teamLineStats, 'sourceCode')} | ${formatLineStatsCell(teamLineStats, 'testFiles')} | ${formatLineStatsCell(teamLineStats, 'misc')} |`;
})
: [`| _No owning teams matched_ | 0 | 0% | ${formatLineStatsCell(totalLineStats, 'sourceCode')} | ${formatLineStatsCell(totalLineStats, 'testFiles')} | ${formatLineStatsCell(totalLineStats, 'misc')} |`];
if (allocations.length === 0 || total === 0) {
return [
BOT_MARKER,
"## Recommended reviewers",
"",
"_No owning teams matched the files changed in this PR._",
].join("\n");
if (otherAllocations.length > 0 && total > 0) {
const otherFileCount = otherAllocations.reduce((sum, { fileCount }) => sum + fileCount, 0);
const otherPct = Math.round((otherFileCount / total) * 100);
const otherLineStats = aggregateLineStats(otherAllocations, lineStatsByTeam);
rows.push(`| Other teams | ${otherFileCount} | ${otherPct}% | ${formatLineStatsCell(otherLineStats, 'sourceCode')} | ${formatLineStatsCell(otherLineStats, 'testFiles')} | ${formatLineStatsCell(otherLineStats, 'misc')} |`);
}
const rows = allocations.map(({ team, fileCount }) => {
const pct = Math.round((fileCount / total) * 100);
return `| ${team} | ${fileCount} | ${pct}% |`;
});
return [
BOT_MARKER,
"## Recommended reviewers",
"",
`Based on ownership of the ${total} changed file${total === 1 ? "" : "s"} in this PR:`,
"",
"| Team | Files owned | Share |",
"| --- | ---: | ---: |",
'## PR review overview',
'',
`Based on ownership of the ${total} changed file${total === 1 ? '' : 's'} in this PR:`,
'',
'| Ownership | Files owned | Share | Source code | Test files | Misc |',
'| --- | ---: | ---: | ---: | ---: | ---: |',
...rows,
].join("\n");
`| **Total** | **${total.toLocaleString()}** | **${total === 0 ? 0 : 100}%** | **${formatLineStatsCell(totalLineStats, 'sourceCode')}** | **${formatLineStatsCell(totalLineStats, 'testFiles')}** | **${formatLineStatsCell(totalLineStats, 'misc')}** |`,
].join('\n');
}
/**
* Post the recommendations as a PR comment, or update the existing one if a
* previous run already left one (identified by BOT_MARKER).
* Construct the full PR comment body from reviewer allocations and line stats.
*
* @param { number } pullRequestNumber
* @param { Allocation[] } allocations
* @param { Set<string> } changedFiles
* */
export async function commentOnPrWithRecommendations(pullRequestNumber, allocations, changedFiles) {
const { octokit, owner, repo } = initGithub();
* @param { LineStats } lineStats
* @param { Map<string, LineStats> } lineStatsByTeam
* @param { Allocation[] } [otherAllocations]
* @returns { string }
*/
export function buildComment(allocations, changedFiles, lineStats, lineStatsByTeam = new Map(), otherAllocations = []) {
const body = [
BOT_MARKER,
buildOverviewTable(allocations, changedFiles, lineStats, lineStatsByTeam, otherAllocations),
];
const body = buildRecommendationsBody(allocations, changedFiles);
const comments = await octokit.paginate(octokit.rest.issues.listComments, {
owner,
repo,
issue_number: pullRequestNumber,
per_page: 100,
});
const existing = comments.find(c => c.body?.includes(BOT_MARKER));
if (existing) {
await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await octokit.rest.issues.createComment({
owner,
repo,
issue_number: pullRequestNumber,
body,
});
if (lineStats.sourceCodeAdded > SIZE_LIMIT) {
body.push('', `❗ Source code additions (${lineStats.sourceCodeAdded.toLocaleString()}) exceed the ${SIZE_LIMIT.toLocaleString()}-line limit.`);
}
return body.join('\n');
}
/**
* @param { number } pullRequestNumber
*/
export async function run(pullRequestNumber) {
const files = await getPrFiles(pullRequestNumber);
const changedFiles = new Set([
...files.map((f) => f.filename),
...files.filter((f) => f.previous_filename).map((f) => f.previous_filename),
]);
const lineStats = computeLineStats(files);
const owners = parseOwnersFile();
const ownerships = assignOwnership(changedFiles, owners);
const allocations = ownershipsToAllocations(ownerships);
const lineStatsByTeam = computeAllocationLineStats(allocations, ownerships, files);
const sortedAllocations = allocations
.toSorted((a, b) => {
const lineChangeDiff =
totalLineChanges(lineStatsByTeam.get(b.team) ?? createEmptyLineStats()) -
totalLineChanges(lineStatsByTeam.get(a.team) ?? createEmptyLineStats());
return lineChangeDiff || b.fileCount - a.fileCount;
});
const topAllocations = sortedAllocations.slice(0, 3);
const otherAllocations = sortedAllocations.slice(3);
const body = buildComment(topAllocations, changedFiles, lineStats, lineStatsByTeam, otherAllocations);
await postOrUpdateComment(pullRequestNumber, body, BOT_MARKER);
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
const pullRequestNumber = parseInt(ensureEnvVar("PULL_REQUEST_NUMBER"));
await getReviewRecommendations(pullRequestNumber);
const pullRequestNumber = parseInt(ensureEnvVar('PULL_REQUEST_NUMBER'));
await run(pullRequestNumber);
}

View File

@ -1,4 +1,4 @@
import { describe, it, mock } from 'node:test';
import { beforeEach, describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
/**
@ -7,71 +7,382 @@ import assert from 'node:assert/strict';
* node --test --experimental-test-module-mocks ./.github/scripts/owners-review-recommendations.test.mjs
* */
// mock.module must be called before the module under test is imported,
// because static imports are hoisted and resolve before any code runs.
/** @type {(pullRequestNumber: number) => Promise<Array<{ filename: string, additions: number, deletions: number, previous_filename?: string }>>} */
let getPrFilesImpl = async () => [];
/** @type {(pullRequestNumber: number, body: string, botMarker: string) => Promise<void>} */
let postOrUpdateCommentImpl = async () => {};
mock.module('./github-helpers.mjs', {
namedExports: {
ensureEnvVar: () => {}, // no-op in tests
initGithub: () => {}, // no-op in tests
getChangedFiles: () => Promise.resolve(new Set()), // no-op in tests
getChangedFiles: () => Promise.resolve(new Set()),
getEventFromGithubEventPath: () => ({}),
getPrFiles: (n) => getPrFilesImpl(n),
postOrUpdateComment: (pullRequestNumber, body, botMarker) =>
postOrUpdateCommentImpl(pullRequestNumber, body, botMarker),
},
});
/** @type { (allocations: { team: string, fileCount: number }[], changedFiles: Set<string>) => string } */
let buildRecommendationsBody;
const { buildRecommendationsBody: imported } = await import('./owners-review-recommendations.mjs');
buildRecommendationsBody = imported;
/** @type {() => Array<{ filepath: string, team: string }>} */
let parseOwnersFileImpl = () => [];
/** @type {(files: Set<string>, owners: Array<{ filepath: string, team: string }>) => Map<string, string[]>} */
let assignOwnershipImpl = () => new Map();
/** @type {(ownerships: Map<string, string[]>) => Array<{ team: string, fileCount: number }>} */
let ownershipsToAllocationsImpl = () => [];
describe('buildRecommendationsBody', () => {
const marker = '<!-- owners-review-recommendations -->';
mock.module('./owners.mjs', {
namedExports: {
parseOwnersFile: () => parseOwnersFileImpl(),
assignOwnership: (files, owners) => assignOwnershipImpl(files, owners),
ownershipsToAllocations: (ownerships) => ownershipsToAllocationsImpl(ownerships),
},
});
it('returns the fallback message when there are no allocations', () => {
const body = buildRecommendationsBody([], new Set(['a.ts']));
const { buildOverviewTable, buildComment, computeAllocationLineStats, computeLineStats, run } =
await import('./owners-review-recommendations.mjs');
assert.ok(body.startsWith(marker), 'body must start with the bot marker');
assert.match(body, /No owning teams matched/);
});
const BOT_MARKER = '<!-- pr-recommendations -->';
const EMPTY_LINE_STATS = {
sourceCodeAdded: 0,
sourceCodeRemoved: 0,
testFilesAdded: 0,
testFilesRemoved: 0,
miscAdded: 0,
miscRemoved: 0,
};
it('returns the fallback message when no files changed', () => {
const body = buildRecommendationsBody(
[{ team: '@n8n-io/cli-team', fileCount: 0 }],
new Set(),
);
assert.match(body, /No owning teams matched/);
});
it('renders a table with team name, file count, and rounded percentage', () => {
const body = buildRecommendationsBody(
describe('buildOverviewTable', () => {
it('renders an ownership-first table with per-team line stats', () => {
const table = buildOverviewTable(
[
{ team: '@n8n-io/cli-team', fileCount: 6 },
{ team: '@n8n-io/catalysts', fileCount: 3 },
{ team: '@n8n-io/ai-team', fileCount: 1 },
{ team: '@n8n-io/cli-team', fileCount: 2 },
{ team: '@n8n-io/editor-ui-team', fileCount: 1 },
],
new Set(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']),
new Set(['a.ts', 'b.test.ts', 'README.md']),
{
sourceCodeAdded: 10,
sourceCodeRemoved: 2,
testFilesAdded: 5,
testFilesRemoved: 1,
miscAdded: 3,
miscRemoved: 0,
},
new Map([
[
'@n8n-io/cli-team',
{
sourceCodeAdded: 10,
sourceCodeRemoved: 2,
testFilesAdded: 5,
testFilesRemoved: 1,
miscAdded: 0,
miscRemoved: 0,
},
],
[
'@n8n-io/editor-ui-team',
{
sourceCodeAdded: 0,
sourceCodeRemoved: 0,
testFilesAdded: 0,
testFilesRemoved: 0,
miscAdded: 3,
miscRemoved: 0,
},
],
]),
);
assert.ok(body.startsWith(marker));
assert.match(body, /\| @n8n-io\/cli-team \| 6 \| 60% \|/);
assert.match(body, /\| @n8n-io\/catalysts \| 3 \| 30% \|/);
assert.match(body, /\| @n8n-io\/ai-team \| 1 \| 10% \|/);
assert.match(table, /## PR review overview/);
assert.match(table, /\| Ownership \| Files owned \| Share \| Source code \| Test files \| Misc \|/);
assert.match(table, /\| @n8n-io\/cli-team \| 2 \| 67% \| \+10 \/ -2 \| \+5 \/ -1 \| \+0 \/ -0 \|/);
assert.match(table, /\| @n8n-io\/editor-ui-team \| 1 \| 33% \| \+0 \/ -0 \| \+0 \/ -0 \| \+3 \/ -0 \|/);
assert.match(table, /\| \*\*Total\*\* \| \*\*3\*\* \| \*\*100%\*\* \| \*\*\+10 \/ -2\*\* \| \*\*\+5 \/ -1\*\* \| \*\*\+3 \/ -0\*\* \|/);
});
it('renders a no-owner row when no owners matched', () => {
const table = buildOverviewTable(
[],
new Set(['a.ts']),
{ ...EMPTY_LINE_STATS, sourceCodeAdded: 12, sourceCodeRemoved: 1 },
new Map(),
);
assert.match(table, /\| _No owning teams matched_ \| 0 \| 0% \| \+12 \/ -1 \| \+0 \/ -0 \| \+0 \/ -0 \|/);
});
it('uses singular "file" when exactly one file changed', () => {
const body = buildRecommendationsBody(
const table = buildOverviewTable(
[{ team: '@n8n-io/cli-team', fileCount: 1 }],
new Set(['only.ts']),
EMPTY_LINE_STATS,
new Map(),
);
assert.match(body, /ownership of the 1 changed file in this PR/);
assert.match(table, /ownership of the 1 changed file in this PR/);
});
it('uses plural "files" for more than one changed file', () => {
const body = buildRecommendationsBody(
[{ team: '@n8n-io/cli-team', fileCount: 2 }],
it('uses plural "files" for more than one file changed', () => {
const table = buildOverviewTable(
[{ team: '@n8n-io/cli-team', fileCount: 1 }],
new Set(['a.ts', 'b.ts']),
EMPTY_LINE_STATS,
new Map(),
);
assert.match(body, /ownership of the 2 changed files in this PR/);
assert.match(table, /ownership of the 2 changed files in this PR/);
});
it('renders an aggregate row for allocations outside the displayed teams', () => {
const table = buildOverviewTable(
[{ team: '@n8n-io/cli-team', fileCount: 2 }],
new Set(['a.ts', 'b.ts', 'c.ts']),
{ ...EMPTY_LINE_STATS, sourceCodeAdded: 13, sourceCodeRemoved: 3 },
new Map([
['@n8n-io/cli-team', { ...EMPTY_LINE_STATS, sourceCodeAdded: 10, sourceCodeRemoved: 1 }],
['@n8n-io/team-a', { ...EMPTY_LINE_STATS, sourceCodeAdded: 2, sourceCodeRemoved: 1 }],
['@n8n-io/team-b', { ...EMPTY_LINE_STATS, sourceCodeAdded: 1, sourceCodeRemoved: 1 }],
]),
[
{ team: '@n8n-io/team-a', fileCount: 1 },
{ team: '@n8n-io/team-b', fileCount: 1 },
],
);
assert.match(table, /\| Other teams \| 2 \| 67% \| \+3 \/ -2 \| \+0 \/ -0 \| \+0 \/ -0 \|/);
});
});
describe('buildComment', () => {
it('starts with the bot marker', () => {
const body = buildComment([], new Set(['a.ts']), EMPTY_LINE_STATS);
assert.ok(body.startsWith(BOT_MARKER), 'body must start with the bot marker');
});
it('includes the overview table', () => {
const body = buildComment(
[{ team: '@n8n-io/cli-team', fileCount: 1 }],
new Set(['a.ts']),
{ ...EMPTY_LINE_STATS, sourceCodeAdded: 42 },
);
assert.match(body, /## PR review overview/);
assert.match(body, /\| @n8n-io\/cli-team \| 1 \| 100% \|/);
});
it('shows a warning below the table when source code additions exceed the size limit', () => {
const body = buildComment([], new Set(['a.ts']), { ...EMPTY_LINE_STATS, sourceCodeAdded: 1001 });
assert.match(body, /❗ Source code additions \(1[,.]001\) exceed the 1[,.]000-line limit\./);
});
});
describe('computeLineStats', () => {
it('returns all zeros for an empty file list', () => {
assert.deepEqual(computeLineStats([]), EMPTY_LINE_STATS);
});
it('categorises a plain source file as source code', () => {
const stats = computeLineStats([{ filename: 'src/foo.ts', additions: 10, deletions: 3 }]);
assert.equal(stats.sourceCodeAdded, 10);
assert.equal(stats.sourceCodeRemoved, 3);
assert.equal(stats.testFilesAdded, 0);
assert.equal(stats.miscAdded, 0);
});
describe('test file patterns', () => {
for (const [label, filename] of [
['*.test.ts', 'src/foo.test.ts'],
['*.test.js', 'src/foo.test.js'],
['*.test.mjs', 'src/foo.test.mjs'],
['*.spec.ts', 'src/foo.spec.ts'],
['*.spec.js', 'src/foo.spec.js'],
['*.spec.mjs', 'src/foo.spec.mjs'],
['test/ directory', 'packages/cli/test/helpers.ts'],
['tests/ directory', 'packages/cli/tests/helpers.ts'],
['__tests__/ directory', 'src/__tests__/foo.ts'],
['__snapshots__/ directory', 'src/__snapshots__/foo.snap'],
['*.snap extension', 'src/foo.snap'],
['fixtures/ directory', 'src/fixtures/data.json'],
['__mocks__/ directory', 'src/__mocks__/axios.ts'],
['packages/testing/**', 'packages/testing/playwright/spec.ts'],
]) {
it(`classifies ${label} as testFiles`, () => {
const stats = computeLineStats([{ filename, additions: 7, deletions: 2 }]);
assert.equal(stats.testFilesAdded, 7, `expected ${filename} to be a test file`);
assert.equal(stats.testFilesRemoved, 2);
assert.equal(stats.sourceCodeAdded, 0);
assert.equal(stats.miscAdded, 0);
});
}
});
describe('misc file patterns', () => {
for (const [label, filename] of [
['pnpm-lock.yaml', 'pnpm-lock.yaml'],
['*.md', 'README.md'],
['nested *.md', 'packages/cli/CHANGELOG.md'],
['*.mdx', 'docs/guide.mdx'],
['nested *.mdx', 'packages/editor-ui/docs/page.mdx'],
]) {
it(`classifies ${label} as misc`, () => {
const stats = computeLineStats([{ filename, additions: 3, deletions: 1 }]);
assert.equal(stats.miscAdded, 3, `expected ${filename} to be misc`);
assert.equal(stats.miscRemoved, 1);
assert.equal(stats.sourceCodeAdded, 0);
assert.equal(stats.testFilesAdded, 0);
});
}
});
it('sums additions and deletions across multiple files per category', () => {
const stats = computeLineStats([
{ filename: 'src/a.ts', additions: 10, deletions: 5 },
{ filename: 'src/b.ts', additions: 15, deletions: 3 },
{ filename: 'src/a.test.ts', additions: 5, deletions: 1 },
{ filename: 'src/b.spec.ts', additions: 8, deletions: 2 },
{ filename: 'pnpm-lock.yaml', additions: 100, deletions: 50 },
{ filename: 'README.md', additions: 20, deletions: 10 },
]);
assert.equal(stats.sourceCodeAdded, 25);
assert.equal(stats.sourceCodeRemoved, 8);
assert.equal(stats.testFilesAdded, 13);
assert.equal(stats.testFilesRemoved, 3);
assert.equal(stats.miscAdded, 120);
assert.equal(stats.miscRemoved, 60);
});
});
describe('computeAllocationLineStats', () => {
it('computes line stats per owner allocation', () => {
const statsByTeam = computeAllocationLineStats(
[
{ team: '@n8n-io/cli-team', fileCount: 2 },
{ team: '@n8n-io/docs-team', fileCount: 1 },
],
new Map([
['@n8n-io/cli-team', ['src/a.ts', 'src/a.test.ts']],
['@n8n-io/docs-team', ['README.md']],
]),
[
{ filename: 'src/a.ts', additions: 10, deletions: 1 },
{ filename: 'src/a.test.ts', additions: 5, deletions: 2 },
{ filename: 'README.md', additions: 3, deletions: 1 },
],
);
assert.deepEqual(statsByTeam.get('@n8n-io/cli-team'), {
sourceCodeAdded: 10,
sourceCodeRemoved: 1,
testFilesAdded: 5,
testFilesRemoved: 2,
miscAdded: 0,
miscRemoved: 0,
});
assert.deepEqual(statsByTeam.get('@n8n-io/docs-team'), {
sourceCodeAdded: 0,
sourceCodeRemoved: 0,
testFilesAdded: 0,
testFilesRemoved: 0,
miscAdded: 3,
miscRemoved: 1,
});
});
it('matches renamed files by previous filename', () => {
const statsByTeam = computeAllocationLineStats(
[{ team: '@n8n-io/cli-team', fileCount: 1 }],
new Map([['@n8n-io/cli-team', ['old-name.ts']]]),
[{ filename: 'new-name.ts', previous_filename: 'old-name.ts', additions: 7, deletions: 3 }],
);
assert.equal(statsByTeam.get('@n8n-io/cli-team').sourceCodeAdded, 7);
assert.equal(statsByTeam.get('@n8n-io/cli-team').sourceCodeRemoved, 3);
});
});
describe('run', () => {
let postOrUpdateComment;
beforeEach(() => {
getPrFilesImpl = async () => [];
postOrUpdateComment = mock.fn(async () => {});
postOrUpdateCommentImpl = postOrUpdateComment;
parseOwnersFileImpl = () => [];
assignOwnershipImpl = () => new Map();
ownershipsToAllocationsImpl = () => [];
});
it('calls postOrUpdateComment with the PR number, generated body, and bot marker', async () => {
await run(42);
assert.equal(postOrUpdateComment.mock.calls.length, 1, 'postOrUpdateComment should be called once');
assert.equal(postOrUpdateComment.mock.calls[0].arguments[0], 42);
assert.ok(postOrUpdateComment.mock.calls[0].arguments[1].includes(BOT_MARKER));
assert.equal(postOrUpdateComment.mock.calls[0].arguments[2], BOT_MARKER);
});
it('includes renamed files in the changed files set', async () => {
getPrFilesImpl = async () => [
{ filename: 'new-name.ts', additions: 5, deletions: 0, previous_filename: 'old-name.ts' },
];
let capturedChangedFiles = new Set();
assignOwnershipImpl = (files) => {
capturedChangedFiles = files;
return new Map();
};
await run(42);
assert.ok(capturedChangedFiles.has('new-name.ts'), 'new filename must be included');
assert.ok(capturedChangedFiles.has('old-name.ts'), 'previous filename must be included');
});
it('limits recommendations to the top 3 allocations by total line changes', async () => {
getPrFilesImpl = async () => [
{ filename: 'a.ts', additions: 1, deletions: 0 },
{ filename: 'b.ts', additions: 50, deletions: 50 },
{ filename: 'c.ts', additions: 20, deletions: 20 },
{ filename: 'd.ts', additions: 10, deletions: 10 },
];
assignOwnershipImpl = () =>
new Map([
['@n8n-io/team-a', ['a.ts']],
['@n8n-io/team-b', ['b.ts']],
['@n8n-io/team-c', ['c.ts']],
['@n8n-io/team-d', ['d.ts']],
]);
ownershipsToAllocationsImpl = () => [
{ team: '@n8n-io/team-a', fileCount: 1 },
{ team: '@n8n-io/team-b', fileCount: 1 },
{ team: '@n8n-io/team-c', fileCount: 1 },
{ team: '@n8n-io/team-d', fileCount: 1 },
];
await run(42);
const body = postOrUpdateComment.mock.calls[0].arguments[1];
assert.match(body, /@n8n-io\/team-b[\s\S]*@n8n-io\/team-c[\s\S]*@n8n-io\/team-d/);
assert.doesNotMatch(body, /@n8n-io\/team-a/);
assert.match(body, /\| Other teams \| 1 \| 25% \| \+1 \/ -0 \| \+0 \/ -0 \| \+0 \/ -0 \|/);
});
it('posted comment body includes the ownership-first overview table', async () => {
getPrFilesImpl = async () => [{ filename: 'src/foo.ts', additions: 50, deletions: 5 }];
ownershipsToAllocationsImpl = () => [{ team: '@n8n-io/cli-team', fileCount: 1 }];
assignOwnershipImpl = () => new Map([['@n8n-io/cli-team', ['src/foo.ts']]]);
await run(42);
const body = postOrUpdateComment.mock.calls[0].arguments[1];
assert.match(body, /## PR review overview/);
assert.match(body, /\| @n8n-io\/cli-team \| 1 \| 100% \| \+50 \/ -5 \|/);
});
});

View File

@ -2,9 +2,11 @@
"name": "workflow-scripts",
"scripts": {
"test": "node --test --experimental-test-module-mocks ./*.test.mjs ./quality/*.test.mjs ./slack/*.test.mjs ./stale/*.test.mjs ../../scripts/licenses/*.test.mjs",
"generate-sbom": "FETCH_LICENSE=true cdxgen -t pnpm --no-install-deps --profile license-compliance -o ../../sbom-source.cdx.json ../../compiled/",
"generate-sbom": "FETCH_LICENSE=true cdxgen -t pnpm --no-install-deps --profile license-compliance --spec-version 1.6 -o ../../sbom-source.cdx.json ../../compiled/",
"enrich-sbom": "node ../../scripts/licenses/enrich-sbom.mjs ../../sbom-source.cdx.json",
"render-licenses-md": "node ../../scripts/licenses/render-licenses-md.mjs ../../sbom-source.cdx.json ../../packages/cli/THIRD_PARTY_LICENSES.md ../../compiled/node_modules",
"generate-licenses": "pnpm generate-sbom && pnpm render-licenses-md"
"check-licenses": "node ../../scripts/licenses/check-sbom-licenses.mjs ../../sbom-source.cdx.json --allow-ref=LicenseRef-n8n-sustainable-use --allow-ref=LicenseRef-n8n-enterprise",
"generate-licenses": "pnpm generate-sbom && pnpm enrich-sbom && pnpm render-licenses-md && pnpm check-licenses"
},
"dependencies": {
"@actions/github": "9.0.0",
@ -20,5 +22,10 @@
},
"devDependencies": {
"conventional-changelog-angular": "8.3.0"
},
"pnpm": {
"overrides": {
"brace-expansion@5": "^5.0.5"
}
}
}

View File

@ -4,6 +4,9 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
brace-expansion@5: ^5.0.5
importers:
.:
@ -365,8 +368,8 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@5.0.4:
resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
byte-counter@0.1.0:
@ -1567,7 +1570,7 @@ snapshots:
boolbase@1.0.0: {}
brace-expansion@5.0.4:
brace-expansion@5.0.6:
dependencies:
balanced-match: 4.0.4
@ -2029,7 +2032,7 @@ snapshots:
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4
brace-expansion: 5.0.6
minimist@1.2.8: {}

View File

@ -18,7 +18,7 @@ import { initGithub, getEventFromGithubEventPath } from '../github-helpers.mjs';
export const SIZE_LIMIT = 1000;
export const OVERRIDE_COMMAND = '/size-limit-override';
export const EXCLUDE_PATTERNS = [
export const TEST_PATTERNS = [
// Test files (by extension)
'**/*.test.ts',
'**/*.test.js',
@ -38,12 +38,17 @@ export const EXCLUDE_PATTERNS = [
'**/__mocks__/**',
// Dedicated testing package
'packages/testing/**',
];
export const MISC_PATTERNS = [
// Lock file (can produce massive diffs on dependency changes)
'pnpm-lock.yaml',
'**/*.md',
'**/*.mdx'
'**/*.mdx',
];
export const EXCLUDE_PATTERNS = [...TEST_PATTERNS, ...MISC_PATTERNS];
const BOT_MARKER = '<!-- pr-size-check -->';
/**

View File

@ -19,16 +19,21 @@ export function metric(name, value, unit, dimensions = {}) {
/** Build git/ci/runner context from environment variables. */
export function buildContext(benchmarkName = null) {
const ref = process.env.GITHUB_REF ?? '';
// Use `||` (not `??`) when falling back across GitHub env vars: on `push` and
// `merge_group` events GITHUB_HEAD_REF is set to '' (empty string, not undefined),
// so `??` would never fall through to GITHUB_REF_NAME and every non-PR row would
// land in BigQuery with git_branch = ''. The same applies to any other GITHUB_*
// var whose intended fallback is null — preserve null, not ''.
const ref = process.env.GITHUB_REF || '';
const prMatch = ref.match(/refs\/pull\/(\d+)/);
const runId = process.env.GITHUB_RUN_ID ?? null;
const runId = process.env.GITHUB_RUN_ID || null;
return {
timestamp: new Date().toISOString(),
benchmark_name: benchmarkName,
git: {
sha: process.env.GITHUB_SHA?.slice(0, 8) ?? null,
branch: process.env.GITHUB_HEAD_REF ?? process.env.GITHUB_REF_NAME ?? null,
sha: process.env.GITHUB_SHA?.slice(0, 8) || null,
branch: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || null,
pr: prMatch ? parseInt(prMatch[1], 10) : null,
},
ci: {
@ -37,8 +42,8 @@ export function buildContext(benchmarkName = null) {
runId && process.env.GITHUB_REPOSITORY
? `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${runId}`
: null,
job: process.env.GITHUB_JOB ?? null,
workflow: process.env.GITHUB_WORKFLOW ?? null,
job: process.env.GITHUB_JOB || null,
workflow: process.env.GITHUB_WORKFLOW || null,
attempt: process.env.GITHUB_RUN_ATTEMPT
? parseInt(process.env.GITHUB_RUN_ATTEMPT, 10)
: null,

67
.github/scripts/verify-safechain.mjs vendored Normal file
View File

@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* Verify that Aikido SafeChain is active by running `safe-chain-verify`
* *through* each wrapped package manager. SafeChain's shim prints
* "OK: Safe-chain works!" only when it is intercepting the call — a truer
* check than matching the resolved binary path.
*
* SafeChain's CI shims are bare executables on Unix but `.cmd`/`.ps1` on Windows; running
* the command with `shell: true` delegates to the platform shell
* (`/bin/sh` / `cmd.exe`), so each OS resolves its own shim form and a
* single code path covers both.
*
* Note: this must run as a *separate* step after `safe-chain setup-ci`.
* `setup-ci` exposes the shims via `$GITHUB_PATH`, which the runner only
* applies to `PATH` between steps — so the shims are not on this process's
* PATH until the activation step has finished.
*
* Exits 0 only when every package manager reports OK; 1 otherwise.
*/
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const PACKAGE_MANAGERS = ['npm', 'pnpm'];
/**
* @param {string} pm Package manager to probe (e.g. 'npm', 'pnpm')
* @param {typeof spawnSync} spawnFn Injectable for testing
* @returns {{ pm: string, ok: boolean, output: string }}
*/
export function verifyPackageManager(pm, spawnFn = spawnSync) {
const { status, stdout, stderr } = spawnFn(pm, ['safe-chain-verify'], {
shell: true,
encoding: 'utf8',
});
const output = `${stdout ?? ''}${stderr ?? ''}`;
const ok = status === 0 && output.includes('OK: Safe-chain works!');
return { pm, ok, output };
}
/**
* Verify Safe Chain for each package manager defined in PACKAGE_MANAGERS
*
* @param {typeof spawnSync} spawnFn Injectable for testing
* @returns {boolean} true when all package managers report OK
*/
export function verifySafeChain(spawnFn = spawnSync) {
let allOk = true;
for (const pm of PACKAGE_MANAGERS) {
const { ok, output } = verifyPackageManager(pm, spawnFn);
process.stdout.write(output.endsWith('\n') ? output : `${output}\n`);
if (!ok) {
console.error(
`::error::SafeChain inactive: '${pm} safe-chain-verify' failed or returned unexpected output`,
);
allOk = false;
}
}
return allOk;
}
// only run when executed directly, not when imported by tests
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
console.log("Verifying SafeChain");
process.exit(verifySafeChain() ? 0 : 1);
}

View File

@ -0,0 +1,57 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { verifyPackageManager, verifySafeChain } from './verify-safechain.mjs';
/**
* Run these tests by running
*
* node --test ./.github/scripts/verify-safechain.test.mjs
* */
// Build a fake spawnSync that returns a canned result per package manager.
const fakeSpawn = (resultsByPm) => (pm) => resultsByPm[pm] ?? { status: 127, stdout: '', stderr: '' };
describe('verifyPackageManager', () => {
it('is ok when status is 0 and output contains OK: Safe-chain works!', () => {
const spawn = fakeSpawn({ npm: { status: 0, stdout: 'OK: Safe-chain works!\n', stderr: '' } });
assert.deepEqual(verifyPackageManager('npm', spawn), {
pm: 'npm',
ok: true,
output: 'OK: Safe-chain works!\n',
});
});
it('is not ok when the command exits non-zero', () => {
const spawn = fakeSpawn({ npm: { status: 1, stdout: '', stderr: 'command not found' } });
assert.equal(verifyPackageManager('npm', spawn).ok, false);
});
it('is not ok when exit is 0 but OK marker is missing (unwrapped pm)', () => {
const spawn = fakeSpawn({ npm: { status: 0, stdout: 'Unknown command: safe-chain-verify', stderr: '' } });
assert.equal(verifyPackageManager('npm', spawn).ok, false);
});
it('reads the marker from stderr too', () => {
const spawn = fakeSpawn({ npm: { status: 0, stdout: '', stderr: 'OK: Safe-chain works!' } });
assert.equal(verifyPackageManager('npm', spawn).ok, true);
});
});
describe('verifySafeChain', () => {
it('returns true only when every package manager reports OK', () => {
const spawn = fakeSpawn({
npm: { status: 0, stdout: 'OK: Safe-chain works!\n', stderr: '' },
pnpm: { status: 0, stdout: 'OK: Safe-chain works!\n', stderr: '' },
});
assert.equal(verifySafeChain(spawn), true);
});
it('returns false when any package manager fails', () => {
const spawn = fakeSpawn({
npm: { status: 0, stdout: 'OK: Safe-chain works!\n', stderr: '' },
pnpm: { status: 1, stdout: '', stderr: 'nope' },
});
assert.equal(verifySafeChain(spawn), false);
});
});

File diff suppressed because one or more lines are too long

View File

@ -29,7 +29,7 @@ jobs:
# While we're on alpine 3.22 awaiting NODE-4184 (graphicsmagick → sharp
# migration that unblocks 3.23), Node 26 base builds can't succeed.
# Restore '26' to this matrix once 3.23 is back.
node_version: ['22', '24.15.0']
node_version: ['22', '24.16.0']
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

View File

@ -39,7 +39,21 @@ permissions:
statuses: write
concurrency:
group: cla-check-${{ github.event.pull_request.number || github.event.issue.number || github.event.merge_group.head_sha || github.event.inputs.pr_number || github.ref }}
# Concurrency is evaluated BEFORE the job-level `if` below, so a comment that
# isn't `/cla-check` (e.g. a bot's "StageReview" comment) still creates a run.
# If that run shared the PR's group (issue number == PR number) it would, with
# cancel-in-progress, cancel the in-progress pull_request_target check — then
# get skipped by the `if` and do nothing, leaving the "CLA Check" status stuck
# on pending. Keep comment-triggered runs in their own group so they can only
# ever cancel each other, never the PR push check that gates the merge. PR
# pushes, merge_group and dispatch keep the head-based group (push debounce).
group: >-
cla-check-${{ github.event_name == 'issue_comment'
&& format('comment-{0}', github.event.issue.number)
|| github.event.pull_request.number
|| github.event.merge_group.head_sha
|| github.event.inputs.pr_number
|| github.ref }}
cancel-in-progress: true
env:

View File

@ -1,12 +1,12 @@
# .github/workflows/ci-codeowners-validation.yml
name: "CI: Validate CODEOWNERS"
name: 'CI: Validate CODEOWNERS'
# Only run when CODEOWNERS or packages change
on:
pull_request:
paths:
- ".github/CODEOWNERS"
- "packages/**"
- '.github/CODEOWNERS'
- 'packages/**'
jobs:
validate:
@ -19,5 +19,5 @@ jobs:
# Start with safe checks only. Add "owners" and
# experimental_checks: "notowned" once the file has settled
# and skip patterns are configured.
checks: "files,duppatterns,syntax"
github_access_token: "${{ secrets.GITHUB_TOKEN }}"
checks: 'files,duppatterns,syntax'
github_access_token: '${{ secrets.GITHUB_TOKEN }}'

View File

@ -1,13 +1,25 @@
name: 'CI: Instance AI Evals'
# Auto-trigger disabled — run manually via workflow_dispatch.
# Auto-runs on PR open/reopen/ready (non-draft, path-filtered). Pushes don't
# re-trigger (no `synchronize`); use workflow_dispatch for manual runs.
on:
pull_request:
types: [opened, reopened, ready_for_review]
paths:
- 'packages/@n8n/instance-ai/src/**'
- 'packages/@n8n/instance-ai/evaluations/**'
- 'packages/cli/src/modules/instance-ai/**'
- 'packages/core/src/execution-engine/eval-mock-helpers.ts'
workflow_dispatch:
inputs:
branch:
description: 'GitHub branch to test'
required: false
default: 'master'
tier:
description: 'Test-case dataset to run (e.g. `pr`, `full`)'
required: false
default: 'full'
sandbox-provider:
description: 'Sandbox provider (n8n-sandbox or daytona)'
required: false
@ -28,11 +40,19 @@ concurrency:
jobs:
run-evals:
name: Instance AI Workflow Evals
if: github.repository == 'n8n-io/n8n'
# Skip drafts; allow workflow_dispatch (no pull_request payload).
if: >-
github.repository == 'n8n-io/n8n' &&
(github.event_name != 'pull_request' || github.event.pull_request.draft == false)
uses: ./.github/workflows/test-evals-instance-ai.yml
with:
branch: ${{ inputs.branch }}
sandbox-provider: ${{ inputs.sandbox-provider }}
# On PR events github.sha is the merge commit, so the eval tests the
# merged state (like e2e) and the checkout matches the key under which
# prepare-docker publishes the docker image cache.
branch: ${{ inputs.branch || github.sha }}
# PR events default to `pr`; dispatch defaults to `full`.
tier: ${{ inputs.tier || 'pr' }}
sandbox-provider: ${{ inputs.sandbox-provider || 'n8n-sandbox' }}
iterations: ${{ inputs.iterations }}
experiment-name: ${{ inputs.experiment-name }}
secrets: inherit

View File

@ -30,11 +30,11 @@ jobs:
strategy:
fail-fast: false
matrix:
node-version: [22.22.3, 24.15.0]
node-version: [22.22.3, 24.16.0]
with:
ref: ${{ github.sha }}
nodeVersion: ${{ matrix.node-version }}
collectCoverage: ${{ matrix.node-version == '24.15.0' }}
collectCoverage: ${{ matrix.node-version == '24.16.0' }}
secrets: inherit
lint:

View File

@ -0,0 +1,60 @@
name: 'CI: Owners Assign Reviewers'
# Opt-in reviewer auto-assignment.
#
# When a PR carries the `Auto-assign reviewers` label, requests review from
# every team that owns a changed file (based on .github/OWNERS). Without the
# label this is a no-op — opting in is entirely up to the PR author.
#
# Assignment runs once: afterwards the trigger label is swapped for a
# `Reviewers auto-assigned` marker, and any PR already carrying that marker is
# skipped. This keeps later `synchronize` events from re-requesting the teams.
#
# Runs on `labeled` (so adding the label assigns immediately) and on
# `synchronize` (so newly-touched files still get assigned before the marker
# lands). The label checks live in the script, so other PRs exit early.
on:
pull_request:
types:
- labeled
- synchronize
- reopened
branches:
- master
jobs:
assign-reviewers:
name: Assign reviewers
# Skipped for:
# - PRs from forks (no token to assign with)
# - Bot-authored PRs (Dependabot, Renovate, etc.)
if: |
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.type != 'Bot'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
steps:
- name: Generate App Token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
- name: Assign reviewer teams
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
run: node .github/scripts/owners-assign-reviewers.mjs

View File

@ -1,7 +1,8 @@
name: 'CI: Owners Review Recommendations'
# Posts (or updates) a PR comment recommending which @n8n-io teams should
# review the PR, based on file ownership defined in .github/OWNERS.
# Posts (or updates) a PR comment with recommended reviewer teams (based on
# file ownership in .github/OWNERS) and a breakdown of changed lines by
# category (source code, test files, misc).
#
# Advisory only — does not gate merging.

View File

@ -44,39 +44,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node .github/scripts/quality/handle-size-override.mjs
check-ownership-checkbox:
name: Ownership Acknowledgement
# Checks that the author has acknowledged the ownership of their code
# by checking the checkbox in the PR summary.
# Skipped for bot-authored PRs (Dependabot, Renovate, github-actions, Aikido, etc.).
# The required aggregator `required-pr-quality-checks` treats skipped as success.
if: |
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
!contains(github.event.pull_request.labels.*.name, 'automation:backport') &&
!contains(github.event.pull_request.title, '(backport to') &&
github.event.pull_request.user.type != 'Bot'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
cache-dependency-path: .github/scripts/pnpm-lock.yaml
- name: Check ownership checkbox
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node .github/scripts/quality/check-ownership-checkbox.mjs
check-pr-size:
name: PR Size Limit
# Checks that the PR size doesn't exceed the limit (currently 1000 lines)
@ -160,7 +127,7 @@ jobs:
required-pr-quality-checks:
name: Required PR Quality Checks
needs: [check-ownership-checkbox, check-pr-size, check-static-analysis]
needs: [check-pr-size, check-static-analysis]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
@ -174,4 +141,3 @@ jobs:
with:
mode: validate
job-results: ${{ toJSON(needs) }}

View File

@ -95,7 +95,9 @@ jobs:
packages/testing/playwright/package.json
.github/workflows/test-dev-server-smoke-reusable.yml
workflows: .github/**
workflow-scripts: .github/scripts/**
workflow-scripts:
.github/scripts/**
scripts/licenses/**
performance:
packages/testing/performance/**
packages/workflow/src/**
@ -120,16 +122,26 @@ jobs:
packages/cli/test/integration/**
packages/cli/test/migration/**
packages/cli/test/shared/db/**
packages/cli/test/setup-testcontainers.js
packages/cli/test/teardown-testcontainers.js
packages/cli/jest.config.integration.js
packages/cli/jest.config.integration.testcontainers.js
packages/cli/jest.config.migration.js
packages/cli/jest.config.migration.testcontainers.js
packages/@n8n/db/**
packages/@n8n/backend-test-utils/**
packages/cli/**/__tests__/**
packages/testing/containers/services/postgres.ts
.github/workflows/test-db-reusable.yml
docs/generated/**
.tbls.sqlite.yml
.tbls.postgres.yml
- name: Setup and Build
if: fromJSON(steps.ci-filter.outputs.results).ci || fromJSON(steps.ci-filter.outputs.results).e2e
uses: ./.github/actions/setup-nodejs
with:
build-command: ${{ fromJSON(steps.ci-filter.outputs.results).ci && 'pnpm build:unchecked' || 'pnpm turbo run build --filter=@n8n/playwright-janitor' }}
build-command: ${{ fromJSON(steps.ci-filter.outputs.results).ci && 'pnpm build' || 'pnpm turbo run build --filter=@n8n/playwright-janitor' }}
- name: Run format check
if: fromJSON(steps.ci-filter.outputs.results).ci
@ -174,6 +186,8 @@ jobs:
if: needs.install-and-build.outputs.ci == 'true'
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
needs: install-and-build
env:
NODE_OPTIONS: --max-old-space-size=7168
steps:
- uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1
with:
@ -222,7 +236,8 @@ jobs:
(needs.install-and-build.outputs.runtime == 'true'
|| needs.install-and-build.outputs.e2e == 'true'
|| needs.install-and-build.outputs.e2e_performance == 'true'
|| contains(github.event.pull_request.labels.*.name, 'force-e2e'))
|| contains(github.event.pull_request.labels.*.name, 'force-e2e')
|| contains(github.event.pull_request.labels.*.name, 'run-perf-tests'))
uses: ./.github/workflows/prepare-docker-reusable.yml
with:
branch: ${{ needs.install-and-build.outputs.commit_sha }}
@ -304,9 +319,13 @@ jobs:
name: E2E Performance
needs: [install-and-build, prepare-docker]
# Performance is internal-only (license secrets required, not available on forks).
# Runs when path filter trips OR when the PR carries the `run-perf-tests` label
# (opt-in coverage for shared-utility / Vue Flow changes that the path filter
# would otherwise miss).
if: >-
needs.prepare-docker.result == 'success' &&
needs.install-and-build.outputs.e2e_performance == 'true' &&
(needs.install-and-build.outputs.e2e_performance == 'true' ||
contains(github.event.pull_request.labels.*.name, 'run-perf-tests')) &&
github.event.pull_request.head.repo.fork != true
uses: ./.github/workflows/test-e2e-performance-reusable.yml
secrets: inherit
@ -387,5 +406,5 @@ jobs:
if: always()
uses: ./.github/workflows/util-qa-metrics-comment-reusable.yml
with:
metrics: memory-heap-used-baseline,memory-rss-baseline,instance-ai-heap-used-baseline,instance-ai-rss-baseline,agents-heap-used-baseline,agents-rss-baseline,docker-image-size-n8n,docker-image-size-runners
metrics: memory-heap-used-baseline,memory-rss-baseline,instance-ai-heap-used-baseline,instance-ai-rss-baseline,agents-heap-used-baseline,agents-rss-baseline,docker-image-size-n8n,docker-image-size-runners,canvas-cold-load-S-ms,canvas-cold-load-M-ms,canvas-cold-load-L-ms,canvas-server-heap-M-mb,canvas-server-heap-L-mb,canvas-browser-heap-M-mb,canvas-browser-heap-L-mb,canvas-pan-frame-p95-M-ms,canvas-pan-frame-p95-L-ms,canvas-tidy-up-M-ms,canvas-tidy-up-L-ms,canvas-exec-heavy-M-ms,canvas-exec-heavy-L-ms
secrets: inherit

View File

@ -2,7 +2,7 @@ name: 'Util: Clean Stale Branches'
on:
schedule:
- cron: "0 10 * * 1" # Every monday
- cron: '0 10 * * 1' # Every monday
workflow_dispatch:
inputs:
dry-run:

View File

@ -7,7 +7,7 @@ name: 'Docker: Build and Push'
env:
NODE_OPTIONS: '--max-old-space-size=7168'
NODE_VERSION: '24.15.0'
NODE_VERSION: '24.16.0'
on:
schedule:
@ -421,6 +421,65 @@ jobs:
--predicate security/vex.openvex.json \
${{ needs.create_multi_arch_manifest.outputs.runners_distroless_image }}@${{ needs.create_multi_arch_manifest.outputs.runners_distroless_digest }}
# SBOM Attestation - one enriched, license-gated CycloneDX SBOM per image.
# cdxgen scans the image (OS + npm), enrich-sbom resolves first-party + override
# licenses, check-sbom-licenses gates the npm components, and the result is
# attested to the image digest via cosign (same mechanism as VEX/provenance).
# This replaces BuildKit's `sbom: true` so the image carries the same resolved
# license picture as the release SBOM rather than the un-enriched syft output.
sbom-attestation:
name: SBOM Attestation
needs:
[
determine-build-context,
build-and-push-docker,
create_multi_arch_manifest,
provenance-n8n,
provenance-runners,
provenance-runners-distroless,
]
if: |
always() &&
needs.create_multi_arch_manifest.result == 'success' &&
(needs.determine-build-context.outputs.release_type == 'stable' ||
needs.determine-build-context.outputs.release_type == 'rc' ||
needs.determine-build-context.outputs.release_type == 'nightly')
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
id-token: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js and install SBOM tooling
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
cache-dependency-path: .github/scripts/pnpm-lock.yaml
- name: Install Cosign
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
- name: Login to GHCR
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate, enrich, gate and attest SBOM per image
env:
N8N_IMAGE: ${{ needs.create_multi_arch_manifest.outputs.n8n_image }}
N8N_DIGEST: ${{ needs.create_multi_arch_manifest.outputs.n8n_digest }}
RUNNERS_IMAGE: ${{ needs.create_multi_arch_manifest.outputs.runners_image }}
RUNNERS_DIGEST: ${{ needs.create_multi_arch_manifest.outputs.runners_digest }}
DISTROLESS_IMAGE: ${{ needs.create_multi_arch_manifest.outputs.runners_distroless_image }}
DISTROLESS_DIGEST: ${{ needs.create_multi_arch_manifest.outputs.runners_distroless_digest }}
run: node .github/scripts/attest-image-sbom.mjs
security-scan:
name: Security Scan
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest]

View File

@ -14,6 +14,10 @@ on:
- baseline # score files with no result yet (the `new` bucket)
- coverage # revisit the weakest scored files (`red`/`stale`, lowest first)
default: both
source_file:
description: 'Optional: re-score this exact repo-relative file, skipping the picker (e.g. packages/workflow/src/common/get-node-by-name.ts). Used by the lane:mutation-increase close handler to refresh the ledger.'
type: string
default: ''
permissions:
contents: read
@ -36,6 +40,7 @@ jobs:
id: build
env:
REQUESTED_MODE: ${{ github.event.inputs.mode || 'both' }}
SOURCE_FILE: ${{ github.event.inputs.source_file || '' }}
# vitest packages only. A package with its own stryker.config.mjs overrides
# the shared default (scripts/mutation-health/stryker.default.mjs) — n8n-workflow
# does this to run the legacy expression engine and dodge the isolated-vm
@ -48,9 +53,17 @@ jobs:
{ name: "@n8n/crdt", dir: "packages/@n8n/crdt", slug: "crdt" },
{ name: "@n8n/decorators", dir: "packages/@n8n/decorators", slug: "decorators" },
];
const req = process.env.REQUESTED_MODE;
const modes = req === "both" ? ["baseline", "coverage"] : [req];
const include = packages.flatMap((p) => modes.map((mode) => ({ ...p, mode })));
const sourceFile = (process.env.SOURCE_FILE || "").trim();
let include;
if (sourceFile) {
const pkg = packages.find((p) => sourceFile.startsWith(p.dir + "/"));
if (!pkg) throw new Error("No mutation-tracked package owns " + sourceFile);
include = [{ ...pkg, mode: "file", source_file: sourceFile }];
} else {
const req = process.env.REQUESTED_MODE;
const modes = req === "both" ? ["baseline", "coverage"] : [req];
include = packages.flatMap((p) => modes.map((mode) => ({ ...p, mode })));
}
console.log("matrix=" + JSON.stringify({ include }));
' >> "$GITHUB_OUTPUT"
@ -72,6 +85,7 @@ jobs:
PKG_DIR: ${{ matrix.dir }}
MODE: ${{ matrix.mode }}
REPORTS_DIR: ${{ matrix.dir }}/reports/mutation
SOURCE_FILE: ${{ matrix.source_file || '' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -90,6 +104,12 @@ jobs:
- name: Pick next source file
id: pick
run: |
if [ -n "$SOURCE_FILE" ]; then
echo "::notice::On-demand re-score of $SOURCE_FILE (picker skipped)."
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "source-rel=${SOURCE_FILE#"$PKG_DIR"/}" >> "$GITHUB_OUTPUT"
exit 0
fi
picked_json=$(node scripts/mutation-health/pick-next.mjs \
--package-dir "$PKG_DIR" \
--ledger-file "$REPORTS_DIR/live-ledger.json" \
@ -108,11 +128,12 @@ jobs:
- name: Mutate the picked source file
if: steps.pick.outputs.skip != 'true'
# Exit code semantics from mutate.mjs:
# 0 — score >= threshold (green)
# 1 — score < threshold (red) — emit step still runs, this is normal
# 0 — gate passed: score >= threshold AND no unjustified survivors (green)
# 1 — gate failed: score < threshold OR >=1 Survived/NoCoverage mutant (red)
# — emit step still runs, this is normal
# 2 — usage error
# 3 — Stryker hard failure (no summary.json) — must fail the job
# We capture rc explicitly so we can distinguish "below threshold" from
# We capture rc explicitly so we can distinguish "gate failed" from
# "Stryker crashed." continue-on-error: true would collapse them.
run: |
set +e

View File

@ -8,6 +8,11 @@ on:
description: 'Release Track'
required: true
type: string
force:
description: 'Create the PR even when there are no actionable commits'
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
@ -16,6 +21,11 @@ on:
required: true
type: choice
options: [stable, beta, v1]
force:
description: 'Create the PR even when there are no actionable commits'
required: false
type: boolean
default: true
jobs:
determine-version-info:
@ -23,7 +33,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
release_candidate_branch: ${{ steps.determine-branch.outputs.release_candidate_branch }}
should_update: ${{ steps.determine-branch.outputs.should_update }}
should_create_pr: ${{ steps.determine-branch.outputs.should_create_pr }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -41,12 +51,13 @@ jobs:
id: determine-branch
env:
TRACK: ${{ inputs.track }}
FORCE: ${{ inputs.force }}
run: node .github/scripts/determine-release-candidate-branch-for-track.mjs
skip-release-pr:
name: Skip release PR (no new commits)
needs: [determine-version-info]
if: needs.determine-version-info.outputs.should_update != 'true'
if: needs.determine-version-info.outputs.should_create_pr != 'true'
runs-on: ubuntu-latest
steps:
- name: Log skip reason
@ -55,7 +66,7 @@ jobs:
create-release-pr:
name: Create release PR
needs: [determine-version-info]
if: needs.determine-version-info.outputs.should_update == 'true'
if: needs.determine-version-info.outputs.should_create_pr == 'true'
uses: ./.github/workflows/release-create-pr.yml
secrets: inherit
with:

View File

@ -128,7 +128,7 @@ jobs:
branch: 'release-pr/${{ env.NEXT_RELEASE }}'
commit-message: ':rocket: Release ${{ env.NEXT_RELEASE }}'
delete-branch: true
labels: release,release:${{ inputs.release-type }}
labels: release,release:${{ inputs.release-type }},automation:release
title: ':rocket: Release ${{ env.NEXT_RELEASE }}'
body: ${{ steps.generate-body.outputs.content }}

View File

@ -43,7 +43,7 @@ jobs:
needs: [determine-version-info]
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
timeout-minutes: 20
timeout-minutes: 30
environment: npm
permissions:
id-token: write

View File

@ -75,7 +75,7 @@ jobs:
steps:
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 24.15.0
node-version: 24.16.0
# Remove after https://github.com/npm/cli/issues/8547 gets resolved
- run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc

View File

@ -15,3 +15,4 @@ jobs:
secrets: inherit
with:
track: ${{ matrix.track }}
force: ${{ github.event_name == 'workflow_dispatch' }}

View File

@ -56,6 +56,16 @@ jobs:
# produce sbom-source.cdx.json + packages/cli/THIRD_PARTY_LICENSES.md.
N8N_GENERATE_LICENSES: 'true'
# Release-blocking gate. build:deploy already enriches + gates the SBOM via
# the generate-licenses pipeline; re-run the gate here as an explicit,
# un-skippable check on the exact artifact we are about to attest, so an
# unlicensed or non-SPDX component can never reach the signed attestation.
- name: Gate SBOM on resolved SPDX licenses
run: |
node scripts/licenses/check-sbom-licenses.mjs \
sbom-source.cdx.json \
--allow-ref=LicenseRef-n8n-sustainable-use --allow-ref=LicenseRef-n8n-enterprise
- name: Attest SBOM for release
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
@ -74,11 +84,10 @@ jobs:
--clobber
COMPONENT_COUNT=$(jq '.components | length' sbom-source.cdx.json 2>/dev/null || echo "unknown")
MISSING_LICENSES=$(jq '[.components[] | select((.licenses // []) | length == 0)] | length' sbom-source.cdx.json 2>/dev/null || echo "unknown")
VEX_STATEMENTS=$(jq '.statements | length' security/vex.openvex.json 2>/dev/null || echo "0")
echo "SBOM, VEX, and license manifest attached to release"
echo " - SBOM: $COMPONENT_COUNT components from shipped artifact (no devDeps, no optional deps)"
echo " - SBOM: $MISSING_LICENSES components missing license metadata"
echo " - SBOM: all components carry a resolved SPDX license (gated before attest)"
echo " - VEX: $VEX_STATEMENTS CVE statements"
echo " - THIRD_PARTY_LICENSES.md: rendered from SBOM (same artefact served by /third-party-licenses endpoint)"

View File

@ -13,16 +13,10 @@ on:
type: string
description: 'Full image reference to scan e.g. ghcr.io/n8n-io/n8n:latest'
required: true
secrets:
QBOT_SLACK_TOKEN:
required: true
permissions:
contents: read
env:
SLACK_CHANNEL_ID: C0AHNJU9XFA #updates-security
jobs:
security_scan:
name: Security - Scan Docker Image With Trivy
@ -36,7 +30,6 @@ jobs:
security/trivy.yaml
security/trivy-ignore-policy.rego
.github/scripts/retry.mjs
.github/scripts/slack
sparse-checkout-cone-mode: false
- name: Pull Docker image with retry
@ -165,17 +158,3 @@ jobs:
echo "🔍 **View detailed logs above for full analysis**"
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Send Slack notification
if: steps.process_results.outputs.vulnerabilities_found == 'true'
env:
SLACK_TOKEN: ${{ secrets.QBOT_SLACK_TOKEN }}
IMAGE_REF: ${{ inputs.image_ref }}
run: |
node .github/scripts/slack/notify.mjs \
--channel "$SLACK_CHANNEL_ID" \
--text "🚨 Trivy Scan: ${{ steps.process_results.outputs.critical_count }} Critical, ${{ steps.process_results.outputs.high_count }} High, ${{ steps.process_results.outputs.medium_count }} Medium, ${{ steps.process_results.outputs.low_count }} Low vulnerabilities found in $IMAGE_REF" \
--blocks trivy \
--results trivy-results.json \
--image-ref "$IMAGE_REF"

View File

@ -24,11 +24,17 @@ jobs:
runner: blacksmith-4vcpu-ubuntu-2204
test-cmd: pnpm test:sqlite
migration-cmd: pnpm test:sqlite:migrations
schema-check-cmd: pnpm --filter=@n8n/db schema:check:sqlite
collectCoverage: 'false'
- name: Postgres 16
runner: blacksmith-8vcpu-ubuntu-2204
# 4vcpu is enough since persistent-worker + template-DB make PG16
# complete in ~480s, which is now close to the SQLite leg (~360s)
# — the matrix wall is bounded by the slower leg, so the larger
# 8vcpu runner buys headroom we can't cash in anywhere.
runner: blacksmith-4vcpu-ubuntu-2204
test-cmd: pnpm test:postgres:integration:tc
migration-cmd: pnpm test:postgres:migrations:tc
schema-check-cmd: pnpm --filter=@n8n/db schema:check:postgres
TEST_IMAGE_POSTGRES: 'postgres:16'
collectCoverage: 'true'
env:
@ -54,6 +60,12 @@ jobs:
working-directory: packages/cli
run: ${{ matrix.migration-cmd }}
# Regenerates the schema docs for this leg's database (sqlite temp file /
# postgres testcontainer) and fails if they differ from what's committed.
# tbls runs as a Docker image in CI.
- name: Verify schema docs are up to date
run: ${{ matrix.schema-check-cmd }}
- name: Upload coverage to Codecov
if: env.COVERAGE_ENABLED == 'true'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
@ -61,5 +73,5 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
flags: backend-integration-postgres
name: backend-integration-postgres
files: ./packages/cli/coverage/cobertura-coverage.xml
files: ./packages/cli/coverage/lcov.info
fail_ci_if_error: false

View File

@ -10,6 +10,19 @@ concurrency:
cancel-in-progress: true
jobs:
# Shared build so the Turbo remote cache is warm before any test job starts.
# Downstream jobs call setup-nodejs with the default build-command and get
# ~30s cache hits rather than racing to build in parallel from cold.
build:
name: Install & Build
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
prepare-docker:
name: Prepare Docker (coverage)
uses: ./.github/workflows/prepare-docker-reusable.yml
@ -24,6 +37,7 @@ jobs:
# then blew past the timeout; duration-weighting keeps every shard even.
generate-matrix:
name: Generate shard matrix
needs: build
runs-on: blacksmith-2vcpu-ubuntu-2204
outputs:
matrix: ${{ steps.gen.outputs.matrix }}
@ -31,15 +45,15 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup and Build janitor
- name: Setup
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor
- name: Generate matrix (8 shards, duration-weighted)
- name: Generate matrix (16 shards, duration-weighted)
id: gen
run: |
MATRIX=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix 8 --orchestrate)
MATRIX=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix 16 --orchestrate)
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
e2e:
@ -60,9 +74,116 @@ jobs:
currents-project-id: 'LRxcNt'
secrets: inherit
# Unit + integration coverage, one leg per suite — no change-scoping, run in
# parallel with the E2E shards (the shared `build` job keeps Turbo cache warm).
# The integration leg adds a Postgres pre-pull and runs from packages/cli to
# reach branches a mocked DB can't (real query paths, multi-main, migrations).
unit-coverage:
name: ${{ matrix.name }}
needs: build
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
fail-fast: false
matrix:
include:
- name: Unit Coverage (backend + nodes)
artifact: unit-coverage
test-command: pnpm test:ci:backend:unit
artifact-path: packages/**/coverage/lcov.info
- name: Unit Coverage (frontend)
artifact: frontend-coverage
test-command: pnpm test:ci:frontend
artifact-path: packages/**/coverage/lcov.info
- name: Unit Coverage (CLI integration + Postgres)
artifact: integration-coverage
test-command: pnpm test:postgres:integration:tc
# Glob (not the single file) so the artifact keeps its packages/<pkg>/
# path, which aggregate-coverage.mjs uses to qualify SF: keys.
artifact-path: packages/**/coverage/lcov.info
working-directory: packages/cli
pre-pull-containers: true
env:
COVERAGE_ENABLED: 'true'
NODE_OPTIONS: --max-old-space-size=7168
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup
uses: ./.github/actions/setup-nodejs
- name: Pre-pull Test Container Images
if: matrix.pre-pull-containers
run: pnpm tsx packages/testing/containers/pull-test-images.ts || true
- name: Run tests
working-directory: ${{ matrix.working-directory || github.workspace }}
run: ${{ matrix.test-command }}
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact }}
path: ${{ matrix.artifact-path }}
retention-days: 3
# Both Python packages in one job — they're fast and share the uv install
# (each still pins its own Python version: 3.13 task-runner, 3.11 evals).
python-coverage:
name: Python Coverage (task-runner + ai-workflow-builder)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
with:
enable-cache: true
- name: Install just
uses: extractions/setup-just@e33e0265a09d6d736e2ee1e0eb685ef1de4669ff # v3.0.0
- name: Install Python 3.13 (task-runner)
working-directory: packages/@n8n/task-runner-python
run: uv python install 3.13
- name: Install task-runner dependencies
working-directory: packages/@n8n/task-runner-python
run: just sync-all
- name: Test task-runner-python
working-directory: packages/@n8n/task-runner-python
run: uv run pytest --cov=src --cov-report=xml --cov-report=term-missing
- name: Install Python 3.11 (ai-workflow-builder evals)
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python
run: uv python install 3.11
- name: Install ai-workflow-builder eval dependencies
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python
run: just sync-all
- name: Test ai-workflow-builder evals
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python
run: uv run pytest --cov=src --cov-report=xml --cov-report=term-missing
- name: Upload Python Coverage to Codecov
if: always()
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: |
packages/@n8n/task-runner-python/coverage.xml
packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/coverage.xml
flags: nightly-python
name: nightly-python-coverage
fail_ci_if_error: false
aggregate:
name: Aggregate Coverage
needs: e2e
needs: [e2e, unit-coverage, python-coverage]
if: always() && needs.e2e.result != 'skipped' && needs.e2e.result != 'cancelled'
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
@ -72,21 +193,34 @@ jobs:
- name: Setup Environment
uses: ./.github/actions/setup-nodejs
- name: Download shard artifacts
- name: Download E2E shard artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
pattern: coverage-shard-*
path: /tmp/shards/
- name: Build janitor (tested merge-coverage)
# No merge-multiple: packages/cli/coverage/lcov.info exists in both
# unit-coverage and integration-coverage — separate artifact subdirs
# prevent clobbering, and findFiles recurses into all of them so MCR
# unions unit + integration coverage for CLI correctly.
- name: Download unit + integration + frontend coverage artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
pattern: '*-coverage'
path: /tmp/unit-lcovs/
- name: Build janitor (merge-coverage)
run: pnpm turbo run build --filter=@n8n/playwright-janitor
# Report (shard lcovs → unified lcov) + impact map (per-spec lcovs →
# spec-keyed map), both via the property-tested janitor merge-coverage.
# Logic lives in the tested aggregate-coverage.mjs, not inline bash.
# Merges all lcovs into one lcov.info (SF: paths normalised to repo-root-
# relative) and builds the per-spec E2E impact map for PR selection.
- name: Aggregate coverage + build impact map
working-directory: packages/testing/playwright
run: node scripts/aggregate-coverage.mjs --shards=/tmp/shards --out=coverage
run: |
node scripts/aggregate-coverage.mjs \
--shards=/tmp/shards \
--unit-shards=/tmp/unit-lcovs \
--out=coverage
- name: Upload Coverage Report Artifact
if: always()
@ -96,14 +230,14 @@ jobs:
path: packages/testing/playwright/coverage/
retention-days: 14
- name: Upload E2E Coverage to Codecov
- name: Upload Combined Coverage to Codecov
if: always()
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/testing/playwright/coverage/lcov.info
flags: frontend-e2e
name: playwright-e2e
flags: nightly-full
name: nightly-full-coverage
fail_ci_if_error: false
# Codecov needs ~60-90s to finish indexing the fresh upload before its

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