ci: Backport .github/ to 1.x (#27154)

This commit is contained in:
Matsu
2026-04-02 12:40:06 +03:00
committed by GitHub
parent f3c0b2c0cb
commit bf86a98163
103 changed files with 6095 additions and 1804 deletions
+77 -16
View File
@@ -2,26 +2,39 @@
Pipeline: **GitHub Actions → Webhook → n8n → BigQuery**
## Standard Data Points
## Unified Payload Shape
All telemetry includes these fields for correlation:
All telemetry uses the same format:
```json
{
"timestamp": "2026-03-16T12:00:00.000Z",
"benchmark_name": "kafka-throughput-10n-10kb",
"git": { "sha": "abc12345", "branch": "master", "pr": null },
"ci": { "runId": "123", "runUrl": "...", "job": "test", "workflow": "CI", "attempt": 1 },
"runner": { "provider": "blacksmith", "cpuCores": 8, "memoryGb": 16.0 },
"metrics": [
{ "metric_name": "exec-per-sec", "value": 12.4, "unit": "exec/s", "dimensions": { "trigger": "kafka", "nodes": 10 } }
]
}
```
## Standard Context Fields
```typescript
// Git context
git.sha // GITHUB_SHA (first 8 chars)
git.branch // GITHUB_HEAD_REF ?? GITHUB_REF_NAME
git.pr // PR number from GITHUB_REF
// CI context
ci.runId // GITHUB_RUN_ID
ci.runUrl // https://github.com/<repo>/actions/runs/<runId>
ci.job // GITHUB_JOB
ci.workflow // GITHUB_WORKFLOW
ci.attempt // GITHUB_RUN_ATTEMPT
// Runner detection
runner.provider // 'github' | 'blacksmith' | 'local'
runner.cpuCores // os.cpus().length
runner.memoryGb // os.totalmem()
runner.provider // 'github' | 'blacksmith' | 'local'
runner.cpuCores // os.cpus().length
runner.memoryGb // os.totalmem()
```
**Runner provider logic:**
@@ -35,19 +48,67 @@ return 'blacksmith';
| Telemetry | Source | Metrics |
|-----------|--------|---------|
| Build stats | `.github/scripts/send-build-stats.mjs` | Per-package build time, cache hits |
| Container stack | `packages/testing/containers/telemetry.ts` | E2E startup times |
| Playwright perf/benchmark | `packages/testing/playwright/reporters/metrics-reporter.ts` | Any metric attached via `attachMetric()` |
| 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 |
## Secrets
```
BUILD_STATS_WEBHOOK_URL
BUILD_STATS_WEBHOOK_USER
BUILD_STATS_WEBHOOK_PASSWORD # Alphanumeric + hyphens only (no $!#@)
QA_METRICS_WEBHOOK_URL
QA_METRICS_WEBHOOK_USER
QA_METRICS_WEBHOOK_PASSWORD
```
## BigQuery Table
`qa_performance_metrics` — schema:
```sql
timestamp TIMESTAMP NOT NULL
benchmark_name STRING
metric_name STRING NOT NULL
value FLOAT64 NOT NULL
unit STRING
dimensions JSON -- {"nodes": 10, "trigger": "kafka", "package": "@n8n/cli"}
git_sha STRING
git_branch STRING
git_pr INT64
ci_run_id STRING
ci_run_url STRING
ci_job STRING
ci_workflow STRING
ci_attempt INT64
runner_provider STRING
runner_cpu_cores INT64
runner_memory_gb FLOAT64
```
Query example:
```sql
-- Build duration trend by package (cache misses only)
SELECT DATE(timestamp), JSON_VALUE(dimensions, '$.package'), AVG(value)
FROM qa_performance_metrics
WHERE metric_name = 'build-duration'
AND JSON_VALUE(dimensions, '$.cache') = 'miss'
GROUP BY 1, 2 ORDER BY 1;
```
## Adding New Telemetry
1. Copy data point helpers from `send-build-stats.mjs`
2. Create n8n workflow: Webhook (Basic Auth) → Code (flatten) → BigQuery
3. Add secrets to GitHub
**From a script:**
```javascript
import { sendMetrics, metric } from './send-metrics.mjs';
await sendMetrics([
metric('my-metric', 42.0, 'ms', { context: 'value' }),
]);
```
**From a Playwright test:**
```typescript
import { attachMetric } from '../utils/performance-helper';
await attachMetric(testInfo, 'my-metric', 42.0, 'ms', { context: 'value' });
```
+4 -2
View File
@@ -1,4 +1,6 @@
packages/@n8n/db/src/migrations/ @n8n-io/migrations-review
.github/workflows @n8n-io/ci-admins
.github/scripts @n8n-io/ci-admins
.github/actions @n8n-io/ci-admins
.github/poutine-rules @n8n-io/ci-admins
# Node popularity data updates
packages/frontend/editor-ui/data/node-popularity.json @n8n-io/catalysts
+1 -3
View File
@@ -66,7 +66,7 @@ body:
id: nodejs-version
attributes:
label: Node.js Version
placeholder: ex. 22.16.0
placeholder: ex. 24.0.0
validations:
required: true
- type: dropdown
@@ -76,8 +76,6 @@ body:
options:
- SQLite (default)
- PostgreSQL
- MySQL
- MariaDB
default: 0
validations:
required: true
+20 -15
View File
@@ -173,7 +173,7 @@ These only run if specific files changed:
| Files Changed | Workflow | Branch |
|------------------------------------------------------------------------|-----------------------------|------------|
| `packages/@n8n/task-runner-python/**` | `ci-python.yml` | any |
| `packages/cli/src/databases/**`, `*.entity.ts`, `*.repository.ts` | `test-db-postgres-mysql.yml`| any |
| `packages/cli/src/databases/**`, `*.entity.ts`, `*.repository.ts` | `test-db.yml` | any |
| `packages/frontend/@n8n/storybook/**`, design-system, chat | `test-visual-storybook.yml` | master |
| `docker/images/n8n-base/Dockerfile` | `build-base-image.yml` | any |
| `**/package.json`, `**/turbo.json` | `build-windows.yml` | master |
@@ -200,7 +200,6 @@ These only run if specific files changed:
| Command | Workflow | Permissions |
|--------------------|------------------------------|---------------------|
| `/build-unit-test` | `ci-manual-unit-tests.yml` | admin/write/maintain|
| `/test-workflows` | `test-workflows-callable.yml`| admin/write/maintain|
**Why:** Re-run tests without pushing commits. Useful for flaky test investigation.
@@ -260,9 +259,6 @@ test-workflows-nightly.yml
└──────────────────────────▶ test-workflows-callable.yml
PR Comment Dispatchers (triggered by /command in PR comments):
build-unit-test-pr-comment.yml
└──────────────────────────▶ ci-manual-unit-tests.yml
test-workflows-pr-comment.yml
└──────────────────────────▶ test-workflows-callable.yml
```
@@ -354,8 +350,8 @@ Runs on push to `master` or `1.x`:
```
Push to master/1.x
├─ build-github (populate cache)
├─ unit-test (matrix: Node 20.x, 22.x, 24.3.x)
│ └─ Coverage only on 22.x
├─ unit-test (matrix: Node 22.x, 24.13.1, 25.x)
│ └─ Coverage only on 24.13.1
├─ lint
└─ notify-on-failure (Slack #alerts-build)
```
@@ -367,7 +363,7 @@ Push to master/1.x
| Schedule (UTC) | Workflow | Purpose |
|---------------------------|-----------------------------------|--------------------------|
| Daily 00:00 | `docker-build-push.yml` | Nightly Docker images |
| Daily 00:00 | `test-db-postgres-mysql.yml` | Database compatibility |
| Daily 00:00 | `test-db.yml` | Database compatibility |
| Daily 00:00 | `test-e2e-performance-reusable.yml`| Performance E2E |
| Daily 00:00 | `test-visual-storybook.yml` | Storybook deploy |
| Daily 00:00 | `test-visual-chromatic.yml` | Visual regression |
@@ -394,7 +390,7 @@ Composite actions in `.github/actions/`:
```yaml
inputs:
node-version: # default: '22.x'
node-version: # default: '24.13.1'
enable-docker-cache: # default: 'false' (Blacksmith Buildx)
build-command: # default: 'pnpm build'
```
@@ -422,7 +418,6 @@ Workflows with `workflow_call` trigger:
| `test-e2e-ci-reusable.yml` | `branch` | E2E orchestrator |
| `test-e2e-docker-pull-reusable.yml`| `branch`, `n8n_version` | E2E with pulled image |
| `test-workflows-callable.yml` | `git_ref`, `compare_schemas` | Workflow tests |
| `ci-check-eligibility-reusable.yml`| (internal) | PR eligibility checks |
| `docker-build-push.yml` | `n8n_version`, `release_type`, `push_enabled` | Docker build |
| `sec-ci-reusable.yml` | `ref` | Security orchestrator |
| `sec-poutine-reusable.yml` | `ref` | Poutine scanner |
@@ -479,7 +474,6 @@ Team ownership mappings in `CODEOWNERS`:
| Path Pattern | Team |
|--------------------------------------------------------------|----------------------------|
| `packages/@n8n/db/src/migrations/` | @n8n-io/migrations-review |
| `packages/frontend/editor-ui/data/node-popularity.json` | @n8n-io/catalysts |
---
@@ -506,6 +500,17 @@ Team ownership mappings in `CODEOWNERS`:
**`blacksmith-8vcpu-ubuntu-2204`** - Heavy parallel workloads, full E2E coverage runs
### Runner Provider Toggle
The `RUNNER_PROVIDER` repository variable controls runner selection across workflows:
| Value | Behavior |
|-------|----------|
| (unset) | Use Blacksmith runners (default) |
| `github` | Use GitHub-hosted `ubuntu-latest` |
**Note:** When set to `github`, all jobs use `ubuntu-latest` regardless of any runner inputs or defaults specified in reusable workflows. GitHub runners have fewer vCPUs (2 vs 4), so jobs may run slower.
---
## Security
@@ -589,10 +594,10 @@ npm audit signatures n8n@VERSION
VEX documents which CVEs actually affect n8n vs false positives from scanners.
- **File:** `vex.openvex.json` (repo root)
- **File:** `security/vex.openvex.json`
- **Format:** OpenVEX (broad scanner compatibility - Trivy, Docker Scout, etc.)
- **Attached to:** GitHub Release, Docker image attestations
- **Used by:** Trivy scans (via `.github/trivy.yaml`)
- **Used by:** Trivy scans (via `security/trivy.yaml`)
**VEX Status Types:**
| Status | Meaning |
@@ -610,7 +615,7 @@ cosign verify-attestation --type openvex \
ghcr.io/n8n-io/n8n:VERSION
```
**Adding a CVE statement to vex.openvex.json:**
**Adding a CVE statement to security/vex.openvex.json:**
```json
{
"statements": [
@@ -654,7 +659,7 @@ cosign verify-attestation --type openvex \
### Redundancy Review
Comment triggers (`/build-unit-test`, `/test-workflows`) are workarounds.
Comment trigger (`/test-workflows`) is a workaround.
Long-term: Main CI should be reliable enough to not need these.
@@ -0,0 +1,235 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { matchGlob, parseFilters, evaluateFilter, runValidate } from '../ci-filter.mjs';
// --- matchGlob ---
describe('matchGlob', () => {
it('** matches dotfiles', () => {
assert.ok(matchGlob('.github/workflows/ci.yml', '**'));
});
it('** matches deeply nested paths', () => {
assert.ok(matchGlob('packages/cli/src/controllers/auth.ts', '**'));
});
it('** matches root-level files', () => {
assert.ok(matchGlob('README.md', '**'));
});
it('.github/** matches workflow files', () => {
assert.ok(matchGlob('.github/workflows/ci.yml', '.github/**'));
});
it('.github/** matches action files', () => {
assert.ok(matchGlob('.github/actions/ci-filter/action.yml', '.github/**'));
});
it('.github/** does not match non-.github paths', () => {
assert.ok(!matchGlob('packages/cli/src/index.ts', '.github/**'));
});
it('scoped package pattern matches files in that package', () => {
assert.ok(
matchGlob(
'packages/@n8n/task-runner-python/src/main.py',
'packages/@n8n/task-runner-python/**',
),
);
});
it('scoped package pattern does not match other packages', () => {
assert.ok(!matchGlob('packages/@n8n/config/src/index.ts', 'packages/@n8n/task-runner-python/**'));
});
it('* matches single-level only', () => {
assert.ok(matchGlob('README.md', '*.md'));
assert.ok(!matchGlob('docs/README.md', '*.md'));
});
it('exact path match', () => {
assert.ok(matchGlob('package.json', 'package.json'));
assert.ok(!matchGlob('packages/cli/package.json', 'package.json'));
});
it('? matches single character', () => {
assert.ok(matchGlob('file1.txt', 'file?.txt'));
assert.ok(!matchGlob('file12.txt', 'file?.txt'));
});
it('**/ at start matches zero or more path segments', () => {
assert.ok(matchGlob('src/index.ts', '**/index.ts'));
assert.ok(matchGlob('packages/cli/src/index.ts', '**/index.ts'));
assert.ok(matchGlob('index.ts', '**/index.ts'));
});
it('**/ in middle matches nested paths', () => {
assert.ok(matchGlob('packages/@n8n/db/src/deep/file.ts', 'packages/@n8n/db/**'));
});
});
// --- parseFilters ---
describe('parseFilters', () => {
it('parses single-line filter', () => {
const filters = parseFilters('workflows: .github/**');
assert.deepEqual(filters.get('workflows'), ['.github/**']);
});
it('parses single-line with multiple patterns', () => {
const filters = parseFilters('db: packages/@n8n/db/** packages/cli/**');
assert.deepEqual(filters.get('db'), ['packages/@n8n/db/**', 'packages/cli/**']);
});
it('parses multi-line filter', () => {
const input = `non-python:
**
!packages/@n8n/task-runner-python/**`;
const filters = parseFilters(input);
assert.deepEqual(filters.get('non-python'), ['**', '!packages/@n8n/task-runner-python/**']);
});
it('parses mixed single and multi-line', () => {
const input = `non-python:
**
!packages/@n8n/task-runner-python/**
workflows: .github/**`;
const filters = parseFilters(input);
assert.equal(filters.size, 2);
assert.deepEqual(filters.get('non-python'), ['**', '!packages/@n8n/task-runner-python/**']);
assert.deepEqual(filters.get('workflows'), ['.github/**']);
});
it('ignores comments and blank lines', () => {
const input = `# This is a comment
workflows: .github/**
# Another comment
db: packages/@n8n/db/**`;
const filters = parseFilters(input);
assert.equal(filters.size, 2);
});
it('throws on malformed input', () => {
assert.throws(() => parseFilters('not a valid filter line'), /Malformed/);
});
it('throws on filter with no patterns', () => {
const input = `empty:
other: .github/**`;
assert.throws(() => parseFilters(input), /no patterns/);
});
});
// --- evaluateFilter ---
describe('evaluateFilter', () => {
it('python-only files with non-python filter returns false', () => {
const files = [
'packages/@n8n/task-runner-python/src/main.py',
'packages/@n8n/task-runner-python/pyproject.toml',
];
const patterns = ['**', '!packages/@n8n/task-runner-python/**'];
assert.equal(evaluateFilter(files, patterns), false);
});
it('mixed python and non-python returns true', () => {
const files = [
'packages/@n8n/task-runner-python/src/main.py',
'packages/cli/src/index.ts',
];
const patterns = ['**', '!packages/@n8n/task-runner-python/**'];
assert.equal(evaluateFilter(files, patterns), true);
});
it('non-python files with non-python filter returns true', () => {
const files = ['packages/cli/src/index.ts', 'packages/core/src/utils.ts'];
const patterns = ['**', '!packages/@n8n/task-runner-python/**'];
assert.equal(evaluateFilter(files, patterns), true);
});
it('.github files with workflows filter returns true', () => {
const files = ['.github/workflows/ci.yml', '.github/actions/setup/action.yml'];
const patterns = ['.github/**'];
assert.equal(evaluateFilter(files, patterns), true);
});
it('non-.github files with workflows filter returns false', () => {
const files = ['packages/cli/src/index.ts'];
const patterns = ['.github/**'];
assert.equal(evaluateFilter(files, patterns), false);
});
it('empty changed files returns false', () => {
assert.equal(evaluateFilter([], ['**']), false);
});
it('last matching pattern wins (gitignore semantics)', () => {
const files = ['packages/@n8n/task-runner-python/src/main.py'];
const patterns = ['**', '!packages/@n8n/task-runner-python/**', 'packages/@n8n/task-runner-python/**'];
assert.equal(evaluateFilter(files, patterns), true);
});
});
// --- runValidate ---
describe('runValidate', () => {
function runWithResults(jobResults: Record<string, { result: string }>): number | null {
const originalEnv = process.env.INPUT_JOB_RESULTS;
const originalExit = process.exit;
let exitCode: number | null = null;
process.env.INPUT_JOB_RESULTS = JSON.stringify(jobResults);
process.exit = ((code: number) => { exitCode = code; }) as never;
try {
runValidate();
} finally {
process.env.INPUT_JOB_RESULTS = originalEnv;
process.exit = originalExit;
}
return exitCode;
}
it('passes when all jobs succeed', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'success' },
typecheck: { result: 'success' },
lint: { result: 'success' },
}), null);
});
it('passes when some jobs are skipped (filtered out)', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'success' },
'security-checks': { result: 'skipped' },
}), null);
});
it('fails when a job fails', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'failure' },
typecheck: { result: 'success' },
}), 1);
});
it('fails when a job is cancelled', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'cancelled' },
}), 1);
});
it('fails when multiple jobs have problems', () => {
assert.equal(runWithResults({
'unit-test': { result: 'failure' },
typecheck: { result: 'cancelled' },
lint: { result: 'success' },
}), 1);
});
});
+39
View File
@@ -0,0 +1,39 @@
name: 'CI Filter'
description: |
Filter CI jobs by changed files and validate results.
Modes:
- filter: Determines which jobs to run based on changed files and a provided filter definition.
- validate: Checks the results of required jobs and fails if any of them failed or were cancelled.
inputs:
mode:
description: 'filter or validate'
required: true
filters:
description: 'Filter definitions (gitignore-style DSL)'
required: false
base-ref:
description: 'Base branch for diff. Auto-detected if not specified.'
required: false
job-results:
description: 'Job results from needs context as JSON (mode=validate)'
required: false
outputs:
results:
description: 'JSON object: { "filter-name": true/false }'
value: ${{ steps.run.outputs.results }}
runs:
using: 'composite'
steps:
- name: Run CI Filter
id: run
shell: bash
env:
INPUT_MODE: ${{ inputs.mode }}
INPUT_FILTERS: ${{ inputs.filters }}
INPUT_BASE_REF: ${{ inputs.base-ref || github.event.pull_request.base.ref || github.event.merge_group.base_ref || 'master' }}
INPUT_JOB_RESULTS: ${{ inputs.job-results }}
run: node ${{ github.action_path }}/ci-filter.mjs
+216
View File
@@ -0,0 +1,216 @@
import { execSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
// --- Glob matching (dotfile-safe) ---
/**
* Match a file path against a glob pattern.
* Unlike path.matchesGlob / standard POSIX globs, `**` matches dotfiles.
*/
export function matchGlob(filePath, pattern) {
let regex = '';
let i = 0;
while (i < pattern.length) {
const ch = pattern[i];
if (ch === '*' && pattern[i + 1] === '*') {
if (pattern[i + 2] === '/') {
regex += '(?:.+/)?';
i += 3;
} else {
regex += '.*';
i += 2;
}
} else if (ch === '*') {
regex += '[^/]*';
i++;
} else if (ch === '?') {
regex += '[^/]';
i++;
} else {
regex += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
i++;
}
}
return new RegExp(`^${regex}$`).test(filePath);
}
// --- Filter DSL parser ---
/**
* Parse filter definitions from the input DSL.
*
* Supports two formats:
* Single-line: `name: pattern1 pattern2`
* Multi-line: `name:` followed by indented patterns (one per line)
*
* Lines starting with # and blank lines are ignored.
*/
export function parseFilters(input) {
const filters = new Map();
const lines = input.split('\n');
let currentFilter = null;
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const headerMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)?$/);
if (headerMatch) {
const name = headerMatch[1];
const rest = (headerMatch[2] || '').trim();
const patterns = [];
currentFilter = name;
filters.set(name, patterns);
if (rest) {
patterns.push(...rest.split(/\s+/));
currentFilter = null;
}
continue;
}
if (currentFilter && rawLine.match(/^\s/)) {
const patterns = filters.get(currentFilter);
if (patterns) patterns.push(line);
continue;
}
throw new Error(`Malformed filter input at: "${rawLine}"`);
}
for (const [name, patterns] of filters) {
if (patterns.length === 0) {
throw new Error(`Filter "${name}" has no patterns`);
}
}
return filters;
}
// --- Git operations ---
const SAFE_REF = /^[a-zA-Z0-9_./-]+$/;
export function getChangedFiles(baseRef) {
if (!SAFE_REF.test(baseRef)) {
throw new Error(`Unsafe base ref: "${baseRef}"`);
}
execSync(`git fetch --depth=1 origin ${baseRef}`, { stdio: 'pipe' });
const output = execSync('git diff --name-only FETCH_HEAD HEAD', { encoding: 'utf-8' });
return output
.split('\n')
.map((f) => f.trim())
.filter(Boolean);
}
// --- Filter evaluation ---
/**
* Evaluate a single filter against changed files using gitignore semantics.
* Patterns evaluated in order, last match wins. ! prefix excludes.
* Filter triggers if ANY changed file passes.
*/
export function evaluateFilter(changedFiles, patterns) {
for (const file of changedFiles) {
let included = false;
for (const pattern of patterns) {
if (pattern.startsWith('!')) {
if (matchGlob(file, pattern.slice(1))) {
included = false;
}
} else {
if (matchGlob(file, pattern)) {
included = true;
}
}
}
if (included) return true;
}
return false;
}
// --- Mode: filter ---
function setOutput(name, value) {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
const delimiter = `ghadelimiter_${Date.now()}`;
appendFileSync(outputFile, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
}
}
export function runFilter() {
const filtersInput = process.env.INPUT_FILTERS;
const baseRef = process.env.INPUT_BASE_REF;
if (!filtersInput) {
throw new Error('INPUT_FILTERS is required in filter mode');
}
if (!baseRef) {
throw new Error('INPUT_BASE_REF is required in filter mode');
}
const filters = parseFilters(filtersInput);
const changedFiles = getChangedFiles(baseRef);
console.log(`Changed files (${changedFiles.length}):`);
for (const f of changedFiles) {
console.log(` ${f}`);
}
const results = {};
for (const [name, patterns] of filters) {
const matched = evaluateFilter(changedFiles, patterns);
results[name] = matched;
console.log(`Filter "${name}": ${matched}`);
}
setOutput('results', JSON.stringify(results));
}
// --- Mode: validate ---
export function runValidate() {
const raw = process.env.INPUT_JOB_RESULTS;
if (!raw) {
throw new Error('INPUT_JOB_RESULTS is required in validate mode');
}
const jobResults = JSON.parse(raw);
const problems = [];
for (const [job, data] of Object.entries(jobResults)) {
if (data.result === 'failure') problems.push(`${job}: failed`);
if (data.result === 'cancelled') problems.push(`${job}: cancelled`);
}
if (problems.length > 0) {
console.error('Required checks failed:');
for (const p of problems) {
console.error(` - ${p}`);
}
process.exit(1);
}
console.log('All required checks passed:');
for (const [job, data] of Object.entries(jobResults)) {
console.log(` ${job}: ${data.result}`);
}
}
// --- Main (only when run directly, not when imported by tests) ---
if (resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1])) {
const mode = process.env.INPUT_MODE;
if (mode === 'filter') {
runFilter();
} else if (mode === 'validate') {
runValidate();
} else {
throw new Error(`Unknown mode: "${mode}". Expected "filter" or "validate".`);
}
}
@@ -29,22 +29,24 @@ runs:
steps:
- name: Login to GitHub Container Registry
if: inputs.login-ghcr == 'true'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
shell: bash
env:
GHCR_TOKEN: ${{ github.token }}
GHCR_USER: ${{ github.actor }}
run: |
node .github/scripts/retry.mjs --attempts 3 --delay 10 \
'echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin'
- name: Login to DockerHub
if: inputs.login-dockerhub == 'true'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
username: ${{ inputs.dockerhub-username }}
password: ${{ inputs.dockerhub-password }}
- name: Login to DHI Registry
if: inputs.login-dhi == 'true'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: dhi.io
username: ${{ inputs.dockerhub-username }}
+52 -13
View File
@@ -3,47 +3,86 @@
# GitHub-hosted runners use standard GitHub Actions caching.
name: 'Node.js Build Setup'
description: 'Configures Node.js with pnpm, installs dependencies, enables Turborepo caching, (optional) sets up Docker layer caching, and builds the project or an optional command.'
description: 'Configures Node.js with pnpm, installs Aikido SafeChain for supply chain protection, installs dependencies, enables Turborepo caching, (optional) sets up Docker layer caching, and builds the project or an optional command.'
inputs:
node-version:
description: 'Node.js version to use. Uses latest 22.x by default.'
description: 'Node.js version to use. Pinned to 24.14.1 by default for reproducible builds.'
required: false
default: '22.x'
default: '24.14.1'
enable-docker-cache:
description: 'Whether to set up Blacksmith Buildx for Docker layer caching (Blacksmith runners only).'
required: false
default: 'false'
type: boolean
build-command:
description: 'Command to execute for building the project or an optional command. Leave empty to skip build step.'
required: false
default: 'pnpm build'
type: string
install-command:
description: 'Command to execute for installing project dependencies. Leave empty to skip install step.'
required: false
default: 'pnpm install --frozen-lockfile'
runs:
using: 'composite'
steps:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ inputs.node-version }}
cache: 'pnpm'
# To avoid setup-node cache failure.
# see: https://github.com/actions/setup-node/issues/1137
- name: Verify PNPM Cache Directory
shell: bash
run: |
PNPM_STORE_PATH="$( pnpm store path --silent )"
if [ ! -d "$PNPM_STORE_PATH" ]; then
mkdir -p "$PNPM_STORE_PATH"
fi
- name: Install Aikido SafeChain
if: runner.os != 'Windows'
run: |
VERSION="1.4.1"
EXPECTED_SHA256="628235987175072a4255aa3f5f0128f31795b63970f1970ae8a04d07bf8527b0"
node .github/scripts/retry.mjs --attempts 3 --delay 10 \
"curl -fsSL -o install-safe-chain.sh https://github.com/AikidoSec/safe-chain/releases/download/${VERSION}/install-safe-chain.sh"
echo "${EXPECTED_SHA256} install-safe-chain.sh" | sha256sum -c -
sh install-safe-chain.sh --ci
rm install-safe-chain.sh
shell: bash
- name: Install Dependencies
run: pnpm install --frozen-lockfile
if: ${{ inputs.install-command != '' }}
run: |
${{ inputs.install-command }}
shell: bash
- name: Disable safe-chain
if: runner.os != 'Windows'
run: safe-chain teardown
shell: bash
- name: Configure Turborepo Cache
uses: rharkor/caching-for-turbo@cda201ff2b32699a2ae9f59704db029e3dde4fbd # v2.3.5
uses: rharkor/caching-for-turbo@0abc2381e688c4d2832f0665a68a01c6e82f0d6c # v2.3.11
- name: Setup Docker Builder for Docker Cache
if: ${{ inputs.enable-docker-cache == 'true' }}
uses: useblacksmith/setup-docker-builder@53647ab5afe8827af5623b35bd4302eabd41619f # v1.2.0
- name: Setup Docker Builder for Docker Cache (Blacksmith)
if: ${{ inputs.enable-docker-cache == 'true' && contains(runner.name, 'blacksmith') }}
uses: useblacksmith/setup-docker-builder@ef12d5b165b596e3aa44ea8198d8fde563eab402 # v1.4.0
- name: Setup Docker Builder (GitHub fallback)
if: ${{ inputs.enable-docker-cache == 'true' && !contains(runner.name, 'blacksmith') }}
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build Project
run: ${{ inputs.build-command }}
if: ${{ inputs.build-command != '' }}
run: |
${{ inputs.build-command }} --summarize
node .github/scripts/send-build-stats.mjs || true
node .github/scripts/send-docker-stats.mjs || true
shell: bash
+10
View File
@@ -9,6 +9,11 @@ services:
- 3306:3306
tmpfs:
- /var/lib/mysql
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', '-ppassword']
interval: 5s
timeout: 10s
retries: 10
mysql-8.4:
image: mysql:8.4
@@ -19,6 +24,11 @@ services:
- 3306:3306
tmpfs:
- /var/lib/mysql
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', '-ppassword']
interval: 5s
timeout: 10s
retries: 10
postgres:
image: postgres:16
+1 -1
View File
@@ -26,4 +26,4 @@ https://linear.app/n8n/issue/
A bug is not considered fixed, unless a test is added to prevent it from happening again.
A feature is not complete without tests.
-->
- [ ] PR Labeled with `release/backport` (if the PR is an urgent fix that needs to be backported)
- [ ] PR Labeled with `Backport to Beta`, `Backport to Stable`, or `Backport to v1` (if the PR is an urgent fix that needs to be backported)
@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* Builds the Claude task prompt and writes it to GITHUB_ENV.
* Uses a random delimiter to prevent heredoc collision with user input.
*
* Usage: node prepare-claude-prompt.mjs
*
* Environment variables:
* INPUT_TASK - The task description (required)
* USE_RAW_PROMPT - "true" to pass task directly without wrapping
* GITHUB_ENV - Path to GitHub env file (set by Actions)
*/
import { randomUUID } from 'node:crypto';
import { appendFileSync, readdirSync } from 'node:fs';
const task = process.env.INPUT_TASK;
const useRaw = process.env.USE_RAW_PROMPT === 'true';
const envFile = process.env.GITHUB_ENV;
if (!task) {
console.error('INPUT_TASK environment variable is required');
process.exit(1);
}
if (!envFile) {
console.error('GITHUB_ENV environment variable is required');
process.exit(1);
}
let prompt;
if (useRaw) {
prompt = task;
} else {
// List available templates so Claude knows what exists (reads them if needed)
const templateDir = '.github/claude-templates';
let templateSection = '';
try {
const files = readdirSync(templateDir).filter((f) => f.endsWith('.md'));
if (files.length > 0) {
const listing = files.map((f) => ` - ${templateDir}/${f}`).join('\n');
templateSection = `\n# Templates\nThese guides are available if relevant to your task. Read any that match before starting:\n${listing}`;
}
} catch {
// No templates directory, skip
}
prompt = `# Task
${task}
${templateSection}
# Instructions
1. Read any relevant templates listed above before starting
2. Complete the task described above
3. Make commits as you work - the last commit message will be used as the PR title
4. IMPORTANT: End every commit message with: Co-authored-by: Claude <[email protected]>
5. Ensure code passes linting and type checks before finishing
# Token Optimization
When running lint/typecheck, suppress verbose output:
pnpm lint 2>&1 | tail -30
pnpm typecheck 2>&1 | tail -30`;
}
// Random delimiter guarantees no collision with user content
const delimiter = `CLAUDE_PROMPT_DELIM_${randomUUID().replace(/-/g, '')}`;
appendFileSync(envFile, `CLAUDE_PROMPT<<${delimiter}\n${prompt}\n${delimiter}\n`);
@@ -0,0 +1,59 @@
#!/usr/bin/env node
/**
* Sends a callback to the resume URL with the Claude task result.
* Uses fetch() directly to avoid E2BIG errors from shell argument limits.
*
* Usage: node resume-callback.mjs
*
* Environment variables:
* RESUME_URL - Callback URL to POST to (required)
* EXECUTION_FILE - Path to Claude's execution output JSON (optional)
* CLAUDE_OUTCOME - "success" or "failure" (required)
* CLAUDE_SESSION_ID - Session ID for resuming conversations (optional)
* BRANCH_NAME - Git branch name (optional)
*/
import { existsSync, readFileSync } from 'node:fs';
const resumeUrl = process.env.RESUME_URL;
const executionFile = process.env.EXECUTION_FILE;
const claudeOutcome = process.env.CLAUDE_OUTCOME;
const sessionId = process.env.CLAUDE_SESSION_ID ?? '';
const branchName = process.env.BRANCH_NAME ?? '';
if (!resumeUrl) {
console.error('RESUME_URL environment variable is required');
process.exit(1);
}
const success = claudeOutcome === 'success';
let result = null;
if (executionFile && existsSync(executionFile)) {
try {
const execution = JSON.parse(readFileSync(executionFile, 'utf-8'));
// Extract the last element (Claude's final result message)
result = Array.isArray(execution) ? execution.at(-1) : execution;
} catch (err) {
console.warn(`Failed to parse execution file: ${err.message}`);
}
}
const payload = JSON.stringify({ success, branch: branchName, sessionId, result });
try {
const response = await fetch(resumeUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
});
if (!response.ok) {
const body = await response.text();
console.error(`Callback failed: ${body}`);
process.exit(1);
}
} catch (err) {
console.error(`Callback error: ${err.message}`);
process.exit(1);
}
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env node
/**
* Cleanup GHCR images for n8n CI
*
* Modes:
* --tag <tag> Delete exact tag (post-run cleanup)
* --stale <days> Delete ci-* images older than N days (daily scheduled cleanup)
*
* Context:
* - Each CI run tags images as ci-{run_id}
* - Post-run cleanup uses --tag to delete the current run's images
* - Daily cron uses --stale to catch any orphaned images
*/
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
const execAsync = promisify(exec);
const ORG = process.env.GHCR_ORG || 'n8n-io';
const REPO = process.env.GHCR_REPO || 'n8n';
const PACKAGES = [REPO, 'runners'];
const [mode, rawValue] = process.argv.slice(2);
if (!['--tag', '--stale'].includes(mode) || !rawValue) {
console.error('Usage: cleanup-ghcr-images.mjs --tag <tag> | --stale <days>');
process.exit(1);
}
const value = mode === '--stale' ? parseInt(rawValue, 10) : rawValue;
if (mode === '--stale' && (isNaN(value) || value <= 0)) {
console.error('Error: --stale requires a positive number of days');
process.exit(1);
}
async function ghApi(path) {
const { stdout } = await execAsync(
`gh api "/orgs/${ORG}/packages/container/${path}"`,
);
return JSON.parse(stdout);
}
async function ghDelete(path) {
await execAsync(`gh api --method DELETE "/orgs/${ORG}/packages/container/${path}"`);
}
async function fetchPage(pkg, page) {
try {
return await ghApi(`${pkg}/versions?per_page=100&page=${page}`);
} catch (err) {
if (err.code === 1 && err.stderr?.includes('404')) return [];
throw new Error(`Failed to fetch ${pkg} page ${page}: ${err.message}`);
}
}
const isCiImage = (v) => {
const tags = v.metadata?.container?.tags || [];
return tags.some((t) => t.startsWith('ci-') || t.startsWith('pr-'));
};
const isStale = (v, days) => {
const cutoff = Date.now() - days * 86400000;
return isCiImage(v) && new Date(v.created_at) < cutoff;
};
async function getVersionsForTag(pkg, tag) {
const batch = await fetchPage(pkg, 1);
const match = batch.find((v) => v.metadata?.container?.tags?.includes(tag));
return match ? [match] : [];
}
async function getVersionsForStale(pkg, days) {
const versions = [];
const cutoff = Date.now() - days * 86400000;
// Use 2x cutoff as safety window for early termination
const earlyExitCutoff = Date.now() - days * 2 * 86400000;
let pagesWithoutCiImages = 0;
const firstPage = await fetchPage(pkg, 1);
if (!firstPage.length) return [];
for (const v of firstPage) {
if (isStale(v, days)) versions.push(v);
}
if (firstPage.length < 100) return versions;
for (let page = 2; ; page += 10) {
const batches = await Promise.all(
Array.from({ length: 10 }, (_, i) => fetchPage(pkg, page + i)),
);
let done = false;
for (const batch of batches) {
if (!batch.length || batch.length < 100) done = true;
let hasCiImages = false;
for (const v of batch) {
if (isCiImage(v)) {
hasCiImages = true;
if (new Date(v.created_at) < cutoff) versions.push(v);
}
}
// Early termination: if we've gone through pages without finding
// any CI images and all items are older than 2x cutoff, we're past
// the CI image window
if (!hasCiImages) {
pagesWithoutCiImages++;
const oldestInBatch = batch[batch.length - 1];
if (
pagesWithoutCiImages >= 3 &&
oldestInBatch &&
new Date(oldestInBatch.created_at) < earlyExitCutoff
) {
console.log(` Early termination at page ${page + batches.indexOf(batch)}`);
done = true;
}
} else {
pagesWithoutCiImages = 0;
}
if (!batch.length || done) break;
}
if (done) break;
}
return versions;
}
let hasErrors = false;
for (const pkg of PACKAGES) {
console.log(`Processing ${pkg}...`);
let consecutiveErrors = 0;
const toDelete =
mode === '--tag'
? await getVersionsForTag(pkg, value)
: await getVersionsForStale(pkg, value);
if (!toDelete.length) {
console.log(` No matching images found`);
continue;
}
for (const v of toDelete) {
try {
await ghDelete(`${pkg}/versions/${v.id}`);
console.log(` Deleted ${v.metadata.container.tags.join(',')}`);
consecutiveErrors = 0;
} catch (err) {
console.error(` Failed to delete ${v.id}: ${err.message}`);
hasErrors = true;
if (++consecutiveErrors >= 3) {
throw new Error('Too many consecutive delete failures, aborting');
}
}
}
}
if (hasErrors) process.exit(1);
+123
View File
@@ -0,0 +1,123 @@
import fs from 'node:fs/promises';
import { getOctokit } from '@actions/github';
import { ensureEnvVar, readPrLabels } from './github-helpers.mjs';
/**
* @typedef {PullRequestCheckPass | PullRequestCheckFail} PullRequestCheckResult
**/
/**
* @typedef PullRequestCheckPass
* @property {true} pass
* @property {string} baseRef
* */
/**
* @typedef PullRequestCheckFail
* @property {false} pass
* @property {string} reason
* */
/**
* @param {PullRequestCheckResult} pullRequestCheck
*
* @returns { pullRequestCheck is PullRequestCheckFail }
* */
function pullRequestCheckFailed(pullRequestCheck) {
return !pullRequestCheck.pass;
}
/**
* @param {any} pullRequest
* @returns {PullRequestCheckResult}
*/
export function pullRequestIsDismissedRelease(pullRequest) {
if (!pullRequest) {
throw new Error('Missing pullRequest in event payload');
}
const baseRef = pullRequest?.base?.ref ?? '';
const headRef = pullRequest?.head?.ref ?? '';
const merged = Boolean(pullRequest?.merged);
if (merged) {
return { pass: false, reason: 'PR was merged' };
}
// Must match your release PR pattern:
// base: release/<ver>
// head: release-pr/<ver>
if (!baseRef.startsWith('release/')) {
return { pass: false, reason: `Base ref '${baseRef}' is not release/*` };
}
if (!headRef.startsWith('release-pr/')) {
return { pass: false, reason: `Head ref '${headRef}' is not release-pr/*` };
}
const baseVer = baseRef.slice('release/'.length);
const headVer = headRef.slice('release-pr/'.length);
if (!baseVer || baseVer !== headVer) {
return { pass: false, reason: `Version mismatch: base='${baseVer}' head='${headVer}'` };
}
const labelNames = readPrLabels(pullRequest);
if (!labelNames.includes('release')) {
return {
pass: false,
reason: `Missing required label 'release' (labels: ${labelNames.join(', ') || '[none]'})`,
};
}
return { pass: true, baseRef };
}
async function main() {
const token = ensureEnvVar('GITHUB_TOKEN');
const eventPath = ensureEnvVar('GITHUB_EVENT_PATH');
const repoFullName = ensureEnvVar('GITHUB_REPOSITORY');
const [owner, repo] = repoFullName.split('/');
if (!owner || !repo) {
throw new Error(`Invalid GITHUB_REPOSITORY: '${repoFullName}'`);
}
const rawEventData = await fs.readFile(eventPath, 'utf8');
const event = JSON.parse(rawEventData);
const result = pullRequestIsDismissedRelease(event.pull_request);
if (pullRequestCheckFailed(result)) {
console.log(`no-op: ${result.reason}`);
return;
}
const branch = result.baseRef; // e.g. "release/2.11.0"
console.log(`PR qualifies. Deleting branch '${branch}'...`);
const octokit = getOctokit(token);
try {
await octokit.rest.git.deleteRef({
owner,
repo,
// ref must be "heads/<branch>"
ref: `heads/${branch}`,
});
console.log(`Deleted '${branch}'.`);
} catch (err) {
// If it was already deleted, treat as success.
const status = err?.status;
if (status === 404) {
console.log(`Branch '${branch}' not found (already deleted).`);
return;
}
console.error(err);
throw new Error(`Failed to delete '${branch}'.`);
}
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
await main();
}
@@ -0,0 +1,147 @@
import { describe, it, mock, before } from 'node:test';
import assert from 'node:assert/strict';
import { readPrLabels } from './github-helpers.mjs';
/**
* Run these tests by running
*
* node --test --experimental-test-module-mocks ./.github/scripts/cleanup-release-branch.test.mjs
* */
// mock.module must be called before the module under test is imported,
// because static imports are hoisted and resolve before any code runs.
mock.module('./github-helpers.mjs', {
namedExports: {
ensureEnvVar: () => {}, // no-op
readPrLabels: (pr) => {
return readPrLabels(pr);
},
},
});
let pullRequestIsDismissedRelease;
before(async () => {
({ pullRequestIsDismissedRelease } = await import('./cleanup-release-branch.mjs'));
});
describe('pullRequestIsDismissedRelease', () => {
it('Recognizes classic dismissed pull request', () => {
const pullRequest = {
merged: false,
labels: ['release'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, true);
assert.equal(result.reason, undefined);
});
it("Doesn't pass PR with malformed head", () => {
const pullRequest = {
merged: false,
labels: ['release'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'my-fork-release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(result.reason, `Head ref '${pullRequest.head.ref}' is not release-pr/*`);
});
it("Doesn't pass PR with malformed base", () => {
const pullRequest = {
merged: false,
labels: ['release'],
base: {
ref: 'master',
},
head: {
ref: 'release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(result.reason, `Base ref '${pullRequest.base.ref}' is not release/*`);
});
it("Doesn't pass merged PR's", () => {
const pullRequest = {
merged: true,
labels: ['release'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(result.reason, `PR was merged`);
});
it("Doesn't pass on PR version mismatch", () => {
const pullRequest = {
merged: false,
labels: ['release'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'release-pr/2.9.1',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(
result.reason,
`Version mismatch: base='${pullRequest.base.ref.replace('release/', '')}' head='${pullRequest.head.ref.replace('release-pr/', '')}'`,
);
});
it("Doesn't pass a PR with missing 'release' label", () => {
const pullRequest = {
merged: false,
labels: ['release-pr', 'core-team'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(
result.reason,
`Missing required label 'release' (labels: ${pullRequest.labels.join(', ')})`,
);
});
});
@@ -0,0 +1,109 @@
// Creates backport PR's according to labels on merged PR
import {
getPullRequestById,
readPrLabels,
resolveRcBranchForTrack,
writeGithubOutput,
} from './github-helpers.mjs';
/** @type { Record<string, import('./github-helpers.mjs').ReleaseTrack> } */
const BACKPORT_BY_TAG_MAP = {
'Backport to Beta': 'beta',
'Backport to Stable': 'stable',
};
const BACKPORT_BY_BRANCH_MAP = {
'Backport to v1': '1.x',
};
/**
* @param {Set<string>} labels
*
* @returns { Set<string> }
*/
export function labelsToReleaseCandidateBranches(labels) {
const targets = new Set();
// Backport by tag map includes mapping of label to git tag to resolve
for (const [label, tag] of Object.entries(BACKPORT_BY_TAG_MAP)) {
// Check if backport label is present
if (!labels.has(label)) {
continue;
}
const branch = resolveRcBranchForTrack(tag);
// Make sure our backport branch exists
if (!branch) {
continue;
}
targets.add(branch);
}
// Backport by branch map includes mapping of label to git branch. This is used for
// older versions of n8n. v1, etc.
for (const [label, branch] of Object.entries(BACKPORT_BY_BRANCH_MAP)) {
// Check if backport label is present
if (!labels.has(label)) {
continue;
}
targets.add(branch);
}
return targets;
}
/**
* This script is called in 2 cases:
*
* 1. When a PR is merged, in which case functions like `readPrLabels` reads PR info from GITHUB_EVENT_PATH
* 2. Manually via Workflow Dispatch, where a Pull Request ID is passed as an env parameter
*
* @returns { Promise<undefined | any> } Pull request object, if ID was provided in env params
*/
async function fetchPossiblePullRequestFromEnv() {
const pullRequestEnv = process.env.PULL_REQUEST_ID;
if (!pullRequestEnv) {
// No ID provided, will proceed to read data from GITHUB_EVENT_PATH
return undefined;
}
const pullRequestNumber = parseInt(pullRequestEnv);
if (isNaN(pullRequestNumber)) {
throw new Error(
"PULL_REQUEST_ID must be a number. It shouldn't contain any other symbols (#, PR, etc.)",
);
}
return await getPullRequestById(pullRequestNumber);
}
export async function getLabels() {
const pullRequest = await fetchPossiblePullRequestFromEnv();
return new Set(readPrLabels(pullRequest));
}
async function main() {
const labels = await getLabels();
if (!labels || labels.size === 0) {
console.log('No labels on PR. Exiting...');
return;
}
const backportBranches = labelsToReleaseCandidateBranches(labels);
if (backportBranches.size === 0) {
console.log('No backports needed. Exiting...');
return;
}
const target_branches = [...backportBranches].join(' '); // korthout/backport-action@v4 uses space-delimited branch list
writeGithubOutput({ target_branches });
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
await main();
}
@@ -0,0 +1,104 @@
import { describe, it, mock, before } from 'node:test';
import assert from 'node:assert/strict';
import { readPrLabels } from './github-helpers.mjs';
/**
* Run these tests by running
*
* node --test --experimental-test-module-mocks ./.github/scripts/compute-backport-targets.test.mjs
* */
// mock.module must be called before the module under test is imported,
// because static imports are hoisted and resolve before any code runs.
mock.module('./github-helpers.mjs', {
namedExports: {
ensureEnvVar: () => {}, // no-op
readPrLabels: readPrLabels,
resolveRcBranchForTrack: mockResolveRcBranchForTrack,
writeGithubOutput: () => {}, //no-op
getPullRequestById: () => {
return {
labels: ['n8n team', 'Backport to Beta'],
};
},
},
});
function mockResolveRcBranchForTrack(track) {
switch (track) {
case 'beta':
return 'release-candidate/2.10.1';
case 'stable':
return 'release-candidate/2.9.4';
}
return undefined;
}
let labelsToReleaseCandidateBranches, getLabels;
before(async () => {
({ labelsToReleaseCandidateBranches, getLabels } = await import(
'./compute-backport-targets.mjs'
));
});
describe('Compute backport targets', () => {
it('Finds backport branches for pointer tag labels', () => {
const labels = new Set(['Backport to Beta', 'Backport to Stable']);
/** @type { Set<string> } */
const result = labelsToReleaseCandidateBranches(labels);
assert.equal(result.size, 2);
assert.ok(result.has('release-candidate/2.10.1'));
assert.ok(result.has('release-candidate/2.9.4'));
});
it("Doesn't parse other labes to backport branches", () => {
const labels = new Set(['n8n team', 'release']);
/** @type { Set<string> } */
const result = labelsToReleaseCandidateBranches(labels);
assert.equal(result.size, 0);
});
it("Doesn't parse malformed backport labels", () => {
const labels = new Set(['Backport to Fork', 'Backport to my Home']);
/** @type { Set<string> } */
const result = labelsToReleaseCandidateBranches(labels);
assert.equal(result.size, 0);
});
it('Should parse labels properly in Pull request context', async () => {
process.env.GITHUB_EVENT_PATH = './fixtures/mock-github-event.json';
/** @type { Set<string> } */
const labels = await getLabels();
assert.equal(labels.size, 2);
assert.ok(labels.has('release'));
assert.ok(labels.has('Backport to Stable'));
});
it('Should parse labels properly in manual workflow context', async () => {
process.env.PULL_REQUEST_ID = '123';
/** @type { Set<string> } */
const labels = await getLabels();
assert.equal(labels.size, 2);
assert.ok(labels.has('n8n team'));
assert.ok(labels.has('Backport to Beta'));
});
it('Should throw when passed pull request id with #', async () => {
process.env.PULL_REQUEST_ID = '#123';
await assert.rejects(getLabels);
});
it('Should not throw when passed pull request id with just a number', async () => {
process.env.PULL_REQUEST_ID = '123';
await assert.doesNotReject(getLabels);
});
it('Should throw when passed pull request id with other than numbers included', async () => {
process.env.PULL_REQUEST_ID = 'abc-123';
await assert.rejects(getLabels);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Detects packages in the monorepo that have not yet been published to npm.
*
* Packages that are new (never published) cannot be released via OIDC Trusted
* Publishing because Trusted Publishing requires the package to already exist
* on npm with the publisher configured first.
*
* New packages must be handled manually:
* 1. Published once using an NPM token
* 2. Configured with Trusted Publishing on npmjs.com
*
* Exit codes:
* 0 – All public packages exist on npm
* 1 – One or more public packages have never been published
*/
import child_process from 'child_process';
import { promisify } from 'util';
const exec = promisify(child_process.exec);
const packages = JSON.parse((await exec('pnpm ls -r --only-projects --json')).stdout);
const newPackages = [];
for (const { name, private: isPrivate } of packages) {
if (isPrivate) continue;
// Scoped packages must be encoded: @n8n/foo → @n8n%2Ffoo
const encodedName = name.startsWith('@') ? name.replace('/', '%2F') : name;
const url = `https://registry.npmjs.org/${encodedName}`;
try {
const response = await fetch(url, { method: 'HEAD' });
if (response.status === 404) {
newPackages.push(name);
} else if (!response.ok && response.status !== 405) {
// 405 = Method Not Allowed for HEAD (some registries), not an error
console.log(
`::warning::Unexpected HTTP ${response.status} when checking npm registry for "${name}". Skipping check.`,
);
}
} catch (error) {
console.log(
`::warning::Could not reach npm registry for "${name}": ${error.message}. Skipping check.`,
);
}
}
if (newPackages.length === 0) {
const publicCount = packages.filter((p) => !p.private).length;
console.log(`✅ All ${publicCount} public packages exist on npm.`);
process.exit(0);
}
console.log(`
❌ New unpublished packages detected!
The following packages do not yet exist on npm and cannot be published via
OIDC Trusted Publishing until they have been published at least once manually:
`);
for (const pkg of newPackages) {
console.log(` - ${pkg}`);
console.log(
`::error::Package "${pkg}" has never been published to npm. A manual first-publish with an NPM token is required before it can use OIDC Trusted Publishing.`,
);
}
console.log(`
Steps to unblock the release, for each new package listed above:
1. Publish the package once manually using an NPM token:
cd to/where/package/lives
pnpm login
pnpm publish --access public
2. Configure Trusted Publishing on npmjs.com for each new package:
https://docs.npmjs.com/trusted-publishers
Use the following settings:
Repository owner : n8n-io
Repository name : n8n
Workflow filename: release-publish.yml
3. Re-run the Release: Publish workflow.
`);
process.exit(1);
@@ -0,0 +1,39 @@
import {
countCommitsBetweenRefs,
ensureEnvVar,
listCommitsBetweenRefs,
resolveRcBranchForTrack,
resolveReleaseTagForTrack,
writeGithubOutput,
} from './github-helpers.mjs';
function main() {
const track = /** @type { import('./github-helpers.mjs').ReleaseTrack } */ (
ensureEnvVar('TRACK')
);
const currentTag = resolveReleaseTagForTrack(track);
const releaseCandidateBranch = resolveRcBranchForTrack(track);
if (!currentTag?.tag || !releaseCandidateBranch) {
throw new Error(
`Couldn't resolve needed parameters. currentTag.tag=${currentTag?.tag}, releaseCandidateBranch=${releaseCandidateBranch}`,
);
}
console.log(`Commits between ${releaseCandidateBranch} and ${currentTag.tag}:`);
console.log(listCommitsBetweenRefs(releaseCandidateBranch, currentTag.tag));
const commitCount = countCommitsBetweenRefs(releaseCandidateBranch, currentTag.tag);
writeGithubOutput({
release_candidate_branch: releaseCandidateBranch,
should_update: commitCount > 0 ? 'true' : 'false',
});
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
@@ -0,0 +1,46 @@
import { ensureEnvVar, sh, writeGithubOutput } from './github-helpers.mjs';
function determineReleaseVersionChanges() {
const previousVersion = ensureEnvVar('PREVIOUS_VERSION_TAG');
const releaseVersion = ensureEnvVar('RELEASE_VERSION_TAG');
const log = sh('git', [
'--no-pager',
'log',
'--format="%s (%h)"',
`${previousVersion}..${releaseVersion}`,
]);
writeGithubOutput({
has_node_enhancements: hasNodeEnhancements(log),
has_core_changes: hasCoreChanges(log),
});
}
/**
* Matches commit messages with
*
* fix(nodes)
* fix(xyz Node)
* feat(nodes)
* feat(xyz Node)
*
* @param {string} log
*/
export function hasNodeEnhancements(log) {
return /(fix|feat)\((.*Node|nodes)\)/.test(log);
}
/**
* Matches commit messages with feat(core) or feat(editor)
*
* @param {string} log
*/
export function hasCoreChanges(log) {
return /feat\((core|editor)\)/.test(log);
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
determineReleaseVersionChanges();
}
@@ -0,0 +1,47 @@
import { describe, it, mock, before } from 'node:test';
import assert from 'node:assert/strict';
/**
* Run these tests by running
*
* node --test --experimental-test-module-mocks ./.github/scripts/determine-release-version-changes.test.mjs
* */
// mock.module must be called before the module under test is imported,
// because static imports are hoisted and resolve before any code runs.
mock.module('./github-helpers.mjs', {
namedExports: {
ensureEnvVar: () => {}, // no-op
sh: () => {}, // no-op
writeGithubOutput: () => {}, // no-op
},
});
let hasNodeEnhancements, hasCoreChanges;
before(async () => {
({ hasNodeEnhancements, hasCoreChanges } = await import(
'./determine-release-version-changes.mjs'
));
});
describe('Determine release version changes', () => {
it('Matches nodes feature', () => {
assert.ok(hasNodeEnhancements('feat(nodes): Added a utility for node'));
});
it('Matches nodes fix', () => {
assert.ok(hasNodeEnhancements('fix(nodes): Fix said utility'));
});
it('Matches named node feature', () => {
assert.ok(hasNodeEnhancements('feat(Github Actions Node): Add ability to call webhooks'));
});
it('Matches named node fix', () => {
assert.ok(hasNodeEnhancements('fix(OpenAI Node): Allow credentials to pass through'));
});
it('Matches core changes', () => {
assert.ok(hasCoreChanges('feat(core): Add cli flag'));
});
it('Matches editor changes', () => {
assert.ok(hasCoreChanges('feat(editor): Add button'));
});
});
+132
View File
@@ -0,0 +1,132 @@
import { readFileSync } from 'node:fs';
import { RELEASE_TRACKS, resolveReleaseTagForTrack, writeGithubOutput } from './github-helpers.mjs';
import { tagVersionInfoToReleaseCandidateBranchName } from './ensure-release-candidate-branches.mjs';
import semver from 'semver';
/**
* @param {any} packageVersion
*/
export function determineTrack(packageVersion) {
if (!semver.valid(packageVersion)) {
throw new Error(`Package semver not valid. Got ${packageVersion}`);
}
/** @type { Partial<Record<import('./github-helpers.mjs').ReleaseTrack, import('./github-helpers.mjs').TagVersionInfo>> } */
const trackToReleaseMap = {};
for (const t of RELEASE_TRACKS) {
trackToReleaseMap[t] = resolveReleaseTagForTrack(t);
}
console.log('Current Tracks: ', JSON.stringify(trackToReleaseMap, null, 4));
let track = null;
let newStable = null;
let bump = determineBump(packageVersion);
const releaseType = determineReleaseType(packageVersion);
// Check through our current release versions, if semver matches,
// we inherit the track pointer from them
for (const [releaseTrack, tagVersionInfo] of Object.entries(trackToReleaseMap)) {
if (tagVersionInfo && matchesTrack(tagVersionInfo, packageVersion)) {
track = releaseTrack;
break;
}
}
if (!track) {
if (!trackToReleaseMap.beta?.version) {
throw new Error(
'Likely updating to new beta release, but no existing beta tag was found in git.',
);
}
// If not track was found in current versions, we verify we're building a
// new beta version and the input is not invalid.
assertNewBetaRelease(trackToReleaseMap.beta.version, packageVersion);
track = 'beta';
newStable = trackToReleaseMap.beta.version;
}
if (!track) {
throw new Error('Could not determine track for release. Exiting...');
}
const rc_branch = tagVersionInfoToReleaseCandidateBranchName({
version: packageVersion,
tag: /** @type {import('./github-helpers.mjs').ReleaseVersion} */ (`n8n@${packageVersion}`),
});
const previousVersion = trackToReleaseMap[track]?.version;
const output = {
previous_version: previousVersion,
version: packageVersion,
track,
bump,
new_stable_version: newStable,
release_type: releaseType,
rc_branch,
};
writeGithubOutput(output);
console.log(
`Determined track info: track=${track}, version=${packageVersion}, previous_version=${previousVersion}, new_stable_version=${newStable}, release_type=${releaseType}, rc_branch=${rc_branch}`,
);
return output;
}
/**
* The current version matches the track, if their Major and Minor semvers match.
*
* This means that we are working with a patch release
*
* @param {import("./github-helpers.mjs").TagVersionInfo} tagVersionInfo
* @param {any} currentVersion
*/
function matchesTrack(tagVersionInfo, currentVersion) {
if (semver.major(tagVersionInfo.version) !== semver.major(currentVersion)) {
return false;
}
if (semver.minor(tagVersionInfo.version) !== semver.minor(currentVersion)) {
return false;
}
return true;
}
/**
* @param {string} currentBetaVersion
* @param {any} currentVersion
*/
function assertNewBetaRelease(currentBetaVersion, currentVersion) {
if (semver.major(currentBetaVersion) !== semver.major(currentVersion)) {
throw new Error('Major version bumps are not allowed by this pipeline');
}
const bumpedCurrentBeta = semver.inc(currentBetaVersion, 'minor');
if (semver.minor(bumpedCurrentBeta) !== semver.minor(currentVersion)) {
throw new Error(
`Trying to upgrade minor version by more than one increment. Previous: ${bumpedCurrentBeta}, Requested: ${currentVersion}`,
);
}
}
function determineReleaseType(currentVersion) {
if (currentVersion.includes('-rc.')) {
return 'rc';
}
return 'stable';
}
function determineBump(currentVersion) {
if (semver.patch(currentVersion) === 0 && determineReleaseType(currentVersion) != 'rc') {
return 'minor';
}
return 'patch';
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
const packageJson = JSON.parse(readFileSync('./package.json', 'utf8'));
determineTrack(packageJson.version);
}
@@ -0,0 +1,105 @@
import { describe, it, mock, before } from 'node:test';
import assert from 'node:assert/strict';
/**
* Run these tests by running
*
* node --test --experimental-test-module-mocks ./.github/scripts/determine-version-info.test.mjs
* */
// mock.module must be called before the module under test is imported,
// because static imports are hoisted and resolve before any code runs.
mock.module('./github-helpers.mjs', {
namedExports: {
RELEASE_TRACKS: ['stable', 'beta', 'v1'],
resolveReleaseTagForTrack: (track) => {
// Always return deterministic data
if (track === 'stable') return { version: '2.9.2', tag: '[email protected]' };
if (track === 'beta') return { version: '2.10.1', tag: '[email protected]' };
return { version: '1.123.33', tag: '[email protected]' };
},
writeGithubOutput: () => {}, // no-op in tests
getCommitForRef: () => {}, // no-op
localRefExists: () => {}, // no-op
remoteBranchExists: () => {}, // no-op
sh: () => {}, // no-op
},
});
let determineTrack;
before(async () => {
({ determineTrack } = await import('./determine-version-info.mjs'));
});
describe('determine-tracks', () => {
it('Allow patch releases on stable', () => {
const output = determineTrack('2.9.3');
assert.equal(output.track, 'stable');
assert.equal(output.version, '2.9.3');
assert.equal(output.previous_version, '2.9.2');
assert.equal(output.bump, 'patch');
assert.equal(output.new_stable_version, null);
assert.equal(output.release_type, 'stable');
assert.equal(output.rc_branch, 'release-candidate/2.9.x');
});
it('Allow patch releases on beta', () => {
const output = determineTrack('2.10.2');
assert.equal(output.track, 'beta');
assert.equal(output.version, '2.10.2');
assert.equal(output.previous_version, '2.10.1');
assert.equal(output.bump, 'patch');
assert.equal(output.new_stable_version, null);
assert.equal(output.release_type, 'stable');
assert.equal(output.rc_branch, 'release-candidate/2.10.x');
});
// This use case might happen if a patch release fails and we proceed with rolling over to next release
it('Allow skipping versions in patches', () => {
const output = determineTrack('2.9.4');
assert.equal(output.track, 'stable');
assert.equal(output.version, '2.9.4');
assert.equal(output.previous_version, '2.9.2');
assert.equal(output.bump, 'patch');
assert.equal(output.new_stable_version, null);
assert.equal(output.release_type, 'stable');
assert.equal(output.rc_branch, 'release-candidate/2.9.x');
});
it('Disallow skipping versions in minors', () => {
assert.throws(() => determineTrack('2.12.0'));
});
it('Disallow changing major version', () => {
assert.throws(() => determineTrack('3.0.0'));
});
it('Throw when track is not determinable', () => {
assert.throws(() => determineTrack(''));
});
it('Set track as "beta" when doing a minor bump', () => {
const output = determineTrack('2.11.0');
assert.equal(output.track, 'beta');
assert.equal(output.version, '2.11.0');
assert.equal(output.previous_version, '2.10.1');
assert.equal(output.bump, 'minor');
assert.equal(output.new_stable_version, '2.10.1');
assert.equal(output.release_type, 'stable');
assert.equal(output.rc_branch, 'release-candidate/2.11.x');
});
it('Set release_type accordingly on rc releases', () => {
const output = determineTrack('2.10.2-rc.1');
assert.equal(output.track, 'beta');
assert.equal(output.version, '2.10.2-rc.1');
assert.equal(output.previous_version, '2.10.1');
assert.equal(output.bump, 'patch');
assert.equal(output.new_stable_version, null);
assert.equal(output.release_type, 'rc');
assert.equal(output.rc_branch, 'release-candidate/2.10.x');
});
});
+1 -1
View File
@@ -31,7 +31,7 @@ class BuildContext {
case 'pull_request':
context.version = `pr-${pr}`;
context.release_type = 'dev';
context.push_to_ghcr = false;
context.platforms = ['linux/amd64'];
break;
case 'workflow_dispatch':
@@ -0,0 +1,156 @@
import semver from 'semver';
import {
getCommitForRef,
localRefExists,
remoteBranchExists,
resolveReleaseTagForTrack,
sh,
writeGithubOutput,
} from './github-helpers.mjs';
const RELEASE_CANDIDATE_BRANCH_PREFIX = 'release-candidate/';
/**
* @typedef BranchChanges
* @property { import('./github-helpers.mjs').TagVersionInfo[] } branchesToEnsure TagVersionInfo for branches the system needs to make sure exist
* @property { string[] } branchesToDeprecate Branches the system needs to remove as deprecated
* */
/**
* Look into git tags and determine which release candidate branches need to
* exist and which need to be deprecated and removed.
*
* @returns { BranchChanges }
* */
export function determineBranchChanges() {
const branchesToDeprecate = [];
const currentBetaVersion = resolveReleaseTagForTrack('beta');
const currentStableVersion = resolveReleaseTagForTrack('stable');
if (!currentBetaVersion || !currentStableVersion) {
throw new Error(
`Could not find current stable and/or beta tags. Beta: ${currentBetaVersion?.tag ?? 'not found'}, Stable: ${currentStableVersion?.tag ?? 'not found'}`,
);
}
const branchesToEnsure = [currentBetaVersion, currentStableVersion];
const stableVersion = currentStableVersion.version;
// Deprecated branch is the current stable minus 2 versions. e.g. stable: 2.9.x, deprecated is 2.7.x
const deprecatedMinorVersion = semver.minor(stableVersion) - 2;
if (deprecatedMinorVersion >= 0) {
const deprecatedBranch = `${RELEASE_CANDIDATE_BRANCH_PREFIX}${semver.major(stableVersion)}.${deprecatedMinorVersion}.x`;
branchesToDeprecate.push(deprecatedBranch);
}
return {
branchesToEnsure,
branchesToDeprecate,
};
}
/**
* Takes a TagVersionInfo object and returns a rc-branch name.
*
* e.g. release-candidate/2.8.x
*
* @param {import('./github-helpers.mjs').TagVersionInfo} tagVersionInfo
*
* @returns { `${RELEASE_CANDIDATE_BRANCH_PREFIX}${number}.${number}.x` }
* */
export function tagVersionInfoToReleaseCandidateBranchName(tagVersionInfo) {
const version = tagVersionInfo.version;
return `${RELEASE_CANDIDATE_BRANCH_PREFIX}${semver.major(version)}.${semver.minor(version)}.x`;
}
/**
* @param {import("./github-helpers.mjs").TagVersionInfo} tagInfo
*/
function ensureBranch(tagInfo) {
const branch = tagVersionInfoToReleaseCandidateBranchName(tagInfo);
if (remoteBranchExists(branch)) {
console.log(`Branch ${branch} already exists on origin. Skipping.`);
return branch;
}
const commitRef = getCommitForRef(tagInfo.tag);
console.log(`Creating branch ${branch} from ${tagInfo.tag} (${commitRef})`);
// Create local branch (force safe: it shouldn't exist, but keep it robust)
if (localRefExists(`refs/heads/${branch}`)) {
sh('git', ['branch', '-f', branch, commitRef]);
} else {
sh('git', ['switch', '-c', branch, commitRef]);
}
sh('git', ['push', 'origin', branch]);
return branch;
}
/**
* @param {string} branch
*/
function removeBranch(branch) {
if (!remoteBranchExists(branch)) {
console.log(`Couldn't find branch ${branch}. Skipping removal.`);
return null;
}
console.log(`Removing remote branch ${branch} from origin...`);
// Delete remote branch
sh('git', ['push', 'origin', '--delete', branch]);
// Optional local cleanup (keeps reruns tidy)
if (localRefExists(`refs/heads/${branch}`)) {
console.log(`Removing local branch ${branch}...`);
sh('git', ['branch', '-D', branch]);
}
return branch;
}
function main() {
const branchChanges = determineBranchChanges();
console.log('💡 Determined branch changes');
console.log('');
console.log(
` Branches to ensure: ${branchChanges.branchesToEnsure.map(tagVersionInfoToReleaseCandidateBranchName).join(', ')}`,
);
console.log(` Branches to deprecate: ${branchChanges.branchesToDeprecate.join(', ')}`);
console.log('');
console.log('Preparing to apply changes...');
let ensuredBranches = [];
for (const tagInfo of branchChanges.branchesToEnsure) {
const branch = ensureBranch(tagInfo);
ensuredBranches.push(branch);
}
console.log('');
console.log('Starting deprecation of branches...');
let removedBranches = [];
for (const branch of branchChanges.branchesToDeprecate) {
const removedBranch = removeBranch(branch);
if (removedBranch) {
removedBranches.push(removedBranch);
}
}
console.log('Done!');
writeGithubOutput({
ensuredBranches: ensuredBranches.join(','),
removedBranches: removedBranches.join(','),
});
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
@@ -0,0 +1,62 @@
import { describe, it, mock, before } from 'node:test';
import assert from 'node:assert/strict';
/**
* Run these tests by running
*
* node --test --experimental-test-module-mocks ./.github/scripts/ensure-release-candidate-branches.test.mjs
* */
// mock.module must be called before the module under test is imported,
// because static imports are hoisted and resolve before any code runs.
mock.module('./github-helpers.mjs', {
namedExports: {
RELEASE_TRACKS: ['stable', 'beta', 'v1'],
RELEASE_PREFIX: 'n8n@',
resolveReleaseTagForTrack: (track) => {
// Always return deterministic data
if (track === 'stable') return { version: '2.9.2', tag: '[email protected]' };
if (track === 'beta') return { version: '2.10.1', tag: '[email protected]' };
return { version: '1.123.33', tag: '[email protected]' };
},
writeGithubOutput: () => {}, // no-op in tests
sh: () => {}, // no-op in tests
getCommitForRef: () => {}, // no-op in tests
remoteBranchExists: () => {}, // no-op in tests
localRefExists: () => {}, // no-op in tests
},
});
let determineBranchChanges, tagVersionInfoToReleaseCandidateBranchName;
before(async () => {
({ determineBranchChanges, tagVersionInfoToReleaseCandidateBranchName } = await import(
'./ensure-release-candidate-branches.mjs'
));
});
describe('Determine branch changes', () => {
it('Correctly determines ensureable branches', () => {
const output = determineBranchChanges();
const ensureBranches = output.branchesToEnsure.map(tagVersionInfoToReleaseCandidateBranchName);
assert.ok(
ensureBranches.includes('release-candidate/2.10.x'),
"Beta release-candidate branch doesn't exist",
);
assert.ok(
ensureBranches.includes('release-candidate/2.9.x'),
"Stable release-candidate branch doesn't exist",
);
});
it('Correctly determines deprecated branches', () => {
/** @type { import('./ensure-release-candidate-branches.mjs').BranchChanges} */
const output = determineBranchChanges();
assert.ok(
output.branchesToDeprecate.includes('release-candidate/2.7.x'),
'Existing branch release-candidate/2.7.x should be marked for removal',
);
});
});
@@ -0,0 +1,5 @@
{
"pull_request": {
"labels": ["release", "Backport to Stable"]
}
}
+63
View File
@@ -0,0 +1,63 @@
import semver from 'semver';
import {
getCommitForRef,
listTagsPointingAt,
RELEASE_PREFIX,
RELEASE_TRACKS,
stripReleasePrefixes,
writeGithubOutput,
} from './github-helpers.mjs';
/**
* Given a list of tag names, return the highest semver tag (keeping the original 'v' prefix),
* or "" if none match semver.
*
* @param {string[]} tags
**/
function highestSemverTag(tags) {
const candidates = tags
.filter((t) => t.startsWith(RELEASE_PREFIX))
.map((t) => ({
tag: t,
version: stripReleasePrefixes(t),
}))
.filter(({ version }) => semver.valid(version));
if (candidates.length === 0) return '';
candidates.sort((a, b) => semver.rcompare(a.version, b.version));
return candidates[0]?.tag;
}
/**
* @param {string} track
**/
function getSemverTagForTrack(track) {
const commit = getCommitForRef(track);
if (!commit) return '';
const tags = listTagsPointingAt(commit);
return highestSemverTag(tags);
}
function main() {
/** @type { Record<string, string> } */
const outputs = {};
for (const track of RELEASE_TRACKS) {
outputs[track] = getSemverTagForTrack(track);
}
writeGithubOutput(outputs);
console.log('Current release versions: ');
for (const [k, v] of Object.entries(outputs)) {
console.log(`${k}: ${v || '(not found)'}`);
}
}
try {
main();
} catch (err) {
console.error(String(err?.message ?? err));
process.exit(1);
}
+316
View File
@@ -0,0 +1,316 @@
import { getOctokit } from '@actions/github';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import semver from 'semver';
export const RELEASE_TRACKS = /** @type { const } */ ([
//
'stable',
'beta',
'v1',
]);
/**
* @typedef {typeof RELEASE_TRACKS[number]} ReleaseTrack
* */
/**
* @typedef {`${number}.${number}.${number}`} SemVer
* */
/**
* @typedef {`${RELEASE_PREFIX}${SemVer}`} ReleaseVersion
* */
/**
* @typedef {{ tag: ReleaseVersion, version: SemVer}} TagVersionInfo
* */
export const RELEASE_PREFIX = 'n8n@';
/**
* Given a list of tags, return the highest semver for tags like "[email protected]".
* Returns the *tag string* (e.g. "[email protected]") or null.
*
* @param {string[]} tags
*
* @returns { ReleaseVersion | null }
* */
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));
return /** @type { ReleaseVersion } */ (versions[0]?.tag) ?? null;
}
/**
* @param {any} track
*
* @returns { ReleaseTrack }
* */
export function ensureReleaseTrack(track) {
if (!RELEASE_TRACKS.includes(track)) {
throw new Error(`Invalid track ${track}. Available tracks are ${RELEASE_TRACKS.join(', ')}`);
}
return track;
}
/**
* Resolve a release track tag (stable/beta/etc.) to the corresponding
* [email protected] tag pointing at the same commit.
*
* Returns null if the track tag or release tag is missing.
*
* @param { typeof RELEASE_TRACKS[number] } track
*
* @returns { TagVersionInfo | null }
* */
export function resolveReleaseTagForTrack(track) {
const commit = getCommitForRef(track);
if (!commit) return null;
const tagsAtCommit = listTagsPointingAt(commit);
const releaseTag = pickHighestReleaseTag(tagsAtCommit);
if (!releaseTag) return null;
return {
tag: releaseTag,
version: stripReleasePrefixes(releaseTag),
};
}
/**
* Resolve a release track tag (stable/beta/etc.) to the corresponding
* release-candidate/<major>.<minor>.x branch, based on the n8n@<x.y.z> tag
* pointing at the same commit.
*
* Returns null if the track tag or release tag is missing.
*
* @param { ReleaseTrack } track
* */
export function resolveRcBranchForTrack(track) {
if (track === 'v1') {
return '1.x';
}
const commit = getCommitForRef(track);
if (!commit) return null;
const tagsAtCommit = listTagsPointingAt(commit);
const releaseTag = pickHighestReleaseTag(tagsAtCommit);
if (!releaseTag) return null;
const version = stripReleasePrefixes(releaseTag);
const parsed = semver.parse(version);
if (!parsed) return null;
return `release-candidate/${parsed.major}.${parsed.minor}.x`;
}
/**
* @param {string} tag
*
* @returns { SemVer }
* */
export function stripReleasePrefixes(tag) {
return /** @type { SemVer } */ (
tag.startsWith(RELEASE_PREFIX) ? tag.slice(RELEASE_PREFIX.length) : tag
);
}
export function getEventFromGithubEventPath() {
let eventPath = ensureEnvVar('GITHUB_EVENT_PATH');
if (!path.isAbsolute(eventPath)) {
eventPath = import.meta.dirname + '/' + eventPath;
}
return JSON.parse(fs.readFileSync(eventPath, 'utf8'));
}
/**
* @param {any} [pullRequest] Optional pull request object. If not provided, reads from GITHUB_EVENT_PATH
*
* @returns {string[]}
*/
export function readPrLabels(pullRequest) {
if (!pullRequest) {
const event = getEventFromGithubEventPath();
pullRequest = event.pull_request;
}
/** @type { string[] | { name: string }[] } */
const labels = pullRequest?.labels ?? [];
return labels.map((l) => (typeof l === 'string' ? l : l?.name)).filter(Boolean);
}
/**
* Ensures git tag exists.
*
* @throws { Error } if no tag was found
* */
export function ensureTagExists(tag) {
sh('git', ['fetch', '--force', '--no-tags', 'origin', `refs/tags/${tag}:refs/tags/${tag}`]);
}
/**
* @param {string} bump
*
* @returns { bump is import("semver").ReleaseType }
* */
export function isReleaseType(bump) {
return ['major', 'minor', 'patch'].includes(bump);
}
/**
* @param {string} variableName
*/
export function ensureEnvVar(variableName) {
const v = process.env[variableName];
if (!v) {
throw new Error(`Missing required env var: ${variableName}`);
}
return v;
}
/**
* @param {string} cmd
* @param {readonly string[]} args
* @param {import("node:child_process").ExecFileOptionsWithStringEncoding} args
*
* @example sh("git", ["tag", "--points-at", commit]);
* */
export function sh(cmd, args, opts = {}) {
return execFileSync(cmd, args, { encoding: 'utf8', ...opts }).trim();
}
/**
* @param {string} cmd
* @param {readonly string[]} args
* @param {import("node:child_process").ExecFileOptionsWithStringEncoding} args
*
* @example trySh("git", ["tag", "--points-at", commit]);
* */
export function trySh(cmd, args, opts = {}) {
try {
return { ok: true, out: sh(cmd, args, opts) };
} catch {
return { ok: false, out: '' };
}
}
/**
* Append outputs to GITHUB_OUTPUT if available.
*
* @param {Record<string, string | boolean>} obj
*/
export function writeGithubOutput(obj) {
const path = process.env.GITHUB_OUTPUT;
if (!path) return;
const lines = Object.entries(obj)
.map(([k, v]) => `${k}=${v ?? ''}`)
.join('\n');
fs.appendFileSync(path, lines + '\n', 'utf8');
}
/**
* Resolve a ref (tag/branch/SHA) to the underlying commit SHA.
* Uses ^{} so annotated tags are peeled to the commit.
* Returns null if ref doesn't exist.
*
* @param {string} ref
*/
export function getCommitForRef(ref) {
const res = trySh('git', ['rev-parse', `${ref}^{}`]);
return res.ok && res.out ? res.out : null;
}
/**
* List all tags that point at the given commit SHA.
*
* @param {string} commit
*/
export function listTagsPointingAt(commit) {
const res = trySh('git', ['tag', '--points-at', commit]);
if (!res.ok || !res.out) return [];
return res.out
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
}
/**
* @param {string} from
* @param {string} to
*/
export function listCommitsBetweenRefs(from, to) {
return sh('git', ['--no-pager', 'log', '--format="- %s (%h)', `${to}..origin/${from}`]);
}
/**
* @param {string} from
* @param {string} to
*/
export function countCommitsBetweenRefs(from, to) {
const output = sh('git', ['rev-list', '--count', `${to}..origin/${from}`]);
const count = parseInt(output);
return isNaN(count) ? 0 : count;
}
/**
* @param {string} branch
*/
export function remoteBranchExists(branch) {
const res = trySh('git', ['ls-remote', '--heads', 'origin', branch]);
return res.ok && res.out.length > 0;
}
/**
* @param {string} ref
*/
export function localRefExists(ref) {
const res = trySh('git', ['show-ref', '--verify', '--quiet', ref]);
return res.ok;
}
/**
* Initializes octokit with GITHUB_TOKEN from env vars.
*
* Also ensures the existence of useful environment variables.
* */
export function initGithub() {
const token = ensureEnvVar('GITHUB_TOKEN');
const repoFullName = ensureEnvVar('GITHUB_REPOSITORY');
const [owner, repo] = repoFullName.split('/');
const octokit = getOctokit(token);
return {
octokit,
owner,
repo,
};
}
/**
* @param {number} pullRequestId
*/
export async function getPullRequestById(pullRequestId) {
const { octokit, owner, repo } = initGithub();
const pullRequest = await octokit.rest.pulls.get({
owner,
repo,
pull_number: pullRequestId,
});
return pullRequest.data;
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"checkJs": true,
"moduleResolution": "node"
},
"exclude": ["node_modules"]
}
+18
View File
@@ -0,0 +1,18 @@
import { ensureEnvVar, ensureReleaseTrack, ensureTagExists, sh } from './github-helpers.mjs';
function main() {
const trackEnv = ensureEnvVar('TRACK');
const track = ensureReleaseTrack(trackEnv);
const versionInput = ensureEnvVar('VERSION_TAG'); // e.g. [email protected]
ensureTagExists(versionInput);
sh('git', ['tag', '-f', track, versionInput]);
sh('git', ['push', 'origin', '-f', `refs/tags/${track}:refs/tags/${track}`]);
console.log(`Moved pointer tag ${track} to point to ${versionInput}`);
}
main();
+13 -8
View File
@@ -1,12 +1,17 @@
{
"name": "workflow-scripts",
"scripts": {
"test": "node --test --experimental-test-module-mocks ./*.test.mjs"
},
"dependencies": {
"cacheable-lookup": "6.1.0",
"conventional-changelog": "^4.0.0",
"debug": "4.3.4",
"glob": "10.5.0",
"p-limit": "3.1.0",
"picocolors": "1.0.1",
"semver": "7.5.4",
"tempfile": "5.0.0"
"@actions/github": "9.0.0",
"conventional-changelog": "7.2.0",
"debug": "4.4.3",
"glob": "13.0.6",
"semver": "7.7.4",
"tempfile": "6.0.1"
},
"devDependencies": {
"conventional-changelog-angular": "^8.3.0"
}
}
+62
View File
@@ -0,0 +1,62 @@
import semver from 'semver';
import {
ensureEnvVar,
isReleaseType,
RELEASE_PREFIX,
stripReleasePrefixes,
writeGithubOutput,
} from './github-helpers.mjs';
const track = ensureEnvVar('TRACK');
const bump = ensureEnvVar('BUMP');
const stable = process.env['STABLE_VERSION'];
const beta = process.env['BETA_VERSION'];
const v1 = process.env['V1_VERSION'];
let base = null;
switch (track) {
case 'stable':
base = stable;
break;
case 'beta':
base = beta;
break;
case 'v1':
base = v1;
break;
}
if (!base) {
console.error(
`Unknown track or missing base version. track=${track} stable=${stable} beta=${beta} v1=${v1}`,
);
process.exit(1);
}
const cleanedBase = stripReleasePrefixes(base);
if (!cleanedBase) {
console.error(`Invalid base version: ${base}`);
process.exit(1);
}
if (!isReleaseType(bump)) {
console.error(`Invalid release type in $bump: ${bump}`);
process.exit(1);
}
const next = semver.inc(cleanedBase, bump);
if (!next) {
console.error(`Could not bump version. base=${cleanedBase} bump=${bump}`);
process.exit(1);
}
const output = {
base_version: cleanedBase,
new_version: next,
new_version_tag: `${RELEASE_PREFIX}${next}`,
};
writeGithubOutput(output);
console.log(`Releasing track=${track} bump=${bump} base=${cleanedBase} -> new=${next}`);
+536
View File
@@ -0,0 +1,536 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@actions/github':
specifier: 9.0.0
version: 9.0.0
conventional-changelog:
specifier: 7.2.0
version: 7.2.0([email protected])
debug:
specifier: 4.4.3
version: 4.4.3
glob:
specifier: 13.0.6
version: 13.0.6
semver:
specifier: 7.7.4
version: 7.7.4
tempfile:
specifier: 6.0.1
version: 6.0.1
devDependencies:
conventional-changelog-angular:
specifier: ^8.3.0
version: 8.3.0
packages:
'@actions/[email protected]':
resolution: {integrity: sha512-yJ0RoswsAaKcvkmpCE4XxBRiy/whH2SdTBHWzs0gi4wkqTDhXMChjSdqBz/F4AeiDlP28rQqL33iHb+kjAMX6w==}
'@actions/[email protected]':
resolution: {integrity: sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA==}
'@conventional-changelog/[email protected]':
resolution: {integrity: sha512-T+uPDciKf0/ioNNDpMGc8FDsehJClZP0yR3Q5MN6wE/Y/1QZ7F+80OgznnTCOlMEG4AV0LvH2UJi3C/nBnaBUg==}
engines: {node: '>=18'}
peerDependencies:
conventional-commits-filter: ^5.0.0
conventional-commits-parser: ^6.3.0
peerDependenciesMeta:
conventional-commits-filter:
optional: true
conventional-commits-parser:
optional: true
'@octokit/[email protected]':
resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==}
engines: {node: '>= 20'}
'@octokit/[email protected]':
resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==}
engines: {node: '>= 20'}
'@octokit/[email protected]':
resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==}
engines: {node: '>= 20'}
'@octokit/[email protected]':
resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==}
engines: {node: '>= 20'}
'@octokit/[email protected]':
resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==}
'@octokit/[email protected]':
resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==}
engines: {node: '>= 20'}
peerDependencies:
'@octokit/core': '>=6'
'@octokit/[email protected]':
resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==}
engines: {node: '>= 20'}
peerDependencies:
'@octokit/core': '>=6'
'@octokit/[email protected]':
resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==}
engines: {node: '>= 20'}
'@octokit/[email protected]':
resolution: {integrity: sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==}
engines: {node: '>= 20'}
'@octokit/[email protected]':
resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==}
'@simple-libs/[email protected]':
resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==}
engines: {node: '>=18'}
'@simple-libs/[email protected]':
resolution: {integrity: sha512-aAmGQdMH+ZinytKuA2832u0ATeOFNYNk4meBEXtB5xaPotUgggYNhq5tYU/v17wEbmTW5P9iHNqNrFyrhnqBAg==}
engines: {node: '>=18'}
'@simple-libs/[email protected]':
resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==}
engines: {node: '>=18'}
'@types/[email protected]':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
[email protected]:
resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==}
[email protected]:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
[email protected]:
resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==}
[email protected]:
resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==}
engines: {node: 18 || 20 || >=22}
[email protected]:
resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==}
[email protected]:
resolution: {integrity: sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-SetDSntXLk8Jh1NOAl1Gu5uLiCNSYenB5tm0YVeZKePRIgDW9lQImromTwLa3c/Gae298tsgOM+/CYT9XAl0NA==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==}
engines: {node: '>=18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-BEdgG+vPl53EVlTTk9sZ96aagFp0AQ5pw/ggiQMy2SClLbTo1r0l+8dSg79gkLOO5DS1Lswuhp5fWn6RwE+ivg==}
engines: {node: '>=18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg==}
engines: {node: '>=18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
[email protected]:
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==}
[email protected]:
resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==}
[email protected]:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
[email protected]:
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
engines: {node: '>=0.4.7'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==}
engines: {node: ^18.17.0 || >=20.5.0}
[email protected]:
resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==}
[email protected]:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
[email protected]:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22}
[email protected]:
resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
[email protected]:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
[email protected]:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
[email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
[email protected]:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
[email protected]:
resolution: {integrity: sha512-linxNAT6M0ebEYZOx2tO6vBEFsVgnPpv+AVjk0wJHfaUIbq31Jm3T6vvZaarnOeWDh8ShnwXuaAyM7WT3RzErA==}
engines: {node: ^18.17.0 || >=20.5.0}
[email protected]:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
[email protected]:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==}
[email protected]:
resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==}
[email protected]:
resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==}
[email protected]:
resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==}
[email protected]:
resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==}
engines: {node: '>=14.16'}
[email protected]:
resolution: {integrity: sha512-DE4nURsf7nUqYHJKTgOVdpt0SBY5r4us4kbFXqg7KZFB7ih27NxIk3qXv29FtqTaE45stnLKTECmSc9ICuRbDQ==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==}
engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'}
[email protected]:
resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
engines: {node: '>=0.8.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==}
engines: {node: '>=18.17'}
[email protected]:
resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==}
[email protected]:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
[email protected]:
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
engines: {node: 20 || >=22}
[email protected]:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
snapshots:
'@actions/[email protected]':
dependencies:
'@actions/http-client': 3.0.2
'@octokit/core': 7.0.6
'@octokit/plugin-paginate-rest': 14.0.0(@octokit/[email protected])
'@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/[email protected])
'@octokit/request': 10.0.8
'@octokit/request-error': 7.1.0
undici: 6.24.1
'@actions/[email protected]':
dependencies:
tunnel: 0.0.6
undici: 6.24.1
'@conventional-changelog/[email protected]([email protected])([email protected])':
dependencies:
'@simple-libs/child-process-utils': 1.0.2
'@simple-libs/stream-utils': 1.2.0
semver: 7.7.4
optionalDependencies:
conventional-commits-filter: 5.0.0
conventional-commits-parser: 6.3.0
'@octokit/[email protected]': {}
'@octokit/[email protected]':
dependencies:
'@octokit/auth-token': 6.0.0
'@octokit/graphql': 9.0.3
'@octokit/request': 10.0.8
'@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0
before-after-hook: 4.0.0
universal-user-agent: 7.0.3
'@octokit/[email protected]':
dependencies:
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
'@octokit/[email protected]':
dependencies:
'@octokit/request': 10.0.8
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
'@octokit/[email protected]': {}
'@octokit/[email protected](@octokit/[email protected])':
dependencies:
'@octokit/core': 7.0.6
'@octokit/types': 16.0.0
'@octokit/[email protected](@octokit/[email protected])':
dependencies:
'@octokit/core': 7.0.6
'@octokit/types': 16.0.0
'@octokit/[email protected]':
dependencies:
'@octokit/types': 16.0.0
'@octokit/[email protected]':
dependencies:
'@octokit/endpoint': 11.0.3
'@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0
fast-content-type-parse: 3.0.0
json-with-bigint: 3.5.7
universal-user-agent: 7.0.3
'@octokit/[email protected]':
dependencies:
'@octokit/openapi-types': 27.0.0
'@simple-libs/[email protected]':
dependencies:
'@simple-libs/stream-utils': 1.2.0
'@simple-libs/[email protected]': {}
'@simple-libs/[email protected]': {}
'@types/[email protected]': {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
balanced-match: 4.0.4
[email protected]:
dependencies:
array-ify: 1.0.0
dot-prop: 5.3.0
[email protected]:
dependencies:
compare-func: 2.0.0
[email protected]: {}
[email protected]:
dependencies:
'@simple-libs/stream-utils': 1.2.0
conventional-commits-filter: 5.0.0
handlebars: 4.7.8
meow: 13.2.0
semver: 7.7.4
[email protected]([email protected]):
dependencies:
'@conventional-changelog/git-client': 2.6.0([email protected])([email protected])
'@simple-libs/hosted-git-info': 1.0.2
'@types/normalize-package-data': 2.4.4
conventional-changelog-preset-loader: 5.0.0
conventional-changelog-writer: 8.4.0
conventional-commits-parser: 6.3.0
fd-package-json: 2.0.0
meow: 13.2.0
normalize-package-data: 7.0.1
transitivePeerDependencies:
- conventional-commits-filter
[email protected]: {}
[email protected]:
dependencies:
'@simple-libs/stream-utils': 1.2.0
meow: 13.2.0
[email protected]:
dependencies:
ms: 2.1.3
[email protected]:
dependencies:
is-obj: 2.0.0
[email protected]: {}
[email protected]:
dependencies:
walk-up-path: 4.0.0
[email protected]:
dependencies:
minimatch: 10.2.4
minipass: 7.1.3
path-scurry: 2.0.2
[email protected]:
dependencies:
minimist: 1.2.8
neo-async: 2.6.2
source-map: 0.6.1
wordwrap: 1.0.0
optionalDependencies:
uglify-js: 3.19.3
[email protected]:
dependencies:
lru-cache: 10.4.3
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
brace-expansion: 5.0.4
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
hosted-git-info: 8.1.0
semver: 7.7.4
validate-npm-package-license: 3.0.4
[email protected]:
dependencies:
lru-cache: 11.2.7
minipass: 7.1.3
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
spdx-expression-parse: 3.0.1
spdx-license-ids: 3.0.23
[email protected]: {}
[email protected]:
dependencies:
spdx-exceptions: 2.5.0
spdx-license-ids: 3.0.23
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
is-safe-filename: 0.1.1
temp-dir: 3.0.0
[email protected]: {}
[email protected]:
optional: true
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
spdx-correct: 3.2.0
spdx-expression-parse: 3.0.1
[email protected]: {}
[email protected]: {}
@@ -0,0 +1,32 @@
import { ensureEnvVar } from './github-helpers.mjs';
async function populateCloudDatabases() {
const payload = ensureEnvVar('PAYLOAD');
const webhookData = ensureEnvVar('N8N_POPULATE_CLOUD_WEBHOOK_DATA');
const { user, secret, url } = JSON.parse(webhookData);
console.log('Payload: ', JSON.parse(payload));
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Basic ' + Buffer.from(`${user}:${secret}`).toString('base64'),
},
body: payload,
});
const status = response.status;
console.log('Webhook call returned status ' + status);
if (status !== 200) {
const body = await response.text();
throw new Error(`Webhook call failed:\n\n ${body}`);
}
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
populateCloudDatabases();
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env node
/**
* Retry a shell command with configurable attempts and delay.
*
* Usage: node retry.mjs [--attempts N] [--delay N] '<command>'
*
* Options:
* --attempts N Maximum number of attempts (default: 4)
* --delay N Seconds to wait between retries (default: 15)
*
* The command is executed via shell, so pipes and env-var expansion work.
* Exits 0 on first success, 1 if all attempts fail.
*/
import { execSync } from 'node:child_process';
const args = process.argv.slice(2);
function getFlag(name, defaultValue) {
const index = args.indexOf(`--${name}`);
if (index === -1 || !args[index + 1]) return defaultValue;
const value = parseInt(args[index + 1], 10);
if (Number.isNaN(value) || value <= 0) {
console.error(`Error: --${name} must be a positive integer`);
process.exit(1);
}
return value;
}
const attempts = getFlag('attempts', 4);
const delay = getFlag('delay', 15);
// Command is the last positional arg (skip flags and their values)
const command = args
.filter((a, i) => {
if (a.startsWith('--')) return false;
if (i > 0 && args[i - 1].startsWith('--')) return false;
return true;
})
.pop();
if (!command) {
console.error("Usage: node retry.mjs [--attempts N] [--delay N] '<command>'");
process.exit(1);
}
for (let i = 1; i <= attempts; i++) {
try {
execSync(command, { shell: true, stdio: 'inherit' });
process.exit(0);
} catch {
if (i < attempts) {
console.error(`Attempt ${i}/${attempts} failed, retrying in ${delay}s...`);
execSync(`sleep ${delay}`);
} else {
console.error(`Attempt ${i}/${attempts} failed, no more retries.`);
}
}
}
process.exit(1);
+47 -81
View File
@@ -1,101 +1,67 @@
#!/usr/bin/env node
/**
* Sends Turbo build summary JSON to a webhook
* Sends Turbo build stats to the unified QA metrics webhook.
*
* Reads the Turbo run summary from .turbo/runs/ and emits per-package
* build-duration metrics with {package, cache, task} dimensions, plus
* a run-level build-total-duration summary.
*
* Usage: node send-build-stats.mjs
*
* Auto-detects summary from .turbo/runs/ directory.
*
* Environment variables:
* BUILD_STATS_WEBHOOK_URL - Webhook URL (required to send)
* BUILD_STATS_WEBHOOK_USER - Basic auth username (required if URL set)
* BUILD_STATS_WEBHOOK_PASSWORD - Basic auth password (required if URL set)
* QA_METRICS_WEBHOOK_URL - Webhook URL (required to send)
* QA_METRICS_WEBHOOK_USER - Basic auth username
* QA_METRICS_WEBHOOK_PASSWORD - Basic auth password
*/
import { readFileSync, readdirSync } from 'node:fs';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { sendMetrics, metric } from './send-metrics.mjs';
const runsDir = '.turbo/runs';
const files = readdirSync(runsDir).filter((f) => f.endsWith('.json'));
const summaryPath = files.length > 0 ? join(runsDir, files[0]) : null;
if (!existsSync(runsDir)) {
console.log('No .turbo/runs directory found (turbo --summarize not used), skipping.');
process.exit(0);
}
const webhookUrl = process.env.BUILD_STATS_WEBHOOK_URL;
const webhookUser = process.env.BUILD_STATS_WEBHOOK_USER;
const webhookPassword = process.env.BUILD_STATS_WEBHOOK_PASSWORD;
if (!summaryPath) {
const files = readdirSync(runsDir)
.filter((f) => f.endsWith('.json'))
.sort();
if (files.length === 0) {
console.error('No summary file found in .turbo/runs/');
process.exit(1);
}
if (!webhookUrl) {
console.log('BUILD_STATS_WEBHOOK_URL not set, skipping.');
process.exit(0);
const summary = JSON.parse(readFileSync(join(runsDir, files.at(-1)), 'utf-8'));
const metrics = [];
for (const task of summary.tasks ?? []) {
if (task.execution?.exitCode !== 0) continue;
const durationMs = task.execution.durationMs ?? 0;
const cacheHit = task.cache?.status === 'HIT';
// taskId format: "package-name#task-name"
const [pkg, taskName] = task.taskId?.split('#') ?? [task.package, task.task];
metrics.push(
metric('build-duration', durationMs / 1000, 's', {
package: pkg ?? 'unknown',
task: taskName ?? 'build',
cache: cacheHit ? 'hit' : 'miss',
}),
);
}
if (!webhookUser || !webhookPassword) {
console.error('BUILD_STATS_WEBHOOK_USER and BUILD_STATS_WEBHOOK_PASSWORD are required');
process.exit(1);
}
const totalMs = summary.durationMs ?? 0;
const totalTasks = summary.tasks?.length ?? 0;
const cachedTasks = summary.tasks?.filter((t) => t.cache?.status === 'HIT').length ?? 0;
const basicAuth = Buffer.from(`${webhookUser}:${webhookPassword}`).toString('base64');
metrics.push(
metric('build-total-duration', totalMs / 1000, 's', {
total_tasks: totalTasks,
cached_tasks: cachedTasks,
}),
);
import * as os from 'node:os';
const summary = JSON.parse(readFileSync(summaryPath, 'utf-8'));
// Extract PR number from GITHUB_REF (refs/pull/123/merge)
const ref = process.env.GITHUB_REF ?? '';
const prMatch = ref.match(/refs\/pull\/(\d+)/);
// Detect runner provider (matches packages/testing/containers/telemetry.ts)
function getRunnerProvider() {
if (!process.env.CI) return 'local';
if (process.env.RUNNER_ENVIRONMENT === 'github-hosted') return 'github';
return 'blacksmith';
}
// Add git context (aligned with container telemetry)
summary.git = {
sha: process.env.GITHUB_SHA?.slice(0, 8) || null,
branch: process.env.GITHUB_HEAD_REF ?? process.env.GITHUB_REF_NAME ?? null,
pr: prMatch ? parseInt(prMatch[1], 10) : null,
};
// Add CI context
summary.ci = {
runId: process.env.GITHUB_RUN_ID || null,
runUrl: process.env.GITHUB_RUN_ID
? `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
: null,
job: process.env.GITHUB_JOB || null,
workflow: process.env.GITHUB_WORKFLOW || null,
attempt: process.env.GITHUB_RUN_ATTEMPT ? parseInt(process.env.GITHUB_RUN_ATTEMPT, 10) : null,
};
// Add runner info
summary.runner = {
provider: getRunnerProvider(),
cpuCores: os.cpus().length,
memoryGb: Math.round((os.totalmem() / (1024 * 1024 * 1024)) * 10) / 10,
};
const headers = {
'Content-Type': 'application/json',
'Authorization': `Basic ${basicAuth}`,
};
const response = await fetch(webhookUrl, {
method: 'POST',
headers,
body: JSON.stringify(summary),
});
if (!response.ok) {
console.error(`Webhook failed: ${response.status} ${response.statusText}`);
const body = await response.text();
if (body) console.error(`Response: ${body}`);
process.exit(1);
}
console.log(`Build stats sent: ${response.status}`);
await sendMetrics(metrics, 'build-stats');
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
/**
* Sends Docker build stats to the unified QA metrics webhook.
*
* Reads manifests produced by build-n8n.mjs and dockerize-n8n.mjs and emits
* per-image docker-image-size metrics and build duration metrics with
* {image, platform} dimensions.
*
* Usage: node send-docker-stats.mjs
*
* Environment variables:
* QA_METRICS_WEBHOOK_URL - Webhook URL (required to send)
* QA_METRICS_WEBHOOK_USER - Basic auth username
* QA_METRICS_WEBHOOK_PASSWORD - Basic auth password
*/
import { existsSync, readFileSync } from 'node:fs';
import { sendMetrics, metric } from './send-metrics.mjs';
const buildManifestPath = 'compiled/build-manifest.json';
const dockerManifestPath = 'docker-build-manifest.json';
if (!existsSync(buildManifestPath) && !existsSync(dockerManifestPath)) {
console.log('No build or docker manifests found, skipping.');
process.exit(0);
}
const buildManifest = existsSync(buildManifestPath)
? JSON.parse(readFileSync(buildManifestPath, 'utf-8'))
: null;
const dockerManifest = existsSync(dockerManifestPath)
? JSON.parse(readFileSync(dockerManifestPath, 'utf-8'))
: null;
const metrics = [];
if (buildManifest) {
if (buildManifest.artifactSize != null) {
metrics.push(metric('artifact-size', buildManifest.artifactSize, 'bytes', { artifact: 'compiled' }));
}
if (buildManifest.buildDuration != null) {
metrics.push(metric('build-duration', buildManifest.buildDuration / 1000, 's', { artifact: 'compiled' }));
}
}
if (dockerManifest) {
const platform = dockerManifest.platform ?? 'unknown';
for (const image of dockerManifest.images ?? []) {
if (image.sizeBytes != null) {
metrics.push(
metric('docker-image-size', image.sizeBytes, 'bytes', {
image: image.name ?? 'unknown',
platform,
}),
);
}
}
if (dockerManifest.buildDurationMs != null) {
metrics.push(
metric('docker-build-duration', dockerManifest.buildDurationMs / 1000, 's', { platform }),
);
}
}
if (metrics.length === 0) {
console.log('No metrics to send.');
process.exit(0);
}
await sendMetrics(metrics, 'docker-stats');
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Shared metrics sender for CI scripts.
* See .github/CI-TELEMETRY.md for payload shape and BigQuery schema.
*
* Usage:
* import { sendMetrics, metric } from './send-metrics.mjs';
* await sendMetrics([metric('build-duration', 45.2, 's', { package: '@n8n/cli' })]);
*
* Env: QA_METRICS_WEBHOOK_URL, QA_METRICS_WEBHOOK_USER, QA_METRICS_WEBHOOK_PASSWORD
*/
import * as os from 'node:os';
/** Build a single metric object. */
export function metric(name, value, unit, dimensions = {}) {
return { metric_name: name, value, unit, dimensions };
}
/** Build git/ci/runner context from environment variables. */
export function buildContext(benchmarkName = null) {
const ref = process.env.GITHUB_REF ?? '';
const prMatch = ref.match(/refs\/pull\/(\d+)/);
const runId = process.env.GITHUB_RUN_ID ?? null;
return {
timestamp: new Date().toISOString(),
benchmark_name: benchmarkName,
git: {
sha: process.env.GITHUB_SHA?.slice(0, 8) ?? null,
branch: process.env.GITHUB_HEAD_REF ?? process.env.GITHUB_REF_NAME ?? null,
pr: prMatch ? parseInt(prMatch[1], 10) : null,
},
ci: {
runId,
runUrl:
runId && process.env.GITHUB_REPOSITORY
? `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${runId}`
: null,
job: process.env.GITHUB_JOB ?? null,
workflow: process.env.GITHUB_WORKFLOW ?? null,
attempt: process.env.GITHUB_RUN_ATTEMPT
? parseInt(process.env.GITHUB_RUN_ATTEMPT, 10)
: null,
},
runner: {
provider: !process.env.CI
? 'local'
: process.env.RUNNER_ENVIRONMENT === 'github-hosted'
? 'github'
: 'blacksmith',
cpuCores: os.cpus().length,
memoryGb: Math.round((os.totalmem() / (1024 * 1024 * 1024)) * 10) / 10,
},
};
}
export async function sendMetrics(metrics, benchmarkName = null) {
const webhookUrl = process.env.QA_METRICS_WEBHOOK_URL;
const webhookUser = process.env.QA_METRICS_WEBHOOK_USER;
const webhookPassword = process.env.QA_METRICS_WEBHOOK_PASSWORD;
if (!webhookUrl) {
console.log('QA_METRICS_WEBHOOK_URL not set, skipping.');
return;
}
if (!webhookUser || !webhookPassword) {
console.log('QA_METRICS_WEBHOOK_USER/PASSWORD not set, skipping.');
return;
}
const payload = { ...buildContext(benchmarkName), metrics };
const basicAuth = Buffer.from(`${webhookUser}:${webhookPassword}`).toString('base64');
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${basicAuth}`,
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(
`Webhook failed: ${response.status} ${response.statusText}${body ? `\n${body}` : ''}`,
);
}
console.log(`Sent ${metrics.length} metric(s): ${response.status}`);
}
@@ -0,0 +1,32 @@
import { ensureEnvVar } from './github-helpers.mjs';
async function sendVersionReleaseNotification() {
const payload = ensureEnvVar('PAYLOAD');
const webhookData = ensureEnvVar('N8N_VERSION_RELEASE_NOTIFICATION_DATA');
const { user, secret, url } = JSON.parse(webhookData);
console.log('Payload: ', JSON.parse(payload));
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Basic ' + Buffer.from(`${user}:${secret}`).toString('base64'),
},
body: payload,
});
const status = response.status;
console.log('Webhook call returned status ' + status);
if (status !== 200) {
const body = await response.text();
throw new Error(`Webhook call failed:\n\n ${body}`);
}
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
sendVersionReleaseNotification();
}
+27 -15
View File
@@ -1,5 +1,5 @@
import createTempFile from 'tempfile';
import conventionalChangelog from 'conventional-changelog';
import { ConventionalChangelog, packagePrefix } from 'conventional-changelog';
import { resolve } from 'path';
import { createReadStream, createWriteStream } from 'fs';
import { dirname } from 'path';
@@ -12,21 +12,32 @@ const fullChangelogFile = resolve(baseDir, 'CHANGELOG.md');
// Version includes experimental versions (e.g., 1.2.3-exp.0)
const versionChangelogFile = resolve(baseDir, `CHANGELOG-${packageJson.version}.md`);
const changelogStream = conventionalChangelog({
preset: 'angular',
releaseCount: 1,
tagPrefix: 'n8n@',
transform: (commit, callback) => {
const hasNoChangelogInHeader = commit.header.includes('(no-changelog)');
const isBenchmarkScope = commit.scope === 'benchmark';
const changelogStream = new ConventionalChangelog()
.package(packageJson)
.readRepository()
.loadPreset('angular')
.tags({
prefix: 'n8n@',
})
.context({
version: packageJson.version,
repoUrl: 'https://github.com/n8n-io/n8n',
})
.options({
releaseCount: 1,
transformCommit(commit) {
const hasNoChangelogInHeader = commit.header?.includes('(no-changelog)');
const isBenchmarkScope = commit.scope === 'benchmark';
// Ignore commits that have 'benchmark' scope or '(no-changelog)' in the header
callback(null, hasNoChangelogInHeader || isBenchmarkScope ? undefined : commit);
},
}).on('error', (err) => {
console.error(err.stack);
process.exit(1);
});
// Ignore commits that have 'benchmark' scope or '(no-changelog)' in the header
return hasNoChangelogInHeader || isBenchmarkScope ? null : commit;
},
})
.writeStream()
.on('error', (err) => {
console.error(err.stack);
process.exit(1);
});
// Write the new changelog to a new temporary file, so that the contents can be used in the PR description
await pipeline(changelogStream, createWriteStream(versionChangelogFile));
@@ -36,5 +47,6 @@ await pipeline(changelogStream, createWriteStream(versionChangelogFile));
const tmpFile = createTempFile();
const tmpStream = createWriteStream(tmpFile);
await pipeline(createReadStream(versionChangelogFile), tmpStream, { end: false });
tmpStream.write('\n\n');
await pipeline(createReadStream(fullChangelogFile), tmpStream);
await pipeline(createReadStream(tmpFile), createWriteStream(fullChangelogFile));
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
name: 'Util: Backport pull request changes'
run-name: Backport pull request ${{ github.event.pull_request.number || inputs.pull-request-id }}
on:
pull_request:
types: [closed]
workflow_dispatch:
inputs:
pull-request-id:
description: 'The ID number of the pull request (e.g. 3342). No #, no extra letters.'
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
backport:
if: |
github.event.pull_request.merged == true ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-slim
steps:
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
fetch-depth: 0
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
- name: Compute backport targets
id: targets
env:
PULL_REQUEST_ID: ${{ inputs.pull-request-id }}
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
run: node .github/scripts/compute-backport-targets.mjs
- name: Backport
if: steps.targets.outputs.target_branches != ''
uses: korthout/backport-action@4aaf0e03a94ff0a619c9a511b61aeb42adea5b02 # v4.2.0
with:
github_token: ${{ steps.generate-token.outputs.token }}
source_pr_number: ${{ github.event.pull_request.number || inputs.pull-request-id }}
target_branches: ${{ steps.targets.outputs.target_branches }}
pull_description: |-
# Description
Backport of #${pull_number} to `${target_branch}`.
## Checklist for the author (@${pull_author}) to go through.
- [ ] Review the backport changes
- [ ] Fix possible conflicts
- [ ] Merge to target branch
After this PR has been merged, it will be picked up in the next patch release for release track.
# Original description
${pull_description}
pull_title: ${pull_title} (backport to ${target_branch})
add_author_as_assignee: true
add_author_as_reviewer: true
copy_assignees: true
copy_requested_reviewers: false
copy_labels_pattern: '^(?!Backport to\b).+' # Copy everything except backport labels
add_labels: 'automation:backport'
experimental: >
{
"conflict_resolution": "draft_commit_conflicts"
}
+8 -5
View File
@@ -6,9 +6,11 @@ on:
- master
paths:
- 'docker/images/n8n-base/Dockerfile'
- '.github/workflows/build-base-image.yml'
pull_request:
paths:
- 'docker/images/n8n-base/Dockerfile'
- '.github/workflows/build-base-image.yml'
workflow_dispatch:
inputs:
push:
@@ -21,16 +23,17 @@ jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node_version: ['20', '22.21.0', '24']
node_version: ['22', '24.13.1', '25']
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to DHI Registry (for pulling base images)
uses: ./.github/actions/docker-registry-login
@@ -50,7 +53,7 @@ jobs:
dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
file: ./docker/images/n8n-base/Dockerfile
+4 -4
View File
@@ -16,19 +16,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up QEMU
uses: docker/setup-qemu-action@53851d14592bedcffcf25ea515637cff71ef929a # v3.3.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3.8.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to GitHub Container Registry
uses: ./.github/actions/docker-registry-login
- name: Build
uses: docker/build-push-action@b32b51a8eda65d6793cd0494a773d4f6bcef32dc # v6.11.0
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
env:
DOCKER_BUILD_SUMMARY: false
with:
+15 -1
View File
@@ -32,13 +32,27 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js and Build
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm build
- name: Smoke test pnpm start -- -- --version
shell: pwsh
run: |
Write-Host "Running smoke test: pnpm start -- -- --version"
pnpm start -- -- --version
if ($LASTEXITCODE -ne 0) {
Write-Host "`n❌ Smoke test failed (exit code: $LASTEXITCODE)"
exit $LASTEXITCODE
}
Write-Host "`n✓ Smoke test passed"
- name: Send Slack notification on failure
if: failure() && inputs.notify_on_failure == true
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
@@ -0,0 +1,69 @@
name: 'CI: Block fork PRs to release branches'
on:
pull_request:
branches:
- 'release/**'
types:
- opened
- reopened
- synchronize
- ready_for_review
- edited
jobs:
block-fork-prs:
runs-on: ubuntu-slim
permissions:
pull-requests: write
contents: read
steps:
- name: Check if PR is from a fork
id: check
run: |
if [ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then
echo "fork=true" >> "$GITHUB_OUTPUT"
else
echo "fork=false" >> "$GITHUB_OUTPUT"
fi
- name: Comment on PR explaining the block
if: steps.check.outputs.fork == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
});
const alreadyCommented = comments.some(
(c) => c.user.login === 'github-actions[bot]' && c.body.includes('Pull request blocked')
);
if (!alreadyCommented) {
const body = `
🚫 **Pull request blocked**
Pull requests from **forked repositories** are not allowed to target **release branches** in this repository.
**Target branch:** \`${context.payload.pull_request.base.ref}\`
If you believe this was blocked in error, contact the repository maintainers.
`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body
});
}
- name: Fail workflow if from fork
if: steps.check.outputs.fork == 'true'
run: |
echo "PR from fork targeting a release branch is not allowed."
exit 1
+17 -7
View File
@@ -12,9 +12,13 @@ jobs:
build-github:
name: Build for Github Cache
runs-on: ubuntu-latest
env:
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
@@ -22,14 +26,14 @@ jobs:
name: Unit tests
uses: ./.github/workflows/test-unit-reusable.yml
strategy:
fail-fast: false
matrix:
node-version: [20.x, 22.x, 24.3.x]
node-version: [22.x, 24.13.1, 25.x]
with:
ref: ${{ github.sha }}
nodeVersion: ${{ matrix.node-version }}
collectCoverage: ${{ matrix.node-version == '22.x' }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
collectCoverage: ${{ matrix.node-version == '24.13.1' }}
secrets: inherit
lint:
name: Lint
@@ -37,10 +41,16 @@ jobs:
with:
ref: ${{ github.sha }}
performance:
name: Performance
uses: ./.github/workflows/test-bench-reusable.yml
with:
ref: ${{ github.sha }}
notify-on-failure:
name: Notify Slack on failure
runs-on: ubuntu-latest
needs: [unit-test, lint, build-github]
needs: [unit-test, lint, performance, build-github]
steps:
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
+96 -55
View File
@@ -11,19 +11,24 @@ concurrency:
jobs:
install-and-build:
name: Install & Build
runs-on: blacksmith-2vcpu-ubuntu-2204
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }}
env:
NODE_OPTIONS: '--max-old-space-size=6144'
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
BUILD_STATS_WEBHOOK_URL: ${{ secrets.BUILD_STATS_WEBHOOK_URL }}
BUILD_STATS_WEBHOOK_USER: ${{ secrets.BUILD_STATS_WEBHOOK_USER }}
BUILD_STATS_WEBHOOK_PASSWORD: ${{ secrets.BUILD_STATS_WEBHOOK_PASSWORD }}
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 }}
outputs:
non_python_changed: ${{ steps.paths-filter.outputs.non-python == 'true' }}
workflows_changed: ${{ steps.paths-filter.outputs.workflows == 'true' }}
ci: ${{ fromJSON(steps.ci-filter.outputs.results).ci == true }}
unit: ${{ fromJSON(steps.ci-filter.outputs.results).unit == true }}
e2e: ${{ fromJSON(steps.ci-filter.outputs.results).e2e == true }}
workflows: ${{ fromJSON(steps.ci-filter.outputs.results).workflows == true }}
workflow_scripts: ${{ fromJSON(steps.ci-filter.outputs.results)['workflow-scripts'] == true }}
db: ${{ fromJSON(steps.ci-filter.outputs.results).db == true }}
performance: ${{ fromJSON(steps.ci-filter.outputs.results).performance == true }}
commit_sha: ${{ steps.commit-sha.outputs.sha }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Use merge_group SHA when in merge queue, otherwise PR merge ref
ref: ${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || format('refs/pull/{0}/merge', github.event.pull_request.number) }}
@@ -33,41 +38,70 @@ jobs:
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Check for relevant changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: paths-filter
uses: ./.github/actions/ci-filter
id: ci-filter
with:
mode: filter
filters: |
non-python:
- '**'
- '!packages/@n8n/task-runner-python/**'
workflows:
- .github/**
ci:
**
!packages/@n8n/task-runner-python/**
!.github/**
unit:
**
!packages/@n8n/task-runner-python/**
!packages/testing/playwright/**
!.github/**
e2e:
.github/workflows/test-e2e-*.yml
.github/scripts/cleanup-ghcr-images.mjs
packages/testing/playwright/**
packages/testing/containers/**
workflows: .github/**
workflow-scripts: .github/scripts/**
design-system:
packages/frontend/@n8n/design-system/**
packages/frontend/@n8n/chat/**
packages/frontend/@n8n/storybook/**
.github/workflows/test-visual-chromatic.yml
db:
packages/cli/src/databases/**
packages/cli/src/modules/*/database/**
packages/cli/src/modules/**/*.entity.ts
packages/cli/src/modules/**/*.repository.ts
packages/cli/test/integration/**
packages/cli/test/migration/**
packages/cli/test/shared/db/**
packages/@n8n/db/**
packages/cli/**/__tests__/**
packages/testing/containers/services/postgres.ts
.github/workflows/test-db-reusable.yml
- name: Setup and Build
if: steps.paths-filter.outputs.non-python == 'true'
if: fromJSON(steps.ci-filter.outputs.results).ci
uses: ./.github/actions/setup-nodejs
- name: Run format check
if: steps.paths-filter.outputs.non-python == 'true'
if: fromJSON(steps.ci-filter.outputs.results).ci
run: pnpm format:check
unit-test:
name: Unit tests
if: needs.install-and-build.outputs.non_python_changed == 'true'
if: needs.install-and-build.outputs.unit == 'true'
uses: ./.github/workflows/test-unit-reusable.yml
needs: install-and-build
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
collectCoverage: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
secrets: inherit
typecheck:
name: Typecheck
if: needs.install-and-build.outputs.non_python_changed == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2204
if: needs.install-and-build.outputs.ci == 'true'
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
needs: install-and-build
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
@@ -78,7 +112,7 @@ jobs:
lint:
name: Lint
if: needs.install-and-build.outputs.non_python_changed == 'true'
if: needs.install-and-build.outputs.ci == 'true'
uses: ./.github/workflows/test-linting-reusable.yml
needs: install-and-build
with:
@@ -87,55 +121,62 @@ jobs:
e2e-tests:
name: E2E Tests
needs: install-and-build
if: needs.install-and-build.outputs.non_python_changed == 'true'
if: (needs.install-and-build.outputs.ci == 'true' || needs.install-and-build.outputs.e2e == 'true') && github.repository == 'n8n-io/n8n'
uses: ./.github/workflows/test-e2e-ci-reusable.yml
with:
branch: ${{ needs.install-and-build.outputs.commit_sha }}
secrets: inherit
db-tests:
name: DB Tests
needs: install-and-build
if: needs.install-and-build.outputs.db == 'true'
uses: ./.github/workflows/test-db-reusable.yml
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
security-checks:
name: Security Checks
needs: install-and-build
if: needs.install-and-build.outputs.workflows_changed == 'true'
if: needs.install-and-build.outputs.workflows == 'true'
uses: ./.github/workflows/sec-ci-reusable.yml
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
secrets: inherit
workflow-scripts:
name: Workflow scripts
needs: install-and-build
if: needs.install-and-build.outputs.workflow_scripts == 'true'
uses: ./.github/workflows/test-workflow-scripts-reusable.yml
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
secrets: inherit
# This job is required by GitHub branch protection rules.
# PRs cannot be merged unless this job passes.
# If you add/remove jobs that should block merging, update the 'needs' array
# and the skip conditions in the 'if' block below.
required-checks:
name: Required Checks
needs: [install-and-build, unit-test, typecheck, lint, e2e-tests, security-checks]
needs:
[
install-and-build,
unit-test,
typecheck,
lint,
e2e-tests,
db-tests,
security-checks,
workflow-scripts,
]
if: always()
runs-on: ubuntu-slim
steps:
- name: Fail if any required job failed or was skipped unexpectedly
# Explicit checks for each job's skip conditions:
# - Non-python jobs (unit-test, typecheck, lint, e2e-tests): can skip when non_python_changed == false
# - security-checks: can skip when workflows_changed == false
if: |
contains(needs.*.result, 'failure') ||
(needs.install-and-build.outputs.non_python_changed == 'true' && (
needs.unit-test.result == 'skipped' ||
needs.typecheck.result == 'skipped' ||
needs.lint.result == 'skipped' ||
needs.e2e-tests.result == 'skipped'
)) ||
(needs.install-and-build.outputs.workflows_changed == 'true' &&
needs.security-checks.result == 'skipped')
run: exit 1
# Backward compatibility: 1.x branch protection requires this check name
# TODO: Remove this job once branch protection is updated to use 'Required Checks'
e2e-checks:
name: E2E - Checks
needs: [e2e-tests]
if: always()
runs-on: ubuntu-slim
steps:
- name: Fail if E2E tests failed
if: needs.e2e-tests.result == 'failure'
run: exit 1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/actions/ci-filter
sparse-checkout-cone-mode: false
- name: Validate required checks
uses: ./.github/actions/ci-filter
with:
mode: validate
job-results: ${{ toJSON(needs) }}
+3 -3
View File
@@ -18,10 +18,10 @@ jobs:
working-directory: packages/@n8n/task-runner-python
steps:
- name: Check out project
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@d9e0f98d3fc6adb07d1e3d37f3043649ddad06a1 # 6.5.0
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
with:
enable-cache: true
@@ -47,7 +47,7 @@ jobs:
run: uv run pytest --cov=src --cov-report=xml --cov-report=term-missing
- name: Upload coverage to Codecov
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/@n8n/task-runner-python/coverage.xml
@@ -0,0 +1,91 @@
name: 'CI: Check merge source and destination'
on:
pull_request:
branches:
- master
permissions:
pull-requests: write
contents: read
jobs:
check_branch:
if: ${{ github.repository == 'n8n-io/n8n-private' }}
name: enforce-bundle-branches-only-in-private
runs-on: ubuntu-latest
steps:
- name: Validate head branch
id: validate
shell: bash
env:
HEAD_REF: ${{ github.head_ref }}
run: |
set -euo pipefail
head="$HEAD_REF"
if [[ "$head" == bundle/* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"
fi
- name: Comment on PR (blocked)
if: ${{ steps.validate.outputs.allowed == 'false' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = context.payload.pull_request.number;
const head = context.payload.pull_request.head.ref;
const base = context.payload.pull_request.base.ref;
const marker = "<!-- bundle-branch-only -->";
const body =
`${marker}\n` +
`🚫 **Merge blocked**: PRs into \`${base}\` are only allowed from branches named \`bundle/*\`.\n\n` +
`Current source branch: \`${head}\`\n\n` +
`Merge your developments into a bundle branch instead of directly merging to master.`;
// Find an existing marker comment (to update instead of spamming)
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number,
per_page: 100,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
- name: Fail (blocked)
if: ${{ steps.validate.outputs.allowed == 'false' }}
env:
HEAD_REF: ${{ github.head_ref }}
run: |
echo "::error::You can only merge to master from a bundle/* branch. Got '$HEAD_REF'."
exit 1
- name: Allowed
if: ${{ steps.validate.outputs.allowed == 'true' }}
env:
HEAD_REF: ${{ github.head_ref }}
BASE_REF: ${{ github.base_ref }}
run: |
echo "OK: '$HEAD_REF' can merge into '$BASE_REF'"
@@ -23,7 +23,7 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Validate inputs
+134 -48
View File
@@ -54,7 +54,7 @@ jobs:
build_matrix: ${{ steps.context.outputs.build_matrix }}
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Determine build context
id: context
@@ -81,7 +81,7 @@ jobs:
runners_distroless_primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.runners_distroless_primary_tag }}
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -90,6 +90,9 @@ jobs:
with:
build-command: pnpm build:n8n
enable-docker-cache: 'true'
env:
RELEASE: ${{ needs.determine-build-context.outputs.n8n_version }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Determine Docker tags for all images
id: determine-tags
@@ -115,6 +118,7 @@ jobs:
dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push n8n Docker image
id: build-n8n
uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2
with:
context: .
@@ -124,12 +128,13 @@ jobs:
N8N_VERSION=${{ needs.determine-build-context.outputs.n8n_version }}
N8N_RELEASE_TYPE=${{ needs.determine-build-context.outputs.release_type }}
platforms: ${{ matrix.docker_platform }}
provenance: true
provenance: false # Disabled - using SLSA L3 generator for isolated provenance
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-tags.outputs.n8n_tags }}
- name: Build and push task runners Docker image (Alpine)
id: build-runners
uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2
with:
context: .
@@ -139,12 +144,13 @@ jobs:
N8N_VERSION=${{ needs.determine-build-context.outputs.n8n_version }}
N8N_RELEASE_TYPE=${{ needs.determine-build-context.outputs.release_type }}
platforms: ${{ matrix.docker_platform }}
provenance: true
provenance: false # Disabled - using SLSA L3 generator for isolated provenance
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-tags.outputs.runners_tags }}
- name: Build and push task runners Docker image (distroless)
id: build-runners-distroless
uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2
with:
context: .
@@ -154,7 +160,7 @@ jobs:
N8N_VERSION=${{ needs.determine-build-context.outputs.n8n_version }}
N8N_RELEASE_TYPE=${{ needs.determine-build-context.outputs.release_type }}
platforms: ${{ matrix.docker_platform }}
provenance: true
provenance: false # Disabled - using SLSA L3 generator for isolated provenance
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-tags.outputs.runners_distroless_tags }}
@@ -166,12 +172,19 @@ jobs:
if: |
needs.build-and-push-docker.result == 'success' &&
needs.determine-build-context.outputs.push_enabled == 'true'
outputs:
n8n_digest: ${{ steps.get-digests.outputs.n8n_digest }}
n8n_image: ${{ steps.get-digests.outputs.n8n_image }}
runners_digest: ${{ steps.get-digests.outputs.runners_digest }}
runners_image: ${{ steps.get-digests.outputs.runners_image }}
runners_distroless_digest: ${{ steps.get-digests.outputs.runners_distroless_digest }}
runners_distroless_image: ${{ steps.get-digests.outputs.runners_distroless_image }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker registries
uses: ./.github/actions/docker-registry-login
@@ -215,26 +228,6 @@ jobs:
create_manifest "runners" "${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}"
create_manifest "runners-distroless" "${{ needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag }}"
- name: Tag v1 Docker image on GHCR
run: |
VERSION="${{ needs.determine-build-context.outputs.n8n_version }}"
if [[ "$VERSION" == 1.* ]]; then
echo "Creating GHCR '1' tag for v1 release ${VERSION}"
docker buildx imagetools create \
-t "ghcr.io/${{ github.repository_owner }}/n8n:1" \
"${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}"
docker buildx imagetools create \
-t "ghcr.io/${{ github.repository_owner }}/runners:1" \
"${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}"
docker buildx imagetools create \
-t "ghcr.io/${{ github.repository_owner }}/runners:1-distroless" \
"${{ needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag }}"
fi
- name: Create Docker Hub manifests
if: needs.determine-build-context.outputs.push_to_docker == 'true'
run: |
@@ -259,27 +252,13 @@ jobs:
"${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}-arm64"
done
- name: Tag v1 Docker image on Docker Hub
if: needs.determine-build-context.outputs.push_to_docker == 'true'
run: |
VERSION="${{ needs.determine-build-context.outputs.n8n_version }}"
if [[ "$VERSION" == 1.* ]]; then
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}"
echo "Creating Docker Hub '1' tag for v1 release ${VERSION}"
docker buildx imagetools create \
-t "${DOCKER_BASE}/n8n:1" \
"${DOCKER_BASE}/n8n:${VERSION}"
docker buildx imagetools create \
-t "${DOCKER_BASE}/runners:1" \
"${DOCKER_BASE}/runners:${VERSION}"
docker buildx imagetools create \
-t "${DOCKER_BASE}/runners:1-distroless" \
"${DOCKER_BASE}/runners:${VERSION}-distroless"
fi
- name: Get manifest digests for attestation
id: get-digests
env:
N8N_TAG: ${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}
RUNNERS_TAG: ${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}
DISTROLESS_TAG: ${{ needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag }}
run: node .github/scripts/docker/get-manifest-digests.mjs
call-success-url:
name: Call Success URL
@@ -296,6 +275,113 @@ jobs:
curl -v "${{ env.SUCCESS_URL }}" || echo "Failed to call success URL"
shell: bash
# SLSA L3 Provenance - Must use version tags (@vX.Y.Z), NOT SHAs
provenance-n8n:
name: SLSA Provenance (n8n)
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest]
if: |
needs.create_multi_arch_manifest.result == 'success' &&
needs.create_multi_arch_manifest.outputs.n8n_digest != ''
permissions:
id-token: write
packages: write
actions: read
uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
with:
image: ${{ needs.create_multi_arch_manifest.outputs.n8n_image }}
digest: ${{ needs.create_multi_arch_manifest.outputs.n8n_digest }}
registry-username: ${{ github.actor }}
secrets:
registry-password: ${{ secrets.GITHUB_TOKEN }}
provenance-runners:
name: SLSA Provenance (runners)
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest]
if: |
needs.create_multi_arch_manifest.result == 'success' &&
needs.create_multi_arch_manifest.outputs.runners_digest != ''
permissions:
id-token: write
packages: write
actions: read
uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
with:
image: ${{ needs.create_multi_arch_manifest.outputs.runners_image }}
digest: ${{ needs.create_multi_arch_manifest.outputs.runners_digest }}
registry-username: ${{ github.actor }}
secrets:
registry-password: ${{ secrets.GITHUB_TOKEN }}
provenance-runners-distroless:
name: SLSA Provenance (runners-distroless)
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest]
if: |
needs.create_multi_arch_manifest.result == 'success' &&
needs.create_multi_arch_manifest.outputs.runners_distroless_digest != ''
permissions:
id-token: write
packages: write
actions: read
uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
with:
image: ${{ needs.create_multi_arch_manifest.outputs.runners_distroless_image }}
digest: ${{ needs.create_multi_arch_manifest.outputs.runners_distroless_digest }}
registry-username: ${{ github.actor }}
secrets:
registry-password: ${{ secrets.GITHUB_TOKEN }}
# VEX Attestation - Documents which CVEs affect us (security/vex.openvex.json)
vex-attestation:
name: VEX Attestation
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest, provenance-n8n, provenance-runners, provenance-runners-distroless]
if: |
always() &&
needs.create_multi_arch_manifest.result == 'success' &&
(needs.determine-build-context.outputs.release_type == 'stable' ||
needs.determine-build-context.outputs.release_type == 'rc' ||
needs.determine-build-context.outputs.release_type == 'nightly')
runs-on: ubuntu-latest
permissions:
id-token: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Cosign
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
- name: Login to GHCR
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Attest VEX to n8n image
if: needs.create_multi_arch_manifest.outputs.n8n_digest != ''
run: |
cosign attest --yes \
--type openvex \
--predicate security/vex.openvex.json \
${{ needs.create_multi_arch_manifest.outputs.n8n_image }}@${{ needs.create_multi_arch_manifest.outputs.n8n_digest }}
- name: Attest VEX to runners image
if: needs.create_multi_arch_manifest.outputs.runners_digest != ''
run: |
cosign attest --yes \
--type openvex \
--predicate security/vex.openvex.json \
${{ needs.create_multi_arch_manifest.outputs.runners_image }}@${{ needs.create_multi_arch_manifest.outputs.runners_digest }}
- name: Attest VEX to runners-distroless image
if: needs.create_multi_arch_manifest.outputs.runners_distroless_digest != ''
run: |
cosign attest --yes \
--type openvex \
--predicate security/vex.openvex.json \
${{ needs.create_multi_arch_manifest.outputs.runners_distroless_image }}@${{ needs.create_multi_arch_manifest.outputs.runners_distroless_digest }}
security-scan:
name: Security Scan
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest]
+64
View File
@@ -0,0 +1,64 @@
name: 'Docker Build Smoke Test'
# Verifies the full Docker build chain works without any caching.
# Catches native module compilation failures (e.g., isolated-vm, sqlite3)
# that layer caching can mask in the regular E2E pipeline.
#
# Full chain: pnpm install → pnpm build (no Turbo cache) →
# build base image (no Docker cache) →
# build n8n + runners images (no Docker cache)
on:
schedule:
- cron: '0 3 * * *' # 3:00 AM UTC, after the nightly Docker build at midnight
pull_request:
paths:
- 'docker/images/n8n/**'
- 'docker/images/n8n-base/**'
- 'docker/images/runners/**'
- 'scripts/build-n8n.mjs'
- 'scripts/dockerize-n8n.mjs'
workflow_dispatch:
jobs:
docker-smoke-test:
name: 'Docker Build (no cache)'
runs-on: blacksmith-4vcpu-ubuntu-2204
if: ${{ !github.event.pull_request.head.repo.fork }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to DHI Registry (for base image)
uses: ./.github/actions/docker-registry-login
with:
login-ghcr: 'false'
login-dhi: 'true'
dockerhub-username: ${{ secrets.DOCKER_USERNAME }}
dockerhub-password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build full chain (no cache)
uses: ./.github/actions/setup-nodejs
with:
build-command: 'pnpm build:docker:clean'
enable-docker-cache: true
- name: Verify n8n image starts
run: |
docker run --rm -d --name n8n-smoke-test n8nio/n8n:local
sleep 5
docker logs n8n-smoke-test 2>&1 | tail -20
docker stop n8n-smoke-test
notify-on-failure:
name: Notify on nightly smoke test failure
runs-on: ubuntu-slim
needs: [docker-smoke-test]
if: needs.docker-smoke-test.result == 'failure' && github.event_name == 'schedule'
steps:
- uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
with:
method: chat.postMessage
token: ${{ secrets.QBOT_SLACK_TOKEN }}
payload: |
channel: C0A9RLY8Y20
text: "🚨 Nightly Docker smoke test failed (no-cache build) - ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
@@ -0,0 +1,15 @@
name: 'Release: Create Minor Release PR'
on:
workflow_dispatch:
# schedule:
# - cron: 0 13 * * 1
jobs:
create-release-pr:
name: Create release PR
uses: ./.github/workflows/release-create-pr.yml
secrets: inherit
with:
base-branch: master
release-type: minor
@@ -0,0 +1,55 @@
name: 'Release: Create Patch Release PR'
run-name: 'Release: Create Patch Release PR for track ${{ inputs.track }}'
on:
workflow_dispatch:
inputs:
track:
description: 'Release Track'
required: true
type: choice
options: [stable, beta, v1]
jobs:
determine-version-info:
name: Determine publishing track
runs-on: ubuntu-latest
outputs:
release_candidate_branch: ${{ steps.determine-branch.outputs.release_candidate_branch }}
should_update: ${{ steps.determine-branch.outputs.should_update }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
- name: Determine release candidate branch from track
id: determine-branch
env:
TRACK: ${{ inputs.track }}
run: node .github/scripts/determine-release-candidate-branch-for-track.mjs
skip-release-pr:
name: Skip release PR (no new commits)
needs: [determine-version-info]
if: needs.determine-version-info.outputs.should_update != 'true'
runs-on: ubuntu-latest
steps:
- name: Log skip reason
run: echo "No new commits found between the release candidate branch and the current release tag. Skipping PR creation."
create-release-pr:
name: Create release PR
needs: [determine-version-info]
if: needs.determine-version-info.outputs.should_update == 'true'
uses: ./.github/workflows/release-create-pr.yml
secrets: inherit
with:
base-branch: ${{ needs.determine-version-info.outputs.release_candidate_branch }}
release-type: patch
+46 -11
View File
@@ -1,6 +1,18 @@
name: 'Release: Create Pull Request'
on:
workflow_call:
inputs:
base-branch:
description: 'The branch, tag, or commit to create this release PR from.'
required: true
type: string
release-type:
description: 'A SemVer release type.'
required: true
type: string
workflow_dispatch:
inputs:
base-branch:
@@ -20,6 +32,10 @@ on:
- experimental
- premajor
permissions:
contents: write
pull-requests: write
jobs:
create-release-pr:
runs-on: ubuntu-latest
@@ -30,26 +46,35 @@ jobs:
timeout-minutes: 5
outputs:
pull-request-number: ${{ steps.create-pr.outputs.pull-request-number }}
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
ref: ${{ github.event.inputs.base-branch }}
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
# Checkout base branch via separate step to prevent unsafe actions/checkout ref usage.
# poutine: untrusted_checkout_exec
- name: Switch to base branch
env:
BASE_BRANCH: ${{ inputs.base-branch }}
run: git checkout "$BASE_BRANCH"
- run: npm install --prefix=.github/scripts --no-package-lock
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
- name: Setup corepack and pnpm
run: |
@@ -60,14 +85,14 @@ jobs:
run: |
echo "NEXT_RELEASE=$(node .github/scripts/bump-versions.mjs)" >> "$GITHUB_ENV"
env:
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
RELEASE_TYPE: ${{ inputs.release-type }}
- name: Update Changelog
run: node .github/scripts/update-changelog.mjs
- name: Push the base branch
env:
BASE_BRANCH: ${{ github.event.inputs.base-branch }}
BASE_BRANCH: ${{ inputs.base-branch }}
run: |
git push -f origin "refs/remotes/origin/${{ env.BASE_BRANCH }}:refs/heads/release/${{ env.NEXT_RELEASE }}"
@@ -89,13 +114,23 @@ jobs:
fi
- name: Push the release branch, and Create the PR
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
id: create-pr
with:
token: ${{ steps.generate_token.outputs.token }}
base: 'release/${{ env.NEXT_RELEASE }}'
branch: 'release-pr/${{ env.NEXT_RELEASE }}'
commit-message: ':rocket: Release ${{ env.NEXT_RELEASE }}'
delete-branch: true
labels: release,release:${{ github.event.inputs.release-type }}
labels: release,release:${{ inputs.release-type }}
title: ':rocket: Release ${{ env.NEXT_RELEASE }}'
body: ${{ steps.generate-body.outputs.content }}
approve-and-automerge:
needs: [create-release-pr]
if: |
needs.create-release-pr.outputs.pull-request-number != ''
uses: ./.github/workflows/util-approve-and-set-automerge.yml
secrets: inherit
with:
pull-request-number: ${{ needs.create-release-pr.outputs.pull-request-number }}
@@ -0,0 +1,66 @@
name: 'Release: Merge tag to branch'
run-name: 'Merge n8n@${{ inputs.version }} to ${{ inputs.target-branch }}'
on:
workflow_call:
inputs:
version:
description: 'The release version (e.g. 2.10.2)'
required: true
type: string
target-branch:
description: 'The branch to merge the release tag into (e.g. master or release-candidate/2.10.x)'
required: true
type: string
jobs:
merge-tag-to-branch:
name: Merge release tag to ${{ inputs.target-branch }}
runs-on: ubuntu-latest
environment: minor-release-tag-merge
env:
VERSION: ${{ inputs.version }}
TARGET_BRANCH: ${{ inputs.target-branch }}
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.RELEASE_TAG_MERGE_APP_ID }}
private-key: ${{ secrets.RELEASE_TAG_MERGE_PRIVATE_KEY }}
skip-token-revoke: false
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.target-branch }}
fetch-depth: 500
token: ${{ steps.generate_token.outputs.token }}
- name: Verify release tag exists
run: |
if ! git ls-remote --tags origin "refs/tags/n8n@${VERSION}" | grep -q .; then
echo "::error::Tag n8n@${VERSION} not found on remote"
exit 1
fi
- name: Fetch release tag
run: git fetch origin "refs/tags/n8n@${VERSION}:refs/tags/n8n@${VERSION}"
- name: Merge release tag to branch
run: |
git config user.name "n8n-release-tag-merge[bot]"
git config user.email "256767729+n8n-release-tag-merge[bot]@users.noreply.github.com"
git merge --ff "n8n@${VERSION}"
- name: Push to ${{ inputs.target-branch }}
run: git push origin "HEAD:${TARGET_BRANCH}"
- name: Notify Slack on failure
if: failure()
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
with:
status: ${{ job.status }}
channel: '#updates-and-product-releases'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: |
<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}| Release tag merge to ${{ inputs.target-branch }} failed for n8n@${{ inputs.version }} >
@@ -0,0 +1,72 @@
name: 'Release: Populate cloud with releases'
run-name: 'Populate cloud with version n8n@${{ inputs.version }}'
on:
workflow_dispatch:
inputs:
previous-version:
description: 'The previous release version (e.g. 2.10.2)'
required: true
type: string
version:
description: 'The release version (e.g. 2.11.0)'
required: true
type: string
experimental:
description: 'If publishing experimental version'
type: boolean
default: false
workflow_call:
inputs:
previous-version:
description: 'The previous release version (e.g. 2.10.2)'
required: true
type: string
version:
description: 'The release version (e.g. 2.11.0)'
required: true
type: string
experimental:
description: 'If publishing experimental version'
type: boolean
default: false
jobs:
determine-changes:
runs-on: ubuntu-slim
environment: release
outputs:
has_node_enhancements: ${{ steps.get-changes.outputs.has_node_enhancements }}
has_core_changes: ${{ steps.get-changes.outputs.has_core_changes }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
- name: Extract changes
id: get-changes
env:
PREVIOUS_VERSION_TAG: 'n8n@${{ inputs.previous-version }}'
RELEASE_VERSION_TAG: 'n8n@${{ inputs.version }}'
run: node ./.github/scripts/determine-release-version-changes.mjs
- name: Populate databases
id: populate-databases
env:
N8N_POPULATE_CLOUD_WEBHOOK_DATA: ${{ secrets.N8N_POPULATE_CLOUD_WEBHOOK_DATA }}
PAYLOAD: |
{
"target_version_to_update": "${{ inputs.version }}",
"has_node_enhancements": ${{ steps.get-changes.outputs.has_node_enhancements }},
"has_core_changes": ${{ steps.get-changes.outputs.has_core_changes }},
"has_breaking_change": false,
"is_experimental": ${{ inputs.experimental }}
}
run: node ./.github/scripts/populate-cloud-databases.mjs
@@ -0,0 +1,74 @@
name: 'Release: Publish: Post-release'
on:
workflow_call:
inputs:
track:
description: 'Release track acquired from determine-version-info. (e.g. stable, beta)'
required: true
type: string
previous_version:
description: 'Previous release version acquired from determine-version-info. (e.g. 2.9.2, 1.123.22)'
required: true
type: string
version:
description: 'Release version acquired from determine-version-info. (e.g. 2.9.3, 1.123.23)'
required: true
type: string
bump:
description: 'Release bump size acquired from determine-version-info. (e.g. minor, patch)'
required: true
type: string
new_stable_version:
description: 'New stable version acquired from determine-version-info. (e.g. 2.9.3, null (on patch releases))'
required: true
type: string
release_type:
description: 'Release type acquired from determine-version-info. (stable or rc)'
required: true
type: string
jobs:
push-new-release-to-channel:
name: Push new release to channel
if: inputs.release_type != 'rc'
uses: ./.github/workflows/release-push-to-channel.yml
secrets: inherit
with:
version: ${{ inputs.version }}
release-channel: ${{ inputs.track }}
promote-previous-beta-to-stable:
name: Promote previous beta to stable
if: |
inputs.release_type != 'rc' &&
inputs.bump == 'minor'
uses: ./.github/workflows/release-push-to-channel.yml
secrets: inherit
with:
version: ${{ inputs.new_stable_version }}
release-channel: stable
ensure-release-candidate-branches:
name: 'Ensure release candidate branches'
if: |
inputs.release_type != 'rc'
uses: ./.github/workflows/util-ensure-release-candidate-branches.yml
secrets: inherit
populate-cloud-with-releases:
name: 'Populate cloud database with releases'
uses: ./.github/workflows/release-populate-cloud-with-releases.yml
with:
previous-version: ${{ inputs.previous_version }}
version: ${{ inputs.version }}
experimental: ${{ inputs.release_type == 'rc' }}
secrets: inherit
send-version-release-notification:
name: 'Send version release notifications'
uses: ./.github/workflows/release-version-release-notification.yml
with:
previous-version: ${{ inputs.previous_version }}
version: ${{ inputs.version }}
secrets: inherit
+118 -106
View File
@@ -8,54 +8,58 @@ on:
- 'release/*'
jobs:
build-arm64:
runs-on: blacksmith-4vcpu-ubuntu-2204-arm
determine-version-info:
name: Determine publishing track
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
env:
NODE_OPTIONS: --max-old-space-size=6144
outputs:
track: ${{ steps.determine-info.outputs.track }}
previous_version: ${{ steps.determine-info.outputs.previous_version }}
version: ${{ steps.determine-info.outputs.version }}
bump: ${{ steps.determine-info.outputs.bump }}
new_stable_version: ${{ steps.determine-info.outputs.new_stable_version }}
release_type: ${{ steps.determine-info.outputs.release_type }}
rc_branch: ${{ steps.determine-info.outputs.rc_branch }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup and Build ARM64
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
env:
N8N_FAIL_ON_POPULARITY_FETCH_ERROR: true
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
- name: Determine track from package version number
id: determine-info
run: node .github/scripts/determine-version-info.mjs
publish-to-npm:
name: Publish to NPM
needs: [determine-version-info]
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
timeout-minutes: 20
environment: npm
permissions:
id-token: write
env:
NPM_CONFIG_PROVENANCE: true
outputs:
release: ${{ steps.set-release.outputs.release }}
release_type: ${{ steps.set-release.outputs.release_type }}
RELEASE: ${{ needs.determine-version-info.outputs.version }} # Used by Vite build process
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set release version in env
run: echo "RELEASE=$(node -e 'console.log(require("./package.json").version)')" >> "$GITHUB_ENV"
- name: Determine release type
id: release-type
run: |
VERSION="${{ env.RELEASE }}"
if [[ "$VERSION" == *"-rc."* ]]; then
echo "type=rc" >> "$GITHUB_OUTPUT"
else
echo "type=stable" >> "$GITHUB_OUTPUT"
fi
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
env:
N8N_FAIL_ON_POPULARITY_FETCH_ERROR: true
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Check for new unpublished packages
run: node .github/scripts/detect-new-packages.mjs
- name: Dry-run publishing
run: |
@@ -64,11 +68,10 @@ jobs:
- name: Pre publishing changes
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
node .github/scripts/trim-fe-packageJson.js
node .github/scripts/ensure-provenance-fields.mjs
cp README.md packages/cli/README.md
sed -i "s/default: 'dev'/default: '${{ steps.release-type.outputs.type }}'/g" packages/cli/dist/config/schema.js
sed -i "s/default: 'dev'/default: '${{ needs.determine-version-info.outputs.release_type }}'/g" packages/cli/dist/config/schema.js
- name: Publish n8n to NPM with rc tag
env:
@@ -90,23 +93,18 @@ jobs:
run: npm dist-tag rm n8n rc
continue-on-error: true
- id: set-release
run: |
echo "release=${{ env.RELEASE }}" >> "$GITHUB_OUTPUT"
echo "release_type=${{ steps.release-type.outputs.type }}" >> "$GITHUB_OUTPUT"
publish-to-docker-hub:
name: Publish to DockerHub
needs: [publish-to-npm, build-arm64]
needs: [determine-version-info]
uses: ./.github/workflows/docker-build-push.yml
with:
n8n_version: ${{ needs.publish-to-npm.outputs.release }}
release_type: ${{ needs.publish-to-npm.outputs.release_type }}
n8n_version: ${{ needs.determine-version-info.outputs.version }}
release_type: ${{ needs.determine-version-info.outputs.release_type }}
secrets: inherit
create-github-release:
name: Create a GitHub Release
needs: [publish-to-npm, publish-to-docker-hub]
needs: [determine-version-info, publish-to-npm, publish-to-docker-hub]
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
timeout-minutes: 5
@@ -116,88 +114,102 @@ jobs:
id-token: write
steps:
- name: Create a Release on GitHub
uses: ncipollo/release-action@1c89adf39833729d8f85a31ccbc451b078733c80 # v1
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- name: Create a Release on GitHub
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0
with:
token: ${{ steps.generate_token.outputs.token }}
commit: ${{github.event.pull_request.base.ref}}
tag: 'n8n@${{ needs.publish-to-npm.outputs.release }}'
prerelease: true
makeLatest: false
tag: 'n8n@${{ needs.determine-version-info.outputs.version }}'
prerelease: ${{ needs.determine-version-info.outputs.track == 'beta' }}
makeLatest: ${{ needs.determine-version-info.outputs.track == 'stable' }}
body: ${{github.event.pull_request.body}}
create-sentry-release:
name: Create a Sentry Release
needs: [publish-to-npm, publish-to-docker-hub]
runs-on: ubuntu-latest
move-track-tag:
name: Move track tag
needs: [determine-version-info, create-github-release]
if: github.event.pull_request.merged == true
timeout-minutes: 5
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
uses: ./.github/workflows/release-update-pointer-tag.yml
with:
track: ${{ needs.determine-version-info.outputs.track }}
version-tag: 'n8n@${{ needs.determine-version-info.outputs.version }}'
secrets: inherit
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Restore Turbo Cache
uses: ./.github/actions/setup-nodejs
- name: Create a frontend release
uses: getsentry/action-release@e769183448303de84c5a06aaaddf9da7be26d6c7 # v1.7.0
continue-on-error: true
with:
projects: ${{ secrets.SENTRY_FRONTEND_PROJECT }}
version: n8n@${{ needs.publish-to-npm.outputs.release }}
sourcemaps: packages/frontend/editor-ui/dist
- name: Create a backend release
uses: getsentry/action-release@e769183448303de84c5a06aaaddf9da7be26d6c7 # v1.7.0
continue-on-error: true
with:
projects: ${{ secrets.SENTRY_BACKEND_PROJECT }}
version: n8n@${{ needs.publish-to-npm.outputs.release }}
sourcemaps: packages/cli/dist packages/core/dist packages/nodes-base/dist packages/@n8n/n8n-nodes-langchain/dist
- name: Create a task runner release
uses: getsentry/action-release@e769183448303de84c5a06aaaddf9da7be26d6c7 # v1.7.0
continue-on-error: true
with:
projects: ${{ secrets.SENTRY_TASK_RUNNER_PROJECT }}
version: n8n@${{ needs.publish-to-npm.outputs.release }}
sourcemaps: packages/core/dist packages/workflow/dist/esm packages/@n8n/task-runner/dist
promote-stable-tag:
name: Promote stable tag (minor bump)
needs: [determine-version-info, create-github-release]
if: |
github.event.pull_request.merged == true &&
needs.determine-version-info.outputs.new_stable_version != ''
uses: ./.github/workflows/release-update-pointer-tag.yml
with:
track: stable
version-tag: 'n8n@${{ needs.determine-version-info.outputs.new_stable_version }}'
secrets: inherit
generate-and-attach-sbom:
name: Generate and Attach SBOM to Release
needs: [publish-to-npm, create-github-release]
needs: [determine-version-info, create-github-release]
uses: ./.github/workflows/sbom-generation-callable.yml
with:
n8n_version: ${{ needs.publish-to-npm.outputs.release }}
release_tag_ref: 'n8n@${{ needs.publish-to-npm.outputs.release }}'
n8n_version: ${{ needs.determine-version-info.outputs.version }}
release_tag_ref: 'n8n@${{ needs.determine-version-info.outputs.version }}'
secrets: inherit
trigger-release-note:
name: Trigger a release note
needs: [publish-to-npm, create-github-release]
merge-release-tag-to-master:
name: Merge release tag to master on minor release
needs: [determine-version-info, publish-to-npm, create-github-release]
if: |
github.event.pull_request.merged == true &&
!contains(needs.publish-to-npm.outputs.release, '-rc.')
runs-on: ubuntu-latest
steps:
- name: Trigger a release note
run: curl -u docsWorkflows:${{ secrets.N8N_WEBHOOK_DOCS_PASSWORD }} --request GET 'https://internal.users.n8n.cloud/webhook/trigger-release-note' --header 'Content-Type:application/json' --data '{"version":"${{ needs.publish-to-npm.outputs.release }}"}'
needs.determine-version-info.outputs.bump == 'minor' &&
needs.determine-version-info.outputs.release_type != 'rc'
uses: ./.github/workflows/release-merge-tag-to-branch.yml
with:
version: ${{ needs.determine-version-info.outputs.version }}
target-branch: master
secrets: inherit
# merge-back-into-master:
# name: Merge back into master
# needs: [publish-to-npm, create-github-release]
# if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }}
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
# v4.1.1
# fetch-depth: 0
# - run: |
# git checkout --track origin/master
# git config user.name "github-actions[bot]"
# git config user.email 41898282+github-actions[bot]@users.noreply.github.com
# git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }}
# git push origin master
# git push origin :${{github.event.pull_request.base.ref}}
merge-release-tag-to-rc-branch:
name: Merge release tag to RC branch on patch release
needs: [determine-version-info, publish-to-npm, create-github-release]
if: |
github.event.pull_request.merged == true &&
needs.determine-version-info.outputs.bump == 'patch' &&
needs.determine-version-info.outputs.release_type != 'rc'
uses: ./.github/workflows/release-merge-tag-to-branch.yml
with:
version: ${{ needs.determine-version-info.outputs.version }}
target-branch: ${{ needs.determine-version-info.outputs.rc_branch }}
secrets: inherit
post-release:
name: Run Post-release actions
needs:
[
determine-version-info,
publish-to-npm,
create-github-release,
move-track-tag,
promote-stable-tag,
]
if: |
always() &&
needs.publish-to-npm.result == 'success' &&
needs.create-github-release.result == 'success' &&
(needs.move-track-tag.result == 'success' || needs.move-track-tag.result == 'skipped') &&
(needs.promote-stable-tag.result == 'success' || needs.promote-stable-tag.result == 'skipped')
uses: ./.github/workflows/release-publish-post-release.yml
with:
track: ${{ needs.determine-version-info.outputs.track }}
previous_version: ${{ needs.determine-version-info.outputs.previous_version }}
version: ${{ needs.determine-version-info.outputs.version }}
bump: ${{ needs.determine-version-info.outputs.bump }}
new_stable_version: ${{ needs.determine-version-info.outputs.new_stable_version }}
release_type: ${{ needs.determine-version-info.outputs.release_type }}
secrets: inherit
+28 -8
View File
@@ -1,6 +1,18 @@
name: 'Release: Push to Channel'
on:
workflow_call:
inputs:
version:
description: 'n8n Release version to push to a channel (e.g., 1.2.3 or 1.2.3-beta.4)'
required: true
type: string
release-channel:
description: 'Release channel'
required: true
type: string
workflow_dispatch:
inputs:
version:
@@ -23,12 +35,12 @@ jobs:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.check_version.outputs.version }}
release_channel: ${{ github.event.inputs.release-channel }}
release_channel: ${{ inputs.release-channel }}
steps:
- name: Check Version Format
id: check_version
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_VERSION: ${{ inputs.version }}
run: |
input_version="${{ env.INPUT_VERSION }}"
version_regex='^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$'
@@ -43,8 +55,8 @@ jobs:
- name: Block RC promotion to stable/beta
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
CHANNEL: ${{ github.event.inputs.release-channel }}
INPUT_VERSION: ${{ inputs.version }}
CHANNEL: ${{ inputs.release-channel }}
run: |
if [[ "$INPUT_VERSION" == *"-rc."* ]]; then
echo "::error::RC versions cannot be promoted to '$CHANNEL' channel. Version '$INPUT_VERSION' contains '-rc.'"
@@ -57,10 +69,13 @@ jobs:
runs-on: ubuntu-latest
needs: validate-inputs
timeout-minutes: 5
environment: release
permissions:
id-token: write
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22.x
node-version: 24.13.1
# Remove after https://github.com/npm/cli/issues/8547 gets resolved
- run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
@@ -84,9 +99,10 @@ jobs:
runs-on: ubuntu-latest
needs: validate-inputs
timeout-minutes: 5
environment: release
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to DockerHub
uses: ./.github/actions/docker-registry-login
@@ -121,9 +137,12 @@ jobs:
runs-on: ubuntu-latest
needs: validate-inputs
timeout-minutes: 5
environment: release
permissions:
packages: write
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to GitHub Container Registry
uses: ./.github/actions/docker-registry-login
@@ -148,6 +167,7 @@ jobs:
name: Update latest and next in the docs
runs-on: ubuntu-latest
needs: [validate-inputs, release-to-npm, release-to-docker-hub]
environment: release
steps:
- continue-on-error: true
run: curl -u docsWorkflows:${{ secrets.N8N_WEBHOOK_DOCS_PASSWORD }} --request GET 'https://internal.users.n8n.cloud/webhook/update-latest-next'
@@ -8,10 +8,14 @@ on:
required: true
type: choice
options:
- '@n8n/node-cli'
- '@n8n/codemirror-lang'
- '@n8n/codemirror-lang-html'
- '@n8n/codemirror-lang-sql'
- '@n8n/create-node'
- '@n8n/scan-community-package'
- '@n8n/eslint-plugin-community-nodes'
- '@n8n/node-cli'
- '@n8n/scan-community-package'
# All packages listed above require OIDC enabled in NPM. https://docs.npmjs.com/trusted-publishers
concurrency:
group: release-package-${{ github.event.inputs.package }}
@@ -25,6 +29,7 @@ jobs:
name: Publish to NPM
runs-on: ubuntu-latest
timeout-minutes: 15
environment: npm
permissions:
id-token: write
env:
@@ -38,7 +43,7 @@ jobs:
exit 1
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
@@ -47,11 +52,9 @@ jobs:
- name: Pre publishing changes
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
node .github/scripts/ensure-provenance-fields.mjs
- name: Publish package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
PACKAGE: ${{ github.event.inputs.package }}
run: pnpm --filter "$PACKAGE" publish --access public --no-git-checks --publish-branch master
@@ -0,0 +1,66 @@
name: 'Release: Update pointer tag'
run-name: 'Update pointer tag: ${{ inputs.track }} -> ${{ inputs.version-tag }}'
on:
workflow_call:
inputs:
track:
required: true
type: string
version-tag:
required: true
type: string
workflow_dispatch:
inputs:
track:
description: 'Release Track'
required: true
type: choice
options: [stable, beta, v1]
version-tag:
description: 'Version tag (e.g. [email protected]). Track tag will point to this version tag.'
required: true
type: string
permissions:
contents: write
jobs:
update-pointer-tags:
name: Update pointer tags
runs-on: ubuntu-slim
environment: minor-release-tag-merge
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.RELEASE_TAG_MERGE_APP_ID }}
private-key: ${{ secrets.RELEASE_TAG_MERGE_PRIVATE_KEY }}
skip-token-revoke: false
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate_token.outputs.token }}
# We can manage with a shallow clone, since `ensureTagExists` in github-helpers.mjs
# does a targeted fetch for the tags it needs.
fetch-depth: 1
- name: Setup NodeJS
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
- name: Configure git author
run: |
git config user.name "n8n-release-tag-merge[bot]"
git config user.email "256767729+n8n-release-tag-merge[bot]@users.noreply.github.com"
- name: Move track tag
env:
TRACK: ${{ inputs.track }}
VERSION_TAG: ${{ inputs.version-tag }}
run: node ./.github/scripts/move-track-tag.mjs
@@ -0,0 +1,50 @@
name: 'Release: Send version release notification'
run-name: 'Send version release notification for n8n@${{ inputs.version }}'
on:
workflow_dispatch:
inputs:
previous-version:
description: 'The previous release version (e.g. 2.10.2)'
required: true
type: string
version:
description: 'The release version (e.g. 2.11.0)'
required: true
type: string
workflow_call:
inputs:
previous-version:
description: 'The previous release version (e.g. 2.10.2)'
required: true
type: string
version:
description: 'The release version (e.g. 2.11.0)'
required: true
type: string
jobs:
release-notification:
runs-on: ubuntu-slim
environment: release
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
- name: Send release notification
env:
N8N_VERSION_RELEASE_NOTIFICATION_DATA: ${{ secrets.N8N_VERSION_RELEASE_NOTIFICATION_DATA }}
PAYLOAD: |
{
"previous_version": "${{ inputs.previous-version }}",
"new_version": "${{ inputs.version }}"
}
run: node ./.github/scripts/send-version-release-notification.mjs
@@ -39,7 +39,7 @@ jobs:
continue-on-error: true
steps:
- name: Checkout release tag
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.release_tag_ref }}
@@ -49,14 +49,14 @@ jobs:
build-command: ''
- name: Generate CycloneDX SBOM for source code
uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6
uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1
with:
path: ./
format: cyclonedx-json
output-file: sbom-source.cdx.json
- name: Attest SBOM for source release
uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3.0.0
uses: actions/attest-sbom@07e74fc4e78d1aad915e867f9a094073a9f71527 # v4.0.0
with:
subject-path: './package.json'
sbom-path: 'sbom-source.cdx.json'
@@ -68,11 +68,11 @@ jobs:
# Upload SBOM and VEX files to the existing release
gh release upload "${{ inputs.release_tag_ref }}" \
sbom-source.cdx.json \
vex.openvex.json \
security/vex.openvex.json \
--clobber
COMPONENT_COUNT=$(jq '.components | length' sbom-source.cdx.json 2>/dev/null || echo "unknown")
VEX_STATEMENTS=$(jq '.statements | length' vex.openvex.json 2>/dev/null || echo "0")
VEX_STATEMENTS=$(jq '.statements | length' security/vex.openvex.json 2>/dev/null || echo "0")
echo "SBOM and VEX attached to release"
echo " - SBOM: $COMPONENT_COUNT components"
echo " - VEX: $VEX_STATEMENTS CVE statements"
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref }}
@@ -39,6 +39,6 @@ jobs:
- name: Upload SARIF results
uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
if: always()
if: github.repository == 'n8n-io/n8n'
with:
sarif_file: results.sarif
+24 -37
View File
@@ -1,24 +1,3 @@
# Publish security fix from n8n-io/n8n-private to n8n-io/n8n
#
# On PR merge to n8n-private's master:
#
# 1. Cherrypick the squashed commit onto n8n's master
# 2. Sync n8n's master onto n8n-private's master
#
# Before:
# private master: A -- B -- C -- D (D = security fix)
# public master: A -- B -- C -- E (E = normal development)
#
# After:
# private master: A -- B -- C -- E -- D'
# public master: A -- B -- C -- E -- D'
#
# On cherrypick conflict:
#
# 1. Run "Security: Sync from Public" workflow
# 2. Rebase your branch: `git checkout fix-123 && git rebase master`
# 3. Reopen PR and merge again
name: 'Security: Publish fix'
on:
@@ -34,35 +13,43 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
owner: n8n-io
repositories: n8n
repositories: n8n,n8n-private
- name: Cherry-pick to public repo
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: ${{ steps.generate_token.outputs.token }}
- name: Open PR to public repo
run: |
COMMIT_TO_PUBLISH=$(git rev-parse HEAD)
BRANCH_NAME="private-$(date +%Y%m%d-%H%M%S)"
git remote add public-repo https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/n8n-io/n8n.git
git fetch public-repo master
git checkout public-repo/master
git checkout -b "$BRANCH_NAME" public-repo/master
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git cherry-pick "$COMMIT_TO_PUBLISH"
git push public-repo HEAD:master
- name: Sync public back to private
run: |
git fetch public-repo master
git push origin public-repo/master:master --force
git push public-repo "$BRANCH_NAME"
gh pr create \
--repo n8n-io/n8n \
--base master \
--head "$BRANCH_NAME" \
--title "$PR_TITLE" \
--body "Cherry-picked from n8n-private. Original PR: $PR_URL"
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_URL: ${{ github.event.pull_request.html_url }}
- name: Notify on failure
if: failure()
@@ -71,4 +58,4 @@ jobs:
status: ${{ job.status }}
channel: '#alerts-security'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: 'Security fix cherry-pick failed. Run "Security: Sync from Public" workflow, rebase your branch, reopen PR. (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})'
message: 'Security fix PR creation failed. Run "Security: Sync from Public" workflow, rebase your branch, reopen PR. (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})'
@@ -34,7 +34,7 @@ jobs:
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
@@ -43,8 +43,8 @@ jobs:
run: |
git fetch https://github.com/n8n-io/n8n.git master:public-master
# Check if private is ahead of public
AHEAD_COUNT=$(git rev-list public-master..HEAD --count)
# Check if private is ahead of public, ignore Bundle commits
AHEAD_COUNT=$(git rev-list public-master..HEAD --pretty=oneline --grep="chore: Bundle" --invert-grep --count)
if [ "$AHEAD_COUNT" -gt 0 ]; then
if [ "${{ github.event_name }}" = "schedule" ]; then
@@ -22,8 +22,7 @@ permissions:
env:
QBOT_SLACK_TOKEN: ${{ secrets.QBOT_SLACK_TOKEN }}
SLACK_CHANNEL_ID: C0A6Y62BH9T #notify-security-scan-outputs
SLACK_CHANNEL_CRITICAL: C042WDXPTEZ #mission-security
SLACK_CHANNEL_ID: C0AHNJU9XFA #updates-security
jobs:
security_scan:
@@ -31,22 +30,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout for VEX file
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
vex.openvex.json
.github/trivy.yaml
security/vex.openvex.json
security/trivy.yaml
security/trivy-ignore-policy.rego
.github/scripts/retry.mjs
sparse-checkout-cone-mode: false
- name: Pull Docker image with retry
run: |
for i in {1..4}; do
docker pull "${{ inputs.image_ref }}" && break
[ "$i" -lt 4 ] && echo "Retry $i failed, waiting..." && sleep 15
done
run: node .github/scripts/retry.mjs --attempts 4 --delay 15 'docker pull "${{ inputs.image_ref }}"'
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # v0.32.0
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # v0.34.1
id: trivy_scan
with:
image-ref: ${{ inputs.image_ref }}
@@ -56,7 +53,7 @@ jobs:
severity: 'CRITICAL,HIGH,MEDIUM,LOW'
ignore-unfixed: false
exit-code: '0'
trivy-config: '.github/trivy.yaml'
trivy-config: 'security/trivy.yaml'
- name: Calculate vulnerability counts
id: process_results
@@ -258,91 +255,3 @@ jobs:
text: "🚨 Trivy Scan: ${{ steps.process_results.outputs.critical_count }} Critical, ${{ steps.process_results.outputs.high_count }} High, ${{ steps.process_results.outputs.medium_count }} Medium, ${{ steps.process_results.outputs.low_count }} Low vulnerabilities found in ${{ inputs.image_ref }}"
blocks: ${{ steps.generate_blocks.outputs.slack_blocks }}
- name: Generate Critical Vulnerability Blocks for Mission Security
if: steps.process_results.outputs.vulnerabilities_found == 'true' && steps.process_results.outputs.critical_count != '0'
id: generate_critical_blocks
run: |
CRITICAL_BLOCKS_JSON=$(jq -c --arg image_ref "${{ inputs.image_ref }}" \
--arg repo_url "${{ github.server_url }}/${{ github.repository }}" \
--arg repo_name "${{ github.repository }}" \
--arg run_url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
--arg critical_count "${{ steps.process_results.outputs.critical_count }}" \
--arg unique_cves "${{ steps.process_results.outputs.unique_cves }}" \
'
# Function to create a detailed critical vulnerability block
def critical_vuln_block:
# Build references section if available
(if (.References | length) > 0 then
"\n*References:* " + ([.References[0:2][] | "<\(.)|\(. | split("/")[-1])>"] | join(", ")) + (if (.References | length) > 2 then " and \((.References | length) - 2) more" else "" end)
else "" end) as $refs |
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ":red_circle: *<https://nvd.nist.gov/vuln/detail/\(.VulnerabilityID)|\(.VulnerabilityID)>*\n*Severity:* CRITICAL | *CVSS Score:* `\(.CVSS.nvd.V3Score // "N/A")` | *Vector:* `\(.CVSS.nvd.V3Vector // "N/A")`\n*Package:* `\(.PkgName)@\(.InstalledVersion)` → `\(.FixedVersion // "No fix available")`\n*Published:* \(.PublishedDate // "Unknown" | split("T")[0])\n*Description:* \(.Description // "No description available" | split("\n")[0] | .[0:200])\($refs)"
}
};
# Main structure for critical vulnerabilities
[
{
"type": "header",
"text": { "type": "plain_text", "text": ":rotating_light: CRITICAL Vulnerabilities Detected" }
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Repository:*\n<\($repo_url)|\($repo_name)>" },
{ "type": "mrkdwn", "text": "*Image:*\n`\($image_ref)`" },
{ "type": "mrkdwn", "text": "*Critical Vulnerabilities:*\n:red_circle: \($critical_count)" },
{ "type": "mrkdwn", "text": "*Scan Time:*\n<!date^\(now | floor)^{date_num} {time_secs}|Unknown>" }
]
},
{ "type": "divider" },
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*:warning: Critical CVE Details:*"
}
}
] +
(
# Filter only CRITICAL vulnerabilities
[.Results[] | select(.Vulnerabilities != null) | .Vulnerabilities[] | select(.Severity == "CRITICAL")] |
# Group by CVE to avoid duplicates
group_by(.VulnerabilityID) |
map(.[0]) |
# Sort by CVSS score descending
sort_by(-((.CVSS.nvd.V3Score // 0) | tonumber? // 0)) |
# Include all critical vulnerabilities
map(. | critical_vuln_block)
) +
[
{ "type": "divider" },
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": ":github: View Full Scan Report" },
"style": "danger",
"url": $run_url
}
]
}
]
' trivy-results.json)
echo "critical_blocks=$CRITICAL_BLOCKS_JSON" >> "$GITHUB_OUTPUT"
- name: Send Critical Vulnerability Notification to Mission Security
if: steps.process_results.outputs.critical_count != '0'
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
with:
method: chat.postMessage
token: ${{ secrets.QBOT_SLACK_TOKEN }}
payload: |
channel: ${{ env.SLACK_CHANNEL_CRITICAL }}
text: "🚨 CRITICAL: ${{ steps.process_results.outputs.critical_count }} critical vulnerabilities found in ${{ inputs.image_ref }}"
blocks: ${{ steps.generate_critical_blocks.outputs.critical_blocks }}
@@ -20,10 +20,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Azure login
uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a # v2.1.1
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ secrets.BENCHMARK_ARM_CLIENT_ID }}
tenant-id: ${{ secrets.BENCHMARK_ARM_TENANT_ID }}
+4 -4
View File
@@ -42,19 +42,19 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
- uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3
- uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # v4.0.0
with:
terraform_version: '1.8.5'
- name: Azure login
uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a # v2.1.1
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ env.ARM_CLIENT_ID }}
tenant-id: ${{ env.ARM_TENANT_ID }}
@@ -86,7 +86,7 @@ jobs:
# We need to login again because the access token expires
- name: Azure login
if: always()
uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a # v2.1.1
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ env.ARM_CLIENT_ID }}
tenant-id: ${{ env.ARM_TENANT_ID }}
@@ -1,25 +1,12 @@
name: 'Test: DB Postgres MySQL'
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
pull_request:
paths:
- packages/cli/src/databases/**
- packages/cli/src/modules/*/database/**
- packages/cli/src/modules/**/*.entity.ts
- packages/cli/src/modules/**/*.repository.ts
- packages/cli/test/integration/**
- packages/cli/test/shared/db/**
- packages/@n8n/db/**
- packages/cli/**/__tests__/**
- .github/workflows/test-db-postgres-mysql.yml
- .github/docker-compose.yml
concurrency:
group: db-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
workflow_call:
inputs:
ref:
required: false
type: string
default: ''
env:
NODE_OPTIONS: '--max-old-space-size=3072'
@@ -77,6 +64,7 @@ jobs:
compose-file: ./.github/docker-compose.yml
services: |
mariadb
up-flags: --wait
- name: Test MariaDB
working-directory: packages/cli
@@ -104,11 +92,11 @@ jobs:
with:
compose-file: ./.github/docker-compose.yml
services: mysql-8.4
up-flags: --wait
- name: Test MySQL
working-directory: packages/cli
# We sleep here due to flakiness with DB tests if we connect to the database too soon
run: sleep 2s && pnpm test:mysql --testTimeout 120000
run: pnpm test:mysql --testTimeout 120000
postgres:
name: Postgres
+22 -7
View File
@@ -7,7 +7,6 @@ on:
env:
NODE_OPTIONS: --max-old-space-size=16384
TESTCONTAINERS_RYUK_DISABLED: true
PLAYWRIGHT_WORKERS: 4
PLAYWRIGHT_BROWSERS_PATH: packages/testing/playwright/.playwright-browsers
@@ -18,7 +17,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Environment
uses: ./.github/actions/setup-nodejs
@@ -42,9 +41,9 @@ jobs:
BUILD_WITH_COVERAGE: 'true'
CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }}
CURRENTS_PROJECT_ID: 'LRxcNt'
QA_PERFORMANCE_METRICS_WEBHOOK_URL: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_URL }}
QA_PERFORMANCE_METRICS_WEBHOOK_USER: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_USER }}
QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD }}
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 }}
- name: Generate Coverage Report
if: always() && steps.coverage-tests.outcome != 'skipped'
@@ -52,7 +51,7 @@ jobs:
- name: Upload Coverage Report Artifact
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: coverage-report
path: packages/testing/playwright/coverage/
@@ -60,10 +59,26 @@ jobs:
- name: Upload E2E Coverage to Codecov
if: always()
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/testing/playwright/coverage/lcov.info
flags: frontend-e2e
name: playwright-e2e
fail_ci_if_error: false
- name: Analyse Coverage Gaps
if: always() && steps.coverage-tests.outcome != 'skipped'
env:
CODECOV_API_TOKEN: ${{ secrets.CODECOV_API_TOKEN }}
run: |
node packages/testing/playwright/scripts/coverage-analysis.mjs \
--md --top=15 --out-json=coverage-gaps.json >> "$GITHUB_STEP_SUMMARY"
- name: Upload Coverage Gap Report
if: always() && steps.coverage-tests.outcome != 'skipped'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: coverage-gap-report
path: coverage-gaps.json
retention-days: 21
@@ -11,7 +11,6 @@ on:
jobs:
build-and-test-performance:
if: false # Disabled on 1.x branch
uses: ./.github/workflows/test-e2e-reusable.yml
with:
test-mode: docker-build
+39 -35
View File
@@ -42,6 +42,11 @@ on:
required: false
default: false
type: boolean
timeout-minutes:
description: 'Job timeout in minutes'
required: false
default: 30
type: number
upload-failure-artifacts:
description: 'Upload test failure artifacts (screenshots, traces, videos). Enable for community PRs without Currents access.'
required: false
@@ -52,46 +57,31 @@ on:
required: false
default: 'LRxcNt'
type: string
pre-generated-matrix:
description: 'Pre-generated shard matrix JSON (skips matrix job if provided)'
required: false
default: ''
type: string
secrets:
CURRENTS_RECORD_KEY:
required: false
CURRENTS_API_KEY:
required: false
QA_PERFORMANCE_METRICS_WEBHOOK_URL:
required: false
QA_PERFORMANCE_METRICS_WEBHOOK_USER:
required: false
QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD:
required: false
N8N_LICENSE_ACTIVATION_KEY:
required: false
N8N_LICENSE_CERT:
required: false
N8N_ENCRYPTION_KEY:
required: false
CONTAINER_TELEMETRY_WEBHOOK:
required: false
env:
NODE_OPTIONS: ${{ contains(inputs.runner, '2vcpu') && '--max-old-space-size=6144' || '' }}
# Disable Ryuk to avoid issues with Docker since it needs privileged access, containers are cleaned on teardown anyway
TESTCONTAINERS_RYUK_DISABLED: true
PLAYWRIGHT_WORKERS: ${{ inputs.workers != '' && inputs.workers || '2' }}
# Browser cache location - must match install-browsers script
PLAYWRIGHT_BROWSERS_PATH: packages/testing/playwright/.playwright-browsers
N8N_DOCKER_IMAGE: ${{ inputs.test-mode == 'docker-build' && 'n8nio/n8n:local' || inputs.docker-image }}
TEST_IMAGE_N8N: ${{ inputs.test-mode == 'docker-build' && 'n8nio/n8n:local' || inputs.docker-image }}
N8N_SKIP_LICENSES: 'true'
CURRENTS_CI_BUILD_ID: ${{ github.repository }}-${{ github.run_id }}-${{ github.run_attempt }}
CURRENTS_PROJECT_ID: ${{ inputs.currents-project-id }}
jobs:
matrix:
runs-on: blacksmith-2vcpu-ubuntu-2204
if: ${{ inputs.pre-generated-matrix == '' }}
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }}
outputs:
matrix: ${{ steps.generate.outputs.matrix }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.branch || github.ref }}
fetch-depth: 1
@@ -107,17 +97,21 @@ jobs:
test:
needs: matrix
runs-on: ${{ inputs.runner }}
timeout-minutes: 30
if: ${{ !cancelled() }}
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || inputs.runner }}
timeout-minutes: ${{ inputs.timeout-minutes }}
permissions:
packages: read
contents: read
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.matrix.outputs.matrix) }}
include: ${{ fromJSON(inputs.pre-generated-matrix || needs.matrix.outputs.matrix) }}
name: Shard ${{ matrix.shard }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
ref: ${{ inputs.branch || github.ref }}
@@ -128,7 +122,7 @@ jobs:
# docker-build: build app + docker image locally
# docker-pull: no build needed, image is pre-built
# local: build app for local server
build-command: ${{ inputs.test-mode == 'docker-build' && 'pnpm build:docker' || (inputs.test-mode == 'local' && 'pnpm build' || '') }}
build-command: ${{ inputs.test-mode == 'docker-build' && 'pnpm build:docker' || 'pnpm build' }}
enable-docker-cache: ${{ inputs.test-mode == 'docker-build' }}
env:
INCLUDE_TEST_CONTROLLER: ${{ inputs.test-mode == 'docker-build' && 'true' || '' }}
@@ -136,26 +130,29 @@ jobs:
- name: Install Browsers
run: pnpm turbo run install-browsers --filter=n8n-playwright
- name: Login to GHCR
if: ${{ inputs.test-mode == 'docker-pull' }}
uses: ./.github/actions/docker-registry-login
- name: Pre-pull Test Container Images
if: ${{ !contains(inputs.test-command, 'test:local') }}
run: npx tsx packages/testing/containers/pull-test-images.ts || true
run: npx tsx packages/testing/containers/pull-test-images.ts ${{ matrix.images }} || true
- name: Run Tests
# Uses pre-distributed specs if orchestration enabled, otherwise falls back to Playwright sharding
run: ${{ inputs.test-command }} --workers=${{ env.PLAYWRIGHT_WORKERS }} ${{ matrix.specs || format('--shard={0}/{1}', matrix.shard, strategy.job-total) }}
env:
CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }}
QA_PERFORMANCE_METRICS_WEBHOOK_URL: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_URL }}
QA_PERFORMANCE_METRICS_WEBHOOK_USER: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_USER }}
QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD }}
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 }}
N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }}
N8N_LICENSE_CERT: ${{ secrets.N8N_LICENSE_CERT }}
N8N_ENCRYPTION_KEY: ${{ secrets.N8N_ENCRYPTION_KEY }}
CONTAINER_TELEMETRY_WEBHOOK: ${{secrets.CONTAINER_TELEMETRY_WEBHOOK}}
- name: Upload Failure Artifacts
if: ${{ failure() && inputs.upload-failure-artifacts }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: playwright-report-shard-${{ matrix.shard }}
path: |
@@ -163,6 +160,13 @@ jobs:
packages/testing/playwright/playwright-report/
retention-days: 7
- name: Cleanup cached CI images
if: ${{ inputs.test-mode == 'docker-pull' }}
continue-on-error: true
run: |
docker images --format '{{.Repository}}:{{.Tag}}' | grep -E 'ghcr\.io/n8n-io/(n8n|runners):(ci|pr)-' | xargs -r docker rmi || true
docker system prune -f || true
- name: Cancel Currents run if workflow is cancelled
if: ${{ cancelled() }}
env:
+5 -3
View File
@@ -42,7 +42,7 @@ jobs:
LANGSMITH_ENDPOINT: ${{ secrets.EVALS_LANGSMITH_ENDPOINT }}
LANGSMITH_API_KEY: ${{ secrets.EVALS_LANGSMITH_API_KEY }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.branch }}
@@ -74,7 +74,7 @@ jobs:
uses: ./.github/actions/setup-nodejs
- name: Install uv
uses: astral-sh/setup-uv@d9e0f98d3fc6adb07d1e3d37f3043649ddad06a1 # 6.5.0
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
with:
enable-cache: true
@@ -103,4 +103,6 @@ jobs:
--repetitions ${{ inputs.repetitions }} \
--judges ${{ inputs.judges }} \
--concurrency ${{ inputs.concurrency }} \
--name "${{ steps.experiment.outputs.name }}"
--name "${{ steps.experiment.outputs.name }}" \
${{ secrets.EVALS_WEBHOOK_URL && format('--webhook-url "{0}"', secrets.EVALS_WEBHOOK_URL) || '' }} \
${{ secrets.EVALS_WEBHOOK_SECRET && format('--webhook-secret "{0}"', secrets.EVALS_WEBHOOK_SECRET) || '' }}
+12 -1
View File
@@ -15,6 +15,15 @@ on:
description: 'GitHub branch to test.'
required: false
default: 'master'
eval_type:
description: 'Which evaluations to run.'
required: false
default: 'both'
type: choice
options:
- both
- spec
- matrix
spec_repetitions:
description: 'Number of repetitions for spec (pairwise) evals.'
required: false
@@ -33,7 +42,7 @@ jobs:
steps:
- name: Check for no-prompt-changes opt-out
id: check
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const SKIP_TAG = '(no-prompt-changes)';
@@ -125,6 +134,7 @@ jobs:
run-pairwise-evals:
name: Run Pairwise (Spec) Evaluations
needs: determine-config
if: github.event_name == 'push' || inputs.eval_type == 'both' || inputs.eval_type == 'spec'
uses: ./.github/workflows/test-evals-ai-reusable.yml
with:
branch: ${{ needs.determine-config.outputs.branch }}
@@ -138,6 +148,7 @@ jobs:
run-llm-judge-evals:
name: Run LLM Judge (Matrix) Evaluations
needs: determine-config
if: github.event_name == 'push' || inputs.eval_type == 'both' || inputs.eval_type == 'matrix'
uses: ./.github/workflows/test-evals-ai-reusable.yml
with:
branch: ${{ needs.determine-config.outputs.branch }}
+3 -3
View File
@@ -18,10 +18,10 @@ jobs:
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python
steps:
- name: Check out project
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@d9e0f98d3fc6adb07d1e3d37f3043649ddad06a1 # 6.5.0
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
with:
enable-cache: true
@@ -47,7 +47,7 @@ jobs:
run: uv run pytest --cov=src --cov-report=xml --cov-report=term-missing
- name: Upload coverage to Codecov
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/coverage.xml
+3 -3
View File
@@ -12,7 +12,7 @@ on:
description: Version of node to use.
required: false
type: string
default: 22.x
default: 24.13.1
env:
NODE_OPTIONS: --max-old-space-size=7168
@@ -20,9 +20,9 @@ env:
jobs:
lint:
name: Lint
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref }}
+54 -26
View File
@@ -12,27 +12,22 @@ on:
description: Version of node to use.
required: false
type: string
default: 22.x
default: 24.13.1
collectCoverage:
required: false
default: false
type: boolean
secrets:
CODECOV_TOKEN:
description: 'Codecov upload token.'
required: false
env:
NODE_OPTIONS: --max-old-space-size=7168
jobs:
unit-test-backend:
name: Backend Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }} # Coverage collected when true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref }}
@@ -42,18 +37,27 @@ jobs:
node-version: ${{ inputs.nodeVersion }}
- name: Test Unit
run: pnpm test:ci:backend:unit
run: pnpm test:ci:backend:unit --summarize
- name: Send Test Stats
if: ${{ !cancelled() }}
run: node .github/scripts/send-build-stats.mjs || true
env:
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 }}
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
report-type: test_results
name: backend-unit
- name: Upload coverage to Codecov
if: env.COVERAGE_ENABLED == 'true'
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: backend-unit
@@ -61,11 +65,11 @@ jobs:
integration-test-backend:
name: Backend Integration Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }} # Coverage collected when true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref }}
@@ -75,18 +79,26 @@ jobs:
node-version: ${{ inputs.nodeVersion }}
- name: Test Integration
run: pnpm test:ci:backend:integration
run: pnpm test:ci:backend:integration --summarize
- name: Send Test Stats
if: ${{ !cancelled() }}
run: node .github/scripts/send-build-stats.mjs || true
env:
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 }}
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
name: backend-integration
- name: Upload coverage to Codecov
if: env.COVERAGE_ENABLED == 'true'
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: backend-integration
@@ -94,11 +106,11 @@ jobs:
unit-test-nodes:
name: Nodes Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }} # Coverage collected when true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref }}
@@ -108,18 +120,26 @@ jobs:
node-version: ${{ inputs.nodeVersion }}
- name: Test Nodes
run: pnpm turbo test --filter=n8n-nodes-base
run: pnpm turbo test --filter=n8n-nodes-base --summarize
- name: Send Test Stats
if: ${{ !cancelled() }}
run: node .github/scripts/send-build-stats.mjs || true
env:
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 }}
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
name: nodes-unit
- name: Upload coverage to Codecov
if: env.COVERAGE_ENABLED == 'true'
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: nodes-unit
@@ -127,7 +147,7 @@ jobs:
unit-test-frontend:
name: Frontend (${{ matrix.shard }}/2)
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
strategy:
fail-fast: false
matrix:
@@ -135,7 +155,7 @@ jobs:
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref }}
@@ -145,20 +165,28 @@ jobs:
node-version: ${{ inputs.nodeVersion }}
- name: Test
run: pnpm test:ci:frontend -- --shard=${{ matrix.shard }}/2
run: pnpm test:ci:frontend --summarize -- --shard=${{ matrix.shard }}/2
env:
VITEST_SHARD: ${{ matrix.shard }}/2
- name: Send Test Stats
if: ${{ !cancelled() }}
run: node .github/scripts/send-build-stats.mjs || true
env:
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 }}
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
name: frontend-shard-${{ matrix.shard }}
- name: Upload coverage to Codecov
if: env.COVERAGE_ENABLED == 'true'
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: frontend
+10 -58
View File
@@ -1,51 +1,23 @@
name: 'Test: Visual Chromatic'
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
pull_request_review:
types: [submitted]
concurrency:
group: chromatic-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
workflow_call:
inputs:
ref:
description: GitHub ref to check out.
required: false
type: string
default: ''
jobs:
changeset:
runs-on: blacksmith-2vcpu-ubuntu-2204
steps:
- name: Determine changed files
uses: tomi/paths-filter-action@32c62f5ca100c1110406e3477d5b3ecef4666fec # v3.0.2
id: changed
if: github.event_name == 'pull_request_review'
with:
filters: |
design_system:
- 'packages/frontend/@n8n/design-system/**'
- 'packages/frontend/@n8n/chat/**'
- 'packages/frontend/@n8n/storybook/**'
- '.github/workflows/test-visual-storybook.yml'
outputs:
has_changes: ${{ steps.changed.outputs.design_system || 'false' }}
chromatic:
needs: [changeset]
if: |
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'pull_request_review' &&
needs.changeset.outputs.has_changes == 'true' &&
github.event.review.state == 'approved' &&
!startsWith(github.event.pull_request.head.ref, 'release/') &&
!startsWith(github.event.pull_request.head.ref, 'release-pr/')
)
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
ref: ${{ inputs.ref }}
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
@@ -55,31 +27,11 @@ jobs:
- name: Publish to Chromatic
uses: chromaui/action@1cfa065cbdab28f6ca3afaeb3d761383076a35aa # v11
id: chromatic_tests
continue-on-error: true
with:
workingDir: packages/frontend/@n8n/storybook
buildScriptName: build
autoAcceptChanges: 'master'
skip: 'release/**'
onlyChanged: true
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
exitZeroOnChanges: false
- name: Success comment
if: steps.chromatic_tests.outcome == 'success' && github.ref != 'refs/heads/master'
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
with:
issue-number: ${{ github.event.pull_request.number }}
token: ${{ secrets.GITHUB_TOKEN }}
edit-mode: replace
body: |
:white_check_mark: No visual regressions found.
- name: Fail comment
if: steps.chromatic_tests.outcome != 'success' && github.ref != 'refs/heads/master'
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
with:
issue-number: ${{ github.event.pull_request.number }}
token: ${{ secrets.GITHUB_TOKEN }}
edit-mode: replace
body: |
[:warning: Visual regressions found](${{steps.chromatic_tests.outputs.url}}): ${{steps.chromatic_tests.outputs.changeCount}}
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
contents: read
deployments: write
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup Node.js
@@ -0,0 +1,37 @@
name: 'Test: Workflow scripts'
on:
workflow_call:
inputs:
ref:
description: GitHub ref to lint.
required: false
type: string
default: ''
nodeVersion:
description: Version of node to use.
required: false
type: string
default: 24.13.1
env:
NODE_OPTIONS: --max-old-space-size=7168
jobs:
workflow-script-tests:
name: Run tests for workflow scripts
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-slim' || 'blacksmith-4vcpu-ubuntu-2204' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref }}
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
- name: Run tests
id: run-tests
run: npm test --prefix=.github/scripts
@@ -29,7 +29,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2204
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.git_ref }}
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Validate User, Get PR Details, and React
id: pr_check_and_details
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -0,0 +1,45 @@
name: 'Util: Approve and set Automerge'
run-name: Approve and automerge PR ${{ inputs.pull-request-number }}
on:
workflow_call:
inputs:
pull-request-number:
type: string
required: true
permissions:
contents: write
pull-requests: write
jobs:
approve-and-automerge:
if: |
inputs.pull-request-number != ''
runs-on: ubuntu-slim
environment: release
steps:
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_RELEASE_HELPER_APP_ID }}
private-key: ${{ secrets.N8N_RELEASE_HELPER_PRIVATE_KEY }}
- name: Approve PR (as the App)
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
run: |
gh pr review "${{ inputs.pull-request-number }}" \
--approve \
--repo "${{ github.repository }}"
- name: Enable auto-merge (merge when checks pass)
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
run: |
gh pr merge "${{ inputs.pull-request-number }}" \
--auto \
--squash \
--repo "${{ github.repository }}"
+133 -90
View File
@@ -7,10 +7,35 @@ on:
description: 'Task description - what should Claude do?'
required: true
type: string
user_token:
description: 'Your GitHub PAT (required for PR authorship - you cannot approve PRs you author)'
required: true
resumeUrl:
description: 'Optional callback URL to call with the result when the workflow completes'
required: false
type: string
model:
description: 'Claude model to use (e.g., claude-opus-4-6, claude-sonnet-4-5, claude-haiku-4-5)'
required: false
type: string
default: 'claude-sonnet-4-5-20250929'
use_raw_prompt:
description: 'Pass the task input directly as the prompt without wrapping it with guidelines and instructions'
required: false
type: boolean
default: false
max_turns:
description: 'Maximum conversation turns before Claude stops gracefully'
required: false
type: number
default: 50
suppress_output:
description: 'Suppress console output and job summary'
required: false
type: boolean
default: true
ref:
description: 'Git ref (branch/tag/SHA) to check out and work on'
required: false
type: string
default: 'master'
jobs:
run-claude-task:
@@ -22,15 +47,22 @@ jobs:
issues: write
steps:
- name: Mask user token
run: echo "::add-mask::${{ inputs.user_token }}"
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ inputs.user_token }}
ref: master
ref: ${{ inputs.ref }}
fetch-depth: 1
token: ${{ steps.generate_token.outputs.token }}
- name: Configure git remote with token
run: git remote set-url origin "https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/${{ github.repository }}.git"
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
@@ -44,108 +76,119 @@ jobs:
git checkout -b "$BRANCH_NAME"
- name: Configure git author
env:
GH_TOKEN: ${{ inputs.user_token }}
run: |
# Set git author from the authenticated user (token owner)
USER_DATA=$(gh api user)
USER_NAME=$(echo "$USER_DATA" | jq -r '.name // .login')
USER_LOGIN=$(echo "$USER_DATA" | jq -r '.login')
USER_ID=$(echo "$USER_DATA" | jq -r '.id')
USER_EMAIL="${USER_ID}+${USER_LOGIN}@users.noreply.github.com"
git config user.name "$USER_NAME"
git config user.email "$USER_EMAIL"
echo "Git author configured as: $USER_NAME <$USER_EMAIL>"
git config user.name "${{ github.actor }}"
git config user.email "${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com"
echo "Git author configured as: ${{ github.actor }}"
- name: Prepare Claude prompt
env:
INPUT_TASK: ${{ inputs.task }}
USE_RAW_PROMPT: ${{ inputs.use_raw_prompt }}
run: node .github/scripts/claude-task/prepare-claude-prompt.mjs
- name: Create MCP config
run: |
# Build the prompt with task and pointer to templates
cat > /tmp/mcp-config.json << 'MCPEOF'
{
echo 'CLAUDE_PROMPT<<EOF'
echo "# Task"
echo "$INPUT_TASK"
echo ""
echo "# Guidelines"
echo "Check .github/claude-templates/ for relevant guides before starting."
echo "Read any templates that match your task type (e.g., security-fix.md for CVE fixes)."
echo ""
echo "# Instructions"
echo "1. Read relevant templates from .github/claude-templates/ first"
echo "2. Complete the task described above"
echo "3. Follow the guidelines from the templates"
echo "4. Make commits as you work - the last commit message will be used as the PR title"
echo "5. IMPORTANT: End every commit message with: Co-authored-by: Claude <[email protected]>"
echo "6. Ensure code passes linting and type checks before finishing"
echo ""
echo "# Token Optimization"
echo "When running lint/typecheck, suppress verbose output:"
echo " pnpm lint 2>&1 | tail -30"
echo " pnpm typecheck 2>&1 | tail -30"
echo 'EOF'
} >> "$GITHUB_ENV"
"mcpServers": {
"linear": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://mcp.linear.app/sse",
"--header",
"Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer MCP_LINEAR_API_KEY_PLACEHOLDER"
}
},
"notion": {
"command": "npx",
"args": ["-y", "@notionhq/notion-mcp-server"],
"env": {
"OPENAPI_MCP_HEADERS": "{\"Authorization\":\"Bearer MCP_NOTION_TOKEN_PLACEHOLDER\",\"Notion-Version\":\"2025-02-19\"}"
}
}
}
}
MCPEOF
sed -i "s|MCP_LINEAR_API_KEY_PLACEHOLDER|${{ secrets.MCP_LINEAR_API_KEY }}|g" /tmp/mcp-config.json
sed -i "s|MCP_NOTION_TOKEN_PLACEHOLDER|${{ secrets.MCP_NOTION_TOKEN }}|g" /tmp/mcp-config.json
- name: Run Claude
id: claude
uses: anthropics/claude-code-action@1b8ee3b94104046d71fde52ec3557651ad8c0d71 # v1
continue-on-error: true
uses: anthropics/claude-code-action@d668cc452deb931732f24c6b1bbf24d97bc0c586 # v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ inputs.user_token }}
github_token: ${{ steps.generate_token.outputs.token }}
prompt: ${{ env.CLAUDE_PROMPT }}
claude_args: |
--allowedTools Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite
- name: Extract PR title
run: |
# Use the last commit message as PR title
PR_TITLE=$(git log -1 --format='%s' 2>/dev/null | head -1)
# Strip Co-authored-by suffix if present
PR_TITLE="${PR_TITLE%%[Cc]o-[Aa]uthored-[Bb]y:*}"
PR_TITLE="${PR_TITLE%% }"
if [ -z "$PR_TITLE" ]; then
PR_TITLE="chore: Claude automated task (run ${{ github.run_id }})"
fi
echo "PR_TITLE=$PR_TITLE" >> "$GITHUB_ENV"
echo "Extracted PR title: $PR_TITLE"
- name: Push branch and create PR
env:
GH_TOKEN: ${{ inputs.user_token }}
INPUT_TASK: ${{ inputs.task }}
TRIGGERED_BY: ${{ github.actor }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if ! git diff --quiet master; then
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
git push -u origin "$BRANCH_NAME"
show_full_output: ${{ inputs.suppress_output != true }}
display_report: false
settings: |
{
echo "## Summary"
echo "Automated task completed by Claude."
echo ""
echo "### Task Description"
echo "$INPUT_TASK"
echo ""
echo "### Generated by"
echo "[Workflow Run #${{ github.run_id }}]($RUN_URL)"
echo ""
echo "---"
echo "*Triggered by @$TRIGGERED_BY via the Claude Task Runner workflow.*"
} > /tmp/pr-body.md
"permissions": {
"allow": [
"Bash",
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
"TodoWrite",
"Skill",
"Task",
"mcp__linear__*",
"mcp__notion__*"
]
}
}
claude_args: |
--model ${{ inputs.model }} --max-turns ${{ inputs.max_turns }} --mcp-config /tmp/mcp-config.json
gh pr create --title "$PR_TITLE" --body-file /tmp/pr-body.md --base master
- name: Push branch
if: always()
run: |
if ! git diff --quiet "${{ inputs.ref }}" 2>/dev/null; then
git push -u origin "$BRANCH_NAME"
echo "::notice::Changes pushed to branch $BRANCH_NAME"
else
echo "::notice::No changes made by Claude. Skipping PR creation."
echo "::notice::No changes to push."
fi
- name: Summary
if: always()
env:
INPUT_TASK: ${{ inputs.task }}
CLAUDE_OUTCOME: ${{ steps.claude.outcome }}
CLAUDE_SESSION_ID: ${{ steps.claude.outputs.session_id }}
run: |
{
echo "## Claude Task Runner Summary"
echo ""
echo "**Task:** $INPUT_TASK"
echo "**Branch:** $BRANCH_NAME"
echo "## Claude Task Runner"
echo "**Branch:** ${BRANCH_NAME:-N/A}"
echo "**Claude outcome:** $CLAUDE_OUTCOME"
if [ -n "$CLAUDE_SESSION_ID" ]; then
echo "**Session ID:** \`$CLAUDE_SESSION_ID\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Call resume url
if: always() && inputs.resumeUrl != ''
env:
RESUME_URL: ${{ inputs.resumeUrl }}
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
CLAUDE_OUTCOME: ${{ steps.claude.outcome }}
CLAUDE_SESSION_ID: ${{ steps.claude.outputs.session_id }}
run: node .github/scripts/claude-task/resume-callback.mjs
- name: Check final status
if: always()
run: |
if [ "${{ steps.claude.outcome }}" = "failure" ]; then
echo "::error::Claude task failed. Check the 'Run Claude' step logs for details."
exit 1
fi
+2 -2
View File
@@ -25,12 +25,12 @@ jobs:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Run Claude PR Action
uses: anthropics/claude-code-action@de8e0b9c42c6cb58e904c857f164aa072244c1ac # beta
uses: anthropics/claude-code-action@d668cc452deb931732f24c6b1bbf24d97bc0c586 # v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Or use OAuth token instead:
@@ -0,0 +1,40 @@
name: 'Util: Cleanup abandoned release branches'
on:
pull_request:
types: [closed]
jobs:
delete-release-branch:
# Only if PR was closed without merge
if: >
github.event.pull_request.merged == false
runs-on: ubuntu-slim
permissions:
contents: write
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
- name: Cleanup release branch if PR qualifies
run: node .github/scripts/cleanup-release-branch.mjs
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
GITHUB_EVENT_PATH: ${{ github.event_path }}
GITHUB_REPOSITORY: ${{ github.repository }}
@@ -0,0 +1,27 @@
name: 'Util: Cleanup CI Docker Images'
on:
schedule:
# Daily cleanup at 3 AM UTC
- cron: '0 3 * * *'
jobs:
cleanup:
name: 'Delete stale CI images'
runs-on: ubuntu-slim
permissions:
packages: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Delete stale CI images
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GHCR_ORG: ${{ github.repository_owner }}
GHCR_REPO: ${{ github.event.repository.name }}
run: node .github/scripts/cleanup-ghcr-images.mjs --stale 1
+17 -17
View File
@@ -15,22 +15,22 @@ jobs:
name: sqlite database export -> sqlite database import
runs-on: blacksmith-2vcpu-ubuntu-2204
outputs:
db_changed: ${{ steps.paths-filter.outputs.db == 'true' }}
db_changed: ${{ fromJSON(steps.paths-filter.outputs.results).db }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# with:
# ref: refs/pull/${{ github.event.pull_request.number }}/merge
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for frontend changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
- name: Check for db changes
uses: ./.github/actions/ci-filter
id: paths-filter
with:
mode: filter
base-ref: master
filters: |
db:
- packages/@n8n/db/**
- packages/cli/**
- name: Setup, build and export sqlite
if: steps.paths-filter.outputs.db == 'true'
if: fromJSON(steps.paths-filter.outputs.results).db
uses: ./.github/actions/setup-nodejs
with:
build-command: |
@@ -41,7 +41,7 @@ jobs:
name: postgres export -> postgres import
runs-on: blacksmith-2vcpu-ubuntu-2204
outputs:
db_changed: ${{ steps.paths-filter.outputs.db == 'true' }}
db_changed: ${{ fromJSON(steps.paths-filter.outputs.results).db }}
env:
DB_TYPE: postgresdb
DB_POSTGRESDB_DATABASE: n8n
@@ -51,34 +51,34 @@ jobs:
DB_POSTGRESDB_PASSWORD: password
DB_POSTGRESDB_POOL_SIZE: 1 # Detect connection pooling deadlocks
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# with:
# ref: refs/pull/${{ github.event.pull_request.number }}/merge
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for db changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
uses: ./.github/actions/ci-filter
id: paths-filter
with:
mode: filter
base-ref: master
filters: |
db:
- packages/@n8n/db/**
- packages/cli/**
- name: Setup and Build
if: steps.paths-filter.outputs.db == 'true'
if: fromJSON(steps.paths-filter.outputs.results).db
uses: ./.github/actions/setup-nodejs
- name: Start Postgres
if: steps.paths-filter.outputs.db == 'true'
uses: isbang/compose-action@802a148945af6399a338c7906c267331b39a71af # v2.0.0
if: fromJSON(steps.paths-filter.outputs.results).db
uses: isbang/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
with:
compose-file: ./.github/docker-compose.yml
services: |
postgres
- name: Export postgres
if: steps.paths-filter.outputs.db == 'true'
if: fromJSON(steps.paths-filter.outputs.results).db
run: ./packages/cli/bin/n8n export:entities --outputDir packages/cli/commands/export/outputs
- name: Import postgres
if: steps.paths-filter.outputs.db == 'true'
if: fromJSON(steps.paths-filter.outputs.results).db
run: ./packages/cli/bin/n8n import:entities --inputDir packages/cli/commands/export/outputs --truncateTables
@@ -0,0 +1,45 @@
name: 'Util: Determine current versions'
on:
workflow_dispatch:
workflow_call:
outputs:
stable:
description: 'Stable release version'
value: ${{ jobs.get-versions.outputs.stable }}
beta:
description: 'Beta release version'
value: ${{ jobs.get-versions.outputs.beta }}
v1:
description: 'v1 release version'
value: ${{ jobs.get-versions.outputs.v1 }}
jobs:
get-versions:
runs-on: ubuntu-latest
outputs:
stable: ${{ steps.get-tags.outputs.stable }}
beta: ${{ steps.get-tags.outputs.beta }}
v1: ${{ steps.get-tags.outputs.v1 }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
- name: Extract release versions
id: get-tags
run: node ./.github/scripts/get-release-versions.mjs
- name: Print detected versions
run: |
echo "Stable: ${{ steps.get-tags.outputs.stable }}"
echo "Beta: ${{ steps.get-tags.outputs.beta }}"
echo "v1: ${{ steps.get-tags.outputs.v1 }}"
@@ -0,0 +1,27 @@
name: 'Util: Ensure release candidate branches'
on:
workflow_dispatch:
workflow_call:
jobs:
ensure-release-candidate-branches:
name: Ensure release-candidate branches
runs-on: ubuntu-slim
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
- name: Setup NodeJS
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
- name: Ensure release-candidate branches
run: node ./.github/scripts/ensure-release-candidate-branches.mjs
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
env:
NOTIFY_URL: ${{ secrets.N8N_NOTIFY_PR_STATUS_CHANGED_URL }}
steps:
- uses: fjogeleit/http-request-action@bf78da14118941f7e940279dd58f67e863cbeff6 # v1
- uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2.0.0
if: ${{ env.NOTIFY_URL != '' && !contains(github.event.pull_request.labels.*.name, 'community') }}
name: Notify
env:
+4 -4
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout Main n8n Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # Fetch all history for git log
@@ -53,7 +53,7 @@ jobs:
- name: Generate GitHub App Token
if: steps.verify_file.outputs.file_exists == 'true'
id: generate_token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
@@ -62,7 +62,7 @@ jobs:
- name: Checkout Docs Repository
if: steps.verify_file.outputs.file_exists == 'true'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: n8n-io/n8n-docs
token: ${{ steps.generate_token.outputs.token }}
@@ -131,7 +131,7 @@ jobs:
if: steps.verify_file.outputs.file_exists == 'true'
# Pin v7.0.8
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ steps.generate_token.outputs.token }}
@@ -12,10 +12,24 @@ permissions:
jobs:
update-popularity:
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'schedule' && github.repository == 'n8n-io/n8n')
runs-on: ubuntu-latest
outputs:
pull-request-number: ${{ steps.create-pr.outputs.pull-request-number }}
steps:
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
- name: Setup Node.js and Dependencies
uses: ./.github/actions/setup-nodejs
@@ -30,7 +44,7 @@ jobs:
N8N_FAIL_ON_POPULARITY_FETCH_ERROR: 'false' # Don't fail if API is down
- name: Format generated file
run: pnpm format packages/frontend/editor-ui/data/node-popularity.json
run: pnpm biome format --write packages/frontend/editor-ui/data/node-popularity.json
- name: Check for changes
id: check-changes
@@ -45,10 +59,12 @@ jobs:
- name: Create Pull Request
if: steps.check-changes.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
id: create-pr
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
branch-token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: Update node popularity data'
labels: 'automation:scheduled-update'
title: 'chore: Update node popularity data'
body: |
This automated PR updates the node popularity data used for sorting nodes in the node creator panel.
@@ -61,3 +77,14 @@ jobs:
delete-branch: true
author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
approve-and-automerge:
needs: [update-popularity]
if: |
needs.update-popularity.outputs.pull-request-number != '' &&
(github.event_name == 'workflow_dispatch' ||
(github.event_name == 'schedule' && github.repository == 'n8n-io/n8n'))
uses: ./.github/workflows/util-approve-and-set-automerge.yml
secrets: inherit
with:
pull-request-number: ${{ needs.update-popularity.outputs.pull-request-number }}
+15 -2
View File
@@ -10,11 +10,13 @@ google-generated-credentials.json
_START_PACKAGE
.env
.vscode/*
!.claude
!.vscode/extensions.json
!.vscode/settings.default.json
.idea
nodelinter.config.json
**/package-lock.json
!.github/scripts/package-lock.json
packages/**/.turbo
.turbo
*.tsbuildinfo
@@ -23,19 +25,23 @@ packages/**/.turbo
CHANGELOG-*.md
*.mdx
build-storybook.log
build.log
*.junit.xml
junit.xml
test-results.json
*.0x
packages/testing/playwright/playwright-report
packages/testing/playwright/test-results
packages/testing/playwright/ms-playwright-cache
packages/testing/playwright/.playwright-browsers
packages/testing/playwright/.playwright-cli
test-results/
compiled_app_output
trivy_report*
compiled
docker-build-manifest.json
packages/cli/src/modules/my-feature
.secrets
act-event.json
packages/testing/**/.cursor/rules/
.venv
.ruff_cache
@@ -43,4 +49,11 @@ __pycache__
packages/cli/THIRD_PARTY_LICENSES.md
.coverage
.nyc_output
packages/cli/src/commands/export/outputs
coverage-gaps.json
packages/cli/src/commands/export/outputs
*.bak
.data
.data/
.claude/settings.local.json
lefthook-local.yml
.playwright-mcp
@@ -1,118 +0,0 @@
# Performance Testing Helper
A simple toolkit for measuring and asserting performance in Playwright tests.
## Quick Start
### "I just want to measure how long something takes"
```typescript
const duration = await measurePerformance(page, 'open-node', async () => {
await n8n.canvas.openNode('Code');
});
console.log(`Opening node took ${duration.toFixed(1)}ms`);
```
### "I want to ensure an action completes within a time limit"
```typescript
const openNodeDuration = await measurePerformance(page, 'open-node', async () => {
await n8n.canvas.openNode('Code');
});
expect(openNodeDuration).toBeLessThan(2000); // Must complete in under 2 seconds
```
### "I want to measure the same action multiple times"
```typescript
const stats = [];
for (let i = 0; i < 20; i++) {
const duration = await measurePerformance(page, `open-node-${i}`, async () => {
await n8n.canvas.openNode('Code');
});
await n8n.ndv.clickBackToCanvasButton();
stats.push(duration);
}
const average = stats.reduce((a, b) => a + b, 0) / stats.length;
console.log(`Average: ${average.toFixed(1)}ms`);
expect(average).toBeLessThan(2000);
```
### "I want to set performance budgets for different actions"
```typescript
const budgets = {
triggerWorkflow: 8000, // 8 seconds
openLargeNode: 2500, // 2.5 seconds
};
// Measure workflow execution
const triggerDuration = await measurePerformance(page, 'trigger-workflow', async () => {
await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful');
});
expect(triggerDuration).toBeLessThan(budgets.triggerWorkflow);
// Measure node opening
const openDuration = await measurePerformance(page, 'open-large-node', async () => {
await n8n.canvas.openNode('Code');
});
expect(openDuration).toBeLessThan(budgets.openLargeNode);
```
### "I want to test performance with different data sizes"
```typescript
const testData = [
{ size: 30000, budgets: { triggerWorkflow: 8000, openLargeNode: 2500 } },
{ size: 60000, budgets: { triggerWorkflow: 15000, openLargeNode: 6000 } },
];
testData.forEach(({ size, budgets }) => {
test(`performance - ${size.toLocaleString()} items`, async ({ page }) => {
// Setup test with specific data size
await setupTest(size);
// Measure against size-specific budgets
const duration = await measurePerformance(page, 'trigger-workflow', async () => {
await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful')
});
expect(duration).toBeLessThan(budgets.triggerWorkflow);
});
});
```
### "I want to see all performance metrics from my test"
```typescript
// After running various performance measurements...
const allMetrics = await getAllPerformanceMetrics(page);
console.log('All performance metrics:', allMetrics);
// Output: { 'open-node': 1234.5, 'save-workflow': 567.8, ... }
```
### "I want to attach performance results to my test report"
```typescript
const allMetrics = await getAllPerformanceMetrics(page);
await test.info().attach('performance-metrics', {
body: JSON.stringify({
dataSize: 30000,
metrics: allMetrics,
budgets: { triggerWorkflow: 8000, openLargeNode: 2500 },
passed: {
triggerWorkflow: allMetrics['trigger-workflow'] < 8000,
openNode: allMetrics['open-large-node'] < 2500,
}
}, null, 2),
contentType: 'application/json',
});
```
## API Reference
### `measurePerformance(page, actionName, actionFn)`
Measures the duration of an async action using the Performance API.
- **Returns:** `Promise<number>` - Duration in milliseconds
### `getAllPerformanceMetrics(page)`
Retrieves all performance measurements from the current page.
- **Returns:** `Promise<Record<string, number>>` - Map of action names to durations
## Tips
- Use unique names for measurements in loops (e.g., `open-node-${i}`) to avoid conflicts
- Set realistic budgets - add some buffer to account for variance
- Consider different budgets for different data sizes or environments

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