Skip to content

Commit 234bb5a

Browse files
committed
fix(ci): retry transient dependency audit failures
1 parent c024e5d commit 234bb5a

3 files changed

Lines changed: 195 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
],
1919
"scripts": {
2020
"prepare": "bash scripts/setup-githooks.sh",
21-
"audit": "bun audit",
21+
"audit": "bun scripts/audit.ts",
2222
"lint": "biome check . --diagnostic-level=warn --error-on-warnings",
2323
"lint:fix": "biome check --write .",
2424
"lint:changed": "bash scripts/lint-changed.sh",

scripts/audit.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { readFileSync } from "node:fs";
3+
import { type AuditResult, isTransientAuditFailure, runAudit } from "./audit.ts";
4+
import { profileSteps } from "./verify.ts";
5+
6+
const connectionClosed: AuditResult = {
7+
exitCode: 1,
8+
stdout: "",
9+
stderr: "bun audit v1.3.5 (1e86cebd)\nConnectionClosed: audit request failed\n",
10+
};
11+
const success: AuditResult = { exitCode: 0, stdout: "No vulnerabilities found\n", stderr: "" };
12+
13+
function auditSequence(results: AuditResult[]) {
14+
let attempts = 0;
15+
const delays: number[] = [];
16+
const stdout: string[] = [];
17+
const stderr: string[] = [];
18+
return {
19+
get attempts() {
20+
return attempts;
21+
},
22+
delays,
23+
stdout,
24+
stderr,
25+
dependencies: {
26+
execute: () => {
27+
const result = results[attempts++];
28+
if (!result) throw new Error("Unexpected audit attempt");
29+
return result;
30+
},
31+
sleep: async (delayMs: number) => {
32+
delays.push(delayMs);
33+
},
34+
writeStdout: (output: string) => stdout.push(output),
35+
writeStderr: (output: string) => stderr.push(output),
36+
},
37+
};
38+
}
39+
40+
describe("dependency audit retries", () => {
41+
test("keeps successful audits unchanged without waiting", async () => {
42+
const sequence = auditSequence([success]);
43+
expect(await runAudit(sequence.dependencies)).toBe(0);
44+
expect(sequence.attempts).toBe(1);
45+
expect(sequence.delays).toEqual([]);
46+
expect(sequence.stdout).toEqual([success.stdout]);
47+
});
48+
49+
test("retries the CI ConnectionClosed error with bounded backoff", async () => {
50+
const sequence = auditSequence([connectionClosed, connectionClosed, success]);
51+
expect(await runAudit(sequence.dependencies)).toBe(0);
52+
expect(sequence.attempts).toBe(3);
53+
expect(sequence.delays).toEqual([2_000, 5_000]);
54+
expect(sequence.stderr.join("")).toContain(connectionClosed.stderr);
55+
expect(sequence.stderr.join("")).toContain("retrying (3/3)");
56+
});
57+
58+
test("still fails after three unsuccessful network attempts", async () => {
59+
const sequence = auditSequence([connectionClosed, connectionClosed, connectionClosed]);
60+
expect(await runAudit(sequence.dependencies)).toBe(1);
61+
expect(sequence.attempts).toBe(3);
62+
expect(sequence.delays).toEqual([2_000, 5_000]);
63+
expect(sequence.stderr.join("")).toContain("verification remains failed");
64+
});
65+
66+
test("does not retry vulnerability findings, even after a transient error", async () => {
67+
const finding = { exitCode: 1, stdout: "1 vulnerability (1 high)\n", stderr: "" };
68+
const sequence = auditSequence([connectionClosed, finding]);
69+
expect(await runAudit(sequence.dependencies)).toBe(1);
70+
expect(sequence.attempts).toBe(2);
71+
expect(sequence.delays).toEqual([2_000]);
72+
expect(sequence.stdout).toContain(finding.stdout);
73+
});
74+
75+
test("does not retry unknown errors, auth failures, invalid lockfiles, or interrupted processes", async () => {
76+
for (const result of [
77+
{ exitCode: 2, stdout: "", stderr: "error: invalid lockfile\n" },
78+
{ exitCode: 1, stdout: "", stderr: "HTTP 401 Unauthorized\n" },
79+
{ exitCode: 1, stdout: "", stderr: "unknown audit error\n" },
80+
{ ...connectionClosed, errorCode: "ENOENT" },
81+
{ ...connectionClosed, signal: "SIGINT" },
82+
]) {
83+
const sequence = auditSequence([result]);
84+
expect(await runAudit(sequence.dependencies)).toBe(result.exitCode);
85+
expect(sequence.attempts).toBe(1);
86+
expect(sequence.delays).toEqual([]);
87+
}
88+
});
89+
90+
test("handles ANSI banners but does not match errors embedded in vulnerability reports", () => {
91+
expect(isTransientAuditFailure({ ...connectionClosed, stderr: `\x1b[1m${connectionClosed.stderr}\x1b[0m` })).toBe(
92+
true,
93+
);
94+
expect(isTransientAuditFailure({ ...connectionClosed, stdout: "1 vulnerability (1 high)\n" })).toBe(false);
95+
expect(
96+
isTransientAuditFailure({ ...connectionClosed, stderr: "high: ConnectionClosed: audit request failed\n" }),
97+
).toBe(false);
98+
});
99+
100+
test("bounds retries of timed-out audit processes", async () => {
101+
const timeout = { exitCode: 1, stdout: "", stderr: "", errorCode: "ETIMEDOUT", signal: "SIGTERM" };
102+
const sequence = auditSequence([timeout, timeout, timeout]);
103+
expect(await runAudit(sequence.dependencies)).toBe(1);
104+
expect(sequence.attempts).toBe(3);
105+
expect(sequence.stderr.join("")).toContain("ETIMEDOUT");
106+
expect(isTransientAuditFailure({ ...timeout, stdout: "1 vulnerability (1 high)\n" })).toBe(false);
107+
});
108+
109+
test("routes both full and release verification through the audit wrapper", () => {
110+
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
111+
expect(manifest.scripts.audit).toBe("bun scripts/audit.ts");
112+
expect(profileSteps("full")).toContain("audit");
113+
expect(profileSteps("release")).toContain("audit");
114+
});
115+
});

scripts/audit.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { spawnSync } from "node:child_process";
2+
import { resolve } from "node:path";
3+
import { stripVTControlCharacters } from "node:util";
4+
5+
export interface AuditResult {
6+
exitCode: number;
7+
stdout: string;
8+
stderr: string;
9+
errorCode?: string;
10+
signal?: string;
11+
}
12+
13+
const retryDelaysMs = [2_000, 5_000] as const;
14+
const root = resolve(import.meta.dirname, "..");
15+
16+
export function isTransientAuditFailure(result: AuditResult): boolean {
17+
if (result.exitCode === 0) return false;
18+
19+
// Only retry a standalone transport error, optionally preceded by Bun's banner.
20+
// A vulnerability report or any unknown error must remain an immediate failure.
21+
const diagnostics = stripVTControlCharacters(`${result.stdout}\n${result.stderr}`)
22+
.split(/\r?\n/)
23+
.map((line) => line.trim())
24+
.filter((line) => line && !/^bun audit(?: v\d+\.\d+\.\d+(?:-[\w.-]+)?(?: \([a-f0-9]+\))?)?$/.test(line));
25+
const transportError =
26+
diagnostics.length === 1 &&
27+
/^(?:ConnectionClosed|ConnectionReset|ConnectionRefused|Timeout): audit request failed$/.test(diagnostics[0]);
28+
if (result.errorCode === "ETIMEDOUT") return diagnostics.length === 0 || transportError;
29+
if (result.errorCode || result.signal) return false;
30+
return transportError;
31+
}
32+
33+
function executeAudit(): AuditResult {
34+
const result = spawnSync(process.execPath, ["audit"], {
35+
cwd: root,
36+
encoding: "utf8",
37+
stdio: ["ignore", "pipe", "pipe"],
38+
timeout: 45_000,
39+
maxBuffer: 16 * 1024 * 1024,
40+
});
41+
return {
42+
exitCode: result.status ?? 1,
43+
stdout: result.stdout ?? "",
44+
stderr: result.stderr ?? "",
45+
errorCode: (result.error as NodeJS.ErrnoException | undefined)?.code,
46+
signal: result.signal ?? undefined,
47+
};
48+
}
49+
50+
export async function runAudit(
51+
dependencies: {
52+
execute?: () => AuditResult;
53+
sleep?: (delayMs: number) => Promise<void>;
54+
writeStdout?: (output: string) => void;
55+
writeStderr?: (output: string) => void;
56+
} = {},
57+
): Promise<number> {
58+
const execute = dependencies.execute ?? executeAudit;
59+
const sleep = dependencies.sleep ?? Bun.sleep;
60+
const writeStdout = dependencies.writeStdout ?? ((output) => process.stdout.write(output));
61+
const writeStderr = dependencies.writeStderr ?? ((output) => process.stderr.write(output));
62+
63+
for (let attempt = 0; ; attempt++) {
64+
const result = execute();
65+
writeStdout(result.stdout);
66+
writeStderr(result.stderr);
67+
if (result.errorCode) writeStderr(`Audit process failed (${result.errorCode}).\n`);
68+
if (!isTransientAuditFailure(result)) return result.exitCode;
69+
if (attempt === retryDelaysMs.length) {
70+
writeStderr("Dependency audit could not complete after 3 attempts; verification remains failed.\n");
71+
return result.exitCode;
72+
}
73+
const delayMs = retryDelaysMs[attempt];
74+
writeStderr(`Audit request failed temporarily; retrying (${attempt + 2}/3) in ${delayMs / 1000}s.\n`);
75+
await sleep(delayMs);
76+
}
77+
}
78+
79+
if (import.meta.main) process.exit(await runAudit());

0 commit comments

Comments
 (0)