From 50881c31d00a06e25f304a15bcadddd5b48c8010 Mon Sep 17 00:00:00 2001
From: Diego Rodrigues de Sa e Souza
<8016841+diegosouzapw@users.noreply.github.com>
Date: Fri, 10 Jul 2026 07:23:27 -0300
Subject: [PATCH] =?UTF-8?q?chore(release):=20merge-train=20=E2=80=94=20bat?=
=?UTF-8?q?ch-validate=20queued=20PRs=20once,=20--admin=20with=20evidence?=
=?UTF-8?q?=20(#6784)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Merges every queued PR into a throwaway detached worktree cut from origin/,
runs the fast-gates parity suite ONCE on the final train tip, and prints the
evidence line that authorizes gh pr merge --squash --admin per member
(merge-gates.md §7). Conflicting PRs are ejected and reported, the train
continues. Never pushes, never merges PRs, never stashes.
---
changelog.d/maintenance/6784-merge-train.md | 1 +
scripts/release/merge-train.sh | 137 ++++++++++++++++++++
tests/unit/merge-train-plan.test.ts | 59 +++++++++
3 files changed, 197 insertions(+)
create mode 100644 changelog.d/maintenance/6784-merge-train.md
create mode 100755 scripts/release/merge-train.sh
create mode 100644 tests/unit/merge-train-plan.test.ts
diff --git a/changelog.d/maintenance/6784-merge-train.md b/changelog.d/maintenance/6784-merge-train.md
new file mode 100644
index 0000000000..e98586183f
--- /dev/null
+++ b/changelog.d/maintenance/6784-merge-train.md
@@ -0,0 +1 @@
+- **Merge-train script** (`scripts/release/merge-train.sh`): batch-validates N queued PRs as ONE merged result on the runner box — merges every queued PR into a throwaway worktree cut from the release tip, runs the fast-gates parity suite once, and prints the `--admin` evidence block per PR (merge-gates §7). Replaces O(N²) per-PR CI re-runs in merge-storms. Regression guard: `tests/unit/merge-train-plan.test.ts`.
diff --git a/scripts/release/merge-train.sh b/scripts/release/merge-train.sh
new file mode 100755
index 0000000000..217a66212e
--- /dev/null
+++ b/scripts/release/merge-train.sh
@@ -0,0 +1,137 @@
+#!/usr/bin/env bash
+# scripts/release/merge-train.sh — batch-validate N queued PRs as ONE merged result.
+#
+# Why: in a merge-storm, waiting for each PR's CI after each sibling merge costs
+# O(N²) CI runs. The train merges every queued PR into a throwaway worktree cut from
+# the release tip, runs the full fast-gates parity suite ONCE on the final result, and
+# prints the evidence block that authorizes `gh pr merge --squash --admin` for each
+# train member (merge-gates.md §7 — owner-approved policy extension of §4, 2026-07-09).
+#
+# Designed for the 32-core runner box (192.168.0.113) or any checkout with
+# node_modules. It only READS from origin — it never pushes, never merges PRs, never
+# touches other worktrees, and never uses `git stash` (Hard Rule #22a).
+#
+# Usage:
+# scripts/release/merge-train.sh [--plan] [...]
+# --plan print the planned steps and exit 0 (no worktree, no network) — used by
+# the unit test and for a quick sanity read.
+#
+# Exit codes: 0 = suite green (evidence printed); 1 = usage error; 2 = suite red;
+# PRs whose merge conflicts are EJECTED (reported, train continues).
+set -euo pipefail
+
+PLAN=0
+if [ "${1:-}" = "--plan" ]; then
+ PLAN=1
+ shift
+fi
+
+if [ $# -lt 2 ]; then
+ echo "usage: $0 [--plan] [...]" >&2
+ exit 1
+fi
+
+BASE="$1"
+shift
+PRS=("$@")
+for N in "${PRS[@]}"; do
+ case "$N" in
+ ''|*[!0-9]*) echo "error: PR number '$N' is not numeric" >&2; exit 1 ;;
+ esac
+done
+
+ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+SUITE=(
+ "npm run typecheck:core"
+ "node scripts/check/check-file-size.mjs"
+ "node scripts/check/check-complexity.mjs"
+ "node scripts/check/check-cognitive-complexity.mjs"
+ "node scripts/check/check-changelog-integrity.mjs"
+ "TEST_SHARD=1/2 npm run test:unit:ci:shard"
+ "TEST_SHARD=2/2 npm run test:unit:ci:shard"
+ "npm run test:vitest"
+)
+
+if [ "$PLAN" = "1" ]; then
+ echo "[merge-train] PLAN — base=origin/${BASE} prs=${PRS[*]}"
+ echo "[merge-train] 1. worktree add .claude/worktrees/merge-train- --detach origin/${BASE}"
+ for N in "${PRS[@]}"; do
+ echo "[merge-train] 2. fetch origin pull/${N}/head && merge (conflict → EJECT #${N}, continue)"
+ done
+ i=3
+ for c in "${SUITE[@]}"; do
+ echo "[merge-train] ${i}. ${c}"
+ i=$((i + 1))
+ done
+ echo "[merge-train] ${i}. green → print --admin evidence per PR; red → exit 2 (bisect + eject)"
+ echo "[merge-train] ${i}. teardown: git worktree remove --force (trap EXIT)"
+ exit 0
+fi
+
+if [ -z "$ROOT" ]; then
+ echo "error: not inside a git checkout" >&2
+ exit 1
+fi
+
+TS="$(date +%Y%m%d-%H%M%S)"
+WT="$ROOT/.claude/worktrees/merge-train-$TS"
+LOG="$WT-suite.log"
+
+cleanup() {
+ git -C "$ROOT" worktree remove --force "$WT" 2>/dev/null || true
+}
+trap cleanup EXIT
+
+echo "[merge-train] fetching origin/${BASE}…"
+git -C "$ROOT" fetch origin "$BASE" --quiet
+git -C "$ROOT" worktree add --detach "$WT" "origin/$BASE" --quiet
+# reuse the main checkout's node_modules (same convention as dev worktrees)
+[ -e "$WT/node_modules" ] || ln -s "$ROOT/node_modules" "$WT/node_modules"
+
+EJECTED=()
+BOARDED=()
+for N in "${PRS[@]}"; do
+ echo "[merge-train] boarding #${N}…"
+ if ! git -C "$WT" fetch origin "pull/${N}/head" --quiet; then
+ echo "[merge-train] ✗ #${N} EJECTED — could not fetch pull/${N}/head"
+ EJECTED+=("$N")
+ continue
+ fi
+ if git -C "$WT" merge FETCH_HEAD --no-edit --quiet >/dev/null 2>&1; then
+ BOARDED+=("$N")
+ else
+ git -C "$WT" merge --abort 2>/dev/null || true
+ echo "[merge-train] ✗ #${N} EJECTED — merge conflict vs the train (route it through the normal §5 path)"
+ EJECTED+=("$N")
+ fi
+done
+
+if [ ${#BOARDED[@]} -eq 0 ]; then
+ echo "[merge-train] no PR boarded — nothing to validate." >&2
+ exit 1
+fi
+
+TIP="$(git -C "$WT" rev-parse HEAD)"
+EJ_MSG=""
+[ ${#EJECTED[@]} -gt 0 ] && EJ_MSG=" — ejected: ${EJECTED[*]}"
+echo "[merge-train] train tip ${TIP} — boarded: ${BOARDED[*]}${EJ_MSG}"
+echo "[merge-train] running parity suite (log: ${LOG})…"
+
+for c in "${SUITE[@]}"; do
+ echo "[merge-train] ▶ ${c}"
+ if ! (cd "$WT" && eval "$c") >>"$LOG" 2>&1; then
+ echo "[merge-train] ✗ SUITE RED at: ${c}" >&2
+ echo "[merge-train] tail of ${LOG}:" >&2
+ tail -30 "$LOG" >&2
+ echo "[merge-train] bisect: re-run the failing gate on intermediate train commits, eject the offender, re-run." >&2
+ exit 2
+ fi
+done
+
+echo "[merge-train] ✅ SUITE GREEN on ${TIP}"
+echo "[merge-train] evidence line for each PR (paste before gh pr merge --squash --admin):"
+for N in "${BOARDED[@]}"; do
+ echo " #${N}: Validated in local merge-train ${LOG} on $(hostname) @ ${TIP} (suite green)"
+done
+[ ${#EJECTED[@]} -gt 0 ] && echo "[merge-train] ejected (need the normal path): ${EJECTED[*]}"
+exit 0
diff --git a/tests/unit/merge-train-plan.test.ts b/tests/unit/merge-train-plan.test.ts
new file mode 100644
index 0000000000..770434d22d
--- /dev/null
+++ b/tests/unit/merge-train-plan.test.ts
@@ -0,0 +1,59 @@
+// Guards scripts/release/merge-train.sh (merge-gates.md §7 — batch validation of N
+// queued PRs as one merged result, replacing O(N²) per-PR CI re-runs). Only the
+// side-effect-free surface is testable in unit scope: --plan mode (no worktree, no
+// network) and argument validation.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const pExecFile = promisify(execFile);
+const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "../../scripts/release/merge-train.sh");
+
+async function run(args: string[]) {
+ try {
+ const { stdout, stderr } = await pExecFile("bash", [SCRIPT, ...args]);
+ return { code: 0, stdout, stderr };
+ } catch (err) {
+ const e = err as { code?: number; stdout?: string; stderr?: string };
+ return { code: e.code ?? -1, stdout: e.stdout ?? "", stderr: e.stderr ?? "" };
+ }
+}
+
+test("--plan prints the full step plan without touching anything and exits 0", async () => {
+ const { code, stdout } = await run(["--plan", "release/v9.9.9", "111", "222"]);
+ assert.equal(code, 0);
+ assert.match(stdout, /PLAN — base=origin\/release\/v9\.9\.9 prs=111 222/);
+ assert.match(stdout, /worktree add \.claude\/worktrees\/merge-train-/);
+ assert.match(stdout, /pull\/111\/head/);
+ assert.match(stdout, /pull\/222\/head/);
+ // the parity suite is fully enumerated in the plan
+ for (const gate of [
+ "typecheck:core",
+ "check-file-size.mjs",
+ "check-complexity.mjs",
+ "check-cognitive-complexity.mjs",
+ "check-changelog-integrity.mjs",
+ "TEST_SHARD=1/2",
+ "TEST_SHARD=2/2",
+ "test:vitest",
+ ]) {
+ assert.ok(stdout.includes(gate), `plan must include ${gate}`);
+ }
+ assert.match(stdout, /--admin evidence/);
+ assert.match(stdout, /teardown: git worktree remove/);
+});
+
+test("usage error without enough args", async () => {
+ const { code, stderr } = await run(["--plan", "release/v9.9.9"]);
+ assert.equal(code, 1);
+ assert.match(stderr, /usage:/);
+});
+
+test("rejects a non-numeric PR ref", async () => {
+ const { code, stderr } = await run(["--plan", "release/v9.9.9", "12a"]);
+ assert.equal(code, 1);
+ assert.match(stderr, /not numeric/);
+});