mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
Merge remote-tracking branch 'origin/cat-4471-rename-internal-regex' into cat-4471-separate-user-regex
This commit is contained in:
commit
2ba593d815
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
24
.agents/skills/agent-design-language/SKILL.md
Normal file
24
.agents/skills/agent-design-language/SKILL.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
|
|
|||
377
.agents/skills/create-agent-builder-eval/SKILL.md
Normal file
377
.agents/skills/create-agent-builder-eval/SKILL.md
Normal 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.
|
||||
110
.agents/skills/create-agent-builder-eval/local-setup.md
Normal file
110
.agents/skills/create-agent-builder-eval/local-setup.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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>`).
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
2
.aikido
2
.aikido
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"issues": 163
|
||||
"issues": 110
|
||||
}
|
||||
|
|
|
|||
1
.claude/plugins/n8n/skills/agent-design-language
Symbolic link
1
.claude/plugins/n8n/skills/agent-design-language
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/agent-design-language
|
||||
1
.claude/plugins/n8n/skills/create-agent-builder-eval
Symbolic link
1
.claude/plugins/n8n/skills/create-agent-builder-eval
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../.agents/skills/create-agent-builder-eval
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
36
.devcontainer/codespaces/fake-bin.mjs
Normal file
36
.devcontainer/codespaces/fake-bin.mjs
Normal 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)),
|
||||
};
|
||||
}
|
||||
224
.devcontainer/codespaces/opencode-client.test.mjs
Normal file
224
.devcontainer/codespaces/opencode-client.test.mjs
Normal 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'));
|
||||
});
|
||||
}
|
||||
147
.devcontainer/codespaces/opencode-proxy.test.mjs
Normal file
147
.devcontainer/codespaces/opencode-proxy.test.mjs
Normal 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/);
|
||||
});
|
||||
228
.devcontainer/codespaces/opencode-server.mjs
Normal file
228
.devcontainer/codespaces/opencode-server.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
275
.devcontainer/codespaces/opencode-server.test.mjs
Normal file
275
.devcontainer/codespaces/opencode-server.test.mjs
Normal 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');
|
||||
},
|
||||
);
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
32
.devcontainer/codespaces/ssh-config.test.mjs
Normal file
32
.devcontainer/codespaces/ssh-config.test.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/**
|
||||
|
||||
|
|
|
|||
|
|
@ -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
36
.githooks/check-pnpm.mjs
Normal 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}
|
||||
`);
|
||||
|
|
@ -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
|
||||
6
.github/CI-TELEMETRY.md
vendored
6
.github/CI-TELEMETRY.md
vendored
|
|
@ -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
11
.github/CODEOWNERS
vendored
|
|
@ -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
|
||||
33
.github/DEVELOPING_V3.md
vendored
33
.github/DEVELOPING_V3.md
vendored
|
|
@ -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
256
.github/WORKFLOWS.md
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
2
.github/actions/ci-filter/action.yml
vendored
2
.github/actions/ci-filter/action.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
74
.github/actions/ci-filter/ci-filter.mjs
vendored
74
.github/actions/ci-filter/ci-filter.mjs
vendored
|
|
@ -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) {
|
||||
|
|
|
|||
42
.github/actions/run-workflow-script/action.yml
vendored
Normal file
42
.github/actions/run-workflow-script/action.yml
vendored
Normal 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();
|
||||
2
.github/actions/setup-nodejs/action.yml
vendored
2
.github/actions/setup-nodejs/action.yml
vendored
|
|
@ -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) }}
|
||||
|
|
|
|||
2
.github/docker-compose.yml
vendored
2
.github/docker-compose.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
313
.github/scripts/codespace-preview.mjs
vendored
313
.github/scripts/codespace-preview.mjs
vendored
|
|
@ -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`);
|
||||
|
||||
|
|
|
|||
158
.github/scripts/codespace-preview.test.mjs
vendored
158
.github/scripts/codespace-preview.test.mjs
vendored
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
20
.github/scripts/db-test-matrix.mjs
vendored
20
.github/scripts/db-test-matrix.mjs
vendored
|
|
@ -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',
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
17
.github/scripts/db-test-matrix.test.mjs
vendored
17
.github/scripts/db-test-matrix.test.mjs
vendored
|
|
@ -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',
|
||||
|
|
|
|||
150
.github/scripts/github-helpers.mjs
vendored
150
.github/scripts/github-helpers.mjs
vendored
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
150
.github/scripts/github-helpers.test.mjs
vendored
150
.github/scripts/github-helpers.test.mjs
vendored
|
|
@ -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
49
.github/scripts/glob.mjs
vendored
Normal 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
81
.github/scripts/glob.test.mjs
vendored
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
6
.github/scripts/owners/assign-reviewers.mjs
vendored
6
.github/scripts/owners/assign-reviewers.mjs
vendored
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
6
.github/scripts/owners/owners.mjs
vendored
6
.github/scripts/owners/owners.mjs
vendored
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
16
.github/scripts/owners/owners.test.mjs
vendored
16
.github/scripts/owners/owners.test.mjs
vendored
|
|
@ -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 }),
|
||||
|
|
|
|||
144
.github/scripts/owners/required-reviews.mjs
vendored
144
.github/scripts/owners/required-reviews.mjs
vendored
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
344
.github/scripts/owners/required-reviews.test.mjs
vendored
344
.github/scripts/owners/required-reviews.test.mjs
vendored
|
|
@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('|', '|');
|
||||
}
|
||||
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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|b\/<script>\.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 —/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
10
.github/scripts/package.json
vendored
10
.github/scripts/package.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
143
.github/scripts/pnpm-lock.yaml
vendored
143
.github/scripts/pnpm-lock.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
41
.github/scripts/quality/check-pr-size.mjs
vendored
41
.github/scripts/quality/check-pr-size.mjs
vendored
|
|
@ -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({
|
||||
|
|
|
|||
15
.github/scripts/quality/check-pr-size.test.mjs
vendored
15
.github/scripts/quality/check-pr-size.test.mjs
vendored
|
|
@ -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', () => {
|
||||
|
|
|
|||
77
.github/scripts/quality/cubic-config.schema.json
vendored
77
.github/scripts/quality/cubic-config.schema.json
vendored
|
|
@ -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": {
|
||||
|
|
|
|||
12
.github/scripts/quality/handle-size-override.mjs
vendored
12
.github/scripts/quality/handle-size-override.mjs
vendored
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
88
.github/scripts/sync-bundle-branch.mjs
vendored
88
.github/scripts/sync-bundle-branch.mjs
vendored
|
|
@ -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' };
|
||||
}
|
||||
|
||||
|
|
|
|||
136
.github/scripts/sync-bundle-branch.test.mjs
vendored
136
.github/scripts/sync-bundle-branch.test.mjs
vendored
|
|
@ -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'],
|
||||
],
|
||||
|
|
|
|||
2
.github/scripts/sync-conflict-owners.mjs
vendored
2
.github/scripts/sync-conflict-owners.mjs
vendored
|
|
@ -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.',
|
||||
]
|
||||
: []),
|
||||
'',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
92
.github/scripts/sync-master-to-3x.mjs
vendored
92
.github/scripts/sync-master-to-3x.mjs
vendored
|
|
@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
180
.github/scripts/sync-master-to-3x.test.mjs
vendored
180
.github/scripts/sync-master-to-3x.test.mjs
vendored
|
|
@ -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
34
.github/scripts/test-files.mjs
vendored
Normal 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));
|
||||
}
|
||||
200
.github/scripts/workflow-validation.test.mjs
vendored
Normal file
200
.github/scripts/workflow-validation.test.mjs
vendored
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
2
.github/test-metrics/e2e-impact-map.json
vendored
2
.github/test-metrics/e2e-impact-map.json
vendored
File diff suppressed because one or more lines are too long
1608
.github/test-metrics/playwright.json
vendored
1608
.github/test-metrics/playwright.json
vendored
File diff suppressed because it is too large
Load Diff
2
.github/workflows/backport.yml
vendored
2
.github/workflows/backport.yml
vendored
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
2
.github/workflows/build-base-image.yml
vendored
2
.github/workflows/build-base-image.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/build-benchmark-image.yml
vendored
6
.github/workflows/build-benchmark-image.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/build-node-pc-image.yml
vendored
4
.github/workflows/build-node-pc-image.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/build-v3-nightly.yml
vendored
2
.github/workflows/build-v3-nightly.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
3
.github/workflows/build-windows.yml
vendored
3
.github/workflows/build-windows.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.github/workflows/ci-cla-check.yml
vendored
3
.github/workflows/ci-cla-check.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
2
.github/workflows/ci-detect-new-packages.yml
vendored
2
.github/workflows/ci-detect-new-packages.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
48
.github/workflows/ci-instance-ai-evals.yml
vendored
48
.github/workflows/ci-instance-ai-evals.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
29
.github/workflows/ci-master.yml
vendored
29
.github/workflows/ci-master.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
17
.github/workflows/ci-owners-assign-reviewers.yml
vendored
17
.github/workflows/ci-owners-assign-reviewers.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
225
.github/workflows/ci-owners-required-reviews.yml
vendored
225
.github/workflows/ci-owners-required-reviews.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
2
.github/workflows/ci-owners-validation.yml
vendored
2
.github/workflows/ci-owners-validation.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
70
.github/workflows/ci-pr-quality.yml
vendored
70
.github/workflows/ci-pr-quality.yml
vendored
|
|
@ -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
Loading…
Reference in New Issue
Block a user