mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 21:49:36 +02:00
432 lines
15 KiB
JavaScript
432 lines
15 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
|
|
import { annotation, syncBundleBranch } from './sync-bundle-branch.mjs';
|
|
|
|
// A git stub: routes calls by a matcher, records every invocation. Route results also receive
|
|
// the calls so far, which is how FETCH_HEAD is modelled — see `fetchHead`.
|
|
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, calls) : result;
|
|
}
|
|
return '';
|
|
};
|
|
fn.calls = calls;
|
|
return fn;
|
|
}
|
|
|
|
const fail =
|
|
(stdout = '') =>
|
|
() => {
|
|
const err = new Error('command failed');
|
|
err.status = 1;
|
|
err.stdout = stdout;
|
|
throw err;
|
|
};
|
|
|
|
const PRE_HEAD = 'PREHEAD';
|
|
const BASE = 'BASESHA';
|
|
const PUBLIC = 'PUBLICSHA';
|
|
const MERGE_TREE = 'MERGETREEOID';
|
|
const REMOTE = 'https://x-access-token:tok@github.com/n8n-io/n8n-private.git';
|
|
// Spelled out rather than imported: a duplicated literal is what makes the "the private token
|
|
// never reaches the public repo" assertion below a real one.
|
|
const PUBLIC_REMOTE = 'https://github.com/n8n-io/n8n.git';
|
|
|
|
const env = {
|
|
BUNDLE_BRANCH: 'bundle/2.x',
|
|
BASE_BRANCH: 'master',
|
|
GH_TOKEN: 'tok',
|
|
GITHUB_REPOSITORY: 'n8n-io/n8n-private',
|
|
};
|
|
|
|
const isMerge = (a) => a[0] === 'merge';
|
|
const isPush = (a) => a[0] === 'push';
|
|
|
|
// What `git merge-tree --write-tree --name-only` emits on a conflict: the (marker-carrying)
|
|
// tree OID, the conflicted paths, a blank line, then informational messages.
|
|
const conflictedMergeTree = (...paths) =>
|
|
fail(`${MERGE_TREE}\n${paths.join('\n')}\n\nCONFLICT (content): Merge conflict in ${paths[0]}`);
|
|
|
|
// FETCH_HEAD is one slot holding whatever the last fetch brought down, so the stub has to answer
|
|
// by looking back at that fetch. Resolving both fetches to the same SHA would let the
|
|
// convergence guard pass by accident and assert nothing.
|
|
const fetchHead = (_args, calls) => {
|
|
const [, url, ref] = [...calls].reverse().find((a) => a[0] === 'fetch') ?? [];
|
|
if (url === PUBLIC_REMOTE) return PUBLIC;
|
|
if (ref?.startsWith('bundle/')) return PRE_HEAD;
|
|
return BASE;
|
|
};
|
|
|
|
// Routes shared by every path: base fetched and already published, the bundle branch hasn't
|
|
// absorbed it yet, the merge tree is computable, and the merge lands on exactly that tree. git
|
|
// grep exits non-zero => no markers.
|
|
const baseGitRoutes = [
|
|
[(a) => a[0] === 'ls-remote', `${PRE_HEAD}\trefs/heads/bundle/2.x`],
|
|
[(a) => a[0] === 'rev-parse' && a[1] === 'FETCH_HEAD', fetchHead],
|
|
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', PRE_HEAD],
|
|
// Split by descendant: the base has reached public, but the bundle branch has not absorbed
|
|
// it. One `merge-base` matcher would answer both and decide convergence by accident.
|
|
[(a) => a[0] === 'merge-base' && a[3] === PUBLIC, ''],
|
|
[(a) => a[0] === 'merge-base' && a[3] === PRE_HEAD, fail()],
|
|
[(a) => a[0] === 'merge-tree', MERGE_TREE],
|
|
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', MERGE_TREE],
|
|
[(a) => a[0] === 'grep', fail()],
|
|
];
|
|
|
|
// A base that is ahead of public by the commits given, so the guard trips.
|
|
const unpublishedRoutes = (...lines) => [
|
|
[(a) => a[0] === 'merge-base' && a[3] === PUBLIC, fail()],
|
|
[(a) => a[0] === 'log' && a[1] === '--format=%h %s', lines.join('\n')],
|
|
];
|
|
|
|
const silent = () => {};
|
|
|
|
test('a missing branch env var fails before any git command runs', () => {
|
|
const git = makeStub();
|
|
assert.throws(() => syncBundleBranch({ git, env: { ...env, BUNDLE_BRANCH: '' }, log: silent }), {
|
|
message: /BUNDLE_BRANCH env var is required/,
|
|
});
|
|
assert.equal(git.calls.length, 0);
|
|
});
|
|
|
|
test('a clean sync merges the base in and pushes without forcing', () => {
|
|
const git = makeStub(baseGitRoutes);
|
|
const result = syncBundleBranch({ git, env, log: silent });
|
|
|
|
assert.deepEqual(result, { status: 'merged' });
|
|
assert.deepEqual(
|
|
git.calls.find((a) => a[0] === 'checkout'),
|
|
['checkout', '--force', '-B', 'bundle/2.x', 'FETCH_HEAD'],
|
|
);
|
|
assert.deepEqual(git.calls.find(isMerge), [
|
|
'merge',
|
|
'--no-edit',
|
|
'-m',
|
|
'Merge master into bundle/2.x',
|
|
BASE,
|
|
]);
|
|
// No lease and no --force: the branch is append-only, so a non-fast-forward must be
|
|
// refused by git rather than resolved by overwriting whatever landed.
|
|
assert.deepEqual(git.calls.find(isPush), ['push', REMOTE, 'HEAD:refs/heads/bundle/2.x']);
|
|
});
|
|
|
|
test('the private fetches are authenticated and the public one is not', () => {
|
|
const git = makeStub(baseGitRoutes);
|
|
syncBundleBranch({ git, env, log: silent });
|
|
|
|
// The private repo rejects an anonymous fetch; the public repo needs no credential.
|
|
assert.deepEqual(
|
|
git.calls.filter((a) => a[0] === 'fetch'),
|
|
[
|
|
['fetch', REMOTE, 'master'],
|
|
['fetch', PUBLIC_REMOTE, 'master'],
|
|
['fetch', REMOTE, 'bundle/2.x'],
|
|
],
|
|
);
|
|
|
|
// The token is scoped to the private repo, so it must never be sent to the public one.
|
|
assert.equal(
|
|
git.calls.some((a) =>
|
|
a.some((arg) => String(arg).includes('x-access-token') && String(arg).includes('/n8n.git')),
|
|
),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('the public tip is read straight after its own fetch', () => {
|
|
const git = makeStub(baseGitRoutes);
|
|
syncBundleBranch({ git, env, log: silent });
|
|
|
|
// Separating a fetch from the read that consumes it would silently pin the wrong SHA.
|
|
assert.deepEqual(
|
|
git.calls.filter((a) => a[0] === 'fetch' || (a[0] === 'rev-parse' && a[1] === 'FETCH_HEAD')),
|
|
[
|
|
['fetch', REMOTE, 'master'],
|
|
['rev-parse', 'FETCH_HEAD'],
|
|
['fetch', PUBLIC_REMOTE, 'master'],
|
|
['rev-parse', 'FETCH_HEAD'],
|
|
['fetch', REMOTE, 'bundle/2.x'],
|
|
],
|
|
);
|
|
});
|
|
|
|
test('a bundle branch that already contains its base is left alone', () => {
|
|
const git = makeStub([
|
|
// Only the bundle-branch ancestry is overridden; the convergence probe keeps its own
|
|
// answer from baseGitRoutes, or this would pass without the guard being exercised.
|
|
[(a) => a[0] === 'merge-base' && a[3] === PRE_HEAD, ''], // --is-ancestor succeeds
|
|
...baseGitRoutes,
|
|
]);
|
|
const result = syncBundleBranch({ git, env, log: silent });
|
|
|
|
assert.deepEqual(result, { status: 'current' });
|
|
assert.equal(git.calls.some(isMerge), false);
|
|
assert.equal(git.calls.some(isPush), false);
|
|
});
|
|
|
|
test('a conflicting base fails without touching the branch', () => {
|
|
const git = makeStub([
|
|
[(a) => a[0] === 'merge-tree', conflictedMergeTree('packages/cli/src/a.ts')],
|
|
...baseGitRoutes,
|
|
]);
|
|
const logs = [];
|
|
|
|
assert.throws(() => syncBundleBranch({ git, env, log: (m) => logs.push(m) }), {
|
|
message: /master conflicts with bundle\/2\.x/,
|
|
});
|
|
// The conflict is detected from the merge tree alone: no merge, no push.
|
|
assert.equal(git.calls.some(isMerge), false);
|
|
assert.equal(git.calls.some(isPush), false);
|
|
assert.match(logs.join('\n'), /::error title=bundle\/2\.x is out of sync::/);
|
|
assert.match(logs.join('\n'), /packages\/cli\/src\/a\.ts/);
|
|
});
|
|
|
|
test('a merged tree that is not the merge tree refuses to push', () => {
|
|
const git = makeStub([
|
|
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'SOMEOTHERTREE'],
|
|
...baseGitRoutes,
|
|
]);
|
|
|
|
assert.throws(() => syncBundleBranch({ git, env, log: silent }), {
|
|
message: /does not match the merge tree MERGETREEOID; refusing to push/,
|
|
});
|
|
assert.equal(git.calls.some(isPush), false);
|
|
});
|
|
|
|
test('conflict markers in the merged tree refuse to push', () => {
|
|
const git = makeStub([
|
|
[(a) => a[0] === 'grep', 'packages/cli/src/a.ts'], // git grep found markers
|
|
...baseGitRoutes,
|
|
]);
|
|
|
|
assert.throws(() => syncBundleBranch({ git, env, log: silent }), {
|
|
message: /conflict markers present in HEAD/,
|
|
});
|
|
assert.equal(git.calls.some(isPush), false);
|
|
});
|
|
|
|
test('a fix landing mid-run is retried from a fresh fetch, never forced', () => {
|
|
let pushes = 0;
|
|
const git = makeStub([
|
|
[
|
|
isPush,
|
|
() => {
|
|
pushes += 1;
|
|
if (pushes === 1) throw Object.assign(new Error('rejected'), { status: 1 });
|
|
return '';
|
|
},
|
|
],
|
|
...baseGitRoutes,
|
|
]);
|
|
const result = syncBundleBranch({ git, env, log: silent });
|
|
|
|
assert.deepEqual(result, { status: 'merged' });
|
|
assert.equal(pushes, 2);
|
|
// The retry re-fetches rather than reusing the tip it already merged onto — including the
|
|
// public tip, so the convergence guard is re-evaluated instead of carried over.
|
|
assert.equal(git.calls.filter((a) => a[0] === 'fetch').length, 6);
|
|
assert.equal(git.calls.filter((a) => a[1] === PUBLIC_REMOTE).length, 2);
|
|
assert.equal(
|
|
git.calls.every((a) => !a.some((arg) => String(arg).includes('force-with-lease'))),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('a branch that keeps moving fails instead of forcing', () => {
|
|
const git = makeStub([[isPush, fail('non-fast-forward')], ...baseGitRoutes]);
|
|
|
|
assert.throws(() => syncBundleBranch({ git, env, log: silent }), {
|
|
message: /bundle\/2\.x kept moving while syncing/,
|
|
});
|
|
});
|
|
|
|
test('a bundle branch that does not exist yet is created at its base', () => {
|
|
const git = makeStub([[(a) => a[0] === 'ls-remote', ''], ...baseGitRoutes]);
|
|
const result = syncBundleBranch({ git, env, log: silent });
|
|
|
|
assert.deepEqual(result, { status: 'created' });
|
|
assert.deepEqual(
|
|
git.calls.find((a) => a[0] === 'checkout'),
|
|
['checkout', '--force', '-B', 'bundle/2.x', BASE],
|
|
);
|
|
assert.equal(git.calls.some(isMerge), false);
|
|
assert.deepEqual(
|
|
git.calls.filter((a) => a[0] === 'fetch'),
|
|
[
|
|
['fetch', REMOTE, 'master'],
|
|
['fetch', PUBLIC_REMOTE, 'master'],
|
|
],
|
|
);
|
|
assert.deepEqual(git.calls.find(isPush), ['push', REMOTE, 'HEAD:refs/heads/bundle/2.x']);
|
|
});
|
|
|
|
test('a remote that cannot be listed fails instead of re-creating the branch', () => {
|
|
const git = makeStub([[(a) => a[0] === 'ls-remote', fail()], ...baseGitRoutes]);
|
|
|
|
assert.throws(() => syncBundleBranch({ git, env, log: silent }), {
|
|
message: /Could not check whether bundle\/2\.x exists on the remote/,
|
|
});
|
|
assert.equal(git.calls.some(isPush), false);
|
|
});
|
|
|
|
test('a branch created under us mid-run is merged into on the retry', () => {
|
|
let lsRemotes = 0;
|
|
let pushes = 0;
|
|
const git = makeStub([
|
|
[
|
|
(a) => a[0] === 'ls-remote',
|
|
() => (++lsRemotes === 1 ? '' : `${PRE_HEAD}\trefs/heads/bundle/2.x`),
|
|
],
|
|
[
|
|
isPush,
|
|
() => {
|
|
if (++pushes === 1) throw Object.assign(new Error('rejected'), { status: 1 });
|
|
return '';
|
|
},
|
|
],
|
|
...baseGitRoutes,
|
|
]);
|
|
const result = syncBundleBranch({ git, env, log: silent });
|
|
|
|
assert.deepEqual(result, { status: 'merged' });
|
|
assert.equal(pushes, 2);
|
|
assert.equal(git.calls.filter(isMerge).length, 1);
|
|
});
|
|
|
|
test('annotation keeps a multi-line message on one line', () => {
|
|
assert.equal(annotation('t', 'a\nb'), '::error title=t::a%0Ab');
|
|
});
|
|
|
|
test('a base that has not reached the public repo is left alone', () => {
|
|
const git = makeStub([
|
|
...unpublishedRoutes('abc1234 chore: Bundle 2.x (#123)'),
|
|
...baseGitRoutes,
|
|
]);
|
|
const logs = [];
|
|
|
|
const result = syncBundleBranch({ git, env, log: (m) => logs.push(m) });
|
|
|
|
assert.deepEqual(result, { status: 'unconverged' });
|
|
// Nothing about the bundle branch is even asked: no existence probe, no fetch, no checkout.
|
|
assert.equal(
|
|
git.calls.some((a) => a[0] === 'ls-remote'),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
git.calls.some((a) => a[0] === 'checkout'),
|
|
false,
|
|
);
|
|
assert.equal(git.calls.some(isMerge), false);
|
|
assert.equal(git.calls.some(isPush), false);
|
|
assert.equal(git.calls.filter((a) => a[0] === 'fetch').length, 2);
|
|
assert.match(logs.join('\n'), /has not reached public master/);
|
|
});
|
|
|
|
test('a missing bundle branch is not created from an unpublished base', () => {
|
|
const git = makeStub([
|
|
[(a) => a[0] === 'ls-remote', ''],
|
|
...unpublishedRoutes('abc1234 chore: Bundle/1.x'),
|
|
...baseGitRoutes,
|
|
]);
|
|
|
|
// One guard covers both paths: the branch must not be conjured onto a doomed commit either.
|
|
assert.deepEqual(syncBundleBranch({ git, env, log: silent }), { status: 'unconverged' });
|
|
assert.equal(
|
|
git.calls.some((a) => a[0] === 'checkout'),
|
|
false,
|
|
);
|
|
assert.equal(git.calls.some(isPush), false);
|
|
});
|
|
|
|
test('a base ahead of public by a non-bundle commit fails loudly', () => {
|
|
const git = makeStub([
|
|
...unpublishedRoutes('abc1234 fix(core): Something a human pushed directly'),
|
|
...baseGitRoutes,
|
|
]);
|
|
const logs = [];
|
|
|
|
assert.throws(() => syncBundleBranch({ git, env, log: (m) => logs.push(m) }), {
|
|
message: /master is ahead of public by commits that are not bundle cuts/,
|
|
});
|
|
assert.equal(git.calls.some(isPush), false);
|
|
|
|
// The annotation is the headline of a failed run: a count, never a subject.
|
|
const annotations = logs.filter((m) => m.startsWith('::error'));
|
|
assert.match(annotations.join('\n'), /carries 1 commit\(s\)/);
|
|
assert.doesNotMatch(annotations.join('\n'), /Something a human pushed directly/);
|
|
});
|
|
|
|
test('one stray commit among bundle cuts still fails', () => {
|
|
const git = makeStub([
|
|
...unpublishedRoutes('abc1234 chore: Bundle 2.x (#123)', 'def5678 fix(core): Stray'),
|
|
...baseGitRoutes,
|
|
]);
|
|
|
|
// All-or-nothing: a single unexplained commit means the mirror cannot clear the base.
|
|
assert.throws(() => syncBundleBranch({ git, env, log: silent }), {
|
|
message: /not bundle cuts/,
|
|
});
|
|
});
|
|
|
|
test('an unrelated history skips rather than forcing anything', () => {
|
|
const git = makeStub([...unpublishedRoutes(), ...baseGitRoutes]);
|
|
|
|
// Ancestry fails but the range is empty, so there is nothing to classify. Skipping is the
|
|
// only safe default here.
|
|
assert.deepEqual(syncBundleBranch({ git, env, log: silent }), { status: 'unconverged' });
|
|
assert.equal(git.calls.some(isPush), false);
|
|
});
|
|
|
|
test('an unconverged base is not retried as a push race', () => {
|
|
const git = makeStub([...unpublishedRoutes('abc1234 chore: Bundle 2.x'), ...baseGitRoutes]);
|
|
const logs = [];
|
|
|
|
syncBundleBranch({ git, env, log: (m) => logs.push(m) });
|
|
|
|
// Only 'rejected' earns a second attempt; the mirror runs hourly, not within this run.
|
|
assert.equal(git.calls.filter((a) => a[0] === 'fetch').length, 2);
|
|
assert.doesNotMatch(logs.join('\n'), /retrying/);
|
|
});
|
|
|
|
test('a public fetch that fails stops the run instead of guessing', () => {
|
|
const git = makeStub([
|
|
[(a) => a[0] === 'fetch' && a[1] === PUBLIC_REMOTE, fail('could not read from remote')],
|
|
...baseGitRoutes,
|
|
]);
|
|
|
|
assert.throws(() => syncBundleBranch({ git, env, log: silent }), {
|
|
message: /Could not fetch master from the public repo/,
|
|
});
|
|
assert.equal(
|
|
git.calls.some((a) => a[0] === 'checkout'),
|
|
false,
|
|
);
|
|
assert.equal(git.calls.some(isPush), false);
|
|
});
|
|
|
|
test('the 1.x bundle branch is checked against public 1.x', () => {
|
|
const git = makeStub([
|
|
[(a) => a[0] === 'ls-remote', `${PRE_HEAD}\trefs/heads/bundle/1.x`],
|
|
...baseGitRoutes,
|
|
]);
|
|
syncBundleBranch({
|
|
git,
|
|
env: { ...env, BUNDLE_BRANCH: 'bundle/1.x', BASE_BRANCH: '1.x' },
|
|
log: silent,
|
|
});
|
|
|
|
// The base branch name carries over to the public side unchanged.
|
|
assert.deepEqual(
|
|
git.calls.filter((a) => a[0] === 'fetch'),
|
|
[
|
|
['fetch', REMOTE, '1.x'],
|
|
['fetch', PUBLIC_REMOTE, '1.x'],
|
|
['fetch', REMOTE, 'bundle/1.x'],
|
|
],
|
|
);
|
|
});
|