mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
ci: Backport .github/ to 1.x (#27154)
This commit is contained in:
+77
-16
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"checkJs": true,
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
Generated
+536
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
+9
-21
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) || '' }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user