Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

97 linesCodeBlameRaw
1// `npm ci` that survives a dropped connection: run as
2//
3// node scripts/deploy/npm-ci.mjs [npm ci's flags…]
4//
5// npm retries a request that fails before its answer starts, but not one
6// whose body breaks off midway (`ECONNRESET`, `network aborted`), and an
7// install can also stall with nothing more arriving. Either fails a whole
8// job that passes when run again. So an install whose output names a
9// network failure, or that ends without saying why, is run again (up to
10// `ATTEMPTS` times, after `WAITS_MS`), and one that goes `STALL_MS`
11// without a line of output is stopped and run again. npm's cache keeps
12// what the failed attempt already fetched, so a second try is quick.
13// Anything else, such as a lockfile out of step, fails at once.
14
15import { spawn } from "node:child_process";
16import { fileURLToPath } from "node:url";
17
18export const ATTEMPTS = 3;
19export const WAITS_MS = [5_000, 15_000];
20/** How long an install may print nothing before it counts as stalled. */
21export const STALL_MS = Number(process.env.G1T_NPM_STALL_MS) || 12 * 60_000;
22
23/** npm's words for a connection that failed: worth trying again. */
24const NETWORK =
25 /\b(ECONNRESET|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|EAI_AGAIN|EPIPE|ERR_SOCKET_TIMEOUT)\b|network aborted|socket hang up|network timeout|request to \S+ failed/i;
26
27/**
28 * Whether a failed install is worth running again: its output names a
29 * network failure, it stalled, or it failed without an `npm error` line
30 * that says why (a dropped connection can end npm before it writes one).
31 */
32export function retryable(output, { stalled = false } = {}) {
33 if (stalled) return true;
34 if (NETWORK.test(output)) return true;
35 return !/^npm (error|ERR!)/m.test(output);
36}
37
38/** The npm arguments for these flags: `ci` and the flags as given. */
39export function npmArgs(flags) {
40 return ["ci", ...flags];
41}
42
43/** One install: its exit code, what it printed, and whether it stalled. */
44function attempt(args, { stallMs, onLine }) {
45 return new Promise((resolve) => {
46 const child = spawn("npm", args, { shell: process.platform === "win32", stdio: ["ignore", "pipe", "pipe"] });
47 let output = "";
48 let stalled = false;
49 let timer;
50 const quiet = () => {
51 clearTimeout(timer);
52 timer = setTimeout(() => {
53 stalled = true;
54 child.kill("SIGKILL");
55 }, stallMs);
56 };
57 quiet();
58 const take = (stream, out) =>
59 stream.on("data", (chunk) => {
60 const text = chunk.toString();
61 output += text;
62 out.write(text);
63 onLine?.(text);
64 quiet();
65 });
66 take(child.stdout, process.stdout);
67 take(child.stderr, process.stderr);
68 child.on("close", (code) => {
69 clearTimeout(timer);
70 resolve({ code: stalled ? 1 : (code ?? 1), output, stalled });
71 });
72 });
73}
74
75/** Runs `npm ci` with `flags`, again when it fails in a way worth retrying. Resolves to its exit code. */
76export async function npmCi(
77 flags,
78 { attempts = ATTEMPTS, waits = WAITS_MS, stallMs = STALL_MS, run = attempt, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), log = console.error } = {},
79) {
80 const args = npmArgs(flags);
81 for (let tries = 1; ; tries++) {
82 const done = await run(args, { stallMs });
83 if (done.code === 0) return 0;
84 const why = done.stalled ? `printed nothing for ${Math.round(stallMs / 1000)} s` : `exited ${done.code}`;
85 if (tries >= attempts || !retryable(done.output, done)) {
86 log(`npm ci ${why}${tries > 1 ? ` on try ${tries} of ${attempts}` : ""}; not trying again.`);
87 return done.code || 1;
88 }
89 const wait = waits[Math.min(tries - 1, waits.length - 1)];
90 log(`npm ci ${why}, which looks like a dropped connection: trying again in ${wait / 1000} s (try ${tries + 1} of ${attempts}).`);
91 await sleep(wait);
92 }
93}
94
95if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
96 process.exitCode = await npmCi(process.argv.slice(2));
97}