mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 04:32:31 +03:00
Merge branch 'release/v3.8.47' into feat/3512-usage-entry-type
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).");
|
||||
|
||||
@@ -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)
|
||||
|
||||
104
scripts/dev/head-response-guard.cjs
Normal file
104
scripts/dev/head-response-guard.cjs
Normal 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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
167
scripts/release/aggregate-changelog.mjs
Normal file
167
scripts/release/aggregate-changelog.mjs
Normal 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
137
scripts/release/merge-train.sh
Executable 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
|
||||
@@ -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}...`);
|
||||
|
||||
Reference in New Issue
Block a user