mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
254 lines
11 KiB
JavaScript
254 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Keeps a bundle integration branch (`bundle/2.x`, `bundle/1.x` in n8n-io/n8n-private) in
|
|
* sync with its base by MERGING the base into it.
|
|
*
|
|
* These branches are APPEND-ONLY: fix PRs target them, and rewriting a branch that receives
|
|
* PRs orphans the copies of its commits those PR branches already contain — every such PR's
|
|
* merge base regresses to an old base commit, and GitHub then shows it carrying everyone
|
|
* else's fixes, in the commit list and in the diff. It compounds: each time an author
|
|
* refreshes their branch between rewrites, it picks up another generation of the same fixes
|
|
* and starts conflicting with itself. Squash-merging a fix INTO the branch is safe, which is
|
|
* why only a rewrite breaks this.
|
|
*
|
|
* The cost of merging is a merge commit per run, plus already-published fixes staying in the
|
|
* branch's log (the rebase used to drop them as empty). Neither reaches anything downstream:
|
|
* a bundle is published as one squashed, deliberately obfuscated commit, and the squash is
|
|
* taken from the tree, not the history. For a list of what a bundle actually carries, read
|
|
* the fix PRs merged into the branch since the last cut, not `base..bundle`.
|
|
*
|
|
* The content pushed is verified to be exactly the tree `git merge-tree` says a merge of the
|
|
* two sides produces; a mismatch, or a conflict marker, fails the run instead of pushing.
|
|
*
|
|
* FAIL LOUD: this script never resolves a conflict. A conflict is detected before the working
|
|
* tree is touched, so the branch is left exactly as it was; a human merges the base in
|
|
* locally, resolves, and pushes — and that resolution then lives in the merge commit instead
|
|
* of being re-litigated on every later run. Contrast `sync-master-to-3x.mjs`, which
|
|
* auto-resolves mechanical files and opens a conflict PR.
|
|
*
|
|
* PUBLISHED HISTORY ONLY: the base is synced in only once the public repo already has it. A cut
|
|
* squash-merges the bundle branch into private master as `chore: Bundle …`, and the hourly
|
|
* public→private mirror then DISCARDS that commit in favour of the public cherry-pick of the same
|
|
* changes. Building on it would strand the discarded commit on a branch that fix PRs are cut
|
|
* from, and the replacement gets merged in on top of it later. An unpublished cut is a SKIP (the
|
|
* mirror clears it within the hour, whatever the cut is, so waiting cannot deadlock); a base
|
|
* ahead of public for any other reason is a FAILURE, because the mirror will not clear it.
|
|
*
|
|
* Runs from a full checkout (fetch-depth 0) of any branch. Assumes credentials are NOT
|
|
* persisted by checkout — every private fetch and the push go through an explicit token URL,
|
|
* which matters because the repository is private. The public fetch is anonymous.
|
|
*
|
|
* Env: BUNDLE_BRANCH (e.g. bundle/2.x), BASE_BRANCH (e.g. master),
|
|
* GH_TOKEN (installation token with contents:write),
|
|
* GITHUB_REPOSITORY (owner/repo, auto-provided by Actions).
|
|
* Requires git 2.38+ (`merge-tree --write-tree`).
|
|
*/
|
|
|
|
import {
|
|
assertNoMarkers,
|
|
assertTreeMatches,
|
|
attempt,
|
|
isAncestor,
|
|
mergeTree,
|
|
runGit,
|
|
} from './branch-replay.mjs';
|
|
|
|
const BOT_NAME = 'n8n-assistant[bot]';
|
|
const BOT_EMAIL = 'n8n-assistant[bot]@users.noreply.github.com';
|
|
|
|
// Hard-coded like the mirror in sec-sync-public-to-private.yml, and fetched anonymously: the
|
|
// installation token this script holds is scoped to the private repo.
|
|
const PUBLIC_REMOTE = 'https://github.com/n8n-io/n8n.git';
|
|
|
|
// What a cut lands on private base before it is published. Anything else ahead of public means
|
|
// the mirror is stuck, not that a cut is in flight.
|
|
const BUNDLE_SUBJECT = /^chore: Bundle/;
|
|
|
|
function required(env, name) {
|
|
const value = env[name];
|
|
if (!value) throw new Error(`${name} env var is required`);
|
|
return value;
|
|
}
|
|
|
|
// A GitHub annotation is one line: fold anything multi-line into the URL-escaped form so the
|
|
// whole message survives in the run's error list.
|
|
export function annotation(title, message) {
|
|
return `::error title=${title}::${message.replace(/\n/g, '%0A')}`;
|
|
}
|
|
|
|
/**
|
|
* Whether the base tip is still private-only, and what to do about it.
|
|
*
|
|
* `null` means the public repo already has it, so the sync may proceed. The predicate is
|
|
* containment in public rather than "is not a bundle commit", so a direct push that bypassed
|
|
* ci-restrict-private-merges.yml is caught too; the subjects only decide how to react.
|
|
*/
|
|
function unconvergedBase({ git, log, bundle, base, baseSha, publicSha }) {
|
|
if (isAncestor(git, baseSha, publicSha)) return null;
|
|
|
|
const ahead = git(['log', '--format=%h %s', `${publicSha}..${baseSha}`])
|
|
.split('\n')
|
|
.filter(Boolean);
|
|
const subjects = ahead.map((line) => line.slice(line.indexOf(' ') + 1));
|
|
|
|
// All-or-nothing: one unexplained commit means the mirror will not clear the base on its own.
|
|
// An empty range here means unrelated histories, which `every` treats as a skip — the safe
|
|
// direction, since forcing past an ancestry check we could not evaluate is never right.
|
|
if (subjects.every((subject) => BUNDLE_SUBJECT.test(subject))) {
|
|
log(
|
|
`${base} ${baseSha} has not reached public ${base} ${publicSha} yet; leaving ${bundle} ` +
|
|
'untouched. The hourly public → private sync clears this.',
|
|
);
|
|
return { status: 'unconverged' };
|
|
}
|
|
|
|
// Subjects go to the run log, which is private. The annotation stays a count: it is the
|
|
// headline of a failed run, and a subject hints at the fix.
|
|
log(ahead.join('\n'));
|
|
log(
|
|
annotation(
|
|
`${base} is ahead of public`,
|
|
`${base} carries ${ahead.length} commit(s) that public does not have and that are not ` +
|
|
`bundle cuts, so the mirror cannot clear them. Fix the mirror, then re-run this workflow.`,
|
|
),
|
|
);
|
|
throw new Error(`${base} is ahead of public by commits that are not bundle cuts.`);
|
|
}
|
|
|
|
// Takes `baseSha` explicitly. This path never fetches the bundle branch, so FETCH_HEAD still
|
|
// holds the PUBLIC tip here, not the base — never swap the argument for FETCH_HEAD.
|
|
function createBundleBranch({ git, log, bundle, base, baseSha, remote }) {
|
|
log(`${bundle} does not exist yet; creating it at ${base} ${baseSha}.`);
|
|
git(['checkout', '--force', '-B', bundle, baseSha]);
|
|
|
|
const push = attempt(git, ['push', remote, `HEAD:refs/heads/${bundle}`]);
|
|
if (!push.ok) {
|
|
if (push.out) log(push.out);
|
|
return { status: 'rejected' };
|
|
}
|
|
log(`Created ${bundle} at ${base} ${baseSha}.`);
|
|
return { status: 'created' };
|
|
}
|
|
|
|
/**
|
|
* One fetch-merge-push cycle. `status: 'rejected'` means the branch moved under us — the
|
|
* caller re-runs from a fresh fetch rather than forcing anything. `status: 'unconverged'` means
|
|
* the base is not published yet and nothing was touched.
|
|
*/
|
|
function mergeBaseIntoBundle({ git, log, bundle, base, remote }) {
|
|
// Pin every side to the SHA fetched for it — fetching by URL never updates the origin/*
|
|
// tracking refs, so FETCH_HEAD is the only handle, and each of the three fetches below
|
|
// overwrites the last. Read FETCH_HEAD on the line after its own fetch; never separate a
|
|
// fetch from the read that consumes it.
|
|
git(['fetch', remote, base]);
|
|
const baseSha = git(['rev-parse', 'FETCH_HEAD']);
|
|
|
|
// The base branch is named the same on both sides (master, 1.x). If that ever stops being
|
|
// true, map it here rather than at the call site.
|
|
const publicFetch = attempt(git, ['fetch', PUBLIC_REMOTE, base]);
|
|
if (!publicFetch.ok) {
|
|
// A guard that cannot be evaluated must stop the run, not be assumed satisfied.
|
|
throw new Error(`Could not fetch ${base} from the public repo:\n${publicFetch.out}`);
|
|
}
|
|
const publicSha = git(['rev-parse', 'FETCH_HEAD']);
|
|
|
|
const unconverged = unconvergedBase({ git, log, bundle, base, baseSha, publicSha });
|
|
if (unconverged) return unconverged;
|
|
|
|
const listed = attempt(git, ['ls-remote', '--heads', remote, `refs/heads/${bundle}`]);
|
|
if (!listed.ok) {
|
|
throw new Error(`Could not check whether ${bundle} exists on the remote:\n${listed.out}`);
|
|
}
|
|
if (!listed.out) {
|
|
return createBundleBranch({ git, log, bundle, base, baseSha, remote });
|
|
}
|
|
|
|
git(['fetch', remote, bundle]);
|
|
git(['checkout', '--force', '-B', bundle, 'FETCH_HEAD']);
|
|
const preHead = git(['rev-parse', 'HEAD']);
|
|
|
|
if (isAncestor(git, baseSha, preHead)) {
|
|
log(`${bundle} already contains ${base} (${baseSha}); nothing to sync.`);
|
|
return { status: 'current' };
|
|
}
|
|
|
|
const pending = git([
|
|
'log',
|
|
'--no-merges',
|
|
'--reverse',
|
|
'--format=%h %s',
|
|
`${baseSha}..${preHead}`,
|
|
]);
|
|
log(`Merging ${base} ${baseSha} into ${bundle}. Commits on ${bundle}:\n${pending || '(none)'}`);
|
|
|
|
// The content the merge must produce — and whether the two sides conflict at all. Computed
|
|
// without touching the working tree, so a conflict costs the branch nothing.
|
|
const merged = mergeTree(git, preHead, baseSha);
|
|
if (!merged.ok) {
|
|
const paths = merged.conflictedPaths;
|
|
log(
|
|
annotation(
|
|
`${bundle} is out of sync`,
|
|
`${base} conflicts with ${bundle} on: ${paths.join(', ') || '(unknown)'}\n` +
|
|
`${bundle} is untouched. Merge ${base} into ${bundle} locally, resolve, push, then re-run this workflow.`,
|
|
),
|
|
);
|
|
throw new Error(`${base} conflicts with ${bundle}; leaving ${bundle} untouched.`);
|
|
}
|
|
|
|
// Fast-forward when the branch carries nothing of its own, so an empty queue records no
|
|
// merge. Otherwise the bundle stays the first parent, keeping `--first-parent` a list of
|
|
// the fixes. An explicit subject, because merging a raw SHA would otherwise read
|
|
// `Merge commit '<sha>'`.
|
|
git(['merge', '--no-edit', '-m', `Merge ${base} into ${bundle}`, baseSha]);
|
|
|
|
assertTreeMatches(git, merged.tree);
|
|
assertNoMarkers(git);
|
|
|
|
const push = attempt(git, ['push', remote, `HEAD:refs/heads/${bundle}`]);
|
|
if (!push.ok) {
|
|
// Not a force push, so this is git refusing a non-fast-forward: a fix landed mid-run.
|
|
if (push.out) log(push.out);
|
|
return { status: 'rejected' };
|
|
}
|
|
log(`Merged ${base} into ${bundle} — nothing rewritten.`);
|
|
return { status: 'merged' };
|
|
}
|
|
|
|
export function syncBundleBranch({ git = runGit, env = process.env, log = console.log } = {}) {
|
|
const bundle = required(env, 'BUNDLE_BRANCH');
|
|
const base = required(env, 'BASE_BRANCH');
|
|
const token = env.GH_TOKEN || env.GITHUB_TOKEN;
|
|
const repo = required(env, 'GITHUB_REPOSITORY');
|
|
if (!token) throw new Error('GH_TOKEN / GITHUB_TOKEN env var is required');
|
|
|
|
git(['config', 'user.name', BOT_NAME]);
|
|
git(['config', 'user.email', BOT_EMAIL]);
|
|
// A merge must never sit waiting for an editor.
|
|
git(['config', 'core.editor', 'true']);
|
|
|
|
// Every remote operation goes through this URL: checkout does not persist credentials, and
|
|
// the repository is private, so `git fetch origin` on its own is unauthenticated.
|
|
const remote = `https://x-access-token:${token}@github.com/${repo}.git`;
|
|
|
|
const first = mergeBaseIntoBundle({ git, log, bundle, base, remote });
|
|
if (first.status !== 'rejected') return first;
|
|
|
|
log(`${bundle} moved while syncing; retrying from a fresh fetch.`);
|
|
const retry = mergeBaseIntoBundle({ git, log, bundle, base, remote });
|
|
if (retry.status === 'rejected') {
|
|
throw new Error(`${bundle} kept moving while syncing; re-run this workflow.`);
|
|
}
|
|
return retry;
|
|
}
|
|
|
|
// Only run when executed directly, not when imported by tests.
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
try {
|
|
syncBundleBranch();
|
|
} catch (error) {
|
|
console.error(`Error: ${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|