mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
Merge branch 'master' into n8n-10005-chore-re-export-reka-ui-collapsible-primitives-from
Some checks failed
CI: Python / Checks (push) Has been cancelled
Some checks failed
CI: Python / Checks (push) Has been cancelled
This commit is contained in:
commit
0f3ec22a1a
|
|
@ -1 +0,0 @@
|
|||
../.claude/plugins/n8n/skills
|
||||
39
.agents/skills/AGENTS.md
Normal file
39
.agents/skills/AGENTS.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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 (``)
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -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`):
|
||||
|
||||
|
|
@ -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.
|
||||
---
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
---
|
||||
|
|
@ -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
|
||||
|
|
@ -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
21
.aikido
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"owner": {
|
||||
"name": "n8n"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "n8n",
|
||||
"source": "./"
|
||||
}
|
||||
]
|
||||
"name": "n8n",
|
||||
"owner": {
|
||||
"name": "n8n"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "n8n",
|
||||
"source": "./"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1
.claude/plugins/n8n/skills/community-pr-readiness-check
Symbolic link
1
.claude/plugins/n8n/skills/community-pr-readiness-check
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/community-pr-readiness-check
|
||||
1
.claude/plugins/n8n/skills/content-design
Symbolic link
1
.claude/plugins/n8n/skills/content-design
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/content-design
|
||||
1
.claude/plugins/n8n/skills/conventions
Symbolic link
1
.claude/plugins/n8n/skills/conventions
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/conventions
|
||||
1
.claude/plugins/n8n/skills/create-community-node-lint-rule
Symbolic link
1
.claude/plugins/n8n/skills/create-community-node-lint-rule
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/create-community-node-lint-rule
|
||||
1
.claude/plugins/n8n/skills/create-issue
Symbolic link
1
.claude/plugins/n8n/skills/create-issue
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/create-issue
|
||||
1
.claude/plugins/n8n/skills/create-pr
Symbolic link
1
.claude/plugins/n8n/skills/create-pr
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/create-pr
|
||||
1
.claude/plugins/n8n/skills/create-skill
Symbolic link
1
.claude/plugins/n8n/skills/create-skill
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/create-skill
|
||||
1
.claude/plugins/n8n/skills/db-migrations
Symbolic link
1
.claude/plugins/n8n/skills/db-migrations
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/db-migrations
|
||||
1
.claude/plugins/n8n/skills/design-system
Symbolic link
1
.claude/plugins/n8n/skills/design-system
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/design-system
|
||||
1
.claude/plugins/n8n/skills/experiments
Symbolic link
1
.claude/plugins/n8n/skills/experiments
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/experiments
|
||||
1
.claude/plugins/n8n/skills/human-like-code-review
Symbolic link
1
.claude/plugins/n8n/skills/human-like-code-review
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/human-like-code-review
|
||||
1
.claude/plugins/n8n/skills/linear-issue
Symbolic link
1
.claude/plugins/n8n/skills/linear-issue
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/linear-issue
|
||||
1
.claude/plugins/n8n/skills/loom-transcript
Symbolic link
1
.claude/plugins/n8n/skills/loom-transcript
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/loom-transcript
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
1
.claude/plugins/n8n/skills/node-add-oauth
Symbolic link
1
.claude/plugins/n8n/skills/node-add-oauth
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/node-add-oauth
|
||||
1
.claude/plugins/n8n/skills/protect-endpoints
Symbolic link
1
.claude/plugins/n8n/skills/protect-endpoints
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/protect-endpoints
|
||||
1
.claude/plugins/n8n/skills/reproduce-bug
Symbolic link
1
.claude/plugins/n8n/skills/reproduce-bug
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/reproduce-bug
|
||||
1
.claude/plugins/n8n/skills/spec-driven-development
Symbolic link
1
.claude/plugins/n8n/skills/spec-driven-development
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/spec-driven-development
|
||||
|
|
@ -1 +0,0 @@
|
|||
plugins/n8n/skills
|
||||
File diff suppressed because it is too large
Load Diff
29
.github/WORKFLOWS.md
vendored
29
.github/WORKFLOWS.md
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
.github/actionlint.yml
vendored
2
.github/actionlint.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
69
.github/actions/ci-filter/ci-filter.mjs
vendored
69
.github/actions/ci-filter/ci-filter.mjs
vendored
|
|
@ -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
|
||||
|
|
|
|||
139
.github/actions/setup-nodejs/action.yml
vendored
139
.github/actions/setup-nodejs/action.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
89
.github/scripts/attest-image-sbom.mjs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
37
.github/scripts/attest-image-sbom.test.mjs
vendored
Normal file
37
.github/scripts/attest-image-sbom.test.mjs
vendored
Normal 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({}), []);
|
||||
});
|
||||
});
|
||||
2
.github/scripts/bump-versions.mjs
vendored
2
.github/scripts/bump-versions.mjs
vendored
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
2
.github/scripts/docker/docker-config.mjs
vendored
2
.github/scripts/docker/docker-config.mjs
vendored
|
|
@ -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 = {
|
||||
|
|
|
|||
118
.github/scripts/github-helpers.mjs
vendored
118
.github/scripts/github-helpers.mjs
vendored
|
|
@ -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
90
.github/scripts/github-helpers.test.mjs
vendored
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
15
.github/scripts/jsconfig.json
vendored
15
.github/scripts/jsconfig.json
vendored
|
|
@ -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"]
|
||||
}
|
||||
|
||||
|
|
|
|||
104
.github/scripts/owners-assign-reviewers.mjs
vendored
Normal file
104
.github/scripts/owners-assign-reviewers.mjs
vendored
Normal 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);
|
||||
}
|
||||
174
.github/scripts/owners-assign-reviewers.test.mjs
vendored
Normal file
174
.github/scripts/owners-assign-reviewers.test.mjs
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
294
.github/scripts/owners-review-recommendations.mjs
vendored
294
.github/scripts/owners-review-recommendations.mjs
vendored
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 \|/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
11
.github/scripts/package.json
vendored
11
.github/scripts/package.json
vendored
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
.github/scripts/pnpm-lock.yaml
vendored
11
.github/scripts/pnpm-lock.yaml
vendored
|
|
@ -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: {}
|
||||
|
||||
|
|
|
|||
9
.github/scripts/quality/check-pr-size.mjs
vendored
9
.github/scripts/quality/check-pr-size.mjs
vendored
|
|
@ -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 -->';
|
||||
|
||||
/**
|
||||
|
|
|
|||
17
.github/scripts/send-metrics.mjs
vendored
17
.github/scripts/send-metrics.mjs
vendored
|
|
@ -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
67
.github/scripts/verify-safechain.mjs
vendored
Normal 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);
|
||||
}
|
||||
57
.github/scripts/verify-safechain.test.mjs
vendored
Normal file
57
.github/scripts/verify-safechain.test.mjs
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
2
.github/test-metrics/e2e-impact-map.json
vendored
2
.github/test-metrics/e2e-impact-map.json
vendored
File diff suppressed because one or more lines are too long
2
.github/workflows/build-base-image.yml
vendored
2
.github/workflows/build-base-image.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
16
.github/workflows/ci-cla-check.yml
vendored
16
.github/workflows/ci-cla-check.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
10
.github/workflows/ci-codeowners-validation.yml
vendored
10
.github/workflows/ci-codeowners-validation.yml
vendored
|
|
@ -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 }}'
|
||||
|
|
|
|||
28
.github/workflows/ci-instance-ai-evals.yml
vendored
28
.github/workflows/ci-instance-ai-evals.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/ci-master.yml
vendored
4
.github/workflows/ci-master.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
60
.github/workflows/ci-owners-assign-reviewers.yml
vendored
Normal file
60
.github/workflows/ci-owners-assign-reviewers.yml
vendored
Normal 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
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
36
.github/workflows/ci-pr-quality.yml
vendored
36
.github/workflows/ci-pr-quality.yml
vendored
|
|
@ -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) }}
|
||||
|
||||
|
|
|
|||
29
.github/workflows/ci-pull-requests.yml
vendored
29
.github/workflows/ci-pull-requests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/clean-stale-branches.yml
vendored
2
.github/workflows/clean-stale-branches.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
61
.github/workflows/docker-build-push.yml
vendored
61
.github/workflows/docker-build-push.yml
vendored
|
|
@ -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]
|
||||
|
|
|
|||
33
.github/workflows/mutation-health-nightly.yml
vendored
33
.github/workflows/mutation-health-nightly.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
17
.github/workflows/release-create-patch-pr.yml
vendored
17
.github/workflows/release-create-patch-pr.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
2
.github/workflows/release-create-pr.yml
vendored
2
.github/workflows/release-create-pr.yml
vendored
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
2
.github/workflows/release-publish.yml
vendored
2
.github/workflows/release-publish.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -15,3 +15,4 @@ jobs:
|
|||
secrets: inherit
|
||||
with:
|
||||
track: ${{ matrix.track }}
|
||||
force: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
|
|
|
|||
13
.github/workflows/sbom-generation-callable.yml
vendored
13
.github/workflows/sbom-generation-callable.yml
vendored
|
|
@ -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)"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
16
.github/workflows/test-db-reusable.yml
vendored
16
.github/workflows/test-db-reusable.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
160
.github/workflows/test-e2e-coverage-nightly.yml
vendored
160
.github/workflows/test-e2e-coverage-nightly.yml
vendored
|
|
@ -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
Loading…
Reference in New Issue
Block a user