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/); +});