Compare commits

..

1 Commits

Author SHA1 Message Date
Xiangzhe
30cac12f5e fix(build): stop bundling the better-sqlite3 stub at runtime (#11343)
next.config.mjs aliased `better-sqlite3` to its build-time stub
unconditionally, recording the premise that "runtime still uses the real
package via serverExternalPackages". That premise does not hold: a
Turbopack resolveAlias rewrites the request BEFORE the externals check
runs, so the request stopped matching the serverExternalPackages entry
and the stub was baked into the shipped bundle.

Every artifact built from the release tip then answered HTTP 500 on
every route -- the sync driver failed with "r(...) is not a constructor"
(the minified stub export), fell through node:sqlite and sql.js, and the
instrumentation hook aborted at boot.

Same failure shape as #6344, one alias above it in the same object, so
it gets the same treatment: a shared flag helper makes the alias opt-in
via OMNIROUTE_BETTER_SQLITE3_STUB=1, and a default build externalizes
the real native addon. Nobody sets the flag today; it exists for a build
host that genuinely hits the SIGABRT worker teardown from #10060, and
such a build is not shippable -- which the helper and the stub header
now say explicitly instead of describing the stub as a harmless
build-only stand-in.

Closes #11343
2026-08-24 09:56:20 -03:00
14 changed files with 181 additions and 617 deletions

View File

@@ -2433,10 +2433,10 @@ APP_LOG_TO_FILE=true
# test suite must NEVER mutate the OS trust store (a fake test PEM installed via
# update-ca-certificates broke all system TLS on a persistent runner, 2026-07-05).
# OMNIROUTE_SKIP_SYSTEM_TRUST=1
# check-changelog-integrity.mjs (anti CHANGELOG-eat gate): explicit base ref override.
# Intentional transformations require an exact reviewed entry in
# config/release/changelog-reconciliations.json; there is no runtime bypass.
# check-changelog-integrity.mjs (anti CHANGELOG-eat gate): explicit base ref
# override, and the justified-removal escape hatch for intentional bullet removals.
# CHANGELOG_BASE_REF=origin/release/v0.0.0
# ALLOW_CHANGELOG_REMOVALS=1
# ── Remote audio provider nodes ──
# Used by: src/app/api/v1/_shared/audioProviderNodes.ts — lets the /v1/audio/*

View File

@@ -180,6 +180,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
### 🐛 Bug Fixes
- **fix(build):** every route no longer answers HTTP 500 on artifacts built from the release tip ([#11343](https://github.com/diegosouzapw/OmniRoute/issues/11343)) — `next.config.mjs` aliased `better-sqlite3` to its build-time stub **unconditionally**, on the premise that `serverExternalPackages` still won at runtime. It does not: a Turbopack `resolveAlias` rewrites the request *before* the externals check, so the request stopped matching the `better-sqlite3` external entry and the stub was baked into the shipped bundle. The sync driver then failed with `r(...) is not a constructor`, fell through `node:sqlite` and sql.js, and the instrumentation hook aborted at boot. Same failure shape as [#6344](https://github.com/diegosouzapw/OmniRoute/issues/6344), so it gets the same treatment: the alias is opt-in via `OMNIROUTE_BETTER_SQLITE3_STUB=1` through the shared `scripts/build/better-sqlite3-stub-flag.mjs` helper — set it only on a build host that actually hits the SIGABRT build-worker teardown ([#10060](https://github.com/diegosouzapw/OmniRoute/issues/10060)); default builds externalize the real native addon. Regression guards: `tests/unit/better-sqlite3-stub-alias-11343.test.mjs` (5) and the env matrix in `tests/unit/next-config.test.ts`.
- **security(search)**: block SSRF via `/v1/search` `provider_options.baseUrl` for the Firecrawl search provider — the client-controlled override is now validated as a public URL before it is used to build the server-side fetch target, so a caller with a valid API key can no longer redirect search requests at loopback, RFC1918, or cloud-metadata hosts — thanks @zmf963
- **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366)
- **cli**: route provider test commands through configured connection test endpoints (#10570)

View File

@@ -1 +0,0 @@
- **ci(changelog):** replace the broad removal bypass with an exact, hash-bound reconciliation ledger and bind merge-train checks to their requested release base ([#11345](https://github.com/diegosouzapw/OmniRoute/pull/11345)).

View File

@@ -1,4 +0,0 @@
{
"schemaVersion": 1,
"reconciliations": []
}

View File

@@ -1281,6 +1281,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `OMNIROUTE_SKIP_DNS_WRITE` | _(unset)_ | `src/mitm/dns/dnsConfig.ts` | Set `1` to skip writing to the hosts file when adding/removing DNS entries — for sandboxed or read-only test environments. |
| `OMNIROUTE_SKIP_SYSTEM_TRUST` | `0` | `src/mitm/cert/install.ts`, `src/mitm/tproxy/caTrust.ts` | Test/CI-only guard: set `1` to make cert trust install/uninstall a no-op so the suite never mutates the OS trust store. Set automatically by the test setup and CI workflows. |
| `CHANGELOG_BASE_REF` | _(auto)_ | `scripts/check/check-changelog-integrity.mjs` | Explicit base ref for the anti CHANGELOG-eat gate (defaults to the PR base branch in CI, or the highest `release/v*`). |
| `ALLOW_CHANGELOG_REMOVALS` | `0` | `scripts/check/check-changelog-integrity.mjs` | Set `1` to turn intentional CHANGELOG bullet removals into a report instead of a failure (justify in the PR body). |
| `ONEPROXY_ENABLED` | `true` | `src/lib/oneproxySync.ts` | Enable the 1Proxy egress pool sync. |
| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | `src/lib/oneproxySync.ts` | 1Proxy service API URL override. |
| `ONEPROXY_MAX_PROXIES` | `500` | `src/lib/oneproxySync.ts` | Maximum proxies imported per sync. |

View File

@@ -2,6 +2,7 @@ import createNextIntlPlugin from "next-intl/plugin";
import { createMDX } from "fumadocs-mdx/next";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { betterSqlite3AliasFor } from "./scripts/build/better-sqlite3-stub-flag.mjs";
import { mitmManagerAliasFor } from "./scripts/build/mitm-stub-flag.mjs";
import { normalizeBasePath } from "./scripts/build/normalizeBasePath.mjs";
import {
@@ -138,10 +139,14 @@ const nextConfig = {
// the stub to every npm/Electron/VPS artifact and broke Agent Bridge
// start for all non-Docker users (#6344). See scripts/build/mitm-stub-flag.mjs.
...mitmManagerAliasFor(process.env),
// Build-time stub so the bundler never traces the native better-sqlite3
// addon into a build worker (SIGABRT at worker teardown). Runtime still
// uses the real package via serverExternalPackages. (#10060)
"better-sqlite3": "./src/lib/db/better-sqlite3.stub.js",
// better-sqlite3 → build-time stub ONLY where the build worker actually
// aborts while tracing the native addon (SIGABRT at worker teardown,
// #10060); opt in with OMNIROUTE_BETTER_SQLITE3_STUB=1. The alias used to
// be unconditional on the premise that serverExternalPackages still won
// at runtime — it does not: resolveAlias rewrites the request before the
// externals check, so the stub was bundled and EVERY route answered 500
// (#11343). See scripts/build/better-sqlite3-stub-flag.mjs.
...betterSqlite3AliasFor(process.env),
...minimalBuildAliases,
},
// src/lib/agentSkills/generator.ts builds its fs base path from a runtime

View File

@@ -0,0 +1,36 @@
/**
* Decide whether the Next.js build should alias `better-sqlite3` to the
* build-time stub (src/lib/db/better-sqlite3.stub.js).
*
* History (#11343): the alias was UNCONDITIONAL, added to keep the bundler from
* tracing the native addon into a Next.js build worker, whose thread teardown
* can abort with SIGABRT (assertion in node::RemoveEnvironmentCleanupHook) and
* leave the build without standalone output (#10060).
*
* The premise recorded next to that alias — "runtime still uses the real
* package via serverExternalPackages" — does not hold. A Turbopack
* `resolveAlias` rewrites the request BEFORE the externals check runs, so
* `better-sqlite3` becomes a relative path, no longer matches the
* `serverExternalPackages` entry, and the stub is baked into the bundle. Every
* artifact built from that config answered HTTP 500 on every route: the stub's
* default export is not a constructor, the sync driver chain fell through to
* `node:sqlite` and then sql.js, and the instrumentation hook aborted at boot.
*
* This is the same failure shape as #6344 (the @/mitm/manager stub shipping to
* every npm/Electron/VPS artifact), so it gets the same treatment: the alias is
* opt-in, and a default build gets the real, externalized native package.
*
* Set OMNIROUTE_BETTER_SQLITE3_STUB=1 ONLY on a build host that actually hits
* the SIGABRT worker teardown, and never for an artifact that will be run —
* the resulting bundle cannot open a database.
*/
export function shouldStubBetterSqlite3(env = process.env) {
return env.OMNIROUTE_BETTER_SQLITE3_STUB === "1";
}
/** Turbopack resolveAlias fragment for `better-sqlite3`, derived from the env. */
export function betterSqlite3AliasFor(env = process.env) {
return shouldStubBetterSqlite3(env)
? { "better-sqlite3": "./src/lib/db/better-sqlite3.stub.js" }
: {};
}

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env node
// scripts/check/check-changelog-integrity.mjs
//
// Anti "CHANGELOG-eat" gate: no bullet-line occurrence that exists in the BASE
// branch's CHANGELOG.md may disappear in the merge result. The chronic failure mode is
// Anti "CHANGELOG-eat" gate: no bullet line that exists in the BASE branch's
// CHANGELOG.md may disappear in the merge result. The chronic failure mode is
// git's merge auto-resolve silently dropping sibling bullets (or whole version
// sections) when two branches touch adjacent CHANGELOG lines — incident
// 2026-07-05: PR #6193's merge ate 212 lines (the entire [3.8.45] + [3.8.44]
@@ -16,221 +16,47 @@
// quality.yml runs it blocking for own-origin PRs and report-only for forks.
// The release captain's reconciliation rewrites the CHANGELOG legitimately,
// but that happens on the release PR (PR → main, ci.yml), which does not run
// this gate. There is no runtime escape hatch: every unexplained removal fails.
// Intentional rewrites require a reviewed record in
// config/release/changelog-reconciliations.json. Each record binds the complete base
// and result files by SHA-256 and lists the exact removed/added bullet-line multiset;
// repeated strings encode repeated occurrences. The gate deliberately protects
// bullet lines, not standalone headings, dates, or prose outside a bullet.
// this gate. Escape hatch for intentional removals (e.g. reverting a reverted
// feature's bullet): ALLOW_CHANGELOG_REMOVALS=1 turns failures into a report.
//
// Usage:
// node scripts/check/check-changelog-integrity.mjs
// env GITHUB_BASE_REF PR base branch (CI); local fallback: current release/*
// env CHANGELOG_BASE_REF explicit ref override (e.g. origin/release/v3.8.45)
// env ALLOW_CHANGELOG_REMOVALS=1 report-only (never fails)
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
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 RECONCILIATIONS = "config/release/changelog-reconciliations.json";
const FRAGMENTS_DIR = "changelog.d";
const FRAGMENT_SECTIONS = ["features", "fixes", "maintenance"];
const FRAGMENT_SKIP = new Set(["README.md", ".gitkeep"]);
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
const RECONCILIATION_KEYS = new Set([
"id",
"reason",
"baseChangelogSha256",
"resultChangelogSha256",
"removedBullets",
"addedBullets",
]);
/** Extract the set of bullet lines (trimmed) from a CHANGELOG text. */
export function extractBullets(text) {
return new Set(extractBulletOccurrences(text));
}
/** Extract every bullet-line occurrence, preserving order and duplicates. */
export function extractBulletOccurrences(text) {
const bullets = [];
const bullets = new Set();
for (const raw of String(text || "").split("\n")) {
const line = raw.trim();
if (line.startsWith("- ") && line.length > 4) bullets.push(line);
if (line.startsWith("- ") && line.length > 4) bullets.add(line);
}
return bullets;
}
function findMissingOccurrences(sourceText, targetText) {
const available = new Map();
for (const bullet of extractBulletOccurrences(targetText)) {
available.set(bullet, (available.get(bullet) || 0) + 1);
}
const missing = [];
for (const bullet of extractBulletOccurrences(sourceText)) {
const count = available.get(bullet) || 0;
if (count > 0) available.set(bullet, count - 1);
else missing.push(bullet);
}
return missing;
}
/**
* Bullet-line occurrences present in the base CHANGELOG but absent from the head
* CHANGELOG — including one lost copy of a repeated line. Pure so it has a unit test.
* Bullet lines present in the base CHANGELOG but absent from the head
* CHANGELOG — the "eaten" set. Pure so it has a unit test.
*/
export function findLostBullets(baseText, headText) {
return findMissingOccurrences(baseText, headText);
}
/** Bullet-line occurrences present only in the result CHANGELOG. */
export function findAddedBullets(baseText, headText) {
return findMissingOccurrences(headText, baseText);
}
/** Stable digest tying a reconciliation record to the complete file, not just its bullets. */
export function changelogSha256(text) {
return createHash("sha256")
.update(String(text || ""), "utf8")
.digest("hex");
}
function validateBulletList(value, path, { allowEmpty }) {
if (!Array.isArray(value)) return [`${path} must be an array`];
const errors = [];
if (!allowEmpty && value.length === 0) errors.push(`${path} must not be empty`);
for (let index = 0; index < value.length; index++) {
const bullet = value[index];
if (
typeof bullet !== "string" ||
bullet !== bullet.trim() ||
!bullet.startsWith("- ") ||
bullet.length <= 4
) {
errors.push(`${path}[${index}] must be one exact, trimmed markdown bullet`);
}
const headBullets = extractBullets(headText);
const lost = [];
for (const b of extractBullets(baseText)) {
if (!headBullets.has(b)) lost.push(b);
}
return errors;
}
/** Validate the durable reconciliation ledger without trusting any of its claims. */
export function validateReconciliationLedger(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return ["ledger must be a JSON object"];
}
const errors = [];
const topLevelKeys = Object.keys(value);
for (const key of topLevelKeys) {
if (key !== "schemaVersion" && key !== "reconciliations") {
errors.push(`unknown top-level field: ${key}`);
}
}
if (value.schemaVersion !== 1) errors.push("schemaVersion must be 1");
if (!Array.isArray(value.reconciliations)) {
errors.push("reconciliations must be an array");
return errors;
}
const ids = new Set();
const filePairs = new Set();
for (let index = 0; index < value.reconciliations.length; index++) {
const record = value.reconciliations[index];
const path = `reconciliations[${index}]`;
if (!record || typeof record !== "object" || Array.isArray(record)) {
errors.push(`${path} must be an object`);
continue;
}
for (const key of Object.keys(record)) {
if (!RECONCILIATION_KEYS.has(key)) errors.push(`${path} has unknown field: ${key}`);
}
if (typeof record.id !== "string" || !/^[a-z0-9][a-z0-9._-]{2,79}$/.test(record.id)) {
errors.push(`${path}.id must be a 3-80 character lowercase slug`);
} else if (ids.has(record.id)) {
errors.push(`${path}.id duplicates "${record.id}"`);
} else {
ids.add(record.id);
}
if (typeof record.reason !== "string" || record.reason.trim().length < 20) {
errors.push(`${path}.reason must explain the reconciliation in at least 20 characters`);
}
if (!SHA256_PATTERN.test(record.baseChangelogSha256 || "")) {
errors.push(`${path}.baseChangelogSha256 must be a lowercase SHA-256 digest`);
}
if (!SHA256_PATTERN.test(record.resultChangelogSha256 || "")) {
errors.push(`${path}.resultChangelogSha256 must be a lowercase SHA-256 digest`);
}
if (
SHA256_PATTERN.test(record.baseChangelogSha256 || "") &&
record.baseChangelogSha256 === record.resultChangelogSha256
) {
errors.push(`${path} must describe a changed CHANGELOG.md`);
}
errors.push(
...validateBulletList(record.removedBullets, `${path}.removedBullets`, {
allowEmpty: false,
}),
...validateBulletList(record.addedBullets, `${path}.addedBullets`, { allowEmpty: true })
);
if (Array.isArray(record.removedBullets) && Array.isArray(record.addedBullets)) {
const removed = new Set(record.removedBullets);
for (const bullet of record.addedBullets) {
if (removed.has(bullet)) errors.push(`${path} lists the same bullet as removed and added`);
}
}
const pair = `${record.baseChangelogSha256}:${record.resultChangelogSha256}`;
if (filePairs.has(pair)) errors.push(`${path} duplicates an earlier base/result digest pair`);
filePairs.add(pair);
}
return errors;
}
function sameStringMultiset(left, right) {
if (left.length !== right.length) return false;
const remaining = new Map();
for (const item of right) remaining.set(item, (remaining.get(item) || 0) + 1);
for (const item of left) {
const count = remaining.get(item) || 0;
if (count === 0) return false;
remaining.set(item, count - 1);
}
return true;
}
/** Find the single record that exactly explains this complete base → result transition. */
export function findLedgeredReconciliation(baseText, headText, ledger) {
const baseChangelogSha256 = changelogSha256(baseText);
const resultChangelogSha256 = changelogSha256(headText);
const removedBullets = findLostBullets(baseText, headText);
const addedBullets = findAddedBullets(baseText, headText);
return ledger.reconciliations.find(
(record) =>
record.baseChangelogSha256 === baseChangelogSha256 &&
record.resultChangelogSha256 === resultChangelogSha256 &&
sameStringMultiset(record.removedBullets, removedBullets) &&
sameStringMultiset(record.addedBullets, addedBullets)
);
}
function readReconciliationLedger(root = ROOT) {
const path = join(root, RECONCILIATIONS);
if (!existsSync(path)) {
return { ledger: null, errors: [`${RECONCILIATIONS} is missing`] };
}
let ledger;
try {
ledger = JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
return {
ledger: null,
errors: [`${RECONCILIATIONS} is not valid JSON: ${error.message}`],
};
}
return { ledger, errors: validateReconciliationLedger(ledger) };
return lost;
}
/**
@@ -285,13 +111,7 @@ function resolveBaseRef() {
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
// Local fallback: the highest release/v* on origin (the active development base).
try {
const branches = git([
"branch",
"-r",
"--list",
"origin/release/v*",
"--format=%(refname:short)",
])
const branches = git(["branch", "-r", "--list", "origin/release/v*", "--format=%(refname:short)"])
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
@@ -303,33 +123,16 @@ function resolveBaseRef() {
}
function main() {
if (Object.hasOwn(process.env, "ALLOW_CHANGELOG_REMOVALS")) {
console.error(
"[changelog-integrity] ALLOW_CHANGELOG_REMOVALS was removed; delete it from the environment and record intentional transformations in config/release/changelog-reconciliations.json."
);
return 1;
}
// 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):`
);
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 { ledger, errors: ledgerErrors } = readReconciliationLedger();
if (ledgerErrors.length > 0) {
console.error(`[changelog-integrity] invalid reconciliation ledger (${ledgerErrors.length}):`);
for (const error of ledgerErrors) console.error(`${error}`);
return 1;
}
const hasExplicitBaseRef = Boolean(process.env.CHANGELOG_BASE_REF || process.env.GITHUB_BASE_REF);
const baseRef = resolveBaseRef();
if (!baseRef) {
console.log("[changelog-integrity] SKIP — could not resolve a base ref (offline/fresh clone).");
@@ -340,12 +143,6 @@ function main() {
try {
baseText = git(["show", `${baseRef}:${CHANGELOG}`]);
} catch {
if (hasExplicitBaseRef) {
console.error(
`[changelog-integrity] FAIL — ${CHANGELOG} not readable at explicit base ${baseRef}.`
);
return 1;
}
console.log(`[changelog-integrity] SKIP — ${CHANGELOG} not readable at ${baseRef}.`);
return 0;
}
@@ -357,30 +154,21 @@ function main() {
return 0;
}
const reconciliation = findLedgeredReconciliation(baseText, headText, ledger);
if (reconciliation) {
console.log(
`[changelog-integrity] OK — ${lost.length} removed base bullet(s) covered by ledgered reconciliation "${reconciliation.id}" vs ${baseRef}.`
);
return 0;
}
console.error(
`[changelog-integrity] ${lost.length} bullet(s) present in ${baseRef} are MISSING from this tree's ${CHANGELOG}:`
);
for (const b of lost.slice(0, 15)) console.error(`${b.slice(0, 160)}`);
if (lost.length > 15) console.error(` … and ${lost.length - 15} more`);
const added = findAddedBullets(baseText, headText);
console.error(
"\nThis is the CHANGELOG-eat pattern (merge auto-resolve dropping sibling bullets)." +
"\nFix: restore the base CHANGELOG (`git checkout <base> -- CHANGELOG.md`), re-insert ONLY" +
"\nyour own bullet, and prove the net diff is additive." +
`\nIntentional reconciliation: add one exact, reviewed record to ${RECONCILIATIONS}.` +
`\n baseChangelogSha256: ${changelogSha256(baseText)}` +
`\n resultChangelogSha256: ${changelogSha256(headText)}` +
`\n removedBullets: ${lost.length}; addedBullets: ${added.length}` +
"\nThere is no environment-variable bypass."
"\nyour own bullet, and prove the net diff is additive. Intentional removals (rare):" +
"\nre-run with ALLOW_CHANGELOG_REMOVALS=1 and justify in the PR body."
);
if (process.env.ALLOW_CHANGELOG_REMOVALS === "1") {
console.error("[changelog-integrity] ALLOW_CHANGELOG_REMOVALS=1 — reporting only, not failing.");
return 0;
}
return 1;
}

View File

@@ -57,16 +57,12 @@ for N in "${PRS[@]}"; do
done
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
# The train worktree is detached, so the changelog gate cannot infer which release
# branch seeded it. Shell-quote the requested base before it enters the eval-backed
# gate list, then bind that exact ref only for the changelog check.
printf -v CHANGELOG_BASE_REF_Q '%q' "origin/${BASE}"
STATIC_GATES=(
"npm run typecheck:core"
"node scripts/check/check-file-size.mjs"
"node scripts/check/check-complexity.mjs"
"node scripts/check/check-cognitive-complexity.mjs"
"env CHANGELOG_BASE_REF=${CHANGELOG_BASE_REF_Q} node scripts/check/check-changelog-integrity.mjs"
"node scripts/check/check-changelog-integrity.mjs"
)
# Full mode: the box-speed runner (same coverage as the two CI shards combined —
# main + dashboard + serial groups — at local concurrency instead of runner-sized).

View File

@@ -1,13 +1,19 @@
// Build-time stub for better-sqlite3 (#10060).
//
// Aliased in for the Next.js production build (turbopack + webpack) so the
// bundler never pulls the real native addon into a build worker. The native
// Statement destructor aborts with SIGABRT when a build worker thread exits
// OPT-IN ONLY — set OMNIROUTE_BETTER_SQLITE3_STUB=1 to alias it in, and only on
// a build host that actually hits the SIGABRT worker teardown: the native
// Statement destructor aborts when a Next.js build worker thread exits
// (assertion in node::RemoveEnvironmentCleanupHook, env == nullptr), which can
// leave the build with no standalone output. At runtime the real package is
// used (it is listed in serverExternalPackages, so it is require()'d natively,
// not bundled); this stub only stands in during the build, where the DB is
// never actually queried.
// leave the build with no standalone output.
//
// It is NOT a build-only stand-in. A Turbopack resolveAlias rewrites the
// request before the externals check, so aliasing `better-sqlite3` here also
// removes it from serverExternalPackages' reach and bakes THIS FILE into the
// shipped bundle. An artifact built with the flag on cannot open a database:
// the sync driver chain fails with "r(...) is not a constructor", falls through
// node:sqlite and sql.js, and the instrumentation hook aborts at boot, so every
// route answers HTTP 500. That is exactly what an unconditional alias shipped
// in #11343. See scripts/build/better-sqlite3-stub-flag.mjs.
class Database {
constructor() {}
prepare() {

View File

@@ -0,0 +1,59 @@
// Regression test for #11343 — an unconditional Turbopack `resolveAlias` for
// better-sqlite3 shipped the build-time stub into the runtime bundle, so every
// artifact built from the release tip answered HTTP 500 on every route (the
// stub export is not a constructor, the sync driver chain fell through to
// node:sqlite and sql.js, and the instrumentation hook aborted at boot).
//
// The alias defeats `serverExternalPackages` because resolveAlias rewrites the
// request BEFORE the externals check runs. It must therefore be opt-in, and a
// default production build must externalize the REAL native package.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const { shouldStubBetterSqlite3, betterSqlite3AliasFor } =
await import("../../scripts/build/better-sqlite3-stub-flag.mjs");
describe("better-sqlite3 stub alias (#11343)", () => {
it("default env does NOT stub better-sqlite3 (shipped artifacts get the real addon)", () => {
assert.equal(shouldStubBetterSqlite3({}), false);
assert.deepEqual(betterSqlite3AliasFor({}), {});
});
it("only the exact opt-in value enables the stub", () => {
for (const value of ["", "0", "true", "yes"]) {
assert.equal(
shouldStubBetterSqlite3({ OMNIROUTE_BETTER_SQLITE3_STUB: value }),
false,
`OMNIROUTE_BETTER_SQLITE3_STUB=${JSON.stringify(value)} must not enable the stub`
);
}
});
it("OMNIROUTE_BETTER_SQLITE3_STUB=1 opts into the stub (SIGABRT-prone build hosts, #10060)", () => {
assert.equal(shouldStubBetterSqlite3({ OMNIROUTE_BETTER_SQLITE3_STUB: "1" }), true);
assert.deepEqual(betterSqlite3AliasFor({ OMNIROUTE_BETTER_SQLITE3_STUB: "1" }), {
"better-sqlite3": "./src/lib/db/better-sqlite3.stub.js",
});
});
it("next.config.mjs derives the turbopack alias from the flag (no unconditional stub)", () => {
const config = readFileSync(new URL("../../next.config.mjs", import.meta.url), "utf8");
assert.match(
config,
/betterSqlite3AliasFor/,
"next.config.mjs must use betterSqlite3AliasFor()"
);
assert.doesNotMatch(
config,
/^\s*"better-sqlite3":\s*"\.\/src\/lib\/db\/better-sqlite3\.stub\.js",?\s*$/m,
"next.config.mjs must not hardcode the better-sqlite3 stub alias"
);
});
it("better-sqlite3 stays in serverExternalPackages so the default build externalizes it", () => {
const config = readFileSync(new URL("../../next.config.mjs", import.meta.url), "utf8");
const externals = config.slice(config.indexOf("serverExternalPackages:"));
assert.match(externals.slice(0, externals.indexOf("]")), /"better-sqlite3"/);
});
});

View File

@@ -4,20 +4,10 @@
// PR #6193: 212 lines / 130 bullets eaten).
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const { extractBullets, findLostBullets } =
await import("../../scripts/check/check-changelog-integrity.mjs");
const SCRIPT_PATH = fileURLToPath(
new URL("../../scripts/check/check-changelog-integrity.mjs", import.meta.url)
const { extractBullets, findLostBullets } = await import(
"../../scripts/check/check-changelog-integrity.mjs"
);
const LEDGER_PATH = "config/release/changelog-reconciliations.json";
const BASE = `# Changelog
@@ -56,288 +46,13 @@ test("detects a whole eaten version section (#6193 pattern)", () => {
assert.deepEqual(lost, ["- **feat(c):** shipped bullet ([#3](https://x/3))"]);
});
test("detects one lost occurrence when an identical bullet still exists elsewhere", () => {
const duplicate = "- **fix(repeated):** same rendered bullet ([#9](https://x/9))";
const base = `${BASE}${duplicate}\n${duplicate}\n`;
const head = `${BASE}${duplicate}\n`;
assert.deepEqual(findLostBullets(base, head), [duplicate]);
});
test("bullets moved between sections are NOT reported (line content preserved)", () => {
const head = BASE.replace("- **fix(a):** first bullet ([#1](https://x/1))\n", "").replace(
const head = BASE.replace(
"- **fix(a):** first bullet ([#1](https://x/1))\n",
""
).replace(
"- **feat(c):** shipped bullet ([#3](https://x/3))",
"- **feat(c):** shipped bullet ([#3](https://x/3))\n- **fix(a):** first bullet ([#1](https://x/1))"
);
assert.deepEqual(findLostBullets(BASE, head), []);
});
function makeCliRepo(baseText = BASE) {
const root = mkdtempSync(join(tmpdir(), "changelog-integrity-cli-"));
const script = join(root, "scripts/check/check-changelog-integrity.mjs");
mkdirSync(dirname(script), { recursive: true });
mkdirSync(join(root, "changelog.d/features"), { recursive: true });
mkdirSync(join(root, "changelog.d/fixes"), { recursive: true });
mkdirSync(join(root, "changelog.d/maintenance"), { recursive: true });
mkdirSync(join(root, "config/release"), { recursive: true });
writeFileSync(script, readFileSync(SCRIPT_PATH, "utf8"));
writeFileSync(join(root, "CHANGELOG.md"), baseText);
writeLedger(root, []);
execFileSync("git", ["init", "--quiet"], { cwd: root });
execFileSync("git", ["add", "."], { cwd: root });
execFileSync(
"git",
[
"-c",
"user.name=Changelog Integrity Test",
"-c",
"user.email=changelog-integrity@example.invalid",
"commit",
"--quiet",
"-m",
"base",
],
{ cwd: root }
);
const baseRef = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: root,
encoding: "utf8",
}).trim();
return { root, baseRef };
}
function sha256(text) {
return createHash("sha256").update(text, "utf8").digest("hex");
}
function writeLedger(root, reconciliations) {
writeFileSync(
join(root, LEDGER_PATH),
`${JSON.stringify({ schemaVersion: 1, reconciliations }, null, 2)}\n`
);
}
function runCli(root, baseRef, extraEnv = {}) {
return spawnSync(process.execPath, ["scripts/check/check-changelog-integrity.mjs"], {
cwd: root,
encoding: "utf8",
env: { ...process.env, CHANGELOG_BASE_REF: baseRef, ...extraEnv },
});
}
test("CLI rejects an unledgered loss", () => {
const { root, baseRef } = makeCliRepo();
try {
writeFileSync(
join(root, "CHANGELOG.md"),
BASE.replace("- **fix(b):** second bullet ([#2](https://x/2))\n", "")
);
const result = runCli(root, baseRef);
assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`);
assert.match(result.stderr, /1 bullet\(s\).*MISSING/s);
assert.doesNotMatch(result.stderr, /reporting only, not failing/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("CLI fails closed when the removed legacy bypass is still configured", () => {
const { root, baseRef } = makeCliRepo();
try {
const result = runCli(root, baseRef, { ALLOW_CHANGELOG_REMOVALS: "1" });
assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`);
assert.match(result.stderr, /ALLOW_CHANGELOG_REMOVALS.*removed/);
assert.match(result.stderr, /changelog-reconciliations\.json/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("CLI accepts only an exact, reviewable ledgered reconciliation", () => {
const { root, baseRef } = makeCliRepo();
try {
const removed = "- **fix(b):** second bullet ([#2](https://x/2))";
const added = "- **fix(b):** clarified replacement bullet ([#2](https://x/2))";
const resultText = BASE.replace(removed, added);
writeFileSync(join(root, "CHANGELOG.md"), resultText);
writeLedger(root, [
{
id: "clarify-fix-b",
reason: "Clarify the wording while preserving the original fix and pull request reference.",
baseChangelogSha256: sha256(BASE),
resultChangelogSha256: sha256(resultText),
removedBullets: [removed],
addedBullets: [added],
},
]);
const result = runCli(root, baseRef);
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /OK.*ledgered reconciliation "clarify-fix-b"/s);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("CLI keeps an additional loss RED after an approved result is tampered with", () => {
const { root, baseRef } = makeCliRepo();
try {
const removed = "- **fix(b):** second bullet ([#2](https://x/2))";
const added = "- **fix(b):** clarified replacement bullet ([#2](https://x/2))";
const approvedResult = BASE.replace(removed, added);
writeLedger(root, [
{
id: "clarify-fix-b",
reason: "Clarify the wording while preserving the original fix and pull request reference.",
baseChangelogSha256: sha256(BASE),
resultChangelogSha256: sha256(approvedResult),
removedBullets: [removed],
addedBullets: [added],
},
]);
const tamperedResult = approvedResult.replace(
"- **fix(a):** first bullet ([#1](https://x/1))\n",
""
);
writeFileSync(join(root, "CHANGELOG.md"), tamperedResult);
const result = runCli(root, baseRef);
assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`);
assert.match(result.stderr, /2 bullet\(s\).*MISSING/s);
assert.doesNotMatch(result.stdout, /ledgered reconciliation/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("CLI rejects exact file hashes when the ledger omits one removed occurrence", () => {
const { root, baseRef } = makeCliRepo();
try {
const removedA = "- **fix(a):** first bullet ([#1](https://x/1))";
const removedB = "- **fix(b):** second bullet ([#2](https://x/2))";
const added = "- **fix(ab):** consolidated replacement ([#2](https://x/2))";
const resultText = BASE.replace(`${removedA}\n${removedB}`, added);
writeFileSync(join(root, "CHANGELOG.md"), resultText);
writeLedger(root, [
{
id: "incomplete-removed-multiset",
reason: "Deliberately incomplete fixture that must not authorize the full transition.",
baseChangelogSha256: sha256(BASE),
resultChangelogSha256: sha256(resultText),
removedBullets: [removedB],
addedBullets: [added],
},
]);
const result = runCli(root, baseRef);
assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`);
assert.match(result.stderr, /2 bullet\(s\).*MISSING/s);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("CLI rejects exact file hashes when the ledger omits one removed duplicate", () => {
const duplicate = "- **fix(repeated):** same rendered bullet ([#9](https://x/9))";
const baseText = `${BASE}${duplicate}\n${duplicate}\n`;
const { root, baseRef } = makeCliRepo(baseText);
try {
const added = "- **fix(repeated):** consolidated duplicate ([#9](https://x/9))";
const resultText = `${BASE}${added}\n`;
writeFileSync(join(root, "CHANGELOG.md"), resultText);
writeLedger(root, [
{
id: "incomplete-duplicate-multiset",
reason: "Deliberately omit one identical occurrence from the declared transition.",
baseChangelogSha256: sha256(baseText),
resultChangelogSha256: sha256(resultText),
removedBullets: [duplicate],
addedBullets: [added],
},
]);
const result = runCli(root, baseRef);
assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`);
assert.match(result.stderr, /2 bullet\(s\).*MISSING/s);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("CLI rejects exact bullet deltas when the ledger base hash is wrong", () => {
const { root, baseRef } = makeCliRepo();
try {
const removed = "- **fix(b):** second bullet ([#2](https://x/2))";
const added = "- **fix(b):** clarified replacement bullet ([#2](https://x/2))";
const resultText = BASE.replace(removed, added);
writeFileSync(join(root, "CHANGELOG.md"), resultText);
writeLedger(root, [
{
id: "wrong-base-hash",
reason: "Deliberately stale base digest that must not authorize this transition.",
baseChangelogSha256: "0".repeat(64),
resultChangelogSha256: sha256(resultText),
removedBullets: [removed],
addedBullets: [added],
},
]);
const result = runCli(root, baseRef);
assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`);
assert.match(result.stderr, /1 bullet\(s\).*MISSING/s);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("CLI validates a new fragment without treating it as a reconciliation", () => {
const { root, baseRef } = makeCliRepo();
try {
writeFileSync(
join(root, "changelog.d/fixes/11326-new-valid-fragment.md"),
"- **fix(kie):** preserve a newly added valid fragment ([#11326](https://x/11326)).\n"
);
const result = runCli(root, baseRef);
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /OK — no base bullets lost/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("CLI fails closed on a malformed reconciliation ledger", () => {
const { root, baseRef } = makeCliRepo();
try {
writeFileSync(join(root, LEDGER_PATH), '{"schemaVersion":1,"reconciliations":"all"}\n');
const result = runCli(root, baseRef);
assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`);
assert.match(result.stderr, /invalid reconciliation ledger/);
assert.match(result.stderr, /reconciliations must be an array/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("CLI fails closed when an explicit base ref is unreadable", () => {
const { root } = makeCliRepo();
try {
const result = runCli(root, "missing-explicit-base");
assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`);
assert.match(result.stderr, /FAIL.*CHANGELOG\.md.*missing-explicit-base/s);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

View File

@@ -5,17 +5,13 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { readFile } from "node:fs/promises";
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"
);
const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "../../scripts/release/merge-train.sh");
async function run(args: string[]) {
try {
@@ -72,59 +68,6 @@ test("--plan --fast swaps the full unit suite for changed-tests, keeps static ga
assert.ok(!stdout.includes("npm run test:unit"), "fast mode must not run the full unit suite");
});
test("--plan binds the changelog gate to the requested base inside the detached worktree", async () => {
const { code, stdout } = await run(["--plan", "release/v3.8.50", "11326"]);
assert.equal(code, 0);
assert.match(
stdout,
/worktree add .* --detach origin\/release\/v3\.8\.50/,
"the train worktree must remain detached from the requested base"
);
assert.match(
stdout,
/env CHANGELOG_BASE_REF=origin\/release\/v3\.8\.50 node scripts\/check\/check-changelog-integrity\.mjs/,
"the gate must not fall back to a different numerically highest release branch"
);
});
test("--plan shell-quotes a hostile base before the gate command is evaluated", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "merge-train-plan-"));
const dollarMarker = join(tempDir, "dollar-marker");
const backtickMarker = join(tempDir, "backtick-marker");
const semicolonMarker = join(tempDir, "semicolon-marker");
const base =
`release/v9.9.9 $(touch ${dollarMarker}) ` +
`\`touch ${backtickMarker}\` whitespace gap ; touch ${semicolonMarker}`;
try {
const { code, stdout } = await run(["--plan", base, "11326"]);
assert.equal(code, 0);
const gateLine = stdout.split("\n").find((line) => line.includes("env CHANGELOG_BASE_REF="));
assert.ok(gateLine, "the plan must include the changelog gate command");
const plannedGate = gateLine.replace(/^\[merge-train\] \d+\. /, "");
assert.ok(
!plannedGate.includes(`CHANGELOG_BASE_REF=origin/${base}`),
"hostile shell syntax must not appear unescaped in the eval-backed gate command"
);
// Exercise the exact plan command through the same eval boundary as the real
// train, replacing only the gate executable with a side-effect-free env probe.
const probe = plannedGate.replace(
"node scripts/check/check-changelog-integrity.mjs",
"printenv CHANGELOG_BASE_REF"
);
const { stdout: evaluatedBase } = await pExecFile("bash", ["-c", 'eval "$1"', "bash", probe]);
assert.equal(evaluatedBase, `origin/${base}\n`);
for (const marker of [dollarMarker, backtickMarker, semicolonMarker]) {
await assert.rejects(access(marker), { code: "ENOENT" });
}
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
test("fast mode's UNIT_SUBDIRS allowlist mirrors package.json test:unit exactly", async () => {
// Regression for the 2026-07-18 train red: tests/unit/autoCombo/ (a vitest-only
// subdir) was fed to the node:test bucket because the fast filter had no subdir
@@ -136,15 +79,8 @@ test("fast mode's UNIT_SUBDIRS allowlist mirrors package.json test:unit exactly"
const pkg = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8"));
const pkgList = pkg.scripts["test:unit"].match(/tests\/unit\/\{([^}]+)\}/)?.[1];
assert.ok(pkgList, "package.json test:unit must carry the {subdir} allowlist glob");
assert.equal(
scriptList,
pkgList,
"merge-train.sh UNIT_SUBDIRS must equal test:unit's subdir set"
);
assert.ok(
!scriptList.split(",").includes("autoCombo"),
"autoCombo belongs to vitest, not node:test"
);
assert.equal(scriptList, pkgList, "merge-train.sh UNIT_SUBDIRS must equal test:unit's subdir set");
assert.ok(!scriptList.split(",").includes("autoCombo"), "autoCombo belongs to vitest, not node:test");
});
test("rejects an unknown flag", async () => {

View File

@@ -83,6 +83,10 @@ test("next config declares Turbopack aliases, runtime assets and server external
// A default production build must NOT alias it, or the stub ships to npm/Electron/VPS
// artifacts and breaks Agent Bridge start. See the dedicated env-matrix test below.
assert.equal(nextConfig.turbopack.resolveAlias["@/mitm/manager"], undefined);
// #11343: same story for the better-sqlite3 build stub. resolveAlias is applied
// BEFORE the serverExternalPackages check, so an unconditional alias bundles the
// stub and every route answers 500 at runtime ("r(...) is not a constructor").
assert.equal(nextConfig.turbopack.resolveAlias["better-sqlite3"], undefined);
assert.equal(nextConfig.outputFileTracingRoot, process.cwd());
assert.ok(tracingIncludes.includes("./src/lib/db/migrations/**/*"));
assert.ok(
@@ -118,6 +122,28 @@ test("next config declares Turbopack aliases, runtime assets and server external
}
});
test("Turbopack aliases better-sqlite3 to the stub ONLY when OMNIROUTE_BETTER_SQLITE3_STUB=1 (#11343)", async () => {
const original = process.env.OMNIROUTE_BETTER_SQLITE3_STUB;
try {
delete process.env.OMNIROUTE_BETTER_SQLITE3_STUB;
const { default: def } = await loadNextConfig("bettersqlite-default");
assert.equal(def.turbopack.resolveAlias["better-sqlite3"], undefined);
// The default build must keep the real package reachable as an external, which
// is exactly what the alias silently defeated.
assert.ok(new Set(def.serverExternalPackages).has("better-sqlite3"));
process.env.OMNIROUTE_BETTER_SQLITE3_STUB = "1";
const { default: stubbed } = await loadNextConfig("bettersqlite-optin");
assert.equal(
stubbed.turbopack.resolveAlias["better-sqlite3"],
"./src/lib/db/better-sqlite3.stub.js"
);
} finally {
if (original === undefined) delete process.env.OMNIROUTE_BETTER_SQLITE3_STUB;
else process.env.OMNIROUTE_BETTER_SQLITE3_STUB = original;
}
});
test("Turbopack aliases @/mitm/manager to the stub ONLY when OMNIROUTE_MITM_STUB=1 (#6344)", async () => {
const original = process.env.OMNIROUTE_MITM_STUB;
try {