first commit
Some checks failed
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Some checks failed
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
74
.github/scripts/bump-versions.mjs
vendored
Normal file
74
.github/scripts/bump-versions.mjs
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
import semver from 'semver';
|
||||
import { writeFile, readFile } from 'fs/promises';
|
||||
import { resolve } from 'path';
|
||||
import child_process from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import assert from 'assert';
|
||||
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
function generateExperimentalVersion(currentVersion) {
|
||||
const parsed = semver.parse(currentVersion);
|
||||
if (!parsed) throw new Error(`Invalid version: ${currentVersion}`);
|
||||
|
||||
// Check if it's already an experimental version
|
||||
if (parsed.prerelease.length > 0 && parsed.prerelease[0] === 'exp') {
|
||||
// Increment the experimental minor version
|
||||
const expMinor = (parsed.prerelease[1] || 0) + 1;
|
||||
return `${parsed.major}.${parsed.minor}.${parsed.patch}-exp.${expMinor}`;
|
||||
}
|
||||
|
||||
// Create new experimental version: <major>.<minor>.<patch>-exp.0
|
||||
return `${parsed.major}.${parsed.minor}.${parsed.patch}-exp.0`;
|
||||
}
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const releaseType = process.env.RELEASE_TYPE;
|
||||
assert.match(releaseType, /^(patch|minor|major|experimental|premajor)$/, 'Invalid RELEASE_TYPE');
|
||||
|
||||
// TODO: if releaseType is `auto` determine release type based on the changelog
|
||||
|
||||
const lastTag = (await exec('git describe --tags --match "n8n@*" --abbrev=0')).stdout.trim();
|
||||
const packages = JSON.parse((await exec('pnpm ls -r --only-projects --json')).stdout);
|
||||
|
||||
const packageMap = {};
|
||||
for (let { name, path, version, private: isPrivate, dependencies } of packages) {
|
||||
if (isPrivate && path !== rootDir) continue;
|
||||
if (path === rootDir) name = 'monorepo-root';
|
||||
|
||||
const isDirty = await exec(`git diff --quiet HEAD ${lastTag} -- ${path}`)
|
||||
.then(() => false)
|
||||
.catch((error) => true);
|
||||
|
||||
packageMap[name] = { path, isDirty, version };
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
Object.values(packageMap).some(({ isDirty }) => isDirty),
|
||||
'No changes found since the last release',
|
||||
);
|
||||
|
||||
// Keep the monorepo version up to date with the released version
|
||||
packageMap['monorepo-root'].version = packageMap['n8n'].version;
|
||||
|
||||
for (const packageName in packageMap) {
|
||||
const { path, version, isDirty } = packageMap[packageName];
|
||||
const packageFile = resolve(path, 'package.json');
|
||||
const packageJson = JSON.parse(await readFile(packageFile, 'utf-8'));
|
||||
|
||||
packageJson.version = packageMap[packageName].nextVersion =
|
||||
isDirty ||
|
||||
Object.keys(packageJson.dependencies || {}).some(
|
||||
(dependencyName) => packageMap[dependencyName]?.isDirty,
|
||||
)
|
||||
? releaseType === 'experimental'
|
||||
? generateExperimentalVersion(version)
|
||||
: releaseType === 'premajor'
|
||||
? semver.inc(version, version.includes('-rc.') ? 'prerelease' : 'premajor', undefined, 'rc')
|
||||
: semver.inc(version, releaseType)
|
||||
: version;
|
||||
|
||||
await writeFile(packageFile, JSON.stringify(packageJson, null, 2) + '\n');
|
||||
}
|
||||
|
||||
console.log(packageMap['n8n'].nextVersion);
|
||||
68
.github/scripts/claude-task/prepare-claude-prompt.mjs
vendored
Normal file
68
.github/scripts/claude-task/prepare-claude-prompt.mjs
vendored
Normal file
@@ -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 <noreply@anthropic.com>
|
||||
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`);
|
||||
|
||||
59
.github/scripts/claude-task/resume-callback.mjs
vendored
Normal file
59
.github/scripts/claude-task/resume-callback.mjs
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sends a callback to the resume URL with the Claude task result.
|
||||
* Uses fetch() directly to avoid E2BIG errors from shell argument limits.
|
||||
*
|
||||
* Usage: node resume-callback.mjs
|
||||
*
|
||||
* Environment variables:
|
||||
* RESUME_URL - Callback URL to POST to (required)
|
||||
* EXECUTION_FILE - Path to Claude's execution output JSON (optional)
|
||||
* CLAUDE_OUTCOME - "success" or "failure" (required)
|
||||
* CLAUDE_SESSION_ID - Session ID for resuming conversations (optional)
|
||||
* BRANCH_NAME - Git branch name (optional)
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
const resumeUrl = process.env.RESUME_URL;
|
||||
const executionFile = process.env.EXECUTION_FILE;
|
||||
const claudeOutcome = process.env.CLAUDE_OUTCOME;
|
||||
const sessionId = process.env.CLAUDE_SESSION_ID ?? '';
|
||||
const branchName = process.env.BRANCH_NAME ?? '';
|
||||
|
||||
if (!resumeUrl) {
|
||||
console.error('RESUME_URL environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const success = claudeOutcome === 'success';
|
||||
let result = null;
|
||||
|
||||
if (executionFile && existsSync(executionFile)) {
|
||||
try {
|
||||
const execution = JSON.parse(readFileSync(executionFile, 'utf-8'));
|
||||
// Extract the last element (Claude's final result message)
|
||||
result = Array.isArray(execution) ? execution.at(-1) : execution;
|
||||
} catch (err) {
|
||||
console.warn(`Failed to parse execution file: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ success, branch: branchName, sessionId, result });
|
||||
|
||||
try {
|
||||
const response = await fetch(resumeUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
console.error(`Callback failed: ${body}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Callback error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
157
.github/scripts/cleanup-ghcr-images.mjs
vendored
Normal file
157
.github/scripts/cleanup-ghcr-images.mjs
vendored
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleanup GHCR images for n8n CI
|
||||
*
|
||||
* Modes:
|
||||
* --tag <tag> Delete exact tag (post-run cleanup)
|
||||
* --stale <days> Delete ci-* images older than N days (daily scheduled cleanup)
|
||||
*
|
||||
* Context:
|
||||
* - Each CI run tags images as ci-{run_id}
|
||||
* - Post-run cleanup uses --tag to delete the current run's images
|
||||
* - Daily cron uses --stale to catch any orphaned images
|
||||
*/
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const ORG = process.env.GHCR_ORG || 'n8n-io';
|
||||
const REPO = process.env.GHCR_REPO || 'n8n';
|
||||
const PACKAGES = [REPO, 'runners'];
|
||||
const [mode, rawValue] = process.argv.slice(2);
|
||||
|
||||
if (!['--tag', '--stale'].includes(mode) || !rawValue) {
|
||||
console.error('Usage: cleanup-ghcr-images.mjs --tag <tag> | --stale <days>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const value = mode === '--stale' ? parseInt(rawValue, 10) : rawValue;
|
||||
if (mode === '--stale' && (isNaN(value) || value <= 0)) {
|
||||
console.error('Error: --stale requires a positive number of days');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function ghApi(path) {
|
||||
const { stdout } = await execAsync(
|
||||
`gh api "/orgs/${ORG}/packages/container/${path}"`,
|
||||
);
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
async function ghDelete(path) {
|
||||
await execAsync(`gh api --method DELETE "/orgs/${ORG}/packages/container/${path}"`);
|
||||
}
|
||||
|
||||
async function fetchPage(pkg, page) {
|
||||
try {
|
||||
return await ghApi(`${pkg}/versions?per_page=100&page=${page}`);
|
||||
} catch (err) {
|
||||
if (err.code === 1 && err.stderr?.includes('404')) return [];
|
||||
throw new Error(`Failed to fetch ${pkg} page ${page}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const isCiImage = (v) => {
|
||||
const tags = v.metadata?.container?.tags || [];
|
||||
return tags.some((t) => t.startsWith('ci-') || t.startsWith('pr-'));
|
||||
};
|
||||
|
||||
const isStale = (v, days) => {
|
||||
const cutoff = Date.now() - days * 86400000;
|
||||
return isCiImage(v) && new Date(v.created_at) < cutoff;
|
||||
};
|
||||
|
||||
async function getVersionsForTag(pkg, tag) {
|
||||
const batch = await fetchPage(pkg, 1);
|
||||
const match = batch.find((v) => v.metadata?.container?.tags?.includes(tag));
|
||||
return match ? [match] : [];
|
||||
}
|
||||
|
||||
async function getVersionsForStale(pkg, days) {
|
||||
const versions = [];
|
||||
const cutoff = Date.now() - days * 86400000;
|
||||
// Use 2x cutoff as safety window for early termination
|
||||
const earlyExitCutoff = Date.now() - days * 2 * 86400000;
|
||||
let pagesWithoutCiImages = 0;
|
||||
|
||||
const firstPage = await fetchPage(pkg, 1);
|
||||
if (!firstPage.length) return [];
|
||||
|
||||
for (const v of firstPage) {
|
||||
if (isStale(v, days)) versions.push(v);
|
||||
}
|
||||
if (firstPage.length < 100) return versions;
|
||||
|
||||
for (let page = 2; ; page += 10) {
|
||||
const batches = await Promise.all(
|
||||
Array.from({ length: 10 }, (_, i) => fetchPage(pkg, page + i)),
|
||||
);
|
||||
let done = false;
|
||||
for (const batch of batches) {
|
||||
if (!batch.length || batch.length < 100) done = true;
|
||||
|
||||
let hasCiImages = false;
|
||||
for (const v of batch) {
|
||||
if (isCiImage(v)) {
|
||||
hasCiImages = true;
|
||||
if (new Date(v.created_at) < cutoff) versions.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
// Early termination: if we've gone through pages without finding
|
||||
// any CI images and all items are older than 2x cutoff, we're past
|
||||
// the CI image window
|
||||
if (!hasCiImages) {
|
||||
pagesWithoutCiImages++;
|
||||
const oldestInBatch = batch[batch.length - 1];
|
||||
if (
|
||||
pagesWithoutCiImages >= 3 &&
|
||||
oldestInBatch &&
|
||||
new Date(oldestInBatch.created_at) < earlyExitCutoff
|
||||
) {
|
||||
console.log(` Early termination at page ${page + batches.indexOf(batch)}`);
|
||||
done = true;
|
||||
}
|
||||
} else {
|
||||
pagesWithoutCiImages = 0;
|
||||
}
|
||||
|
||||
if (!batch.length || done) break;
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
for (const pkg of PACKAGES) {
|
||||
console.log(`Processing ${pkg}...`);
|
||||
let consecutiveErrors = 0;
|
||||
|
||||
const toDelete =
|
||||
mode === '--tag'
|
||||
? await getVersionsForTag(pkg, value)
|
||||
: await getVersionsForStale(pkg, value);
|
||||
|
||||
if (!toDelete.length) {
|
||||
console.log(` No matching images found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const v of toDelete) {
|
||||
try {
|
||||
await ghDelete(`${pkg}/versions/${v.id}`);
|
||||
console.log(` Deleted ${v.metadata.container.tags.join(',')}`);
|
||||
consecutiveErrors = 0;
|
||||
} catch (err) {
|
||||
console.error(` Failed to delete ${v.id}: ${err.message}`);
|
||||
hasErrors = true;
|
||||
if (++consecutiveErrors >= 3) {
|
||||
throw new Error('Too many consecutive delete failures, aborting');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) process.exit(1);
|
||||
123
.github/scripts/cleanup-release-branch.mjs
vendored
Normal file
123
.github/scripts/cleanup-release-branch.mjs
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { getOctokit } from '@actions/github';
|
||||
import { ensureEnvVar, readPrLabels } from './github-helpers.mjs';
|
||||
|
||||
/**
|
||||
* @typedef {PullRequestCheckPass | PullRequestCheckFail} PullRequestCheckResult
|
||||
**/
|
||||
|
||||
/**
|
||||
* @typedef PullRequestCheckPass
|
||||
* @property {true} pass
|
||||
* @property {string} baseRef
|
||||
* */
|
||||
|
||||
/**
|
||||
* @typedef PullRequestCheckFail
|
||||
* @property {false} pass
|
||||
* @property {string} reason
|
||||
* */
|
||||
|
||||
/**
|
||||
* @param {PullRequestCheckResult} pullRequestCheck
|
||||
*
|
||||
* @returns { pullRequestCheck is PullRequestCheckFail }
|
||||
* */
|
||||
function pullRequestCheckFailed(pullRequestCheck) {
|
||||
return !pullRequestCheck.pass;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} pullRequest
|
||||
* @returns {PullRequestCheckResult}
|
||||
*/
|
||||
export function pullRequestIsDismissedRelease(pullRequest) {
|
||||
if (!pullRequest) {
|
||||
throw new Error('Missing pullRequest in event payload');
|
||||
}
|
||||
|
||||
const baseRef = pullRequest?.base?.ref ?? '';
|
||||
const headRef = pullRequest?.head?.ref ?? '';
|
||||
const merged = Boolean(pullRequest?.merged);
|
||||
|
||||
if (merged) {
|
||||
return { pass: false, reason: 'PR was merged' };
|
||||
}
|
||||
|
||||
// Must match your release PR pattern:
|
||||
// base: release/<ver>
|
||||
// head: release-pr/<ver>
|
||||
if (!baseRef.startsWith('release/')) {
|
||||
return { pass: false, reason: `Base ref '${baseRef}' is not release/*` };
|
||||
}
|
||||
if (!headRef.startsWith('release-pr/')) {
|
||||
return { pass: false, reason: `Head ref '${headRef}' is not release-pr/*` };
|
||||
}
|
||||
|
||||
const baseVer = baseRef.slice('release/'.length);
|
||||
const headVer = headRef.slice('release-pr/'.length);
|
||||
|
||||
if (!baseVer || baseVer !== headVer) {
|
||||
return { pass: false, reason: `Version mismatch: base='${baseVer}' head='${headVer}'` };
|
||||
}
|
||||
|
||||
const labelNames = readPrLabels(pullRequest);
|
||||
if (!labelNames.includes('release')) {
|
||||
return {
|
||||
pass: false,
|
||||
reason: `Missing required label 'release' (labels: ${labelNames.join(', ') || '[none]'})`,
|
||||
};
|
||||
}
|
||||
|
||||
return { pass: true, baseRef };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const token = ensureEnvVar('GITHUB_TOKEN');
|
||||
const eventPath = ensureEnvVar('GITHUB_EVENT_PATH');
|
||||
const repoFullName = ensureEnvVar('GITHUB_REPOSITORY');
|
||||
|
||||
const [owner, repo] = repoFullName.split('/');
|
||||
if (!owner || !repo) {
|
||||
throw new Error(`Invalid GITHUB_REPOSITORY: '${repoFullName}'`);
|
||||
}
|
||||
|
||||
const rawEventData = await fs.readFile(eventPath, 'utf8');
|
||||
const event = JSON.parse(rawEventData);
|
||||
|
||||
const result = pullRequestIsDismissedRelease(event.pull_request);
|
||||
if (pullRequestCheckFailed(result)) {
|
||||
console.log(`no-op: ${result.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const branch = result.baseRef; // e.g. "release/2.11.0"
|
||||
console.log(`PR qualifies. Deleting branch '${branch}'...`);
|
||||
|
||||
const octokit = getOctokit(token);
|
||||
|
||||
try {
|
||||
await octokit.rest.git.deleteRef({
|
||||
owner,
|
||||
repo,
|
||||
// ref must be "heads/<branch>"
|
||||
ref: `heads/${branch}`,
|
||||
});
|
||||
console.log(`Deleted '${branch}'.`);
|
||||
} catch (err) {
|
||||
// If it was already deleted, treat as success.
|
||||
const status = err?.status;
|
||||
if (status === 404) {
|
||||
console.log(`Branch '${branch}' not found (already deleted).`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
throw new Error(`Failed to delete '${branch}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
// only run when executed directly, not when imported by tests
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
await main();
|
||||
}
|
||||
147
.github/scripts/cleanup-release-branch.test.mjs
vendored
Normal file
147
.github/scripts/cleanup-release-branch.test.mjs
vendored
Normal file
@@ -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(', ')})`,
|
||||
);
|
||||
});
|
||||
});
|
||||
74
.github/scripts/compute-backport-targets.mjs
vendored
Normal file
74
.github/scripts/compute-backport-targets.mjs
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
// Creates backport PR's according to labels on merged PR
|
||||
|
||||
import { 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;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const labels = new Set(readPrLabels());
|
||||
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();
|
||||
}
|
||||
62
.github/scripts/compute-backport-targets.test.mjs
vendored
Normal file
62
.github/scripts/compute-backport-targets.test.mjs
vendored
Normal file
@@ -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/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: () => {}, // no-op
|
||||
resolveRcBranchForTrack: mockResolveRcBranchForTrack,
|
||||
writeGithubOutput: () => {}, //no-op
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
before(async () => {
|
||||
({ labelsToReleaseCandidateBranches } = 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);
|
||||
});
|
||||
});
|
||||
91
.github/scripts/detect-new-packages.mjs
vendored
Normal file
91
.github/scripts/detect-new-packages.mjs
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Detects packages in the monorepo that have not yet been published to npm.
|
||||
*
|
||||
* Packages that are new (never published) cannot be released via OIDC Trusted
|
||||
* Publishing because Trusted Publishing requires the package to already exist
|
||||
* on npm with the publisher configured first.
|
||||
*
|
||||
* New packages must be handled manually:
|
||||
* 1. Published once using an NPM token
|
||||
* 2. Configured with Trusted Publishing on npmjs.com
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 – All public packages exist on npm
|
||||
* 1 – One or more public packages have never been published
|
||||
*/
|
||||
|
||||
import child_process from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
const packages = JSON.parse((await exec('pnpm ls -r --only-projects --json')).stdout);
|
||||
|
||||
const newPackages = [];
|
||||
|
||||
for (const { name, private: isPrivate } of packages) {
|
||||
if (isPrivate) continue;
|
||||
|
||||
// Scoped packages must be encoded: @n8n/foo → @n8n%2Ffoo
|
||||
const encodedName = name.startsWith('@') ? name.replace('/', '%2F') : name;
|
||||
const url = `https://registry.npmjs.org/${encodedName}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' });
|
||||
if (response.status === 404) {
|
||||
newPackages.push(name);
|
||||
} else if (!response.ok && response.status !== 405) {
|
||||
// 405 = Method Not Allowed for HEAD (some registries), not an error
|
||||
console.log(
|
||||
`::warning::Unexpected HTTP ${response.status} when checking npm registry for "${name}". Skipping check.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`::warning::Could not reach npm registry for "${name}": ${error.message}. Skipping check.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (newPackages.length === 0) {
|
||||
const publicCount = packages.filter((p) => !p.private).length;
|
||||
console.log(`✅ All ${publicCount} public packages exist on npm.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`
|
||||
|
||||
❌ New unpublished packages detected!
|
||||
|
||||
The following packages do not yet exist on npm and cannot be published via
|
||||
OIDC Trusted Publishing until they have been published at least once manually:
|
||||
|
||||
`);
|
||||
|
||||
for (const pkg of newPackages) {
|
||||
console.log(` - ${pkg}`);
|
||||
console.log(
|
||||
`::error::Package "${pkg}" has never been published to npm. A manual first-publish with an NPM token is required before it can use OIDC Trusted Publishing.`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`
|
||||
Steps to unblock the release, for each new package listed above:
|
||||
|
||||
1. Publish the package once manually using an NPM token:
|
||||
cd to/where/package/lives
|
||||
pnpm login
|
||||
pnpm publish --access public
|
||||
|
||||
2. Configure Trusted Publishing on npmjs.com for each new package:
|
||||
https://docs.npmjs.com/trusted-publishers
|
||||
|
||||
Use the following settings:
|
||||
Repository owner : n8n-io
|
||||
Repository name : n8n
|
||||
Workflow filename: release-publish.yml
|
||||
|
||||
3. Re-run the Release: Publish workflow.
|
||||
|
||||
`);
|
||||
process.exit(1);
|
||||
122
.github/scripts/determine-version-info.mjs
vendored
Normal file
122
.github/scripts/determine-version-info.mjs
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { RELEASE_TRACKS, resolveReleaseTagForTrack, writeGithubOutput } from './github-helpers.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 output = {
|
||||
version: packageVersion,
|
||||
track,
|
||||
bump,
|
||||
new_stable_version: newStable,
|
||||
release_type: releaseType,
|
||||
};
|
||||
|
||||
writeGithubOutput(output);
|
||||
console.log(
|
||||
`Determined track info: track=${track}, version=${packageVersion}, new_stable_version=${newStable}, release_type=${releaseType}`,
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
91
.github/scripts/determine-version-info.test.mjs
vendored
Normal file
91
.github/scripts/determine-version-info.test.mjs
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
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: 'n8n@2.9.2' };
|
||||
if (track === 'beta') return { version: '2.10.1', tag: 'n8n@2.10.1' };
|
||||
return { version: '1.123.33', tag: 'n8n@1.123.33' };
|
||||
},
|
||||
writeGithubOutput: () => {}, // no-op in tests
|
||||
},
|
||||
});
|
||||
|
||||
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.bump, 'patch');
|
||||
assert.equal(output.new_stable_version, null);
|
||||
assert.equal(output.release_type, 'stable');
|
||||
});
|
||||
|
||||
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.bump, 'patch');
|
||||
assert.equal(output.new_stable_version, null);
|
||||
assert.equal(output.release_type, 'stable');
|
||||
});
|
||||
|
||||
// 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.bump, 'patch');
|
||||
assert.equal(output.new_stable_version, null);
|
||||
assert.equal(output.release_type, 'stable');
|
||||
});
|
||||
|
||||
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.bump, 'minor');
|
||||
assert.equal(output.new_stable_version, '2.10.1');
|
||||
assert.equal(output.release_type, 'stable');
|
||||
});
|
||||
|
||||
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.bump, 'patch');
|
||||
assert.equal(output.new_stable_version, null);
|
||||
assert.equal(output.release_type, 'rc');
|
||||
});
|
||||
});
|
||||
177
.github/scripts/docker/docker-config.mjs
vendored
Normal file
177
.github/scripts/docker/docker-config.mjs
vendored
Normal file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { appendFileSync } from 'node:fs';
|
||||
|
||||
class BuildContext {
|
||||
constructor() {
|
||||
this.githubOutput = process.env.GITHUB_OUTPUT || null;
|
||||
}
|
||||
|
||||
determine({ event, pr, branch, version, releaseType, pushEnabled }) {
|
||||
let context = {
|
||||
version: '',
|
||||
release_type: '',
|
||||
platforms: ['linux/amd64', 'linux/arm64'],
|
||||
push_to_ghcr: true,
|
||||
push_to_docker: false,
|
||||
};
|
||||
|
||||
if (version && releaseType) {
|
||||
context.version = version;
|
||||
context.release_type = releaseType;
|
||||
context.push_to_docker = true;
|
||||
} else {
|
||||
switch (event) {
|
||||
case 'schedule':
|
||||
context.version = 'nightly';
|
||||
context.release_type = 'nightly';
|
||||
context.push_to_docker = true;
|
||||
break;
|
||||
|
||||
case 'pull_request':
|
||||
context.version = `pr-${pr}`;
|
||||
context.release_type = 'dev';
|
||||
context.platforms = ['linux/amd64'];
|
||||
break;
|
||||
|
||||
case 'workflow_dispatch':
|
||||
context.version = `branch-${this.sanitizeBranch(branch)}`;
|
||||
context.release_type = 'branch';
|
||||
context.platforms = ['linux/amd64'];
|
||||
break;
|
||||
|
||||
case 'push':
|
||||
if (branch === 'master') {
|
||||
context.version = 'dev';
|
||||
context.release_type = 'dev';
|
||||
context.push_to_docker = true;
|
||||
} else {
|
||||
context.version = `branch-${this.sanitizeBranch(branch)}`;
|
||||
context.release_type = 'branch';
|
||||
context.platforms = ['linux/amd64'];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'workflow_call':
|
||||
case 'release':
|
||||
if (!version) throw new Error('Version required for release');
|
||||
context.version = version;
|
||||
context.release_type = releaseType || 'stable';
|
||||
context.push_to_docker = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown event: ${event}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle push_enabled override
|
||||
if (pushEnabled !== undefined) {
|
||||
context.push_enabled = pushEnabled;
|
||||
} else {
|
||||
context.push_enabled = context.push_to_ghcr;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
sanitizeBranch(branch) {
|
||||
if (!branch) return 'unknown';
|
||||
return branch
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]/g, '-')
|
||||
.replace(/^[.-]/, '')
|
||||
.replace(/[.-]$/, '')
|
||||
.substring(0, 128);
|
||||
}
|
||||
|
||||
buildMatrix(platforms) {
|
||||
const runners = {
|
||||
'linux/amd64': 'blacksmith-4vcpu-ubuntu-2204',
|
||||
'linux/arm64': 'blacksmith-4vcpu-ubuntu-2204-arm',
|
||||
};
|
||||
|
||||
const matrix = {
|
||||
platform: [],
|
||||
include: [],
|
||||
};
|
||||
|
||||
for (const platform of platforms) {
|
||||
const shortName = platform.split('/').pop(); // amd64 or arm64
|
||||
matrix.platform.push(shortName);
|
||||
matrix.include.push({
|
||||
platform: shortName,
|
||||
runner: runners[platform],
|
||||
docker_platform: platform,
|
||||
});
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
output(context, matrix = null) {
|
||||
const buildMatrix = matrix || this.buildMatrix(context.platforms);
|
||||
|
||||
if (this.githubOutput) {
|
||||
const outputs = [
|
||||
`version=${context.version}`,
|
||||
`release_type=${context.release_type}`,
|
||||
`platforms=${JSON.stringify(context.platforms)}`,
|
||||
`push_to_ghcr=${context.push_to_ghcr}`,
|
||||
`push_to_docker=${context.push_to_docker}`,
|
||||
`push_enabled=${context.push_enabled}`,
|
||||
`build_matrix=${JSON.stringify(buildMatrix)}`,
|
||||
];
|
||||
appendFileSync(this.githubOutput, outputs.join('\n') + '\n');
|
||||
} else {
|
||||
console.log(JSON.stringify({ ...context, build_matrix: buildMatrix }, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CLI - Simple argument parsing
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = process.argv.slice(2);
|
||||
const getArg = (name) => {
|
||||
const index = args.indexOf(`--${name}`);
|
||||
if (index === -1 || !args[index + 1]) return undefined;
|
||||
const value = args[index + 1];
|
||||
// Handle empty strings and 'null' as undefined
|
||||
return value === '' || value === 'null' ? undefined : value;
|
||||
};
|
||||
|
||||
try {
|
||||
const context = new BuildContext();
|
||||
const pushEnabledArg = getArg('push-enabled');
|
||||
const result = context.determine({
|
||||
event: getArg('event') || process.env.GITHUB_EVENT_NAME,
|
||||
pr: getArg('pr') || process.env.GITHUB_PR_NUMBER,
|
||||
branch: getArg('branch') || process.env.GITHUB_REF_NAME,
|
||||
version: getArg('version'),
|
||||
releaseType: getArg('release-type'),
|
||||
pushEnabled:
|
||||
pushEnabledArg === 'true' ? true : pushEnabledArg === 'false' ? false : undefined,
|
||||
});
|
||||
|
||||
const matrix = context.buildMatrix(result.platforms);
|
||||
|
||||
// Debug output when GITHUB_OUTPUT is set (running in Actions)
|
||||
if (context.githubOutput) {
|
||||
console.log('=== Build Context ===');
|
||||
console.log(`version: ${result.version}`);
|
||||
console.log(`release_type: ${result.release_type}`);
|
||||
console.log(`platforms: ${JSON.stringify(result.platforms, null, 2)}`);
|
||||
console.log(`push_to_ghcr: ${result.push_to_ghcr}`);
|
||||
console.log(`push_to_docker: ${result.push_to_docker}`);
|
||||
console.log(`push_enabled: ${result.push_enabled}`);
|
||||
console.log('build_matrix:', JSON.stringify(matrix, null, 2));
|
||||
}
|
||||
|
||||
context.output(result, matrix);
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export default BuildContext;
|
||||
113
.github/scripts/docker/docker-tags.mjs
vendored
Normal file
113
.github/scripts/docker/docker-tags.mjs
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { appendFileSync } from 'node:fs';
|
||||
|
||||
class TagGenerator {
|
||||
constructor() {
|
||||
this.githubOwner = process.env.GITHUB_REPOSITORY_OWNER || 'n8n-io';
|
||||
this.dockerUsername = process.env.DOCKER_USERNAME || 'n8nio';
|
||||
this.githubOutput = process.env.GITHUB_OUTPUT || null;
|
||||
}
|
||||
|
||||
generate({ image, version, platform, includeDockerHub = false }) {
|
||||
let imageName = image;
|
||||
let versionSuffix = '';
|
||||
|
||||
if (image === 'runners-distroless') {
|
||||
imageName = 'runners';
|
||||
versionSuffix = '-distroless';
|
||||
}
|
||||
|
||||
const platformSuffix = platform ? `-${platform.split('/').pop()}` : '';
|
||||
const fullVersion = `${version}${versionSuffix}${platformSuffix}`;
|
||||
|
||||
const tags = {
|
||||
ghcr: [`ghcr.io/${this.githubOwner}/${imageName}:${fullVersion}`],
|
||||
docker: includeDockerHub ? [`${this.dockerUsername}/${imageName}:${fullVersion}`] : [],
|
||||
};
|
||||
|
||||
tags.all = [...tags.ghcr, ...tags.docker];
|
||||
return tags;
|
||||
}
|
||||
|
||||
output(tags, prefix = '') {
|
||||
if (this.githubOutput) {
|
||||
const prefixStr = prefix ? `${prefix}_` : '';
|
||||
const primaryTag = tags.ghcr[0] ? tags.ghcr[0].replace(/-amd64$|-arm64$/, '') : '';
|
||||
const outputs = [
|
||||
`${prefixStr}tags=${tags.all.join(',')}`,
|
||||
`${prefixStr}ghcr_tag=${tags.ghcr[0] || ''}`,
|
||||
`${prefixStr}docker_tag=${tags.docker[0] || ''}`,
|
||||
`${prefixStr}primary_tag=${primaryTag}`,
|
||||
];
|
||||
appendFileSync(this.githubOutput, outputs.join('\n') + '\n');
|
||||
} else {
|
||||
console.log(JSON.stringify(tags, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
generateAll({ version, platform, includeDockerHub = false }) {
|
||||
const images = ['n8n', 'runners', 'runners-distroless'];
|
||||
const results = {};
|
||||
|
||||
for (const image of images) {
|
||||
const tags = this.generate({ image, version, platform, includeDockerHub });
|
||||
const prefix = image.replace('-distroless', '_distroless');
|
||||
results[prefix] = tags;
|
||||
|
||||
if (this.githubOutput) {
|
||||
this.output(tags, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = process.argv.slice(2);
|
||||
const getArg = (name) => {
|
||||
const index = args.indexOf(`--${name}`);
|
||||
return index !== -1 && args[index + 1] ? args[index + 1] : undefined;
|
||||
};
|
||||
const hasFlag = (name) => args.includes(`--${name}`);
|
||||
|
||||
try {
|
||||
const generator = new TagGenerator();
|
||||
const version = getArg('version');
|
||||
|
||||
if (!version) {
|
||||
console.error('Error: --version is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (hasFlag('all')) {
|
||||
const results = generator.generateAll({
|
||||
version,
|
||||
platform: getArg('platform'),
|
||||
includeDockerHub: hasFlag('include-docker'),
|
||||
});
|
||||
if (!generator.githubOutput) {
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
}
|
||||
} else {
|
||||
const image = getArg('image');
|
||||
if (!image) {
|
||||
console.error('Error: Either --image or --all is required');
|
||||
process.exit(1);
|
||||
}
|
||||
const tags = generator.generate({
|
||||
image,
|
||||
version,
|
||||
platform: getArg('platform'),
|
||||
includeDockerHub: hasFlag('include-docker'),
|
||||
});
|
||||
generator.output(tags);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export default TagGenerator;
|
||||
63
.github/scripts/docker/get-manifest-digests.mjs
vendored
Normal file
63
.github/scripts/docker/get-manifest-digests.mjs
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extracts manifest digests and image names for SLSA provenance and VEX attestation.
|
||||
*
|
||||
* Usage:
|
||||
* N8N_TAG=ghcr.io/n8n-io/n8n:1.0.0 node get-manifest-digests.mjs
|
||||
*
|
||||
* Environment variables:
|
||||
* N8N_TAG - Full image reference for n8n image
|
||||
* RUNNERS_TAG - Full image reference for runners image
|
||||
* DISTROLESS_TAG - Full image reference for runners-distroless image
|
||||
* GITHUB_OUTPUT - Path to GitHub Actions output file (optional)
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
|
||||
const githubOutput = process.env.GITHUB_OUTPUT || null;
|
||||
|
||||
function getDigest(imageRef) {
|
||||
if (!imageRef) return '';
|
||||
const raw = execSync(`docker buildx imagetools inspect "${imageRef}" --raw`, {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const hash = execSync('sha256sum', { input: raw, encoding: 'utf8' }).split(' ')[0].trim();
|
||||
return `sha256:${hash}`;
|
||||
}
|
||||
|
||||
function getImageName(imageRef) {
|
||||
if (!imageRef) return '';
|
||||
return imageRef.replace(/:([^:]+)$/, '');
|
||||
}
|
||||
|
||||
function setOutput(name, value) {
|
||||
if (githubOutput && value) appendFileSync(githubOutput, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
const n8nTag = process.env.N8N_TAG || '';
|
||||
const runnersTag = process.env.RUNNERS_TAG || '';
|
||||
const distrolessTag = process.env.DISTROLESS_TAG || '';
|
||||
|
||||
const results = {
|
||||
n8n: { digest: getDigest(n8nTag), image: getImageName(n8nTag) },
|
||||
runners: { digest: getDigest(runnersTag), image: getImageName(runnersTag) },
|
||||
runners_distroless: { digest: getDigest(distrolessTag), image: getImageName(distrolessTag) },
|
||||
};
|
||||
|
||||
setOutput('n8n_digest', results.n8n.digest);
|
||||
setOutput('n8n_image', results.n8n.image);
|
||||
setOutput('runners_digest', results.runners.digest);
|
||||
setOutput('runners_image', results.runners.image);
|
||||
setOutput('runners_distroless_digest', results.runners_distroless.digest);
|
||||
setOutput('runners_distroless_image', results.runners_distroless.image);
|
||||
|
||||
console.log('=== Manifest Digests ===');
|
||||
console.log(`n8n: ${results.n8n.digest || 'N/A'}`);
|
||||
console.log(`runners: ${results.runners.digest || 'N/A'}`);
|
||||
console.log(`runners-distroless: ${results.runners_distroless.digest || 'N/A'}`);
|
||||
console.log('');
|
||||
console.log('=== Image Names ===');
|
||||
console.log(`n8n: ${results.n8n.image || 'N/A'}`);
|
||||
console.log(`runners: ${results.runners.image || 'N/A'}`);
|
||||
console.log(`runners-distroless: ${results.runners_distroless.image || 'N/A'}`);
|
||||
44
.github/scripts/ensure-provenance-fields.mjs
vendored
Normal file
44
.github/scripts/ensure-provenance-fields.mjs
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
import { writeFile, readFile, copyFile } from 'fs/promises';
|
||||
import { resolve, dirname } from 'path';
|
||||
import child_process from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const exec = promisify(child_process.exec);
|
||||
|
||||
const commonFiles = ['LICENSE.md', 'LICENSE_EE.md'];
|
||||
|
||||
const baseDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const packages = JSON.parse((await exec('pnpm ls -r --only-projects --json')).stdout);
|
||||
|
||||
for (let { name, path, version, private: isPrivate } of packages) {
|
||||
if (isPrivate) continue;
|
||||
|
||||
const packageFile = resolve(path, 'package.json');
|
||||
const packageJson = {
|
||||
...JSON.parse(await readFile(packageFile, 'utf-8')),
|
||||
// Add these fields to all published package.json files to ensure provenance checks pass
|
||||
license: 'SEE LICENSE IN LICENSE.md',
|
||||
homepage: 'https://n8n.io',
|
||||
author: {
|
||||
name: 'Jan Oberhauser',
|
||||
email: 'jan@n8n.io',
|
||||
},
|
||||
repository: {
|
||||
type: 'git',
|
||||
url: 'git+https://github.com/n8n-io/n8n.git',
|
||||
},
|
||||
};
|
||||
|
||||
// Copy over LICENSE.md and LICENSE_EE.md into every published package, and ensure they get included in the published package
|
||||
await Promise.all(
|
||||
commonFiles.map(async (file) => {
|
||||
await copyFile(resolve(baseDir, file), resolve(path, file));
|
||||
if (packageJson.files && !packageJson.files.includes(file)) {
|
||||
packageJson.files.push(file);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await writeFile(packageFile, JSON.stringify(packageJson, null, 2) + '\n');
|
||||
}
|
||||
156
.github/scripts/ensure-release-candidate-branches.mjs
vendored
Normal file
156
.github/scripts/ensure-release-candidate-branches.mjs
vendored
Normal file
@@ -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();
|
||||
}
|
||||
62
.github/scripts/ensure-release-candidate-branches.test.mjs
vendored
Normal file
62
.github/scripts/ensure-release-candidate-branches.test.mjs
vendored
Normal file
@@ -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: 'n8n@2.9.2' };
|
||||
if (track === 'beta') return { version: '2.10.1', tag: 'n8n@2.10.1' };
|
||||
return { version: '1.123.33', tag: 'n8n@1.123.33' };
|
||||
},
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
63
.github/scripts/get-release-versions.mjs
vendored
Normal file
63
.github/scripts/get-release-versions.mjs
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
import semver from 'semver';
|
||||
import {
|
||||
getCommitForRef,
|
||||
listTagsPointingAt,
|
||||
RELEASE_PREFIX,
|
||||
RELEASE_TRACKS,
|
||||
stripReleasePrefixes,
|
||||
writeGithubOutput,
|
||||
} from './github-helpers.mjs';
|
||||
|
||||
/**
|
||||
* Given a list of tag names, return the highest semver tag (keeping the original 'v' prefix),
|
||||
* or "" if none match semver.
|
||||
*
|
||||
* @param {string[]} tags
|
||||
**/
|
||||
function highestSemverTag(tags) {
|
||||
const candidates = tags
|
||||
.filter((t) => t.startsWith(RELEASE_PREFIX))
|
||||
.map((t) => ({
|
||||
tag: t,
|
||||
version: stripReleasePrefixes(t),
|
||||
}))
|
||||
.filter(({ version }) => semver.valid(version));
|
||||
|
||||
if (candidates.length === 0) return '';
|
||||
|
||||
candidates.sort((a, b) => semver.rcompare(a.version, b.version));
|
||||
return candidates[0]?.tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} track
|
||||
**/
|
||||
function getSemverTagForTrack(track) {
|
||||
const commit = getCommitForRef(track);
|
||||
if (!commit) return '';
|
||||
|
||||
const tags = listTagsPointingAt(commit);
|
||||
return highestSemverTag(tags);
|
||||
}
|
||||
|
||||
function main() {
|
||||
/** @type { Record<string, string> } */
|
||||
const outputs = {};
|
||||
for (const track of RELEASE_TRACKS) {
|
||||
outputs[track] = getSemverTagForTrack(track);
|
||||
}
|
||||
|
||||
writeGithubOutput(outputs);
|
||||
|
||||
console.log('Current release versions: ');
|
||||
for (const [k, v] of Object.entries(outputs)) {
|
||||
console.log(`${k}: ${v || '(not found)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
console.error(String(err?.message ?? err));
|
||||
process.exit(1);
|
||||
}
|
||||
249
.github/scripts/github-helpers.mjs
vendored
Normal file
249
.github/scripts/github-helpers.mjs
vendored
Normal file
@@ -0,0 +1,249 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
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 "n8n@2.7.0".
|
||||
* Returns the *tag string* (e.g. "n8n@2.7.0") 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
|
||||
* n8n@x.y.z 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 }
|
||||
* */
|
||||
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 { typeof RELEASE_TRACKS[number] } track
|
||||
* */
|
||||
export function resolveRcBranchForTrack(track) {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} [pullRequest] Optional pull request object. If not provided, reads from GITHUB_EVENT_PATH
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function readPrLabels(pullRequest) {
|
||||
if (!pullRequest) {
|
||||
const eventPath = ensureEnvVar('GITHUB_EVENT_PATH');
|
||||
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
|
||||
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>} 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} 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;
|
||||
}
|
||||
10
.github/scripts/jsconfig.json
vendored
Normal file
10
.github/scripts/jsconfig.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"checkJs": true,
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
18
.github/scripts/move-track-tag.mjs
vendored
Normal file
18
.github/scripts/move-track-tag.mjs
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ensureEnvVar, ensureReleaseTrack, ensureTagExists, sh } from './github-helpers.mjs';
|
||||
|
||||
function main() {
|
||||
const trackEnv = ensureEnvVar('TRACK');
|
||||
const track = ensureReleaseTrack(trackEnv);
|
||||
|
||||
const versionInput = ensureEnvVar('VERSION_TAG'); // e.g. n8n@2.7.0
|
||||
|
||||
ensureTagExists(versionInput);
|
||||
|
||||
sh('git', ['tag', '-f', track, versionInput]);
|
||||
|
||||
sh('git', ['push', 'origin', '-f', `refs/tags/${track}:refs/tags/${track}`]);
|
||||
|
||||
console.log(`Moved pointer tag ${track} to point to ${versionInput}`);
|
||||
}
|
||||
|
||||
main();
|
||||
13
.github/scripts/package.json
vendored
Normal file
13
.github/scripts/package.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
62
.github/scripts/plan-release.mjs
vendored
Normal file
62
.github/scripts/plan-release.mjs
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
import semver from 'semver';
|
||||
import {
|
||||
ensureEnvVar,
|
||||
isReleaseType,
|
||||
RELEASE_PREFIX,
|
||||
stripReleasePrefixes,
|
||||
writeGithubOutput,
|
||||
} from './github-helpers.mjs';
|
||||
|
||||
const track = ensureEnvVar('TRACK');
|
||||
const bump = ensureEnvVar('BUMP');
|
||||
|
||||
const stable = process.env['STABLE_VERSION'];
|
||||
const beta = process.env['BETA_VERSION'];
|
||||
const v1 = process.env['V1_VERSION'];
|
||||
|
||||
let base = null;
|
||||
switch (track) {
|
||||
case 'stable':
|
||||
base = stable;
|
||||
break;
|
||||
case 'beta':
|
||||
base = beta;
|
||||
break;
|
||||
case 'v1':
|
||||
base = v1;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!base) {
|
||||
console.error(
|
||||
`Unknown track or missing base version. track=${track} stable=${stable} beta=${beta} v1=${v1}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cleanedBase = stripReleasePrefixes(base);
|
||||
if (!cleanedBase) {
|
||||
console.error(`Invalid base version: ${base}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!isReleaseType(bump)) {
|
||||
console.error(`Invalid release type in $bump: ${bump}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const next = semver.inc(cleanedBase, bump);
|
||||
if (!next) {
|
||||
console.error(`Could not bump version. base=${cleanedBase} bump=${bump}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const output = {
|
||||
base_version: cleanedBase,
|
||||
new_version: next,
|
||||
new_version_tag: `${RELEASE_PREFIX}${next}`,
|
||||
};
|
||||
|
||||
writeGithubOutput(output);
|
||||
|
||||
console.log(`Releasing track=${track} bump=${bump} base=${cleanedBase} -> new=${next}`);
|
||||
104
.github/scripts/send-build-stats.mjs
vendored
Normal file
104
.github/scripts/send-build-stats.mjs
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sends Turbo build summary JSON to a webhook
|
||||
*
|
||||
* 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)
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const runsDir = '.turbo/runs';
|
||||
if (!existsSync(runsDir)) {
|
||||
console.log('No .turbo/runs directory found (turbo --summarize not used), skipping.');
|
||||
process.exit(0);
|
||||
}
|
||||
const files = readdirSync(runsDir).filter((f) => f.endsWith('.json'));
|
||||
const summaryPath = files.length > 0 ? join(runsDir, files[0]) : null;
|
||||
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (!webhookUser || !webhookPassword) {
|
||||
console.error('BUILD_STATS_WEBHOOK_USER and BUILD_STATS_WEBHOOK_PASSWORD are required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const basicAuth = Buffer.from(`${webhookUser}:${webhookPassword}`).toString('base64');
|
||||
|
||||
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}`);
|
||||
104
.github/scripts/send-docker-stats.mjs
vendored
Normal file
104
.github/scripts/send-docker-stats.mjs
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sends Docker build stats to a webhook for BigQuery ingestion.
|
||||
*
|
||||
* Reads manifests produced by build-n8n.mjs and dockerize-n8n.mjs,
|
||||
* enriches with git/CI/runner context, and POSTs to a webhook.
|
||||
*
|
||||
* Usage: node send-docker-stats.mjs
|
||||
*
|
||||
* Environment variables:
|
||||
* DOCKER_STATS_WEBHOOK_URL - Webhook URL (required to send)
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
|
||||
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 webhookUrl = process.env.DOCKER_STATS_WEBHOOK_URL;
|
||||
|
||||
if (!webhookUrl) {
|
||||
console.log('DOCKER_STATS_WEBHOOK_URL not set, 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;
|
||||
|
||||
// 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';
|
||||
}
|
||||
|
||||
const payload = {
|
||||
build: buildManifest
|
||||
? {
|
||||
artifactSize: buildManifest.artifactSize,
|
||||
buildDuration: buildManifest.buildDuration,
|
||||
}
|
||||
: null,
|
||||
|
||||
docker: dockerManifest
|
||||
? {
|
||||
platform: dockerManifest.platform,
|
||||
images: dockerManifest.images,
|
||||
}
|
||||
: null,
|
||||
|
||||
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: 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,
|
||||
},
|
||||
|
||||
runner: {
|
||||
provider: getRunnerProvider(),
|
||||
cpuCores: os.cpus().length,
|
||||
memoryGb: Math.round((os.totalmem() / (1024 * 1024 * 1024)) * 10) / 10,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
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(`Docker build stats sent: ${response.status}`);
|
||||
18
.github/scripts/trim-fe-packageJson.js
vendored
Normal file
18
.github/scripts/trim-fe-packageJson.js
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
const { writeFileSync } = require('fs');
|
||||
const { resolve } = require('path');
|
||||
const baseDir = resolve(__dirname, '../..');
|
||||
|
||||
const trimPackageJson = (packageName) => {
|
||||
const filePath = resolve(baseDir, 'packages', packageName, 'package.json');
|
||||
const { scripts, peerDependencies, devDependencies, dependencies, ...packageJson } = require(
|
||||
filePath,
|
||||
);
|
||||
if (packageName === 'frontend/@n8n/chat') {
|
||||
packageJson.dependencies = dependencies;
|
||||
}
|
||||
writeFileSync(filePath, JSON.stringify(packageJson, null, 2) + '\n', 'utf-8');
|
||||
};
|
||||
|
||||
trimPackageJson('frontend/@n8n/chat');
|
||||
trimPackageJson('frontend/@n8n/design-system');
|
||||
trimPackageJson('frontend/editor-ui');
|
||||
40
.github/scripts/update-changelog.mjs
vendored
Normal file
40
.github/scripts/update-changelog.mjs
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
import createTempFile from 'tempfile';
|
||||
import conventionalChangelog from 'conventional-changelog';
|
||||
import { resolve } from 'path';
|
||||
import { createReadStream, createWriteStream } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import packageJson from '../../package.json' with { type: 'json' };
|
||||
|
||||
const baseDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
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';
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// 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));
|
||||
|
||||
// Since we can't read and write from the same file at the same time,
|
||||
// we use a temporary file to output the updated changelog to.
|
||||
const tmpFile = createTempFile();
|
||||
const tmpStream = createWriteStream(tmpFile);
|
||||
await pipeline(createReadStream(versionChangelogFile), tmpStream, { end: false });
|
||||
await pipeline(createReadStream(fullChangelogFile), tmpStream);
|
||||
await pipeline(createReadStream(tmpFile), createWriteStream(fullChangelogFile));
|
||||
90
.github/scripts/validate-docs-links.js
vendored
Normal file
90
.github/scripts/validate-docs-links.js
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const packages = ['nodes-base', '@n8n/nodes-langchain'];
|
||||
const concurrency = 20;
|
||||
let exitCode = 0;
|
||||
|
||||
const debug = require('debug')('n8n');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const glob = require('glob');
|
||||
const pLimit = require('p-limit');
|
||||
const picocolors = require('picocolors');
|
||||
const Lookup = require('cacheable-lookup').default;
|
||||
|
||||
const agent = new https.Agent({ keepAlive: true, keepAliveMsecs: 5000 });
|
||||
new Lookup().install(agent);
|
||||
const limiter = pLimit(concurrency);
|
||||
|
||||
const validateUrl = async (packageName, kind, type) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const name = type.displayName;
|
||||
const documentationUrl =
|
||||
kind === 'credentials'
|
||||
? type.documentationUrl
|
||||
: type.codex?.resources?.primaryDocumentation?.[0]?.url;
|
||||
if (!documentationUrl) resolve([name, null]);
|
||||
|
||||
const url = new URL(
|
||||
/^https?:\/\//.test(documentationUrl)
|
||||
? documentationUrl
|
||||
: `https://docs.n8n.io/integrations/builtin/${kind}/${documentationUrl.toLowerCase()}/`,
|
||||
);
|
||||
https
|
||||
.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port: 443,
|
||||
path: url.pathname,
|
||||
method: 'HEAD',
|
||||
agent,
|
||||
},
|
||||
(res) => {
|
||||
debug(picocolors.green('✓'), packageName, kind, name);
|
||||
resolve([name, res.statusCode]);
|
||||
},
|
||||
)
|
||||
.on('error', (e) => {
|
||||
debug(picocolors.red('✘'), packageName, kind, name);
|
||||
reject(e);
|
||||
})
|
||||
.end();
|
||||
});
|
||||
|
||||
const checkLinks = async (packageName, kind) => {
|
||||
const baseDir = path.resolve(__dirname, '../../packages', packageName);
|
||||
let types = require(path.join(baseDir, `dist/types/${kind}.json`));
|
||||
if (kind === 'nodes')
|
||||
types = types.filter(
|
||||
({ codex, hidden }) => !!codex?.resources?.primaryDocumentation && !hidden,
|
||||
);
|
||||
debug(packageName, kind, types.length);
|
||||
|
||||
const statuses = await Promise.all(
|
||||
types.map((type) =>
|
||||
limiter(() => {
|
||||
return validateUrl(packageName, kind, type);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const missingDocs = [];
|
||||
const invalidUrls = [];
|
||||
for (const [name, statusCode] of statuses) {
|
||||
if (statusCode === null) missingDocs.push(name);
|
||||
if (statusCode !== 200) invalidUrls.push(name);
|
||||
}
|
||||
|
||||
if (missingDocs.length)
|
||||
console.log('Documentation URL missing in %s for %s', packageName, kind, missingDocs);
|
||||
if (invalidUrls.length)
|
||||
console.log('Documentation URL invalid in %s for %s', packageName, kind, invalidUrls);
|
||||
if (missingDocs.length || invalidUrls.length) exitCode = 1;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
for (const packageName of packages) {
|
||||
await Promise.all([checkLinks(packageName, 'credentials'), checkLinks(packageName, 'nodes')]);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user