Compare commits

..

2 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
c80526f904 docs(changelog): link OpenAPI coverage PR 2026-08-24 04:53:00 -03:00
Diego Rodrigues de Sa e Souza
c5dda9953f docs(openapi): document try proxy operation 2026-08-24 04:48:52 -03:00
11 changed files with 206 additions and 606 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

@@ -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

@@ -0,0 +1 @@
- **docs(openapi):** document the conditionally management-authenticated, same-origin `POST /api/openapi/try` proxy contract and restore the release branch's operation-coverage ratchet ([#11363](https://github.com/diegosouzapw/OmniRoute/pull/11363))

View File

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

View File

@@ -6931,6 +6931,104 @@ paths:
"500":
description: Failed to parse OpenAPI spec
/api/openapi/try:
post:
tags: [System]
summary: Proxy an API Explorer request to an OmniRoute endpoint
description: >-
Executes an API Explorer request through a server-side, same-origin proxy. The target
must start with `/api/`, `/v1/`, `/v1beta/`, `/a2a`, or
`/.well-known/agent.json`; protocol-relative and cross-origin targets are rejected.
Hop-by-hop, proxy, host, cookie, and forwarding headers supplied in `headers` are
stripped, while any dashboard cookie on the original request is forwarded separately.
When `requireLogin` is disabled, the management-auth bypass mirrors the runtime setting;
otherwise a management Bearer credential or dashboard session is required. Failures
caught after authentication, including request JSON parsing, fetch, and response-body
parsing failures, are returned in the normal HTTP 200 result envelope so the Explorer
can display them; `status: 0` identifies that caught-failure path.
security:
- BearerAuth: []
- ManagementSessionAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [path]
properties:
method:
type: string
enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS]
default: GET
path:
type: string
minLength: 1
pattern: "^/(?:api/|v1/|v1beta/|a2a|\\.well-known/agent\\.json)"
description: Same-origin OmniRoute API path, optionally including a query string.
headers:
type: object
default: {}
additionalProperties:
type: string
description: >-
Headers to forward after removing connection, content-length, cookie, host,
keep-alive, proxy-authenticate, proxy-authorization, te, trailer,
transfer-encoding, upgrade, x-forwarded-for, x-forwarded-host, and
x-forwarded-proto headers.
body:
description: >-
Optional JSON value. A truthy value is serialized unless it is already a
string, and is not forwarded when `method` is `GET`.
responses:
"200":
description: Upstream response or displayable caught-failure envelope
content:
application/json:
schema:
type: object
additionalProperties: false
required: [status, statusText, headers, body, latencyMs, contentType]
properties:
status:
type: integer
minimum: 0
description: Upstream HTTP status, or 0 when request processing throws.
statusText:
type: string
headers:
type: object
additionalProperties:
type: string
body:
description: >-
Parsed JSON, response text truncated after 10,000 characters, or a sanitized
caught-error object.
latencyMs:
type: integer
minimum: 0
contentType:
type: string
"400":
description: Invalid request body or non-same-origin path
content:
application/json:
schema:
oneOf:
- $ref: "#/components/schemas/ValidationErrorResponse"
- type: object
required: [error]
properties:
error:
type: string
example: Path must be same-origin
"401":
$ref: "#/components/responses/ManagementAuthenticationRequired"
"403":
$ref: "#/components/responses/ManagementInvalidToken"
"503":
$ref: "#/components/responses/InternalError"
# ─── Agent Skills Catalog ────────────────────────────────────────────────────
/api/agent-skills:

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

@@ -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

@@ -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

@@ -49,6 +49,76 @@ test("GET /api/openapi/spec documents its conditional management auth contract",
);
});
test("POST /api/openapi/try documents its bounded management proxy contract", () => {
const operation = paths["/api/openapi/try"]?.post;
assert.ok(operation, "POST /api/openapi/try must be present in docs/openapi.yaml");
assert.deepEqual(operation.security, [{ BearerAuth: [] }, { ManagementSessionAuth: [] }]);
assert.match(operation.description ?? "", /same-origin/);
assert.match(operation.description ?? "", /When `requireLogin` is disabled/);
const requestBody = operation.requestBody;
const requestSchema = requestBody?.content?.["application/json"]?.schema;
assert.equal(requestBody?.required, true);
assert.equal(requestSchema?.type, "object");
assert.deepEqual(requestSchema?.required, ["path"]);
assert.deepEqual(requestSchema?.properties?.method?.enum, [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"OPTIONS",
]);
assert.equal(requestSchema?.properties?.method?.default, "GET");
assert.equal(requestSchema?.properties?.path?.minLength, 1);
assert.equal(
requestSchema?.properties?.path?.pattern,
"^/(?:api/|v1/|v1beta/|a2a|\\.well-known/agent\\.json)"
);
assert.equal(requestSchema?.properties?.headers?.type, "object");
assert.deepEqual(requestSchema?.properties?.headers?.additionalProperties, {
type: "string",
});
assert.deepEqual(requestSchema?.properties?.headers?.default, {});
assert.ok("body" in requestSchema.properties);
const successSchema = operation.responses?.["200"]?.content?.["application/json"]?.schema;
assert.equal(successSchema?.type, "object");
assert.equal(successSchema?.additionalProperties, false);
assert.deepEqual(successSchema?.required, [
"status",
"statusText",
"headers",
"body",
"latencyMs",
"contentType",
]);
assert.equal(successSchema?.properties?.status?.type, "integer");
assert.equal(successSchema?.properties?.status?.minimum, 0);
assert.equal(successSchema?.properties?.statusText?.type, "string");
assert.equal(successSchema?.properties?.headers?.type, "object");
assert.deepEqual(successSchema?.properties?.headers?.additionalProperties, {
type: "string",
});
assert.match(successSchema?.properties?.body?.description ?? "", /10,000 characters/);
assert.equal(successSchema?.properties?.latencyMs?.type, "integer");
assert.equal(successSchema?.properties?.latencyMs?.minimum, 0);
assert.equal(successSchema?.properties?.contentType?.type, "string");
const badRequestSchema = operation.responses?.["400"]?.content?.["application/json"]?.schema;
assert.equal(badRequestSchema?.oneOf?.length, 2);
assert.equal(badRequestSchema?.oneOf?.[0]?.$ref, "#/components/schemas/ValidationErrorResponse");
assert.equal(badRequestSchema?.oneOf?.[1]?.properties?.error?.type, "string");
assert.equal(
operation.responses?.["401"]?.$ref,
"#/components/responses/ManagementAuthenticationRequired"
);
assert.equal(operation.responses?.["403"]?.$ref, "#/components/responses/ManagementInvalidToken");
assert.equal(operation.responses?.["503"]?.$ref, "#/components/responses/InternalError");
});
test("every x-always-protected path matches ALWAYS_PROTECTED_API_PATHS in routeGuard.ts", () => {
for (const [pathStr, methods] of Object.entries(paths)) {
if (!methods || typeof methods !== "object") continue;