Merge branch 'release/v3.8.47' into feat/3512-usage-entry-type

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-11 04:26:26 -03:00
committed by GitHub
357 changed files with 24128 additions and 1797 deletions

View File

@@ -130,7 +130,7 @@ const req = client.request({
traceparent: traceParent,
"user-agent": "connect-es/1.6.1",
"x-cursor-client-type": "cli",
"x-cursor-client-version": "cli-2025.10.21-b2dfaef",
"x-cursor-client-version": "cli-2026.07.08-0c04a8a",
"x-ghost-mode": "true",
"x-original-request-id": requestId,
"x-request-id": requestId,

View File

@@ -173,7 +173,14 @@ export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
const preferredEnvFiltered = Object.fromEntries(
Object.entries(preferredEnv).filter(([, v]) => typeof v === "string" && v.length > 0)
);
const merged = { ...persisted, ...preferredEnvFiltered, ...process.env };
// Filter empty strings from process.env so that Docker `-e KEY=` (which sets an
// empty string) does not override real values persisted in server.env or set
// in .env. Only shell/Docker vars that the operator actually set should win.
// Mirrors the filtering already applied to preferredEnv above. (fixes #6824)
const processEnvFiltered = Object.fromEntries(
Object.entries(process.env).filter(([, v]) => typeof v === "string" && v.length > 0)
);
const merged = { ...persisted, ...preferredEnvFiltered, ...processEnvFiltered };
// ── Auto-generate required secrets ────────────────────────────────────────
let needsPersist = false;

View File

@@ -26,12 +26,15 @@
// env ALLOW_CHANGELOG_REMOVALS=1 report-only (never fails)
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const CHANGELOG = "CHANGELOG.md";
const FRAGMENTS_DIR = "changelog.d";
const FRAGMENT_SECTIONS = ["features", "fixes", "maintenance"];
const FRAGMENT_SKIP = new Set(["README.md", ".gitkeep"]);
/** Extract the set of bullet lines (trimmed) from a CHANGELOG text. */
export function extractBullets(text) {
@@ -56,6 +59,49 @@ export function findLostBullets(baseText, headText) {
return lost;
}
/**
* Validate changelog FRAGMENTS (changelog.d/<section>/*.md — see changelog.d/README.md).
* A fragment must be a well-formed markdown bullet ("- ...") with no merge-conflict
* markers, and must live in a known section dir. Returns [{file, error}]. Pure over
* the filesystem — unit-tested via a tmp root.
*/
export function findInvalidFragments(root = ROOT) {
const invalid = [];
const base = join(root, FRAGMENTS_DIR);
if (!existsSync(base)) return invalid;
const entries = readdirSync(base, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile()) {
if (!FRAGMENT_SKIP.has(entry.name)) {
invalid.push({
file: `${FRAGMENTS_DIR}/${entry.name}`,
error: `fragments live in a section dir (${FRAGMENT_SECTIONS.join("|")}), not at changelog.d root`,
});
}
continue;
}
if (!FRAGMENT_SECTIONS.includes(entry.name)) {
invalid.push({
file: `${FRAGMENTS_DIR}/${entry.name}/`,
error: `unknown section dir (expected ${FRAGMENT_SECTIONS.join("|")})`,
});
continue;
}
for (const f of readdirSync(join(base, entry.name))) {
if (FRAGMENT_SKIP.has(f) || !f.endsWith(".md")) continue;
const file = `${FRAGMENTS_DIR}/${entry.name}/${f}`;
const text = readFileSync(join(base, entry.name, f), "utf8");
const firstContent = text.split("\n").find((l) => l.trim().length > 0);
if (!firstContent) invalid.push({ file, error: "empty fragment" });
else if (!firstContent.trimStart().startsWith("- "))
invalid.push({ file, error: 'fragment must start with a markdown bullet ("- ")' });
else if (/^(<{7}|={7}|>{7})/m.test(text))
invalid.push({ file, error: "fragment contains merge-conflict markers" });
}
}
return invalid;
}
function git(args) {
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
}
@@ -77,6 +123,16 @@ function resolveBaseRef() {
}
function main() {
// Fragment well-formedness first (changelog.d/ — the fragments pattern makes the
// eat-guard below structurally unnecessary for PRs that stop editing CHANGELOG.md).
const invalidFragments = findInvalidFragments();
if (invalidFragments.length > 0) {
console.error(`[changelog-integrity] ${invalidFragments.length} invalid changelog fragment(s):`);
for (const { file, error } of invalidFragments) console.error(`${file}: ${error}`);
console.error("\nSee changelog.d/README.md for the fragment convention.");
return 1;
}
const baseRef = resolveBaseRef();
if (!baseRef) {
console.log("[changelog-integrity] SKIP — could not resolve a base ref (offline/fresh clone).");

View File

@@ -56,6 +56,7 @@ export const INTENTIONALLY_INTERNAL = new Set([
"healthCheck", // db-internal: importado por db/core.ts (runDbHealthCheck)
"jsonMigration", // intentionally-internal: src/app/api/settings/import-json/route.ts
"migrationRunner", // db-internal: importado por db/core.ts (runMigrations ao inicializar o DB)
"modelCapabilityOverrides", // intentionally-internal: src/app/api/model-capability-overrides/route.ts via import direto "@/lib/db/modelCapabilityOverrides" (#6727 — evita empurrar localDb.ts para o cap de 800 linhas)
"notion", // intentionally-internal: settings/notion API route + open-sse/mcp-server/tools/notionTools.ts
"obsidian", // intentionally-internal: src/lib/obsidianSync.ts + settings/obsidian route + MCP obsidianTools.ts
"optimizationSettings", // db-internal: imported by db/core.ts for SQLite PRAGMA application helpers that require the live adapter
@@ -63,6 +64,7 @@ export const INTENTIONALLY_INTERNAL = new Set([
"prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts
"providerNodeSelect", // db-internal: importado só por db/providers.ts (selectProviderNodeForConnection — lógica pura de seleção de provider node split do providers.ts, #4421)
"providerStats", // intentionally-internal: src/app/api/provider-stats/route.ts
"proxyLatency", // intentionally-internal: imported directly by src/lib/db/proxies.ts (anti-barrel, #6798)
"recovery", // intentionally-internal: bin/cli/runtime.mjs (import() dinâmico) + tests
"schemaColumns", // db-internal: importado só por db/core.ts (ensureProviderConnections/UsageHistory/CallLogsColumns + hasColumn/hasTable/getTableColumns — schema-column reconciliation split do core.ts, #4948)
"secrets", // intentionally-internal: src/instrumentation-node.ts (import() dinâmico na inicialização)

View File

@@ -0,0 +1,104 @@
"use strict";
/**
* HEAD response guard (#6400).
*
* RFC 9110 §9.3.2 requires a HEAD response to carry the same headers/status a
* GET would, with ZERO body, and the connection should not leave the client
* guessing about when the (bodyless) response is actually finished.
*
* Next.js 16 handles this correctly for App Router *route handlers*
* (`route.ts` exporting `GET`) — `next/dist/server/send-response.js` explicitly
* skips piping the `Response.body` when `req.method === 'HEAD'`. But Next's
* *page*-rendering pipeline (`next/dist/server/pipe-readable.js` ->
* `pipeToNodeResponse`, used for every app-router page/layout render — the
* root page, the `not-found` boundary that unmatched paths fall through to,
* dashboard pages, etc.) has NO such check: it always pipes the fully
* rendered body to the HTTP response regardless of method. Combined with
* Node's default keep-alive framing, a HEAD request to any page-rendered path
* ends up with the socket only settling once that render finishes — on a
* client that doesn't special-case a HEAD response's implicit zero-length
* body (observed on Windows/curl in #6400), this reads as "headers arrive,
* then it hangs" instead of the RFC-mandated "closes immediately".
*
* Fix: for every inbound HEAD request, before Next ever sees it, wrap the
* Node `ServerResponse` so:
* - Any body bytes written by Next (route handler OR page render) are
* discarded — status code and headers Next computed (auth 401s, 404s,
* 200s, etc.) are preserved untouched.
* - The connection is force-closed right after headers flush
* (`Connection: close`), removing any keep-alive ambiguity a client could
* have about whether more bytes are coming.
*
* This applies globally (valid routes, unmatched/404 paths, authed and
* unauthed) because it operates at the Node HTTP transport layer shared by
* every request — the same tier as the existing `http-method-guard.cjs` /
* `peer-stamp.mjs` wrappers — never inside Next's per-route code.
* See: https://github.com/diegosouzapw/OmniRoute/issues/6400
*/
function isHeadRequest(req) {
return typeof req?.method === "string" && req.method.toUpperCase() === "HEAD";
}
/**
* Mutates `res` in place so any body write is discarded and the response
* ends (closing the connection) as soon as `.end()` is called, regardless of
* what body argument was passed to it.
*
* @param {import("node:http").ServerResponse} res
*/
function suppressBodyAndForceClose(res) {
try {
// Never leave the client guessing whether the (bodyless) response has
// more bytes coming — closing the socket is the unambiguous signal.
res.setHeader("Connection", "close");
} catch {
// Headers may already be flushed in rare re-entrant cases — the write/end
// overrides below still guarantee an empty, prompt HEAD response.
}
const originalEnd = res.end.bind(res);
let ended = false;
res.write = function headSuppressedWrite(_chunk, encodingOrCb, cb) {
// Discard the body but keep the writable-stream contract: report the
// write as flushed (no backpressure) so callers like Next's
// `pipeToNodeResponse` never block waiting on a `drain` that would
// otherwise never fire, and invoke whichever callback form was passed.
if (typeof encodingOrCb === "function") encodingOrCb();
else if (typeof cb === "function") cb();
return true;
};
res.end = function headSuppressedEnd(chunk, encoding, cb) {
if (ended) return res;
ended = true;
if (typeof chunk === "function") return originalEnd(chunk);
if (typeof encoding === "function") return originalEnd(encoding);
if (typeof cb === "function") return originalEnd(cb);
return originalEnd();
};
}
/**
* Wrap a Node request listener so every inbound HEAD request gets the
* body-suppression + forced-close treatment before the wrapped listener
* (eventually Next.js) runs.
*
* @param {(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => unknown} listener
*/
function wrapRequestListenerWithHeadResponseGuard(listener) {
return function headResponseGuardRequestHandler(req, res) {
if (isHeadRequest(req)) {
suppressBodyAndForceClose(res);
}
return listener.call(this, req, res);
};
}
module.exports = {
isHeadRequest,
suppressBodyAndForceClose,
wrapRequestListenerWithHeadResponseGuard,
};

View File

@@ -10,6 +10,7 @@ import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs";
import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs";
import methodGuard from "./http-method-guard.cjs";
import headResponseGuard from "./head-response-guard.cjs";
import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs";
import {
isTurbopackCacheCorruption,
@@ -18,6 +19,7 @@ import {
import { randomUUID } from "node:crypto";
const { maybeHandleDisallowedMethod } = methodGuard;
const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard;
// Pre-read DATA_DIR from local .env before bootstrap resolves paths
if (!process.env.DATA_DIR) {
@@ -143,13 +145,15 @@ async function start() {
baseUrl: `http://127.0.0.1:${dashboardPort}`,
});
const server = http.createServer((req, res) => {
if (maybeHandleDisallowedMethod(req, res)) return;
// Stamp the real TCP peer IP before Next sees the request, so the authz
// middleware can decide LOCAL_ONLY locality without trusting the Host header.
stampPeerIp(req);
return requestHandler(req, res);
});
const server = http.createServer(
wrapRequestListenerWithHeadResponseGuard((req, res) => {
if (maybeHandleDisallowedMethod(req, res)) return;
// Stamp the real TCP peer IP before Next sees the request, so the authz
// middleware can decide LOCAL_ONLY locality without trusting the Host header.
stampPeerIp(req);
return requestHandler(req, res);
})
);
server.on("upgrade", async (req, socket, head) => {
try {
const responsesWsHandled = await responsesWsProxy.handleUpgrade(req, socket, head);

View File

@@ -5,11 +5,13 @@ import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
import { ensurePeerStampToken, wrapRequestListenerWithPeerStamp } from "./peer-stamp.mjs";
import { maybeHandleWebdav } from "./webdav-handler.mjs";
import methodGuard from "./http-method-guard.cjs";
import headResponseGuard from "./head-response-guard.cjs";
import { resolveTlsOptions, createServerListener } from "./tls-options.mjs";
const originalCreateServer = http.createServer.bind(http);
const proxiesByPort = new Map();
const { wrapRequestListenerWithMethodGuard } = methodGuard;
const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard;
// Opt-in native HTTPS (#5242). Resolved once at boot: when both OMNIROUTE_TLS_CERT
// and OMNIROUTE_TLS_KEY point at readable files we terminate TLS on the same
@@ -17,9 +19,7 @@ const { wrapRequestListenerWithMethodGuard } = methodGuard;
// TLS). Absent or misconfigured → null → identical plain-HTTP behavior as before.
const tlsOptions = resolveTlsOptions(process.env);
if (tlsOptions) {
console.log(
`[omniroute][tls] HTTPS enabled — terminating TLS with cert=${tlsOptions.certPath}`
);
console.log(`[omniroute][tls] HTTPS enabled — terminating TLS with cert=${tlsOptions.certPath}`);
}
process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID();
@@ -49,8 +49,23 @@ function getProxy(server) {
return proxy;
}
function deriveLiveWsPath() {
const publicUrl = process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL;
if (!publicUrl) return "/live-ws";
if (!publicUrl.startsWith("ws://") && !publicUrl.startsWith("wss://")) return "/live-ws";
try {
const parsed = new URL(publicUrl);
const pathname = parsed.pathname;
return pathname && pathname !== "/" ? pathname : "/live-ws";
} catch {
return "/live-ws";
}
}
const LIVE_WS_PATH = deriveLiveWsPath();
function proxyLiveWs(req, socket, head) {
const targetPort = parseInt(process.env.LIVE_WS_PORT || "20129", 10);
const targetPort = parseInt(process.env.LIVE_WS_PORT || "20132", 10);
const targetSocket = net.connect(targetPort, "127.0.0.1", () => {
let rawRequest = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`;
for (const [key, val] of Object.entries(req.headers)) {
@@ -74,8 +89,16 @@ function proxyLiveWs(req, socket, head) {
function wrapUpgradeListener(server, listener) {
return async function responsesWsAwareUpgrade(req, socket, head) {
try {
// If this server IS the LiveWS server (port 20132), the ws library's
// own upgrade handler should process the request directly — proxying
// /live-ws back to 127.0.0.1:20132 would create an infinite self-loop.
const liveWsPort = parseInt(process.env.LIVE_WS_PORT || "20132", 10);
if (getPort(server) === liveWsPort) {
return listener.call(this, req, socket, head);
}
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
if (url.pathname === "/live-ws" || url.pathname.startsWith("/live-ws")) {
if (url.pathname === LIVE_WS_PATH || url.pathname.startsWith(LIVE_WS_PATH + "/")) {
proxyLiveWs(req, socket, head);
return;
}
@@ -114,8 +137,12 @@ http.createServer = function createServerWithResponsesWs(...args) {
const lastFnIdx = args.map((a) => typeof a === "function").lastIndexOf(true);
if (lastFnIdx >= 0) {
// Method guard runs before Next because Next 16 rejects TRACE while constructing requests.
args[lastFnIdx] = wrapRequestListenerWithMethodGuard(
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(args[lastFnIdx]))
// Head-response guard wraps outermost so it sees (and can force-close) every
// HEAD request regardless of which inner layer ends up handling it (#6400).
args[lastFnIdx] = wrapRequestListenerWithHeadResponseGuard(
wrapRequestListenerWithMethodGuard(
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(args[lastFnIdx]))
)
);
}
@@ -134,8 +161,10 @@ http.createServer = function createServerWithResponsesWs(...args) {
if (eventName === "request" && typeof listener === "function") {
return originalOn(
eventName,
wrapRequestListenerWithMethodGuard(
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
wrapRequestListenerWithHeadResponseGuard(
wrapRequestListenerWithMethodGuard(
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
)
)
);
}
@@ -149,8 +178,10 @@ http.createServer = function createServerWithResponsesWs(...args) {
if (eventName === "request" && typeof listener === "function") {
return originalAddListener(
eventName,
wrapRequestListenerWithMethodGuard(
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
wrapRequestListenerWithHeadResponseGuard(
wrapRequestListenerWithMethodGuard(
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
)
)
);
}

View File

@@ -97,6 +97,15 @@ const LOCALE_SPECS = [
readmeName: "中文 (简体)",
docsName: "中文 (简体)",
},
{
code: "zh-TW",
googleTl: "zh-TW",
label: "ZH-TW",
flag: "🇹🇼",
languageName: "中文 (繁體)",
readmeName: "中文 (繁體)",
docsName: "中文 (繁體)",
},
{
code: "de",
googleTl: "de",
@@ -414,7 +423,7 @@ const LOCALE_SPECS = [
},
];
const EXISTING_README_CODES = new Set(["pt-BR", "es", "fr", "it", "ru", "zh-CN", "de"]);
const EXISTING_README_CODES = new Set(["pt-BR", "es", "fr", "it", "ru", "zh-CN", "zh-TW", "de"]);
const RTL_LOCALES = new Set(["ar", "fa", "he", "ur"]);
const URL_MAX_TEXT_LENGTH = 1800;

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env node
// scripts/release/aggregate-changelog.mjs
//
// Changelog FRAGMENTS aggregator (towncrier/changesets pattern, adopted 2026-07-09).
//
// Why: during a release cycle every PR used to edit the same few lines at the top of
// CHANGELOG.md (its bullet). In a merge-storm each merge conflicted every sibling
// (CHANGELOG-eat / DIRTY cascade), forcing a re-sync push + full CI re-run per PR per
// merge — O(N²) CI runs for N queued PRs. With fragments, a PR adds ONE NEW FILE under
// changelog.d/<section>/ instead, so two PRs never touch the same file: no conflicts,
// no eat, no re-sync. This script is the single place fragments become CHANGELOG.md
// bullets — run by the release captain (or /generate-release) at reconciliation, and
// safe to run mid-cycle whenever a consolidated view is wanted.
//
// Convention:
// changelog.d/features/<PR>-<slug>.md → appended to "### ✨ New Features"
// changelog.d/fixes/<PR>-<slug>.md → appended to "### 🐛 Bug Fixes"
// changelog.d/maintenance/<PR>-<slug>.md → appended to "### 📝 Maintenance"
// File content = the exact bullet line(s), starting with "- " (continuation lines
// allowed). Credit format stays the repo norm: "(#PR — thanks @user)".
//
// Usage:
// node scripts/release/aggregate-changelog.mjs [--dry-run]
// --dry-run print the would-be CHANGELOG.md to stdout and list fragments;
// touch nothing.
//
// On a real run, aggregated fragment files are DELETED (leaving README.md and the
// .gitkeep placeholders) — the caller commits both the CHANGELOG.md update and the
// deletions in one commit.
import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const FRAGMENTS_DIR = "changelog.d";
/** Section subdir → the CHANGELOG heading its bullets are appended under. */
export const SECTIONS = Object.freeze({
features: "### ✨ New Features",
fixes: "### 🐛 Bug Fixes",
maintenance: "### 📝 Maintenance",
});
const SKIP_FILES = new Set(["README.md", ".gitkeep"]);
/**
* Validate one fragment's text. Returns null when OK, or a human-readable error.
* Pure — unit-tested.
*/
export function validateFragmentText(text) {
const body = String(text || "").replace(/^/, "");
const lines = body.split("\n");
const firstContent = lines.find((l) => l.trim().length > 0);
if (!firstContent) return "empty fragment";
if (!firstContent.trimStart().startsWith("- ")) {
return 'fragment must start with a markdown bullet ("- ")';
}
if (/^(<{7}|={7}|>{7})/m.test(body)) return "fragment contains merge-conflict markers";
return null;
}
/**
* Collect fragments from <root>/changelog.d, sorted by filename per section for a
* deterministic output order. Returns { features: [...], fixes: [...],
* maintenance: [...], invalid: [{file, error}] } where each valid entry is
* { file, text } (text trimmed of trailing whitespace).
*/
export function collectFragments(root) {
const out = { features: [], fixes: [], maintenance: [], invalid: [] };
const base = join(root, FRAGMENTS_DIR);
if (!existsSync(base)) return out;
for (const section of Object.keys(SECTIONS)) {
const dir = join(base, section);
if (!existsSync(dir)) continue;
const files = readdirSync(dir)
.filter((f) => f.endsWith(".md") && !SKIP_FILES.has(f))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
for (const f of files) {
const file = join(dir, f);
const text = readFileSync(file, "utf8").replace(/\s+$/, "");
const error = validateFragmentText(text);
if (error) out.invalid.push({ file: relative(root, file), error });
else out[section].push({ file: relative(root, file), text });
}
}
return out;
}
/**
* Append bullets at the END of a living-section heading's bullet block (before the
* next "##"/"###" heading). Operates on the FIRST occurrence of the heading — in this
* repo's CHANGELOG the living cycle section always appears first. Pure — unit-tested.
* Throws when a needed heading is missing (the release captain adds the heading; the
* script never invents structure).
*/
export function insertBullets(changelogText, bulletsBySection) {
let lines = changelogText.split("\n");
for (const [section, heading] of Object.entries(SECTIONS)) {
const bullets = (bulletsBySection[section] || []).map((b) => b.text ?? b);
if (bullets.length === 0) continue;
const headIdx = lines.findIndex((l) => l.trim() === heading);
if (headIdx === -1) {
throw new Error(
`heading "${heading}" not found in CHANGELOG.md — add it to the living section before aggregating ${section} fragments`
);
}
// End of this section's block: last non-empty line before the next heading.
let nextHead = lines.length;
for (let i = headIdx + 1; i < lines.length; i++) {
if (/^##/.test(lines[i])) {
nextHead = i;
break;
}
}
let insertAt = nextHead;
while (insertAt > headIdx + 1 && lines[insertAt - 1].trim() === "") insertAt--;
const block = bullets.flatMap((b) => b.split("\n"));
lines = [...lines.slice(0, insertAt), ...block, ...lines.slice(insertAt)];
}
return lines.join("\n");
}
/**
* Aggregate fragments into CHANGELOG.md. Returns a summary object. When dryRun is
* true nothing is written or deleted.
*/
export function aggregate({ root = ROOT, dryRun = false } = {}) {
const collected = collectFragments(root);
if (collected.invalid.length > 0) {
const detail = collected.invalid.map((i) => `${i.file}: ${i.error}`).join("\n");
throw new Error(`invalid changelog fragments:\n${detail}`);
}
const total = collected.features.length + collected.fixes.length + collected.maintenance.length;
const changelogPath = join(root, "CHANGELOG.md");
const before = readFileSync(changelogPath, "utf8");
const after = total === 0 ? before : insertBullets(before, collected);
if (!dryRun && total > 0) {
writeFileSync(changelogPath, after);
for (const section of Object.keys(SECTIONS)) {
for (const { file } of collected[section]) unlinkSync(join(root, file));
}
}
return { total, collected, changed: total > 0, after };
}
function main() {
const dryRun = process.argv.includes("--dry-run");
const result = aggregate({ dryRun });
if (result.total === 0) {
console.log("[aggregate-changelog] no fragments to aggregate — nothing to do.");
return 0;
}
for (const section of Object.keys(SECTIONS)) {
for (const { file } of result.collected[section]) {
console.log(`[aggregate-changelog] ${dryRun ? "would aggregate" : "aggregated"} ${file}`);
}
}
console.log(
`[aggregate-changelog] ${result.total} fragment(s) → CHANGELOG.md${dryRun ? " (dry-run, nothing written)" : " (fragments deleted — commit CHANGELOG.md + deletions together)"}`
);
return 0;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.exit(main());
}

137
scripts/release/merge-train.sh Executable file
View File

@@ -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] <base-branch> <PR#> [<PR#>...]
# --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] <base-branch> <PR#> [<PR#>...]" >&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-<ts> --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

View File

@@ -7,9 +7,9 @@
* node scripts/start-ws-server.mjs
*
* Environment variables:
* LIVE_WS_PORT — WebSocket server port (default: 20129)
* LIVE_WS_PORT — WebSocket server port (default: 20132)
* LIVE_WS_HOST — WebSocket server host (default: 127.0.0.1)
* OMNIROUTE_DISABLE_LIVE_WS — Set to "1" or "true" to disable
* OMNIROUTE_ENABLE_LIVE_WS — Set to "0" or "false" to disable
*/
import { spawnSync } from "node:child_process";
@@ -60,10 +60,10 @@ export function buildSidecarSpawn(scriptUrl, env = process.env) {
async function main() {
if (
process.env.OMNIROUTE_DISABLE_LIVE_WS === "1" ||
process.env.OMNIROUTE_DISABLE_LIVE_WS === "true"
process.env.OMNIROUTE_ENABLE_LIVE_WS === "0" ||
process.env.OMNIROUTE_ENABLE_LIVE_WS?.toLowerCase() === "false"
) {
console.log("[LiveWS] Disabled via OMNIROUTE_DISABLE_LIVE_WS");
console.log("[LiveWS] Disabled via OMNIROUTE_ENABLE_LIVE_WS");
process.exit(0);
}
@@ -80,7 +80,7 @@ async function main() {
const { startLiveDashboardServer } = await import("../src/server/ws/liveServer.ts");
const port = parseInt(process.env.LIVE_WS_PORT || "20129", 10);
const port = parseInt(process.env.LIVE_WS_PORT || "20132", 10);
const host = process.env.LIVE_WS_HOST || "127.0.0.1";
console.log(`[LiveWS] Starting dashboard WebSocket server on ${host}:${port}...`);