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.

g1t.sh
93 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Every install in g1t's workflows and deploy tool goes through scripts/deploy/npm-ci.mjs, which runs npm ci again when a connection drops: npm does not retry a body that breaks off midway, so an ECONNRESET or network aborted, an install that ends without saying why, or one that prints nothing for 12 minutes is tried again, up to three times; a real error, such as a lockfile out of step, still fails at once. A test keeps every workflow on it.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 = /\b(ECONNRESET|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|EAI_AGAIN|EPIPE|ERR_SOCKET_TIMEOUT)\b|network aborted|socket hang up|network timeout|request to \S+ failed/i;
25
26/**
27 * Whether a failed install is worth running again: its output names a
28 * network failure, it stalled, or it failed without an `npm error` line
29 * that says why (a dropped connection can end npm before it writes one).
30 */
31export function retryable(output, { stalled = false } = {}) {
32 if (stalled) return true;
33 if (NETWORK.test(output)) return true;
34 return !/^npm (error|ERR!)/m.test(output);
35}
36
37/** The npm arguments for these flags: `ci` and the flags as given. */
38export function npmArgs(flags) {
39 return ["ci", ...flags];
40}
41
42/** One install: its exit code, what it printed, and whether it stalled. */
43function attempt(args, { stallMs, onLine }) {
44 return new Promise((resolve) => {
45 const child = spawn("npm", args, { shell: process.platform === "win32", stdio: ["ignore", "pipe", "pipe"] });
46 let output = "";
47 let stalled = false;
48 let timer;
49 const quiet = () => {
50 clearTimeout(timer);
51 timer = setTimeout(() => {
52 stalled = true;
53 child.kill("SIGKILL");
54 }, stallMs);
55 };
56 quiet();
57 const take = (stream, out) =>
58 stream.on("data", (chunk) => {
59 const text = chunk.toString();
60 output += text;
61 out.write(text);
62 onLine?.(text);
63 quiet();
64 });
65 take(child.stdout, process.stdout);
66 take(child.stderr, process.stderr);
67 child.on("close", (code) => {
68 clearTimeout(timer);
69 resolve({ code: stalled ? 1 : (code ?? 1), output, stalled });
70 });
71 });
72}
73
74/** Runs `npm ci` with `flags`, again when it fails in a way worth retrying. Resolves to its exit code. */
75export async function npmCi(flags, { attempts = ATTEMPTS, waits = WAITS_MS, stallMs = STALL_MS, run = attempt, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), log = console.error } = {}) {
76 const args = npmArgs(flags);
77 for (let tries = 1; ; tries++) {
78 const done = await run(args, { stallMs });
79 if (done.code === 0) return 0;
80 const why = done.stalled ? `printed nothing for ${Math.round(stallMs / 1000)} s` : `exited ${done.code}`;
81 if (tries >= attempts || !retryable(done.output, done)) {
82 log(`npm ci ${why}${tries > 1 ? ` on try ${tries} of ${attempts}` : ""}; not trying again.`);
83 return done.code || 1;
84 }
85 const wait = waits[Math.min(tries - 1, waits.length - 1)];
86 log(`npm ci ${why}, which looks like a dropped connection: trying again in ${wait / 1000} s (try ${tries + 1} of ${attempts}).`);
87 await sleep(wait);
88 }
89}
90
91if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
92 process.exitCode = await npmCi(process.argv.slice(2));
93}