Merge remote-tracking branch 'origin/cat-4471-rename-internal-regex' into cat-4471-separate-user-regex

This commit is contained in:
Emilia 2026-10-06 09:14:51 +01:00
commit 2ba593d815
No known key found for this signature in database
GPG Key ID: 837988349B71DD1A
8387 changed files with 500469 additions and 380142 deletions

View File

@ -17,7 +17,7 @@ clear, when to stay silent, what never to say — lives once in
| `backend/` | Backend | `cli`, `@n8n/db`, `core`, `workflow`, node packages |
| `db-migrations/` | DB migrations | `@n8n/db` migrations + their tests in `cli` |
| `frontend/` | Frontend | `packages/frontend` |
| `qa-dx/` | QA & DX | `.github`, `docker`, `scripts`, `patches`, `packages/testing`, the lint/test/TS config packages, baselines |
| `qa-dx/` | QA & DX | `.github`, `docker`, `scripts`, `patches`, `packages/quality`, the lint/test/TS config packages, baselines |
| `testing/` | Backend + Frontend | any package with a test suite |
A directory maps to one agent unless, like `testing/`, the policy is identical
@ -27,7 +27,7 @@ never a copy per directory. Security and QA & DX deliberately don't link
agents shouldn't be able to produce.
All five slots are used, so a new domain now merges into an existing agent. QA &
DX covers the build, test, and CI surface — the same paths `.github/OWNERS`
DX covers the build, test, and CI surface — the same paths `OWNERS`
assigns to `@n8n-io/qa-dx`. Code-quality rules that happen to apply broadly
(error classes, `any`, lazy imports) are backend rules, not QA & DX ones. DB
migrations is split out of Backend because a migration is permanent and runs

View File

@ -13,7 +13,7 @@ Flag a diff that **adds** entries to any of these, and ask for the fix instead:
|------|---------|
| `.code-health-baseline.json` | `@n8n/code-health` violations |
| `.boundaries-baseline.json` | `turbo boundaries` issue count |
| `packages/testing/playwright/.janitor-baseline.json` | Playwright janitor findings |
| `packages/quality/testing/playwright/.janitor-baseline.json` | Playwright janitor findings |
| `packages/cli/eslint.config.mjs` | the `misplaced-n8n-typeorm-import` and public-API allowlists, each captioned "NEVER add to this list" |
| `.code-health-baseline.json` (`lint-config-layering`) | package-wide rule downgrades left in package ESLint configs |

View File

@ -35,7 +35,7 @@ fed from a `needs:` list. Flag:
Jobs gated on a `ci-filter` filter only run when a matching file changed, so a
filter that is too narrow means the test quietly stops covering new code. When
a PR adds a directory that an existing filter was meant to cover — a new package
under `packages/testing/`, a new script under `.github/scripts/` — check the
under `packages/quality/`, a new script under `.github/scripts/` — check the
filter still matches it.
## Least privilege

View File

@ -0,0 +1,24 @@
---
name: n8n:agent-design-language
description: Design or review Agent Builder interfaces. Use for Agent configuration patterns, modal flows, responsive behavior, status treatments, and other Agent-only UI work.
---
# Agent design language
Use this skill only for interface work in
`packages/frontend/editor-ui/src/features/agents`.
Before you change code:
1. Read `packages/frontend/editor-ui/src/features/agents/AGENTS.md`.
2. Read `packages/frontend/editor-ui/src/features/agents/agents-design-language.md`
completely.
3. Use [ui-design](../ui-design/SKILL.md) for visual implementation rules.
4. Use [content-design](../content-design/SKILL.md) when you add or change UI
text.
Keep Agent-specific components in the Agent feature. Do not change the Design
System unless the user explicitly asks for this change.
Update the design language document when you establish a reusable Agent
interface pattern. Add or update tests for the behavior that you change.

View File

@ -113,7 +113,7 @@ Run when `AutoReject` is `null`. Full rules for each in `reference/checks.md`:
## Step 4 — Identify the responsible team
Run `node .github/scripts/owners.mjs` against the changed file list and map the winning GitHub team to a Linear team. Full mapping table, sub-agent fallback procedure, and label rules: see `reference/teams.md`.
Run `node .github/scripts/owners/owners.mjs` against the changed file list and map the winning GitHub team to a Linear team. Full mapping table, sub-agent fallback procedure, and label rules: see `reference/teams.md`.
## Step 5 — Extract the Linear ticket

View File

@ -16,7 +16,7 @@ Owner resolution, the GitHub-team → Linear-team → label mapping, and the Lin
## Identifying the owning team
Use the canonical owners script at `.github/scripts/owners.mjs`. It parses `.github/OWNERS` with last-match-wins semantics and returns allocations sorted by file count with a `share` percentage. Using the script keeps this skill consistent with whatever CI uses.
Use the canonical owners script at `.github/scripts/owners/owners.mjs`. It parses `OWNERS` with last-match-wins semantics and returns allocations sorted by file count with a `share` percentage. Using the script keeps this skill consistent with whatever CI uses.
1. Write the PR's changed file paths (from the `files` list) to a temp file, one per line:
```bash
@ -24,7 +24,7 @@ Use the canonical owners script at `.github/scripts/owners.mjs`. It parses `.git
```
2. Run the script:
```bash
node .github/scripts/owners.mjs /tmp/pr-<number>-files.txt
node .github/scripts/owners/owners.mjs /tmp/pr-<number>-files.txt
```
3. The script prints JSON of the form:
```json
@ -38,10 +38,10 @@ Use the canonical owners script at `.github/scripts/owners.mjs`. It parses `.git
```
Allocations are already sorted by `fileCount` descending — take the first entry as the winning team.
4. Clean up: `rm /tmp/pr-<number>-files.txt`.
5. Strip the `@n8n-io/` prefix from `allocations[0].team` — the GitHub team slug is `nodes`, `iam`, `ai`, etc. If `allocations` is empty (no file matched any rule, which is possible only if `.github/OWNERS` lost its catch-all), fall back to `catalysts`.
5. Strip the `@n8n-io/` prefix from `allocations[0].team` — the GitHub team slug is `nodes`, `iam`, `ai`, etc. If `allocations` is empty (no file matched any rule, which is possible only if `OWNERS` lost its catch-all), fall back to `catalysts`.
6. Map the GitHub team slug to its Linear team name and PR label using the table below. The `team` field in the JSON output is the **Linear team name**. If the resolved GitHub team slug has no entry in the table, fall back to `Engineering`.
**Sub-agent fallback**: if `node` execution is denied by the sandbox, read `.github/OWNERS` directly and apply last-match-wins by hand. All the active rules fit on one screen.
**Sub-agent fallback**: if `node` execution is denied by the sandbox, read `OWNERS` directly and apply last-match-wins by hand. All the active rules fit on one screen.
## GitHub team → Linear team → GitHub label

View File

@ -45,6 +45,9 @@ throw new UnexpectedError('message', { extra: { context } });
**Testing:**
- Vitest (unit), Playwright (E2E)
- Mock external dependencies
- Keep filesystem tests in a test-owned temporary directory
- Set `N8N_USER_FOLDER` before importing settings code
- Trace branches activated by mocks and isolate every reachable mutation
- Work from package directory: `pushd packages/cli && pnpm test`
**Database:**

View File

@ -0,0 +1,377 @@
---
name: n8n:create-agent-builder-eval
description: >-
Authors and calibrates Instance AI evaluations that build standalone n8n
Agents through Agent Builder. Use when a change under
packages/cli/src/modules/agents affects build-agent routing, Agent setup,
model or credential selection, tools, MCP servers, integrations, skills,
tasks, testing, or user-facing build responses. Requires LangTracer access
before authoring so each finished case can be published.
---
# Create an Agent Builder eval
Use the shared Instance AI eval harness. Agent cases use an Agent-specific
authoring directory, dataset, and LangTracer suite.
## Required LangTracer preflight
Run this check before sourcing, drafting, or writing an eval. Run it from
`packages/@n8n/instance-ai`:
```bash
pnpm exec dotenvx run -f ../../../.env.eval -- \
sh -c 'test -n "${LANGTRACER_URL:-}" && test -n "${LANGTRACER_API_KEY:-}"'
```
If the check fails, stop before creating an eval file. Ask the user to:
1. Generate a key on the
[LangTracer API page](https://lang-tracer.n8n-maintenance.workers.dev/account?section=api).
2. Add these variables to the repository root `.env.eval` file:
```env
LANGTRACER_URL=https://lang-tracer.n8n-maintenance.workers.dev
LANGTRACER_API_KEY=<generated-key>
```
3. Confirm when the environment is ready.
Do not ask the user to paste the key into chat. Do not print or inspect its
value. Rerun the check after the user confirms. Continue only when it passes.
## Non-negotiable routing
- Author the case at
`packages/@n8n/instance-ai/evaluations/data/agents/<slug>.json`.
- Set `"datasets": ["agents"]`.
- Ask the driver for the target suite before you do anything else. See
"Ask for the target suite first" below. Do not assume `agents`.
- Do not commit the case JSON. LangTracer is the durable source of truth.
- Commit changes to this skill, the harness, and CI when applicable.
The disk runner loads both `data/agents/` and `data/workflows/`. A misplaced
Agent case can therefore pass locally. That does not make the location correct.
## Decide what the case proves
Write the smallest user request that exercises the changed behavior.
- Use `processExpectations` for the Instance AI conversation and final response.
- Use `outcomeExpectations` for the created Agent artifact and its configuration.
- Use `executionScenarios` only when the built Agent must run to prove the behavior.
- Declare only the credentials that the build must see.
The harness captures the Agent configuration and authored skills. It supplies
them to the expectation judge. A scenario-less Agent case is valid when process
or outcome expectations can prove the behavior.
Use the substitution test for every expectation. A correct alternative build
must pass. A build that misses the requested behavior must fail.
## Write expectations about the Agent, not the builder's internals
Agent Builder runs as a delegated sub-agent architecture today. That design
can change. Do not write an expectation that depends on it. Follow
"Keep expectations free of internal mechanics" in
[create-instance-ai-eval](../create-instance-ai-eval/SKILL.md).
Do not write:
- "The sub-agent runs/tests the Agent before it responds."
- "Instance AI delegates to `build-agent`" or "calls `build-agent` once."
- "The builder calls `call_agent` to verify the Agent."
- "The sub-agent reads the skill, then sets the model."
Write what a user or a reviewer can observe:
| ❌ mechanical | ✅ intent |
|---|---|
| "The sub-agent tests the Agent before it reports" | "The final response says the Agent was tested only if a test ran, and it reports the result of that test honestly" |
| "`build-agent` is called with the Slack credential" | "The Agent uses the Slack credential the user has" |
| "The sub-agent adds a tool for the lookup" | "The Agent can look up the order status when a user asks for it" |
| "The builder sets `model` after the catalog lookup" | "The Agent uses a model that the connected credential supports" |
Put each one where it belongs:
- The Agent's configuration, tools, skills, and behavior go in
`outcomeExpectations`.
- What the user is told, asked, or shown goes in `processExpectations`.
- The final response is the place to check claims ("says it is ready",
"lists the tools it added"). Do not check which component made the claim.
The calibration steps below ask you to confirm that `build-agent` ran. That is
a check for you, to be sure the case reached Agent Builder. Do not add it to the
case.
For multi-turn, seeded, or capability-gap cases, follow the case-shape and
calibration rules in [create-instance-ai-eval](../create-instance-ai-eval/SKILL.md).
This skill overrides its workflow directory and suite guidance for Agent cases.
The sections below add the Agent-specific parts. Do not skip the general rules
they point to.
## Ask for the target suite first
Follow "Ask for the target suite first" in
[create-instance-ai-eval](../create-instance-ai-eval/SKILL.md). Do it after the
LangTracer preflight and before you source or draft anything. If the request
already names a suite, state it and do not ask. Otherwise ask, in autonomous
mode too, in the same message as the autonomy question.
For Agent cases, recommend
[Instance AI capabilities — agents](https://lang-tracer.n8n-maintenance.workers.dev/suites/10)
(slug `agents`). Offer `baseline` as well: the Instance AI (INS) team monitors
it and it runs nightly. Get the full list from `list_suites`. Use the suite the
driver picks in every push command, in place of `agents` in the examples below.
Keep the `agents` dataset and the `data/agents/` directory. They do not depend
on the suite.
## Set the autonomy level first
Follow "Set the autonomy level first" in
[create-instance-ai-eval](../create-instance-ai-eval/SKILL.md). Ask the driver
for *autonomous* or *checkpoint* mode before you source or draft anything. The
four gates are the same: selection, shape and expectations, calibration, push.
## Source the case from a real thread
The best Agent cases come from a real conversation. Discover the conversation
with LangTracer. Author a synthetic case from what you learn.
1. List candidates: `list_conversations` with `usedAgentBuilder: true`. Do not
use `source: "agent-builder"`. That pool is a different product, has
unscrubbed payloads, and has no Agent snapshots.
2. List what the thread did to the Agent: `list_conversation_agent_snapshots`.
`target-resolved` is the state that a turn opened on. `config-updated` is the
state after the builder changed it.
3. Pick the shape from the snapshot sequence (next section).
4. Fetch the state to seed with `get_conversation_agent_snapshot`. Seed from the
`target-resolved` row of the turn under test.
5. Read the raw turn before you write any expectation. Follow
"First reproduce, then reclassify" in the general skill.
Two snapshot patterns to look for:
- A `target-resolved` hash that differs from the previous `config-updated` hash.
The user edited the Agent in the UI between turns. This is the best seed for a
repair or update case.
- A run of turns that each end in `config-updated`. The user added one rule per
turn. Use it for a "new rule does not erase old rules" case.
Credentials arrive as `[redacted]`. Instructions and skill bodies do not. Scrub
the prose before it enters a case. See the `agents` section in
[case-shapes.md](../create-instance-ai-eval/case-shapes.md) (the `agents` seed section).
## Pick the shape: create or update
| Shape | Question it answers | How to write it |
|---|---|---|
| **Create** | Does the builder make the right Agent from a fresh request? | One user turn. No seed. Assert on the Agent artifact. |
| **Update** | Does the builder change only what the user asked for? | `seed.agents` with the Agent before the change. One live turn with the change. Assert on the new behavior and that the untouched parts survive. |
| **Repair** | Does the builder find the cause of a failure and fix it? | Seed the broken Agent. The live turn reports the failure. Assert on the cause found and the fix applied. |
| **Incremental rules** | Do earlier rules survive when a new rule lands? | Seed the Agent after several rules. Add one conflicting or overlapping rule. Assert that the old rules are still in force. |
Rules that apply to all four:
- An update that rebuilds the Agent from scratch is a failure. Assert on a
specific untouched instruction, tool, or skill that must still be there.
- Write the conversation in English, even when the source thread is not.
- Trim to the fewest turns that reproduce the behavior. Do not copy a long
thread.
- Put only the live turn in `conversation`. Put earlier turns in `seed.messages`.
## Confirm the precondition fired
A pass can mean the builder did the right thing. It can also mean the situation
never happened. Check before you trust a green.
- For "the builder did not do X", confirm that it reached the turn where X was
possible. A builder that never edited the Agent passes "did not delete the old
rule".
- For a repair case, confirm that the seeded Agent really has the defect. Read
the seeded config in the rendered artifact.
- For an update case, compare the seeded config with the final config. Confirm
that the requested change is the only difference you expected.
- For a "say you cannot" case, confirm that the tool or data was really missing
in the run.
## A red is a finding
Follow "A red is signal" in the general skill. For Agent cases, add this:
1. Classify the red as a product gap, a harness limitation, or non-determinism.
2. Keep a product-gap red. Start the case `description` with
`Capability-gap finding:`.
3. **Propose a Linear ticket for the Agent team.** The Linear team is named
`Agent`. Propose the ticket. Do not create it unless the driver says go.
Follow "Capability gap → propose a Linear ticket" in the general skill. Also
check whether the gap already has a ticket.
- Write the ticket in English. Use Simplified Technical English, as
[AGENTS.md](../../../AGENTS.md) requires. Translate the user's words. Do
not paste them.
- This repository is public. Keep customer names, company names, and real
URLs out of the ticket. Describe the use case in neutral words.
- Link the LangTracer case and the source thread. Do not copy raw user
messages or agent instructions from the thread.
- Reproduce the gap first. This is a default, not a hard rule. Propose a
ticket only after a case is red on a real build. Quote the failing
expectation and the judge reason. Follow "Reproduce first" in the general
skill.
- The driver may approve an exception, for example when the harness cannot
reach the mechanism after about three attempts. Then the ticket must say
"Not reproduced in the eval. Based on trace analysis."
4. **Check the builder's own guidance for the gap.** Read the files that steer
Agent Builder:
`packages/cli/src/modules/agents/builder/agents-builder-prompts.ts`,
`packages/cli/src/modules/agents/builder/prompts/`, and
`packages/cli/src/modules/agents/builder/skills/`. Also read the Instance AI
orchestrator prompt in `packages/@n8n/instance-ai/src/agent/system-prompt.ts`.
If none of them covers the failing situation, add that to the ticket as a
proposed prompt or skill change.
5. Propose the smallest added line first. Do not delete existing guidance to fix a
gap. A measured case: adding one line fixed a routing gap, and deleting two
lines did not.
6. Every prompt or skill change needs a before and after measurement. The new
case is the "before" run. Run it again after the change. Use `--iterations 5`.
When the failure is in the Agent's runtime (for example a channel that cannot
report an error), the builder cannot see it. The right fix is often a builder
instruction to say so and stop guessing. Write that as a `processExpectations`
item on the final response.
## Publish and link
Follow "Share links, never bare ids" and "Link the pushed case to its source" in
the general skill. For a sourced Agent case:
- Report the case, the suite, and the source thread as links. Use the
`https://lang-tracer.n8n-maintenance.workers.dev/test-cases/<id>` form.
- Call `update_test_case` with `sourceThreadId`, `expectedBehavior`, and
`failurePattern`.
- Add a capability tag with `add_case_tags`.
- Push a case whose *build* is wrong with `--set-kind capability_gap` into a
suite of that kind. If no such Agent suite exists, tell the driver. Do not put
it in a `regression` suite.
## Draft the case
Start with this shape:
```json
{
"description": "The Agent Builder behavior this case guards.",
"conversation": [
{ "role": "user", "text": "Build me an Agent that ..." }
],
"complexity": "simple",
"tags": ["agent", "agent-build", "<capability>"],
"credentials": [{ "type": "<credentialType>", "name": "<display name>" }],
"processExpectations": [
"The final response ..."
],
"outcomeExpectations": [
"A standalone Agent is the deliverable; any workflow created exists only as a tool the Agent calls.",
"The Agent ..."
],
"datasets": ["agents"]
}
```
Keep the prompt in the user's voice. Do not tell Instance AI which internal
tools or configuration fields to use unless that choice is the behavior under
test.
## Validate and run locally
Read [local-setup.md](local-setup.md) when the machine does not already have an
eval instance and environment file.
From `packages/@n8n/instance-ai`:
```bash
pnpm exec tsx -e "import { loadAgentEvalTestCasesWithFiles } from './evaluations/data/agents/index.ts'; const matches = loadAgentEvalTestCasesWithFiles('<slug>'); if (matches.length !== 1) throw new Error('Expected exactly one Agent eval case, found ' + matches.length); console.log(matches[0].fileSlug)"
pnpm eval:instance-ai \
--base-url http://localhost:5680 \
--filter <slug> \
--tier agents \
--concurrency 1 \
--keep-workflows \
--verbose
```
Use `eval:instance-ai` for a new disk case. `eval:agents` reads the published
LangTracer suite and is for running cases that are already there.
Inspect the transcript, the rendered Agent artifact, and each judge reason.
Do not accept a green result when a conditional expectation never occurred.
Do not weaken an expectation to hide a real Agent Builder defect.
## CI coverage
The Instance AI PR gate checks the files changed by the PR. A change under
`packages/cli/src/modules/agents/` selects the `Instance AI capabilities — agents`
suite through its `agents` slug. It also selects the `agents` dataset and uses
an absolute pass gate. The run uses a suite-scoped LangSmith cohort. It does not
write to the workflow dataset or compare against the workflow baseline. Other
Instance AI changes select the `baseline` suite and its `pr` dataset.
The gate runs when a PR opens, reopens, or becomes ready for review. It does not
run for each new push. Use the PR gate's manual dispatch after a later push.
## Credential behavior
Declared credentials are real n8n credential records with placeholder data.
The eval thread limits the builder to those credential IDs.
Agent Builder model catalog requests return deterministic fake models during an
eval. They do not decrypt the placeholder model credential or call its provider.
Production model catalog requests remain live.
This mock covers catalog lookup only. A builder `call_agent` action and an Agent
`executionScenario` run the target Agent model. They need a working provider
credential such as `EVAL_OPENAI_API_KEY`. A build-only case does not need one.
## Calibrate and publish
1. Run the case once with `--concurrency 1`.
2. Confirm that Instance AI called `build-agent`.
3. Confirm that the expected Agent artifact was captured.
4. Classify each red as a product gap, harness limitation, or non-determinism.
5. Use `--iterations 5` before adding a case to a gating tier.
6. Preview the LangTracer change.
7. Push it to the suite the driver chose (`<suite>` below).
```bash
pnpm exec dotenvx run -f ../../../.env.eval -- \
pnpm eval:langtracer-push --suite <suite> --dry-run --changed
pnpm exec dotenvx run -f ../../../.env.eval -- \
pnpm eval:langtracer-push --suite <suite> --changed
```
The push needs `LANGTRACER_URL` and `LANGTRACER_API_KEY`. Generate a key on the
[LangTracer API page](https://lang-tracer.n8n-maintenance.workers.dev/account?section=api).
Report the case and suite as clickable LangTracer links. Delete the local JSON
after a successful push.
## Completion checklist
- The LangTracer environment preflight passed before authoring.
- The driver chose the target suite before any work, including in autonomous mode.
- The autonomy level was set and stated before any work.
- A sourced case links to its source thread, and the source turn was read raw.
- Each conditional expectation has proof that its precondition fired.
- A kept product-gap red has a proposed Linear ticket and a check of the builder guidance.
- The case is in `data/agents/` and uses the `agents` dataset.
- The strict loader accepts it.
- A local run captures a standalone Agent.
- The transcript proves each process expectation was exercised.
- The rendered Agent artifact proves each outcome expectation.
- No expectation names a sub-agent, an internal tool, or a delegation step.
Each one passes for a correct build from a different architecture.
- Repeated runs are stable enough for the selected tier.
- The case is pushed to the suite the driver chose, and the report links to it.
- Agent Builder PR changes select the `agents` suite in CI. A case pushed to
another suite does not run in that lane.
- The local case JSON is not committed.

View File

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

View File

@ -1,19 +1,21 @@
---
name: n8n:create-instance-ai-eval
description: >-
Authors a new Instance AI workflow eval case — written locally as JSON,
calibrated against a real build, then pushed to the LangTracer suite CI runs
— build cases, behaviour/process cases, credential cases, and seeded
Authors a new Instance AI workflow or Agent eval case — written locally as
JSON, calibrated against a real build, then pushed to the LangTracer suite CI
runs — build cases, behaviour/process cases, credential cases, and seeded
(mid-conversation) cases — with intent-driven expectations. Use when adding
or changing an Instance AI workflow eval, or debugging why one is flaky.
or changing an Instance AI eval, or debugging why one is flaky.
---
# Create an Instance AI workflow eval
# Create an Instance AI eval
Each eval is **one JSON case** — authored locally as a file in
`packages/@n8n/instance-ai/evaluations/data/workflows/` (the disk loader
auto-discovers `*.json`, no registration step), with a LangTracer suite as its
durable home. Cases validate against
Each eval is **one JSON case**. Author workflow cases in
`packages/@n8n/instance-ai/evaluations/data/workflows/`. Author standalone Agent
cases in `packages/@n8n/instance-ai/evaluations/data/agents/` and follow the
[`create-agent-builder-eval` skill](../create-agent-builder-eval/SKILL.md).
The disk loader auto-discovers `*.json` in both directories. A LangTracer suite
is the durable home. Cases validate against
[`harness/schema.ts`](../../../packages/@n8n/instance-ai/evaluations/harness/schema.ts)
(`.strict()` — unknown keys fail at load). The eval
[README](../../../packages/@n8n/instance-ai/evaluations/README.md) is the
@ -23,7 +25,9 @@ exhaustive field reference; this skill is the opinionated *how*.
> approach.** Author the file locally (uncommitted), calibrate it against a real
> build, then **push it to a lang-tracer suite** with `eval:langtracer-push`
> (see [Push to a lang-tracer suite](#push-to-a-lang-tracer-suite)) —
> `--suite baseline` for the consolidated corpus n8n CI runs, or a dedicated
> the suite the driver chose (see
> [Ask for the target suite first](#ask-for-the-target-suite-first)) —
> `baseline` for the consolidated corpus n8n CI runs, or a dedicated
> capability suite like `agents`.
> The suite is the home for the case; the eval CLI reads it back via
> `--source langtracer`. You still write the JSON file — it's just the input to
@ -36,6 +40,34 @@ exhaustive field reference; this skill is the opinionated *how*.
> pruned and has no durable home. Don't commit a replay case either — derive a
> synthetic case from it. See [`case-shapes.md`](case-shapes.md).
## Ask for the target suite first
**Before you source, draft, or run anything, ask the driver which LangTracer
suite receives every eval you create in this session.** Ask this in both
autonomy levels, including autonomous mode. Never pick the suite yourself and
never default silently. A case pushed to the wrong suite runs in the wrong CI
lane and alerts the wrong team.
Ask the suite question and the autonomy question together, in one message.
Recommend `baseline`:
- `baseline` (recommended): the general corpus. The Instance AI (INS) team
monitors it, and it runs nightly.
- Any other suite, such as `agents` or `node-gauntlet`: call `list_suites` and
offer the live list. Do not use a hardcoded list, because suites are added
and retired.
Rules:
- The driver's answer applies to all cases in the session. Do not ask again per
case.
- If the driver already named a suite in the request, state it back in one line
and continue. Do not ask again.
- Check that the suite kind matches the case. Push a case with a wrong build
(`capability_gap`) only into a suite that allows that kind. If the suite does
not allow it, tell the driver and ask again.
- Put the chosen suite slug in the final decision log, as a link.
## Set the autonomy level first
**Before you source, draft, or run anything, decide how hands-on the driver
@ -138,7 +170,7 @@ discover → verify → encode workflow.
## Pick the case shape first
The corpus is four archetypes. Decide which you're writing before you draft — it
The corpus is five archetypes. Decide which you're writing before you draft — it
determines the fields, the grading, and how you validate. They compose (a seeded
case can still assert outcome), but the primary shape drives the work.
@ -148,10 +180,25 @@ case can still assert outcome), but the primary shape drives the work.
| **Behaviour / process** | Does the agent *converse* correctly (ask the right clarifying question, not re-ask, honour a correction, respect plan approval)? | `processExpectations` + multi-turn director script; often **build-only** |
| **Credential** | Does the build behave correctly given a specific credential view? | `credentials[]` |
| **Seeded** | Start mid-thread, with prior work already in place, and drive the turn under test | `seed` (authored `mode: "inline"`; `"replay"` for a local check) |
| **Context** | Does the agent still work once the conversation is long — does it reuse what it already read, and keep what matters after observational memory compacts the history? | `processExpectations` + a long `seed`; `requiresMemoryCompaction` for the compaction ones |
**Build** is documented in full below. The other three, the director-script
**Build** is documented in full below. The other four, the director-script
vocabulary, and the seeding modes are in [`case-shapes.md`](case-shapes.md).
The judge also sees **token ground truth** for workflow-build cases: per-turn
input and output tokens inline in each transcript turn header, a build-wide
total, a cache read/write split, and the opening step's cost. So an expectation
may reference cost or consumption directly ("does not re-read the same node's
schema in turn 2"). The numbers cover the orchestrator's own LLM steps only. A
delegated Agent build runs in a sub-agent whose steps are not in the snapshot,
so do not write cost expectations on an Agent case. Those numbers come from run-debug snapshots, so they need
`N8N_INSTANCE_AI_RUN_DEBUG_ENABLED=true` on the instance under test; without it
the judge reads `(no run debug captured)` and cost expectations are ungradeable.
The judge also sees the thread's **observation rows** — what observational
memory kept after it compacted — so a memory case can grade the summary itself.
That block is a separate REST read and needs no flag.
## Core principle (all shapes)
**Write expectations from intent, then calibrate against a real build.** Decide
@ -251,7 +298,7 @@ calibration you hand the driver the thread link + login to review the real build
7. **Push to the suite — do NOT commit the JSON.** Once calibrated, push the case
into its curated lang-tracer suite with `eval:langtracer-push` (see
[Push to a lang-tracer suite](#push-to-a-lang-tracer-suite)); the suite is the
case's home, not the repo. Leave the `data/workflows/*.json` file uncommitted
case's home, not the repo. Leave the `data/{workflows,agents}/*.json` file uncommitted
(or delete it once it's in the suite). Committing new case JSONs into the repo
is no longer the approach. (An `inline` seed pushes with the case; only a
`replay` case is refused — it's a local throwaway; see
@ -451,6 +498,16 @@ read the run assumes someone already owns it. So once a red is classified as a
real gap (and the driver has confirmed it, per the autonomy level), **propose a
Linear ticket for it.**
**Reproduce first. This is a default, not a hard rule.** Propose a ticket only
after a case has reproduced the gap. That means the case is red on a real build,
the precondition was confirmed to fire, and you re-read the raw thread (see
[First reproduce, then reclassify](#first-reproduce-then-reclassify)). A gap that
comes from trace analysis alone is a hypothesis. A ticket for it costs the owning
team time. The driver may approve an exception, for example when the harness
cannot reach the mechanism after about three attempts. In that case the ticket
must say that the eval did not reproduce the gap. Never file an unreproduced gap
without the driver's approval.
**Propose, don't create.** Per [AGENTS.md](../../../AGENTS.md), never open a
Linear ticket unasked. Put the draft in front of the driver — interactively in
checkpoint mode, in the decision log in autonomous mode — with a title, a team,
@ -598,7 +655,8 @@ nodes get LLM-generated pin data). So:
static data** (`removeItemsSeenInPreviousExecutions`, `$getWorkflowStaticData`)
is **not** seedable, because static data starts empty every run, so such a
scenario reds vacuously (it sees everything as "new"). To get a seedable
change-detection scenario, steer the build toward a Data Table; otherwise
change-detection scenario, say "keep the state in a data table" in the prompt;
otherwise
accept the static-data red as a harness limit and carry the logic in
`outcomeExpectations`. Note the agent may *choose* static-data dedup on its own.
- Don't assert exact counts that depend on mock generation ("exactly 7 posts").
@ -694,8 +752,9 @@ Both are natural-language assertions graded by the same Sonnet judge, and each
They run everywhere, including prebuilt/MCP runs (no transcript needed).
- **`processExpectations`** — **how the agent behaved during the build**, judged
from the transcript. Assert clarifying questions asked (or not re-asked),
tool-call behaviour, plan/approval handling, batching, honouring a correction,
ordering. They need a transcript, so they're **skipped in prebuilt/MCP runs**.
user-visible behaviour, plan/approval handling, batching, honouring a
correction, ordering. Do not assert internal mechanics such as sub-agents or
tool names (see "Keep expectations free of internal mechanics"). They need a transcript, so they're **skipped in prebuilt/MCP runs**.
Rule of thumb: an assertion about *the artifact* is an outcome expectation; an
assertion about *the conversation or the agent's choices along the way* is a
@ -733,6 +792,54 @@ case, where the source and channel were **left unspecified**):
Put intent the conversation only *implied* (a preferred but unstated channel) in
`processExpectations`, not `outcomeExpectations`.
## Keep expectations free of internal mechanics
An expectation describes what the user or the artifact sees. It does not
describe how Instance AI is built inside. The internal architecture changes:
sub-agents, tool names, delegation, planning steps, and run ordering all move.
An expectation that names them goes red when the design changes, although the
behavior is still correct. It also stops proving the behavior: a build that
solves the problem by another route fails it.
**Do not name these in an expectation:**
- Sub-agents, delegation, or hand-off ("the sub-agent runs ...", "the
orchestrator delegates ...").
- Internal tool names or call counts ("calls `build-agent`", "calls
`search-nodes` twice").
- Internal stages, task lists, or step order that the user cannot see.
- Which component produced a result.
**Do name these:**
- What the user sees in the conversation: the question asked, the plan shown
for approval, the claim in the final response.
- What the artifact contains: the workflow or Agent, its configuration, and
how it behaves.
- A user-visible sequence, only when the user asked for it ("asks before it
builds").
Rewrite test. Delete the internal term. State the result the term produced.
| ❌ mechanical | ✅ intent |
|---|---|
| "The sub-agent runs the workflow before it reports success" | "The final response reports only results that the build verified; it does not claim an untested step works" |
| "The orchestrator calls `ask-user` before delegating" | "The agent asks for the missing Slack channel before it builds, and does not guess one" |
| "The builder calls `search-nodes` for the trigger" | "The workflow starts from a trigger that matches the requested schedule" |
Second test. Imagine the internals are replaced by one agent with different
tools. A correct build must still pass. If the expectation would fail or become
meaningless, rewrite it.
Some mechanics are a legitimate subject: a case whose purpose is to guard a
specific tool contract (for example, a regression in how a tool reports an
error). Keep these rare. State the reason in `description`. Do not mix them with
intent expectations in the same case.
**Calibration steps are not expectations.** Reading the transcript to confirm
that a build ran, or that a sub-agent was reached, is a good calibration check.
Keep it in your own run notes. Do not copy it into the case JSON.
## Robust design vs harness flakiness
Two different things — keep them apart:
@ -803,7 +910,10 @@ concluding whether the failure is your case, the build, or the harness.
```bash
cd packages/@n8n/instance-ai
npx tsx -e "import {loadWorkflowTestCasesWithFiles} from './evaluations/data/workflows/index.ts'; console.log(loadWorkflowTestCasesWithFiles('<slug>')[0].fileSlug)"
pnpm exec tsx -e "import {loadWorkflowTestCasesWithFiles} from './evaluations/data/workflows/index.ts'; console.log(loadWorkflowTestCasesWithFiles('<slug>')[0].fileSlug)"
# For a standalone Agent case:
pnpm exec tsx -e "import {loadAgentEvalTestCasesWithFiles} from './evaluations/data/agents/index.ts'; console.log(loadAgentEvalTestCasesWithFiles('<slug>')[0].fileSlug)"
```
## Push to a lang-tracer suite
@ -816,24 +926,32 @@ drifted, leaves the rest unchanged, and never prunes. It's the inverse of
```bash
cd packages/@n8n/instance-ai
# preview first — no writes (use `npx dotenvx`; the bare `dotenvx` binary is usually not on PATH):
npx dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite baseline --dry-run --changed
# <suite> is the slug the driver chose (e.g. baseline)
# preview first — no writes:
pnpm exec dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite <suite> --dry-run --changed
# then push (drop --dry-run):
npx dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite baseline --changed
pnpm exec dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite <suite> --changed
```
- **Selectors** (at least one required — no accidental push-all): positional
`<slugs...>` (exact file slugs), `--changed` (new/untracked + staged + modified
`data/workflows/*.json`, ideal right after authoring an uncommitted case),
`data/{workflows,agents}/*.json`, ideal right after authoring an uncommitted case),
`--filter`/`--tier` (with `--exclude` as a modifier).
- **Validation is selective.** Exact slugs and `--changed` read only the named
files, so an unrelated invalid file in `data/workflows` (a case authored on a
newer branch, a half-written draft) never blocks your push. `--filter` parses
only the files whose slug matches; `--tier` reads the tier from inside each
file, so it parses them all. An invalid file either one parses prints
`⚠ skipped invalid case file …` and the push continues without it. A file you
named still fails the push when it is invalid.
- **Multiple positional slugs? Skip pnpm — call the script directly.** `pnpm
eval:langtracer-push … slugA slugB` forwards the slugs as one joined argument
(`"slugA slugB"`), so no case file matches and nothing is pushed. Either use a
no-positional selector through pnpm (`--changed`), or run the script directly so
each slug is its own argv: `npx dotenvx run -f .env.eval -- npx tsx
each slug is its own argv: `pnpm exec dotenvx run -f .env.eval -- pnpm exec tsx
evaluations/cli/langtracer-push.ts --suite <slug> <slug1> <slug2> …`.
- **Env:** `LANGTRACER_URL` + `LANGTRACER_API_KEY` (an `lt_` bearer; one key works
for MCP + REST) — put them in `.env.eval` and run under `npx dotenvx`.
for MCP + REST) — put them in `.env.eval` and run under `pnpm exec dotenvx`.
- **Options:** `--set-kind regression|capability_gap` (default `regression`, must
match the suite's kind), `--contains-user-data` (default is `synthetic`). A case
whose **build is correct** (outcome expectations green) but that carries a
@ -903,8 +1021,9 @@ in *checkpoint* mode calibration this is how the driver opens the built thread
## Other eval harnesses (not this skill)
This skill is for `data/workflows/` cases. Three siblings exist with their own
data dirs and CLIs: **`eval:subagent`** (workflow-build compatibility corpus,
Use the [`create-agent-builder-eval` skill](../create-agent-builder-eval/SKILL.md)
for standalone Agent cases. Three other harnesses have their own data dirs and
CLIs: **`eval:subagent`** (workflow-build compatibility corpus,
binary-check scored), **`eval:discovery`** (asserts first-hop tool/dispatch
routing, no n8n server), **`eval:pairwise`** (head-to-head build comparison vs
`ai-workflow-builder.ee`). Authoring them is out of scope here — see the README

View File

@ -1,7 +1,7 @@
# Case shapes beyond the plain build
The [SKILL](SKILL.md) covers the **build** archetype. This file covers the other
three — **behaviour/process**, **credential**, **seeded** — plus the
four — **behaviour/process**, **credential**, **seeded**, **context** — plus the
director-note vocabulary multi-turn cases rely on. Field-level docs live in the
eval [README](../../../packages/@n8n/instance-ai/evaluations/README.md); this is
the opinionated *how* and the traps.
@ -568,6 +568,56 @@ it on a dev instance, fetch both, scrub, paste. Things worth knowing:
- **Requires the agents module.** A seeded agent restore fails loudly (as a
framework issue) on an instance where agents are disabled, rather than running
the case unseeded.
#### `folders` — "look at the ODW folder"
A folder case grades how the agent finds the contents of a folder the user names
(CONTEXT-86: the workflow list tool takes `folderPath` or `folderId`). Declare the
folders and place the seed workflows in them:
```json
"seed": {
"mode": "inline",
"folders": [{ "id": "odwFolder0001", "name": "ODW" }],
"workflows": [
{ "id": "odwSignal1Wf", "name": "Odds Watch - 1", "parentFolderId": "odwFolder0001", "nodes": [], "connections": {} },
{ "id": "rootWorkflow1", "name": "Voice Agent", "nodes": [], "connections": {} }
]
}
```
Things worth knowing:
- **Leave decoys at the root.** A folder case is only informative when the folder's
contents differ from what a name search would return. Keep workflows outside the
folder, and pick names a `query` filter for the folder name would not match.
- **Grade the mechanism and the result.** One expectation for the `folderPath` or
`folderId` call, one for reporting exactly the folder's workflows, one for not asking
the user what the folder holds. The miss path (no folder exists) is a separate case:
LangTracer case 699 grades it, and it needs no `folders` slot.
- **Names are verbatim and leftovers are evicted**, like seeded projects. The live turn
names the folder, so the created name has to match exactly; a root folder of the same
name that existed before the run started is deleted before the restore, with every
workflow and subfolder in it. There is no seed marker on the name, so a same-named
folder a human made on that instance goes too: use an eval instance, and a name a
real project would not use. A previous iteration's folder, created during the run,
is left alone.
- **Run it alone.** A folder case and its miss-path sibling (case 699) share the
project, so one invocation per case. With several iterations the previous folder can
still be live for a few seconds while it is judged; keep the name distinctive.
- **Nesting works.** A folder's own `parentFolderId` names another seed folder. Parents
are created first. The `folderPath` for a nested folder is `Parent/Child`, so a folder
name cannot contain `/`.
- **Rules are checked at case load**: ids of at least 8 characters, unique; every
`parentFolderId` names a declared folder; no cycles; trimmed names that pass n8n's
folder-name rules; at most 20 folders.
- **Needs a licensed instance** (`feat:folders`) with
`N8N_INSTANCE_AI_FOLDER_EXPLORATION_ENABLED=true`. An unlicensed instance fails the
restore with a hint rather than running the case without its folder. Locally, after
`/rest/e2e/reset`: `PATCH /rest/e2e/feature {"feature":"feat:folders","enabled":true}`.
- **Keep it on disk for now.** The LangTracer case-write API does not store
`seed.folders` or `workflows[].parentFolderId`, so the push refuses the case until
`n8n-io/lang-tracer` adds both.
#### Which opening shape — the agent is handed the workflow, or it has to find it
Two real conversations look the same in a case file but test different things, and
@ -612,6 +662,24 @@ really had one — and the agent has to guess from prose that deliberately names
nothing. It will list workflows and pick, or ask which one, and you will score a
clarification failure the real user never hit.
#### Seeded faults must really fail
`seed.priorRuns[].hints` steer the mock for the staged run's outbound calls and
nothing else. The builder's own runs are never mocked, and Code, Set, IF, Filter
and Merge run for real. So:
- **The fault must fail in n8n.** A Code node that returns a plain object does not
fail; n8n wraps it as one item. Return an array of plain values, reference a
node that does not exist (`$('Missing')`), or throw.
- **The fault fires before any external call.** The builder's rerun reaches a
fixture host and dies there (DNS, 401), so a fault behind an HTTP node is never
reached. Never grade the rerun itself; grade the fix with `outcomeExpectations`
and the harness's `executionScenarios`.
- **An empty branch needs a Filter that keeps nothing.** Split Out over an empty
array still emits a placeholder item.
- **Walk the item flow before asserting a count.** A Slack node fed three items
posts three times.
#### Before you ship a seeded case — three checks
1. **The defect still bites.** A seed whose workflow isn't broken any more makes the
@ -628,3 +696,139 @@ build". A seeded thread starts with an empty sandbox, so the agent re-reads the
workflow from the database and re-derives SDK source a real resumed session would
still have on disk. The bias is *harder* than reality, so those numbers read worse
for a reason that has nothing to do with the builder.
---
## Context cases (long conversations, token cost, compaction)
A context case asks whether the agent still works once the conversation is
long. Two questions, two mechanics.
### The judge already has the token numbers
Every case's judge context carries token ground truth, so no new field is
needed to grade cost:
- Each transcript turn header reads
`Turn 2 (3 steps, 47446 tokens in [41220 cached], 812 tokens out)`.
- A **Total** block sums every LLM step across the whole build, main run and
resumes, plus a cache read/write split and a **Fixed overhead** line — the
opening step's input, which is instructions and tool schemas plus one user
message, so it reads as the floor cost of any turn on that instance. Use it
to separate "the prompt got bigger" from "this conversation got expensive".
So `processExpectations` may reference consumption directly. Write them as
statements about *behaviour the numbers reveal*, not as thresholds:
```json
"processExpectations": [
"The agent answers the second question without calling get_node_details on the Notion node again — it read that schema in turn 1 and the answer needs nothing new.",
"Turn 2's input tokens are mostly cache reads, which is what an unchanged prefix looks like. A turn that re-reads the history from scratch shows a cache-read share near zero."
]
```
Absolute token budgets rot: a prompt edit moves every number and the case goes
red for a reason that has nothing to do with the agent. Grade the *shape* —
what got re-read, what came from cache, which turn is the expensive one.
Two limits to know before you write a cost expectation:
- It needs `N8N_INSTANCE_AI_RUN_DEBUG_ENABLED=true` on the instance under test.
Without it the blocks render `(no run debug captured)` and the expectation is
unjudgeable — it fails or passes at random.
- The capture hooks **only the orchestrator's own stream**. A workflow build
runs inside that loop, so a workflow case's numbers are its real cost. A
delegated **Agent** build (the `build-agent` tool) runs on a separate stream
with no hooks, so its tokens are missing from every block. Don't write a cost
expectation on an `data/agents/` case.
### `requiresMemoryCompaction` — grade the post-compaction window
Observational memory compacts a long thread: an Observer summarises it into
observation rows, the summary is rendered into the system prompt, and the early
turns are masked out of the agent's window. A case that tests whether a decision
*survives* that has a setup problem — production compacts at 30k tokens of
visible message content, which is a conversation too long to hand-author.
Set the flag and the harness handles it:
```json
"requiresMemoryCompaction": true
```
Two things happen, both per-thread, so no other case in the run is affected:
1. The harness sends a low observer threshold with every turn of that thread
only: **1,000 tokens** of visible message content, the request field's floor.
The seed plus the live turns must cross it, or the observer never runs — a
short seed does not test memory, it reports **not judged** (below). Size the
seed generously; a few real turns of tool output cross 1,000 tokens easily.
The Observer's prompt, masking and cursor logic are the production ones at
any threshold.
2. After the build, the harness checks the premise actually held. If it did
not, every expectation on the case is reported **not judged** — an
`incomplete` verdict, excluded from scoring, not a red.
The premise check is the point of the flag. Uncompacted, the raw early turns
are still in the window, so the agent answers off them and every expectation
passes for free — a green that tests nothing.
The evidence is structural. `GET /rest/instance-ai/eval/threads/:threadId/memory`
returns the observation rows and the compaction cursor from their own tables. A
cursor means the observer ran and everything up to `lastObservedMessageId` is
masked out; the rows are what replaced it. Missing either, the case had nothing
to test and is reported not judged. Nothing reads the system prompt, so this
needs **no debug flag** and a prompt or SDK rename cannot quietly turn "never
compacted" into the answer.
The judge never sees the premise check — it grades the conversation, not whether
the harness configured the scenario. A misconfigured lane must not read as a
quality regression.
### Grade the summary, not just the reply
The judge *does* see the observation rows, as ground truth:
```
## Observational memory after compaction (ground truth — do not recount)
- [CRITICAL] User ruled OUT the native Slack node; will post through the HTTP
Request node instead, because the workspace is pinned to a legacy Slack API
version. Do not revisit.
- [CRITICAL] Alert fires when queue depth goes above 4700 items.
```
So write expectations at **both** layers:
```json
"processExpectations": [
"The observational-memory block retains the alert threshold as 4700 queued items. A summary that keeps the topic but loses the number is the specific failure this looks for.",
"In its reply to the final turn, the agent gives the alert threshold as 4700 queued items."
]
```
The first grades the memory system, the second grades the agent. Grading only
the reply is weaker than it looks: the agent can answer correctly from the
unmasked tail, or guess well, and the case goes green while memory dropped the
detail. The `[MARKER]` is the Observer's own priority label, so "retained as
CRITICAL" is checkable too.
### Writing the conversation
The shape that makes a compaction case real:
1. **State the anchors once, in a `user` turn near the start of the seed**, and
never restate them. Anything the agent repeats later lands *after* the
cursor and survives for the wrong reason.
2. **Make live turn 1 off-topic.** Its answer must not re-surface the anchors
into the post-cursor window. Compaction runs at the end of it.
3. **Ask for the anchors back in live turn 2.** That turn runs against the
compacted context.
4. **Force the antecedent** — a lost anchor must produce a *confident wrong*
answer, not a vague one. "Posting goes through HTTP Request, not the native
Slack node" works because the Slack node is the obvious guess. "The
threshold is 4700" works because a guess reaches for 5000. See
[negative expectations need a forced antecedent](SKILL.md#core-principle-all-shapes).
Tag these `context-consumption` and put them in a `context` dataset, so a
tier that needs run debug enabled can select them.

View File

@ -218,6 +218,44 @@ deliberately does not re-sync tier-only edits to an existing case. **Only
promote to `pr` after `--iterations 5+` shows it's reliably green** — a flaky
case in the gate poisons it.
## Calibrating a batch of new cases on the dispatchers
One case costs 10–20 minutes per iteration on a laptop, and docker lanes have
crashed it. A batch calibrates on the LangTracer dispatchers:
1. Author the JSON locally. Static checks only: a `--dry-run` push (schema) and
the similarity check against the verification suites.
2. Push the batch to its suite with the tag `calibration-pending`.
3. Dispatch one manual sweep for those case ids:
`gh workflow run eval-run.yml --repo n8n-io/lang-tracer -f image=n8nio/n8n:nightly -f case_ids=<ids> -f iterations=3 -f trigger=manual`.
`trigger=manual` keeps it out of the nightly baseline.
4. Read `list_eval_runs` → `get_eval_run`. Classify each red: **builder** (keep
it red, tag `capability-gap-finding`, propose a ticket), **harness** (fix it or
design around it; never leave it to charge the model), **authoring** (fix the
case).
5. Re-push the fixed cases and re-dispatch only those ids.
6. Swap `calibration-pending` for `calibrated` and stamp the evidence into the
description, e.g. "Calibration (Opus 4.8, N=3): sweep 174 3/4, sweep 176 4/4".
## Validating a harness change before merge
```bash
gh workflow run test-evals-instance-ai.yml --repo n8n-io/n8n \
-f branch=<your-branch> -f cache-sha=$(git rev-parse origin/<your-branch>) \
-f suite=<suite> -f filter=<slug-substrings> \
-f iterations=3 -f experiment-name=<change-name>
```
`cache-sha` is what puts a backend change under test: without it the workflow
restores the n8n image cached for master's head, and only the eval CLI runs from
your branch. A harness-only change works either way.
CI reads cases from LangTracer only. A case that exists only on disk goes into a
throwaway suite first (`eval:langtracer-push --suite <scratch-suite> <slug>`).
Never pass `experiment-name=instance-ai-baseline`; that name refreshes the shared
baseline. Results: the run log's summary table and the
`instance-ai-workflow-eval-results` artifact (`eval-results.json` + HTML report).
## Baselines & regression
When `LANGSMITH_API_KEY` is set, every run auto-compares against the most recent

View File

@ -47,9 +47,20 @@ ids. Full table in
1. **Scan cluster themes** — `list_cluster_runs` / `get_latest_cluster_run`
return capability-gap themes with a `label`, `summary`, and `mechanism`: the
real, recurring failure modes.
2. **Pull the conversations** — `list_conversations` (e.g. `verdict:"bad"`,
`analyzed:"yes"`) → `get_conversation` (raw trace) + `get_conversation_analysis`
(findings). `get_linear_ticket_context` links a thread to its ticket. To target
2. **Pull the conversations.** Start from what LangTracer already detected.
`list_signal_rates` lists every code check and label rule with its rate over a
window: what fails most right now. Then `list_conversations` with `signals`,
one token per signal, ANDed — `check:has_nodes`,
`label:task_outcome=agent-gave-up`, `analysis:bad`, `keeper:*`, `review:*` — puts
the matching entries on each row, each with its evidence (the verdict comment,
the judge's words, Keeper's reason) and its version. On a candidate,
`get_conversation_signals` shows what every source said and which ones agreed:
a thread flagged by a code check, the judge and Keeper is a stronger source
than one flagged by a single model pass. A source marked `not_run` never looked
at the thread, so read its silence as unknown, not as clean. The older path
still works: `list_conversations` (e.g. `verdict:"bad"`, `analyzed:"yes"`) →
`get_conversation` (raw trace) + `get_conversation_analysis` (findings).
`get_linear_ticket_context` links a thread to its ticket. To target
**execution failures**, add `funnelDrop:"05"` (built + launched an execution
that never *succeeded*). **Caveat: the funnel drop is a *conversion* signal, not
a build-quality one** — most `funnelDrop:"05"` threads are healthy builds that
@ -61,7 +72,8 @@ ids. Full table in
(was `build-workflow` called on this turn?), which it reads reliably — but a
content-dependent claim ("invented ID", "missing node") can be wrong when it
couldn't see the built workflow. Confirm against the raw trace before building
a case around it.
a case around it. The same goes for a signal's `evidence`: it is what that
source saw, not the trace.
4. **Optionally record the selection as an observation.** Offer to save why this
thread was selected and what the developer observed. This is a best-effort
LangTracer note, not a gate: skip it when the developer declines or has not

View File

@ -34,7 +34,7 @@ Creates GitHub PRs with titles that pass n8n's `check-pr-title` CI validation.
- `benchmark` - Benchmark CLI changes
- `core` - Core/backend/private API
- `editor` - Editor UI changes
- `engine` - New workflow execution engine v2 (@n8n/engine package)
- `engine` - New workflow execution engine v2 (@n8n/engine package & engine-v2 module in cli)
- `* Node` - Specific node (e.g., `Slack Node`, `GitHub Node`)
### Summary Rules
@ -43,7 +43,7 @@ Creates GitHub PRs with titles that pass n8n's `check-pr-title` CI validation.
- Capitalize first letter
- No period at the end
- No ticket IDs (e.g., N8N-1234)
- Add `(no-changelog)` suffix to exclude from changelog
- Add `(no-changelog)` suffix to exclude from changelog, unless the type already excludes it
## Steps
@ -147,7 +147,7 @@ feat(API)!: Remove deprecated v1 endpoints
### No changelog entry
```
refactor(core): Simplify error handling (no-changelog)
feat(core): Experimental feature not enabled yet (no-changelog)
```
### No scope (affects multiple areas)

View File

@ -13,11 +13,11 @@ the same commands from the repo via `pnpm nathan`.
## Prerequisites
- **No tunnel setup needed.** Nathan replies asynchronously through a short-lived
public tunnel that the script opens for you (`npx localtunnel`). A `deploy` to a
**new** `test-<name>` also polls that instance URL directly, so it still reports
success even if the tunnel drops. (Redeploying a name that's *already up* skips
the poll — it can't tell the old instance from the new — so it falls back to the
tunnel; prefer a fresh name when you need the reliable report.)
Cloudflare Quick Tunnel that the script opens for you (`npx cloudflared`). A
`deploy` to a **new** `test-<name>` also polls that instance URL directly, so it
still reports success even if the tunnel drops. (Redeploying a name that's
*already up* skips the poll — it can't tell the old instance from the new — so
it falls back to the tunnel; prefer a fresh name when you need the reliable report.)
- **A token in `~/.n8n/dev/nathan-token`.** If a command reports no token, **ask the
user for one** — point them at the form
(`https://internal.users.n8n.cloud/form/d6d34a2f-4899-4ee8-afc8-f8c41a8a243d`),

View File

@ -41,6 +41,7 @@ add to it.
## Team defaults
- Write code that acts as its own documentation. The schema, the decorator, and the test should make the rule clear on their own without a comment.
- List endpoints: cursor-based pagination (internal API uses both cursor- and
page-based — don't copy an internal endpoint's model).
- Pagination args are always `offset` and `limit` — on service methods, handler
@ -111,7 +112,7 @@ model; reuse only what applies. Decorators, all from `@n8n/decorators`:
| `@ApiResponse(status)` / `@ApiResponse(status, Dto)` | Success status + (optional) output DTO; registry `.parse()`s + strips the return value. Exactly one per route — a second `@ApiResponse` throws. `204` can't carry a DTO — throws. |
| `@ApiErrorResponse(status)` | Declares an additional documented non-2xx status (e.g. `404`, `409`). Stack multiple for more than one. `400`/`401`/`403` are added automatically (body/query present, always, and `@ApiKeyScope` present, respectively) — don't declare those yourself. |
| `@ApiSummary(text)` / `@ApiDescription(text)` / `@ApiTags([...])` | OpenAPI summary/description/tags. `@ApiTags` sorts alphabetically regardless of the order you pass. All optional but expected on every real route. |
| `@Query` / `@Body` / `@Param('name')` | Bind + validate via a `Z.class` DTO / path param. |
| `@Query` / `@Body` / `@Param('name')` | Bind + validate via a `Z.class` DTO / path param. `@Body` is JSON by default; `@Body({ mediaType: 'multipart/form-data', uploadLimits })` takes a `multipart/form-data` body instead — see [Request body media types](reference.md#request-body-media-types). |
| `@Licensed('feat')` | Gates the route on a single `BooleanLicenseFeature`; `PublicApiControllerRegistry` runs its own license middleware (after auth/`@ApiKeyScope`/`@ProjectScope`|`@GlobalScope`, before the handler) and 403s unlicensed requests. Only takes one feature — if the gate is an any-of/all-of combination (e.g. `LicenseState.isProvisioningLicensed()`, which is `feat:saml` OR `feat:oidc`), `@Licensed` can't express that; check manually in the handler instead, same as the internal `provisioning.controller.ee.ts`/`role-mapping-rule.controller.ee.ts` do today (throwing `ForbiddenError` on failure). |
## Authorization (easy to get wrong)

View File

@ -103,6 +103,49 @@ scope, RBAC denial. Add whichever apply, matching the nearest existing tests:
unchanged. Tests alone cannot show this — see
[Verifying a migration](#verifying-a-migration).
## Request body media types
`@Body` defaults to `application/json`; the body is already parsed by the
app-wide `bodyParser` before the registry sees it. Declaring
`@Body({ mediaType: 'multipart/form-data', uploadLimits })` instead takes a
`multipart/form-data` body:
- `uploadLimits: () => MultipartUploadLimits` is a thunk, read once the route
handles its first request (not at startup) — read live config inside it
(e.g. `Container.get(GlobalConfig)`), don't inline a literal.
- The route's `@Body` DTO validates text fields merged with uploaded files,
not `req.body` alone. A text field and a file share the same flat object;
a file field uses `publicApiUploadedFileSchema` (`@n8n/api-types`) — a
multer-file-shaped schema that documents itself as `{ type: 'string',
format: 'binary' }`. Several files under the same field name become an
array.
- An unknown field (one the DTO doesn't declare, on a `{ strict: true }` DTO)
fails with `Unexpected form field "<name>"` — not the default "unrecognized
keys" wording.
- Multer's own parsing errors map to the same statuses express-openapi-validator
used: `413` for a size/count limit, `400` for any other multer error, `500`
— unmasked, since this is a `ResponseError` — for anything else (a
malformed body; a missing boundary gives `400` with `multipart file(s)
required`).
- The body is parsed **after** every auth/scope/license/quota gate and
**before** controller/route middlewares — a caller those gates would reject
never has their (possibly huge) body read off the socket, and a middleware
that reads `req.body` sees it already parsed.
- `/discover` shows no request schema for a multipart route (same as a legacy
multipart route today) — a client can't assume a JSON schema it never gets.
- The generator documents the body under the `multipart/form-data` content
key (not `application/json`) and adds `413` alongside `415` to the route's
documented responses automatically.
Everything above lives in `packages/cli/src/public-api/media-types/`, one
handler per media type (`REQUEST_BODY_HANDLERS` in
`media-types/request-body/index.ts`). Adding a further media type (e.g.
`application/octet-stream`) means: add it to `RequestBodyMediaOptions` in
`@n8n/decorators`'s `controller/types.ts`, write a handler implementing
`RequestBodyHandler` in `media-types/request-body/`, and register it in
`REQUEST_BODY_HANDLERS` — the registry, resolver, generator and `/discover`
need no change, since they all read the handler, not the media type.
## Migrating legacy EOV endpoints
Legacy `express-openapi-validator` endpoints live under
@ -146,8 +189,10 @@ not templates.
entry from the `off` allowlists for `no-repository-in-public-api-handler` and
`require-public-api-controller` in `packages/cli/eslint.config.mjs` (shrink-only
— never extend them).
- For complex legacy-only, multipart, or non-standard endpoints, study the
nearest existing handler first.
- A `multipart/form-data` legacy endpoint migrates onto `@Body({ mediaType:
'multipart/form-data', uploadLimits })` — see
[Request body media types](#request-body-media-types). For any other
non-standard endpoint, study the nearest existing handler first.
- Keep each field in its original position when you extract a request shape shared
by two routes, and destructure out the ones a route doesn't take. The generator
emits properties in shape order, so a moved field rewrites the `*.generated.yml`

View File

@ -32,7 +32,7 @@ Based on the affected area, pick the test layer and pattern:
| CLI / API | Vitest integration | setupTestServer + supertest | `packages/cli/test/integration/` |
| Config | Vitest unit | GlobalConfig + Container | `packages/@n8n/config/src/configs/__tests__/` |
| Editor UI | Vitest | Vue Test Utils + Pinia | `packages/frontend/editor-ui/src/**/__tests__/` |
| E2E / Canvas | Playwright | Test containers + composables | `packages/testing/playwright/` |
| E2E / Canvas | Playwright | Test containers + composables | `packages/quality/testing/playwright/` |
## Step 3: Locate Source Files
@ -73,6 +73,12 @@ Write a regression test that:
- Asserts the CORRECT behavior (test will fail on current code)
- Also includes a "happy path" test to prove the setup works
Complete the test safety gate in `/AGENTS.md` before you run the test:
- Trace side effects from imports, constructors, hooks, and mocked branches
- Keep filesystem access in a test-owned temporary directory
- Set `N8N_USER_FOLDER` before importing modules that resolve it
- When a mock changes a state check, inspect and isolate the branch that it activates
## Step 8: Run and Score
Run the test from the package directory (e.g., `cd packages/nodes-base && pnpm test <file>`).

View File

@ -13,7 +13,18 @@ Comprehensive guide for building, styling, and using components in the frontend.
- When reviewing UI changes, use `rules/web-interface-guidelines.md`
## Best practices
- ALWAYS use CSS variables for styles from `packages/frontend/@n8n/design-system/src/css/_tokens.scss` or `packages/frontend/@n8n/design-system/src/css/_primitives.scss`. Use hard-coded values only when no suitable tokens.
- ALWAYS use CSS variables from `_tokens.scss` or `_primitives.scss` in
`packages/frontend/@n8n/design-system/src/css/`. Look up every length, color,
font-size and duration before you write it as a literal. Each spacing token
carries its px value in a comment: `--spacing--5xs: 0.125rem; /** 2px **/`.
Grep that comment to find the token for a px value. The grep gives one hit for
each token family, so pick the family that matches the property: `--spacing--*`
for margin and padding, `--radius--*` for border-radius, `--height--*` for
height. The styleguide
`packages/frontend/@n8n/design-system/src/styleguide/spacing-size.mdx` lists the
same scale as a table. The spacing scale starts at 2px, so a small value is not
a reason to hardcode. Hardcode only if the lookup finds no token. Give the
reason in a comment.
- ALWAYS prefer using existing components from `packages/frontend/@n8n/design-system/src/components`. Prefer components that aren't marked `@deprecated`.
- If you need to add hover/active alpha behavior to solid components, prefer `color-mix()` with explicit percentages.
- When working with animations or transitions, ALWAYS prefer using mixins from `packages/frontend/@n8n/design-system/src/css/mixins/motion.scss`

View File

@ -8,7 +8,7 @@ exclude:
# 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
- packages/quality/testing/playwright/expectations
# Test code repo-wide. Fixtures routinely contain synthetic PEM blocks,
# basic-auth strings, and deliberately fake keys (including the secret

View File

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

View File

@ -0,0 +1 @@
../../../../.agents/skills/agent-design-language

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ ARG NODE_VERSION=24
FROM node:${NODE_VERSION}-bookworm
ARG PLAYWRIGHT_VERSION=1.62.1
ARG OPENCODE_VERSION=1.18.25
RUN apt-get update && apt-get install -y --no-install-recommends \
tmux sudo less jq ripgrep postgresql-client \
@ -14,7 +15,7 @@ RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
# Agent harnesses. Self-update is off: the image is the update mechanism
ENV DISABLE_AUTOUPDATER=1
RUN npm install -g @anthropic-ai/claude-code opencode-ai
RUN npm install -g @anthropic-ai/claude-code opencode-ai@${OPENCODE_VERSION}
COPY codespaces-env.sh /usr/local/lib/codespaces-env.sh
COPY codespaces-secrets.sh /etc/profile.d/codespaces-secrets.sh
@ -37,4 +38,7 @@ ENV PNPM_HOME=/home/node/.local/share/pnpm
ENV PATH=$PNPM_HOME/bin:$PATH
USER node
RUN mkdir -p $PNPM_HOME ~/.pnpm-store && pnpm config set store-dir ~/.pnpm-store --global
# The store must sit on the same filesystem as the worktrees (/workspaces is its own
# mount), or pnpm copies node_modules into every worktree instead of hardlinking.
# The directory is created on the first install: Codespaces mounts /workspaces at runtime.
RUN mkdir -p $PNPM_HOME && pnpm config set store-dir /workspaces/.pnpm-store --global

View File

@ -2,7 +2,7 @@
Run long-lived, human-steered agent sessions (Claude Code / OpenCode) on a
GitHub Codespace instead of your laptop: start a task, close the lid, steer it
from anywhere with a terminal, resume tomorrow.
from anywhere, resume tomorrow.
This is a separate devcontainer config from the laptop one in
`.devcontainer/` — it ships both agent CLIs, `tmux` for session persistence,
@ -11,26 +11,73 @@ Playwright system deps, and Docker-in-Docker (for testcontainers and
## One-time setup (~5 min)
1. Add provider keys at [github.com/settings/codespaces](https://github.com/settings/codespaces).
Add `ANTHROPIC_API_KEY` for Claude Code. Add `OPENROUTER_API_KEY` for OpenCode.
Give both secrets access to `n8n-io/n8n`.
(Alternative for Max subscriptions: `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`.)
2. Give the GitHub CLI the codespace scope:
1. Give the GitHub CLI the codespace scope:
```bash
gh auth refresh -h github.com -s codespace
```
## Daily flow
2. Add provider keys at [github.com/settings/codespaces](https://github.com/settings/codespaces)
if your client needs them. Give each secret access to `n8n-io/n8n`.
- `ANTHROPIC_API_KEY` for Claude Code in the terminal (`pnpm session`).
Alternative for Max subscriptions: `CLAUDE_CODE_OAUTH_TOKEN` from
`claude setup-token`. The desktop app does not need either secret.
- `OPENROUTER_API_KEY` for OpenCode.
## Choose a client
All clients run the agent, its tools, and builds in the Codespace. Your laptop
shows the conversation only.
| Client | Command | Use it when |
|---|---|---|
| Claude Code desktop app | `pnpm session ssh-config` once | You want the full desktop experience. |
| Claude Code in VS Code | Open the codespace in VS Code or the browser | You already work in VS Code. |
| Claude Code in a terminal | `pnpm session` | You want a plain terminal, or you are on a remote machine. |
| OpenCode | `pnpm session:opencode` | You use OpenCode. See [Local OpenCode clients](#local-opencode-clients). |
### Claude Code desktop app
1. From a local checkout, add an SSH host for your codespace:
```bash
pnpm session ssh-config
```
The command creates or starts the codespace. It writes the host
`n8n-codespace` to `~/.ssh/n8n-codespace.conf` and includes that file from
`~/.ssh/config`.
2. In the desktop app, add an SSH connection to the host `n8n-codespace`.
3. Open the folder `/workspaces/n8n`, or a worktree under `/workspaces`.
The desktop app installs its own Claude Code on the codespace and signs in
with your desktop account. Sessions continue when you close the app. They stop
when the codespace stops.
- **Run `pnpm session ssh-config` again after you recreate the codespace.**
- **If the connection times out**, the codespace is probably starting. Run
`pnpm session ssh-config`, then connect again.
- **Test the connection** with `ssh n8n-codespace`.
### Claude Code in VS Code
The dev container installs the Claude Code extension. Open the codespace in
VS Code or the browser, then open the Claude Code panel. Sign in from the
extension if you did not add `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`.
### Claude Code in a terminal
```bash
pnpm session # attach Claude Code (creates everything on first run)
pnpm session:shell # open a shell in the default checkout
pnpm session:opencode # attach OpenCode in auto mode
pnpm session:opencode fix-flaky # OpenCode in a separate worktree
pnpm session:opencode # connect the local OpenCode TUI
pnpm session:opencode fix-flaky # use a separate remote worktree
pnpm session:opencode --web # open OpenCode in the local browser
pnpm session:opencode --legacy # use the remote TUI in tmux
pnpm session fix-flaky # Claude Code in a separate worktree
pnpm session ls # what's running
pnpm session tunnel # forward n8n ports (default 5678, 8080); Ctrl-C to stop
pnpm session ssh-config # add the n8n-codespace SSH host for the desktop app
pnpm session stop # end of day: billing stops, disk survives
pnpm session rm # delete the codespace
```
@ -38,15 +85,136 @@ pnpm session rm # delete the codespace
The dev container supports Codespaces with 2, 4, or 8 cores. The session commands
create an 8-core Codespace by default.
- **Detach** with `Ctrl-b d` — the agent keeps working without you.
- **Scroll** with the mouse wheel (tmux mouse mode is on). To use the
- **Detach from tmux** with `Ctrl-b d` — the agent keeps working without you.
Local OpenCode connections use the exit controls described below.
- **Scroll in tmux** with the mouse wheel (tmux mouse mode is on). To use the
terminal's own text selection, hold **Shift** and drag.
- **Reattach** by running the same session command from any machine.
- Each named session gets its own worktree (`/workspaces/wt-<name>`, branch
`session/<name>`), so parallel agents never touch each other's tree. Builds
`session/<name>`), so parallel agents never touch each other's tree. A new
branch starts from the latest `origin/master`. Builds
in fresh worktrees are cache-hits via a shared turbo cache.
- First codespace creation takes ~20 min uncached (image + full build). After
that, sessions attach instantly; new worktrees cost a `pnpm install` (~1–2 min).
that, sessions attach instantly; new worktrees cost a `pnpm install` (~10 s: the
store lives on `/workspaces`, so `node_modules` is hardlinked, not copied).
## Configure the local OpenCode harness
Install the pinned shared harness:
```bash
pnpm agent:harness
```
The command verifies the release checksum. It caches the bundle under
`~/.cache/n8n-agent-harness`. It then links the bundle plugin into
`~/.config/opencode/plugins`. Run the command again after the lock changes.
Restart OpenCode after the command completes.
## Local OpenCode clients
Run these commands from a local checkout. The TUI runs on your laptop. The
OpenCode server, repository, tools, and builds run in the Codespace. Browser
mode opens the remote web interface through a local connection.
```bash
pnpm session:opencode fix-flaky # resume that workspace's latest conversation
pnpm session:opencode fix-flaky --web # open it in a browser
pnpm session:opencode fix-flaky --new # start a new conversation in that worktree
pnpm session:opencode --web --port 4100 # override the default browser port
pnpm session:opencode --help
```
For the TUI, install the same OpenCode version as the remote server. The
`OPENCODE_VERSION` argument in the [Dockerfile](Dockerfile) pins the version
for new images:
```bash
npm install -g opencode-ai@<version>
```
Use npm, not pnpm. The package gets its binary from a `postinstall` script.
pnpm does not run that script by default.
The launcher checks both versions. It reports a mismatch with both version
numbers before it opens the TUI. An existing Codespace can have a different
version. Install that version locally, or use `--web`. Browser mode does not
need a local OpenCode install.
The launcher sends its bootstrap code over SSH, so an existing Codespace does
not need a rebuild to use this connection method.
The remote server loads the shared OpenCode harness that the Codespace
post-start command installs. The local harness setup above is not required for
these commands.
The command prepares the worktree, starts or reuses one server, opens an SSH
tunnel, and connects the client. Each workspace opens its own conversation: the
most recently updated one in that worktree. A conversation moves to the front
when it receives a message. Switching to a conversation without sending a
message does not move it. Switching to another workspace's conversation does
not change what this workspace opens next. `--new` starts a new conversation
instead. It preserves the worktree and the old conversation.
- **Exit the TUI** with `/exit` or its quit shortcut. The launcher closes its
tunnel. The remote server stays running.
- **Disconnect browser mode** with `Ctrl-C` in the launcher terminal. Closing
the browser tab does not close the tunnel. Keep the launcher running while
you use the browser.
- **Reconnect** with the same command after a network interruption. After a
Codespace stop, the command restarts the server and opens the workspace's
most recently updated conversation.
A stop terminates running tools. It does not resume interrupted commands.
- **Browser mode uses local port 4096 by default.** Use `--port` to override it.
TUI mode selects an available port unless you specify one.
A fixed browser port preserves the browser origin across runs.
An occupied fixed port causes an error. It does not stop the existing listener.
- **Use model and permission controls in the client.** The new connection uses
the remote OpenCode configuration. It does not force the legacy `--auto` mode.
For the old CLI flags, use `pnpm session:opencode fix-flaky --legacy --model <model>`.
Both server and tunnel bind to `127.0.0.1`. The server uses a generated password.
The TUI receives it through its environment. Browser mode uses a local proxy
that adds authentication and rejects requests from other browser origins. The
proxy supports streamed responses, attachments, and terminal WebSockets. The
password does not appear in the URL or terminal output. Other processes on
your laptop can access the local browser proxy while it runs. Do not forward
this proxy or the OpenCode server port to other machines.
The server enables only OpenRouter. It reads `OPENROUTER_API_KEY` when it starts.
It enables OpenCode code mode by default to reduce the initial tool context.
Browser mode opens the workspace's most recently updated conversation directly.
The web UI stores opened projects in browser storage. If a new-session page
shows **New project**, open `/workspaces/n8n` there once. Keep the same browser
port when you reconnect to preserve this selection.
The server runs in the detached tmux session `n8n-opencode-server`. Its log is
`/workspaces/.n8n-opencode/server.log`. That directory also holds the server
credentials. It is readable only by its owner.
An unhealthy server produces an error without stopping active work. Inspect
the log through `pnpm session:shell`. To restart it after checking active work,
run `tmux kill-session -t '=n8n-opencode-server'` in that shell. Then reconnect.
Restart the server after changing provider secrets, server configuration, or
the pinned harness release.
### Limits
- The launcher supports macOS and Linux. On Windows, run it in WSL. If the
browser does not open automatically, open the printed URL yourself.
- Local clipboard and attachment controls depend on the client, terminal,
file type, and model. A laptop file path does not copy a file to the Codespace.
Use a supported attachment control or copy the file with `gh codespace cp`.
- Tools and local MCP processes run on the Codespace. Laptop configuration,
browser sessions, and files do not sync automatically.
- Workspace names identify worktrees. Two agents that use the same name share
files. Separate conversations alone do not isolate edits.
- The server survives a client disconnect while the Codespace stays running.
Codespaces idle timeouts still apply. An open tunnel is not a guarantee that
the Codespace will stay awake. Use `pnpm session stop` to stop compute billing.
- Existing tmux OpenCode conversations are not migrated automatically. Use
`--legacy` to return to them.
For upstream behavior, see the [OpenCode CLI](https://opencode.ai/docs/cli/),
[server](https://opencode.ai/docs/server/), and [web](https://opencode.ai/docs/web/)
documentation.
## PR previews (a running instance of someone else's PR)
@ -66,6 +234,10 @@ the Actions tab: give it a PR number and `up`, `refresh` or `down`.
`pnpm preview refresh <pr>`, `pnpm preview down <pr>` and `pnpm preview ls`. It
needs `gh` with the codespace scope, the same as `pnpm session`.
- **Watch it come up on the PR.** The comment appears before the box work starts
and updates about once a minute with a checklist of the phases, so you can see
which step a slow preview is on. `pnpm preview up <pr>` prints the same phases as
plain progress — the markers the comment reads are `--json` only.
- **Sign in with one click** at `<url>/preview-signin`. It logs you in as the
seeded owner and sends you to the editor. The credentials are
`preview@n8n.io` / `PreviewInstance1`. They are not secrets: the boundary is
@ -76,7 +248,13 @@ needs `gh` with the codespace scope, the same as `pnpm session`.
present. `preview:debug` sets `N8N_LOG_LEVEL=debug`. Adding or removing one
re-serves the box; it never creates or deletes one. From a laptop the labels
apply the same way — `pnpm preview refresh <pr>` reads them from the PR. The
toggles are defined in `scripts/preview-labels.mjs`; add new ones there.
toggles are defined in `scripts/codespace-preview/preview-labels.mjs`; add new ones there.
- **Configure the instance from a webhook.** A preview also reads extra
environment from an n8n webhook, so a value can change without a commit. It
needs the `CODESPACE_ENV_URL`, `CODESPACE_ENV_USER` and `CODESPACE_ENV_PASSWORD`
codespace secrets. Every key the webhook returns becomes an environment
variable, so editing that workflow runs code in the box. Without the secrets
the preview serves as usual. See [WORKFLOWS.md](../../.github/WORKFLOWS.md).
- **A preview sleeps after 2 hours** of no use and GitHub deletes it after 24
hours. A box that slept serves nothing and its port is private again, so wake
it with `pnpm preview up <pr>` or a manual run of
@ -85,7 +263,7 @@ needs `gh` with the codespace scope, the same as `pnpm session`.
builds a new one.
- **A PR from a fork gets no preview.** A codespace's token is scoped to
`n8n-io/n8n`, so it cannot check out a fork head.
- **A PR that predates this tooling has no `scripts/preview-serve.mjs`.** The
- **A PR that predates this tooling has no `scripts/codespace-preview/preview-serve.mjs`.** The
serve step says so and stops; rebase the PR on master and retry.
## Agent worker (drive a session from n8n)
@ -104,7 +282,12 @@ instead. It asks n8n for a turn addressed to this box's owner (`$GITHUB_USER`).
It runs the turn. It sends the result to the turn's resume URL. It uses no
tunnel, no open port, and no domain.
The worker starts on each container start (`postStartCommand`). It needs three
The post-start command installs the pinned OpenCode harness before it starts the
worker. It verifies a valid cached bundle before reuse. The worker does not start
if the harness is unavailable. `/tmp/post-start-status.json` contains the harness
and worker status.
The worker needs three
secrets and uses three optional secrets. Add them at
[github.com/settings/codespaces](https://github.com/settings/codespaces), the
same way as `ANTHROPIC_API_KEY`:
@ -124,8 +307,12 @@ worker posts one placeholder in that thread. It coalesces completed tool calls.
It updates the message at most once every 1.5 seconds. It does not send reasoning
text. The worker replaces the placeholder with the final answer.
If the Slack API fails, the turn still completes through the n8n resume URL.
The worker does not export its dequeue or Slack credentials to OpenCode.
The worker does not export its dequeue or Slack credentials to OpenCode. It uses
the harness `sandbox` runtime and `slack` profile. The profile supplies the
atomic-turn instruction.
Interactive sessions remove all worker-only credentials before they start.
Interactive OpenCode uses the global harness plugin with the `sandbox` runtime.
It does not set a profile. It keeps the dynamic OpenRouter configuration.
An idle worker starts with a 3-second poll interval. After each empty dequeue,
it doubles the interval and limits it to 30 seconds. Work resets the interval
@ -139,16 +326,15 @@ A turn stops after about 25 minutes (`TURN_TIMEOUT_MS`). This limit is below the
n8n Wait limit. So the worker reports a clear message before n8n reports a
generic timeout. Keep the worker limit below the n8n limit if you change either.
**A turn is atomic, and the worker tells the session so.** The turn ends on the
**A turn is atomic, and the harness tells the session so.** The turn ends on the
session's final message, and its children end with it: a background `Bash` task
is killed, `Monitor` events never arrive, `PushNotification` has nowhere to go,
and `ScheduleWakeup` never fires. The session also gets no turn of its own to
report back in — the turn's resume URL continues one waiting n8n execution and is
then spent, so nothing on the box can post to the thread unprompted. A session
that backgrounds a build and signs off with "I'll verify once it finishes" is
therefore describing something that cannot happen. The worker states this in
each OpenCode prompt (`turnContract`), together with a pointer to this file for
the box-specific parts. This is only the n8n/Slack path: an
therefore describing something that cannot happen. The harness `slack` profile
states this contract. This is only the n8n/Slack path: an
interactive session (`pnpm session`, tmux) is long-lived, so background work,
monitors and notifications behave normally there.
@ -191,11 +377,15 @@ session rarely needs a cold `pnpm install` or a full `pnpm build`. Both are slow
Claude sessions and the headless OpenCode worker get the `flaky` MCP server automatically: Currents
flaky/quarantine data, the `qa_*` BigQuery dataset, Sentry RCA, live Linear,
and repo investigation. The worker keeps the token in its environment and puts
only an environment reference in the OpenCode config. Claude login registers
the same server without writing the token to disk. Forks have no secrets and
only an environment reference in the OpenCode config. Forks have no secrets and
skip it. Tell the agent to call `get_flaky_context` first — it returns the rules
the tools assume.
`post-start.mjs` registers the server for Claude Code on each container start.
Its `headersHelper` reads the token from the secrets file on each connect, so
the server works in every client. The token is not copied into the Claude Code
MCP configuration.
## Quality and security skills (Claude plugins)
Claude sessions can also load the private skills from the
@ -257,7 +447,8 @@ to pull the skills into context.
## Viewing the dev UI locally
Two terminal windows:
`pnpm session tunnel` works with every client. With the terminal client, use
two terminal windows:
```bash
pnpm session # window 1: attach the agent session
@ -287,7 +478,7 @@ partial mapping breaks it.
| Event | Running processes | Disk (checkout, worktrees, chat history) |
|---|---|---|
| Detach / close laptop / network drop | ✅ keep running | ✅ |
| Stop, or idle timeout (default 30 min, max 4 h) | ❌ killed | ✅ |
| Stop, or idle timeout (2 h for a codespace that `pnpm session` creates, max 4 h) | ❌ killed | ✅ |
| Delete (`pnpm session rm`) | ❌ | ❌ (push your branches first) |
After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
@ -307,10 +498,10 @@ After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
agent injects them into VS Code sessions only; they're delivered
base64-encoded to `/workspaces/.codespaces/shared/.env-secrets`. The image
sources `/usr/local/lib/codespaces-env.sh` in login shells (profile.d), in
interactive shells (bashrc), and in the `pnpm session` prelude. If Claude
Code shows `Missing environment variables: FLAKY_MCP_TOKEN`, the shell that
started Claude did not source the file. Run
`. /usr/local/lib/codespaces-env.sh` and start Claude again.
interactive shells (bashrc), and in the `pnpm session` prelude. Desktop app
sessions do not source it. `git`, `gh`, `scripts/codespace-env.mjs`, and the
`flaky` MCP entry read the file when they run, so they work everywhere. New
code that needs a secret must do the same.
- **Do not read `CODESPACE_NAME` or `GITHUB_USER` from the process env** — use
`scripts/codespace-env.mjs`. Codespaces gives these variables to VS Code
sessions only. Other processes read them from `codespaces-env.sh`, and a
@ -319,7 +510,7 @@ After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
polled correctly as its owner while `dev:up` in the same session saw an empty
box name, printed the localhost URL, and did not share the port. The helper
reads `/workspaces/.codespaces/shared`, which is always correct.
- **You cannot paste images into a remote Claude session.** Image paste reads
- **You cannot paste images into a terminal Claude session.** Image paste reads
the clipboard of the machine where `claude` runs — the codespace, not your
laptop. Drag the file into the VS Code explorer (or
`gh codespace cp shot.png remote:/workspaces/n8n/`) and give Claude the

View File

@ -90,6 +90,8 @@ export function openCodeEnvironment(environment) {
delete childEnvironment.N8N_DEQUEUE_URL;
delete childEnvironment.SLACK_BOT_TOKEN;
childEnvironment.OPENCODE_CONFIG_CONTENT = JSON.stringify(openCodeConfig(childEnvironment));
childEnvironment.N8N_AGENT_RUNTIME = 'sandbox';
childEnvironment.N8N_AGENT_PROFILE = 'slack';
return childEnvironment;
}
@ -98,37 +100,6 @@ const TURN_ENV = openCodeEnvironment(process.env);
if (BOX_ID) TURN_ENV.CODESPACE_NAME = BOX_ID;
if (GITHUB_USER) TURN_ENV.GITHUB_USER = GITHUB_USER;
const CODESPACE_DOCS = '.devcontainer/codespaces/README.md';
// The worker has no later turn, so each prompt must define the atomic runtime contract.
function turnContract(author) {
return [
'# Your runtime',
'You are one turn of a Slack thread, driven by an n8n workflow that runs you as a headless',
'OpenCode session on a GitHub codespace. Your final message is the reply that reaches Slack, so keep',
'it short and skip heavy markdown.',
author ? `You are replying to ${author}.` : '',
'',
'# A turn is atomic',
'The turn ends when you emit your final message, and everything you started ends with it:',
'background Bash tasks are killed, Monitor events never arrive, PushNotification has nowhere to',
'go, and ScheduleWakeup never fires. You get no turn of your own afterwards — you cannot speak',
'again until a human writes again. So run long work (builds, test suites, restarts) in the',
'foreground of this turn and wait for it, or do not start it at all. Never end a turn promising',
`to verify, check back, or follow up. Work that will not fit the turn limit of ~${Math.round(
TURN_TIMEOUT_MS / 60_000,
)} minutes`,
'should be split: do the part that fits, then say what to ask for next.',
'',
'# This box',
`You are on codespace ${BOX_ID ?? '(unknown)'}, not a laptop. Before you build, start, or expose`,
`the app, read ${CODESPACE_DOCS} ("Build and run the app in a session"). It is box-specific and`,
'the repo AGENTS.md does not cover it.',
]
.filter(Boolean)
.join('\n');
}
function safeCwd(cwd) {
const safeCwd = resolvePath(typeof cwd === 'string' && cwd ? cwd : `${ROOT}/n8n`);
if (safeCwd !== ROOT && !safeCwd.startsWith(ROOT + sep))
@ -150,7 +121,7 @@ function eventError(event) {
}
export function runOpenCode(
{ message, sessionId, cwd, author },
{ message, sessionId, cwd },
onEvent,
onSession,
{
@ -238,9 +209,7 @@ export function runOpenCode(
resolve({ result: text.join('\n').trim(), session_id: activeSessionId });
});
child.stdin.end(
`${turnContract(typeof author === 'string' ? author : '')}\n\n# Request\n${message}`,
);
child.stdin.end(message);
});
}

View File

@ -24,6 +24,9 @@ test('reloads Codespaces secrets before the worker starts', () => {
postStart,
/bash -lc "\. \/usr\/local\/lib\/codespaces-env\.sh; .*node \/workspaces\/n8n\/\.devcontainer\/codespaces\/agent-worker\.mjs/,
);
assert.match(postStart, /installAgentHarness\(\)/);
assert.match(postStart, /harness\.status === 'active' &&\s+tryRun\('worker start'/);
assert.match(postStart, /JSON\.stringify\(\{ installed, failed, harness, workerStarted \}/);
});
function slackRecorder() {
@ -296,7 +299,7 @@ test('streams OpenCode CLI events and resumes an OpenCode session', async () =>
'ses_existing',
]);
assert.equal(invocation.options.detached, true);
assert.match(prompt, /# Request\nTest message/);
assert.equal(prompt, 'Test message');
assert.deepEqual(
events.map((event) => event.type),
['step_start', 'tool_use', 'text'],
@ -345,6 +348,8 @@ test('keeps broker credentials out of the OpenCode process', () => {
ANTHROPIC_API_KEY: 'model',
OPENROUTER_API_KEY: 'openrouter',
GITHUB_TOKEN: 'github',
N8N_AGENT_RUNTIME: 'sandbox',
N8N_AGENT_PROFILE: 'slack',
});
});

View File

@ -27,6 +27,14 @@ if (args[0] === 'codespace' && args[1] === 'list') {
`,
);
chmodSync(mockGh, 0o755);
for (const [name, script] of Object.entries({
infocmp: '[ "$1" = "$SUPPORTED_TERM" ]',
tmux: 'printf "%s" "$TERM"',
})) {
const executable = join(fixtureDir, name);
writeFileSync(executable, `#!/bin/sh\n${script}\n`);
chmodSync(executable, 0o755);
}
});
after(() => rmSync(fixtureDir, { recursive: true, force: true }));
@ -58,18 +66,59 @@ function remoteCommand(args) {
return invocationArgs[6];
}
for (const { name, supportedTerm, expectedTerm } of [
{
name: 'uses xterm-256color when the remote terminal definition is missing',
supportedTerm: 'xterm-256color',
expectedTerm: 'xterm-256color',
},
{
name: 'keeps the terminal type when the remote definition is available',
supportedTerm: 'xterm-ghostty',
expectedTerm: 'xterm-ghostty',
},
]) {
test(name, () => {
const result = spawnSync('/bin/sh', ['-c', remoteCommand(['--opencode', '--legacy'])], {
encoding: 'utf8',
env: {
...process.env,
PATH: `${fixtureDir}:${process.env.PATH}`,
TERM: 'xterm-ghostty',
SUPPORTED_TERM: supportedTerm,
},
});
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, expectedTerm);
});
}
test('starts a named OpenCode session in a worktree', () => {
const command = remoteCommand(['--opencode', 'fix-flaky', '--model', 'test']);
const command = remoteCommand(['--opencode', 'fix-flaky', '--legacy', '--model', 'test']);
assert.match(command, /tmux new -As fix-flaky-opencode/);
assert.match(command, /unset AGENT_WORKER_TOKEN N8N_DEQUEUE_URL SLACK_BOT_TOKEN/);
assert.match(command, /OPENCODE_CONFIG_CONTENT/);
assert.match(command, /git -C \/workspaces\/n8n worktree add "\/workspaces\/wt-fix-flaky"/);
assert.match(command, /export N8N_AGENT_RUNTIME=sandbox; unset N8N_AGENT_PROFILE/);
assert.match(
command,
/git -C \/workspaces\/n8n fetch origin master; git -C \/workspaces\/n8n worktree add --no-track -b "session\/fix-flaky" "\/workspaces\/wt-fix-flaky" origin\/master/,
);
assert.match(command, /cd "\/workspaces\/wt-fix-flaky" && opencode --auto --model test/);
assert.doesNotMatch(command, /unset OPENROUTER_API_KEY/);
assert.doesNotMatch(command, /claude plugin/);
});
test('forwards legacy flags without a workspace name to the main checkout', () => {
for (const flags of [['--help'], ['--model', 'test']]) {
const command = remoteCommand(['--opencode', '--legacy', ...flags]);
assert.match(command, /tmux new -As agent-opencode/);
assert.ok(command.includes(`cd /workspaces/n8n && opencode --auto ${flags.join(' ')}`));
assert.doesNotMatch(command, /git .*worktree add/);
}
});
test('keeps removed credentials out of the login shell', () => {
const command = remoteCommand(['--shell']);
const profile = readFileSync(new URL('./codespaces-secrets.sh', import.meta.url), 'utf8');

View File

@ -12,14 +12,6 @@
'{"hasCompletedOnboarding":true,"theme":"dark","projects":{"/workspaces/n8n":{"hasTrustDialogAccepted":true}}}' \
>"$HOME/.claude.json"
# Register the Flaky MCP server for Claude Code. The config keeps a literal
# ${FLAKY_MCP_TOKEN}; Claude Code expands it at connect time, so the token is
# not written to disk. Forks have no repo secrets and skip this.
if [ -n "$FLAKY_MCP_URL" ] && ! grep -qs '"flaky"' "$HOME/.claude.json" && command -v claude >/dev/null 2>&1; then
claude mcp add --scope user --transport http flaky "$FLAKY_MCP_URL" \
--header 'Authorization: Bearer ${FLAKY_MCP_TOKEN}' >/dev/null 2>&1 || true
fi
# Register the credential helper in the user config, because Codespaces
# regenerates the managed /etc/gitconfig. When the env token is missing, the
# system helper exits 0 without output, and git falls through to ours.

View File

@ -18,13 +18,17 @@
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"forwardPorts": [8080, 5678],
// Grant the codespace's own GitHub token read access to the private skills
// marketplace (same org), so Claude can install it with the codespace's normal
// git auth — no extra PAT. Each user authorizes this once at codespace creation.
"customizations": {
// Claude Code in VS Code or the browser editor. It runs in the codespace.
"vscode": {
"extensions": ["anthropic.claude-code"]
},
// Grant the Codespace token read access to the private skills and harness repositories.
// Each user authorizes this access once when they create the Codespace.
"codespaces": {
"repositories": {
"n8n-io/n8n-agent-skills": { "permissions": { "contents": "read" } }
"n8n-io/n8n-agent-skills": { "permissions": { "contents": "read" } },
"n8n-io/cat-bot": { "permissions": { "contents": "read" } }
}
}
},

View File

@ -0,0 +1,36 @@
// Fake executables for tests. Each shim is a Node script. It receives `args`, `root`,
// `file(name)` under the root, and `log(event)`, which appends to calls.jsonl in the root.
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const COMMON = `
const fs = require('node:fs');
const path = require('node:path');
const args = process.argv.slice(2);
const root = process.env.TEST_ROOT;
const file = (name) => path.join(root, name);
const log = (event) => fs.appendFileSync(file('calls.jsonl'), JSON.stringify(event) + '\\n');
`;
export function fakeBinaries(prefix) {
const root = mkdtempSync(join(tmpdir(), prefix));
const binDir = join(root, 'bin');
mkdirSync(binDir);
const logFile = join(root, 'calls.jsonl');
writeFileSync(logFile, '');
return {
root,
env: { PATH: `${binDir}:${process.env.PATH}`, TEST_ROOT: root },
bin: (name, body) =>
writeFileSync(join(binDir, name), `#!${process.execPath}\n${COMMON}\n${body}`, {
mode: 0o755,
}),
calls: () =>
readFileSync(logFile, 'utf8')
.trim()
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line)),
};
}

View File

@ -0,0 +1,224 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { rmSync, readFileSync } from 'node:fs';
import { test } from 'node:test';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { fakeBinaries } from './fake-bin.mjs';
import { parseOpenCodeArgs } from '../../scripts/cloud-session-opencode.mjs';
const script = fileURLToPath(new URL('../../scripts/cloud-session.mjs', import.meta.url));
const secret = 'a'.repeat(64);
function fixture(t, env = {}) {
const { root, bin, calls, env: binEnv } = fakeBinaries('opencode-client-');
t.after(() => rmSync(root, { recursive: true, force: true }));
bin(
'gh',
`
log({ command: 'gh', args });
if (args[0] === 'codespace' && args[1] === 'list') {
console.log(JSON.stringify([
{ name: 'dev-box', state: 'Available' },
]));
} else if (args.includes('-N')) {
if (process.env.TEST_TUNNEL_FAIL) process.exit(1);
const mapping = args[args.indexOf('-L') + 1];
const port = +mapping.split(':')[1];
const ssh = require('node:child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
log({ event: 'ssh-child', pid: ssh.pid });
const server = require('node:http').createServer((req, res) => {
const expected = 'Basic ' + Buffer.from('opencode:' + '${secret}').toString('base64');
if (process.env.TEST_UNAUTHORIZED || req.headers.authorization !== expected) { res.writeHead(401).end(); return; }
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ healthy: true, version: process.env.TEST_SERVER_VERSION || '1.2.3' }));
});
server.listen(port, '127.0.0.1');
if (process.env.TEST_TUNNEL_DROP) setTimeout(() => process.exit(1), 500);
process.on('SIGTERM', () => { log({ event: 'tunnel-stopped' }); server.closeAllConnections(); server.close(() => process.exit(0)); });
} else if (args.at(-1).includes('node --input-type=module')) {
let source = '';
process.stdin.on('data', chunk => source += chunk);
process.stdin.on('end', () => {
log({ event: 'bootstrap', hasSource: source.includes('prepareOpenCode') });
if (process.env.TEST_BOOTSTRAP_FAIL) process.exit(1);
const parts = args.at(-1).split(' ');
const name = parts.at(-3);
const fresh = parts.at(-2) === 'true';
const web = parts.at(-1) === 'true';
console.log(JSON.stringify({ port: 4242, password: '${secret}',
...(fresh && !web ? {} : { sessionID: 'ses_saved' }),
directory: name === 'agent' ? '/workspaces/n8n' : '/workspaces/wt-' + name }));
});
}
`,
);
bin(
'opencode',
`
if (args[0] === '--version') {
if (process.env.TEST_NO_CLIENT) process.exit(1);
console.log(process.env.TEST_CLIENT_VERSION || '1.2.3');
} else {
log({ command: 'opencode', args, password: process.env.OPENCODE_SERVER_PASSWORD });
if (process.env.TEST_CLIENT_WAIT) setInterval(() => {}, 1000);
}
`,
);
for (const opener of ['open', 'xdg-open']) bin(opener, `log({ command: 'browser', args });`);
return {
calls,
start(args) {
const child = spawn(process.execPath, [script, '--opencode', ...args], {
env: { ...process.env, ...binEnv, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
child.stdout.on('data', (chunk) => {
output += chunk;
});
child.stderr.on('data', (chunk) => {
output += chunk;
});
const done = new Promise((resolve) => child.on('exit', (code) => resolve({ code, output })));
t.after(() => child.kill('SIGKILL'));
return { child, done };
},
};
}
async function waitFor(check) {
for (let attempt = 0; attempt < 100; attempt++) {
const result = check();
if (result) return result;
await delay(50);
}
throw new Error('Fixture did not become ready.');
}
// An orphaned fixture process can linger as a zombie where PID 1 does not reap
// it, and kill(pid, 0) still succeeds for one. Read the process state where the
// kernel exposes it; without /proc only the signal error can tell.
function stopped(pid) {
try {
process.kill(pid, 0);
} catch {
return true;
}
try {
return readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z';
} catch {
return false;
}
}
test('parses options before or after the workspace and rejects unsupported flags', () => {
assert.equal(parseOpenCodeArgs(['--web']).port, 4096);
assert.equal(parseOpenCodeArgs([]).port, 0);
assert.equal(parseOpenCodeArgs(['--port', '4100', '--web']).port, 4100);
assert.deepEqual(parseOpenCodeArgs(['--web', 'fix-flaky', '--new', '--port', '4100']), {
name: 'fix-flaky',
web: true,
fresh: true,
port: 4100,
});
for (const args of [
['--port'],
['--port', '0'],
['--port', '65536'],
['--model', 'test'],
['a/b'],
['one', 'two'],
]) {
assert.throws(() => parseOpenCodeArgs(args));
}
});
test(
'attaches the local TUI to the selected workspace and closes the whole tunnel',
{ timeout: 15000 },
async (t) => {
const f = fixture(t);
const result = await f.start(['fix-flaky']).done;
assert.equal(result.code, 0, result.output);
const calls = f.calls();
assert.ok(calls.find((call) => call.event === 'bootstrap').hasSource);
assert.ok(
calls
.filter((call) => call.command === 'gh' && call.args[1] === 'ssh')
.every((call) => call.args[3] === 'dev-box'),
);
const tunnel = calls.find((call) => call.command === 'gh' && call.args.includes('-N'));
assert.match(tunnel.args.at(-1), /^127\.0\.0\.1:\d+:127\.0\.0\.1:4242$/);
const client = calls.find((call) => call.command === 'opencode');
assert.deepEqual(client.args.slice(2), [
'--dir',
'/workspaces/wt-fix-flaky',
'--session',
'ses_saved',
]);
assert.equal(client.password, secret);
assert.ok(!result.output.includes(secret));
assert.ok(calls.some((call) => call.event === 'tunnel-stopped'));
const pid = calls.find((call) => call.event === 'ssh-child').pid;
await waitFor(() => stopped(pid));
},
);
test('starts a new conversation for --new instead of continuing', { timeout: 15000 }, async (t) => {
const f = fixture(t);
const result = await f.start(['fix-flaky', '--new']).done;
assert.equal(result.code, 0, result.output);
const client = f.calls().find((call) => call.command === 'opencode');
assert.deepEqual(client.args.slice(2), ['--dir', '/workspaces/wt-fix-flaky']);
});
for (const signal of ['SIGINT', 'SIGHUP']) {
test(
`opens the newest web conversation and cleans up on ${signal}`,
{ timeout: 15000 },
async (t) => {
const f = fixture(t, { TEST_NO_CLIENT: '1' });
const run = f.start(['--web', 'fix-flaky']);
const browser = await waitFor(() => f.calls().find((call) => call.command === 'browser'));
const url = new URL(browser.args[0]);
assert.equal(
url.pathname,
`/${Buffer.from('/workspaces/wt-fix-flaky').toString('base64url')}/session/ses_saved`,
);
assert.equal(url.password, '');
const response = await fetch(`${url.origin}/global/health`);
assert.equal(response.status, 200);
await response.text();
run.child.kill(signal);
const result = await run.done;
assert.equal(result.code, 0, result.output);
assert.ok(f.calls().some((call) => call.event === 'tunnel-stopped'));
await assert.rejects(fetch(url.origin));
},
);
}
for (const [label, env, message] of [
[
'a version mismatch',
{ TEST_SERVER_VERSION: '1.14.22' },
/versions differ.*npm install -g opencode-ai@1\.14\.22/,
],
['an authentication failure', { TEST_UNAUTHORIZED: '1' }, /health check failed \(401\)/],
['a tunnel startup failure', { TEST_TUNNEL_FAIL: '1' }, /SSH tunnel closed/],
['a remote preparation failure', { TEST_BOOTSTRAP_FAIL: '1' }, /Could not prepare/],
['a missing local client', { TEST_NO_CLIENT: '1' }, /Install OpenCode locally/],
['a connection loss', { TEST_CLIENT_WAIT: '1', TEST_TUNNEL_DROP: '1' }, /SSH connection closed/],
]) {
test(`reports ${label} without printing credentials`, { timeout: 15000 }, async (t) => {
const f = fixture(t, env);
const result = await f.start([]).done;
assert.equal(result.code, 1, result.output);
assert.match(result.output, message);
assert.ok(!result.output.includes(secret));
if (label !== 'a connection loss')
assert.ok(!f.calls().some((call) => call.command === 'opencode'));
});
}

View File

@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import { createServer, request } from 'node:http';
import { connect } from 'node:net';
import { test } from 'node:test';
import { openCodeProxy } from '../../scripts/cloud-session-opencode-proxy.mjs';
test('proxies uploads and event streams with authentication and rejects other origins', async (t) => {
const requests = [];
const backend = createServer(async (req, res) => {
requests.push(req.headers);
res.writeHead(200, { 'content-type': 'text/event-stream' });
for await (const chunk of req) res.write(chunk);
res.end();
});
await new Promise((resolve) => backend.listen(0, '127.0.0.1', resolve));
t.after(() => {
backend.closeAllConnections();
backend.close();
});
const proxy = await openCodeProxy({ password: 'test-secret' });
proxy.targetPort = backend.address().port;
t.after(proxy.close);
const response = await fetch(`${proxy.origin}/session`, {
method: 'POST',
body: 'data: attached image\n\n',
headers: { origin: proxy.origin },
});
assert.equal(response.status, 200);
assert.equal(await response.text(), 'data: attached image\n\n');
assert.equal(
requests[0].authorization,
`Basic ${Buffer.from('opencode:test-secret').toString('base64')}`,
);
assert.ok(!proxy.origin.includes('test-secret'));
for (const headers of [
{ origin: 'https://example.com' },
{ host: 'example.com' },
{ 'sec-fetch-site': 'cross-site' },
]) {
const status = await new Promise((resolve, reject) => {
const req = request(proxy.origin, { headers }, (response) => {
response.resume();
resolve(response.statusCode);
});
req.on('error', reject);
req.end();
});
assert.equal(status, 403);
}
assert.equal(requests.length, 1);
});
test('forwards WebSocket upgrades and closes their sockets', async (t) => {
const peers = new Set();
const backend = createServer();
backend.on('upgrade', (req, socket) => {
assert.equal(
req.headers.authorization,
`Basic ${Buffer.from('opencode:test-secret').toString('base64')}`,
);
peers.add(socket);
socket.on('close', () => peers.delete(socket));
socket.write(
'HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n',
);
socket.pipe(socket);
});
await new Promise((resolve) => backend.listen(0, '127.0.0.1', resolve));
t.after(() => {
for (const peer of peers) peer.destroy();
backend.close();
});
const proxy = await openCodeProxy({ password: 'test-secret' });
proxy.targetPort = backend.address().port;
t.after(proxy.close);
const socket = await new Promise((resolve, reject) => {
const req = request(`${proxy.origin}/pty/test/connect`, {
headers: { origin: proxy.origin, connection: 'Upgrade', upgrade: 'websocket' },
});
req.on('upgrade', (_response, peer) => resolve(peer));
req.on('error', reject);
req.end();
});
t.after(() => socket.destroy());
const echoed = new Promise((resolve) => socket.once('data', (data) => resolve(data.toString())));
socket.write('terminal input');
assert.equal(await echoed, 'terminal input');
const closed = new Promise((resolve) => socket.once('close', resolve));
proxy.close();
await closed;
});
test(
'closes the browser response when the upstream stream disconnects',
{ timeout: 5000 },
async (t) => {
const backend = createServer((_req, res) => {
res.writeHead(200, { 'content-type': 'text/event-stream' });
res.write('data: partial\n\n');
setTimeout(() => res.destroy(), 50);
});
await new Promise((resolve) => backend.listen(0, '127.0.0.1', resolve));
t.after(() => {
backend.closeAllConnections();
backend.close();
});
const proxy = await openCodeProxy({ password: 'test' });
proxy.targetPort = backend.address().port;
t.after(proxy.close);
const signal = AbortSignal.timeout(3000);
const response = await fetch(proxy.origin, { signal });
await assert.rejects(response.text());
assert.equal(
signal.aborted,
false,
'The proxy must close the response before the client times out.',
);
},
);
test('rejects an occupied browser port and reports a missing or closed upstream', async (t) => {
const proxy = await openCodeProxy({ password: 'test' });
t.after(proxy.close);
await assert.rejects(openCodeProxy({ password: 'test', port: +new URL(proxy.origin).port }), {
code: 'EADDRINUSE',
});
const waiting = await fetch(proxy.origin);
assert.equal(waiting.status, 503);
await waiting.text();
proxy.targetPort = 1;
const response = await fetch(proxy.origin);
assert.equal(response.status, 502);
await response.text();
const rejection = await new Promise((resolve) => {
const socket = connect(+new URL(proxy.origin).port, '127.0.0.1', () => {
socket.write(
'GET /pty/test/connect HTTP/1.1\r\nHost: example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n',
);
});
socket.once('data', (data) => {
socket.destroy();
resolve(data.toString());
});
});
assert.match(rejection, /403 Forbidden/);
});

View File

@ -0,0 +1,228 @@
#!/usr/bin/env node
// The laptop sends this file over SSH as one standalone module. Do not import sibling files.
// It also works in an older Codespace checkout.
import { execFileSync, spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:net';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
function readJson(file) {
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return undefined;
throw new Error(`Cannot read ${file}. Restore or remove this state file, then retry.`);
}
}
function saveJson(file, value) {
writeFileSync(`${file}.tmp`, JSON.stringify(value), { mode: 0o600 });
renameSync(`${file}.tmp`, file);
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: ['ignore', 'inherit', 'inherit'],
...options,
});
if (result.error || result.status !== 0) {
throw new Error(`${command} failed. Check the output above, then retry.`);
}
}
async function freePort() {
const server = createServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const { port } = server.address();
await new Promise((resolve) => server.close(resolve));
return port;
}
async function request(server, path, directory, options = {}, timeout = 60_000) {
return await fetch(`http://127.0.0.1:${server.port}${path}`, {
...options,
headers: {
authorization: `Basic ${Buffer.from(`opencode:${server.password}`).toString('base64')}`,
'x-opencode-directory': encodeURIComponent(directory),
'content-type': 'application/json',
},
signal: AbortSignal.timeout(timeout),
});
}
async function health(server, directory) {
try {
const response = await request(server, '/global/health', directory, {}, 3000);
if (!response.ok) return undefined;
const result = await response.json();
return result.healthy === true && typeof result.version === 'string' ? result : undefined;
} catch {
return undefined;
}
}
const serverFile = (stateDir) => join(stateDir, 'server.json');
async function runningServer({ stateDir, mainDirectory }) {
const previous = readJson(serverFile(stateDir));
return previous && (await health(previous, mainDirectory)) ? previous : undefined;
}
async function startServer({ stateDir, mainDirectory, workspaces }) {
const tmuxSession = 'n8n-opencode-server';
if (spawnSync('tmux', ['has-session', '-t', `=${tmuxSession}`]).status === 0) {
throw new Error(
`OpenCode is still starting or is unhealthy. Check ${stateDir}/server.log, then retry. The running server was not stopped.`,
);
}
const server = { port: await freePort(), password: randomBytes(32).toString('hex') };
const launcher = join(stateDir, 'serve.sh');
// Read secrets when the server starts. Do not store provider keys in the launcher.
writeFileSync(
launcher,
[
'#!/bin/bash',
'. /usr/local/lib/codespaces-env.sh 2>/dev/null || true',
'unset AGENT_WORKER_TOKEN N8N_DEQUEUE_URL SLACK_BOT_TOKEN',
`export TURBO_CACHE_DIR=${quote(join(workspaces, '.turbo-cache'))}`,
`[ -d "$TURBO_CACHE_DIR" ] || cp -r ${quote(join(mainDirectory, '.turbo/cache'))} "$TURBO_CACHE_DIR" 2>/dev/null || mkdir -p "$TURBO_CACHE_DIR"`,
`export OPENCODE_SERVER_USERNAME=opencode OPENCODE_SERVER_PASSWORD=${quote(server.password)}`,
'export OPENCODE_EXPERIMENTAL_CODE_MODE=true',
`export OPENCODE_CONFIG_CONTENT=${quote(
JSON.stringify({
enabled_providers: ['openrouter'],
provider: { openrouter: { options: { apiKey: '{env:OPENROUTER_API_KEY}' } } },
}),
)}`,
// Interactive sessions use the harness sandbox runtime without a profile.
'export N8N_AGENT_RUNTIME=sandbox; unset N8N_AGENT_PROFILE',
`cd ${quote(mainDirectory)} || exit 1`,
`exec opencode serve --hostname 127.0.0.1 --port ${server.port} >> ${quote(join(stateDir, 'server.log'))} 2>&1`,
'',
].join('\n'),
{ mode: 0o600 },
);
saveJson(serverFile(stateDir), server);
run('tmux', ['new-session', '-d', '-s', tmuxSession, `bash ${quote(launcher)}`]);
console.error('Starting the OpenCode server…');
for (let attempt = 0; attempt < 60; attempt++) {
if (await health(server, mainDirectory)) return server;
await delay(500);
}
throw new Error(`OpenCode did not become ready. Check ${stateDir}/server.log.`);
}
function prepareWorkspace({ name, directory, mainDirectory, stateDir }) {
if (!existsSync(directory)) {
console.error(`Creating worktree ${directory}…`);
const branch = `session/${name}`;
const exists =
spawnSync('git', [
'-C',
mainDirectory,
'show-ref',
'--verify',
'--quiet',
`refs/heads/${branch}`,
]).status === 0;
if (!exists)
spawnSync('git', ['-C', mainDirectory, 'fetch', 'origin', 'master'], {
stdio: ['ignore', 2, 2],
});
run(
'git',
[
'-C',
mainDirectory,
'worktree',
'add',
...(exists ? [] : ['--no-track', '-b', branch]),
directory,
exists ? branch : 'origin/master',
],
{ stdio: ['ignore', 2, 2] },
);
}
// A failed install must be retried even if it left a node_modules directory.
const installed = join(stateDir, `${name}.installed`);
const installing = join(stateDir, `${name}.installing`);
if (
!existsSync(join(directory, 'node_modules')) ||
existsSync(installing) ||
(name !== 'agent' && !existsSync(installed))
) {
console.error(`Installing dependencies in ${directory}…`);
writeFileSync(installing, '', { mode: 0o600 });
run('pnpm', ['install'], { cwd: directory, stdio: ['ignore', 2, 2] });
writeFileSync(installed, '', { mode: 0o600 });
rmSync(installing);
}
}
// OpenCode's own resume is scoped to the repository project, so all worktrees
// share one pool. Scope the selection to the workspace directory instead, so
// each worktree resumes its own most recently updated root conversation.
async function resolveSession({ name, fresh, directory, server }) {
if (!fresh) {
const query = new URLSearchParams({ directory, roots: 'true' });
const response = await request(server, `/session?${query}`, directory);
if (!response.ok) throw new Error(`Cannot list OpenCode sessions (${response.status}).`);
const sessions = await response.json();
const latest = Array.isArray(sessions)
? sessions.find((entry) => typeof entry?.id === 'string' && entry.id.length > 0)
: undefined;
if (latest) return latest.id;
}
const response = await request(server, '/session', directory, {
method: 'POST',
body: JSON.stringify({ title: `n8n: ${name}` }),
});
if (!response.ok) throw new Error(`Cannot create OpenCode session (${response.status}).`);
const session = await response.json();
if (typeof session.id !== 'string' || session.directory !== directory)
throw new Error('OpenCode returned an invalid session.');
return session.id;
}
export async function prepareOpenCode({
name = 'agent',
fresh = false,
web = false,
workspaces = '/workspaces',
} = {}) {
const stateDir = join(workspaces, '.n8n-opencode');
const mainDirectory = join(workspaces, 'n8n');
if (!/^\w[\w-]*$/.test(name)) throw new Error('Invalid OpenCode workspace name.');
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
const directory = name === 'agent' ? mainDirectory : join(workspaces, `wt-${name}`);
let server = await runningServer({ stateDir, mainDirectory });
// On a cold start, fail before the worktree install when OpenCode is missing on the Codespace.
if (!server) execFileSync('opencode', ['--version'], { stdio: ['ignore', 'pipe', 'inherit'] });
prepareWorkspace({ name, directory, mainDirectory, stateDir });
server ??= await startServer({ stateDir, mainDirectory, workspaces });
const state = { ...server, directory };
// A fresh TUI conversation needs no record. The TUI creates it on the first message.
if (fresh && !web) return state;
// Only the parent process reads stdout. Never send this record to terminal output.
return { ...state, sessionID: await resolveSession({ name, fresh, directory, server }) };
}
if (process.argv[1] === '-') {
try {
const [name, fresh, web] = process.argv.slice(2);
console.log(
JSON.stringify(await prepareOpenCode({ name, fresh: fresh === 'true', web: web === 'true' })),
);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}

View File

@ -0,0 +1,275 @@
import assert from 'node:assert/strict';
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { test } from 'node:test';
import { setTimeout as delay } from 'node:timers/promises';
import { fakeBinaries } from './fake-bin.mjs';
import { prepareOpenCode } from './opencode-server.mjs';
// An orphaned fixture process can linger as a zombie where PID 1 does not reap
// it, and kill(pid, 0) still succeeds for one. Read the process state where the
// kernel exposes it; without /proc only the signal error can tell.
function stopped(pid) {
try {
process.kill(pid, 0);
} catch {
return true;
}
try {
return readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z';
} catch {
return false;
}
}
function fixture(t) {
const fake = fakeBinaries('opencode-server-');
const dir = fake.root;
mkdirSync(join(dir, 'n8n', 'node_modules'), { recursive: true });
const savedEnv = { ...process.env };
// prepareOpenCode spawns the shims in this process. Log every call before the shim body runs.
Object.assign(process.env, fake.env, {
AGENT_WORKER_TOKEN: 'test-worker',
N8N_DEQUEUE_URL: 'test-queue',
SLACK_BOT_TOKEN: 'test-slack',
});
const bin = (name, body) =>
fake.bin(name, `log({ command: ${JSON.stringify(name)}, args });\n${body}`);
bin(
'git',
`
if (args.includes('show-ref')) process.exit(fs.existsSync(file('branch-exists')) ? 0 : 1);
if (args.includes('fetch')) process.exit(0);
fs.mkdirSync(args.at(-2), { recursive: true });
`,
);
bin(
'pnpm',
`
fs.mkdirSync(path.join(process.cwd(), 'node_modules'), {recursive:true});
if (process.env.TEST_INSTALL_FAIL) process.exit(1);
`,
);
bin(
'tmux',
`
if (args[0] === 'has-session') {
const pid = +fs.readFileSync(file('pid'), 'utf8');
let gone = false;
try { process.kill(pid, 0); } catch { gone = true; }
if (!gone) {
try { gone = fs.readFileSync('/proc/' + pid + '/stat', 'utf8').split(' ')[2] === 'Z'; } catch {}
}
if (gone) process.exit(1);
} else {
const child = require('node:child_process').spawn('bash', ['-c', args.at(-1)], { detached:true, stdio:'ignore' });
fs.writeFileSync(file('pid'), String(child.pid)); child.unref();
}
`,
);
bin(
'opencode',
`
if (args[0] === '--version') { console.log('1.2.3'); process.exit(0); }
fs.writeFileSync(file('server-env.json'), JSON.stringify({
worker: !!process.env.AGENT_WORKER_TOKEN, queue: !!process.env.N8N_DEQUEUE_URL, slack: !!process.env.SLACK_BOT_TOKEN,
cache: process.env.TURBO_CACHE_DIR, config: JSON.parse(process.env.OPENCODE_CONFIG_CONTENT),
runtime: process.env.N8N_AGENT_RUNTIME, profile: process.env.N8N_AGENT_PROFILE ?? null,
}));
let sessions = fs.existsSync(file('sessions.json')) ? JSON.parse(fs.readFileSync(file('sessions.json'), 'utf8')) : {};
const server = require('node:http').createServer(async (req, res) => {
res.setHeader('content-type', 'application/json');
const expected = 'Basic ' + Buffer.from('opencode:' + process.env.OPENCODE_SERVER_PASSWORD).toString('base64');
if (req.headers.authorization !== expected) { res.writeHead(401).end('{}'); return; }
const directory = decodeURIComponent(req.headers['x-opencode-directory']);
if (req.url === '/global/health') { res.end(JSON.stringify({ healthy:true, version:'1.2.3' })); return; }
if (req.method === 'POST' && req.url === '/session') {
if (fs.existsSync(file('reject-create'))) { res.writeHead(503).end('{}'); return; }
let body = '';
for await (const chunk of req) body += chunk;
const id = 'ses_' + (Object.keys(sessions).length + 1);
sessions[id] = { id, directory, title: JSON.parse(body).title };
fs.writeFileSync(file('sessions.json'), JSON.stringify(sessions));
res.end(JSON.stringify(sessions[id])); return;
}
if (fs.existsSync(file('reject-session'))) { res.writeHead(503).end('{}'); return; }
const url = new URL(req.url, 'http://localhost');
if (url.pathname === '/session') {
// Newest first, like the real server. Filter on the query, not the header,
// so the test fails when the launcher omits either parameter. The
// child-session file models a subagent conversation that the roots query
// flag must exclude.
const directory = url.searchParams.get('directory');
const roots = url.searchParams.get('roots') === 'true';
const stored = Object.values(sessions);
if (fs.existsSync(file('child-session')))
stored.push({ id: 'ses_child', directory, title: 'child', parentID: 'ses_1' });
const list = stored
.filter(s => s.directory === directory && (!roots || !s.parentID))
.reverse();
if (fs.existsSync(file('extra-session'))) list.unshift({ id: 'ses_extra', directory, title: 'TUI session' });
res.end(JSON.stringify(list)); return;
}
const session = sessions[req.url.split('/').at(-1)];
if (!session) { res.writeHead(404).end('{}'); return; }
res.end(JSON.stringify(session));
});
server.listen(+args[args.indexOf('--port') + 1], '127.0.0.1');
`,
);
const stop = async () => {
try {
const pid = +readFileSync(join(dir, 'pid'), 'utf8');
process.kill(-pid, 'SIGTERM');
for (let count = 0; count < 100; count++) {
if (stopped(pid)) return;
await delay(20);
}
throw new Error('Fixture server did not stop.');
} catch (error) {
if (!['ENOENT', 'ESRCH'].includes(error.code)) throw error;
}
};
t.after(async () => {
await stop();
for (const key of Object.keys(process.env)) if (!(key in savedEnv)) delete process.env[key];
Object.assign(process.env, savedEnv);
rmSync(dir, { recursive: true, force: true });
});
return {
dir,
stop,
prepare: (options = {}) => prepareOpenCode({ workspaces: dir, ...options }),
commands: fake.calls,
};
}
test(
'reuses the server, serves each workspace its newest conversation, and resumes after restart',
{ timeout: 15000 },
async (t) => {
const f = fixture(t);
const first = await f.prepare({ name: 'fix-flaky' });
assert.equal(first.directory, join(f.dir, 'wt-fix-flaky'));
const git = f.commands().filter((entry) => entry.command === 'git');
assert.deepEqual(
git.map((entry) => entry.args.slice(2)),
[
['show-ref', '--verify', '--quiet', 'refs/heads/session/fix-flaky'],
['fetch', 'origin', 'master'],
[
'worktree',
'add',
'--no-track',
'-b',
'session/fix-flaky',
first.directory,
'origin/master',
],
],
);
assert.equal(first.sessionID, 'ses_1');
assert.deepEqual(await f.prepare({ name: 'fix-flaky' }), first);
const second = await f.prepare({ name: 'another-task' });
assert.equal(second.port, first.port);
assert.equal(second.sessionID, 'ses_2');
const webbed = await f.prepare({ name: 'fix-flaky', web: true });
assert.equal(webbed.sessionID, 'ses_1');
const created = JSON.parse(readFileSync(join(f.dir, 'sessions.json'), 'utf8'));
assert.equal(created.ses_1.title, 'n8n: fix-flaky');
assert.equal(created.ses_1.directory, join(f.dir, 'wt-fix-flaky'));
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_1');
assert.equal((await f.prepare({ name: 'another-task', web: true })).sessionID, 'ses_2');
writeFileSync(join(f.dir, 'extra-session'), '');
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_extra');
rmSync(join(f.dir, 'extra-session'));
writeFileSync(join(f.dir, 'child-session'), '');
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_1');
rmSync(join(f.dir, 'child-session'));
const fresh = await f.prepare({ name: 'fix-flaky', web: true, fresh: true });
assert.notEqual(fresh.sessionID, 'ses_1');
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, fresh.sessionID);
const env = JSON.parse(readFileSync(join(f.dir, 'server-env.json'), 'utf8'));
assert.deepEqual([env.worker, env.queue, env.slack], [false, false, false]);
assert.equal(env.cache, join(f.dir, '.turbo-cache'));
assert.equal(env.config.provider.openrouter.options.apiKey, '{env:OPENROUTER_API_KEY}');
assert.deepEqual(env.config.enabled_providers, ['openrouter']);
assert.deepEqual([env.runtime, env.profile], ['sandbox', null]);
assert.equal(statSync(join(f.dir, '.n8n-opencode')).mode & 0o777, 0o700);
for (const file of ['serve.sh', 'server.json']) {
assert.equal(statSync(join(f.dir, '.n8n-opencode', file)).mode & 0o777, 0o600);
}
assert.ok(!f.commands().some((entry) => JSON.stringify(entry.args).includes(first.password)));
assert.equal(f.commands().filter((entry) => entry.command === 'pnpm').length, 2);
await f.stop();
const restarted = await f.prepare({ name: 'fix-flaky', web: true });
assert.equal(restarted.sessionID, fresh.sessionID);
assert.notEqual(restarted.password, first.password);
rmSync(first.directory, { recursive: true });
writeFileSync(join(f.dir, 'branch-exists'), '');
assert.equal((await f.prepare({ name: 'fix-flaky' })).directory, join(f.dir, 'wt-fix-flaky'));
const worktree = f
.commands()
.filter((entry) => entry.command === 'git' && entry.args.includes('add'))
.at(-1);
assert.deepEqual(worktree.args, [
'-C',
join(f.dir, 'n8n'),
'worktree',
'add',
first.directory,
'session/fix-flaky',
]);
},
);
test(
'retries a failed worktree install before starting the server',
{ timeout: 10000 },
async (t) => {
const f = fixture(t);
process.env.TEST_INSTALL_FAIL = '1';
await assert.rejects(f.prepare({ name: 'retry' }), /pnpm failed/);
assert.ok(!f.commands().some((entry) => entry.command === 'tmux'));
delete process.env.TEST_INSTALL_FAIL;
assert.equal((await f.prepare({ name: 'retry' })).directory, join(f.dir, 'wt-retry'));
assert.equal(f.commands().filter((entry) => entry.command === 'pnpm').length, 2);
},
);
test(
'retries a failed main checkout install even when node_modules exists',
{ timeout: 10000 },
async (t) => {
const f = fixture(t);
rmSync(join(f.dir, 'n8n', 'node_modules'), { recursive: true });
process.env.TEST_INSTALL_FAIL = '1';
await assert.rejects(f.prepare(), /pnpm failed/);
delete process.env.TEST_INSTALL_FAIL;
assert.equal((await f.prepare()).directory, join(f.dir, 'n8n'));
assert.equal(f.commands().filter((entry) => entry.command === 'pnpm').length, 2);
},
);
test(
'reports session API errors and creates conversations only when none exist',
{ timeout: 10000 },
async (t) => {
const f = fixture(t);
const first = await f.prepare({ web: true });
assert.equal(first.sessionID, 'ses_1');
writeFileSync(join(f.dir, 'reject-session'), '');
await assert.rejects(f.prepare({ web: true }), /Cannot list OpenCode sessions \(503\)/);
assert.ok(JSON.parse(readFileSync(join(f.dir, 'sessions.json'), 'utf8')).ses_1);
rmSync(join(f.dir, 'reject-session'));
assert.equal((await f.prepare({ web: true })).sessionID, first.sessionID);
writeFileSync(join(f.dir, 'reject-create'), '');
await assert.rejects(
f.prepare({ name: 'fresh-task', web: true }),
/Cannot create OpenCode session \(503\)/,
);
rmSync(join(f.dir, 'reject-create'));
assert.equal((await f.prepare({ name: 'fresh-task', web: true })).sessionID, 'ses_2');
},
);

View File

@ -1,9 +1,11 @@
#!/usr/bin/env node
// Runs on each codespace start. Installs the skills marketplace, starts the worker.
// Runs on each Codespace start. Installs the skills and harness, then starts the worker.
import { execFileSync } from 'node:child_process';
import { rmSync, writeFileSync } from 'node:fs';
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { installAgentHarness } from '../../scripts/agent-harness.mjs';
import { codespaceSecret } from '../../scripts/codespace-env.mjs';
import { MARKETPLACE, PLUGINS } from './plugins.mjs';
const STATUS_FILE = '/tmp/post-start-status.json';
@ -48,6 +50,42 @@ function addMarketplace() {
return add('marketplace add (retry)');
}
// Claude Code reads the Flaky token when it connects. An env reference such as
// ${FLAKY_MCP_TOKEN} fails in processes that did not load the secrets file, for
// example the desktop app's SSH server. The helper reads the file on each connect,
// so it also gets a rotated token. The token is not copied into ~/.claude.json.
const FLAKY_HEADERS_HELPER =
'. /usr/local/lib/codespaces-env.sh; printf %s "{\\"Authorization\\":\\"Bearer $FLAKY_MCP_TOKEN\\"}"';
function registerFlakyMcp() {
// Forks have no repository secrets.
const url = codespaceSecret('FLAKY_MCP_URL');
if (!url || !codespaceSecret('FLAKY_MCP_TOKEN')) return;
let current;
try {
current = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8')).mcpServers?.flaky;
} catch {
// No readable config yet: register the server below.
}
if (current?.url === url && current.headersHelper === FLAKY_HEADERS_HELPER && !current.headers) {
return;
}
if (current) tryRun('flaky mcp remove', 'claude', ['mcp', 'remove', '--scope', 'user', 'flaky']);
const config = { type: 'http', url, headersHelper: FLAKY_HEADERS_HELPER };
tryRun('flaky mcp add', 'claude', [
'mcp',
'add-json',
'--scope',
'user',
'flaky',
JSON.stringify(config),
]);
}
registerFlakyMcp();
tryRun('skills repo reachable', 'git', ['ls-remote', `https://github.com/${MARKETPLACE}`, 'HEAD']);
const installed = [];
@ -61,19 +99,33 @@ if (addMarketplace()) {
failed.push(...PLUGINS);
}
writeFileSync(STATUS_FILE, JSON.stringify({ installed, failed }, null, 2));
// A skipped install is otherwise invisible until someone misses a skill mid-session.
if (failed.length > 0) {
console.error(`\n!! SKILLS NOT INSTALLED: ${failed.join(', ')}`);
console.error('!! Sessions start without them. Retry with:');
console.error('!! node /workspaces/n8n/.devcontainer/codespaces/post-start.mjs\n');
}
let harness;
try {
const result = installAgentHarness();
harness = { status: 'active', version: result.version, cacheHit: result.cacheHit };
} catch (error) {
harness = { status: 'unavailable', error: error.message };
console.error(`agent harness: ${error.message}`);
}
tryRun('worker start', 'tmux', [
'new-session',
'-d',
'-s',
'agent-worker',
'bash -lc ". /usr/local/lib/codespaces-env.sh; export CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE=1; node /workspaces/n8n/.devcontainer/codespaces/agent-worker.mjs >> /tmp/agent-worker.log 2>&1"',
]);
const workerStarted =
harness.status === 'active' &&
tryRun('worker start', 'tmux', [
'new-session',
'-d',
'-s',
'agent-worker',
'bash -lc ". /usr/local/lib/codespaces-env.sh; export CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE=1; node /workspaces/n8n/.devcontainer/codespaces/agent-worker.mjs >> /tmp/agent-worker.log 2>&1"',
]);
if (!workerStarted && harness.status !== 'active') {
console.error('worker start: skipped because the pinned agent harness is unavailable');
}
writeFileSync(STATUS_FILE, JSON.stringify({ installed, failed, harness, workerStarted }, null, 2));

View File

@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { aliasHostConfig, withInclude } from '../../scripts/cloud-session-ssh-config.mjs';
const GH_CONFIG = `Host cs.ominous-doodle.master
User node
ProxyCommand gh cs ssh -c ominous-doodle --stdio
UserKnownHostsFile=/dev/null
`;
test('replaces the gh host name with the fixed alias', () => {
assert.equal(
aliasHostConfig(GH_CONFIG),
GH_CONFIG.replace('cs.ominous-doodle.master', 'n8n-codespace'),
);
});
test('rejects gh output without exactly one proxied host', () => {
assert.throws(() => aliasHostConfig(''));
assert.throws(() => aliasHostConfig(`${GH_CONFIG}Host other\n`));
assert.throws(() => aliasHostConfig('Host cs.x\n\tUser node\n'));
});
test('adds the Include line at the top once', () => {
const include = 'Include ~/.ssh/n8n-codespace.conf';
assert.equal(withInclude(''), `${include}\n`);
const updated = withInclude('Host github.com\n\tUser git\n');
assert.equal(updated, `${include}\n\nHost github.com\n\tUser git\n`);
assert.equal(withInclude(updated), undefined);
});

View File

@ -33,5 +33,22 @@
// Codespaces regenerates the managed /etc/gitconfig on each start, and the ssh
// command shells `pnpm preview` uses are neither login nor interactive, so
// profile.d and bashrc never fire for them. Register the helper here instead.
"postStartCommand": "git config --global --get-all credential.helper 2>/dev/null | grep -qs gitcredential-refresh || git config --global --add credential.helper /usr/local/bin/gitcredential-refresh.sh"
"postStartCommand": "git config --global --get-all credential.helper 2>/dev/null | grep -qs gitcredential-refresh || git config --global --add credential.helper /usr/local/bin/gitcredential-refresh.sh",
// Declarations only. All of these are set at the repository level, so Codespaces
// injects them into every preview box whether or not they are listed here.
// scripts/codespace-preview/preview-serve.mjs reads them with codespaceSecret().
"secrets": {
"N8N_LICENSE_ACTIVATION_KEY": {
"description": "Sandbox licence key. The preview:enterprise label decides whether it reaches n8n."
},
"CODESPACE_ENV_URL": {
"description": "n8n webhook that returns extra environment for a preview, as a flat JSON object"
},
"CODESPACE_ENV_USER": {
"description": "Basic auth user for CODESPACE_ENV_URL. Defaults to `preview`."
},
"CODESPACE_ENV_PASSWORD": {
"description": "Basic auth password for CODESPACE_ENV_URL"
}
}
}

View File

@ -1,6 +1,6 @@
/**
* One-click sign-in for preview instances. Loaded through EXTERNAL_HOOK_FILES by
* scripts/preview-serve.mjs, so it is never part of a packaged n8n.
* scripts/codespace-preview/preview-serve.mjs, so it is never part of a packaged n8n.
*
* The route performs an ordinary login on the visitor's behalf: it calls
* /rest/login with the preview instance's own seeded credentials and forwards the

View File

@ -26,7 +26,7 @@
!docker/images/runners
!docker/images/runners/n8n-task-runners.json
# === benchmark image (packages/@n8n/benchmark/Dockerfile) ===
# === benchmark image (packages/quality/efficiency/scale/benchmark/Dockerfile) ===
!package.json
!pnpm-lock.yaml
!pnpm-workspace.yaml
@ -34,8 +34,10 @@
!patches/**
!scripts
!scripts/**
!packages/@n8n/benchmark
!packages/@n8n/benchmark/**
!packages/quality
!packages/quality/efficiency
!packages/quality/efficiency/scale
!packages/quality/efficiency/scale/benchmark
!packages/quality/efficiency/scale/benchmark/**
!packages/@n8n/typescript-config
!packages/@n8n/typescript-config/**

View File

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

36
.githooks/check-pnpm.mjs Normal file
View File

@ -0,0 +1,36 @@
#!/usr/bin/env node
// Git hook helper (post-merge, post-rewrite, post-checkout): warn when the
// installed pnpm major differs from the one pinned in package.json.
// Usage: node .githooks/check-pnpm.mjs [checkout-type]
// For post-checkout, pass lefthook's {3}: "0" is a file checkout, which we skip.
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
if (process.argv[2] === '0') process.exit(0);
const packageJsonPath = fileURLToPath(new URL('../package.json', import.meta.url));
const { packageManager = '' } = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
const wanted = /^pnpm@(\d+)/.exec(packageManager)?.[1];
if (!wanted) process.exit(0);
// Run from the home dir so pnpm reports the installed version,
// not the version pinned by this repo.
// `shell` lets Windows resolve pnpm.cmd. The command has no user input.
const result = spawnSync('pnpm --version', { cwd: homedir(), shell: true, encoding: 'utf8' });
const current = result.status === 0 ? result.stdout.trim().split('.')[0] : '';
if (wanted === current) process.exit(0);
const installCommand =
process.platform === 'win32'
? 'powershell -c "irm https://get.pnpm.io/install.ps1 | iex"'
: 'curl -fsSL https://get.pnpm.io/install.sh | sh -';
console.log(`
⚠ This repo now pins pnpm ${wanted} (you have ${current || 'none'}).
pnpm should auto-download it on the next install command.
If that fails (some pnpm 10 versions can't install v12), reinstall:
${installCommand}
`);

View File

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

View File

@ -81,11 +81,11 @@ return 'blacksmith';
| Telemetry | Source | Metrics |
|-----------|--------|---------|
| Playwright perf/benchmark | `packages/testing/playwright/reporters/metrics-reporter.ts` | Any metric attached via `attachMetric()` |
| Accessibility buckets | `packages/testing/playwright/reporters/a11y-reporter.ts` | Per-bucket axe score, violated rules, violating elements |
| Playwright perf/benchmark | `packages/quality/testing/playwright/reporters/metrics-reporter.ts` | Any metric attached via `attachMetric()` |
| Accessibility buckets | `packages/quality/testing/playwright/reporters/a11y-reporter.ts` | Per-bucket axe score, violated rules, violating elements |
| Build stats | `.github/scripts/send-build-stats.mjs` | Per-package build duration, cache hit/miss, run total |
| Docker stats | `.github/scripts/send-docker-stats.mjs` | Image size per platform, docker build duration |
| Container stack | `packages/testing/containers/telemetry.ts` | E2E stack startup times per service |
| Container stack | `packages/quality/environments/containers/telemetry.ts` | E2E stack startup times per service |
## Secrets

11
.github/CODEOWNERS vendored
View File

@ -1,11 +0,0 @@
# Temporary: kept side by side with the top-level OWNERS file while the
# "Required Reviews" enforcement is on trial. Do not add entries here;
# add them to OWNERS. See "Transition from CODEOWNERS" in WORKFLOWS.md.
packages/@n8n/db/src/migrations/ @n8n-io/migrations-review
/OWNERS @n8n-io/qa-dx
.github/workflows @n8n-io/qa-dx
.github/scripts @n8n-io/qa-dx
.github/actions @n8n-io/qa-dx
.github/poutine-rules @n8n-io/qa-dx
docker/get-n8n.sh @n8n-io/qa-dx
.code-health-baseline.json @n8n-io/qa-dx

View File

@ -124,6 +124,9 @@ of the breaking removal on `3.x`.
## How the daily sync works
The sync runs automation code from the triggering `master` SHA while its working checkout
stays on `3.x`.
[`util-sync-master-to-3x.yml`](./workflows/util-sync-master-to-3x.yml) runs daily and
**replays the `3.x`-only commits on top of `master`** (a rebase), then force-pushes `3.x`:
@ -133,20 +136,19 @@ of the breaking removal on `3.x`.
original message and author. Nothing is ever squashed. Merge commits in the range (breaking
PRs merged into `3.x`) are flattened away, and a breaking commit that also landed on
`master` is dropped as empty.
3. Conflicts confined to **mechanical files** — tool-generated content with a deterministic
resolution (`pnpm-lock.yaml`, `packages/frontend/editor-ui/data/node-popularity.json`,
3. Conflicts confined to **non-lockfile mechanical files** — bot-maintained content with a
deterministic resolution (`packages/frontend/editor-ui/data/node-popularity.json`,
`packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json`, and
`.github/test-metrics/e2e-impact-map.json`) — are **auto-resolved during the replay**,
exactly as a human resolver would: the lockfile is regenerated with
`pnpm install --lockfile-only` (pnpm merges its own conflict markers), bot-maintained data
files take `master`'s side. The resolution is folded into the stalled commit, so this
still adds **no commit and no PR**. The list lives in `MECHANICAL_PATHS` in
by taking `master`'s side. The resolution is folded into the stalled commit, so this still
adds **no commit and no PR**. The list lives in `MECHANICAL_PATHS` in
[`sync-master-to-3x.mjs`](./scripts/sync-master-to-3x.mjs).
4. On a **real code conflict**, `3.x` is left **untouched** and a **draft conflict PR**
(labeled `automation:v3-sync`) carrying the conflict markers is opened on
`sync/master-to-3x` — with the mechanical files already pre-resolved — plus a post to the
**`#alerts-v3-sync`** Slack channel. **Syncs pause until that PR is merged** — so
conflicts never pile up silently.
4. On a **real code conflict or any `pnpm-lock.yaml` conflict**, `3.x` is left **untouched**
and a **draft conflict PR** (labeled `automation:v3-sync`) carrying the conflict markers is
opened on `sync/master-to-3x`. Other mechanical files are already pre-resolved. The
lockfile stays unresolved because pnpm validation is not reliable while the merge index is
unresolved. The workflow also posts to the **`#alerts-v3-sync`** Slack channel. **Syncs
pause until that PR is merged**, so conflicts never pile up silently.
Whatever route it takes, the sync verifies that the content it is about to push is **exactly
the tree a merge of `3.x` and `master` produces** (`git merge-tree`), and that no conflict
@ -165,8 +167,8 @@ rewriting `3.x`.
The conflict branch is `master` merged into `3.x` with the **conflict markers committed**, so
you see exactly what clashed — and the required checks stay red until they're gone, so the PR
can't be merged half-resolved. Mechanical files arrive **pre-resolved** (listed in the PR
under "Auto-resolved for you"), so only the real code conflicts need you:
can't be merged half-resolved. Non-lockfile mechanical files arrive **pre-resolved** (listed
in the PR under "Auto-resolved for you"), so only the code and lockfile conflicts need you:
```bash
git fetch origin sync/master-to-3x && git switch sync/master-to-3x
@ -174,9 +176,8 @@ git fetch origin sync/master-to-3x && git switch sync/master-to-3x
git push origin sync/master-to-3x
```
If the PR says the lockfile was **deferred** (a `package.json` / `pnpm-workspace.yaml` is
conflicted too), resolve the manifests first, then regenerate it with
`pnpm install --lockfile-only` and include the result in your fix commit.
If the PR says the lockfile was **deferred**, resolve the other conflicts first. Then
regenerate it with `pnpm install --lockfile-only` and include the result in your fix commit.
Watch for the **"Deleted on one side, changed on the other"** section. Git leaves no markers
for a delete/modify, so the branch looks clean where it is not: the merge keeps `3.x`'s side

256
.github/WORKFLOWS.md vendored
View File

@ -10,7 +10,6 @@ Complete reference for n8n's `.github/` folder.
.github/
├── WORKFLOWS.md # This document
├── CI-TELEMETRY.md # Telemetry & metrics guide
├── CODEOWNERS # Temporary, side by side with OWNERS during the trial
├── pull_request_template.md # PR description template
├── pull_request_title_conventions.md # Title format rules (Angular)
├── actionlint.yml # Workflow linter config
@ -178,7 +177,7 @@ These only run if specific files changed:
| `docker/images/n8n-base/Dockerfile` | `build-base-image.yml` | any |
| `**/package.json`, `**/turbo.json` | `build-windows.yml` | master |
| `packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/**` | `test-evals-python.yml` | any |
| `packages/@n8n/benchmark/**` | `build-benchmark-image.yml` | master |
| `packages/quality/efficiency/scale/benchmark/**` | `build-benchmark-image.yml` | master |
| `packages/cli/src/public-api/**/*.yml`, `packages/cli/src/public-api/**/*.yaml`, `packages/cli/src/public-api/**/*.css`, `packages/cli/src/public-api/v1/openapi-gen/**/*.ts`, `packages/cli/scripts/build.mjs`, `packages/cli/package.json` | `util-publish-api-schema.yml` | master |
| `packages/@n8n/instance-ai/src/**`, `packages/@n8n/instance-ai/skills/**`, `packages/@n8n/instance-ai/knowledge-base/**`, `packages/@n8n/instance-ai/evaluations/**`, `packages/cli/src/modules/instance-ai/**`, `packages/core/src/execution-engine/eval-mock-helpers.ts`, `packages/@n8n/agents/src/**` | `ci-instance-ai-evals.yml` | on PR `opened` / `reopened` / `ready_for_review` |
| `docker/get-n8n.sh`, `docker/get-n8n-compose.yml`, `docker/test-get-n8n.sh` | `test-get-n8n.yml` | any |
@ -254,7 +253,7 @@ parallelism). See the `--build-via-mcp` section in
| `preview:debug` | `util-codespace-preview.yml` | Re-serves the instance with `N8N_LOG_LEVEL=debug` |
**Why:** A reviewer gets a running instance of the PR without a Docker build or a
cloud deploy. The workflow calls `scripts/preview.mjs`, which keeps one codespace
cloud deploy. The workflow calls `scripts/codespace-preview/preview.mjs`, which keeps one codespace
for each PR (display name `preview/pr-<number>`) and shares port 5678 with the
organization. A later push serves the new head in the same box. Removing the
label, or closing the PR, deletes the box.
@ -262,6 +261,25 @@ label, or closing the PR, deletes the box.
Only a PR from a branch in this repository is eligible: a codespace token is
scoped to `n8n-io/n8n` and cannot check out a fork head.
#### Live progress on the PR
`up` and `refresh` take minutes, and an absent comment looks the same as a broken
preview. So the comment goes up before the box work starts, as a checklist of the
phases in `scripts/codespace-preview/preview-phases.mjs`, and is edited for each phase and once a
minute after that. The final URL replaces it in place.
A comment **edit sends no notification** — only a create does. That is what makes a
heartbeat on a sticky comment acceptable at all.
A `refresh` keeps the URL of the previous run in the checklist. The URL does not
change between runs, and taking a working link off the PR for several minutes is
worse than saying it is briefly down.
A cancelled job — including the 45-minute `timeout-minutes` — kills the script while
its checklist is up, so a last `if: cancelled()` step writes the outcome instead. It
writes **only over a checklist**: a run cancelled while it was queued never started
work, and must leave the previous run's URL alone.
#### Running it by hand
A box sleeps after 2 hours of no use, and GitHub makes every forwarded port
@ -291,13 +309,15 @@ A `preview:*` label configures an instance that already exists, so adding or
removing one re-serves the box instead of creating or deleting it. It does
nothing on a PR without `codespace-preview`.
The vocabulary lives in `scripts/preview-labels.mjs`, which both ends import:
The vocabulary lives in `scripts/codespace-preview/preview-labels.mjs`, which both ends import:
`preview.mjs` turns the PR's labels into slugs, and `preview-serve.mjs` turns
those slugs into environment inside the box. Add a toggle there, in one place.
Only slugs cross the gap. The `gh codespace ssh` command is a shell string that
appears in the box's process list, so a value is never passed through it —
`preview:enterprise` resolves to a licence key inside the box, not on the runner.
Two things cross the gap, and both are shape-checked rather than trusted: a
`preview:*` slug, and a phase key from `scripts/codespace-preview/preview-phases.mjs`. The `gh
codespace ssh` command is a shell string that appears in the box's process list, so
a value is never passed through it — `preview:enterprise` resolves to a licence key
inside the box, not on the runner.
`preview:enterprise` needs a **Codespaces** secret named
`N8N_LICENSE_ACTIVATION_KEY`, scoped to `n8n-io/n8n`. That is a Codespaces
@ -312,6 +332,39 @@ whether the key reaches the box. Anyone who can run PR-head code can read
`/workspaces/.codespaces/shared/.env-secrets`. Previews are limited to branches
in this repository, so that is the set of people who already have write access.
#### Preview environment from a webhook
A preview can also take environment from an n8n webhook we control, so a value
can change without a commit and a merge. `scripts/codespace-preview/preview-remote-env.mjs` fetches
it, and `preview-serve.mjs` hands the result to the backend.
It needs three **Codespaces** secrets on `n8n-io/n8n`, again not Actions secrets:
| Secret | Purpose |
| ------------------------ | ------------------------------------------------- |
| `CODESPACE_ENV_URL` | The webhook URL |
| `CODESPACE_ENV_USER` | Basic auth user. Optional, defaults to `preview`. |
| `CODESPACE_ENV_PASSWORD` | Basic auth password |
The webhook answers with a flat JSON object. Its keys become environment
variables and its values are used as-is, so a number or a boolean is stringified
and a nested object is dropped. The request carries the PR number and the head
SHA as query parameters, so one endpoint can answer per PR.
The fetch runs in the box, not on the runner. Codespaces secrets are unreadable
from Actions, so neither the password nor a returned value can reach a CI log.
The log prints key names only.
**Every key is passed through.** A response containing `NODE_OPTIONS`,
`EXTERNAL_HOOK_FILES` or `PATH` runs code inside the preview box, so whoever can
edit that workflow can run code there. The one exception is the preview's own
wiring — the sign-in hook and the owner credentials — which is applied last and
wins. The `.env-secrets` note above applies to these secrets too.
Nothing here is required. Without the secrets, an unreachable webhook or a
rejected password, the preview serves as usual and says so in the log. A wrong
password is not retried: it cannot fix itself.
#### The `CODESPACE_PREVIEW_TOKEN` secret
The job needs `CODESPACE_PREVIEW_TOKEN`, a **fine-grained** personal access token,
@ -521,14 +574,15 @@ out: `npm deprecate n8n@X.Y.Z "Failed release, use X.Y.(Z+1)"`.
## ci-master.yml
Runs on push to `master` or `1.x`:
Runs on push to `master`:
```
Push to master/1.x
├─ build-github (populate cache)
├─ unit-test (matrix: Node 22.23.2, 24.18.1)
Push to master
├─ build-and-format (Blacksmith: build, then format check; populate master cache)
├─ unit-test (matrix: Node 24.18.1, 26.5.1)
│ └─ Coverage only on 24.18.1
├─ lint
├─ (performance: CodSpeed benchmarks, paused until the plugin supports Vitest 5)
├─ verify-single-instance-npm (advisory; packages changed by this push)
└─ notify-on-failure (Slack #alerts-build)
```
@ -563,18 +617,21 @@ Push to master/1.x
## v3 development (master + 3.x)
The sync runs automation code from the triggering `master` SHA while its working checkout
stays on `3.x`.
During the v3 release window, `master` carries normal feature work (behind opt-in
flags) and the long-lived `3.x` branch carries breaking changes. `util-sync-master-to-3x.yml`
syncs daily by **replaying the `3.x`-only commits onto `master` and force-pushing `3.x`**, so a
clean sync adds no commit and nothing is squashed. What it pushes is always verified to be
exactly the tree a merge of `3.x` and `master` produces, and marker-free. Conflicts confined
to mechanical, tool-generated files (the pnpm lockfile, bot-maintained data files — see
`MECHANICAL_PATHS` in `sync-master-to-3x.mjs`) are auto-resolved during the replay; the tree
check then applies to every path except those files. On a real code conflict `3.x` is left
untouched and a draft PR carrying the conflict markers (labeled `automation:v3-sync`, with
mechanical files pre-resolved) is opened on `sync/master-to-3x`, naming both ends of the
conflict — the breaking-commit authors and the `master` commits that touched the same files
— via `sync-conflict-owners.mjs`, posting to `#alerts-v3-sync` and pausing further syncs
to non-lockfile mechanical files (bot-maintained data files — see `MECHANICAL_PATHS` in
`sync-master-to-3x.mjs`) are auto-resolved during the replay. On a code or `pnpm-lock.yaml`
conflict, `3.x` is left untouched and a draft PR carrying the conflict markers (labeled
`automation:v3-sync`, with other mechanical files pre-resolved) is opened on
`sync/master-to-3x`. The lockfile is always left for the resolver. The PR names both ends of
the conflict — the breaking-commit authors and the `master` commits that touched the same
files — via `sync-conflict-owners.mjs`, posts to `#alerts-v3-sync`, and pauses further syncs
until it is resolved and merged normally. Delete/modify conflicts have no markers to carry,
so they are resolved toward `3.x` and listed as an explicit decision in the PR body.
`build-v3-nightly.yml` publishes `n8nio/n8n:v3-nightly[-<date>]` images from `3.x`
@ -597,6 +654,7 @@ Composite actions in `.github/actions/`:
| Action | Purpose | Used By |
|--------------------------|----------------------------------------------|--------------------|
| `setup-nodejs` | pnpm + Node.js + Turbo cache + Docker (opt) | Most CI workflows |
| `run-workflow-script` | Run a `.github/scripts` module with no setup or install | Owners and PR quality checks |
| `docker-registry-login` | GHCR + DockerHub + DHI authentication | Docker workflows |
### setup-nodejs
@ -633,6 +691,30 @@ while the build reports a successful commit. Every job therefore shares the
`n8n-io/n8n` key, which is the only disk that actually retains layers. Revisit
once new-disk retention works.
### run-workflow-script
```yaml
inputs:
script: # path of the module, relative to the repository root
github-token: # token for the Octokit client, also exported as GITHUB_TOKEN
```
Runs one `.github/scripts` module through `actions/github-script`. That action
brings its own Node.js and an Octokit client, so the job needs no
`setup-nodejs` step and no dependency install. The action loads
`github-helpers.mjs`, hands the client to `setOctokit`, then imports the module
and awaits its exported `main()`.
Use it for a module that imports only node builtins and other `.github/scripts`
modules. `github-helpers.mjs` loads `@actions/github` and `semver` only when
they are present, so it works in both modes. A module that needs an npm
package (`semver`, `yaml`, `minimatch`, ...) keeps the `setup-nodejs` path with
the `.github/scripts` install command.
Pair it with a sparse checkout of `.github` when the module reads nothing else
from the tree. Cone mode always includes the root files, so `OWNERS` and
`package.json` stay available.
### docker-registry-login
```yaml
@ -712,6 +794,7 @@ Scripts in `.github/scripts/`:
| `nightly-sbom-context.mjs` | Resolve the source SHA and image tag for nightly SBOM validation | `test-sbom-nightly.yml` |
| `db-test-matrix.mjs` | DB test matrix from `postgres-versions.json` | `ci-pull-requests.yml` |
| `quality/check-cubic-config.mjs` | Validate `cubic.yaml` against the vendored cubic schema; enforce its silent agent/character limits. `--refresh` re-pulls the schema | `test-workflow-scripts-reusable.yml`, `util-refresh-cubic-schema.yml` |
| `glob.mjs` | Builtin-only glob matcher for changed-file paths (`**`, `*`, dot segments) | `quality/check-pr-size.mjs` |
| `probe-registry.mjs` | Registry path throughput probe (temporary) | `util-probe-registry.yml` |
### Preview Scripts
@ -719,11 +802,15 @@ Scripts in `.github/scripts/`:
| Script | Purpose | Called By |
|---------------------------------|-------------------------------------------------------------------------|--------------------------------|
| `codespace-preview.mjs` | Map a `pull_request` event or a manual operation onto a preview operation, comment the result | `util-codespace-preview.yml` |
| `../../scripts/preview.mjs` | One codespace for each PR: `up`, `refresh`, `down`, `ls`. `--json` for CI | `codespace-preview.mjs`, developers |
| `../../scripts/codespace-preview/preview.mjs` | One codespace for each PR: `up`, `refresh`, `down`, `ls`. `--json` for CI | `codespace-preview.mjs`, developers |
| `../../scripts/codespace-preview/preview-remote-env.mjs` | Fetch extra environment for a preview from the webhook, inside the box | `../../scripts/codespace-preview/preview-serve.mjs` |
| `../../scripts/codespace-preview/preview-phases.mjs` | The phase vocabulary and its one-line marker, so the runner, the box and the comment cannot drift | `codespace-preview.mjs`, `../../scripts/codespace-preview/preview.mjs`, `../../scripts/codespace-preview/preview-serve.mjs` |
`scripts/preview.mjs` is also the developer entry point (`pnpm preview up <pr>`).
In `--json` mode it prints one object on stdout and sends all progress to stderr,
so a workflow can read the URL from a run that also streams an in-box build log.
`scripts/codespace-preview/preview.mjs` is also the developer entry point (`pnpm preview up <pr>`).
In `--json` mode stdout carries one line for each phase and then the report object,
and all human progress goes to stderr. So a workflow can follow a run that also
streams an in-box build log. The reader tells the two apart by the `url` field: the
report has one, a phase line never does.
### Branch Replay Scripts
@ -762,9 +849,8 @@ See **[CI-TELEMETRY.md](CI-TELEMETRY.md)** for:
## OWNERS
Team ownership lives in the top-level `OWNERS` file (this replaces the
GitHub-native `CODEOWNERS` file; see the transition note below). The scripts
that consume it live in `.github/scripts/owners/`. Line format:
Team ownership lives in the top-level `OWNERS` file. The scripts that consume
it live in `.github/scripts/owners/`. Line format:
```
<pattern> <@org/team> [required]
@ -796,30 +882,57 @@ The file drives four workflows:
### Required reviews
An entry with the `required` option makes team approval mandatory: when a PR
changes a file whose winning entry carries `required`, a member of each listed
team must approve the PR. `ci-owners-required-reviews.yml` evaluates this on
changes a non-test file whose winning entry carries `required`, a member of each
listed team must approve the PR. Test files match the shared patterns in
`test-files.mjs` and do not trigger required reviews.
`ci-owners-required-reviews.yml` evaluates this on
PR changes and review events, and reports a commit status
named **Required Reviews** on the head SHA. A missing approval reports
`pending` ("Waiting for approval from: …"), not `failure`, so an unreviewed PR
does not show red CI; any non-success state blocks the merge equally. The
ruleset for `master` must list
that status as a required check for the block to take effect. Merge-queue runs
report success on the queue head without re-evaluating: a PR cannot enter the
queue unless the status is green on its head, and the queue does not change
approvals.
ruleset for a branch must list that status as a required check for the block
to take effect. Merge-queue runs report success on the queue head without
re-evaluating: a PR cannot enter the queue unless the status is green on its
head, and the queue does not change approvals.
The workflow reads OWNERS and its scripts from the base branch only, so a PR
cannot lift its own review requirement.
The status is evaluated for a PR into any base branch, from a same-repo head
or a fork. Both matter because the ruleset that gates a PR is not always the
one on its base branch: GitHub applies the ruleset of a stack's target branch
to every PR in the stack, so a stacked PR into a feature branch is gated by
the `master` ruleset. A required status that no run ever writes leaves the PR
blocked on "Expected". Routes that skip the evaluation are listed in
`REQUIRED_REVIEW_EXEMPTIONS` in
`required-reviews.mjs`. An entry is `<head> -> <base>` or just `<base>`
(any head); `*` matches any run of characters. An exempt PR reports
`success` with the route in the description. Only heads in this repository
can match, so a fork branch with a matching name is still evaluated. Add a
route only when every commit it carries was already reviewed elsewhere, as
with `sync/master-to-3x -> 3.x`: its commits landed on `master` first.
### Transition from CODEOWNERS
The workflow reads OWNERS, its scripts and the exemption routes from `master`
only, never from the base branch or the PR: any writable branch can be a base,
so only `master` is trusted input. A PR cannot lift its own review
requirement. A retarget re-evaluates the PR, so a verdict computed against the
old base does not carry over.
During a trial period, `.github/CODEOWNERS` stays in place next to OWNERS:
GitHub's native code-owner enforcement keeps gating merges while the
"Required Reviews" status runs side by side. The two must agree — CODEOWNERS
holds exactly the `required` entries of OWNERS (plus the OWNERS file itself)
and must not gain new entries; new ownership goes into OWNERS. After the
trial, delete `.github/CODEOWNERS`, remove "Require review from Code Owners"
from the master ruleset, and delete this section (tracked in DEVP-887).
Members of the `large-scale-changes` GitHub team can make the **Required
Reviews** status succeed without required OWNERS team approvals. A team member
must apply the `large-scale-change` label. The gate checks the actor from the
label event and verifies their current team membership. The label alone is not
sufficient. Label changes re-evaluate the status. Removing the label removes
the exemption.
Every path that writes the status runs in the base repository context, because
a fork-context run has no secrets and a read-only token. PR changes arrive
through `pull_request_target`, which is safe here because no step checks out
or runs PR code. Review events on a same-repo PR arrive through
`pull_request_review`. Review events on a fork PR arrive through the
`workflow_run` of `ci-pull-request-review.yml`, which runs on every submitted
or dismissed review; the owners workflow looks the PR up from that run's head
and skips same-repo heads, which the direct event already covers. A first
contribution whose runs still wait for approval gets no review-event
re-evaluation until a maintainer approves the runs; `workflow_dispatch` with
the PR number is the manual fallback.
---
@ -844,7 +957,7 @@ from the master ruleset, and delete this section (tracked in DEVP-887).
**`blacksmith-4vcpu-ubuntu-2204`** - Unit tests (parallelized), linting (parallel file processing), typechecking (CPU-intensive), E2E test shards
**`blacksmith-8vcpu-ubuntu-2204`** - Heavy parallel workloads
**`blacksmith-8vcpu-ubuntu-2204`** - Heavy parallel workloads. The PR `install-and-build` job uses it with the default runner provider. Most PR jobs wait for that job, and a cold build keeps all 4 vCPUs of a smaller runner busy.
### Runner Provider Toggle
@ -1037,31 +1150,19 @@ open the PR there. That PR **must stay a single-parent squash** — the publish
posts to `#alerts-build` when that gate fails on a PR opened *from* `bundle/2.x` or
`bundle/1.x` (link only, no PR title, since the branch is embargoed).
`sec-sync-bundle-branches.yml` keeps those branches current, daily plus whenever a PR is
merged into one (and on `workflow_dispatch`). It **merges the base into** the bundle branch
via [`scripts/sync-bundle-branch.mjs`](scripts/sync-bundle-branch.mjs) and pushes without
forcing. Every push is verified to carry exactly the tree a merge of the two sides would
produce (`git merge-tree`); a mismatch, or a conflict marker, fails the run instead of pushing.
`sec-sync-bundle-branches.yml` keeps those branches current after every public-to-private sync,
daily, whenever a PR is merged into one, and on `workflow_dispatch`. It **merges the public
base into** the bundle branch via
[`scripts/sync-bundle-branch.mjs`](scripts/sync-bundle-branch.mjs) and pushes without forcing.
Every push is verified to carry exactly the tree a merge of the two sides would produce (`git
merge-tree`); a mismatch, or a conflict marker, fails the run instead of pushing.
**A bundle branch is only ever built on published history.** Before it creates or merges
anything, the sync fetches the same-named branch from `https://github.com/n8n-io/n8n.git`
(anonymously — its token is scoped to the private repo) and checks that the private base tip is
contained in it. This matters because the `chore: Bundle/*` squash on private `master` is
*private-only*: the mirror above discards it in favour of the public cherry-pick of the same
changes. A sync in that window would root the branch on a commit that is about to disappear,
leaving it carrying two commits for one set of fixes — and every fix PR cut from it inherits the
dead one. The window is real: this workflow's daily cron and the mirror's hourly cron both fire
at `:00` with nothing ordering them, which is why the check lives in the script rather than in
step ordering.
An unpublished cut is a **skip**, not a failure: the mirror discards that commit every hour
regardless, so the run exits green with the reason in the log and the next one proceeds. A
missing branch also self-heals, because the mirror re-dispatches this workflow while one is
absent. A base ahead of public for **any other reason** fails the run — the mirror is stuck and
will not clear it on its own, so fix that first (remove the commit, or dispatch **Security: Sync
from Public** with `force`) and re-run. To sync a bundle branch immediately, dispatch the mirror
and then this workflow. The blocking commits are listed in the run log; the failure annotation
carries only a count, since a subject hints at the fix.
**A bundle branch is only ever built on published history.** The sync fetches `master` or `1.x`
directly from `https://github.com/n8n-io/n8n.git`. It creates a missing bundle branch at that
exact public SHA and merges that public SHA into an existing bundle branch. It never reads the
private base branch. This matters because the `chore: Bundle/*` squash on private `master` is
private-only until publication. The mirror can replace that commit with the public copy after
the bundle branch is deleted. The next bundle branch must not inherit the replaced commit.
Deleting a bundle branch takes its open PRs with it: GitHub moves each one onto the deleted
branch's own base, and re-creating the branch does not move them back. So the sync then calls
@ -1069,8 +1170,7 @@ branch's own base, and re-creating the branch does not move them back. So the sy
on `master` onto `bundle/2.x` and every one on `1.x` onto `bundle/1.x`. It skips a bundle
branch that does not exist, and skips PRs whose *head* is `bundle/*` — those are the
`chore: Bundle/*` cut PRs, which target the base on purpose. It is also dispatchable on its
own. `sec-sync-public-to-private.yml` only *dispatches* the bundle sync when it finds a branch
missing; the retarget runs inside that dispatched run, once the branch exists.
own. The retarget runs inside the bundle sync after a missing branch is recreated.
**`bundle/*` is append-only — never rebase it, never force-push it.** These branches receive
PRs, and rewriting a branch that receives PRs orphans the copies of its commits that the open
@ -1083,21 +1183,19 @@ itself. To refresh a fix branch, use GitHub's **Update branch** button or
sibling PR's merge base untouched, which is why only a rewrite breaks this.
The costs of merging are deliberate and paid for: a merge commit per run, and fixes that have
already been published staying in the branch's log (the old rebase dropped them as empty
commits). Neither reaches anything downstream, because a bundle publishes as one squashed
commit taken from the tree rather than the history — the `chore: Bundle/*` PR's **diff** stays
already been published staying in the branch's log. Neither reaches anything downstream,
because a bundle publishes as one squashed commit taken from the tree rather than the history —
the `chore: Bundle/*` PR's **diff** stays
exactly the pending fixes even when its commit list does not. For a list of what a bundle
actually carries, read the fix PRs merged into the branch since the last cut, not
`base..bundle`. A lower cadence than the base's is fine too: a base push never re-triggered
CI on the fix PRs, so syncing more often bought them nothing.
actually carries, read the fix PRs merged into the branch since the last cut, not `base..bundle`.
There is **one job per bundle branch**. A conflict is detected from the merge tree before the
working tree is touched, so the branch is left exactly as it was, that job **fails** (no
green runs hiding a stalled branch) and `#alerts-security` gets a run link — while the other
branch still syncs. Recovery is deliberate: merge the base into the branch locally, resolve,
push, then re-run the workflow — and that resolution then lives in the merge commit instead of
being re-litigated on every later run. The sync never resolves a conflict itself, unlike
`util-sync-master-to-3x.yml`.
branch still syncs. Recovery is deliberate: merge the public base into the branch locally,
resolve, push, then re-run the workflow — and that resolution then lives in the merge commit
instead of being re-litigated on every later run. The sync never resolves a conflict itself,
unlike `util-sync-master-to-3x.yml`.
See **[`../AGENTS.md`](../AGENTS.md)** ("Security Fix Hygiene") for the naming rules that
keep the vulnerability out of public branch names, commits, and test descriptions.

View File

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

View File

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

View File

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

View File

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

View File

@ -171,7 +171,7 @@ runs:
- name: Restore pnpm metadata cache
if: ${{ inputs.install-command != '' }}
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm-metadata-cache.outputs.path }}
key: pnpm-metadata-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.pnpm-metadata-cache.outputs.version }}-${{ hashFiles(inputs.cache-dependency-path) }}

View File

@ -1,6 +1,6 @@
services:
postgres:
image: ${TEST_IMAGE_POSTGRES:?set it from packages/testing/containers/postgres-versions.json}
image: ${TEST_IMAGE_POSTGRES:?set it from packages/quality/environments/containers/postgres-versions.json}
restart: always
environment:
- POSTGRES_DB=n8n

View File

@ -1,5 +1,5 @@
#!/usr/bin/env node
// Drives a PR preview instance from CI. `scripts/preview.mjs` does the work; this
// Drives a PR preview instance from CI. `scripts/codespace-preview/preview.mjs` does the work; this
// script maps the pull_request event onto one of its operations and reports the
// result back to the PR as a single, edited-in-place comment.
//
@ -12,26 +12,47 @@
//
// `refresh` never creates a box. A box that GitHub already deleted (24 h
// retention) is reported as expired, not as a failure.
import { spawnSync } from 'node:child_process';
//
// `up` and `refresh` take minutes, so the comment goes up before the work starts
// and is edited for each phase and once a minute after that. `preview.mjs --json`
// prints one JSON line per phase on the channel this already parses; the report is
// the line that carries a `url`.
//
// With `--report-cancelled` it posts one body and nothing else. The workflow runs
// that on a cancelled or timed-out job, which kills this process mid-checklist.
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { PREVIEW_LABEL_PREFIX } from '../../scripts/preview-labels.mjs';
import { ensureEnvVar, postOrUpdateComment } from './github-helpers.mjs';
import { PREVIEW_LABEL_PREFIX } from '../../scripts/codespace-preview/preview-labels.mjs';
import { PREVIEW_PHASES, createLineSplitter, phaseIndex } from '../../scripts/codespace-preview/preview-phases.mjs';
import {
ensureEnvVar,
findCommentByMarker,
postOrUpdateComment,
updateCommentById,
} from './github-helpers.mjs';
export const BOT_MARKER = '<!-- codespace-preview -->';
// Only a progress body carries this. The cancelled-run step checks for it before it
// writes, so a run cancelled while it was still queued cannot replace a good ready
// comment left by the run before it.
export const PROGRESS_MARKER = '<!-- codespace-preview:progress -->';
export const PREVIEW_LABEL = 'codespace-preview';
// Copied from `preview.mjs`, which owns them, so they move together. They cannot be
// imported: that module runs its command switch on import. They appear in the comment
// so a reviewer knows how long the instance lasts without reading the script.
const IDLE_TIMEOUT = '2 hours';
const RETENTION_PERIOD = '24 hours';
const CODESPACE_ENV_VARIABLE_URL = "https://internal.users.n8n.cloud/form/codespace-environments";
// A slept box comes back with a private port, so every recovery hint points here.
export const WORKFLOW_URL =
'https://github.com/n8n-io/n8n/actions/workflows/util-codespace-preview.yml';
// What a manual run may ask for. `ls` is absent: it needs no PR and posts no comment.
export const DISPATCH_OPERATIONS = ['up', 'refresh', 'down'];
// Resolved against this file, so the script runs the same from any directory.
const PREVIEW_SCRIPT = fileURLToPath(new URL('../../scripts/preview.mjs', import.meta.url));
const PREVIEW_SCRIPT = fileURLToPath(new URL('../../scripts/codespace-preview/preview.mjs', import.meta.url));
// Often enough to look alive, rarely enough that the edit history stays readable.
const HEARTBEAT_MS = 60_000;
/**
* A `preview:*` label configures an instance that already exists, so toggling one
@ -133,6 +154,9 @@ export function readyComment({ url, codespace, sha, orgVisible, pr }) {
'',
`**[Open the preview](${url}/preview-signin)** — one click signs you in.`,
'',
`If you need to modify the environment variables of this instance, navigate to ${CODESPACE_ENV_VARIABLE_URL}?pr=${pr} and submit them.`,
'The instance will refresh and apply your variables.',
'',
'| | |',
'| --- | --- |',
`| URL | ${url} |`,
@ -169,6 +193,131 @@ export function expiredComment({ pr }) {
].join('\n');
}
/**
* `4m 12s`, for a comment that is read while it counts. Anything unusable reads as
* `0s`: a wrong duration in a status line is worse than a boring one.
*
* @param {number} ms
*/
export function formatElapsed(ms) {
const total = Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0;
const seconds = total % 60;
const minutes = Math.floor(total / 60) % 60;
const hours = Math.floor(total / 3600);
if (hours) return `${hours}h ${minutes}m`;
if (minutes) return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
return `${seconds}s`;
}
/**
* The instance URL out of a body a previous run left. A refresh replaces the ready
* comment with a checklist, so without this the PR loses the URL for several
* minutes. Reads the URL row `readyComment` writes.
*
* @param {string} body
*/
export function previewUrlFromBody(body) {
return body.match(/^\| URL \| (\S+) \|$/m)?.[1];
}
/**
* One line of a `preview.mjs --json` run that reports a phase rather than the final
* result. A report carries a `url`; a progress line never does.
*
* @param {string} line
* @returns {{phase: string, detail?: string, sha?: string, codespace?: string} | undefined}
*/
export function parseProgressLine(line) {
const trimmed = line.trim();
if (!trimmed.startsWith('{')) return undefined;
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed.phase === 'string' && parsed.url === undefined) return parsed;
} catch {}
return undefined;
}
/**
* The checklist, while the box is being set up. Pure: the caller passes the clock,
* so this renders the same body twice.
*
* @param {{
* pr: string | number,
* operation: 'up' | 'refresh',
* runUrl: string,
* sha?: string,
* phase?: string,
* detail?: string,
* previousUrl?: string,
* startedAt: number,
* phaseStartedAt: number,
* now: number,
* }} state
*/
export function progressComment({
pr,
operation,
runUrl,
sha,
phase,
detail,
previousUrl,
startedAt,
phaseStartedAt,
now,
}) {
// Everything before the current phase is done. Going by position rather than by
// the phases actually seen keeps the list right when one never reports — an old
// PR head has no in-box emitter.
const current = phaseIndex(phase);
const inPhaseMs = now - phaseStartedAt;
const steps = PREVIEW_PHASES.map((step, index) => {
if (index < current) return `- [x] ${step.label}`;
if (index !== current) return `- [ ] ${step.label}`;
return `- [ ] **${step.label}**${detail ? ` — ${detail}` : ''} · ${formatElapsed(inPhaseMs)}`;
});
const slow = PREVIEW_PHASES[current]?.slowAfterMs;
const target = sha ? `\`${sha.slice(0, 7)}\`` : `PR #${pr}`;
return [
BOT_MARKER,
PROGRESS_MARKER,
`### Preview instance ${operation === 'refresh' ? 'updating to' : 'starting for'} ${target}`,
'',
'Setting up the box. This usually takes a few minutes.',
'',
...steps,
...(slow && inPhaseMs > slow
? ['', `> ${PREVIEW_PHASES[current].label} is taking longer than usual.`]
: []),
// A refresh stops the old backend before it rebuilds, so the URL is down for
// the rest of this run. Keep it on the PR anyway: it is the same URL when the
// run finishes, and losing it for several minutes is worse than saying so.
...(previousUrl
? ['', `The URL does not change: ${previousUrl}. It stops answering until this finishes.`]
: []),
'',
`Elapsed ${formatElapsed(now - startedAt)} · updated ${new Date(now).toISOString().slice(11, 19)} UTC · [live log](${runUrl})`,
].join('\n');
}
/** @param {{pr: string | number, operation: string, runUrl: string}} context */
export function cancelledComment({ pr, operation, runUrl }) {
return [
BOT_MARKER,
`### Preview instance run stopped`,
'',
`The \`preview ${operation}\` run for PR #${pr} was cancelled or timed out, so the`,
'instance is in an unknown state. The box can still exist.',
'',
`Run [the preview workflow](${WORKFLOW_URL}) with \`up\` to finish the job, or \`down\` to delete the box.`,
`See [the workflow run](${runUrl}) for how far it got.`,
].join('\n');
}
/** @param {{operation: string, runUrl: string, message: string}} context */
export function failureComment({ operation, runUrl, message }) {
return [
@ -183,18 +332,120 @@ export function failureComment({ operation, runUrl, message }) {
}
/**
* Progress and the in-box build log go to stderr, so they stream into the job log
* live. Only the JSON line is captured.
* Human progress and the in-box build log go to stderr, so they stream into the job
* log live. Only stdout is captured, and its phase lines are handed over as they
* arrive — a run this long has to report itself while it runs, not at the end.
*
* @param {readonly string[]} args
* @param {{onProgress?: (progress: {phase: string, detail?: string, sha?: string}) => void}} [hooks]
* @returns {Promise<{status: number | null, stdout: string}>}
*/
function runPreview(args) {
const result = spawnSync('node', [PREVIEW_SCRIPT, ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
function runPreview(args, { onProgress } = {}) {
return new Promise((resolve, reject) => {
const child = spawn('node', [PREVIEW_SCRIPT, ...args], {
stdio: ['ignore', 'pipe', 'inherit'],
});
const splitLines = createLineSplitter();
let stdout = '';
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
// Kept whole for `parsePreviewJson`, which reads the report off the end.
stdout += chunk;
if (!onProgress) return;
for (const line of splitLines(chunk)) {
const progress = parseProgressLine(line);
if (progress) onProgress(progress);
}
});
child.on('error', reject);
child.on('close', (status) => resolve({ status, stdout }));
});
if (result.error) throw result.error;
return { status: result.status, stdout: result.stdout ?? '' };
}
/**
* Keeps the checklist on the PR current while `preview.mjs` runs: one edit for each
* phase, plus a heartbeat so a long phase still looks alive.
*
* @param {{pr: string, operation: 'up' | 'refresh', runUrl: string, previousUrl?: string, commentId?: number}} context
*/
function createProgressReporter({ pr, operation, runUrl, previousUrl, commentId }) {
const startedAt = Date.now();
let phaseStartedAt = startedAt;
let id = commentId;
let phase;
let detail;
let sha;
let writes = Promise.resolve();
// One chain, so two edits never overlap, and every failure is swallowed: this
// Octokit carries no retry plugin, and a status edit must not fail a preview that
// is otherwise fine. Resetting the chain keeps one failure from poisoning the next.
function push() {
writes = writes
.then(async () => {
const body = progressComment({
pr,
operation,
runUrl,
sha,
phase,
detail,
previousUrl,
startedAt,
phaseStartedAt,
now: Date.now(),
});
// Hold the id from the first write: paginating every comment on the PR
// once a minute would be the expensive part of a heartbeat.
if (id === undefined) id = await postOrUpdateComment(Number(pr), body, BOT_MARKER);
else await updateCommentById(id, body);
})
.catch((error) => {
console.log(`::warning::Could not update the preview comment: ${error.message}`);
});
return writes;
}
const timer = setInterval(push, HEARTBEAT_MS);
timer.unref();
return {
start: push,
/** @param {{phase: string, detail?: string, sha?: string}} progress */
onProgress(progress) {
if (progress.sha) sha = progress.sha;
if (progress.phase === phase && progress.detail === detail) return;
phase = progress.phase;
detail = progress.detail;
phaseStartedAt = Date.now();
push();
},
/** Drains the queue, so the final body of the run is always the last write. */
async stop() {
clearInterval(timer);
await writes;
},
};
}
/**
* A cancelled or timed-out job kills this process mid-checklist, so the workflow
* runs it again just to say so. Only over a progress body: a run cancelled while it
* was queued never started work, and must leave a ready comment alone.
*
* @param {string} pr
* @param {string} operation
* @param {string} runUrl
*/
async function reportCancelled(pr, operation, runUrl) {
const existing = await findCommentByMarker(Number(pr), BOT_MARKER);
if (!existing?.body.includes(PROGRESS_MARKER)) {
console.log('The preview comment is not a checklist — leaving it as it is.');
return;
}
await updateCommentById(existing.id, cancelledComment({ pr, operation, runUrl }));
}
async function main() {
@ -222,9 +473,14 @@ async function main() {
return;
}
if (process.argv.includes('--report-cancelled')) {
await reportCancelled(pr, operation, runUrl);
return;
}
try {
if (operation === 'refresh') {
const list = runPreview(['ls']);
const list = await runPreview(['ls']);
if (list.status !== 0) throw new Error(`\`preview ls\` exited ${list.status}`);
if (!hasPreviewBox(list.stdout, pr)) {
console.log(`No preview box for PR #${pr} — reporting it as expired.`);
@ -233,14 +489,37 @@ async function main() {
}
}
const { status, stdout } = runPreview([operation, pr, '--json']);
if (status !== 0) throw new Error(`\`preview ${operation}\` exited ${status}`);
if (operation === 'down') {
const { status } = await runPreview([operation, pr, '--json']);
if (status !== 0) throw new Error(`\`preview ${operation}\` exited ${status}`);
await postOrUpdateComment(Number(pr), downComment({ pr }), BOT_MARKER);
return;
}
// A checklist only earns its place in front of work that takes minutes. It also
// goes up after the `refresh` check above, so a box that is already gone never
// flashes a checklist before the expired body replaces it.
const existing = await findCommentByMarker(Number(pr), BOT_MARKER);
const reporter = createProgressReporter({
pr,
operation,
runUrl,
previousUrl: previewUrlFromBody(existing?.body ?? ''),
commentId: existing?.id,
});
await reporter.start();
let status;
let stdout;
try {
({ status, stdout } = await runPreview([operation, pr, '--json'], {
onProgress: reporter.onProgress,
}));
} finally {
await reporter.stop();
}
if (status !== 0) throw new Error(`\`preview ${operation}\` exited ${status}`);
const preview = parsePreviewJson(stdout);
if (!preview) throw new Error(`\`preview ${operation}\` printed no preview details`);

View File

@ -4,14 +4,20 @@ import { describe, it } from 'node:test';
import {
BOT_MARKER,
DISPATCH_OPERATIONS,
PROGRESS_MARKER,
WORKFLOW_URL,
cancelledComment,
downComment,
expiredComment,
failureComment,
formatElapsed,
hasPreviewBox,
operationFor,
parsePreviewJson,
parseProgressLine,
portFromUrl,
previewUrlFromBody,
progressComment,
readyComment,
resolveOperation,
} from './codespace-preview.mjs';
@ -24,6 +30,18 @@ const PREVIEW = {
orgVisible: true,
};
const START = Date.parse('2026-09-17T09:35:00Z');
const PROGRESS = {
pr: 1234,
operation: 'up',
runUrl: 'https://github.com/n8n-io/n8n/actions/runs/1',
sha: PREVIEW.sha,
phase: 'build',
startedAt: START,
phaseStartedAt: START + 111_000,
now: START + 363_000,
};
describe('operationFor', () => {
const LABEL = 'codespace-preview';
@ -104,6 +122,19 @@ describe('parsePreviewJson', () => {
assert.equal(parsePreviewJson(stdout).sha, PREVIEW.sha);
});
// The phase lines share the channel with the report. This is the contract that
// makes that safe.
it('ignores the phase lines that precede the report', () => {
const stdout = [
'{"phase":"resolve"}',
'{"sha":"abcdef1","phase":"build"}',
JSON.stringify(PREVIEW),
].join('\n');
assert.deepEqual(parsePreviewJson(stdout), PREVIEW);
assert.equal(parsePreviewJson('{"phase":"build"}'), undefined);
});
it('ignores JSON that is not a preview report', () => {
// A build tool can print its own JSON on the same stream.
assert.equal(parsePreviewJson('{"level":"info","msg":"built"}'), undefined);
@ -159,6 +190,12 @@ describe('comment bodies', () => {
runUrl: 'https://github.com/n8n-io/n8n/actions/runs/1',
message: '`preview up` exited 1',
}),
progress: progressComment(PROGRESS),
cancelled: cancelledComment({
pr: PREVIEW.pr,
operation: 'up',
runUrl: 'https://github.com/n8n-io/n8n/actions/runs/1',
}),
};
it('every body starts with the marker, so postOrUpdateComment edits in place', () => {
@ -197,4 +234,125 @@ describe('comment bodies', () => {
assert.ok(bodies[name].includes(WORKFLOW_URL), `${name} must link the workflow`);
}
});
// The cancelled-run step writes only over a checklist, so no settled body may
// carry the progress marker.
it('carries the progress marker on the checklist and nowhere else', () => {
assert.ok(bodies.progress.includes(PROGRESS_MARKER));
for (const name of ['ready', 'down', 'expired', 'failure', 'cancelled']) {
assert.ok(!bodies[name].includes(PROGRESS_MARKER), `${name} must not look in progress`);
}
});
it('the cancelled body says the box can still exist, and links both runs', () => {
assert.match(bodies.cancelled, /can still exist/);
assert.ok(bodies.cancelled.includes(WORKFLOW_URL));
assert.ok(bodies.cancelled.includes('actions/runs/1'));
});
});
describe('formatElapsed', () => {
it('reads as a duration a reviewer can scan', () => {
assert.equal(formatElapsed(0), '0s');
assert.equal(formatElapsed(59_000), '59s');
assert.equal(formatElapsed(60_000), '1m 00s');
assert.equal(formatElapsed(252_000), '4m 12s');
assert.equal(formatElapsed(3_780_000), '1h 3m');
});
// A wrong duration in a status line is worse than a boring one.
it('falls back to 0s for a value it cannot use', () => {
assert.equal(formatElapsed(-1), '0s');
assert.equal(formatElapsed(NaN), '0s');
assert.equal(formatElapsed(undefined), '0s');
});
});
describe('progressComment', () => {
it('ticks off the phases before the current one and leaves the rest open', () => {
const body = progressComment(PROGRESS);
assert.match(body, /- \[x\] Resolve the PR head/);
assert.match(body, /- \[x\] Install dependencies/);
assert.match(body, /- \[ \] \*\*Build the monorepo\*\* · 4m 12s/);
assert.match(body, /- \[ \] Share the port with the org/);
});
it('names the commit and the elapsed time, and links the run', () => {
const body = progressComment(PROGRESS);
assert.match(body, /starting for `abcdef1`/);
assert.match(body, /Elapsed 6m 03s/);
assert.ok(body.includes(PROGRESS.runUrl));
});
it('says what the phase is doing when the phase says so', () => {
const body = progressComment({ ...PROGRESS, phase: 'box', detail: 'reusing psychic-umbrella' });
assert.match(body, /\*\*Prepare the box\*\* — reusing psychic-umbrella/);
});
// A workflow_dispatch run reports the sha only once preview.mjs resolves it.
it('falls back to the PR number before the sha is known', () => {
const body = progressComment({ ...PROGRESS, sha: undefined });
assert.match(body, /starting for PR #1234/);
});
it('leaves every phase open before the first one reports', () => {
const body = progressComment({ ...PROGRESS, phase: undefined });
assert.ok(!body.includes('- [x]'));
assert.match(body, /- \[ \] Resolve the PR head/);
});
it('says a refresh is updating, not starting', () => {
assert.match(progressComment({ ...PROGRESS, operation: 'refresh' }), /updating to `abcdef1`/);
});
it('warns only once a phase passes the time it usually takes', () => {
const slow = { ...PROGRESS, phaseStartedAt: START - 600_001 };
assert.match(progressComment(slow), /Build the monorepo is taking longer than usual/);
assert.ok(!progressComment(PROGRESS).includes('longer than usual'));
});
// Replacing a ready comment with a checklist would otherwise take a working URL
// off the PR for several minutes.
it('keeps a URL a previous run reported', () => {
const body = progressComment({ ...PROGRESS, previousUrl: PREVIEW.url });
assert.ok(body.includes(PREVIEW.url));
assert.match(body, /stops answering/);
assert.ok(!progressComment(PROGRESS).includes('stops answering'));
});
});
describe('previewUrlFromBody', () => {
it('reads the URL back out of a ready body', () => {
assert.equal(previewUrlFromBody(readyComment(PREVIEW)), PREVIEW.url);
});
it('finds nothing in a body that reports no instance', () => {
assert.equal(previewUrlFromBody(downComment({ pr: 1234 })), undefined);
assert.equal(previewUrlFromBody(expiredComment({ pr: 1234 })), undefined);
assert.equal(previewUrlFromBody(progressComment(PROGRESS)), undefined);
assert.equal(previewUrlFromBody(''), undefined);
});
});
describe('parseProgressLine', () => {
it('reads a phase line', () => {
assert.deepEqual(parseProgressLine('{"phase":"build","sha":"abc"}'), {
phase: 'build',
sha: 'abc',
});
});
it('ignores the report and anything that is not a phase line', () => {
assert.equal(parseProgressLine(JSON.stringify(PREVIEW)), undefined);
assert.equal(parseProgressLine('Serving abcdef1 on psychic-umbrella-q7w6gwx…'), undefined);
assert.equal(parseProgressLine('{"level":"info","msg":"built"}'), undefined);
assert.equal(parseProgressLine('{not json'), undefined);
});
});

View File

@ -7,11 +7,16 @@ import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
export const POSTGRES_VERSIONS_PATH = 'packages/testing/containers/postgres-versions.json';
export const POSTGRES_VERSIONS_PATH = 'packages/quality/environments/containers/postgres-versions.json';
/** The matrix wall is bounded by its slowest leg, so a larger runner buys nothing. */
const RUNNER = 'blacksmith-4vcpu-ubuntu-2204';
// Each leg runs one suite on its own runner, so use every CPU. The CI default
// (half the CPUs) is for jobs that run many suites at once. Every test file
// gets its own database, so more workers do not share state.
const MAX_WORKERS = '--maxWorkers=100%';
/**
* @typedef {Object} PostgresVersions
* @property {string} primary
@ -27,8 +32,9 @@ export function readPostgresVersions(repoRoot = REPO_ROOT) {
const SCOPES = new Set(['pr', 'full']);
/**
* Coverage and the schema-docs check run on the primary Postgres leg only, since
* the committed docs come from that one version.
* The schema-docs check runs on the primary Postgres leg only, since the
* committed docs come from that one version. PRs collect coverage on that leg.
* Merge groups use the full scope and skip duplicate coverage collection.
*
* Scope 'pr' keeps SQLite plus the primary Postgres leg. Scope 'full' keeps
* every Postgres major. The merge queue generates with 'full', so every merge
@ -85,7 +91,7 @@ export function buildMatrix(versions, scope = 'full') {
{
name: 'SQLite Pooled',
runner: RUNNER,
'test-cmd': 'pnpm test:sqlite',
'test-cmd': `pnpm test:sqlite ${MAX_WORKERS}`,
'migration-cmd': 'pnpm test:sqlite:migrations',
'schema-check-cmd': 'pnpm --filter=@n8n/db schema:check:sqlite',
TEST_IMAGE_POSTGRES: undefined,
@ -94,11 +100,11 @@ export function buildMatrix(versions, scope = 'full') {
...postgresLegs.map(({ major, image }) => ({
name: `Postgres ${major}`,
runner: RUNNER,
'test-cmd': 'pnpm test:postgres:integration:tc',
'migration-cmd': 'pnpm test:postgres:migrations:tc',
'test-cmd': `pnpm test:postgres:integration:tc ${MAX_WORKERS}`,
'migration-cmd': `pnpm test:postgres:migrations:tc ${MAX_WORKERS}`,
'schema-check-cmd': image === primary ? 'pnpm --filter=@n8n/db schema:check:postgres' : '',
TEST_IMAGE_POSTGRES: image,
collectCoverage: image === primary ? 'true' : 'false',
collectCoverage: scope === 'pr' && image === primary ? 'true' : 'false',
})),
];
}

View File

@ -62,14 +62,27 @@ describe('buildMatrix', () => {
}
});
it('collects coverage and checks schema docs on the primary Postgres leg only', () => {
const legs = buildMatrix(versions());
it('collects coverage on the primary Postgres leg in pr scope only', () => {
const legs = buildMatrix(versions(), 'pr');
const collecting = legs.filter((leg) => leg.collectCoverage === 'true');
assert.deepEqual(
collecting.map((leg) => leg.name),
['Postgres 18'],
);
});
it('does not collect coverage in full scope', () => {
const legs = buildMatrix(versions(), 'full');
assert.equal(
legs.some((leg) => leg.collectCoverage === 'true'),
false,
);
});
it('checks schema docs on the primary Postgres leg only', () => {
const legs = buildMatrix(versions());
const checkingPostgresSchema = legs.filter(
(leg) => leg['schema-check-cmd'] === 'pnpm --filter=@n8n/db schema:check:postgres',

View File

@ -1,8 +1,17 @@
import { getOctokit } from '@actions/github';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import semver from 'semver';
// Scripts that run through `run-workflow-script` have no node_modules: they
// get their client through `setOctokit` and never call the release helpers.
const actionsGithub = await import('@actions/github').catch((error) => {
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
return null;
});
const require = createRequire(import.meta.url);
/** @returns { typeof import('semver') } */
const loadSemver = () => require('semver');
export const CURRENT_MAJOR_VERSION = 2;
export const RELEASE_CANDIDATE_BRANCH_PREFIX = 'release-candidate/';
@ -48,8 +57,8 @@ export function pickHighestReleaseTag(tags) {
const versions = tags
.filter((t) => t.startsWith(RELEASE_PREFIX))
.map((t) => ({ tag: t, v: stripReleasePrefixes(t) }))
.filter(({ v }) => semver.valid(v))
.sort((a, b) => semver.rcompare(a.v, b.v));
.filter(({ v }) => loadSemver().valid(v))
.sort((a, b) => loadSemver().rcompare(a.v, b.v));
return /** @type { ReleaseVersion } */ (versions[0]?.tag) ?? null;
}
@ -128,7 +137,7 @@ export function resolveRcBranchForTrack(track) {
const releaseTag = pickHighestReleaseTag(tagsAtCommit);
if (!releaseTag) return null;
const parsed = semver.parse(stripReleasePrefixes(releaseTag));
const parsed = loadSemver().parse(stripReleasePrefixes(releaseTag));
if (!parsed) return null;
return `release-candidate/${parsed.major}.${parsed.minor}.x`;
@ -145,12 +154,12 @@ export function resolveRcBranchForTrack(track) {
* */
export function tagVersionInfoToReleaseCandidateBranchName(tagVersionInfo) {
const version = tagVersionInfo.version;
const majorVersion = semver.major(version);
const majorVersion = loadSemver().major(version);
if (majorVersion < CURRENT_MAJOR_VERSION) {
return `${majorVersion}.x`;
}
return `${RELEASE_CANDIDATE_BRANCH_PREFIX}${majorVersion}.${semver.minor(version)}.x`;
return `${RELEASE_CANDIDATE_BRANCH_PREFIX}${majorVersion}.${loadSemver().minor(version)}.x`;
}
/**
@ -351,18 +360,37 @@ export function localRefExists(ref) {
return res.ok;
}
/** @type { GitHubInstance | null } */
let injectedOctokit = null;
/**
* Initializes octokit with GITHUB_TOKEN from env vars.
* Use a ready client instead of building one from GITHUB_TOKEN.
* `run-workflow-script` passes the client that actions/github-script provides.
*
* Also ensures the existence of useful environment variables.
* @param { GitHubInstance | null } octokit
*/
export function setOctokit(octokit) {
injectedOctokit = octokit;
}
/**
* Returns the octokit client and the current repository.
*
* Uses the injected client when set, otherwise builds one from GITHUB_TOKEN.
* */
export function initGithub() {
const token = ensureEnvVar('GITHUB_TOKEN');
const repoFullName = ensureEnvVar('GITHUB_REPOSITORY');
const [owner, repo] = repoFullName.split('/');
const octokit = getOctokit(token);
if (injectedOctokit) {
return { octokit: injectedOctokit, owner, repo };
}
if (!actionsGithub) {
throw new Error('No Octokit client: call setOctokit() or install the .github/scripts dependencies');
}
const token = ensureEnvVar('GITHUB_TOKEN');
const octokit = actionsGithub.getOctokit(token);
return {
octokit,
@ -386,6 +414,26 @@ export async function getPullRequestById(pullRequestId) {
return pullRequest.data;
}
/**
* Open PRs whose head is `headOwner:headBranch`, newest first.
*
* @param { string } headOwner Owner of the head repository (a fork owner or this org).
* @param { string } headBranch
* @returns { Promise<any[]> }
*/
export async function listOpenPullRequestsByHead(headOwner, headBranch) {
const { octokit, owner, repo } = initGithub();
const pullRequests = await octokit.rest.pulls.list({
owner,
repo,
state: 'open',
head: `${headOwner}:${headBranch}`,
});
return pullRequests.data;
}
/**
* Returns the set of files changed in a PR, including previous filenames for renames.
*
@ -442,6 +490,23 @@ export async function getPrReviews(pullRequestNumber) {
});
}
/**
* Returns all events for a PR, including label changes and their actors.
*
* @param { number } pullRequestNumber
* @returns { Promise<Array<{ event: string, actor: { login: string } | null, label?: { name?: string } | null, created_at?: string }>> }
* */
export async function getPrEvents(pullRequestNumber) {
const { octokit, owner, repo } = initGithub();
return await octokit.paginate(octokit.rest.issues.listEvents, {
owner,
repo,
issue_number: pullRequestNumber,
per_page: 100,
});
}
/**
* Test whether a user is an active member of an org team.
*
@ -493,14 +558,13 @@ export async function setCommitStatus(sha, { state, context, description, target
}
/**
* Post a PR comment, or update the existing one if a previous run already
* left one identified by the provided bot marker.
* The comment a previous run left, found by its bot marker.
*
* @param { number } pullRequestNumber
* @param { string } body
* @param { string } botMarker
* @returns { Promise<{ id: number, body: string } | undefined> }
*/
export async function postOrUpdateComment(pullRequestNumber, body, botMarker) {
export async function findCommentByMarker(pullRequestNumber, botMarker) {
const { octokit, owner, repo } = initGithub();
const comments = await octokit.paginate(octokit.rest.issues.listComments, {
@ -512,21 +576,49 @@ export async function postOrUpdateComment(pullRequestNumber, body, botMarker) {
const existing = comments.find((c) => c.body?.includes(botMarker));
return existing ? { id: existing.id, body: existing.body ?? '' } : undefined;
}
/**
* Overwrite a comment whose id is already known. A caller that edits the same
* comment repeatedly uses this instead of paginating every comment each time.
*
* @param { number } commentId
* @param { string } body
*/
export async function updateCommentById(commentId, body) {
const { octokit, owner, repo } = initGithub();
await octokit.rest.issues.updateComment({ owner, repo, comment_id: commentId, body });
}
/**
* 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
* @returns { Promise<number> } the id of the comment it wrote
*/
export async function postOrUpdateComment(pullRequestNumber, body, botMarker) {
const { octokit, owner, repo } = initGithub();
const existing = await findCommentByMarker(pullRequestNumber, 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,
});
await updateCommentById(existing.id, body);
return existing.id;
}
const created = await octokit.rest.issues.createComment({
owner,
repo,
issue_number: pullRequestNumber,
body,
});
return created.data.id;
}
/**

View File

@ -18,10 +18,113 @@ mock.module('@actions/github', {
},
});
const { postOrUpdateComment, writeGithubOutput } = await import('./github-helpers.mjs');
const {
findCommentByMarker,
getPrEvents,
initGithub,
postOrUpdateComment,
setOctokit,
updateCommentById,
writeGithubOutput,
} = await import('./github-helpers.mjs');
const ORIGINAL_ENV = { ...process.env };
describe('getPrEvents', () => {
beforeEach(() => {
process.env.GITHUB_REPOSITORY = 'n8n-io/n8n';
});
afterEach(() => {
setOctokit(null);
process.env = { ...ORIGINAL_ENV };
});
it('paginates issue events for the pull request', async () => {
const events = [{ event: 'labeled', actor: { login: 'ana' } }];
const listEvents = {};
const paginate = mock.fn(async () => events);
setOctokit({ paginate, rest: { issues: { listEvents } } });
assert.equal(await getPrEvents(42), events);
assert.equal(paginate.mock.calls[0].arguments[0], listEvents);
assert.deepEqual(paginate.mock.calls[0].arguments[1], {
owner: 'n8n-io',
repo: 'n8n',
issue_number: 42,
per_page: 100,
});
});
});
describe('findCommentByMarker', () => {
beforeEach(() => {
process.env.GITHUB_TOKEN = 'token';
process.env.GITHUB_REPOSITORY = 'n8n-io/n8n';
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
const withComments = (comments) => {
octokitImpl = () => ({
paginate: mock.fn(async () => comments),
rest: { issues: { listComments: {}, createComment: mock.fn(), updateComment: mock.fn() } },
});
};
it('returns the id and the body of the marked comment', async () => {
withComments([
{ id: 1, body: 'unrelated' },
{ id: 42, body: '<!-- marker -->\nold body' },
]);
assert.deepEqual(await findCommentByMarker(123, '<!-- marker -->'), {
id: 42,
body: '<!-- marker -->\nold body',
});
});
it('returns undefined when nothing carries the marker', async () => {
withComments([{ id: 1, body: 'unrelated' }]);
assert.equal(await findCommentByMarker(123, '<!-- marker -->'), undefined);
});
});
describe('updateCommentById', () => {
beforeEach(() => {
process.env.GITHUB_TOKEN = 'token';
process.env.GITHUB_REPOSITORY = 'n8n-io/n8n';
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
// This is the whole point of the helper: a heartbeat must not paginate every
// comment on the PR once a minute.
it('edits the comment without listing any', async () => {
const updateComment = mock.fn(async () => {});
const paginate = mock.fn(async () => []);
octokitImpl = () => ({
paginate,
rest: { issues: { listComments: {}, createComment: mock.fn(), updateComment } },
});
await updateCommentById(42, 'next body');
assert.equal(paginate.mock.calls.length, 0);
assert.deepEqual(updateComment.mock.calls[0].arguments[0], {
owner: 'n8n-io',
repo: 'n8n',
comment_id: 42,
body: 'next body',
});
});
});
describe('postOrUpdateComment', () => {
beforeEach(() => {
process.env.GITHUB_TOKEN = 'token';
@ -33,7 +136,7 @@ describe('postOrUpdateComment', () => {
});
it('creates a new comment when no existing bot-marker comment is found', async () => {
const createComment = mock.fn(async () => {});
const createComment = mock.fn(async () => ({ data: { id: 7 } }));
const updateComment = mock.fn(async () => {});
const paginate = mock.fn(async () => [{ id: 1, body: 'unrelated comment' }]);
octokitImpl = () => ({
@ -47,8 +150,9 @@ describe('postOrUpdateComment', () => {
},
});
await postOrUpdateComment(123, 'new body', '<!-- marker -->');
const id = await postOrUpdateComment(123, 'new body', '<!-- marker -->');
assert.equal(id, 7);
assert.equal(updateComment.mock.calls.length, 0);
assert.equal(createComment.mock.calls.length, 1);
assert.deepEqual(createComment.mock.calls[0].arguments[0], {
@ -66,7 +170,7 @@ describe('postOrUpdateComment', () => {
});
it('updates the existing comment when a bot-marker comment is found', async () => {
const createComment = mock.fn(async () => {});
const createComment = mock.fn(async () => ({ data: { id: 7 } }));
const updateComment = mock.fn(async () => {});
octokitImpl = () => ({
paginate: mock.fn(async () => [{ id: 42, body: '<!-- marker -->\nold body' }]),
@ -79,8 +183,9 @@ describe('postOrUpdateComment', () => {
},
});
await postOrUpdateComment(123, 'updated body', '<!-- marker -->');
const id = await postOrUpdateComment(123, 'updated body', '<!-- marker -->');
assert.equal(id, 42);
assert.equal(createComment.mock.calls.length, 0);
assert.equal(updateComment.mock.calls.length, 1);
assert.deepEqual(updateComment.mock.calls[0].arguments[0], {
@ -129,3 +234,38 @@ describe('writeGithubOutput', () => {
assert.equal(existsSync(outputPath), false);
});
});
describe('initGithub', () => {
beforeEach(() => {
process.env.GITHUB_REPOSITORY = 'n8n-io/n8n';
delete process.env.GITHUB_TOKEN;
});
afterEach(() => {
setOctokit(null);
process.env = { ...ORIGINAL_ENV };
});
it('uses the injected client and needs no token', () => {
const injected = { rest: {} };
setOctokit(injected);
const result = initGithub();
assert.equal(result.octokit, injected);
assert.equal(result.owner, 'n8n-io');
assert.equal(result.repo, 'n8n');
});
it('builds a client from GITHUB_TOKEN when nothing is injected', () => {
const built = { rest: {} };
octokitImpl = () => built;
process.env.GITHUB_TOKEN = 'token';
assert.equal(initGithub().octokit, built);
});
it('requires GITHUB_TOKEN when nothing is injected', () => {
assert.throws(() => initGithub(), /GITHUB_TOKEN/);
});
});

49
.github/scripts/glob.mjs vendored Normal file
View File

@ -0,0 +1,49 @@
/**
* Minimal glob matcher for changed-file paths.
*
* Supports `**` (any number of path segments), `*` (any run of characters
* inside one segment) and literal text. Dot segments match like any other,
* so patterns reach files under `.github/`. Only node builtins, so scripts
* that run through `run-workflow-script` can use it without an install.
*/
/** @type { Map<string, RegExp> } */
const cache = new Map();
/**
* @param { string } pattern
* @returns { RegExp }
*/
export function globToRegExp(pattern) {
const cached = cache.get(pattern);
if (cached) return cached;
let source = '';
const segments = pattern.split('/');
for (const [index, segment] of segments.entries()) {
const last = index === segments.length - 1;
if (segment === '**') {
// `**/` matches zero or more segments; a trailing `**` matches the rest.
source += last ? '.*' : '(?:[^/]+/)*';
continue;
}
source += segment
.split('*')
.map((literal) => literal.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
.join('[^/]*');
if (!last) source += '/';
}
const regExp = new RegExp(`^${source}$`);
cache.set(pattern, regExp);
return regExp;
}
/**
* @param { string } file Repository-relative path with `/` separators.
* @param { string } pattern
* @returns { boolean }
*/
export function matchesGlob(file, pattern) {
return globToRegExp(pattern).test(file);
}

81
.github/scripts/glob.test.mjs vendored Normal file
View File

@ -0,0 +1,81 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { minimatch } from 'minimatch';
import { matchesGlob } from './glob.mjs';
import { EXCLUDE_PATTERNS } from './quality/check-pr-size.mjs';
/**
* Run with:
* node --test --experimental-test-module-mocks ./.github/scripts/glob.test.mjs
* */
describe('matchesGlob', () => {
it('matches a file extension anywhere in the tree', () => {
assert.equal(matchesGlob('a.test.ts', '**/*.test.ts'), true);
assert.equal(matchesGlob('packages/cli/src/a.test.ts', '**/*.test.ts'), true);
assert.equal(matchesGlob('packages/cli/src/a.ts', '**/*.test.ts'), false);
});
it('matches dot segments', () => {
assert.equal(matchesGlob('.github/scripts/a.test.mjs', '**/*.test.mjs'), true);
assert.equal(matchesGlob('.github/scripts/glob.mjs', '**/*.test.mjs'), false);
});
it('matches a directory at any depth', () => {
assert.equal(matchesGlob('test/a.ts', '**/test/**'), true);
assert.equal(matchesGlob('packages/x/test/deep/a.ts', '**/test/**'), true);
assert.equal(matchesGlob('packages/x/tests/a.ts', '**/test/**'), false);
assert.equal(matchesGlob('packages/x/testing/a.ts', '**/test/**'), false);
});
it('matches a prefix pattern', () => {
assert.equal(matchesGlob('packages/quality/testing/playwright/a.ts', 'packages/quality/**'), true);
assert.equal(matchesGlob('packages/quality', 'packages/quality/**'), false);
assert.equal(matchesGlob('packages/cli/a.ts', 'packages/quality/**'), false);
});
it('matches a literal path only exactly', () => {
assert.equal(matchesGlob('pnpm-lock.yaml', 'pnpm-lock.yaml'), true);
assert.equal(matchesGlob('packages/cli/pnpm-lock.yaml', 'pnpm-lock.yaml'), false);
assert.equal(matchesGlob('pnpm-lockXyaml', 'pnpm-lock.yaml'), false);
});
it('keeps `*` inside one segment', () => {
assert.equal(matchesGlob('a/b.snap', '**/*.snap'), true);
assert.equal(matchesGlob('a/b/c.md', 'a/*.md'), false);
});
it('agrees with minimatch on the PR size patterns', () => {
const files = [
'packages/cli/src/a.ts',
'packages/cli/src/a.test.ts',
'packages/cli/test/a.ts',
'packages/cli/__tests__/a.ts',
'packages/cli/__snapshots__/a.ts.snap',
'packages/nodes-base/nodes/Foo/test/fixtures/a.json',
'packages/frontend/editor-ui/src/__mocks__/a.ts',
'packages/quality/testing/playwright/a.ts',
'packages/quality',
'.github/scripts/glob.test.mjs',
'.github/workflows/ci.yml',
'a/.hidden/b.spec.js',
'pnpm-lock.yaml',
'packages/cli/pnpm-lock.yaml',
'README.md',
'docs/a.mdx',
'src/tests.ts',
'src/test.ts',
'src/testing/a.ts',
];
for (const pattern of EXCLUDE_PATTERNS) {
for (const file of files) {
assert.equal(
matchesGlob(file, pattern),
minimatch(file, pattern, { dot: true }),
`${file} against ${pattern}`,
);
}
}
});
});

View File

@ -94,7 +94,11 @@ export async function run(pullRequestNumber) {
await removeLabel(pullRequestNumber, AUTO_ASSIGN_LABEL);
}
if (import.meta.url === `file://${process.argv[1]}`) {
export async function main() {
const pullRequestNumber = parseInt(ensureEnvVar('PULL_REQUEST_NUMBER'));
await run(pullRequestNumber);
}
if (import.meta.url === `file://${process.argv[1]}`) {
await main();
}

View File

@ -1,6 +1,8 @@
import { readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { isTestFile } from "../test-files.mjs";
/**
* @typedef OwnersEntry
* @property { string } pattern
@ -236,7 +238,7 @@ export function assignOwnership(files, entries) {
/**
* Determine which teams must approve the changeset: a team is required when
* its `required` entry wins (last-match) for a changed file.
* its `required` entry wins (last-match) for a changed non-test file.
*
* @param { Set<string> } files
* @param { OwnersEntry[] } entries
@ -247,6 +249,8 @@ export function resolveRequiredTeams(files, entries) {
const teamToFiles = new Map();
for (const file of [...files].sort()) {
if (isTestFile(file)) continue;
const entry = findOwningEntry(file, entries);
if (!entry?.required) continue;

View File

@ -293,6 +293,22 @@ describe('resolveRequiredTeams', () => {
assert.equal(result.size, 0);
});
it('ignores test files whose winning entry is required', () => {
const result = resolveRequiredTeams(
new Set([
'.github/scripts/check.test.mjs',
'.github/workflows/__tests__/check.ts',
'.github/workflows/ci.yml',
]),
[
entry({ pattern: '.github/scripts/', team: '@n8n-io/qa-dx', required: true }),
entry({ pattern: '.github/workflows/', team: '@n8n-io/qa-dx', required: true }),
],
);
assert.deepEqual(result.get('@n8n-io/qa-dx'), ['.github/workflows/ci.yml']);
});
it('a later non-required entry overrides an earlier required one', () => {
const result = resolveRequiredTeams(new Set(['db/migrations/1-init.ts']), [
entry({ pattern: 'db/', team: '@n8n-io/migrations-review', required: true }),

View File

@ -1,13 +1,17 @@
/**
* Required-review enforcement for OWNERS entries marked `required`.
*
* Computes the teams whose approval the changeset needs (per OWNERS)
* Computes the teams whose approval the non-test changes need (per OWNERS)
* and reports the verdict as a "Required Reviews" commit status on the PR
* head SHA. A ruleset that lists this status as a required check blocks the
* merge until a member of each required team has approved the PR. Merge-queue
* runs do not reach this script: the workflow reports success on the queue
* head directly, because entering the queue already required a green status.
*
* Every base branch is enforced unless the PR matches a trusted exemption.
* Exemptions include configured routes and an authorized large-scale-change
* label. An exempt PR reports success without approval evaluation.
*
* The job itself always succeeds when the evaluation runs; the commit status
* carries the verdict. The status is set to pending before the evaluation
* starts, so a crash (e.g. the team membership API is unavailable) cannot
@ -17,15 +21,37 @@
import {
getChangedFiles,
getEventFromGithubEventPath,
getPrEvents,
getPrReviews,
getPullRequestById,
isTeamMember,
listOpenPullRequestsByHead,
readPrLabels,
setCommitStatus,
} from '../github-helpers.mjs';
import { parseOwnersFile, resolveRequiredTeams, teamHandleToSlug } from './owners.mjs';
export const STATUS_CONTEXT = 'Required Reviews';
const TARGET_BRANCH = 'master';
export const LARGE_SCALE_CHANGE_LABEL = 'large-scale-change';
export const LARGE_SCALE_CHANGES_TEAM = 'large-scale-changes';
/**
* PR routes that skip the evaluation. Each entry is `<head> -> <base>` or
* just `<base>` (any head). `*` matches any run of characters, including
* `/`. Only heads in this repository qualify; a fork branch with a
* matching name does not.
*
* Add a route here only when every commit it carries was already reviewed
* elsewhere, like the master-to-3.x sync, whose commits landed on master.
*/
export const REQUIRED_REVIEW_EXEMPTIONS = ['sync/master-to-3x -> 3.x'];
/**
* @typedef ExemptRoute
* @property { string } head Glob for the head branch.
* @property { string } base Glob for the base branch.
* @property { string } source The entry as written.
*/
/**
* A PR review as returned by the reviews API.
@ -36,17 +62,68 @@ const TARGET_BRANCH = 'master';
* @property { string } [submitted_at]
*/
/**
* @param { string } entry `<head> -> <base>` or `<base>`
* @returns { ExemptRoute }
*/
export function parseExemption(entry) {
const parts = entry.split('->').map((part) => part.trim());
if (parts.length > 2 || parts.some((part) => part === '')) {
throw new Error(`Invalid exemption "${entry}": expected "<head> -> <base>" or "<base>"`);
}
const [head, base] = parts.length === 2 ? parts : ['*', parts[0]];
return { head, base, source: entry };
}
/**
* @param { string } ref Branch name.
* @param { string } pattern Glob where `*` matches any run of characters.
* @returns { boolean }
*/
export function matchesBranchPattern(ref, pattern) {
const escaped = pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp(`^${escaped.join('.*')}$`).test(ref);
}
/**
* @param { { head: { ref: string, repo: { full_name: string } | null }, base: { ref: string, repo: { full_name: string } } } } pullRequest
* @param { string[] } [exemptions]
* @returns { ExemptRoute | undefined } The first matching route.
*/
export function findExemption(pullRequest, exemptions = REQUIRED_REVIEW_EXEMPTIONS) {
// A fork can name its branch anything, so the head must live in this repo.
if (pullRequest.head.repo?.full_name !== pullRequest.base.repo.full_name) return undefined;
return exemptions
.map(parseExemption)
.find(
(route) =>
matchesBranchPattern(pullRequest.head.ref, route.head) &&
matchesBranchPattern(pullRequest.base.ref, route.base),
);
}
/**
* Resolve the PR number from the triggering event.
*
* A workflow_run event carries no PR: the PR is looked up from the
* triggering run's head. Only an open PR whose head SHA is the run's head
* SHA counts; several PRs can share a head branch.
*
* @param { string } eventName
* @param { any } event Parsed GITHUB_EVENT_PATH payload.
* @param { string | undefined } pullRequestNumberEnv PULL_REQUEST_NUMBER (workflow_dispatch input).
* @returns { number }
* @returns { Promise<number | undefined> } undefined when the event has no open PR to evaluate.
*/
export function resolvePullRequestNumber(eventName, event, pullRequestNumberEnv) {
export async function resolvePullRequestNumber(eventName, event, pullRequestNumberEnv) {
if (event?.pull_request?.number) return event.pull_request.number;
if (eventName === 'workflow_run' && event?.workflow_run) {
const { head_sha: sha, head_branch: branch, head_repository: headRepository } = event.workflow_run;
const candidates = await listOpenPullRequestsByHead(headRepository.owner.login, branch);
return candidates.find((pullRequest) => pullRequest.head.sha === sha)?.number;
}
const parsed = parseInt(pullRequestNumberEnv ?? '');
if (Number.isNaN(parsed)) {
throw new Error(`Cannot resolve a PR number for event "${eventName}"`);
@ -88,6 +165,32 @@ export function collectApprovers(reviews) {
);
}
/**
* Return the team member who applied the active large-scale-change label.
* The current membership check makes the exemption expire when the actor
* leaves the team.
*
* @param { number } pullRequestNumber
* @param {{ labels?: Array<string | { name: string }> }} pullRequest
* @returns { Promise<string | undefined> }
*/
export async function findLargeScaleChangeExemption(pullRequestNumber, pullRequest) {
if (!readPrLabels(pullRequest).includes(LARGE_SCALE_CHANGE_LABEL)) return undefined;
const labelEvents = (await getPrEvents(pullRequestNumber)).filter(
(event) =>
['labeled', 'unlabeled'].includes(event.event) &&
event.label?.name === LARGE_SCALE_CHANGE_LABEL,
);
const latestEvent = labelEvents.at(-1);
if (latestEvent?.event !== 'labeled') return undefined;
const actor = latestEvent.actor?.login;
if (!actor) return undefined;
return (await isTeamMember(LARGE_SCALE_CHANGES_TEAM, actor)) ? actor : undefined;
}
/**
* @param { string[] } missingTeams Team handles without an approving member.
* @param { number } requiredCount Total number of required teams.
@ -160,18 +263,16 @@ export async function run() {
const eventName = process.env.GITHUB_EVENT_NAME ?? 'workflow_dispatch';
const event = getEventFromGithubEventPath();
const pullRequestNumber = resolvePullRequestNumber(
const pullRequestNumber = await resolvePullRequestNumber(
eventName,
event,
process.env.PULL_REQUEST_NUMBER,
);
const pullRequest = await getPullRequestById(pullRequestNumber);
if (pullRequest.base.ref !== TARGET_BRANCH) {
console.log(`PR #${pullRequestNumber} targets "${pullRequest.base.ref}", not "${TARGET_BRANCH}"; skipping.`);
if (pullRequestNumber === undefined) {
console.log(`No open PR for the ${eventName} head; nothing to evaluate.`);
return;
}
const pullRequest = await getPullRequestById(pullRequestNumber);
const statusSha = pullRequest.head.sha;
const targetUrl = statusTargetUrl();
@ -187,7 +288,24 @@ export async function run() {
/** @type { { state: 'success' | 'pending', description: string } } */
let status;
try {
status = await evaluateRequiredReviews(pullRequestNumber);
const exemption = findExemption(pullRequest);
const largeScaleChangeActor = exemption
? undefined
: await findLargeScaleChangeExemption(pullRequestNumber, pullRequest);
if (exemption) {
console.log(`PR #${pullRequestNumber} matches exempt route "${exemption.source}"; skipping the evaluation.`);
status = { state: 'success', description: `Exempt route: ${exemption.source}` };
} else if (largeScaleChangeActor) {
console.log(
`PR #${pullRequestNumber} has an authorized ${LARGE_SCALE_CHANGE_LABEL} label from ${largeScaleChangeActor}; skipping the evaluation.`,
);
status = {
state: 'success',
description: `Large-scale change exemption by @${largeScaleChangeActor}`,
};
} else {
status = await evaluateRequiredReviews(pullRequestNumber);
}
} catch (evaluationError) {
// Best effort: nicer than a stuck pending status. The pending status
// already blocks the merge if this write fails too.
@ -210,6 +328,8 @@ export async function run() {
});
}
export const main = run;
if (import.meta.url === `file://${process.argv[1]}`) {
await run();
await main();
}

View File

@ -15,19 +15,28 @@ let getPullRequestByIdImpl = async () => ({});
let getChangedFilesImpl = async () => new Set();
/** @type {(pullRequestNumber: number) => Promise<any[]>} */
let getPrReviewsImpl = async () => [];
/** @type {(pullRequestNumber: number) => Promise<any[]>} */
let getPrEventsImpl = async () => [];
/** @type {(teamSlug: string, username: string) => Promise<boolean>} */
let isTeamMemberImpl = async () => false;
/** @type {(sha: string, status: any) => Promise<void>} */
let setCommitStatusImpl = async () => {};
/** @type {(headOwner: string, headBranch: string) => Promise<any[]>} */
let listOpenPullRequestsByHeadImpl = async () => [];
mock.module('../github-helpers.mjs', {
namedExports: {
getEventFromGithubEventPath: () => eventImpl(),
getPullRequestById: (n) => getPullRequestByIdImpl(n),
getChangedFiles: (n) => getChangedFilesImpl(n),
getPrEvents: (n) => getPrEventsImpl(n),
getPrReviews: (n) => getPrReviewsImpl(n),
isTeamMember: (slug, username) => isTeamMemberImpl(slug, username),
setCommitStatus: (sha, status) => setCommitStatusImpl(sha, status),
listOpenPullRequestsByHead: (headOwner, headBranch) =>
listOpenPullRequestsByHeadImpl(headOwner, headBranch),
readPrLabels: (pullRequest) =>
(pullRequest.labels ?? []).map((label) => (typeof label === 'string' ? label : label.name)),
},
});
@ -46,28 +55,153 @@ mock.module('./owners.mjs', {
});
const {
REQUIRED_REVIEW_EXEMPTIONS,
LARGE_SCALE_CHANGE_LABEL,
LARGE_SCALE_CHANGES_TEAM,
STATUS_CONTEXT,
buildStatus,
collectApprovers,
findLargeScaleChangeExemption,
findExemption,
latestReviewStates,
matchesBranchPattern,
parseExemption,
resolvePullRequestNumber,
run,
} = await import('./required-reviews.mjs');
const REPO = 'n8n-io/n8n';
/** @param {string} head @param {string} base @param {string} [headRepo] */
function pullRequestFor(head, base, headRepo = REPO) {
return {
head: { ref: head, sha: 'head-sha', repo: { full_name: headRepo } },
base: { ref: base, repo: { full_name: REPO } },
};
}
describe('parseExemption', () => {
it('splits a "<head> -> <base>" route', () => {
assert.deepEqual(parseExemption('sync/master-to-3x -> 3.x'), {
head: 'sync/master-to-3x',
base: '3.x',
source: 'sync/master-to-3x -> 3.x',
});
});
it('treats a bare "<base>" as any head', () => {
assert.deepEqual(parseExemption('release/*'), { head: '*', base: 'release/*', source: 'release/*' });
});
it('rejects malformed routes', () => {
assert.throws(() => parseExemption('a -> b -> c'), /Invalid exemption/);
assert.throws(() => parseExemption(' -> 3.x'), /Invalid exemption/);
assert.throws(() => parseExemption('3.x -> '), /Invalid exemption/);
});
it('accepts every configured exemption', () => {
for (const entry of REQUIRED_REVIEW_EXEMPTIONS) parseExemption(entry);
});
});
describe('matchesBranchPattern', () => {
it('matches whole branch names', () => {
assert.equal(matchesBranchPattern('3.x', '3.x'), true);
assert.equal(matchesBranchPattern('3.xy', '3.x'), false);
assert.equal(matchesBranchPattern('v3.x', '3.x'), false);
});
it('treats "." as a literal', () => {
assert.equal(matchesBranchPattern('3ax', '3.x'), false);
});
it('expands "*" across path separators', () => {
assert.equal(matchesBranchPattern('release/2.39.5', 'release/*'), true);
assert.equal(matchesBranchPattern('release/2.39.5/hotfix', 'release/*'), true);
assert.equal(matchesBranchPattern('anything', '*'), true);
assert.equal(matchesBranchPattern('release-candidate/2.40.0', 'release/*'), false);
});
});
describe('findExemption', () => {
const exemptions = ['sync/master-to-3x -> 3.x', 'release/*'];
it('finds the route for a matching head and base', () => {
const route = findExemption(pullRequestFor('sync/master-to-3x', '3.x'), exemptions);
assert.equal(route?.source, 'sync/master-to-3x -> 3.x');
});
it('returns nothing when only the base matches', () => {
assert.equal(findExemption(pullRequestFor('feature/x', '3.x'), exemptions), undefined);
});
it('returns nothing when only the head matches', () => {
assert.equal(findExemption(pullRequestFor('sync/master-to-3x', 'master'), exemptions), undefined);
});
it('matches a bare base route for any head', () => {
const route = findExemption(pullRequestFor('feature/x', 'release/2.39.5'), exemptions);
assert.equal(route?.source, 'release/*');
});
it('ignores heads from another repository', () => {
const fromFork = pullRequestFor('sync/master-to-3x', '3.x', 'someone/n8n');
assert.equal(findExemption(fromFork, exemptions), undefined);
});
it('ignores heads whose repository is gone', () => {
const pullRequest = pullRequestFor('sync/master-to-3x', '3.x');
pullRequest.head.repo = null;
assert.equal(findExemption(pullRequest, exemptions), undefined);
});
});
describe('resolvePullRequestNumber', () => {
it('reads the PR number from pull_request payloads', () => {
/** A workflow_run payload for a run on `owner/branch` at `sha`. */
function workflowRunEvent(owner, branch, sha) {
return { workflow_run: { head_sha: sha, head_branch: branch, head_repository: { owner: { login: owner } } } };
}
beforeEach(() => {
listOpenPullRequestsByHeadImpl = async () => [];
});
it('reads the PR number from pull_request payloads', async () => {
assert.equal(
resolvePullRequestNumber('pull_request', { pull_request: { number: 42 } }, undefined),
await resolvePullRequestNumber('pull_request_target', { pull_request: { number: 42 } }, undefined),
42,
);
});
it('falls back to the PULL_REQUEST_NUMBER env value for workflow_dispatch', () => {
assert.equal(resolvePullRequestNumber('workflow_dispatch', {}, '7'), 7);
it('falls back to the PULL_REQUEST_NUMBER env value for workflow_dispatch', async () => {
assert.equal(await resolvePullRequestNumber('workflow_dispatch', {}, '7'), 7);
});
it('throws when no source yields a PR number', () => {
assert.throws(() => resolvePullRequestNumber('workflow_dispatch', {}, undefined), /Cannot resolve/);
it('rejects when no source yields a PR number', async () => {
await assert.rejects(resolvePullRequestNumber('workflow_dispatch', {}, undefined), /Cannot resolve/);
});
it('looks up the open PR for a workflow_run head', async () => {
const listOpenPullRequestsByHead = mock.fn(async () => [
{ number: 11, head: { sha: 'old-sha' } },
{ number: 12, head: { sha: 'run-sha' } },
]);
listOpenPullRequestsByHeadImpl = listOpenPullRequestsByHead;
assert.equal(
await resolvePullRequestNumber('workflow_run', workflowRunEvent('forker', 'fix/x', 'run-sha'), undefined),
12,
);
assert.deepEqual(listOpenPullRequestsByHead.mock.calls[0].arguments, ['forker', 'fix/x']);
});
it('returns undefined when no open PR has the workflow_run head SHA', async () => {
listOpenPullRequestsByHeadImpl = async () => [{ number: 11, head: { sha: 'old-sha' } }];
assert.equal(
await resolvePullRequestNumber('workflow_run', workflowRunEvent('forker', 'fix/x', 'run-sha'), undefined),
undefined,
);
});
});
@ -120,6 +254,116 @@ describe('collectApprovers', () => {
});
});
describe('findLargeScaleChangeExemption', () => {
beforeEach(() => {
getPrEventsImpl = async () => [];
isTeamMemberImpl = async () => false;
});
it('returns the team member who applied the active label', async () => {
getPrEventsImpl = async () => [
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'ana' } },
];
isTeamMemberImpl = async (slug, username) =>
slug === LARGE_SCALE_CHANGES_TEAM && username === 'ana';
assert.equal(
await findLargeScaleChangeExemption(42, { labels: [{ name: LARGE_SCALE_CHANGE_LABEL }] }),
'ana',
);
});
it('rejects a label applied by someone outside the team', async () => {
getPrEventsImpl = async () => [
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'outsider' } },
];
assert.equal(
await findLargeScaleChangeExemption(42, { labels: [{ name: LARGE_SCALE_CHANGE_LABEL }] }),
undefined,
);
});
it('rejects the exemption when the label event is not available', async () => {
assert.equal(
await findLargeScaleChangeExemption(42, { labels: [{ name: LARGE_SCALE_CHANGE_LABEL }] }),
undefined,
);
});
it('rejects the exemption when the label event has no actor', async () => {
getPrEventsImpl = async () => [
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: null },
];
const isTeamMember = mock.fn(async () => true);
isTeamMemberImpl = isTeamMember;
assert.equal(
await findLargeScaleChangeExemption(42, { labels: [{ name: LARGE_SCALE_CHANGE_LABEL }] }),
undefined,
);
assert.equal(isTeamMember.mock.calls.length, 0);
});
it('ignores events for other labels', async () => {
getPrEventsImpl = async () => [
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'ana' } },
{ event: 'labeled', label: { name: 'bug' }, actor: { login: 'outsider' } },
{ event: 'unlabeled', label: { name: 'bug' }, actor: { login: 'outsider' } },
];
isTeamMemberImpl = async (slug, username) =>
slug === LARGE_SCALE_CHANGES_TEAM && username === 'ana';
assert.equal(
await findLargeScaleChangeExemption(42, { labels: [{ name: LARGE_SCALE_CHANGE_LABEL }] }),
'ana',
);
});
it('uses the actor from the latest label application', async () => {
getPrEventsImpl = async () => [
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'ana' } },
{
event: 'unlabeled',
label: { name: LARGE_SCALE_CHANGE_LABEL },
actor: { login: 'outsider' },
},
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'outsider' } },
];
isTeamMemberImpl = async (slug, username) => username === 'ana';
assert.equal(
await findLargeScaleChangeExemption(42, { labels: [{ name: LARGE_SCALE_CHANGE_LABEL }] }),
undefined,
);
});
it('rejects the exemption when the latest event removed the label', async () => {
getPrEventsImpl = async () => [
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'ana' } },
{
event: 'unlabeled',
label: { name: LARGE_SCALE_CHANGE_LABEL },
actor: { login: 'outsider' },
},
];
isTeamMemberImpl = async (slug, username) => username === 'ana';
assert.equal(
await findLargeScaleChangeExemption(42, { labels: [{ name: LARGE_SCALE_CHANGE_LABEL }] }),
undefined,
);
});
it('does not inspect events when the label is absent', async () => {
const getPrEvents = mock.fn(async () => []);
getPrEventsImpl = getPrEvents;
assert.equal(await findLargeScaleChangeExemption(42, { labels: [] }), undefined);
assert.equal(getPrEvents.mock.calls.length, 0);
});
});
describe('buildStatus', () => {
it('succeeds when nothing is required', () => {
const status = buildStatus([], 0);
@ -152,12 +396,12 @@ describe('run', () => {
eventImpl = () => ({ pull_request: { number: 42 } });
getPullRequestByIdImpl = async () => ({
base: { ref: 'master' },
head: { sha: 'head-sha' },
...pullRequestFor('feature/x', 'master'),
user: { login: 'author' },
});
getChangedFilesImpl = async () => new Set(['a.ts']);
getPrReviewsImpl = async () => [];
getPrEventsImpl = async () => [];
isTeamMemberImpl = async () => false;
parseOwnersFileImpl = () => [];
resolveRequiredTeamsImpl = () => new Map();
@ -267,15 +511,91 @@ describe('run', () => {
assert.equal(status.state, 'pending');
});
it('skips PRs that do not target master without setting a status', async () => {
getPullRequestByIdImpl = async () => ({
base: { ref: 'release-candidate/2.9.x' },
head: { sha: 'head-sha' },
it('writes no status for a workflow_run head without an open PR', async () => {
process.env.GITHUB_EVENT_NAME = 'workflow_run';
eventImpl = () => ({
workflow_run: { head_sha: 'gone-sha', head_branch: 'fix/x', head_repository: { owner: { login: 'forker' } } },
});
listOpenPullRequestsByHeadImpl = async () => [];
await run();
assert.equal(setCommitStatus.mock.calls.length, 0);
});
it('evaluates PRs into branches other than master', async () => {
getPullRequestByIdImpl = async () => pullRequestFor('feature/x', 'release-candidate/2.9.x');
resolveRequiredTeamsImpl = () => new Map([['@n8n-io/qa-dx', ['a.ts']]]);
await run();
const [, status] = setCommitStatus.mock.calls.at(-1).arguments;
assert.equal(status.state, 'pending');
assert.match(status.description, /Waiting for approval from: qa-dx/);
});
it('reports success without evaluating a PR on an exempt route', async () => {
getPullRequestByIdImpl = async () => pullRequestFor('sync/master-to-3x', '3.x');
const getChangedFiles = mock.fn(async () => new Set(['a.ts']));
getChangedFilesImpl = getChangedFiles;
resolveRequiredTeamsImpl = () => new Map([['@n8n-io/qa-dx', ['a.ts']]]);
await run();
assert.equal(getChangedFiles.mock.calls.length, 0);
assert.equal(setCommitStatus.mock.calls.length, 2);
assert.equal(setCommitStatus.mock.calls[0].arguments[1].state, 'pending');
const [, status] = setCommitStatus.mock.calls[1].arguments;
assert.equal(status.state, 'success');
assert.equal(status.description, 'Exempt route: sync/master-to-3x -> 3.x');
});
it('reports success without evaluating an authorized large-scale change', async () => {
getPullRequestByIdImpl = async () => ({
...pullRequestFor('feature/x', 'master'),
labels: [{ name: LARGE_SCALE_CHANGE_LABEL }],
});
getPrEventsImpl = async () => [
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'ana' } },
];
isTeamMemberImpl = async (slug, username) =>
slug === LARGE_SCALE_CHANGES_TEAM && username === 'ana';
const getChangedFiles = mock.fn(async () => new Set(['a.ts']));
getChangedFilesImpl = getChangedFiles;
await run();
assert.equal(getChangedFiles.mock.calls.length, 0);
const [, status] = setCommitStatus.mock.calls.at(-1).arguments;
assert.equal(status.state, 'success');
assert.equal(status.description, 'Large-scale change exemption by @ana');
});
it('evaluates a large-scale label applied outside the team', async () => {
getPullRequestByIdImpl = async () => ({
...pullRequestFor('feature/x', 'master'),
labels: [{ name: LARGE_SCALE_CHANGE_LABEL }],
});
getPrEventsImpl = async () => [
{ event: 'labeled', label: { name: LARGE_SCALE_CHANGE_LABEL }, actor: { login: 'outsider' } },
];
resolveRequiredTeamsImpl = () => new Map([['@n8n-io/qa-dx', ['a.ts']]]);
await run();
const [, status] = setCommitStatus.mock.calls.at(-1).arguments;
assert.equal(status.state, 'pending');
assert.match(status.description, /Waiting for approval from: qa-dx/);
});
it('does not exempt a fork branch that is named like an exempt route', async () => {
getPullRequestByIdImpl = async () => pullRequestFor('sync/master-to-3x', '3.x', 'someone/n8n');
resolveRequiredTeamsImpl = () => new Map([['@n8n-io/qa-dx', ['a.ts']]]);
await run();
const [, status] = setCommitStatus.mock.calls.at(-1).arguments;
assert.equal(status.state, 'pending');
});
});

View File

@ -31,6 +31,38 @@ import {
*/
const BOT_MARKER = '<!-- pr-recommendations -->';
const REQUIRED_FILES_DISPLAY_LIMIT = 5;
/**
* @param { string } value
* @returns { string }
*/
function escapeHtml(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('|', '&#124;');
}
/**
* @param { string[] } files
* @returns { string }
*/
function formatRequiredFiles(files) {
const displayedFiles = files
.slice(0, REQUIRED_FILES_DISPLAY_LIMIT)
.map((file) => `<code>${escapeHtml(file)}</code>`);
const omittedFileCount = files.length - displayedFiles.length;
if (omittedFileCount > 0) {
displayedFiles.push(
`<em>${omittedFileCount} more file${omittedFileCount === 1 ? '' : 's'}</em>`,
);
}
return displayedFiles.join('<br>');
}
function createEmptyLineStats() {
return {
@ -210,9 +242,11 @@ export function buildRequiredReviewsSection(requiredTeamFiles) {
'',
'Some changed files have a `required` owner in `OWNERS`. A member of each of these teams must approve this PR before it can merge:',
'',
'| Team | Files |',
'| --- | ---: |',
...[...requiredTeamFiles].map(([team, files]) => `| ${team} | ${files.length} |`),
'| Team | Files owned | Owned files |',
'| --- | ---: | --- |',
...[...requiredTeamFiles].map(
([team, files]) => `| ${team} | ${files.length} | ${formatRequiredFiles(files)} |`,
),
'',
`Request a review from the team${plural ? 's' : ''} — GitHub assigns reviewers according to the team's review settings. The \`Auto-assign reviewers\` label does this for all owning teams.`,
].join('\n');
@ -282,7 +316,11 @@ export async function run(pullRequestNumber) {
await postOrUpdateComment(pullRequestNumber, body, BOT_MARKER);
}
if (import.meta.url === `file://${process.argv[1]}`) {
export async function main() {
const pullRequestNumber = parseInt(ensureEnvVar('PULL_REQUEST_NUMBER'));
await run(pullRequestNumber);
}
if (import.meta.url === `file://${process.argv[1]}`) {
await main();
}

View File

@ -217,7 +217,7 @@ describe('computeLineStats', () => {
['*.snap extension', 'src/foo.snap'],
['fixtures/ directory', 'src/fixtures/data.json'],
['__mocks__/ directory', 'src/__mocks__/axios.ts'],
['packages/testing/**', 'packages/testing/playwright/spec.ts'],
['packages/quality/**', 'packages/quality/testing/playwright/spec.ts'],
['dot-directory *.test.mjs', '.github/scripts/owners/owners.test.mjs'],
]) {
it(`classifies ${label} as testFiles`, () => {
@ -322,7 +322,7 @@ describe('buildRequiredReviewsSection', () => {
assert.equal(buildRequiredReviewsSection(new Map()), null);
});
it('lists each required team with its file count', () => {
it('lists each required team with its file count and owned files', () => {
const section = buildRequiredReviewsSection(
new Map([
['@n8n-io/qa-dx', ['a.yml', 'b.yml']],
@ -331,8 +331,30 @@ describe('buildRequiredReviewsSection', () => {
);
assert.match(section, /### Required reviews/);
assert.match(section, /\| @n8n-io\/qa-dx \| 2 \|/);
assert.match(section, /\| @n8n-io\/migrations-review \| 1 \|/);
assert.match(
section,
/\| @n8n-io\/qa-dx \| 2 \| <code>a\.yml<\/code><br><code>b\.yml<\/code> \|/,
);
assert.match(section, /\| @n8n-io\/migrations-review \| 1 \| <code>m\.ts<\/code> \|/);
});
it('shows no more than five files for each team', () => {
const section = buildRequiredReviewsSection(
new Map([['@n8n-io/qa-dx', ['a.yml', 'b.yml', 'c.yml', 'd.yml', 'e.yml', 'f.yml', 'g.yml']]]),
);
assert.match(section, /<code>a\.yml<\/code>/);
assert.match(section, /<code>e\.yml<\/code>/);
assert.doesNotMatch(section, /<code>f\.yml<\/code>/);
assert.match(section, /<em>2 more files<\/em>/);
});
it('escapes file paths before adding them to the table', () => {
const section = buildRequiredReviewsSection(
new Map([['@n8n-io/qa-dx', ['packages/a|b/<script>.ts']]]),
);
assert.match(section, /<code>packages\/a&#124;b\/&lt;script&gt;\.ts<\/code>/);
});
it('prompts to request review from the team, plural when several teams are required', () => {
@ -447,7 +469,7 @@ describe('run', () => {
const body = postOrUpdateComment.mock.calls[0].arguments[1];
assert.match(body, /### Required reviews/);
assert.match(body, /\| @n8n-io\/qa-dx \| 1 \|/);
assert.match(body, /\| @n8n-io\/qa-dx \| 1 \| <code>\.github\/workflows\/ci\.yml<\/code> \|/);
assert.match(body, /Request a review from the team —/);
});
});

View File

@ -2,11 +2,11 @@
"name": "workflow-scripts",
"engines": {
"node": ">=24.0.0",
"pnpm": ">=12.3.4"
"pnpm": ">=12.4.2"
},
"packageManager": "pnpm@12.3.4",
"packageManager": "pnpm@12.4.2",
"scripts": {
"test": "node --test --experimental-test-module-mocks ./*.test.mjs ./docker/*.test.mjs ./owners/*.test.mjs ./quality/*.test.mjs ./slack/*.test.mjs ./stale/*.test.mjs ../../scripts/*.test.mjs ../../scripts/licenses/*.test.mjs ../../scripts/mutation-health/*.test.mjs ../../.devcontainer/codespaces/*.test.mjs",
"test": "node --test --experimental-test-module-mocks ./*.test.mjs ./docker/*.test.mjs ./owners/*.test.mjs ./quality/*.test.mjs ./slack/*.test.mjs ./stale/*.test.mjs ../../scripts/*.test.mjs ../../scripts/codespace-preview/*.test.mjs ../../scripts/licenses/*.test.mjs ../../scripts/mutation-health/*.test.mjs ../../.devcontainer/codespaces/*.test.mjs",
"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",
@ -16,12 +16,8 @@
"dependencies": {
"@actions/github": "9.0.0",
"@cyclonedx/cdxgen": "12.4.0",
"@octokit/core": "7.0.6",
"ajv": "8.20.0",
"conventional-changelog": "7.2.0",
"debug": "4.4.3",
"glob": "13.0.6",
"json-with-bigint": "3.5.11",
"minimatch": "10.2.4",
"semver": "7.7.4",
"tempfile": "6.0.1",

View File

@ -7,96 +7,153 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.3.4
version: 12.3.4
specifier: 12.4.2
version: 12.4.2
packages:
'@pnpm/exe.darwin-arm64@12.3.4':
resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==}
'@pnpm/exe.android-arm64@12.4.2':
resolution: {integrity: sha512-E255MbcQ0V1577M2BV0ajWuP7KmTPYA0jqZnk3rK6Lq3kTvZWulMMb7iqRmnLsQbyWTkMEB2CfyM70loVDA8xg==}
cpu: [arm64]
os: [android]
'@pnpm/exe.android-x64@12.4.2':
resolution: {integrity: sha512-J1pSeCUwuKxMG70ZzpWn8JzElxiEa8NN/3N0SlZRtU2xbztChaJCKF3HaBNIj9yPfeofWzJo/lwNvDDjraQuZw==}
cpu: [x64]
os: [android]
'@pnpm/exe.darwin-arm64@12.4.2':
resolution: {integrity: sha512-A0WDo8iErfZBXgrLseQxw8i8Y9ctUpOEl/Uu+cubnTzpD8tT9ykIB548L8YTM2WD4OS+ZOHSxy8aGZcvKq8PaQ==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.3.4':
resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==}
'@pnpm/exe.darwin-x64@12.4.2':
resolution: {integrity: sha512-MSgJdovBWHcb5DEOvfPH9yNi/T5O1Xa4ess+E1ESGo/5yuty7S4JoiIjami+fsNXfnoQMlwsMUqYU4zAvBcNCw==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.linux-arm64-musl@12.3.4':
resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==}
'@pnpm/exe.freebsd-x64@12.4.2':
resolution: {integrity: sha512-h2YumlQSNvgbRPv+RXwABohX65f9bOBZn+jMIt7bFDISZPCzQ+Nvpt6Awbp4ip5PwQgYxbu5iREJ1fHE39Fm8A==}
cpu: [x64]
os: [freebsd]
'@pnpm/exe.linux-arm64-musl@12.4.2':
resolution: {integrity: sha512-LwSEtSEDTv6S51YLs3YvSkPyun/QmfMic1UGICUkPWFu6ByP43RdMlkKvmVkfGhAYCpnxO057vrmyJqtfZrPCA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.3.4':
resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==}
'@pnpm/exe.linux-arm64@12.4.2':
resolution: {integrity: sha512-2dSiDXyhx+RTHsewxex8f/jVjqQXWJ2oow4kCVHEWdZKeBpgMxvZ6fHkTAUBJXgqhbKIDHvuNlZBP7gJfUWL5A==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.3.4':
resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==}
'@pnpm/exe.linux-ppc64@12.4.2':
resolution: {integrity: sha512-8Itc+jQk+MTz04LS9D1RcH+VctAWmzM4l1cJQ+Sx7pAJNo7EKHdRMbC0Tok1jyQ7eEHNJZD5EleYneZqWfZM+g==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-riscv64@12.4.2':
resolution: {integrity: sha512-hleOeqhTVpH+z9RVMGnxvU4ZnrkBUClWjCbHD9u6kwyqhGSpevoU1wTGish+CBRhmIgMAy9pAfFpqhbAKOKNfw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-s390x@12.4.2':
resolution: {integrity: sha512-LAsQRRdP9aToENR6dtcIJ9l+e1zMYOX0tQcLGpRyLPVBQcYRLlvAPcmDshsiIHQjp05SDa9FI0czX1ZQ1a7a/A==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.4.2':
resolution: {integrity: sha512-kzfzH2/0BWdTABK14Yj5a1xsdkTEQUp2eXEPNakaD9jKL025lq3hyaKHIz/gIZaPDMe/1bFFK/En4ztFlbBJxw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.3.4':
resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==}
'@pnpm/exe.linux-x64@12.4.2':
resolution: {integrity: sha512-/pbt0UVTa8NMDhzOWLQRfZ6G9ROKXlJPZtx845BqyWfc7hCrWXhTLBd70yO2y8+E+IWN3oHM1s/JsIQNGk1yvg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.3.4':
resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==}
'@pnpm/exe.win32-arm64@12.4.2':
resolution: {integrity: sha512-PsW19e4dAUNpZ0cS9flaxFuAmpt2dKlH/Vvi8TZ4qyJjjQzue/CEsWm+6wKVP4CJ7IT8RdL4qh+soOPWIgF2Zw==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.3.4':
resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==}
'@pnpm/exe.win32-x64@12.4.2':
resolution: {integrity: sha512-+xGoeE0g55ztWvl8i5QqdmNfW3nIrTVcoQrNshEOwxthm9Ag48oXton3uxOA3/SANyz5AXexEjj2KO20NnOrEw==}
cpu: [x64]
os: [win32]
pnpm@12.3.4:
resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
pnpm@12.4.2:
resolution: {integrity: sha512-CK3GYTGAJ1x8ntraOdzwjJxhrU5+rzMKTzRh8QKw+QdCNFTRF/mOctR/7wYWBwZE17/8lzpqV/UJCm18NosHyQ==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
'@pnpm/exe.darwin-arm64@12.3.4':
'@pnpm/exe.android-arm64@12.4.2':
optional: true
'@pnpm/exe.darwin-x64@12.3.4':
'@pnpm/exe.android-x64@12.4.2':
optional: true
'@pnpm/exe.linux-arm64-musl@12.3.4':
'@pnpm/exe.darwin-arm64@12.4.2':
optional: true
'@pnpm/exe.linux-arm64@12.3.4':
'@pnpm/exe.darwin-x64@12.4.2':
optional: true
'@pnpm/exe.linux-x64-musl@12.3.4':
'@pnpm/exe.freebsd-x64@12.4.2':
optional: true
'@pnpm/exe.linux-x64@12.3.4':
'@pnpm/exe.linux-arm64-musl@12.4.2':
optional: true
'@pnpm/exe.win32-arm64@12.3.4':
'@pnpm/exe.linux-arm64@12.4.2':
optional: true
'@pnpm/exe.win32-x64@12.3.4':
'@pnpm/exe.linux-ppc64@12.4.2':
optional: true
pnpm@12.3.4:
'@pnpm/exe.linux-riscv64@12.4.2':
optional: true
'@pnpm/exe.linux-s390x@12.4.2':
optional: true
'@pnpm/exe.linux-x64-musl@12.4.2':
optional: true
'@pnpm/exe.linux-x64@12.4.2':
optional: true
'@pnpm/exe.win32-arm64@12.4.2':
optional: true
'@pnpm/exe.win32-x64@12.4.2':
optional: true
pnpm@12.4.2:
optionalDependencies:
'@pnpm/exe.darwin-arm64': 12.3.4
'@pnpm/exe.darwin-x64': 12.3.4
'@pnpm/exe.linux-arm64': 12.3.4
'@pnpm/exe.linux-arm64-musl': 12.3.4
'@pnpm/exe.linux-x64': 12.3.4
'@pnpm/exe.linux-x64-musl': 12.3.4
'@pnpm/exe.win32-arm64': 12.3.4
'@pnpm/exe.win32-x64': 12.3.4
'@pnpm/exe.android-arm64': 12.4.2
'@pnpm/exe.android-x64': 12.4.2
'@pnpm/exe.darwin-arm64': 12.4.2
'@pnpm/exe.darwin-x64': 12.4.2
'@pnpm/exe.freebsd-x64': 12.4.2
'@pnpm/exe.linux-arm64': 12.4.2
'@pnpm/exe.linux-arm64-musl': 12.4.2
'@pnpm/exe.linux-ppc64': 12.4.2
'@pnpm/exe.linux-riscv64': 12.4.2
'@pnpm/exe.linux-s390x': 12.4.2
'@pnpm/exe.linux-x64': 12.4.2
'@pnpm/exe.linux-x64-musl': 12.4.2
'@pnpm/exe.win32-arm64': 12.4.2
'@pnpm/exe.win32-x64': 12.4.2
---
lockfileVersion: '9.0'
@ -115,24 +172,12 @@ importers:
'@cyclonedx/cdxgen':
specifier: 12.4.0
version: 12.4.0
'@octokit/core':
specifier: 7.0.6
version: 7.0.6
ajv:
specifier: 8.20.0
version: 8.20.0
conventional-changelog:
specifier: 7.2.0
version: 7.2.0(conventional-commits-filter@5.0.0)
debug:
specifier: 4.4.3
version: 4.4.3
glob:
specifier: 13.0.6
version: 13.0.6
json-with-bigint:
specifier: 3.5.11
version: 3.5.11
minimatch:
specifier: 10.2.4
version: 10.2.4

View File

@ -12,34 +12,15 @@
* 1 – PR exceeds the limit with no valid override
*/
import { minimatch } from 'minimatch';
import { initGithub, getEventFromGithubEventPath } from '../github-helpers.mjs';
import { matchesGlob } from '../glob.mjs';
import { isTestFile, TEST_PATTERNS } from '../test-files.mjs';
export { isTestFile, TEST_PATTERNS };
export const SIZE_LIMIT = 1000;
export const OVERRIDE_COMMAND = '/size-limit-override';
export const TEST_PATTERNS = [
// Test files (by extension)
'**/*.test.ts',
'**/*.test.js',
'**/*.test.mjs',
'**/*.spec.ts',
'**/*.spec.js',
'**/*.spec.mjs',
// Test directories
'**/test/**',
'**/tests/**',
'**/__tests__/**',
// Snapshots
'**/__snapshots__/**',
'**/*.snap',
// Fixtures and mocks
'**/fixtures/**',
'**/__mocks__/**',
// Dedicated testing package
'packages/testing/**',
];
export const MISC_PATTERNS = [
// Lock file (can produce massive diffs on dependency changes)
'pnpm-lock.yaml',
@ -49,10 +30,6 @@ export const MISC_PATTERNS = [
export const EXCLUDE_PATTERNS = [...TEST_PATTERNS, ...MISC_PATTERNS];
// Without `dot`, `**` refuses dot segments, so nothing under `.github/`
// would ever match a pattern.
const MATCH_OPTIONS = { dot: true };
/**
* Categorize a changed file for line statistics.
*
@ -60,8 +37,8 @@ const MATCH_OPTIONS = { dot: true };
* @returns { 'testFiles' | 'misc' | 'sourceCode' }
*/
export function categorizeFile(filename) {
if (TEST_PATTERNS.some((pattern) => minimatch(filename, pattern, MATCH_OPTIONS))) return 'testFiles';
if (MISC_PATTERNS.some((pattern) => minimatch(filename, pattern, MATCH_OPTIONS))) return 'misc';
if (isTestFile(filename)) return 'testFiles';
if (MISC_PATTERNS.some((pattern) => matchesGlob(filename, pattern))) return 'misc';
return 'sourceCode';
}
@ -102,11 +79,11 @@ export async function hasValidOverride(comments, getPermission) {
*/
export function countFilteredAdditions(files, excludePatterns) {
return files
.filter((file) => !excludePatterns.some((pattern) => minimatch(file.filename, pattern, MATCH_OPTIONS)))
.filter((file) => !excludePatterns.some((pattern) => matchesGlob(file.filename, pattern)))
.reduce((sum, file) => sum + file.additions, 0);
}
async function main() {
export async function main() {
const event = getEventFromGithubEventPath();
const pr = event.pull_request;
const { octokit, owner, repo } = initGithub();
@ -173,7 +150,7 @@ async function main() {
console.log(
`::error::PR adds ${additions.toLocaleString()} lines (test files excluded), exceeding the ${SIZE_LIMIT.toLocaleString()}-line limit. Reduce PR size or ask a maintainer to comment \`${OVERRIDE_COMMAND}\`.`,
);
process.exit(1);
throw new Error(`PR exceeds the ${SIZE_LIMIT.toLocaleString()}-line size limit`);
} else {
if (botComment) {
await octokit.rest.issues.deleteComment({

View File

@ -203,14 +203,21 @@ describe('countFilteredAdditions', () => {
assert.equal(countFilteredAdditions(files, EXCLUDE_PATTERNS), 50);
});
it('applies EXCLUDE_PATTERNS to packages/testing and pnpm-lock.yaml', () => {
it('excludes former testing workspaces but counts benchmark CLI source', () => {
const files = [
{ filename: 'packages/cli/src/service.ts', additions: 50 },
{ filename: 'packages/testing/playwright/tests/workflow.spec.ts', additions: 100 },
{ filename: 'packages/testing/playwright/pages/CanvasPage.ts', additions: 100 },
{ filename: 'packages/quality/testing/playwright/tests/workflow.spec.ts', additions: 100 },
{ filename: 'packages/quality/testing/playwright/pages/CanvasPage.ts', additions: 100 },
{ filename: 'packages/quality/policy/code-health/src/cli.ts', additions: 100 },
{ filename: 'packages/quality/environments/containers/service-stack.ts', additions: 100 },
{
filename: 'packages/quality/efficiency/microbenchmarks/benchmarks/bench-options.ts',
additions: 100,
},
{ filename: 'packages/quality/efficiency/scale/benchmark/src/commands/list.ts', additions: 100 },
{ filename: 'pnpm-lock.yaml', additions: 500 },
];
assert.equal(countFilteredAdditions(files, EXCLUDE_PATTERNS), 50);
assert.equal(countFilteredAdditions(files, EXCLUDE_PATTERNS), 150);
});
it('applies EXCLUDE_PATTERNS to markdown files', () => {

View File

@ -5,7 +5,9 @@
"description": "JSON schema for cubic.yaml (and the legacy .cubic.yaml) so editors can validate and autocomplete repository settings.",
"type": "object",
"additionalProperties": true,
"required": ["version"],
"required": [
"version"
],
"properties": {
"version": {
"const": 1,
@ -16,13 +18,26 @@
"description": "AI review behavior and optional YAML-defined custom agents.",
"additionalProperties": false,
"properties": {
"senior_reviewers": {
"type": "array",
"maxItems": 5,
"items": {
"type": "string",
"pattern": "^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$"
},
"description": "GitHub usernames to learn review patterns from. Overrides dashboard selections; an empty list disables senior reviewer learning."
},
"enabled": {
"type": "boolean",
"description": "Master toggle for AI reviews on this repository."
},
"sensitivity": {
"type": "string",
"enum": ["low", "medium", "high"],
"enum": [
"low",
"medium",
"high"
],
"description": "Controls how strictly Cubic flags issues."
},
"incremental_commits": {
@ -63,12 +78,21 @@
},
"auto_approve_behavior": {
"type": "string",
"enum": ["disabled", "shadow", "live"],
"enum": [
"disabled",
"shadow",
"live"
],
"description": "Controls whether auto-approval is disabled, simulated in shadow mode, or submitted live to GitHub."
},
"auto_approve": {
"type": "string",
"enum": ["disabled", "always", "low_risk_only", "custom"],
"enum": [
"disabled",
"always",
"low_risk_only",
"custom"
],
"description": "Auto-approve mode: disabled, always approve clean runs, use low-risk heuristics, or use a custom prompt."
},
"auto_approve_custom_prompt": {
@ -80,12 +104,20 @@
},
"ultrareview": {
"type": "string",
"enum": ["disabled", "manual", "automatic"],
"enum": [
"disabled",
"manual",
"automatic"
],
"description": "Master switch for ultrareviews. disabled blocks all ultrareviews (manual and automatic), manual allows only manual triggers (the default), automatic also enables the automatic triggers."
},
"auto_ultrareview": {
"type": "string",
"enum": ["disabled", "high_risk_only", "custom"],
"enum": [
"disabled",
"high_risk_only",
"custom"
],
"description": "Auto-trigger ultrareview mode: disabled, let cubic decide per PR using built-in high-risk criteria, or use a custom prompt."
},
"auto_ultrareview_custom_prompt": {
@ -159,7 +191,10 @@
},
"coding_agent_provider": {
"type": "string",
"enum": ["cubic", "cursor_cloud_agent"],
"enum": [
"cubic",
"cursor_cloud_agent"
],
"description": "Which coding agent applies automatic fixes. 'cubic' uses cubic's built-in agent; 'cursor_cloud_agent' dispatches a Cursor cloud agent (requires Cursor configuration)."
}
}
@ -222,8 +257,20 @@
},
"exclude_external_contributors": {
"type": "boolean",
"default": false,
"description": "If true, public-repository PRs from external contributors are reviewed but never auto-approved."
"default": true,
"description": "If true, public-repository PRs from external contributors, or from authors cubic cannot verify, are reviewed but never auto-approved."
},
"allow_open_issues": {
"type": "string",
"enum": [
"none",
"p3",
"p2_and_p3",
"p1_to_p3",
"all"
],
"default": "none",
"description": "Open cubic findings that don't block auto-approval, by priority. none blocks on any open finding; p3, p2_and_p3, and p1_to_p3 allow that range; all allows every open finding. A finding without a recorded priority blocks unless this is all. Every other auto-approval rule still applies."
},
"exclude_authors": {
"$ref": "#/$defs/globList",
@ -271,13 +318,19 @@
"type": "object",
"description": "Definition of a YAML-managed custom agent.",
"additionalProperties": false,
"required": ["name"],
"required": [
"name"
],
"anyOf": [
{
"required": ["description"]
"required": [
"description"
]
},
{
"required": ["file_paths"]
"required": [
"file_paths"
]
}
],
"properties": {

View File

@ -32,10 +32,9 @@ export async function run({ octokit, owner, repo, prNumber, commenter, commentId
});
if (!['admin', 'write', 'maintain'].includes(perm.permission)) {
console.log(
`::error::@${commenter} does not have permission to override the PR size limit (requires write access).`,
throw new Error(
`@${commenter} does not have permission to override the PR size limit (requires write access).`,
);
process.exit(1);
}
const { data: pr } = await octokit.rest.pulls.get({
@ -56,10 +55,9 @@ export async function run({ octokit, owner, repo, prNumber, commenter, commentId
});
if (check_runs.length === 0) {
console.log(
`::error::No '${CHECK_NAME}' check run found for ${headSha}. Push a new commit to trigger it.`,
throw new Error(
`No '${CHECK_NAME}' check run found for ${headSha}. Push a new commit to trigger it.`,
);
process.exit(1);
}
await octokit.rest.checks.rerequestRun({
@ -78,7 +76,7 @@ export async function run({ octokit, owner, repo, prNumber, commenter, commentId
console.log(`Re-requested '${CHECK_NAME}' check run (${check_runs[0].id}) for ${headSha}`);
}
async function main() {
export async function main() {
const event = getEventFromGithubEventPath();
const { octokit, owner, repo } = initGithub();

View File

@ -12,7 +12,7 @@
* why only a rewrite breaks this.
*
* The cost of merging is a merge commit per run, plus already-published fixes staying in the
* branch's log (the rebase used to drop them as empty). Neither reaches anything downstream:
* branch's log. Neither reaches anything downstream:
* a bundle is published as one squashed, deliberately obfuscated commit, and the squash is
* taken from the tree, not the history. For a list of what a bundle actually carries, read
* the fix PRs merged into the branch since the last cut, not `base..bundle`.
@ -26,17 +26,13 @@
* of being re-litigated on every later run. Contrast `sync-master-to-3x.mjs`, which
* auto-resolves mechanical files and opens a conflict PR.
*
* PUBLISHED HISTORY ONLY: the base is synced in only once the public repo already has it. A cut
* squash-merges the bundle branch into private master as `chore: Bundle …`, and the hourly
* public→private mirror then DISCARDS that commit in favour of the public cherry-pick of the same
* changes. Building on it would strand the discarded commit on a branch that fix PRs are cut
* from, and the replacement gets merged in on top of it later. An unpublished cut is a SKIP (the
* mirror clears it within the hour, whatever the cut is, so waiting cannot deadlock); a base
* ahead of public for any other reason is a FAILURE, because the mirror will not clear it.
* PUBLIC HISTORY ONLY: the base is fetched directly from the public repo. Private master can
* temporarily contain a bundle cut that public master does not have yet. Using the private base
* would strand that commit on a recreated bundle branch after the private mirror replaces it.
*
* Runs from a full checkout (fetch-depth 0) of any branch. Assumes credentials are NOT
* persisted by checkout — every private fetch and the push go through an explicit token URL,
* which matters because the repository is private. The public fetch is anonymous.
* persisted by checkout. The bundle fetch and push use an explicit token URL. The public base
* fetch is anonymous.
*
* Env: BUNDLE_BRANCH (e.g. bundle/2.x), BASE_BRANCH (e.g. master),
* GH_TOKEN (installation token with contents:write),
@ -60,10 +56,6 @@ const BOT_EMAIL = 'n8n-assistant[bot]@users.noreply.github.com';
// installation token this script holds is scoped to the private repo.
const PUBLIC_REMOTE = 'https://github.com/n8n-io/n8n.git';
// What a cut lands on private base before it is published. Anything else ahead of public means
// the mirror is stuck, not that a cut is in flight.
const BUNDLE_SUBJECT = /^chore: Bundle/;
function required(env, name) {
const value = env[name];
if (!value) throw new Error(`${name} env var is required`);
@ -76,49 +68,8 @@ export function annotation(title, message) {
return `::error title=${title}::${message.replace(/\n/g, '%0A')}`;
}
/**
* Whether the base tip is still private-only, and what to do about it.
*
* `null` means the public repo already has it, so the sync may proceed. The predicate is
* containment in public rather than "is not a bundle commit", so a direct push that bypassed
* ci-restrict-private-merges.yml is caught too; the subjects only decide how to react.
*/
function unconvergedBase({ git, log, bundle, base, baseSha, publicSha }) {
if (isAncestor(git, baseSha, publicSha)) return null;
const ahead = git(['log', '--format=%h %s', `${publicSha}..${baseSha}`])
.split('\n')
.filter(Boolean);
const subjects = ahead.map((line) => line.slice(line.indexOf(' ') + 1));
// All-or-nothing: one unexplained commit means the mirror will not clear the base on its own.
// An empty range here means unrelated histories, which `every` treats as a skip — the safe
// direction, since forcing past an ancestry check we could not evaluate is never right.
if (subjects.every((subject) => BUNDLE_SUBJECT.test(subject))) {
log(
`${base} ${baseSha} has not reached public ${base} ${publicSha} yet; leaving ${bundle} ` +
'untouched. The hourly public → private sync clears this.',
);
return { status: 'unconverged' };
}
// Subjects go to the run log, which is private. The annotation stays a count: it is the
// headline of a failed run, and a subject hints at the fix.
log(ahead.join('\n'));
log(
annotation(
`${base} is ahead of public`,
`${base} carries ${ahead.length} commit(s) that public does not have and that are not ` +
`bundle cuts, so the mirror cannot clear them. Fix the mirror, then re-run this workflow.`,
),
);
throw new Error(`${base} is ahead of public by commits that are not bundle cuts.`);
}
// Takes `baseSha` explicitly. This path never fetches the bundle branch, so FETCH_HEAD still
// holds the PUBLIC tip here, not the base — never swap the argument for FETCH_HEAD.
function createBundleBranch({ git, log, bundle, base, baseSha, remote }) {
log(`${bundle} does not exist yet; creating it at ${base} ${baseSha}.`);
log(`${bundle} does not exist yet; creating it at public ${base} ${baseSha}.`);
git(['checkout', '--force', '-B', bundle, baseSha]);
const push = attempt(git, ['push', remote, `HEAD:refs/heads/${bundle}`]);
@ -132,28 +83,15 @@ function createBundleBranch({ git, log, bundle, base, baseSha, remote }) {
/**
* One fetch-merge-push cycle. `status: 'rejected'` means the branch moved under us — the
* caller re-runs from a fresh fetch rather than forcing anything. `status: 'unconverged'` means
* the base is not published yet and nothing was touched.
* caller re-runs from a fresh fetch rather than forcing anything.
*/
function mergeBaseIntoBundle({ git, log, bundle, base, remote }) {
// Pin every side to the SHA fetched for it — fetching by URL never updates the origin/*
// tracking refs, so FETCH_HEAD is the only handle, and each of the three fetches below
// overwrites the last. Read FETCH_HEAD on the line after its own fetch; never separate a
// fetch from the read that consumes it.
git(['fetch', remote, base]);
const baseSha = git(['rev-parse', 'FETCH_HEAD']);
// The base branch is named the same on both sides (master, 1.x). If that ever stops being
// true, map it here rather than at the call site.
// Pin the base to the public SHA. Private master can temporarily contain an unpublished cut.
const publicFetch = attempt(git, ['fetch', PUBLIC_REMOTE, base]);
if (!publicFetch.ok) {
// A guard that cannot be evaluated must stop the run, not be assumed satisfied.
throw new Error(`Could not fetch ${base} from the public repo:\n${publicFetch.out}`);
}
const publicSha = git(['rev-parse', 'FETCH_HEAD']);
const unconverged = unconvergedBase({ git, log, bundle, base, baseSha, publicSha });
if (unconverged) return unconverged;
const baseSha = git(['rev-parse', 'FETCH_HEAD']);
const listed = attempt(git, ['ls-remote', '--heads', remote, `refs/heads/${bundle}`]);
if (!listed.ok) {
@ -179,7 +117,9 @@ function mergeBaseIntoBundle({ git, log, bundle, base, remote }) {
'--format=%h %s',
`${baseSha}..${preHead}`,
]);
log(`Merging ${base} ${baseSha} into ${bundle}. Commits on ${bundle}:\n${pending || '(none)'}`);
log(
`Merging public ${base} ${baseSha} into ${bundle}. Commits on ${bundle}:\n${pending || '(none)'}`,
);
// The content the merge must produce — and whether the two sides conflict at all. Computed
// without touching the working tree, so a conflict costs the branch nothing.
@ -211,7 +151,7 @@ function mergeBaseIntoBundle({ git, log, bundle, base, remote }) {
if (push.out) log(push.out);
return { status: 'rejected' };
}
log(`Merged ${base} into ${bundle} — nothing rewritten.`);
log(`Merged public ${base} into ${bundle}; nothing was rewritten.`);
return { status: 'merged' };
}

View File

@ -28,7 +28,6 @@ const fail =
};
const PRE_HEAD = 'PREHEAD';
const BASE = 'BASESHA';
const PUBLIC = 'PUBLICSHA';
const MERGE_TREE = 'MERGETREEOID';
const REMOTE = 'https://x-access-token:tok@github.com/n8n-io/n8n-private.git';
@ -52,37 +51,27 @@ const conflictedMergeTree = (...paths) =>
fail(`${MERGE_TREE}\n${paths.join('\n')}\n\nCONFLICT (content): Merge conflict in ${paths[0]}`);
// FETCH_HEAD is one slot holding whatever the last fetch brought down, so the stub has to answer
// by looking back at that fetch. Resolving both fetches to the same SHA would let the
// convergence guard pass by accident and assert nothing.
// by looking back at that fetch.
const fetchHead = (_args, calls) => {
const [, url, ref] = [...calls].reverse().find((a) => a[0] === 'fetch') ?? [];
if (url === PUBLIC_REMOTE) return PUBLIC;
if (ref?.startsWith('bundle/')) return PRE_HEAD;
return BASE;
return '';
};
// Routes shared by every path: base fetched and already published, the bundle branch hasn't
// absorbed it yet, the merge tree is computable, and the merge lands on exactly that tree. git
// grep exits non-zero => no markers.
// Routes shared by every path: the bundle branch has not absorbed the public base yet, the
// merge tree is computable, and the merge lands on exactly that tree. git grep exits non-zero
// => no markers.
const baseGitRoutes = [
[(a) => a[0] === 'ls-remote', `${PRE_HEAD}\trefs/heads/bundle/2.x`],
[(a) => a[0] === 'rev-parse' && a[1] === 'FETCH_HEAD', fetchHead],
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', PRE_HEAD],
// Split by descendant: the base has reached public, but the bundle branch has not absorbed
// it. One `merge-base` matcher would answer both and decide convergence by accident.
[(a) => a[0] === 'merge-base' && a[3] === PUBLIC, ''],
[(a) => a[0] === 'merge-base' && a[3] === PRE_HEAD, fail()],
[(a) => a[0] === 'merge-tree', MERGE_TREE],
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', MERGE_TREE],
[(a) => a[0] === 'grep', fail()],
];
// A base that is ahead of public by the commits given, so the guard trips.
const unpublishedRoutes = (...lines) => [
[(a) => a[0] === 'merge-base' && a[3] === PUBLIC, fail()],
[(a) => a[0] === 'log' && a[1] === '--format=%h %s', lines.join('\n')],
];
const silent = () => {};
test('a missing branch env var fails before any git command runs', () => {
@ -107,14 +96,14 @@ test('a clean sync merges the base in and pushes without forcing', () => {
'--no-edit',
'-m',
'Merge master into bundle/2.x',
BASE,
PUBLIC,
]);
// No lease and no --force: the branch is append-only, so a non-fast-forward must be
// refused by git rather than resolved by overwriting whatever landed.
assert.deepEqual(git.calls.find(isPush), ['push', REMOTE, 'HEAD:refs/heads/bundle/2.x']);
});
test('the private fetches are authenticated and the public one is not', () => {
test('the bundle fetch is authenticated and the public base fetch is not', () => {
const git = makeStub(baseGitRoutes);
syncBundleBranch({ git, env, log: silent });
@ -122,7 +111,6 @@ test('the private fetches are authenticated and the public one is not', () => {
assert.deepEqual(
git.calls.filter((a) => a[0] === 'fetch'),
[
['fetch', REMOTE, 'master'],
['fetch', PUBLIC_REMOTE, 'master'],
['fetch', REMOTE, 'bundle/2.x'],
],
@ -145,8 +133,6 @@ test('the public tip is read straight after its own fetch', () => {
assert.deepEqual(
git.calls.filter((a) => a[0] === 'fetch' || (a[0] === 'rev-parse' && a[1] === 'FETCH_HEAD')),
[
['fetch', REMOTE, 'master'],
['rev-parse', 'FETCH_HEAD'],
['fetch', PUBLIC_REMOTE, 'master'],
['rev-parse', 'FETCH_HEAD'],
['fetch', REMOTE, 'bundle/2.x'],
@ -156,8 +142,6 @@ test('the public tip is read straight after its own fetch', () => {
test('a bundle branch that already contains its base is left alone', () => {
const git = makeStub([
// Only the bundle-branch ancestry is overridden; the convergence probe keeps its own
// answer from baseGitRoutes, or this would pass without the guard being exercised.
[(a) => a[0] === 'merge-base' && a[3] === PRE_HEAD, ''], // --is-ancestor succeeds
...baseGitRoutes,
]);
@ -226,9 +210,8 @@ test('a fix landing mid-run is retried from a fresh fetch, never forced', () =>
assert.deepEqual(result, { status: 'merged' });
assert.equal(pushes, 2);
// The retry re-fetches rather than reusing the tip it already merged onto — including the
// public tip, so the convergence guard is re-evaluated instead of carried over.
assert.equal(git.calls.filter((a) => a[0] === 'fetch').length, 6);
// The retry re-fetches both tips instead of reusing the merge that lost the push race.
assert.equal(git.calls.filter((a) => a[0] === 'fetch').length, 4);
assert.equal(git.calls.filter((a) => a[1] === PUBLIC_REMOTE).length, 2);
assert.equal(
git.calls.every((a) => !a.some((arg) => String(arg).includes('force-with-lease'))),
@ -244,22 +227,19 @@ test('a branch that keeps moving fails instead of forcing', () => {
});
});
test('a bundle branch that does not exist yet is created at its base', () => {
test('a bundle branch that does not exist yet is created at the public base', () => {
const git = makeStub([[(a) => a[0] === 'ls-remote', ''], ...baseGitRoutes]);
const result = syncBundleBranch({ git, env, log: silent });
assert.deepEqual(result, { status: 'created' });
assert.deepEqual(
git.calls.find((a) => a[0] === 'checkout'),
['checkout', '--force', '-B', 'bundle/2.x', BASE],
['checkout', '--force', '-B', 'bundle/2.x', PUBLIC],
);
assert.equal(git.calls.some(isMerge), false);
assert.deepEqual(
git.calls.filter((a) => a[0] === 'fetch'),
[
['fetch', REMOTE, 'master'],
['fetch', PUBLIC_REMOTE, 'master'],
],
[['fetch', PUBLIC_REMOTE, 'master']],
);
assert.deepEqual(git.calls.find(isPush), ['push', REMOTE, 'HEAD:refs/heads/bundle/2.x']);
});
@ -301,97 +281,6 @@ test('annotation keeps a multi-line message on one line', () => {
assert.equal(annotation('t', 'a\nb'), '::error title=t::a%0Ab');
});
test('a base that has not reached the public repo is left alone', () => {
const git = makeStub([
...unpublishedRoutes('abc1234 chore: Bundle 2.x (#123)'),
...baseGitRoutes,
]);
const logs = [];
const result = syncBundleBranch({ git, env, log: (m) => logs.push(m) });
assert.deepEqual(result, { status: 'unconverged' });
// Nothing about the bundle branch is even asked: no existence probe, no fetch, no checkout.
assert.equal(
git.calls.some((a) => a[0] === 'ls-remote'),
false,
);
assert.equal(
git.calls.some((a) => a[0] === 'checkout'),
false,
);
assert.equal(git.calls.some(isMerge), false);
assert.equal(git.calls.some(isPush), false);
assert.equal(git.calls.filter((a) => a[0] === 'fetch').length, 2);
assert.match(logs.join('\n'), /has not reached public master/);
});
test('a missing bundle branch is not created from an unpublished base', () => {
const git = makeStub([
[(a) => a[0] === 'ls-remote', ''],
...unpublishedRoutes('abc1234 chore: Bundle/1.x'),
...baseGitRoutes,
]);
// One guard covers both paths: the branch must not be conjured onto a doomed commit either.
assert.deepEqual(syncBundleBranch({ git, env, log: silent }), { status: 'unconverged' });
assert.equal(
git.calls.some((a) => a[0] === 'checkout'),
false,
);
assert.equal(git.calls.some(isPush), false);
});
test('a base ahead of public by a non-bundle commit fails loudly', () => {
const git = makeStub([
...unpublishedRoutes('abc1234 fix(core): Something a human pushed directly'),
...baseGitRoutes,
]);
const logs = [];
assert.throws(() => syncBundleBranch({ git, env, log: (m) => logs.push(m) }), {
message: /master is ahead of public by commits that are not bundle cuts/,
});
assert.equal(git.calls.some(isPush), false);
// The annotation is the headline of a failed run: a count, never a subject.
const annotations = logs.filter((m) => m.startsWith('::error'));
assert.match(annotations.join('\n'), /carries 1 commit\(s\)/);
assert.doesNotMatch(annotations.join('\n'), /Something a human pushed directly/);
});
test('one stray commit among bundle cuts still fails', () => {
const git = makeStub([
...unpublishedRoutes('abc1234 chore: Bundle 2.x (#123)', 'def5678 fix(core): Stray'),
...baseGitRoutes,
]);
// All-or-nothing: a single unexplained commit means the mirror cannot clear the base.
assert.throws(() => syncBundleBranch({ git, env, log: silent }), {
message: /not bundle cuts/,
});
});
test('an unrelated history skips rather than forcing anything', () => {
const git = makeStub([...unpublishedRoutes(), ...baseGitRoutes]);
// Ancestry fails but the range is empty, so there is nothing to classify. Skipping is the
// only safe default here.
assert.deepEqual(syncBundleBranch({ git, env, log: silent }), { status: 'unconverged' });
assert.equal(git.calls.some(isPush), false);
});
test('an unconverged base is not retried as a push race', () => {
const git = makeStub([...unpublishedRoutes('abc1234 chore: Bundle 2.x'), ...baseGitRoutes]);
const logs = [];
syncBundleBranch({ git, env, log: (m) => logs.push(m) });
// Only 'rejected' earns a second attempt; the mirror runs hourly, not within this run.
assert.equal(git.calls.filter((a) => a[0] === 'fetch').length, 2);
assert.doesNotMatch(logs.join('\n'), /retrying/);
});
test('a public fetch that fails stops the run instead of guessing', () => {
const git = makeStub([
[(a) => a[0] === 'fetch' && a[1] === PUBLIC_REMOTE, fail('could not read from remote')],
@ -423,7 +312,6 @@ test('the 1.x bundle branch is checked against public 1.x', () => {
assert.deepEqual(
git.calls.filter((a) => a[0] === 'fetch'),
[
['fetch', REMOTE, '1.x'],
['fetch', PUBLIC_REMOTE, '1.x'],
['fetch', REMOTE, 'bundle/1.x'],
],

View File

@ -254,7 +254,7 @@ export function buildOutputs({
? [
'',
'> [!NOTE]',
'> `pnpm-lock.yaml` still carries its conflict markers because a manifest (`package.json` / `pnpm-workspace.yaml`) is conflicted too, or its regeneration failed. After resolving the manifests, regenerate it with `pnpm install --lockfile-only` and commit the result.',
'> `pnpm-lock.yaml` still carries its conflict markers. Resolve the other conflicts first. Then regenerate it with `pnpm install --lockfile-only` and commit the result.',
]
: []),
'',

View File

@ -210,12 +210,12 @@ test('buildOutputs lists mechanically pre-resolved files apart from the code con
syncBranch: 'sync/master-to-3x',
files: ['packages/cli/x.ts'],
owners: ['alice'],
preResolved: ['pnpm-lock.yaml'],
preResolved: ['packages/frontend/editor-ui/data/node-popularity.json'],
});
assert.match(out.body, /### Conflicted files\n- `packages\/cli\/x\.ts`/);
assert.match(out.body, /### Auto-resolved for you/);
assert.match(out.body, /resolved mechanically — no action needed/);
assert.ok(out.body.indexOf('pnpm-lock.yaml') > out.body.indexOf('Auto-resolved'));
assert.ok(out.body.indexOf('node-popularity.json') > out.body.indexOf('Auto-resolved'));
});
test('buildOutputs carries the regen instruction when the lockfile was deferred', () => {

View File

@ -24,20 +24,16 @@
* the human's fix commit is in the queue and does the real work. Stalls the strategy
* option cannot settle (modify/delete — `-X` never resolves those) are resolved in
* place toward the queue commit's side. Tree is then proven.
* 3. master conflicts with 3.x, but ONLY on mechanical files — tool-generated content
* with a deterministic resolution (the pnpm lockfile, bot-maintained data files).
* These are resolved in place while the replay is stopped, exactly as a human
* resolver would (regenerate the lockfile, take master's blob), and folded into the
* stalled commit — still no commit of its own and no PR. When the `-X theirs` route
* resolves lockfile hunks without stalling, the lockfile is reconciled at the tip
* instead (folded into the tip commit by amending — never a commit of its own).
* 4. The content does NOT reconcile on a real code path → a genuinely new conflict. 3.x
* is left UNTOUCHED and a draft PR is opened on the sync branch carrying the conflict
* markers — with the mechanical files pre-resolved, so the resolver only deals with
* real code — naming both ends of the clash: the authors of the breaking commits and
* the master commits that touched the same files. Delete/modify conflicts leave no
* markers, so they are resolved toward 3.x and reported as an explicit decision
* instead. Syncs pause until it is merged.
* 3. master conflicts with 3.x, but ONLY on non-lockfile mechanical files — bot-maintained
* data with a deterministic resolution. These are resolved in place while the replay
* is stopped and folded into the stalled commit — still no commit of its own and no PR.
* 4. The content does NOT reconcile on a real code path, or `pnpm-lock.yaml` conflicts →
* a genuinely new conflict. 3.x is left UNTOUCHED and a draft PR is opened on the sync
* branch carrying the conflict markers. Other mechanical files are pre-resolved. The
* lockfile is left for the resolver because pnpm validation is not reliable while the
* merge index is unresolved. Delete/modify conflicts leave no markers, so they are
* resolved toward 3.x and reported as an explicit decision instead. Syncs pause until
* the PR is merged.
*
* The conflict branch carries the conflict markers, so the resolver sees exactly what clashed
* and the required checks stay red until they fix it in a commit of their own. A conflict git
@ -64,6 +60,9 @@
*/
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
attempt,
@ -88,7 +87,8 @@ export const LOCKFILE = 'pnpm-lock.yaml';
/**
* Paths whose conflicts are MECHANICAL: tool-generated files with a deterministic
* resolution, so no human judgement is lost by resolving them automatically.
* resolution during replay. Conflict PRs defer the lockfile but still pre-resolve the
* other paths.
* Keys are exact repo-relative paths; values pick the resolution strategy:
* - 'pnpm-regen': pnpm natively merges a conflicted lockfile when regenerating
* (`pnpm install --lockfile-only`).
@ -131,12 +131,18 @@ export function classifyPaths(paths) {
return { mechanical, code };
}
// A conflicted manifest makes lockfile regeneration meaningless until it is resolved
// (catalogs live in pnpm-workspace.yaml, so it counts as a manifest too).
export function blocksLockfileRegen(codePaths) {
return codePaths.some(
(p) => p === 'package.json' || p.endsWith('/package.json') || p === 'pnpm-workspace.yaml',
);
/** Run the trusted frozen install without using a store restored by the sync job. */
export function validateLockfile(pnpm, env = process.env) {
const validationDir = join(env.RUNNER_TEMP || tmpdir(), `n8n-sync-lockfile-${randomUUID()}`);
pnpm([
'install',
'--frozen-lockfile',
'--trust-lockfile',
'--store-dir',
join(validationDir, 'store'),
'--virtual-store-dir',
join(validationDir, 'virtual-store'),
]);
}
/**
@ -150,6 +156,12 @@ export function resolveMechanicalPath({ git, pnpm, path, masterSha, log = consol
// is required: pnpm flips `--frozen-lockfile` on by default when CI=true.
log(`Regenerating ${path} with pnpm...`);
pnpm(['install', '--lockfile-only', '--no-frozen-lockfile']);
try {
validateLockfile(pnpm);
} catch (error) {
git(['checkout', '--conflict=merge', '--', path]);
throw error;
}
git(['add', '--', path]);
return;
}
@ -305,6 +317,7 @@ export function reconcileWithMergeTreeAtTip({
*/
export function reconcileLockfileAtTip({ git, pnpm, masterSha, log = console.log }) {
pnpm(['install', '--lockfile-only', '--no-frozen-lockfile']);
validateLockfile(pnpm);
if (attempt(git, ['diff', '--quiet', '--', LOCKFILE]).ok) return;
if (git(['rev-parse', 'HEAD']) === masterSha) {
throw new Error('Lockfile reconciliation would amend a master commit; refusing.');
@ -316,28 +329,28 @@ export function reconcileLockfileAtTip({ git, pnpm, masterSha, log = console.log
/**
* Build the conflict branch: master merged into 3.x with the conflict markers committed as
* they are — except mechanical files, which are pre-resolved so the resolver only deals
* with real code conflicts. The remaining markers are the review surface: the resolver sees
* exactly what clashed, and the required checks stay red until they fix it, so the PR
* cannot be merged half-resolved (an auto-resolved branch would be green with master's
* change silently dropped).
* they are — except non-lockfile mechanical files, which are pre-resolved so the resolver
* only deals with code and lockfile conflicts. The remaining markers are the review surface:
* the resolver sees exactly what clashed, and the required checks stay red until they fix it,
* so the PR cannot be merged half-resolved (an auto-resolved branch would be green with
* master's change silently dropped).
*
* Delete/modify conflicts have no markers to leave, so they are resolved toward 3.x's side
* — the same side the replay favours — and reported separately. Left to `add -A` they would
* commit master's surviving blob instead, re-adding a file 3.x deleted on purpose with
* nothing in the diff to suggest a decision was made.
*
* The lockfile is left with its markers when a manifest is among the code conflicts
* (regenerating is meaningless until the manifests are resolved) or when the regen fails
* transiently — flagged via `lockfileDeferred` so the PR body carries the instruction.
* The lockfile is always left with its markers. pnpm install validation can give different
* results while the merge index is unresolved, even with isolated stores. `lockfileDeferred`
* makes the PR body carry the resolver instruction.
*
* 3.x never carries the markers at its tip, and not for long in its history either: this
* merge commit is dropped by the next replay, which takes the queue's commits only.
*
* @returns {{ files: string[], deleteConflicts: Array<{path: string, deletedBy: string}>,
* preResolved: string[], lockfileDeferred: boolean }}
* `files` is the marker-carrying list the PR reports, or every conflict when none are
* code (a fallback after a failed auto-resolution); owners are attributed for both lists.
* `files` is the code-conflict list, or the unresolved mechanical list when there are no
* code conflicts. Owners are attributed for both lists.
*/
export function buildConflictBranch({
git,
@ -361,8 +374,7 @@ export function buildConflictBranch({
const preResolved = [];
let lockfileDeferred = false;
for (const path of mechanical) {
const needsManifests = MECHANICAL_PATHS[path] === 'pnpm-regen' && blocksLockfileRegen(code);
if (needsManifests) {
if (path === LOCKFILE) {
lockfileDeferred = true;
continue;
}
@ -373,7 +385,6 @@ export function buildConflictBranch({
// Degrade gracefully (e.g. a transient registry failure): leave the markers
// for the resolver rather than failing the PR-opening path.
log(`warning: could not pre-resolve ${path}: ${error.message}`);
if (MECHANICAL_PATHS[path] === 'pnpm-regen') lockfileDeferred = true;
}
}
@ -393,8 +404,13 @@ export function buildConflictBranch({
git(['add', '-A']);
git(['commit', '--no-edit', '--no-verify']);
const preResolvedSet = new Set(preResolved);
const unresolvedMechanical = mechanical.filter((path) => !preResolvedSet.has(path));
return {
files: code.length > 0 ? code.filter((p) => !deleted.has(p)) : all,
files:
code.length > 0
? code.filter((path) => !deleted.has(path))
: unresolvedMechanical.filter((path) => !deleted.has(path)),
deleteConflicts,
preResolved,
lockfileDeferred,
@ -543,7 +559,7 @@ export async function sync({
if (!merged.ok) {
const { mechanical, code } = classifyPaths(merged.conflictedPaths);
if (code.length === 0 && mechanical.length > 0) {
if (code.length === 0 && mechanical.length > 0 && !mechanical.includes(LOCKFILE)) {
log(
`master conflicts with ${target} only on mechanical files (${mechanical.join(', ')}); auto-resolving.`,
);
@ -584,7 +600,9 @@ export async function sync({
log('Mechanical auto-resolution did not complete; falling back to a conflict PR.');
} else {
log(
`master conflicts with ${target} — leaving ${target} untouched and opening a conflict PR.`,
mechanical.includes(LOCKFILE)
? `master conflicts with ${target} on ${LOCKFILE} — deferring the lockfile and opening a conflict PR.`
: `master conflicts with ${target} — leaving ${target} untouched and opening a conflict PR.`,
);
}

View File

@ -1,11 +1,12 @@
import assert from 'node:assert/strict';
import { tmpdir } from 'node:os';
import { test } from 'node:test';
import {
hasOpenConflictPr,
mergeTree,
classifyPaths,
blocksLockfileRegen,
validateLockfile,
resolveMechanicalPath,
resolveQueueSidePath,
deleteModifyConflicts,
@ -87,6 +88,22 @@ const baseGitRoutes = [
const env = { GH_TOKEN: 'tok', GITHUB_REPOSITORY: 'n8n-io/n8n' };
const noOpenPr = [[(a) => a[0] === 'pr' && a[1] === 'list', '[]']];
function isolatedValidationPaths(args) {
assert.deepEqual(args.slice(0, 3), ['install', '--frozen-lockfile', '--trust-lockfile']);
assert.equal(args.includes('--force'), false);
assert.equal(args.length, 7);
const storeDirIndex = args.indexOf('--store-dir');
const virtualStoreDirIndex = args.indexOf('--virtual-store-dir');
assert.ok(storeDirIndex >= 3);
assert.ok(virtualStoreDirIndex >= 3);
const storeDir = args[storeDirIndex + 1];
const virtualStoreDir = args[virtualStoreDirIndex + 1];
assert.ok(storeDir);
assert.ok(virtualStoreDir);
assert.notEqual(storeDir, virtualStoreDir);
return { storeDir, virtualStoreDir };
}
test('targetBranch defaults to 3.x and honours the rehearsal override', () => {
assert.equal(targetBranch({}), TARGET_BRANCH);
assert.equal(targetBranch({ SYNC_TARGET_BRANCH: '3x-sync-test' }), '3x-sync-test');
@ -135,7 +152,7 @@ test('mergeTree reports the tree when clean, and the conflicted paths when the s
assert.deepEqual(res.conflictedPaths, [LOCKFILE, 'packages/cli/x.ts']);
});
test('classifyPaths and blocksLockfileRegen split mechanical from code conflicts', () => {
test('classifyPaths splits mechanical from code conflicts', () => {
const { mechanical, code } = classifyPaths([
LOCKFILE,
'packages/cli/x.ts',
@ -148,11 +165,23 @@ test('classifyPaths and blocksLockfileRegen split mechanical from code conflicts
'.github/test-metrics/e2e-impact-map.json',
]);
assert.deepEqual(code, ['packages/cli/x.ts']);
});
assert.equal(blocksLockfileRegen(['packages/cli/package.json']), true);
assert.equal(blocksLockfileRegen(['package.json']), true);
assert.equal(blocksLockfileRegen(['pnpm-workspace.yaml']), true);
assert.equal(blocksLockfileRegen(['packages/cli/x.ts']), false);
test('validateLockfile uses fresh stores under the configured temporary directory', () => {
const pnpm = makeStub();
validateLockfile(pnpm, { RUNNER_TEMP: '/runner-temp' });
validateLockfile(pnpm, { RUNNER_TEMP: '/runner-temp' });
validateLockfile(pnpm, {});
const first = isolatedValidationPaths(pnpm.calls[0]);
const second = isolatedValidationPaths(pnpm.calls[1]);
const fallback = isolatedValidationPaths(pnpm.calls[2]);
assert.ok(first.storeDir.startsWith('/runner-temp/'));
assert.ok(first.virtualStoreDir.startsWith('/runner-temp/'));
assert.notEqual(first.storeDir, second.storeDir);
assert.notEqual(first.virtualStoreDir, second.virtualStoreDir);
assert.ok(fallback.storeDir.startsWith(`${tmpdir()}/`));
assert.ok(fallback.virtualStoreDir.startsWith(`${tmpdir()}/`));
});
test('resolveMechanicalPath takes the blob from master, or the deletion when master removed the file', () => {
@ -181,12 +210,14 @@ test('resolveMechanicalPath takes the blob from master, or the deletion when mas
);
});
test('resolveMechanicalPath regenerates the lockfile with pnpm and stages it', () => {
test('resolveMechanicalPath regenerates and applies patches before staging the lockfile', () => {
const git = makeStub();
const pnpm = makeStub();
resolveMechanicalPath({ git, pnpm, path: LOCKFILE, masterSha: MASTER, log: () => {} });
// `--no-frozen-lockfile` is load-bearing: pnpm defaults to frozen when CI=true.
// Regeneration must override CI's frozen default. Validation must use isolated stores.
assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']);
isolatedValidationPaths(pnpm.calls[1]);
assert.equal(pnpm.calls.length, 2);
assert.ok(git.calls.some((a) => a[0] === 'add' && a.includes(LOCKFILE)));
});
@ -417,8 +448,26 @@ test('reconcileLockfileAtTip folds an inconsistent lockfile into the tip commit,
const pnpm = makeStub();
reconcileLockfileAtTip({ git: inconsistent, pnpm, masterSha: MASTER, log: () => {} });
assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']);
isolatedValidationPaths(pnpm.calls[1]);
assert.equal(pnpm.calls.length, 2);
assert.ok(inconsistent.calls.some((a) => a[0] === 'commit' && a.includes('--amend')));
const validationGit = makeStub();
const invalid = makeStub([
[(a) => a.includes('--frozen-lockfile'), fail('ERR_PNPM_PATCH_FAILED')],
]);
assert.throws(
() =>
reconcileLockfileAtTip({
git: validationGit,
pnpm: invalid,
masterSha: MASTER,
log: () => {},
}),
/command failed/,
);
assert.equal(validationGit.calls.length, 0, 'validation must fail before diff or amend checks');
const atMasterTip = makeStub([
[(a) => a[0] === 'diff', fail()],
[(a) => a[0] === 'rev-parse', MASTER],
@ -647,47 +696,39 @@ test('sync fails without pushing when even the favoured replay cannot finish', a
assert.equal(git.calls.filter((a) => a[0] === 'rebase' && a[1] === '--abort').length, 2);
});
test('sync auto-resolves a lockfile-only conflict during the replay — no PR, no commit', async () => {
test('sync opens a conflict PR for a lockfile-only conflict and defers the lockfile', async () => {
const git = makeStub([
...baseGitRoutes.filter((r) => !r[0](['merge-tree'])),
[(a) => a[0] === 'merge-tree', conflictedMergeTree(LOCKFILE)],
[(a) => a[0] === 'rebase' && a[1] === '--continue', ''],
[isRebase, fail(`CONFLICT (content): Merge conflict in ${LOCKFILE}`)],
[(a) => a[0] === 'merge', fail(`CONFLICT (content): Merge conflict in ${LOCKFILE}`)],
[isConflictedFiles, LOCKFILE],
[(a) => a[0] === 'diff' && a.includes('--quiet'), ''], // tip lockfile already consistent
[(a) => a[0] === 'diff-index', fail()], // staged resolution -> continue, not skip
[(a) => a[0] === 'log', 'breaking-sha'],
]);
const gh = makeStub([
...noOpenPr,
[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/99'],
]);
const gh = makeStub(noOpenPr);
const pnpm = makeStub();
await sync({ git, gh, pnpm, env, log: () => {} });
await sync({ git, gh, pnpm, env, fetchFn: okFetch(['alice']), log: () => {} });
// The stall regen plus the tip reconciliation check.
assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']);
assert.equal(pnpm.calls.length, 2);
assert.ok(git.calls.some((a) => a[0] === 'add' && a.includes(LOCKFILE)));
assert.ok(git.calls.some((a) => a[0] === 'rebase' && a[1] === '--continue'));
const push = git.calls.find((a) => a[0] === 'push');
assert.equal(push[1], `--force-with-lease=refs/heads/${TARGET_BRANCH}:${PRE_HEAD}`);
// No human surface: no merge commit, no amend, no conflict PR.
assert.equal(pnpm.calls.length, 0, 'the conflict-PR path must not run pnpm');
assert.equal(
git.calls.some((a) => a[0] === 'merge'),
false,
);
assert.equal(
git.calls.some((a) => a[0] === 'commit'),
false,
);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
git.calls.some((a) => a[0] === 'rebase'),
false,
);
const create = gh.calls.find((a) => a[0] === 'pr' && a[1] === 'create');
assert.ok(create, 'expected a conflict PR');
assert.match(create[create.indexOf('--body') + 1], /pnpm-lock\.yaml.*conflict markers/);
const pushes = git.calls.filter((a) => a[0] === 'push');
assert.equal(pushes.length, 1);
assert.equal(pushes[0].at(-1), `HEAD:refs/heads/${SYNC_BRANCH}`);
});
test('sync falls back to a conflict PR when mechanical auto-resolution cannot complete', async () => {
const git = makeStub([
...baseGitRoutes.filter((r) => !r[0](['merge-tree'])),
[(a) => a[0] === 'merge-tree', conflictedMergeTree(LOCKFILE)],
[(a) => a[0] === 'merge-tree', conflictedMergeTree(POPULARITY)],
[isRebase, fail('CONFLICT')],
// Both replay attempts stall on a code file the endpoints do not reconcile.
[isConflictedFiles, 'packages/cli/x.ts'],
@ -793,7 +834,7 @@ test('buildConflictBranch resolves marker-less delete/modify conflicts toward 3.
assert.ok(git.calls.some((a) => a[0] === 'checkout' && a.includes('--ours')));
});
test('buildConflictBranch pre-resolves mechanical files so only code conflicts remain', () => {
test('buildConflictBranch defers the lockfile with code and pre-resolves other mechanical files', () => {
const git = makeStub([
[(a) => a[0] === 'merge', fail('CONFLICT')],
[isConflictedFiles, `packages/cli/x.ts\n${LOCKFILE}\n${POPULARITY}`],
@ -809,40 +850,20 @@ test('buildConflictBranch pre-resolves mechanical files so only code conflicts r
});
assert.deepEqual(files, ['packages/cli/x.ts']);
assert.deepEqual(preResolved, [LOCKFILE, POPULARITY]);
assert.equal(lockfileDeferred, false);
assert.deepEqual(pnpm.calls[0], ['install', '--lockfile-only', '--no-frozen-lockfile']);
assert.deepEqual(preResolved, [POPULARITY]);
assert.equal(lockfileDeferred, true);
assert.equal(pnpm.calls.length, 0);
assert.ok(
git.calls.some((a) => a[0] === 'checkout' && a[1] === MASTER && a.includes(POPULARITY)),
);
});
test('buildConflictBranch defers the lockfile when a manifest is conflicted too', () => {
const git = makeStub([
[(a) => a[0] === 'merge', fail('CONFLICT')],
[isConflictedFiles, `packages/cli/package.json\n${LOCKFILE}`],
]);
const pnpm = makeStub();
const { files, preResolved, lockfileDeferred } = buildConflictBranch({
git,
pnpm,
masterSha: MASTER,
log: () => {},
});
assert.deepEqual(files, ['packages/cli/package.json']);
assert.deepEqual(preResolved, []);
assert.equal(lockfileDeferred, true);
assert.equal(pnpm.calls.length, 0, 'regen is meaningless until the manifests are resolved');
});
test('buildConflictBranch degrades to a deferred lockfile when the regen fails', () => {
test('buildConflictBranch defers the lockfile with a code conflict without running pnpm', () => {
const git = makeStub([
[(a) => a[0] === 'merge', fail('CONFLICT')],
[isConflictedFiles, `packages/cli/x.ts\n${LOCKFILE}`],
]);
const pnpm = makeStub([[() => true, fail('ERR_PNPM_REGISTRY unreachable')]]);
const pnpm = makeStub();
const { files, preResolved, lockfileDeferred } = buildConflictBranch({
git,
@ -854,9 +875,35 @@ test('buildConflictBranch degrades to a deferred lockfile when the regen fails',
assert.deepEqual(files, ['packages/cli/x.ts']);
assert.deepEqual(preResolved, []);
assert.equal(lockfileDeferred, true);
assert.equal(pnpm.calls.length, 0, 'the conflict-PR path must not run pnpm');
});
test('buildConflictBranch reports only the deferred lockfile after resolving another mechanical file', () => {
const git = makeStub([
[(a) => a[0] === 'merge', fail('CONFLICT')],
[isConflictedFiles, `${LOCKFILE}\n${POPULARITY}`],
[(a) => a[0] === 'cat-file', ''],
]);
const pnpm = makeStub();
const { files, preResolved, lockfileDeferred } = buildConflictBranch({
git,
pnpm,
masterSha: MASTER,
log: () => {},
});
assert.deepEqual(files, [LOCKFILE]);
assert.deepEqual(preResolved, [POPULARITY]);
assert.equal(lockfileDeferred, true);
assert.equal(pnpm.calls.length, 0);
assert.ok(
git.calls.some((a) => a[0] === 'commit'),
'the conflict branch must still be committed',
git.calls.some((a) => a[0] === 'checkout' && a[1] === MASTER && a.includes(POPULARITY)),
);
assert.equal(
git.calls.some((a) => a[0] === 'checkout' && a.includes(LOCKFILE)),
false,
'the lockfile conflict markers must stay untouched',
);
});
@ -915,7 +962,7 @@ test('sync opens a draft conflict PR and leaves 3.x untouched on a real conflict
);
});
test('sync reports only the code conflicts on a mixed conflict, with mechanical files pre-resolved', async () => {
test('sync reports the code conflict and defers the lockfile on a mixed conflict', async () => {
const git = makeStub([
...baseGitRoutes.filter((r) => !r[0](['merge-tree'])),
[(a) => a[0] === 'merge-tree', conflictedMergeTree(LOCKFILE, 'packages/cli/x.ts')],
@ -931,16 +978,13 @@ test('sync reports only the code conflicts on a mixed conflict, with mechanical
await sync({ git, gh, pnpm, env, fetchFn: okFetch(['alice']), log: () => {} });
assert.equal(pnpm.calls.length, 1, 'the lockfile is regenerated for the conflict branch');
assert.equal(pnpm.calls.length, 0, 'the conflict-PR path must not run pnpm');
const create = gh.calls.find((a) => a[0] === 'pr' && a[1] === 'create');
const body = create[create.indexOf('--body') + 1];
assert.match(body, /### Conflicted files\n- `packages\/cli\/x\.ts`/);
assert.match(body, /### Auto-resolved for you/);
assert.ok(
body.indexOf(LOCKFILE) > body.indexOf('Auto-resolved'),
'the lockfile belongs to the auto-resolved section',
);
assert.doesNotMatch(body, /### Auto-resolved for you/);
assert.match(body, /pnpm-lock\.yaml.*conflict markers/);
// Owner attribution is scoped to the real code conflicts only.
const attributions = git.calls.filter((a) => a[0] === 'log' && a.includes('--format=%H'));

34
.github/scripts/test-files.mjs vendored Normal file
View File

@ -0,0 +1,34 @@
import { matchesGlob } from './glob.mjs';
export const TEST_PATTERNS = [
// Test files (by extension)
'**/*.test.ts',
'**/*.test.js',
'**/*.test.mjs',
'**/*.spec.ts',
'**/*.spec.js',
'**/*.spec.mjs',
// Test directories
'**/test/**',
'**/tests/**',
'**/__tests__/**',
// Snapshots
'**/__snapshots__/**',
'**/*.snap',
// Fixtures and mocks
'**/fixtures/**',
'**/__mocks__/**',
// Former packages/testing workspaces
'packages/quality/policy/**',
'packages/quality/testing/**',
'packages/quality/environments/**',
'packages/quality/efficiency/microbenchmarks/**',
];
/**
* @param { string } filename
* @returns { boolean }
*/
export function isTestFile(filename) {
return TEST_PATTERNS.some((pattern) => matchesGlob(filename, pattern));
}

View File

@ -0,0 +1,200 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, it } from 'node:test';
import { parse } from 'yaml';
/**
* Validates the `.github/workflows` YAML that carries logic we cannot see in a
* dry run. Each test parses the real workflow file, so it fails when someone
* changes the step it describes.
*
* Run with:
* node --test .github/scripts/workflow-validation.test.mjs
*/
const workflowFile = (name) =>
parse(readFileSync(new URL(`../workflows/${name}`, import.meta.url), 'utf8'));
/** YAML 1.1 readers fold a bare `on:` key to `true`; the `yaml` package does not. */
const triggersOf = (workflow) => workflow.on ?? workflow[true];
const stepNamed = (workflow, job, name) => {
const step = workflow.jobs[job].steps.find((candidate) => candidate.name === name);
assert.ok(step, `workflow has no step named "${name}" in job "${job}"`);
return step;
};
/**
* Resolves the inputs a job sees: the caller's values on top of the defaults
* the workflow declares. An input the caller omits falls back to its default,
* which is how an absent input and an explicitly empty one become the same
* value at evaluation time.
*/
const resolveInputs = (workflow, callerInputs = {}) => {
const declared = triggersOf(workflow).workflow_call.inputs;
const resolved = Object.fromEntries(
Object.entries(declared).map(([name, spec]) => [name, spec.default]),
);
for (const [name, value] of Object.entries(callerInputs)) {
assert.ok(name in declared, `workflow does not declare an input named "${name}"`);
resolved[name] = value;
}
return resolved;
};
/**
* Evaluates the subset of the GitHub Actions expression syntax that the step
* conditions below use: the status functions, `&&`/`||`, and `==`/`!=` against
* a string literal. Anything outside that subset throws, so a condition this
* evaluator cannot model fails the test instead of passing silently.
*
* @param {string} condition - the raw value of a step's `if:` key
* @param {{ inputs?: Record<string, unknown>, jobStatus?: 'success' | 'failure' | 'cancelled' }} context
* @returns {boolean} true when the step runs, false when the runner skips it
*/
const evaluateStepCondition = (condition, { inputs = {}, jobStatus = 'success' } = {}) => {
const expression = String(condition)
.trim()
.replace(/^\$\{\{(.*)\}\}$/s, '$1')
.trim();
// The subset has no grouping, so only the zero-argument status calls may
// carry parentheses. Anything else would need precedence rules this lacks.
assert.doesNotMatch(
expression.replace(/\w+\(\)/g, ''),
/[()]/,
`step condition uses grouping this evaluator cannot model: "${expression}"`,
);
const value = (token) => {
const term = token.trim();
switch (term) {
case 'always()':
return true;
case 'success()':
return jobStatus === 'success';
case 'failure()':
return jobStatus === 'failure';
case 'cancelled()':
return jobStatus === 'cancelled';
case 'true':
return true;
case 'false':
return false;
default:
break;
}
const literal = term.match(/^'(.*)'$/s);
if (literal) return literal[1];
const input = term.match(/^inputs\.([\w-]+)$/);
if (input) {
assert.ok(input[1] in inputs, `condition reads an unknown input "${input[1]}"`);
return inputs[input[1]];
}
assert.fail(`unsupported term in step condition: "${term}"`);
};
const operand = (token) => {
const comparison = token.match(/^(.+?)(==|!=)(.+)$/s);
if (!comparison) return Boolean(value(token));
const [, left, operator, right] = comparison;
const equal = String(value(left)) === String(value(right));
return operator === '==' ? equal : !equal;
};
// `&&` binds tighter than `||`, and the subset has no parentheses to nest.
return expression
.split('||')
.some((disjunct) => disjunct.split('&&').every((conjunct) => operand(conjunct)));
};
describe('evaluateStepCondition', () => {
it('runs an always() step whatever the job status', () => {
for (const jobStatus of ['success', 'failure', 'cancelled']) {
assert.equal(evaluateStepCondition('always()', { jobStatus }), true);
}
});
it('runs a failure() step only after a failure', () => {
assert.equal(evaluateStepCondition('failure()', { jobStatus: 'failure' }), true);
assert.equal(evaluateStepCondition('failure()', { jobStatus: 'success' }), false);
});
it('compares an input against a string literal', () => {
const inputs = { key: 'abc' };
assert.equal(evaluateStepCondition("${{ inputs.key == 'abc' }}", { inputs }), true);
assert.equal(evaluateStepCondition("${{ inputs.key != 'abc' }}", { inputs }), false);
});
it('rejects a term it cannot model', () => {
assert.throws(() => evaluateStepCondition('github.event_name'), /unsupported term/);
});
});
describe('test-e2e-reusable.yml', () => {
const workflow = workflowFile('test-e2e-reusable.yml');
const shardUpload = stepNamed(workflow, 'test', 'Upload Shard Artifacts');
const a11yUpload = stepNamed(workflow, 'test', 'Upload Accessibility Report');
describe('currents-record-key input', () => {
it('is an optional string that defaults to an empty value', () => {
const { 'currents-record-key': recordKey } = triggersOf(workflow).workflow_call.inputs;
assert.ok(recordKey, 'workflow does not declare a currents-record-key input');
assert.equal(recordKey.type, 'string');
assert.equal(recordKey.required ?? false, false);
assert.equal(recordKey.default, '');
});
});
describe('shard artifact upload', () => {
it('runs when the caller passes no record key', () => {
const inputs = resolveInputs(workflow);
for (const jobStatus of ['success', 'failure']) {
assert.equal(
evaluateStepCondition(shardUpload.if, { inputs, jobStatus }),
true,
`shard upload must run on a ${jobStatus} job when no record key is passed`,
);
}
});
it('runs when the caller passes an empty record key', () => {
const inputs = resolveInputs(workflow, { 'currents-record-key': '' });
assert.equal(evaluateStepCondition(shardUpload.if, { inputs }), true);
});
it('is skipped when the caller passes a record key', () => {
const inputs = resolveInputs(workflow, { 'currents-record-key': 'record-key-value' });
for (const jobStatus of ['success', 'failure']) {
assert.equal(
evaluateStepCondition(shardUpload.if, { inputs, jobStatus }),
false,
`shard upload must be skipped on a ${jobStatus} job when a record key is passed`,
);
}
});
});
describe('accessibility report upload', () => {
it('is not gated on the record key', () => {
assert.doesNotMatch(String(a11yUpload.if), /currents-record-key/);
});
it('runs on a failed job whether or not a record key is passed', () => {
for (const callerInputs of [{}, { 'currents-record-key': 'record-key-value' }]) {
const inputs = resolveInputs(workflow, callerInputs);
assert.equal(evaluateStepCondition(a11yUpload.if, { inputs, jobStatus: 'failure' }), true);
}
});
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -38,7 +38,7 @@ jobs:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
token: ${{ steps.generate-token.outputs.token }}

View File

@ -38,7 +38,7 @@ jobs:
- node_version: '26.7.0'
dhi_ref: dhi.io/node:26.7.0-alpine3.24-dev@sha256:4b494d89fb26c950ce97865acf45b480dc7a6868fdc2b81c2d66599702eeac3f
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0

View File

@ -6,7 +6,7 @@ on:
branches:
- master
paths:
- 'packages/@n8n/benchmark/**'
- 'packages/quality/efficiency/scale/benchmark/**'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/workflows/build-benchmark-image.yml'
@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
@ -33,7 +33,7 @@ jobs:
DOCKER_BUILD_SUMMARY: false
with:
context: .
file: ./packages/@n8n/benchmark/Dockerfile
file: ./packages/quality/efficiency/scale/benchmark/Dockerfile
platforms: linux/amd64
provenance: false
push: true

View File

@ -42,7 +42,7 @@ jobs:
runner: blacksmith-32vcpu-ubuntu-2204-arm
arch: arm64
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
@ -100,7 +100,7 @@ jobs:
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Login to Docker registries
uses: ./.github/actions/docker-registry-login

View File

@ -80,7 +80,7 @@ jobs:
cancel-in-progress: false
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

View File

@ -23,6 +23,7 @@ on:
paths:
- '**/package.json'
- '**/turbo.json'
- 'packages/cli/scripts/build.mjs'
- '.github/workflows/build-windows.yml'
- '.github/actions/setup-nodejs/**'
@ -32,7 +33,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js and Build
uses: ./.github/actions/setup-nodejs

View File

@ -69,7 +69,8 @@ jobs:
steps:
# Defaults for the CLA endpoints, status context, comment marker and label
# are already n8n's production values.
- uses: n8n-io/github-actions/cla-check@931777df53ace1bdb31d2bf5c63c9f2d27451c35 # cla-check/v1.0.0
- uses: n8n-io/github-actions/cla-check@ea082925743265a61d9fd0a69a32b211551f88f0 # v1.0.2
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
pr-number: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number || github.event.issue.number }}

View File

@ -14,7 +14,7 @@ jobs:
if: github.event.pull_request.merged == true
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs

View File

@ -1,10 +1,15 @@
name: 'Instance AI Evals: PR Gate'
run-name: "${{ github.event_name == 'workflow_dispatch' && format('Instance AI Evals: PR #{0} (tier={1})', inputs.pr, inputs.tier || 'pr') || '' }}"
# The PR gate for Instance AI workflow evals. Auto-runs on PR open/reopen/ready
# The PR gate for Instance AI workflow and Agent evals. Auto-runs on PR open/reopen/ready
# (non-draft, non-fork, path-filtered); manual dispatch re-runs a specific PR
# and posts results back. Pushes don't re-trigger (no `synchronize`).
#
# Every run uses the `pr` tier: a hand-picked set of stable cases. A PR that
# touches the Agent Builder sub-agent runs the `pr`-tagged cases of the
# LangTracer `agents` suite; every other PR runs the `pr`-tagged cases of
# `baseline`. Full suites belong to the nightly LangTracer runs, never here.
#
# This workflow is deliberately NOT the experimentation surface. For baselines,
# model experiments, or arbitrary branch runs, dispatch test-evals-instance-ai.yml
# ("Instance AI Evals: Experiments") directly: it exposes the full knob set
@ -19,6 +24,7 @@ on:
- 'packages/@n8n/instance-ai/knowledge-base/**'
- 'packages/@n8n/instance-ai/evaluations/**'
- 'packages/cli/src/modules/instance-ai/**'
- 'packages/cli/src/modules/agents/**'
- 'packages/core/src/execution-engine/eval-mock-helpers.ts'
- 'packages/@n8n/agents/src/**'
workflow_dispatch:
@ -27,7 +33,7 @@ on:
description: 'PR number to re-run against its latest commit. Resolves the PR head at dispatch time and posts results back. For branch/experiment runs use "Instance AI Evals: Experiments" instead.'
required: true
tier:
description: 'Test-case dataset for the re-run (e.g. `full` for broader coverage). Empty = `pr`.'
description: 'Test-case dataset for the re-run. Empty = `pr`.'
required: false
default: ''
@ -59,6 +65,7 @@ jobs:
revision_sha: ${{ steps.resolve.outputs.revision_sha }}
head_ref: ${{ steps.resolve.outputs.head_ref }}
pr_number: ${{ steps.resolve.outputs.pr_number }}
suite: ${{ fromJSON(steps.ci-filter.outputs.results)['agent-builder'] == true && 'agents' || 'baseline' }}
tier: ${{ steps.resolve.outputs.tier }}
steps:
- name: Resolve target ref
@ -72,6 +79,7 @@ jobs:
INPUT_TIER: ${{ inputs.tier }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
EVENT_HEAD_REF: ${{ github.event.pull_request.head.ref }}
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail
if [ -n "$INPUT_PR" ]; then
@ -91,6 +99,7 @@ jobs:
fi
head_sha=$(echo "$pr_json" | jq -r '.head.sha')
head_ref=$(echo "$pr_json" | jq -r '.head.ref')
base_ref=$(echo "$pr_json" | jq -r '.base.ref')
merge_sha=$(echo "$pr_json" | jq -r '.merge_commit_sha // empty')
tested_sha="$head_sha"
if [ -n "$merge_sha" ] && \
@ -102,32 +111,63 @@ jobs:
echo "PR #$INPUT_PR: testing head $head_sha (merge ref stale/unavailable; image rebuilt)"
fi
branch="$tested_sha"; cache_sha="$tested_sha"; revision_sha="$tested_sha"
pr_number="$INPUT_PR"; tier="${INPUT_TIER:-pr}"
classify_sha="$head_sha"; pr_number="$INPUT_PR"; tier="${INPUT_TIER:-pr}"
elif [ "$EVENT_NAME" = "pull_request" ]; then
# PR open/reopen/ready: unchanged - test the merge commit, which also
# matches the SHA-keyed prepare-docker image cache.
branch="$GH_SHA"; cache_sha="$GH_SHA"; revision_sha="$GH_SHA"
head_ref="$EVENT_HEAD_REF"; pr_number="$EVENT_PR_NUMBER"; tier="${INPUT_TIER:-pr}"
classify_sha="$GH_SHA"
head_ref="$EVENT_HEAD_REF"; base_ref="$EVENT_BASE_REF"; pr_number="$EVENT_PR_NUMBER"; tier="${INPUT_TIER:-pr}"
else
# workflow_dispatch enforces `pr` as required; this is unreachable.
echo "::error::manual dispatch requires the pr input"
exit 1
fi
{
echo "branch=$branch"
echo "cache_sha=$cache_sha"
echo "revision_sha=$revision_sha"
echo "classify_sha=$classify_sha"
echo "head_ref=$head_ref"
echo "base_ref=$base_ref"
echo "pr_number=$pr_number"
echo "tier=$tier"
} >> "$GITHUB_OUTPUT"
# Same classifier as the rest of CI. On pull_request the checkout is the
# test-merge commit and ci-filter diffs its PR-head parent against the
# merge base; on dispatch it diffs the PR head, so a stacked PR is not
# charged with files its parent branch changed since the merge commit.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.resolve.outputs.classify_sha }}
persist-credentials: false
# Agent Builder sub-agent paths pick the `agents` suite. The rest of the
# agents module (runtime, channels, sessions) is not exercised by the
# builder cases, so it stays on `baseline`.
- name: Classify changed files
id: ci-filter
uses: ./.github/actions/ci-filter
with:
mode: filter
base-ref: ${{ steps.resolve.outputs.base_ref }}
filters: |
agent-builder:
packages/cli/src/modules/agents/builder/**
packages/cli/src/modules/agents/json-config/**
packages/cli/src/modules/agents/adapters/**
packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts
packages/@n8n/instance-ai/skills/agent-builder/**
run-evals:
name: Instance AI Workflow Evals
needs: resolve
uses: ./.github/workflows/test-evals-instance-ai.yml
with:
branch: ${{ needs.resolve.outputs.branch }}
suite: ${{ needs.resolve.outputs.suite }}
tier: ${{ needs.resolve.outputs.tier }}
pr-number: ${{ needs.resolve.outputs.pr_number }}
cache-sha: ${{ needs.resolve.outputs.cache_sha }}

View File

@ -7,17 +7,21 @@ on:
paths-ignore:
- packages/@n8n/task-runner-python/**
permissions:
contents: read
jobs:
build-github:
name: Build for Github Cache
runs-on: ubuntu-latest
build-and-format:
name: Build & format
runs-on: blacksmith-4vcpu-ubuntu-2204
env:
NODE_OPTIONS: '--max-old-space-size=7168'
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }}
QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }}
QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: useblacksmith/checkout@bcec731f1eb1367240608d1c889a75b96db6ec53 # v1.5.0
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
with:
@ -27,6 +31,9 @@ jobs:
- name: Report build critical path
run: node scripts/turbo-critical-path.mjs >> "$GITHUB_STEP_SUMMARY"
- name: Run format check
run: pnpm format:check
unit-test:
name: Unit tests
uses: ./.github/workflows/test-unit-reusable.yml
@ -46,13 +53,9 @@ jobs:
with:
ref: ${{ github.sha }}
# CodSpeed benchmarks run post-merge only. On PRs they failed for reasons
# unrelated to the change, so the red check gave authors no usable signal.
performance:
name: Performance
uses: ./.github/workflows/test-bench-reusable.yml
with:
ref: ${{ github.sha }}
# The CodSpeed `performance` job (test-bench-reusable.yml) is removed for now:
# @codspeed/vitest-plugin does not support Vitest 5 yet. Add the job back, and
# add it to `notify-on-failure.needs`, when the plugin supports Vitest 5.
# Scoped to the packages this push touched, so it stays cheap enough to run on every commit.
verify-single-instance-npm:
@ -67,10 +70,10 @@ jobs:
notify-on-failure:
name: Notify Slack on failure
runs-on: ubuntu-latest
needs: [unit-test, lint, performance, build-github]
needs: [unit-test, lint, build-and-format]
if: ${{ always() && contains(needs.*.result, 'failure') }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: .github/scripts/slack
sparse-checkout-cone-mode: false

View File

@ -48,17 +48,16 @@ jobs:
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
# The script reads only `.github/` and the root OWNERS file, which cone
# mode always includes.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --lockfile-dir ${{ github.workspace }}/.github/scripts --ignore-workspace
cache-dependency-path: .github/scripts/pnpm-lock.yaml
sparse-checkout: .github
- name: Assign reviewer teams
uses: ./.github/actions/run-workflow-script
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
run: node .github/scripts/owners/assign-reviewers.mjs
with:
script: .github/scripts/owners/assign-reviewers.mjs
github-token: ${{ steps.app-token.outputs.token }}

View File

@ -8,23 +8,46 @@ run-name: "${{ github.event_name == 'workflow_dispatch' && format('CI: Owners Re
# Replaces the GitHub-native CODEOWNERS enforcement, which could not express
# team ownership metadata beyond "request a review".
#
# Every PR is evaluated, whatever its base branch and whether or not its head
# is a fork. Two PR classes make this necessary: GitHub applies the ruleset of
# a stack's target branch to every PR in the stack, so a PR into a feature
# branch can be gated by the master ruleset; and a fork PR cannot write the
# status from a fork-context run. Every path that reports the status runs
# in the base repository context (secrets, write token). The routes that
# skip the evaluation (for example the master-to-3.x sync branch) live in
# REQUIRED_REVIEW_EXEMPTIONS in required-reviews.mjs; an exempt PR gets a
# success status.
#
# Triggers
# - pull_request (opened/synchronize/reopened/ready_for_review):
# re-evaluates whenever the changeset can change. Skipped for fork PRs
# (no secrets): their status stays "expected" — fail closed — until the
# first review event reports it.
# - pull_request_target (opened/synchronize/reopened/ready_for_review,
# labeled/unlabeled, and edited when the base branch changes): re-evaluates
# whenever the changeset or large-scale exemption can change, for same-repo
# and fork heads alike. A retargeted PR keeps its head SHA, so a verdict
# computed against the old base must not survive the retarget.
# - pull_request_review (submitted/dismissed): re-evaluates when approvals
# change. A privileged event (base repo context, secrets), so it covers
# fork PRs too — and approvals can only change through review events.
# change on a same-repo PR. A fork PR's review event runs without secrets
# and with a read-only token, so this path skips fork heads.
# Comment-only reviews are skipped: they cannot change approvals, and
# review bots submit one after every push.
# - workflow_run of "CI: Pull Request Review" (completed): the fork
# counterpart of pull_request_review. That workflow runs on every review
# event; its completion arrives here in the base repository context. The
# PR is looked up from the triggering run's head. Same-repo heads skip
# this path, which pull_request_review already covers.
# - merge_group: reports success on the queue head without re-evaluating.
# A PR cannot enter the queue unless this status is green on its head,
# and the queue does not change approvals.
# - workflow_dispatch: manual re-check (e.g. after a team membership change,
# or a fork PR push after an approval).
# - workflow_dispatch: manual re-check (e.g. after a team membership
# change).
#
# The checkout below always pins the base branch, never the PR merge ref: the
# scripts and the OWNERS file are trusted input, so a PR cannot lift its own
# review requirement or reach the status-writing token.
# The checkout below always pins master, never the base branch or the PR
# merge ref: the scripts, the OWNERS file and the exemption routes are trusted
# input, so a PR cannot lift its own review requirement or reach the
# status-writing token. The large-scale-change label is trusted only when its
# actor belongs to the large-scale-changes team. Any branch can be a base, and
# anyone with push access can edit a branch, so only master is trusted. This is
# also what makes pull_request_target safe here: no step checks out or runs PR
# code.
#
# Output
# - A commit status named "Required Reviews" on the PR head SHA (or
@ -36,12 +59,13 @@ run-name: "${{ github.event_name == 'workflow_dispatch' && format('CI: Owners Re
# stale verdict in effect. The job itself only fails on errors.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches:
- master
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review, edited, labeled, unlabeled]
pull_request_review:
types: [submitted, dismissed]
workflow_run:
workflows: ['CI: Pull Request Review']
types: [completed]
merge_group:
workflow_dispatch:
inputs:
@ -53,14 +77,6 @@ on:
permissions:
contents: read
concurrency:
group: >-
owners-required-reviews-${{ github.event.pull_request.number
|| github.event.merge_group.head_sha
|| inputs.pr_number
|| github.ref }}
cancel-in-progress: true
jobs:
# The queue requires this status on the merge-group head, but there is
# nothing new to evaluate there: entering the queue already required the
@ -73,51 +89,6 @@ jobs:
permissions:
statuses: write
steps:
- env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ github.event.merge_group.head_sha }}
run: |
gh api "repos/${GITHUB_REPOSITORY}/statuses/${SHA}" \
-f state=success \
-f context='Required Reviews' \
-f description='Approvals were verified before the PR entered the queue'
required-reviews:
name: Check required reviews
# Fork PRs are skipped on pull_request (no secrets for the app token);
# pull_request_review covers them. pull_request_review has no `branches`
# filter; the script also skips non-master bases, the guard here keeps
# the early pending status off non-master heads.
if: >-
github.event_name != 'merge_group' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) &&
(github.event_name != 'pull_request_review' ||
github.event.pull_request.base.ref == 'master')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
statuses: write
steps:
# Fail closed before any fallible step: replace a possibly-green status
# with pending, so a failure in token generation, checkout, or setup
# cannot leave a stale verdict in effect. The script sets the verdict.
# Skipped for workflow_dispatch (no SHA in the event); there the script
# sets pending itself.
- name: Set pending status
if: github.event_name != 'workflow_dispatch'
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ github.event.pull_request.head.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh api "repos/${GITHUB_REPOSITORY}/statuses/${SHA}" \
-f state=pending \
-f context='Required Reviews' \
-f description='Evaluating required reviews' \
-f "target_url=${RUN_URL}"
- name: Generate App Token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
@ -130,41 +101,95 @@ jobs:
permission-pull-requests: read
permission-statuses: write
# Always check out the base branch, never the PR merge ref: the OWNERS
# file and the scripts must be trusted input. The 'master' fallback
# covers workflow_dispatch, so a dispatch from another branch cannot
# run that branch's policy.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.ref || 'master' }}
persist-credentials: false
- env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
SHA: ${{ github.event.merge_group.head_sha }}
run: |
gh api "repos/${GITHUB_REPOSITORY}/statuses/${SHA}" \
-f state=success \
-f context='Required Reviews' \
-f description='Approvals were verified before the PR entered the queue'
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
required-reviews:
name: Check required reviews
# Of the edited events only a base change matters; title and body edits
# do not change the verdict. Of the label events only large-scale-change
# can change the exemption. Review events split by head: same-repo heads
# come in through pull_request_review, fork heads through the workflow_run
# of the review workflow.
if: >-
github.event_name != 'merge_group' &&
(github.event_name != 'pull_request_target' ||
github.event.action != 'edited' || github.event.changes.base) &&
(github.event_name != 'pull_request_target' ||
(github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
github.event.label.name == 'large-scale-change') &&
(github.event_name != 'pull_request_review' ||
(github.event.pull_request.head.repo.full_name == github.repository &&
github.event.review.state != 'commented')) &&
(github.event_name != 'workflow_run' ||
(github.event.workflow_run.event == 'pull_request_review' &&
github.event.workflow_run.head_repository.full_name != github.repository))
runs-on: ubuntu-latest
timeout-minutes: 5
# Job-level, not workflow-level: a run whose job is skipped must not
# cancel an in-flight evaluation, or the status stays pending. Keyed by
# head SHA, so the paths above share one group per PR head.
concurrency:
group: >-
owners-required-reviews-${{ github.event.pull_request.head.sha
|| github.event.workflow_run.head_sha
|| inputs.pr_number
|| github.ref }}
cancel-in-progress: true
permissions:
contents: read
statuses: write
steps:
- name: Generate App Token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
build-command: ''
# pnpm 11 and 12 resolve a relative lockfile directory from different locations.
# Use the checkout path because this workflow can run new workflow code against the old base.
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --lockfile-dir ${{ github.workspace }}/.github/scripts --ignore-workspace
cache-dependency-path: .github/scripts/pnpm-lock.yaml
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
# Scope the token to what the script needs; without these inputs it
# would inherit every permission of the app installation.
permission-members: read
permission-pull-requests: read
permission-statuses: write
# Fail closed before any fallible step: replace a possibly-green status
# with pending, so a failure in token generation, checkout, or setup
# cannot leave a stale verdict in effect. The script sets the verdict.
# Skipped for workflow_dispatch (no SHA in the event); there the script
# sets pending itself.
- name: Set pending status
if: github.event_name != 'workflow_dispatch'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
SHA: ${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh api "repos/${GITHUB_REPOSITORY}/statuses/${SHA}" \
-f state=pending \
-f context='Required Reviews' \
-f description='Evaluating required reviews' \
-f "target_url=${RUN_URL}"
# Always check out master, never the base branch or the PR merge ref:
# the OWNERS file, the scripts and the exemption routes must be trusted
# input, and any writable branch can be a base. The script reads only
# `.github/` and the root files, which cone mode always includes.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: master
persist-credentials: false
sparse-checkout: .github
- name: Evaluate required reviews
uses: ./.github/actions/run-workflow-script
env:
# App token: reading org team membership is beyond GITHUB_TOKEN.
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
PULL_REQUEST_NUMBER: ${{ inputs.pr_number || '' }}
STATUS_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Bootstrap: the checkout is the trusted base branch. Until the
# enforcement script exists there, there is no policy to enforce.
if [ ! -f .github/scripts/owners/required-reviews.mjs ]; then
echo "No enforcement script on the base branch; nothing to enforce."
if [ -n "$STATUS_SHA" ]; then
gh api "repos/${GITHUB_REPOSITORY}/statuses/${STATUS_SHA}" \
-f state=success \
-f context='Required Reviews' \
-f description='Owners enforcement is not on the base branch yet'
fi
exit 0
fi
node .github/scripts/owners/required-reviews.mjs
with:
script: .github/scripts/owners/required-reviews.mjs
# App token: reading org team membership is beyond GITHUB_TOKEN.
github-token: ${{ steps.app-token.outputs.token }}

View File

@ -39,17 +39,16 @@ jobs:
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
# The script reads only `.github/` and the root OWNERS file, which cone
# mode always includes.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --lockfile-dir ${{ github.workspace }}/.github/scripts --ignore-workspace
cache-dependency-path: .github/scripts/pnpm-lock.yaml
sparse-checkout: .github
- name: Post review recommendations
uses: ./.github/actions/run-workflow-script
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
run: node .github/scripts/owners/review-recommendations.mjs
with:
script: .github/scripts/owners/review-recommendations.mjs
github-token: ${{ steps.app-token.outputs.token }}

View File

@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

View File

@ -30,19 +30,16 @@ jobs:
issues: write
pull-requests: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
# The script reads nothing outside `.github/`.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --lockfile-dir ${{ github.workspace }}/.github/scripts --ignore-workspace
cache-dependency-path: .github/scripts/pnpm-lock.yaml
sparse-checkout: .github
- name: Re-request PR Size Limit check
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node .github/scripts/quality/handle-size-override.mjs
uses: ./.github/actions/run-workflow-script
with:
script: .github/scripts/quality/handle-size-override.mjs
github-token: ${{ secrets.GITHUB_TOKEN }}
check-pr-size:
name: PR Size Limit
@ -66,19 +63,16 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
# The script reads nothing outside `.github/`.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --lockfile-dir ${{ github.workspace }}/.github/scripts --ignore-workspace
cache-dependency-path: .github/scripts/pnpm-lock.yaml
sparse-checkout: .github
- name: Check PR size
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node .github/scripts/quality/check-pr-size.mjs
uses: ./.github/actions/run-workflow-script
with:
script: .github/scripts/quality/check-pr-size.mjs
github-token: ${{ secrets.GITHUB_TOKEN }}
changes:
name: Detect Changes
@ -96,7 +90,7 @@ jobs:
changed-files: ${{ steps.filter.outputs.changed-files }}
added-files: ${{ steps.filter.outputs.added-files }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Detect changed paths
id: filter
@ -105,8 +99,8 @@ jobs:
mode: filter
filters: |
janitor:
packages/testing/playwright/**
packages/testing/janitor/**
packages/quality/testing/playwright/**
packages/quality/testing/janitor/**
check-static-analysis:
name: Static Analysis
@ -117,7 +111,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
@ -134,6 +128,30 @@ jobs:
if: ${{ !cancelled() && (github.event_name == 'merge_group' || needs.changes.outputs.janitor == 'true') }}
run: pnpm --filter=n8n-playwright janitor
check-knip:
name: Unused Dependencies
# Runs on every PR, not only on manifest changes: a PR that deletes the last
# import of a dependency also makes that dependency unused.
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
# knip loads every vite, vitest, eslint and stylelint config file. These
# import the shared config packages from `dist`, so build those first.
build-command: pnpm turbo run build --filter=@n8n/vitest-config --filter=@n8n/eslint-config --filter=@n8n/stylelint-config --filter=@n8n/frontend-vite-config --filter=@n8n/eslint-plugin-community-nodes --filter=@n8n/node-cli
- name: Run knip
run: pnpm knip
check-do-not-merge:
name: Do Not Merge
# Fails while the 'Do Not Merge' label is present. Re-runs on labeled/unlabeled
@ -151,12 +169,12 @@ jobs:
required-pr-quality-checks:
name: Required PR Quality Checks
needs: [check-pr-size, check-static-analysis, check-do-not-merge]
needs: [check-pr-size, check-static-analysis, check-knip, check-do-not-merge]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: .github/actions/ci-filter
sparse-checkout-cone-mode: false

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