From 42c57328364e441da0ee80d874822e53e011457f Mon Sep 17 00:00:00 2001 From: liyuanyang Date: Sun, 6 Sep 2026 09:59:58 +0800 Subject: [PATCH 01/14] fix(ci): add vitest to Web leg and retry transient audit failures The Web CI job ran npm audit but never ran npm test, leaving the six vitest files under web/src/ without pipeline coverage. The audit step also failed the entire Web leg on the first transient registry error (503, timeout, ECONNRESET) with zero retries. - ci.yml: add "Run unit tests" step (npm test) after audit - audit-retry.mjs: distinguish network errors (retry up to 3x with 10s backoff) from real vulnerability reports (fail immediately) - web/package.json: route audit script through audit-retry.mjs --- .github/workflows/ci.yml | 4 ++ CHANGELOG.md | 7 +++ web/audit-retry.mjs | 104 +++++++++++++++++++++++++++++++++++++++ web/package.json | 2 +- 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 web/audit-retry.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6f912f..fec96b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,6 +253,10 @@ jobs: working-directory: web run: npm run audit + - name: Run unit tests + working-directory: web + run: npm test + - name: Lint working-directory: web run: npm run lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 104b02f..799b6b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ The project publishes 0.x prerelease versions; a stable release line is not yet to the canonical `bytefolk` organization while retaining the published npm scope, MCP identity, and existing cache paths. +### Fixed + +- CI Web job now runs vitest unit tests (`npm test`) so the six test files + under `web/src/` are covered by the pipeline. The `npm run audit` step + retries transient registry failures (503, timeout, ECONNRESET) up to three + times instead of failing the entire Web leg on the first network hiccup. + ### Security - Normalize the client-declared MIME type of a stored file before deciding how diff --git a/web/audit-retry.mjs b/web/audit-retry.mjs new file mode 100644 index 0000000..6f602c9 --- /dev/null +++ b/web/audit-retry.mjs @@ -0,0 +1,104 @@ +import { spawnSync } from "node:child_process"; + +const MAX_ATTEMPTS = 3; +const BACKOFF_MS = 10_000; + +const NETWORK_PATTERNS = [ + "network timeout", + "503 Service Unavailable", + "ECONNRESET", + "ETIMEDOUT", + "fetch failed", + "audit endpoint returned an error", +]; + +const VULNERABILITY_PATTERNS = [ + "found \\d+ vulnerabilities", + "npm audit report", + "vulnerabilities found", +]; + +const COMMANDS = [ + { + label: "production dependencies (moderate threshold)", + args: ["audit", "--omit=dev", "--audit-level=moderate"], + }, + { + label: "all dependencies (high threshold)", + args: ["audit", "--audit-level=high"], + }, +]; + +function isNetworkError(stderr) { + return NETWORK_PATTERNS.some((p) => stderr.toLowerCase().includes(p.toLowerCase())); +} + +function isVulnerabilityReport(stdout) { + return VULNERABILITY_PATTERNS.some((p) => new RegExp(p, "i").test(stdout)); +} + +function runWithRetry(label, args) { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const ts = new Date().toISOString(); + process.stderr.write( + `[audit-retry] ${ts} — ${label} (attempt ${attempt}/${MAX_ATTEMPTS})\n` + ); + + const result = spawnSync("npm", args, { + encoding: "utf8", + stdio: ["inherit", "pipe", "pipe"], + }); + + if (result.status === 0) { + process.stderr.write(`[audit-retry] ${label} passed.\n`); + return 0; + } + + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + + if (isVulnerabilityReport(stdout)) { + process.stderr.write( + `[audit-retry] ${label} found real vulnerabilities — not retrying.\n` + ); + process.stdout.write(stdout); + process.stderr.write(stderr); + return result.status; + } + + if (isNetworkError(stderr)) { + process.stderr.write( + `[audit-retry] ${label} hit a network error — will retry.\n` + ); + if (attempt < MAX_ATTEMPTS) { + spawnSync("sleep", [String(BACKOFF_MS / 1000)]); + } + continue; + } + + process.stderr.write( + `[audit-retry] ${label} failed with unrecognized error — not retrying.\n` + ); + process.stdout.write(stdout); + process.stderr.write(stderr); + return result.status; + } + + process.stderr.write( + `[audit-retry] ${label} exhausted ${MAX_ATTEMPTS} attempts.\n` + ); + const final = spawnSync("npm", args, { encoding: "utf8", stdio: "inherit" }); + return final.status ?? 1; +} + +let exitCode = 0; + +for (const cmd of COMMANDS) { + const code = runWithRetry(cmd.label, cmd.args); + if (code !== 0) { + exitCode = code; + break; + } +} + +process.exit(exitCode); diff --git a/web/package.json b/web/package.json index 6a66d95..f97e956 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "audit": "npm audit --omit=dev --audit-level=moderate && npm audit --audit-level=high", + "audit": "node audit-retry.mjs", "lint": "eslint . --ext .ts,.tsx --max-warnings 0", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "test:enrichment": "node enrichment-acceptance.mjs", From 65da42bc2725ff20c2b69b35af897825a9f74a90 Mon Sep 17 00:00:00 2001 From: liyuanyang Date: Mon, 7 Sep 2026 09:36:58 +0800 Subject: [PATCH 02/14] fix(web): guard audit-retry against null exit status from spawnSync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spawnSync returns null status when npm is killed by signal or not found on PATH. Three return sites in runWithRetry passed null through to process.exit, which Node treats as 0 — failing the audit gate silently. Add `?? 1` at the two unguarded sites (:67, :85) and remove the redundant 4th spawnSync after loop exhaustion (review finding #1), reusing the last loop result with the same `?? 1` guard. All three sites now aligned. --- web/audit-retry.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/audit-retry.mjs b/web/audit-retry.mjs index 6f602c9..f2359a4 100644 --- a/web/audit-retry.mjs +++ b/web/audit-retry.mjs @@ -38,13 +38,14 @@ function isVulnerabilityReport(stdout) { } function runWithRetry(label, args) { + let result; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { const ts = new Date().toISOString(); process.stderr.write( `[audit-retry] ${ts} — ${label} (attempt ${attempt}/${MAX_ATTEMPTS})\n` ); - const result = spawnSync("npm", args, { + result = spawnSync("npm", args, { encoding: "utf8", stdio: ["inherit", "pipe", "pipe"], }); @@ -63,7 +64,7 @@ function runWithRetry(label, args) { ); process.stdout.write(stdout); process.stderr.write(stderr); - return result.status; + return result.status ?? 1; } if (isNetworkError(stderr)) { @@ -81,14 +82,13 @@ function runWithRetry(label, args) { ); process.stdout.write(stdout); process.stderr.write(stderr); - return result.status; + return result.status ?? 1; } process.stderr.write( `[audit-retry] ${label} exhausted ${MAX_ATTEMPTS} attempts.\n` ); - const final = spawnSync("npm", args, { encoding: "utf8", stdio: "inherit" }); - return final.status ?? 1; + return result.status ?? 1; } let exitCode = 0; From 4e9fa549dd482521f5e403ba0ac16c8e71f186d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:19:41 +0800 Subject: [PATCH 03/14] fix(web): make audit retries portable and fail closed --- CHANGELOG.md | 12 +-- web/audit-retry.mjs | 99 ++++++++++------- web/audit-retry.test.mjs | 228 +++++++++++++++++++++++++++++++++++++++ web/vite.config.ts | 2 +- 4 files changed, 297 insertions(+), 44 deletions(-) create mode 100644 web/audit-retry.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 799b6b0..a256b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,13 +13,6 @@ The project publishes 0.x prerelease versions; a stable release line is not yet to the canonical `bytefolk` organization while retaining the published npm scope, MCP identity, and existing cache paths. -### Fixed - -- CI Web job now runs vitest unit tests (`npm test`) so the six test files - under `web/src/` are covered by the pipeline. The `npm run audit` step - retries transient registry failures (503, timeout, ECONNRESET) up to three - times instead of failing the entire Web leg on the first network hiccup. - ### Security - Normalize the client-declared MIME type of a stored file before deciding how @@ -51,6 +44,11 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ### Fixed +- CI Web job now runs unit tests (`npm test`), including audit retry regression + tests. `npm run audit` retries recognized transient registry failures up to + three times per threshold, with a 60-second limit per attempt and portable + backoff. It starts npm through Node on Windows, preserves failure diagnostics, + and fails immediately for vulnerabilities, unknown errors or incomplete runs. - The npm installer no longer aborts a concurrent first run on Windows. The per-asset cache lock previously treated only `EEXIST` as contention, but a contended `mkdir` on Windows may raise `EPERM` or `EACCES`, so a process diff --git a/web/audit-retry.mjs b/web/audit-retry.mjs index f2359a4..adb5844 100644 --- a/web/audit-retry.mjs +++ b/web/audit-retry.mjs @@ -1,19 +1,21 @@ import { spawnSync } from "node:child_process"; +import { setTimeout as sleep } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; const MAX_ATTEMPTS = 3; const BACKOFF_MS = 10_000; +// At most six attempts and four backoffs across both audit thresholds (6m40s). +const ATTEMPT_TIMEOUT_MS = 60_000; const NETWORK_PATTERNS = [ "network timeout", "503 Service Unavailable", "ECONNRESET", "ETIMEDOUT", - "fetch failed", - "audit endpoint returned an error", ]; const VULNERABILITY_PATTERNS = [ - "found \\d+ vulnerabilities", + "found \\d+ vulnerabilit(?:y|ies)", "npm audit report", "vulnerabilities found", ]; @@ -37,68 +39,93 @@ function isVulnerabilityReport(stdout) { return VULNERABILITY_PATTERNS.some((p) => new RegExp(p, "i").test(stdout)); } -function runWithRetry(label, args) { +async function runWithRetry(label, args, { spawn, wait, npmExecPath, stdout, stderr }) { let result; + const finish = () => { + stdout.write(result.stdout ?? ""); + stderr.write(result.stderr ?? ""); + if (result.error) { + stderr.write(`[audit-retry] ${result.error.code ?? "spawn error"}: ${result.error.message}\n`); + } + if (result.signal) { + stderr.write(`[audit-retry] terminated by ${result.signal}.\n`); + } + return Number.isInteger(result.status) && result.status > 0 && result.status <= 255 ? result.status : 1; + }; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { const ts = new Date().toISOString(); - process.stderr.write( + stderr.write( `[audit-retry] ${ts} — ${label} (attempt ${attempt}/${MAX_ATTEMPTS})\n` ); - result = spawnSync("npm", args, { + // npm run supplies the CLI path, including on Windows where npm is a .cmd + // shim that cannot be launched directly with shell-free spawnSync. + result = spawn(npmExecPath ? process.execPath : "npm", npmExecPath ? [npmExecPath, ...args] : args, { encoding: "utf8", stdio: ["inherit", "pipe", "pipe"], + timeout: ATTEMPT_TIMEOUT_MS, + killSignal: "SIGKILL", }); + // An interrupted or unstarted audit is never a valid audit result, even + // when its partial output happens to mention a transient network error. + if (result.error || result.signal || !Number.isInteger(result.status) || result.status < 0 || result.status > 255) { + stderr.write(`[audit-retry] ${label} did not complete — not retrying.\n`); + return finish(); + } + if (result.status === 0) { - process.stderr.write(`[audit-retry] ${label} passed.\n`); + stderr.write(`[audit-retry] ${label} passed.\n`); return 0; } - const stdout = result.stdout ?? ""; - const stderr = result.stderr ?? ""; + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; - if (isVulnerabilityReport(stdout)) { - process.stderr.write( + if (isVulnerabilityReport(output)) { + stderr.write( `[audit-retry] ${label} found real vulnerabilities — not retrying.\n` ); - process.stdout.write(stdout); - process.stderr.write(stderr); - return result.status ?? 1; + return finish(); } - if (isNetworkError(stderr)) { - process.stderr.write( - `[audit-retry] ${label} hit a network error — will retry.\n` - ); + if (isNetworkError(output)) { if (attempt < MAX_ATTEMPTS) { - spawnSync("sleep", [String(BACKOFF_MS / 1000)]); + stderr.write(`[audit-retry] ${label} hit a network error — will retry.\n`); + await wait(BACKOFF_MS); + continue; } - continue; + stderr.write(`[audit-retry] ${label} exhausted ${MAX_ATTEMPTS} attempts.\n`); + return finish(); } - process.stderr.write( + stderr.write( `[audit-retry] ${label} failed with unrecognized error — not retrying.\n` ); - process.stdout.write(stdout); - process.stderr.write(stderr); - return result.status ?? 1; + return finish(); } - - process.stderr.write( - `[audit-retry] ${label} exhausted ${MAX_ATTEMPTS} attempts.\n` - ); - return result.status ?? 1; } -let exitCode = 0; +export async function runAudits({ + spawn = spawnSync, + wait = sleep, + npmExecPath = process.env.npm_execpath, + platform = process.platform, + stdout = process.stdout, + stderr = process.stderr, +} = {}) { + if (platform === "win32" && !npmExecPath) { + stderr.write("[audit-retry] Run npm run audit so npm supplies its CLI path on Windows.\n"); + return 1; + } -for (const cmd of COMMANDS) { - const code = runWithRetry(cmd.label, cmd.args); - if (code !== 0) { - exitCode = code; - break; + for (const cmd of COMMANDS) { + const code = await runWithRetry(cmd.label, cmd.args, { spawn, wait, npmExecPath, stdout, stderr }); + if (code !== 0) return code; } + return 0; } -process.exit(exitCode); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = await runAudits(); +} diff --git a/web/audit-retry.test.mjs b/web/audit-retry.test.mjs new file mode 100644 index 0000000..eab6bb0 --- /dev/null +++ b/web/audit-retry.test.mjs @@ -0,0 +1,228 @@ +// @vitest-environment node +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runAudits } from "./audit-retry.mjs"; + +const PASS = { status: 0, stdout: "", stderr: "" }; +const PROD_ARGS = ["audit", "--omit=dev", "--audit-level=moderate"]; +const ALL_ARGS = ["audit", "--audit-level=high"]; + +function harness(results, overrides = {}) { + let stdout = ""; + let stderr = ""; + const spawn = vi.fn(() => { + const result = results.shift(); + if (!result) throw new Error("Unexpected audit attempt"); + return result; + }); + const wait = vi.fn(async () => {}); + return { + spawn, + wait, + output: () => ({ stdout, stderr }), + run: () => runAudits({ + spawn, + wait, + npmExecPath: "/npm with spaces/npm-cli.js", + stdout: { write: (text) => { stdout += text; } }, + stderr: { write: (text) => { stderr += text; } }, + ...overrides, + }), + }; +} + +describe("audit retry policy", () => { + it("requires both unchanged thresholds to pass, using Node and the npm CLI path", async () => { + const test = harness([PASS, PASS], { platform: "win32" }); + expect(await test.run()).toBe(0); + expect(test.spawn.mock.calls.map(([command, args]) => [command, args])).toEqual([ + [process.execPath, ["/npm with spaces/npm-cli.js", ...PROD_ARGS]], + [process.execPath, ["/npm with spaces/npm-cli.js", ...ALL_ARGS]], + ]); + for (const [, , options] of test.spawn.mock.calls) { + expect(options).toMatchObject({ timeout: 60_000, killSignal: "SIGKILL" }); + expect(options.shell).toBeUndefined(); + } + expect(test.wait).not.toHaveBeenCalled(); + }); + + it("keeps direct Node invocation available on Unix", async () => { + const test = harness([PASS, PASS], { npmExecPath: "", platform: "linux" }); + expect(await test.run()).toBe(0); + expect(test.spawn.mock.calls[0].slice(0, 2)).toEqual(["npm", PROD_ARGS]); + }); + + it("fails with an actionable message when direct invocation lacks npm on Windows", async () => { + const test = harness([], { npmExecPath: "", platform: "win32" }); + expect(await test.run()).toBe(1); + expect(test.output().stderr).toContain("Run npm run audit"); + expect(test.spawn).not.toHaveBeenCalled(); + }); + + it.each(["network timeout", "503 Service Unavailable", "econnreset", "ETIMEDOUT"])( + "retries a recognized transient failure: %s", async (message) => { + const test = harness([{ status: 1, stderr: message }, PASS, PASS]); + expect(await test.run()).toBe(0); + expect(test.spawn).toHaveBeenCalledTimes(3); + expect(test.wait.mock.calls).toEqual([[10_000]]); + }, + ); + + it("recognizes transient failures on stdout", async () => { + const test = harness([{ status: 1, stdout: "ECONNRESET" }, PASS, PASS]); + expect(await test.run()).toBe(0); + expect(test.wait).toHaveBeenCalledTimes(1); + }); + + it("caps retries at three, skips the final backoff, and retains final diagnostics", async () => { + const failure = { status: 7, stdout: "final stdout\n", stderr: "503 Service Unavailable: final detail\n" }; + const test = harness([failure, failure, failure]); + expect(await test.run()).toBe(7); + expect(test.spawn).toHaveBeenCalledTimes(3); + expect(test.wait.mock.calls).toEqual([[10_000], [10_000]]); + expect(test.output().stdout).toBe(failure.stdout); + expect(test.output().stderr).toContain(failure.stderr); + expect(test.output().stderr).toContain("exhausted 3 attempts"); + expect(test.output().stderr.match(/will retry/g)).toHaveLength(2); + }); + + it("gives the second threshold its own bounded retry budget", async () => { + const failure = { status: 1, stderr: "ETIMEDOUT" }; + const test = harness([failure, failure, PASS, failure, failure, PASS]); + expect(await test.run()).toBe(0); + expect(test.spawn).toHaveBeenCalledTimes(6); + expect(test.wait.mock.calls).toEqual(Array(4).fill([10_000])); + expect(test.spawn.mock.calls[3][1].slice(1)).toEqual(ALL_ARGS); + }); + + it.each(["stdout", "stderr"])("never retries vulnerabilities on %s, even with network text", async (stream) => { + const test = harness([{ status: 1, [stream]: "# npm audit report\nETIMEDOUT\n" }]); + expect(await test.run()).toBe(1); + expect(test.spawn).toHaveBeenCalledTimes(1); + expect(test.wait).not.toHaveBeenCalled(); + expect(test.output()[stream]).toContain("# npm audit report"); + }); + + it.each(["found 1 vulnerability", "found 2 vulnerabilities", "vulnerabilities found"])( + "prioritizes vulnerability summaries over network text: %s", async (message) => { + const test = harness([{ status: 1, stdout: message, stderr: "ECONNRESET" }]); + expect(await test.run()).toBe(1); + expect(test.spawn).toHaveBeenCalledTimes(1); + expect(test.wait).not.toHaveBeenCalled(); + }, + ); + + it.each(["E401 unauthorized", "invalid config", "fetch failed", "audit endpoint returned an error"])( + "fails unknown or non-transient errors without retry: %s", async (message) => { + const test = harness([{ status: 2, stdout: "diagnostic\n", stderr: message }]); + expect(await test.run()).toBe(2); + expect(test.wait).not.toHaveBeenCalled(); + expect(test.output().stdout).toBe("diagnostic\n"); + expect(test.output().stderr).toContain(message); + }, + ); + + it.each([ + ["missing executable", { status: null, error: Object.assign(new Error("npm missing"), { code: "ENOENT" }) }, "ENOENT"], + ["timeout", { status: null, error: Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }) }, "timed out"], + ["signal", { status: null, signal: "SIGTERM" }, "SIGTERM"], + ["null status", { status: null }, "did not complete"], + ["missing status", {}, "did not complete"], + ["negative status", { status: -1 }, "did not complete"], + ["out of range status", { status: 256 }, "did not complete"], + ["buffer overflow", { status: null, error: Object.assign(new Error("output limit"), { code: "ENOBUFS" }) }, "ENOBUFS"], + ["error with zero status", { status: 0, error: new Error("incomplete") }, "incomplete"], + ["signal with zero status", { status: 0, signal: "SIGKILL" }, "SIGKILL"], + ])("fails closed for %s regardless of transient-looking output", async (_label, result, diagnostic) => { + const test = harness([{ ...result, stderr: "503 Service Unavailable" }]); + expect(await test.run()).toBe(1); + expect(test.spawn).toHaveBeenCalledTimes(1); + expect(test.wait).not.toHaveBeenCalled(); + expect(test.output().stderr).toContain(diagnostic); + expect(test.output().stderr).toContain("503 Service Unavailable"); + }); + + it("fails overall if the second threshold finds vulnerabilities", async () => { + const test = harness([PASS, { status: 1, stdout: "found 2 vulnerabilities" }]); + expect(await test.run()).toBe(1); + expect(test.spawn).toHaveBeenCalledTimes(2); + expect(test.wait).not.toHaveBeenCalled(); + }); +}); + +describe("audit CLI process behavior", () => { + const directories = []; + afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); + }); + + function fixture(results) { + const directory = mkdtempSync(join(tmpdir(), "audit npm fixture ")); + directories.push(directory); + const cli = join(directory, "npm cli.cjs"); + const log = join(directory, "calls.json"); + writeFileSync(cli, ` + const fs = require('node:fs'); + const log = ${JSON.stringify(log)}; + const calls = fs.existsSync(log) ? JSON.parse(fs.readFileSync(log, 'utf8')) : []; + const result = ${JSON.stringify(results)}[calls.length]; + calls.push(process.argv.slice(2)); + fs.writeFileSync(log, JSON.stringify(calls)); + process.stdout.write(result.stdout || ''); + process.stderr.write(result.stderr || ''); + process.exitCode = result.status; + `); + return { cli, log }; + } + + function invoke(cli) { + return spawnSync(process.execPath, [fileURLToPath(new URL("./audit-retry.mjs", import.meta.url))], { + encoding: "utf8", + timeout: 5_000, + killSignal: "SIGKILL", + env: { ...process.env, npm_execpath: cli }, + }); + } + + it("executes a CLI path with spaces and exits zero only after both audits", () => { + const { cli, log } = fixture([PASS, PASS]); + const result = invoke(cli); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(JSON.parse(readFileSync(log, "utf8"))).toEqual([PROD_ARGS, ALL_ARGS]); + }); + + it.each(["# npm audit report\n", "unknown audit error\n"])("returns failure and output for %s", (message) => { + const { cli, log } = fixture([{ status: 2, stdout: message, stderr: "detail\n" }]); + const result = invoke(cli); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(2); + expect(result.stdout).toBe(message); + expect(result.stderr).toContain("detail\n"); + expect(JSON.parse(readFileSync(log, "utf8"))).toEqual([PROD_ARGS]); + }); + + it("exits nonzero when the npm CLI cannot be started", () => { + const { cli } = fixture([]); + rmSync(cli); + const result = invoke(cli); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("MODULE_NOT_FOUND"); + }); + + it("terminates a stalled process and fails closed at the attempt timeout", async () => { + const test = harness([], { + spawn: (_command, _args, options) => spawnSync(process.execPath, [ + "-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);", + ], { ...options, timeout: 200 }), + }); + expect(await test.run()).toBe(1); + expect(test.wait).not.toHaveBeenCalled(); + expect(test.output().stderr).toContain("ETIMEDOUT"); + expect(test.output().stderr).toContain("SIGKILL"); + }); +}); diff --git a/web/vite.config.ts b/web/vite.config.ts index 7f64a36..f5b8a78 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: ['./src/test-setup.ts'], - include: ['src/**/*.{test,spec}.{ts,tsx}'], + include: ['src/**/*.{test,spec}.{ts,tsx}', 'audit-retry.test.mjs'], coverage: { provider: 'v8', reporter: ['text', 'json', 'lcov'], From fc6417da4f97cadb9202c56669b4f8d1a6a4fbbe Mon Sep 17 00:00:00 2001 From: liyuanyang Date: Tue, 8 Sep 2026 13:44:15 +0800 Subject: [PATCH 04/14] fix(web): add --fetch-timeout so npm fails before SIGKILL catches it The 60s ATTEMPT_TIMEOUT_MS kills npm before its default 300s fetch-timeout fires, so the retry logic never sees "network timeout" for slow registry hangs. Adding --fetch-timeout=45000 to both audit args lets npm report the timeout within the 60s budget, making isNetworkError catch it and trigger retries as designed. --- web/audit-retry.mjs | 6 +++--- web/audit-retry.test.mjs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/audit-retry.mjs b/web/audit-retry.mjs index adb5844..3afe096 100644 --- a/web/audit-retry.mjs +++ b/web/audit-retry.mjs @@ -4,7 +4,7 @@ import { pathToFileURL } from "node:url"; const MAX_ATTEMPTS = 3; const BACKOFF_MS = 10_000; -// At most six attempts and four backoffs across both audit thresholds (6m40s). +// At most six attempts and four backoffs across both audit thresholds (~5m10s with 45s fetch timeout). const ATTEMPT_TIMEOUT_MS = 60_000; const NETWORK_PATTERNS = [ @@ -23,11 +23,11 @@ const VULNERABILITY_PATTERNS = [ const COMMANDS = [ { label: "production dependencies (moderate threshold)", - args: ["audit", "--omit=dev", "--audit-level=moderate"], + args: ["audit", "--omit=dev", "--audit-level=moderate", "--fetch-timeout=45000"], }, { label: "all dependencies (high threshold)", - args: ["audit", "--audit-level=high"], + args: ["audit", "--audit-level=high", "--fetch-timeout=45000"], }, ]; diff --git a/web/audit-retry.test.mjs b/web/audit-retry.test.mjs index eab6bb0..ed8de7e 100644 --- a/web/audit-retry.test.mjs +++ b/web/audit-retry.test.mjs @@ -8,8 +8,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { runAudits } from "./audit-retry.mjs"; const PASS = { status: 0, stdout: "", stderr: "" }; -const PROD_ARGS = ["audit", "--omit=dev", "--audit-level=moderate"]; -const ALL_ARGS = ["audit", "--audit-level=high"]; +const PROD_ARGS = ["audit", "--omit=dev", "--audit-level=moderate", "--fetch-timeout=45000"]; +const ALL_ARGS = ["audit", "--audit-level=high", "--fetch-timeout=45000"]; function harness(results, overrides = {}) { let stdout = ""; From ad907ac7add3dadda5929ccb38a2238e45fbce94 Mon Sep 17 00:00:00 2001 From: liyuanyang Date: Tue, 8 Sep 2026 14:55:48 +0800 Subject: [PATCH 05/14] test(web): guard audit-retry test collection and timeout invariant - Assert vite.config.ts includes audit-retry.test.mjs so removing it fails the suite instead of silently dropping 35 tests. - Assert --fetch-timeout < spawn timeout so the dead-zone fix stays enforced if either constant drifts. --- web/audit-retry.test.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/audit-retry.test.mjs b/web/audit-retry.test.mjs index ed8de7e..cd0776f 100644 --- a/web/audit-retry.test.mjs +++ b/web/audit-retry.test.mjs @@ -43,9 +43,11 @@ describe("audit retry policy", () => { [process.execPath, ["/npm with spaces/npm-cli.js", ...PROD_ARGS]], [process.execPath, ["/npm with spaces/npm-cli.js", ...ALL_ARGS]], ]); - for (const [, , options] of test.spawn.mock.calls) { + for (const [, args, options] of test.spawn.mock.calls) { expect(options).toMatchObject({ timeout: 60_000, killSignal: "SIGKILL" }); expect(options.shell).toBeUndefined(); + const fetchTimeout = args.filter(a => a.startsWith("--fetch-timeout=")).map(a => Number(a.split("=")[1])); + for (const ms of fetchTimeout) expect(ms).toBeLessThan(options.timeout); } expect(test.wait).not.toHaveBeenCalled(); }); @@ -152,6 +154,12 @@ describe("audit retry policy", () => { expect(test.spawn).toHaveBeenCalledTimes(2); expect(test.wait).not.toHaveBeenCalled(); }); + + it("vite.config.ts includes audit-retry.test.mjs in test collection", async () => { + const configPath = join(__dirname, "vite.config.ts"); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("audit-retry.test.mjs"); + }); }); describe("audit CLI process behavior", () => { From 97cd40ef3ab7416ed67d1f23d9701da48cd01b93 Mon Sep 17 00:00:00 2001 From: liyuanyang Date: Tue, 8 Sep 2026 15:38:31 +0800 Subject: [PATCH 06/14] scripts: add win-audit-verify.bat for real-Windows audit evidence Captures git head, npm_execpath probe, full npm run audit output, and exit code. Must be run from cmd.exe (not WSL) to prove the win32 branch in audit-retry.mjs:117 receives npm_execpath. --- scripts/win-audit-verify.bat | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 scripts/win-audit-verify.bat diff --git a/scripts/win-audit-verify.bat b/scripts/win-audit-verify.bat new file mode 100644 index 0000000..1c667f5 --- /dev/null +++ b/scripts/win-audit-verify.bat @@ -0,0 +1,42 @@ +@echo off +REM win-audit-verify.bat — Verify audit-retry.mjs works on real Windows. +REM Must be run from a real Windows terminal (cmd.exe), NOT WSL. +REM Captures: commit, npm_execpath, full audit output, exit code. + +setlocal enabledelayedexpansion + +echo === win-audit-verify === +echo. + +REM 1. Show which commit is being tested +echo --- git head --- +git rev-parse HEAD +git log -1 --format=^"%%h %%s^" +echo. + +REM 2. Show Node version +echo --- node --- +node --version +echo. + +REM 3. Prove npm_execpath is set by npm run, and that a direct node.exe +REM invocation does NOT have it (the failure mode from the review). +echo --- npm_execpath probe via node -e (should be EMPTY) --- +node -e "console.log('npm_execpath=' + (process.env.npm_execpath || '(unset)'))" +echo. + +REM 4. Run the actual audit via npm run — this is the path that supplies npm_execpath. +echo --- npm run audit (full output) --- +echo [win-audit-verify] starting npm run audit at %DATE% %TIME% +npm run audit +set AUDIT_RC=!ERRORLEVEL! +echo [win-audit-verify] finished at %DATE% %TIME% +echo. + +REM 5. Report exit code +echo --- result --- +echo [win-audit-verify] npm run audit exit code: !AUDIT_RC! +echo. + +endlocal +exit /b %AUDIT_RC% From 78b3d2daa1b71230572fe64ecd249f110b210580 Mon Sep 17 00:00:00 2001 From: liyuanyang Date: Tue, 8 Sep 2026 15:48:40 +0800 Subject: [PATCH 07/14] fix(scripts): use CRLF line endings in win-audit-verify.bat Windows cmd.exe requires CRLF; LF-only caused every line to be misread as an unknown command. --- scripts/win-audit-verify.bat | 84 ++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/scripts/win-audit-verify.bat b/scripts/win-audit-verify.bat index 1c667f5..a473070 100644 --- a/scripts/win-audit-verify.bat +++ b/scripts/win-audit-verify.bat @@ -1,42 +1,42 @@ -@echo off -REM win-audit-verify.bat — Verify audit-retry.mjs works on real Windows. -REM Must be run from a real Windows terminal (cmd.exe), NOT WSL. -REM Captures: commit, npm_execpath, full audit output, exit code. - -setlocal enabledelayedexpansion - -echo === win-audit-verify === -echo. - -REM 1. Show which commit is being tested -echo --- git head --- -git rev-parse HEAD -git log -1 --format=^"%%h %%s^" -echo. - -REM 2. Show Node version -echo --- node --- -node --version -echo. - -REM 3. Prove npm_execpath is set by npm run, and that a direct node.exe -REM invocation does NOT have it (the failure mode from the review). -echo --- npm_execpath probe via node -e (should be EMPTY) --- -node -e "console.log('npm_execpath=' + (process.env.npm_execpath || '(unset)'))" -echo. - -REM 4. Run the actual audit via npm run — this is the path that supplies npm_execpath. -echo --- npm run audit (full output) --- -echo [win-audit-verify] starting npm run audit at %DATE% %TIME% -npm run audit -set AUDIT_RC=!ERRORLEVEL! -echo [win-audit-verify] finished at %DATE% %TIME% -echo. - -REM 5. Report exit code -echo --- result --- -echo [win-audit-verify] npm run audit exit code: !AUDIT_RC! -echo. - -endlocal -exit /b %AUDIT_RC% +@echo off +REM win-audit-verify.bat — Verify audit-retry.mjs works on real Windows. +REM Must be run from a real Windows terminal (cmd.exe), NOT WSL. +REM Captures: commit, npm_execpath, full audit output, exit code. + +setlocal enabledelayedexpansion + +echo === win-audit-verify === +echo. + +REM 1. Show which commit is being tested +echo --- git head --- +git rev-parse HEAD +git log -1 --format=^"%%h %%s^" +echo. + +REM 2. Show Node version +echo --- node --- +node --version +echo. + +REM 3. Prove npm_execpath is set by npm run, and that a direct node.exe +REM invocation does NOT have it (the failure mode from the review). +echo --- npm_execpath probe via node -e (should be EMPTY) --- +node -e "console.log('npm_execpath=' + (process.env.npm_execpath || '(unset)'))" +echo. + +REM 4. Run the actual audit via npm run — this is the path that supplies npm_execpath. +echo --- npm run audit (full output) --- +echo [win-audit-verify] starting npm run audit at %DATE% %TIME% +npm run audit +set AUDIT_RC=!ERRORLEVEL! +echo [win-audit-verify] finished at %DATE% %TIME% +echo. + +REM 5. Report exit code +echo --- result --- +echo [win-audit-verify] npm run audit exit code: !AUDIT_RC! +echo. + +endlocal +exit /b %AUDIT_RC% From a7c17eca5f315996de713f9aab6e3114eaf12e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:23:15 +0800 Subject: [PATCH 08/14] chore(web): refresh audited development dependencies (cherry picked from commit 11e02e21ef2c3dbd2dae26e4376872e54e78ecb5) --- web/package-lock.json | 100 +++++++++++++++++++++--------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 103ae42..28a95f0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2161,16 +2161,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2179,13 +2179,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2206,9 +2206,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -2219,13 +2219,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -2233,14 +2233,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2249,9 +2249,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -2259,13 +2259,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3784,9 +3784,9 @@ "peer": true }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -4847,9 +4847,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6009,19 +6009,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6049,12 +6049,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" From d91deffaa794e6eb8e5c7f2400cc53990366d8cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:29:36 +0800 Subject: [PATCH 09/14] fix(web): retain successful dependency audit reports --- CHANGELOG.md | 5 +++-- web/audit-retry.mjs | 4 +++- web/audit-retry.test.mjs | 12 ++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a256b77..e883395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,8 +46,9 @@ The project publishes 0.x prerelease versions; a stable release line is not yet - CI Web job now runs unit tests (`npm test`), including audit retry regression tests. `npm run audit` retries recognized transient registry failures up to - three times per threshold, with a 60-second limit per attempt and portable - backoff. It starts npm through Node on Windows, preserves failure diagnostics, + three attempts per threshold (two retries), with a 60-second limit per attempt + and portable backoff. It starts npm through Node on Windows, preserves audit + reports and failure diagnostics, and fails immediately for vulnerabilities, unknown errors or incomplete runs. - The npm installer no longer aborts a concurrent first run on Windows. The per-asset cache lock previously treated only `EEXIST` as contention, but a diff --git a/web/audit-retry.mjs b/web/audit-retry.mjs index 3afe096..8ef1bfe 100644 --- a/web/audit-retry.mjs +++ b/web/audit-retry.mjs @@ -4,7 +4,7 @@ import { pathToFileURL } from "node:url"; const MAX_ATTEMPTS = 3; const BACKOFF_MS = 10_000; -// At most six attempts and four backoffs across both audit thresholds (~5m10s with 45s fetch timeout). +// At most six 60-second attempts and four 10-second backoffs across both thresholds. const ATTEMPT_TIMEOUT_MS = 60_000; const NETWORK_PATTERNS = [ @@ -76,6 +76,8 @@ async function runWithRetry(label, args, { spawn, wait, npmExecPath, stdout, std } if (result.status === 0) { + stdout.write(result.stdout ?? ""); + stderr.write(result.stderr ?? ""); stderr.write(`[audit-retry] ${label} passed.\n`); return 0; } diff --git a/web/audit-retry.test.mjs b/web/audit-retry.test.mjs index cd0776f..ef17dda 100644 --- a/web/audit-retry.test.mjs +++ b/web/audit-retry.test.mjs @@ -36,6 +36,18 @@ function harness(results, overrides = {}) { } describe("audit retry policy", () => { + it("preserves successful audit reports, including findings below the threshold", async () => { + const production = { status: 0, stdout: "found 0 vulnerabilities\n", stderr: "production notice\n" }; + const development = { status: 0, stdout: "# npm audit report\n1 moderate severity vulnerability\n", stderr: "development notice\n" }; + const test = harness([production, development]); + expect(await test.run()).toBe(0); + expect(test.output().stdout).toBe(production.stdout + development.stdout); + expect(test.output().stderr).toContain(production.stderr); + expect(test.output().stderr).toContain(development.stderr); + expect(test.spawn).toHaveBeenCalledTimes(2); + expect(test.wait).not.toHaveBeenCalled(); + }); + it("requires both unchanged thresholds to pass, using Node and the npm CLI path", async () => { const test = harness([PASS, PASS], { platform: "win32" }); expect(await test.run()).toBe(0); From 5c3a4a75ab96bed4c046c0822ebc76a87f215cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:41:46 +0800 Subject: [PATCH 10/14] fix(ci): preserve Windows audit evidence and exit status --- .github/workflows/ci.yml | 4 +++ scripts/test_win_audit_verify.mjs | 55 +++++++++++++++++++++++++++++++ scripts/win-audit-verify.bat | 5 ++- 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 scripts/test_win_audit_verify.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fec96b7..0a59ea5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -355,6 +355,10 @@ jobs: node --check platforms.js npm pack --dry-run --ignore-scripts + - name: Test Windows audit evidence helper + if: runner.os == 'Windows' + run: node --test scripts/test_win_audit_verify.mjs + deployment: name: Deployment profiles runs-on: ubuntu-24.04 diff --git a/scripts/test_win_audit_verify.mjs b/scripts/test_win_audit_verify.mjs new file mode 100644 index 0000000..0b7b5ae --- /dev/null +++ b/scripts/test_win_audit_verify.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +assert.equal(process.platform, "win32", "Run this process regression in the Windows CI job"); +const source = readFileSync(new URL("./win-audit-verify.bat", import.meta.url), "utf8"); + +function invoke(script, status) { + const directory = mkdtempSync(join(tmpdir(), "mem audit evidence ")); + try { + writeFileSync(join(directory, "verify.bat"), script); + // cmd.exe resolves the real .cmd fixture from its working directory. + // No registry, installed package, or user npm configuration is involved. + writeFileSync(join(directory, "npm.cmd"), + `@echo off\r\necho fixture npm audit status: ${status}\r\nexit /b ${status}\r\n`); + const result = spawnSync(process.env.ComSpec || "cmd.exe", ["/d", "/c", "verify.bat"], { + cwd: directory, + encoding: "utf8", + timeout: 15_000, + env: { ...process.env, AUDIT_RC: "" }, + }); + assert.ifError(result.error); + assert.match(result.stdout, new RegExp(`fixture npm audit status: ${status}`)); + return result; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +for (const status of [0, 7]) { + test(`prints completed evidence and preserves audit exit ${status}`, () => { + const result = invoke(source, status); + assert.equal(result.status, status); + assert.match(result.stdout, /\[win-audit-verify\] finished at/); + assert.match(result.stdout, new RegExp(`npm run audit exit code: ${status}`)); + }); +} + +test("negative control: omitting CALL loses the post-audit evidence", () => { + const broken = source.replace("call npm run audit", "npm run audit"); + assert.notEqual(broken, source); + const result = invoke(broken, 7); + assert.doesNotMatch(result.stdout, /\[win-audit-verify\] finished at/); +}); + +test("negative control: separate ENDLOCAL loses a nonzero saved exit status", () => { + const broken = source.replace("endlocal & exit /b %AUDIT_RC%", "endlocal\r\nexit /b %AUDIT_RC%"); + assert.notEqual(broken, source); + const result = invoke(broken, 7); + assert.match(result.stdout, /npm run audit exit code: 7/); + assert.equal(result.status, 0); +}); diff --git a/scripts/win-audit-verify.bat b/scripts/win-audit-verify.bat index a473070..e899083 100644 --- a/scripts/win-audit-verify.bat +++ b/scripts/win-audit-verify.bat @@ -28,7 +28,7 @@ echo. REM 4. Run the actual audit via npm run — this is the path that supplies npm_execpath. echo --- npm run audit (full output) --- echo [win-audit-verify] starting npm run audit at %DATE% %TIME% -npm run audit +call npm run audit set AUDIT_RC=!ERRORLEVEL! echo [win-audit-verify] finished at %DATE% %TIME% echo. @@ -38,5 +38,4 @@ echo --- result --- echo [win-audit-verify] npm run audit exit code: !AUDIT_RC! echo. -endlocal -exit /b %AUDIT_RC% +endlocal & exit /b %AUDIT_RC% From 62d650784c6c94db5da0bf858e8c83ac3c6bf3f5 Mon Sep 17 00:00:00 2001 From: waterbro-8 <318569545+waterbro-8@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:13:14 +0800 Subject: [PATCH 11/14] fix(ci): keep the audit transcript as a downloadable artifact The step log was the only copy of the audit report, and an attempt that retried left no trace at all. Tee the transcript under RUNNER_TEMP, upload it, and echo each retried attempt to stderr before backing off. The tee runs under shell: bash on purpose: the default bash -e {0} shell has no pipefail, so tee's exit status would have turned the dependency gate fail-open. --- .github/workflows/ci.yml | 15 ++++++++++++++- web/audit-retry.mjs | 7 ++++++- web/audit-retry.test.mjs | 20 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a59ea5..f98398c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,7 +251,20 @@ jobs: - name: Audit dependencies working-directory: web - run: npm run audit + # shell: bash is load-bearing: the default bash -e {0} shell has no + # pipefail, so tee would report its own exit status and a failing audit + # would pass this gate. + shell: bash + run: npm run audit 2>&1 | tee "${RUNNER_TEMP}/web-audit-transcript.txt" + + - name: Upload audit evidence + if: always() && !cancelled() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: web-audit-transcript-${{ github.sha }} + path: ${{ runner.temp }}/web-audit-transcript.txt + if-no-files-found: error + retention-days: 14 - name: Run unit tests working-directory: web diff --git a/web/audit-retry.mjs b/web/audit-retry.mjs index 8ef1bfe..923acfc 100644 --- a/web/audit-retry.mjs +++ b/web/audit-retry.mjs @@ -93,7 +93,12 @@ async function runWithRetry(label, args, { spawn, wait, npmExecPath, stdout, std if (isNetworkError(output)) { if (attempt < MAX_ATTEMPTS) { - stderr.write(`[audit-retry] ${label} hit a network error — will retry.\n`); + stderr.write( + `[audit-retry] ${label} hit a network error — will retry.\n` + + // An attempt that self-heals would otherwise leave no trace, and + // stdout stays reserved for the final audit report. + `[audit-retry] ${label} attempt ${attempt}/${MAX_ATTEMPTS} output:\n${output.trimEnd()}\n` + ); await wait(BACKOFF_MS); continue; } diff --git a/web/audit-retry.test.mjs b/web/audit-retry.test.mjs index ef17dda..994d67a 100644 --- a/web/audit-retry.test.mjs +++ b/web/audit-retry.test.mjs @@ -92,6 +92,16 @@ describe("audit retry policy", () => { expect(test.wait).toHaveBeenCalledTimes(1); }); + it("echoes the transcript of every retried attempt, on stderr only", async () => { + const transient = { status: 1, stdout: "registry notice\n", stderr: "network timeout at .../security/advisories/bulk\n" }; + const test = harness([transient, PASS, PASS]); + expect(await test.run()).toBe(0); + expect(test.output().stderr).toContain("attempt 1/3 output:"); + expect(test.output().stderr).toContain(transient.stdout); + expect(test.output().stderr).toContain(transient.stderr); + expect(test.output().stdout).not.toContain("registry notice"); + }); + it("caps retries at three, skips the final backoff, and retains final diagnostics", async () => { const failure = { status: 7, stdout: "final stdout\n", stderr: "503 Service Unavailable: final detail\n" }; const test = harness([failure, failure, failure]); @@ -172,6 +182,16 @@ describe("audit retry policy", () => { const config = readFileSync(configPath, "utf8"); expect(config).toContain("audit-retry.test.mjs"); }); + + it("ci.yml keeps the audit transcript pipe fail-closed and uploads it", async () => { + const ci = readFileSync(join(__dirname, "../.github/workflows/ci.yml"), "utf8"); + expect(ci).toMatch( + /- name: Audit dependencies\n(?:.*\n)*?\n\s+shell: bash\n\s+run: npm run audit 2>&1 \| tee "\$\{RUNNER_TEMP\}\/web-audit-transcript\.txt"/ + ); + expect(ci).toMatch( + /name: web-audit-transcript-\$\{\{ github\.sha \}\}\n\s+path: \$\{\{ runner\.temp \}\}\/web-audit-transcript\.txt\n\s+if-no-files-found: error/ + ); + }); }); describe("audit CLI process behavior", () => { From d594942408f2ad8fe1251677ca03e8fc5ec57ed7 Mon Sep 17 00:00:00 2001 From: waterbro-8 <318569545+waterbro-8@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:48:19 +0800 Subject: [PATCH 12/14] fix(ci): execute the Windows audit helper and repair its evidence test Closes the remaining review blockers on #198. The batch helper was dead code that the description advertised as evidence: nothing executed scripts/win-audit-verify.bat, only scripts/test_win_audit_verify.mjs ran, and that test replays the batch source against a stub npm.cmd. The repo does have a Windows runner (windows-2025 in the npm-wrapper-compatibility matrix), so the helper can be executed for real. Adds a dedicated `windows-audit-evidence` job that runs npm ci in web, executes the helper against the registry from web/ so `npm run` supplies npm_execpath, and uploads win-audit-transcript- with the same retention and if-no-files-found: error as the Linux transcript. The helper's fixture test moves into this job: it was gated behind `if: runner.os == 'Windows'` on a matrix entry whose removal would have deleted the regression silently. Replaces the "ci.yml keeps the audit transcript pipe fail-closed and uploads it" assertion added by 62d6507, which never passed: its regex required a blank line before `shell: bash`, and the step has comment lines there. It now splits the workflow into steps and asserts the properties directly - every transcript step uses shell: bash (measured: without pipefail a failing audit exits 0, with it exits 1), both transcript uploads use if: always() && !cancelled() plus if-no-files-found: error and retention-days: 14, and ci.yml stays free of continue-on-error and `|| true`. Adds a matching assertion that the batch helper is really executed on a Windows runner. --- .github/workflows/ci.yml | 48 ++++++++++++++++++++++++++++++++++++++- CHANGELOG.md | 13 ++++++++--- web/audit-retry.test.mjs | 49 +++++++++++++++++++++++++++++++++++----- 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f98398c..fdb22c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -368,10 +368,56 @@ jobs: node --check platforms.js npm pack --dry-run --ignore-scripts + windows-audit-evidence: + name: Windows audit evidence + # The batch helper exists to prove audit-retry.mjs works on a real cmd.exe, + # where only `npm run` supplies npm_execpath. Its regression test replays the + # batch source against a stub npm.cmd, so it proves the mechanics but never + # the execution. This job is the execution, and it is a job rather than an + # `if: runner.os == 'Windows'` step inside npm-wrapper-compatibility so the + # check name itself documents the Windows dependency. + runs-on: windows-2025 + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: "24" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install dependencies + working-directory: web + run: npm ci + - name: Test Windows audit evidence helper - if: runner.os == 'Windows' run: node --test scripts/test_win_audit_verify.mjs + - name: Run Windows audit evidence helper against the real registry + working-directory: web + # shell: bash is load-bearing: with the default shell there is no + # pipefail, so tee's exit status would replace cmd.exe's and the + # helper's nonzero-audit assertion would become unfalsifiable. + shell: bash + run: | + cmd.exe /d /c "..\scripts\win-audit-verify.bat" 2>&1 \ + | tee "${RUNNER_TEMP}/win-audit-transcript.txt" + + - name: Upload Windows audit evidence + if: always() && !cancelled() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: win-audit-transcript-${{ github.sha }} + path: ${{ runner.temp }}/win-audit-transcript.txt + if-no-files-found: error + retention-days: 14 + deployment: name: Deployment profiles runs-on: ubuntu-24.04 diff --git a/CHANGELOG.md b/CHANGELOG.md index ce91d40..9386794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,9 +78,16 @@ The project publishes 0.x prerelease versions; a stable release line is not yet - CI Web job now runs unit tests (`npm test`), including audit retry regression tests. `npm run audit` retries recognized transient registry failures up to three attempts per threshold (two retries), with a 60-second limit per attempt - and portable backoff. It starts npm through Node on Windows, preserves audit - reports and failure diagnostics, and fails immediately for vulnerabilities, - unknown errors or incomplete runs. + and portable backoff. It starts npm through Node on Windows, fails immediately + for vulnerabilities, unknown errors or incomplete runs, and echoes the output + of every attempt it retries so a self-healing failure still leaves a trace. + That transcript is uploaded as a downloadable artifact + (`web-audit-transcript-`, 14-day retention) on the success and failure + paths alike, and because the step merges stderr into stdout the artifact also + carries the per-attempt diagnostics. A dedicated `Windows audit evidence` job + runs the audit through `scripts/win-audit-verify.bat` on a real Windows runner + and uploads `win-audit-transcript-`, so the helper is executed rather + than only replayed against a stub by its fixture test. - A configured URL that carries credentials in a shape `url.Parse` does not report as userinfo no longer reaches output. `admin:pw@host` parses as `Scheme="admin"` with the credential in `Opaque` and `User` unset, so an diff --git a/web/audit-retry.test.mjs b/web/audit-retry.test.mjs index 994d67a..337ca39 100644 --- a/web/audit-retry.test.mjs +++ b/web/audit-retry.test.mjs @@ -185,12 +185,49 @@ describe("audit retry policy", () => { it("ci.yml keeps the audit transcript pipe fail-closed and uploads it", async () => { const ci = readFileSync(join(__dirname, "../.github/workflows/ci.yml"), "utf8"); - expect(ci).toMatch( - /- name: Audit dependencies\n(?:.*\n)*?\n\s+shell: bash\n\s+run: npm run audit 2>&1 \| tee "\$\{RUNNER_TEMP\}\/web-audit-transcript\.txt"/ - ); - expect(ci).toMatch( - /name: web-audit-transcript-\$\{\{ github\.sha \}\}\n\s+path: \$\{\{ runner\.temp \}\}\/web-audit-transcript\.txt\n\s+if-no-files-found: error/ - ); + const steps = ci.split(/\n(?= - name: )/); + + // Without `shell: bash` the default `bash -e {0}` has no pipefail, so tee's + // exit status would replace the audit's and a failing gate would pass. + const transcriptSteps = steps.filter((step) => step.includes('tee "${RUNNER_TEMP}/')); + expect(transcriptSteps.map((step) => step.match(/- name: (.+)/)[1])).toEqual([ + "Audit dependencies", + "Run Windows audit evidence helper against the real registry", + ]); + for (const step of transcriptSteps) { + expect(step).toMatch(/^[ ]+shell: bash$/m); + } + + // Evidence is only worth collecting if it also survives a red step. + for (const artifact of ["web-audit-transcript", "win-audit-transcript"]) { + const upload = steps.find((step) => step.includes(`name: ${artifact}-\${{ github.sha }}`)); + expect(upload).toBeDefined(); + expect(upload).toContain("if: always() && !cancelled()"); + expect(upload).toContain(`path: \${{ runner.temp }}/${artifact}.txt`); + expect(upload).toContain("if-no-files-found: error"); + expect(upload).toContain("retention-days: 14"); + } + + expect(ci).not.toMatch(/continue-on-error|\|\| true/); + }); + + it("ci.yml executes win-audit-verify.bat on a Windows runner", async () => { + const ci = readFileSync(join(__dirname, "../.github/workflows/ci.yml"), "utf8"); + // The helper's own test only replays its source text against a stub + // npm.cmd; a real invocation has to exist somewhere or the batch file is + // dead code that the PR description advertises as evidence. + expect(ci).toMatch(/cmd\.exe \/d \/c "\.\.\\scripts\\win-audit-verify\.bat"/); + const job = ci + .split(/\n(?= [a-z][a-z0-9-]*:\n)/) + .find((entry) => entry.includes("win-audit-verify.bat")) + // Judge configuration, not prose: the job's comments explain this very + // pattern, so quoting them would make the assertions self-defeating. + .split("\n") + .filter((line) => !/^\s*#/.test(line)) + .join("\n"); + expect(job).toMatch(/^ runs-on: windows-/m); + // The check must not be reachable only via a matrix entry that can vanish. + expect(job).not.toMatch(/if: runner\.os == 'Windows'/); }); }); From 4e1ea308a60456ea805ded858f99e49a813acea5 Mon Sep 17 00:00:00 2001 From: waterbro-8 <318569545+waterbro-8@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:02:01 +0800 Subject: [PATCH 13/14] fix(ci): stop the Windows audit job from passing on an empty transcript The job added in d594942 was green but collected nothing. Its step log shows cmd.exe printing its interactive banner and a bare prompt, then exiting 0: under the runner's git-bash, the leading /d and /c were rewritten to D:/ and C:/, so cmd.exe never received its option flags and win-audit-verify.bat never ran. The 267-byte artifact was the banner. Doubles the slashes so MSYS leaves the flags alone, and adds a gate that greps the transcript for the helper's starting line, its finished line, a recorded audit exit code of 0, and the report itself. cmd.exe's exit status is not proof of execution, and that is the difference between evidence and a green check. The step dumps the transcript and fails with ::error:: when a marker is missing. Pins the same property in the workflow assertion, since the previous assertion matched the invocation text and still passed while nothing executed. --- .github/workflows/ci.yml | 26 +++++++++++++++++++++++++- web/audit-retry.test.mjs | 6 +++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdb22c8..11263b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -406,9 +406,33 @@ jobs: # helper's nonzero-audit assertion would become unfalsifiable. shell: bash run: | - cmd.exe /d /c "..\scripts\win-audit-verify.bat" 2>&1 \ + # //d //c, not /d /c: git-bash rewrites a leading /d and /c into D:/ + # and C:/, which leaves cmd.exe with no option flags. It then prints + # its interactive banner and exits 0, so the helper never runs and the + # job goes green on an empty transcript. + cmd.exe //d //c "..\scripts\win-audit-verify.bat" 2>&1 \ | tee "${RUNNER_TEMP}/win-audit-transcript.txt" + - name: Verify the transcript is real evidence + shell: bash + # A successful cmd.exe proves nothing on its own; the transcript has to + # show the helper actually reached its report and gated on a passing + # audit, or this job would silently certify nothing. + run: | + transcript="${RUNNER_TEMP}/win-audit-transcript.txt" + for needle in \ + "\[win-audit-verify\] starting npm run audit" \ + "\[win-audit-verify\] finished at" \ + "\[win-audit-verify\] npm run audit exit code: 0" \ + "found 0 vulnerabilities" + do + if ! grep -q -- "$needle" "$transcript"; then + echo "::error::win-audit-verify.bat did not produce '$needle'; the Windows audit evidence is not real." + sed -n '1,40p' "$transcript" + exit 1 + fi + done + - name: Upload Windows audit evidence if: always() && !cancelled() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/web/audit-retry.test.mjs b/web/audit-retry.test.mjs index 337ca39..1a4c560 100644 --- a/web/audit-retry.test.mjs +++ b/web/audit-retry.test.mjs @@ -216,7 +216,11 @@ describe("audit retry policy", () => { // The helper's own test only replays its source text against a stub // npm.cmd; a real invocation has to exist somewhere or the batch file is // dead code that the PR description advertises as evidence. - expect(ci).toMatch(/cmd\.exe \/d \/c "\.\.\\scripts\\win-audit-verify\.bat"/); + expect(ci).toMatch(/cmd\.exe \/\/d \/\/c "\.\.\\scripts\\win-audit-verify\.bat"/); + // Matching the invocation text alone was not enough: the first version of + // this step passed CI on a cmd.exe that never ran the helper. + expect(ci).toMatch(/\[win-audit-verify\\\] finished at/); + expect(ci).toMatch(/npm run audit exit code: 0/); const job = ci .split(/\n(?= [a-z][a-z0-9-]*:\n)/) .find((entry) => entry.includes("win-audit-verify.bat")) From dc865b2a903a66209eb715f4bca512d91bd0eac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:09:40 +0800 Subject: [PATCH 14/14] chore: resolve CHANGELOG conflict against main (rebase #198) Incorporate the three Unreleased Fixed entries merged to main after this branch was cut (design language #211, web contrast, MinIO quay.io #209) ahead of the CI audit entries from this PR. No content change to any of the existing entries. --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9386794..f00dff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,27 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ### Fixed +- Follow the shared design language for reading, numeric and action alignment; generate the existing Web color variables from a pinned design-system token snapshot, and use a single consistent empty-state pattern. Refs #211. + +- Improve web caption and status contrast in both themes, including tinted danger + buttons, and center action labels, context menus, badges, dialog prompts, and + overview/detail headings. Restore localized cancel/confirm labels when a + confirmation dialog caller omits custom action text. + +- Every MinIO image reference in the test stack, the local development stack and + the self-hosted single-node Compose profile now resolves from `quay.io` instead + of Docker Hub. MinIO stopped publishing container images in October 2025 and + removed the `minio/minio` and `minio/mc` repositories from Docker Hub, so an + anonymous `docker compose up` fails with `pull access denied for minio/minio, + repository does not exist or may require 'docker login'`. Because `Validate + Agent memory` → `HTTP, CLI and MCP lifecycle` is a required status context, + that registry withdrawal blocked every pull request from merging (`#207`). + `quay.io` still serves the exact digests pinned in `docker-compose.test.yml`, + so no image bytes change: the digest-pinned test stack keeps its digests and + the release-tagged deployment stack keeps its tags — only the registry host + differs. `deploy/compose/compose.yaml` previously carried the same broken + reference, so the documented self-hosted path would have failed on a cold + host even though no workflow exercises it. - CI Web job now runs unit tests (`npm test`), including audit retry regression tests. `npm run audit` retries recognized transient registry failures up to three attempts per threshold (two retries), with a 60-second limit per attempt