Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -351,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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ 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 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
contended `mkdir` on Windows may raise `EPERM` or `EACCES`, so a process
Expand Down
55 changes: 55 additions & 0 deletions scripts/test_win_audit_verify.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
41 changes: 41 additions & 0 deletions scripts/win-audit-verify.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
@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%
call 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%
133 changes: 133 additions & 0 deletions web/audit-retry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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 60-second attempts and four 10-second backoffs across both thresholds.
const ATTEMPT_TIMEOUT_MS = 60_000;

const NETWORK_PATTERNS = [
"network timeout",
"503 Service Unavailable",
"ECONNRESET",
"ETIMEDOUT",
];

const VULNERABILITY_PATTERNS = [
"found \\d+ vulnerabilit(?:y|ies)",
"npm audit report",
"vulnerabilities found",
];

const COMMANDS = [
{
label: "production dependencies (moderate threshold)",
args: ["audit", "--omit=dev", "--audit-level=moderate", "--fetch-timeout=45000"],
},
{
label: "all dependencies (high threshold)",
args: ["audit", "--audit-level=high", "--fetch-timeout=45000"],
},
];

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));
}

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();
stderr.write(
`[audit-retry] ${ts} — ${label} (attempt ${attempt}/${MAX_ATTEMPTS})\n`
);

// 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) {
stdout.write(result.stdout ?? "");
stderr.write(result.stderr ?? "");
stderr.write(`[audit-retry] ${label} passed.\n`);
return 0;
}

const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;

if (isVulnerabilityReport(output)) {
stderr.write(
`[audit-retry] ${label} found real vulnerabilities — not retrying.\n`
);
return finish();
}

if (isNetworkError(output)) {
if (attempt < MAX_ATTEMPTS) {
stderr.write(`[audit-retry] ${label} hit a network error — will retry.\n`);
await wait(BACKOFF_MS);
continue;
}
stderr.write(`[audit-retry] ${label} exhausted ${MAX_ATTEMPTS} attempts.\n`);
return finish();
}

stderr.write(
`[audit-retry] ${label} failed with unrecognized error — not retrying.\n`
);
return finish();
}
}

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 = await runWithRetry(cmd.label, cmd.args, { spawn, wait, npmExecPath, stdout, stderr });
if (code !== 0) return code;
}
return 0;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exitCode = await runAudits();
}
Loading
Loading