mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-19 09:51:59 +08:00
ci: Set up v3 branch sync and nightly build workflows (no-changelog) (#33678)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ class TagGenerator {
|
||||
this.githubOutput = process.env.GITHUB_OUTPUT || null;
|
||||
}
|
||||
|
||||
generate({ image, version, platform, includeDockerHub = false, sha = '' }) {
|
||||
generate({ image, version, platform, includeDockerHub = false, sha = '', date = '' }) {
|
||||
let imageName = image;
|
||||
let versionSuffix = '';
|
||||
|
||||
@@ -42,6 +42,20 @@ class TagGenerator {
|
||||
tags.shaPrimaryTag = shaGhcr[0].replace(/-amd64$|-arm64$/, '');
|
||||
}
|
||||
|
||||
// Generate additional date-based tags (e.g. v3-nightly-20260625) for nightly builds
|
||||
if (date) {
|
||||
const dateVersion = `${version}-${date}`;
|
||||
const datePlatformTag = `${dateVersion}${versionSuffix}${platformSuffix}`;
|
||||
const dateGhcr = [`ghcr.io/${this.githubOwner}/${imageName}:${datePlatformTag}`];
|
||||
const dateDocker = includeDockerHub
|
||||
? [`${this.dockerUsername}/${imageName}:${datePlatformTag}`]
|
||||
: [];
|
||||
tags.all = [...tags.all, ...dateGhcr, ...dateDocker];
|
||||
tags.ghcr = [...tags.ghcr, ...dateGhcr];
|
||||
tags.docker = [...tags.docker, ...dateDocker];
|
||||
tags.datePrimaryTag = dateGhcr[0].replace(/-amd64$|-arm64$/, '');
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
@@ -58,18 +72,21 @@ class TagGenerator {
|
||||
if (tags.shaPrimaryTag) {
|
||||
outputs.push(`${prefixStr}sha_primary_tag=${tags.shaPrimaryTag}`);
|
||||
}
|
||||
if (tags.datePrimaryTag) {
|
||||
outputs.push(`${prefixStr}date_primary_tag=${tags.datePrimaryTag}`);
|
||||
}
|
||||
appendFileSync(this.githubOutput, outputs.join('\n') + '\n');
|
||||
} else {
|
||||
console.log(JSON.stringify(tags, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
generateAll({ version, platform, includeDockerHub = false, sha = '' }) {
|
||||
generateAll({ version, platform, includeDockerHub = false, sha = '', date = '' }) {
|
||||
const images = ['n8n', 'runners', 'runners-distroless'];
|
||||
const results = {};
|
||||
|
||||
for (const image of images) {
|
||||
const tags = this.generate({ image, version, platform, includeDockerHub, sha });
|
||||
const tags = this.generate({ image, version, platform, includeDockerHub, sha, date });
|
||||
const prefix = image.replace('-distroless', '_distroless');
|
||||
results[prefix] = tags;
|
||||
|
||||
@@ -105,6 +122,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
platform: getArg('platform'),
|
||||
includeDockerHub: hasFlag('include-docker'),
|
||||
sha: getArg('sha') || '',
|
||||
date: getArg('date') || '',
|
||||
});
|
||||
if (!generator.githubOutput) {
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
@@ -121,6 +139,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
platform: getArg('platform'),
|
||||
includeDockerHub: hasFlag('include-docker'),
|
||||
sha: getArg('sha') || '',
|
||||
date: getArg('date') || '',
|
||||
});
|
||||
generator.output(tags);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Attributes a master→3.x sync conflict to the authors of the breaking commits.
|
||||
*
|
||||
* Since 3.x = master + breaking commits, the commits that diverged 3.x are exactly
|
||||
* `<base>..HEAD` (where <base> is the fetched master SHA). Scoped to the conflicted
|
||||
* files, those are the commits responsible for the conflict; their GitHub authors are
|
||||
* who to nudge. Must run while the merge is unresolved — HEAD still at the pre-merge
|
||||
* 3.x tip and unmerged paths present in the index.
|
||||
*
|
||||
* git log gives only the author name/email, and ~2/3 of n8n authors commit with a
|
||||
* non-noreply email that carries no GitHub username. So the conflicted files → breaking
|
||||
* SHAs analysis is done locally, and a SINGLE GraphQL call maps those few SHAs to GitHub
|
||||
* logins. Bot- and unlinked-account commits resolve to a null user and are skipped.
|
||||
*
|
||||
* Emits a JSON object to stdout: { ownersCsv, slack, body }.
|
||||
*
|
||||
* Usage:
|
||||
* node .github/scripts/sync-conflict-owners.mjs --base <masterSha> --sync-branch <name>
|
||||
*
|
||||
* Env: GITHUB_REPOSITORY (owner/repo), GH_TOKEN or GITHUB_TOKEN (for the GraphQL API).
|
||||
* Requires Node 18+ (global fetch).
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
export function runGit(args) {
|
||||
return execFileSync('git', args, { encoding: 'utf8' }).trim();
|
||||
}
|
||||
|
||||
// Files with unresolved conflicts in the current (in-progress) merge.
|
||||
export function conflictedFiles(git = runGit) {
|
||||
const out = git(['diff', '--name-only', '--diff-filter=U']);
|
||||
return out ? out.split('\n').filter(Boolean) : [];
|
||||
}
|
||||
|
||||
// Unique SHAs of the 3.x-only (breaking) commits that touched the given files.
|
||||
export function breakingShas(base, files, git = runGit) {
|
||||
const shas = new Set();
|
||||
for (const file of files) {
|
||||
const out = git(['log', `${base}..HEAD`, '--format=%H', '--', file]);
|
||||
for (const sha of out.split('\n').filter(Boolean)) shas.add(sha);
|
||||
}
|
||||
return [...shas];
|
||||
}
|
||||
|
||||
// Resolve commit SHAs to GitHub logins in one GraphQL call. Commits whose author has
|
||||
// no linked account (unverified email, bots) resolve to a null user and are dropped.
|
||||
export async function resolveLogins(repo, shas, token, fetchFn = fetch) {
|
||||
if (shas.length === 0) return [];
|
||||
const [owner, name] = repo.split('/');
|
||||
const aliases = shas
|
||||
.map((sha, i) => `c${i}: object(oid: "${sha}") { ... on Commit { author { user { login } } } }`)
|
||||
.join('\n');
|
||||
const query = `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${aliases} } }`;
|
||||
|
||||
const res = await fetchFn('https://api.github.com/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'n8n-sync-conflict-owners',
|
||||
},
|
||||
body: JSON.stringify({ query, variables: { owner, name } }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`GitHub GraphQL request failed: ${res.status}`);
|
||||
const json = await res.json();
|
||||
if (json.errors) throw new Error(`GitHub GraphQL error: ${JSON.stringify(json.errors)}`);
|
||||
|
||||
const repository = json.data?.repository ?? {};
|
||||
const logins = new Set();
|
||||
for (const node of Object.values(repository)) {
|
||||
const login = node?.author?.user?.login;
|
||||
if (login) logins.add(login);
|
||||
}
|
||||
return [...logins].sort();
|
||||
}
|
||||
|
||||
// Build the conflict-PR body, reviewer CSV, and Slack owner line.
|
||||
export function buildOutputs({ syncBranch, files, owners }) {
|
||||
const filesMd = files.map((f) => `- \`${f}\``).join('\n') || '_none detected_';
|
||||
const ownersMd = owners.length
|
||||
? owners.map((o) => `- @${o}`).join('\n')
|
||||
: '_Could not auto-attribute — review the conflicted files manually._';
|
||||
const slack = owners.length
|
||||
? `Likely owners (GitHub): ${owners.map((o) => `@${o}`).join(' ')}`
|
||||
: 'Could not auto-attribute owners.';
|
||||
const body = [
|
||||
`Automated \`master\`→\`3.x\` sync hit a merge conflict. Resolve the conflict markers on \`${syncBranch}\`, then merge this PR. **Daily syncs are paused until it is merged.**`,
|
||||
'',
|
||||
'### Conflicted files',
|
||||
filesMd,
|
||||
'',
|
||||
'### Likely owners',
|
||||
'Authors of the 3.x commits touching the conflicted files, requested as reviewers:',
|
||||
ownersMd,
|
||||
].join('\n');
|
||||
return { ownersCsv: owners.join(','), slack, body };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
base: { type: 'string' },
|
||||
'sync-branch': { type: 'string', default: 'sync/master-to-3x' },
|
||||
},
|
||||
});
|
||||
|
||||
const base = values.base;
|
||||
const syncBranch = values['sync-branch'];
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
||||
|
||||
if (!base) throw new Error('--base <masterSha> is required');
|
||||
if (!repo) throw new Error('GITHUB_REPOSITORY env var is required');
|
||||
if (!token) throw new Error('GH_TOKEN / GITHUB_TOKEN env var is required');
|
||||
|
||||
const files = conflictedFiles();
|
||||
const shas = breakingShas(base, files);
|
||||
|
||||
// Degrade gracefully: a transient API failure should still open the PR
|
||||
// (unattributed) rather than fail the whole sync.
|
||||
let owners = [];
|
||||
try {
|
||||
owners = await resolveLogins(repo, shas, token);
|
||||
} catch (error) {
|
||||
console.error(`warning: could not resolve owners: ${error.message}`);
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(buildOutputs({ syncBranch, files, owners })));
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { breakingShas, resolveLogins, buildOutputs } from './sync-conflict-owners.mjs';
|
||||
|
||||
test('breakingShas collects unique SHAs across the conflicted files only', () => {
|
||||
const calls = [];
|
||||
const git = (args) => {
|
||||
calls.push(args);
|
||||
// args: ['log', 'BASE..HEAD', '--format=%H', '--', <file>]
|
||||
const file = args.at(-1);
|
||||
if (file === 'a.ts') return 'sha1\nsha2\n';
|
||||
if (file === 'b.ts') return 'sha2\nsha3'; // sha2 shared -> deduped
|
||||
return '';
|
||||
};
|
||||
const shas = breakingShas('BASE', ['a.ts', 'b.ts'], git);
|
||||
assert.deepEqual(shas, ['sha1', 'sha2', 'sha3']);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(calls[0], ['log', 'BASE..HEAD', '--format=%H', '--', 'a.ts']);
|
||||
});
|
||||
|
||||
test('resolveLogins maps SHAs to logins in one call, dropping unlinked/bot authors', async () => {
|
||||
let calls = 0;
|
||||
const fetchFn = async (url, opts) => {
|
||||
calls++;
|
||||
assert.equal(url, 'https://api.github.com/graphql');
|
||||
const query = JSON.parse(opts.body).query;
|
||||
assert.match(query, /c0: object\(oid: "sha1"\)/);
|
||||
assert.match(query, /c2: object\(oid: "sha3"\)/);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
repository: {
|
||||
c0: { author: { user: { login: 'bob' } } },
|
||||
c1: { author: { user: { login: 'alice' } } },
|
||||
c2: { author: { user: null } }, // unlinked / bot -> dropped
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
const owners = await resolveLogins('n8n-io/n8n', ['sha1', 'sha2', 'sha3'], 't', fetchFn);
|
||||
assert.equal(calls, 1); // single batched request
|
||||
assert.deepEqual(owners, ['alice', 'bob']); // sorted, deduped, null dropped
|
||||
});
|
||||
|
||||
test('resolveLogins makes no request when there are no SHAs', async () => {
|
||||
let calls = 0;
|
||||
const fetchFn = async () => {
|
||||
calls++;
|
||||
return { ok: true, json: async () => ({ data: { repository: {} } }) };
|
||||
};
|
||||
assert.deepEqual(await resolveLogins('r', [], 't', fetchFn), []);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('resolveLogins throws on API/GraphQL errors (caller degrades gracefully)', async () => {
|
||||
const httpError = async () => ({ ok: false, status: 502, json: async () => ({}) });
|
||||
const gqlError = async () => ({ ok: true, json: async () => ({ errors: [{ message: 'bad' }] }) });
|
||||
await assert.rejects(resolveLogins('r', ['s'], 't', httpError), /502/);
|
||||
await assert.rejects(resolveLogins('r', ['s'], 't', gqlError), /GraphQL error/);
|
||||
});
|
||||
|
||||
test('buildOutputs formats reviewers, slack line, and PR body with owners', () => {
|
||||
const out = buildOutputs({ syncBranch: 'sync/master-to-3x', files: ['packages/cli/x.ts'], owners: ['alice', 'bob'] });
|
||||
assert.equal(out.ownersCsv, 'alice,bob');
|
||||
assert.equal(out.slack, 'Likely owners (GitHub): @alice @bob');
|
||||
assert.match(out.body, /### Conflicted files/);
|
||||
assert.match(out.body, /- `packages\/cli\/x\.ts`/);
|
||||
assert.match(out.body, /- @alice/);
|
||||
assert.match(out.body, /- @bob/);
|
||||
assert.match(out.body, /Daily syncs are paused until it is merged/);
|
||||
});
|
||||
|
||||
test('buildOutputs degrades gracefully when nothing could be attributed', () => {
|
||||
const out = buildOutputs({ syncBranch: 'sync/master-to-3x', files: ['x.ts'], owners: [] });
|
||||
assert.equal(out.ownersCsv, '');
|
||||
assert.equal(out.slack, 'Could not auto-attribute owners.');
|
||||
assert.match(out.body, /Could not auto-attribute/);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Syncs the master branch into the long-lived 3.x branch.
|
||||
*
|
||||
* During the v3 development window, master carries normal feature work (behind
|
||||
* opt-in flags) and 3.x is "master + breaking-change commits". This script
|
||||
* fast-forwards 3.x when possible, falls back to a three-way merge when 3.x has
|
||||
* diverged, and — when a merge conflict occurs — opens a draft conflict PR,
|
||||
* attributing it to the authors of the breaking commits. No further syncs run
|
||||
* until that PR is resolved and merged (see the halt gate below).
|
||||
*
|
||||
* Runs from a checkout of the 3.x branch (fetch-depth 0). Assumes credentials
|
||||
* are NOT persisted by checkout — pushes go through an explicit token URL.
|
||||
*
|
||||
* On conflict, emits `conflict_pr` and `conflict_owners` to $GITHUB_OUTPUT so a
|
||||
* downstream job can post to Slack.
|
||||
*
|
||||
* Env: GH_TOKEN (installation token with contents/pull-requests/issues write),
|
||||
* GITHUB_REPOSITORY (owner/repo, auto-provided by Actions).
|
||||
* Requires Node 18+ (global fetch) and the `gh` CLI on PATH.
|
||||
*
|
||||
* See .github/DEVELOPING_V3.md for the full v3 development model.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
|
||||
import { conflictedFiles, breakingShas, resolveLogins, buildOutputs } from './sync-conflict-owners.mjs';
|
||||
|
||||
export const TARGET_BRANCH = '3.x';
|
||||
export const SYNC_BRANCH = 'sync/master-to-3x';
|
||||
export const CONFLICT_LABEL = 'automation:v3-sync';
|
||||
|
||||
const BOT_NAME = 'n8n-assistant[bot]';
|
||||
const BOT_EMAIL = 'n8n-assistant[bot]@users.noreply.github.com';
|
||||
|
||||
// Real command runners. Each takes an args array and returns trimmed stdout,
|
||||
// throwing on a non-zero exit (mirrors `set -e`). Injectable for tests.
|
||||
const runGit = (args, opts = {}) => execFileSync('git', args, { encoding: 'utf8', ...opts }).trim();
|
||||
const runGh = (args, opts = {}) => execFileSync('gh', args, { encoding: 'utf8', ...opts }).trim();
|
||||
|
||||
// True when a previous conflict PR is still open — the halt gate.
|
||||
export function hasOpenConflictPr(gh, label = CONFLICT_LABEL) {
|
||||
const out = gh(['pr', 'list', '--state', 'open', '--label', label, '--json', 'number']);
|
||||
return JSON.parse(out || '[]').length > 0;
|
||||
}
|
||||
|
||||
// Attempt the merge. `git merge` fast-forwards when 3.x has not diverged and
|
||||
// does a three-way merge otherwise; a non-zero exit means a conflict.
|
||||
export function tryMerge(git, masterSha, log = console.log) {
|
||||
try {
|
||||
git(['merge', '--no-edit', masterSha]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Surface the merge output (conflict summary) before falling back.
|
||||
if (error.stdout) log(String(error.stdout).trim());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Append key=value lines to $GITHUB_OUTPUT (no-op when running outside Actions).
|
||||
export function writeGithubOutput(obj, env = process.env) {
|
||||
const path = env.GITHUB_OUTPUT;
|
||||
if (!path) return;
|
||||
const lines = Object.entries(obj)
|
||||
.map(([k, v]) => `${k}=${v ?? ''}`)
|
||||
.join('\n');
|
||||
appendFileSync(path, lines + '\n', 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the conflicted state on the sync branch and open a draft PR, attributing
|
||||
* it to the authors of the 3.x breaking commits touching the conflicted files.
|
||||
* Runs while the merge is unresolved (HEAD still at the pre-merge 3.x tip,
|
||||
* unmerged paths present) — before committing below.
|
||||
*
|
||||
* @returns {Promise<{ prUrl: string, ownersSlack: string }>}
|
||||
*/
|
||||
export async function openConflictPr({ git, gh, repo, token, masterSha, pushUrl, fetchFn = fetch, log = console.log }) {
|
||||
const files = conflictedFiles(git);
|
||||
const shas = breakingShas(masterSha, files, git);
|
||||
|
||||
// Degrade gracefully: a transient API failure should still open the PR
|
||||
// (unattributed) rather than fail the whole sync.
|
||||
let owners = [];
|
||||
try {
|
||||
owners = await resolveLogins(repo, shas, token, fetchFn);
|
||||
} catch (error) {
|
||||
log(`warning: could not resolve owners: ${error.message}`);
|
||||
}
|
||||
|
||||
const { ownersCsv, slack, body } = buildOutputs({ syncBranch: SYNC_BRANCH, files, owners });
|
||||
|
||||
// Record the conflicted state (with markers) on the PR branch so it can be
|
||||
// resolved in review, mirroring the backport conflict convention.
|
||||
git(['add', '-A']);
|
||||
git(['commit', '--no-edit']);
|
||||
git(['push', '--force', pushUrl, `HEAD:refs/heads/${SYNC_BRANCH}`]);
|
||||
|
||||
// Ensure the label exists (idempotent), then open the draft conflict PR.
|
||||
gh(['label', 'create', CONFLICT_LABEL, '--color', 'B60205', '--description', 'master→3.x sync conflict', '--force']);
|
||||
const prUrl = gh([
|
||||
'pr', 'create', '--draft',
|
||||
'--base', TARGET_BRANCH,
|
||||
'--head', SYNC_BRANCH,
|
||||
'--label', CONFLICT_LABEL,
|
||||
'--title', 'chore: Resolve master→3.x sync conflict',
|
||||
'--body', body,
|
||||
]);
|
||||
|
||||
// Request owners as reviewers (best-effort: the API rejects the PR author
|
||||
// and non-collaborators, so a failure here must not fail the sync).
|
||||
if (ownersCsv) {
|
||||
try {
|
||||
gh(['pr', 'edit', prUrl, '--add-reviewer', ownersCsv]);
|
||||
} catch {
|
||||
log(`::warning::could not request some reviewers: ${ownersCsv}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { prUrl, ownersSlack: slack };
|
||||
}
|
||||
|
||||
export async function sync({
|
||||
git = runGit,
|
||||
gh = runGh,
|
||||
env = process.env,
|
||||
fetchFn = fetch,
|
||||
log = console.log,
|
||||
} = {}) {
|
||||
const token = env.GH_TOKEN || env.GITHUB_TOKEN;
|
||||
const repo = env.GITHUB_REPOSITORY;
|
||||
if (!token) throw new Error('GH_TOKEN / GITHUB_TOKEN env var is required');
|
||||
if (!repo) throw new Error('GITHUB_REPOSITORY env var is required');
|
||||
|
||||
// Authenticated push URL (credentials are not persisted by checkout).
|
||||
const pushUrl = `https://x-access-token:${token}@github.com/${repo}.git`;
|
||||
|
||||
// Halt gate: if a previous conflict PR is still open, do nothing until it is
|
||||
// resolved and merged.
|
||||
if (hasOpenConflictPr(gh)) {
|
||||
log(`An open '${CONFLICT_LABEL}' conflict PR exists; skipping sync until it is resolved and merged.`);
|
||||
return;
|
||||
}
|
||||
|
||||
git(['config', 'user.name', BOT_NAME]);
|
||||
git(['config', 'user.email', BOT_EMAIL]);
|
||||
|
||||
git(['fetch', 'origin', 'master']);
|
||||
// Pin to the fetched SHA — a command-line refspec doesn't reliably update the
|
||||
// origin/master tracking ref, so FETCH_HEAD is the unambiguous target.
|
||||
const masterSha = git(['rev-parse', 'FETCH_HEAD']);
|
||||
|
||||
if (tryMerge(git, masterSha, log)) {
|
||||
git(['push', pushUrl, `HEAD:${TARGET_BRANCH}`]);
|
||||
log('Synced master into 3.x.');
|
||||
return;
|
||||
}
|
||||
|
||||
log('Merge conflict encountered — attributing owners and opening a conflict PR.');
|
||||
const { prUrl, ownersSlack } = await openConflictPr({ git, gh, repo, token, masterSha, pushUrl, fetchFn, log });
|
||||
writeGithubOutput({ conflict_pr: prUrl, conflict_owners: ownersSlack }, env);
|
||||
}
|
||||
|
||||
// Only run when executed directly, not when imported by tests.
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
sync().catch((error) => {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
hasOpenConflictPr,
|
||||
tryMerge,
|
||||
openConflictPr,
|
||||
sync,
|
||||
CONFLICT_LABEL,
|
||||
SYNC_BRANCH,
|
||||
TARGET_BRANCH,
|
||||
} from './sync-master-to-3x.mjs';
|
||||
|
||||
// A git/gh stub: routes calls by a matcher, records every invocation.
|
||||
function makeStub(routes = []) {
|
||||
const calls = [];
|
||||
const fn = (args) => {
|
||||
calls.push(args);
|
||||
for (const [match, result] of routes) {
|
||||
if (match(args)) return typeof result === 'function' ? result(args) : result;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
const okFetch = (logins) => async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: { repository: Object.fromEntries(logins.map((l, i) => [`c${i}`, { author: { user: { login: l } } }])) } }),
|
||||
});
|
||||
|
||||
test('hasOpenConflictPr reflects the open-PR count from gh', () => {
|
||||
const empty = makeStub([[() => true, '[]']]);
|
||||
assert.equal(hasOpenConflictPr(empty), false);
|
||||
assert.deepEqual(empty.calls[0], ['pr', 'list', '--state', 'open', '--label', CONFLICT_LABEL, '--json', 'number']);
|
||||
|
||||
const one = makeStub([[() => true, JSON.stringify([{ number: 42 }])]]);
|
||||
assert.equal(hasOpenConflictPr(one), true);
|
||||
});
|
||||
|
||||
test('tryMerge returns true when the merge succeeds', () => {
|
||||
const git = makeStub([[(a) => a[0] === 'merge', '']]);
|
||||
assert.equal(tryMerge(git, 'MASTER', () => {}), true);
|
||||
assert.deepEqual(git.calls[0], ['merge', '--no-edit', 'MASTER']);
|
||||
});
|
||||
|
||||
test('tryMerge returns false and logs merge output on conflict', () => {
|
||||
const logged = [];
|
||||
const git = () => {
|
||||
const err = new Error('merge failed');
|
||||
err.stdout = 'CONFLICT (content): x.ts\n';
|
||||
throw err;
|
||||
};
|
||||
assert.equal(tryMerge(git, 'MASTER', (m) => logged.push(m)), false);
|
||||
assert.match(logged.join('\n'), /CONFLICT/);
|
||||
});
|
||||
|
||||
test('sync fast-forwards and pushes to 3.x on a clean merge', async () => {
|
||||
const git = makeStub([
|
||||
[(a) => a[0] === 'rev-parse', 'MASTERSHA'],
|
||||
[(a) => a[0] === 'merge', ''],
|
||||
]);
|
||||
const gh = makeStub([[(a) => a[0] === 'pr' && a[1] === 'list', '[]']]);
|
||||
const env = { GH_TOKEN: 'tok', GITHUB_REPOSITORY: 'n8n-io/n8n' };
|
||||
|
||||
await sync({ git, gh, env, log: () => {} });
|
||||
|
||||
const push = git.calls.find((a) => a[0] === 'push');
|
||||
assert.ok(push, 'expected a push');
|
||||
assert.equal(push[1], 'https://x-access-token:tok@github.com/n8n-io/n8n.git');
|
||||
assert.equal(push[2], `HEAD:${TARGET_BRANCH}`);
|
||||
// No PR created on a clean merge.
|
||||
assert.equal(gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'), false);
|
||||
});
|
||||
|
||||
test('sync halts (no fetch/merge) when a conflict PR is already open', async () => {
|
||||
const git = makeStub();
|
||||
const gh = makeStub([[(a) => a[0] === 'pr' && a[1] === 'list', JSON.stringify([{ number: 7 }])]]);
|
||||
|
||||
await sync({ git, gh, env: { GH_TOKEN: 't', GITHUB_REPOSITORY: 'n8n-io/n8n' }, log: () => {} });
|
||||
|
||||
assert.equal(git.calls.length, 0, 'must not touch git while halted');
|
||||
});
|
||||
|
||||
test('sync opens a conflict PR and writes outputs on merge conflict', async () => {
|
||||
const git = makeStub([
|
||||
[(a) => a[0] === 'rev-parse', 'MASTERSHA'],
|
||||
[(a) => a[0] === 'merge', () => { const e = new Error('conflict'); e.status = 1; throw e; }],
|
||||
[(a) => a[0] === 'diff', 'packages/cli/x.ts'],
|
||||
[(a) => a[0] === 'log', 'sha1'],
|
||||
]);
|
||||
const gh = makeStub([
|
||||
[(a) => a[0] === 'pr' && a[1] === 'list', '[]'],
|
||||
[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/99'],
|
||||
]);
|
||||
// No GITHUB_OUTPUT set → writeGithubOutput no-ops; assert on the gh/git calls instead.
|
||||
const env = { GH_TOKEN: 'tok', GITHUB_REPOSITORY: 'n8n-io/n8n' };
|
||||
|
||||
await sync({ git, gh, env, fetchFn: okFetch(['alice']), log: () => {} });
|
||||
|
||||
const create = gh.calls.find((a) => a[0] === 'pr' && a[1] === 'create');
|
||||
assert.ok(create, 'expected a PR to be created');
|
||||
assert.ok(create.includes('--draft'));
|
||||
assert.equal(create[create.indexOf('--base') + 1], TARGET_BRANCH);
|
||||
assert.equal(create[create.indexOf('--head') + 1], SYNC_BRANCH);
|
||||
|
||||
// Owner requested as reviewer.
|
||||
const edit = gh.calls.find((a) => a[0] === 'pr' && a[1] === 'edit');
|
||||
assert.ok(edit, 'expected reviewers to be requested');
|
||||
assert.equal(edit[edit.indexOf('--add-reviewer') + 1], 'alice');
|
||||
|
||||
// Conflicted state force-pushed to the sync branch.
|
||||
const push = git.calls.find((a) => a[0] === 'push' && a.includes('--force'));
|
||||
assert.ok(push, 'expected a force push to the sync branch');
|
||||
assert.equal(push.at(-1), `HEAD:refs/heads/${SYNC_BRANCH}`);
|
||||
});
|
||||
|
||||
test('openConflictPr degrades gracefully when owner resolution fails', async () => {
|
||||
const git = makeStub([
|
||||
[(a) => a[0] === 'diff', 'x.ts'],
|
||||
[(a) => a[0] === 'log', 'sha1'],
|
||||
]);
|
||||
const gh = makeStub([[(a) => a[0] === 'pr' && a[1] === 'create', 'https://github.com/n8n-io/n8n/pull/1']]);
|
||||
const failingFetch = async () => ({ ok: false, status: 500, json: async () => ({}) });
|
||||
|
||||
const { prUrl, ownersSlack } = await openConflictPr({
|
||||
git,
|
||||
gh,
|
||||
repo: 'n8n-io/n8n',
|
||||
token: 't',
|
||||
masterSha: 'MASTER',
|
||||
pushUrl: 'https://push',
|
||||
fetchFn: failingFetch,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
assert.equal(prUrl, 'https://github.com/n8n-io/n8n/pull/1');
|
||||
assert.equal(ownersSlack, 'Could not auto-attribute owners.');
|
||||
// No reviewer request when there are no owners.
|
||||
assert.equal(gh.calls.some((a) => a[0] === 'pr' && a[1] === 'edit'), false);
|
||||
});
|
||||
Reference in New Issue
Block a user