mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 16:12:23 +03:00
Compare commits
1 Commits
fix/v3850-
...
fix/better
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30cac12f5e |
@@ -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/*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)).
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"reconciliations": []
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
36
scripts/build/better-sqlite3-stub-flag.mjs
Normal file
36
scripts/build/better-sqlite3-stub-flag.mjs
Normal 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" }
|
||||
: {};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
59
tests/unit/better-sqlite3-stub-alias-11343.test.mjs
Normal file
59
tests/unit/better-sqlite3-stub-alias-11343.test.mjs
Normal 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"/);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user