Skip to content

Commit

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.

syntaqxcommitted Parent602344bBrowse files
12 files+720−940/12 viewed
+2−2
8282 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
8383 restore-keys: npm-full-${{ runner.os }}-
8484 - name: Install
85− run: npm ci --prefer-offline --no-audit --no-fund
85+ run: node scripts/deploy/npm-ci.mjs --prefer-offline --no-audit --no-fund
8686 - name: Type checks
8787 run: npm run typecheck
8888 - name: Tests
115115 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
116116 restore-keys: npm-full-${{ runner.os }}-
117117 - name: Install
118− run: npm ci --prefer-offline --no-audit --no-fund
118+ run: node scripts/deploy/npm-ci.mjs --prefer-offline --no-audit --no-fund
119119 - name: The site, sudo and the docs
120120 run: node scripts/deploy.mjs build --only web,sudo,docs
121121
+1−1
8080 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
8181 restore-keys: npm-full-${{ runner.os }}-
8282 - name: Install Wrangler
83− run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
83+ run: node scripts/deploy/npm-ci.mjs --prefer-offline --workspaces=false --no-audit --no-fund
8484 - name: Publish to g1t.sh/downloads/cli
8585 env:
8686 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+3−3
8282 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
8383 restore-keys: npm-full-${{ runner.os }}-
8484 - name: Install Wrangler
85− run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
85+ run: node scripts/deploy/npm-ci.mjs --prefer-offline --workspaces=false --no-audit --no-fund
8686 - name: The manifest matches every wrangler.jsonc
8787 run: node scripts/deploy.mjs manifest --check
8888 - name: The deploy tool's tests
119119 id: download
120120 if: ${{ steps.saved.outputs.cache-hit != 'true' }}
121121 continue-on-error: true
122− run: npm ci --prefer-offline --ignore-scripts --no-audit --no-fund
122+ run: node scripts/deploy/npm-ci.mjs --prefer-offline --ignore-scripts --no-audit --no-fund
123123 - name: Save them
124124 if: ${{ steps.saved.outputs.cache-hit != 'true' && steps.download.outcome == 'success' }}
125125 uses: actions/cache/save@v4
185185 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
186186 restore-keys: npm-full-${{ runner.os }}-
187187 - name: Install Wrangler
188− run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
188+ run: node scripts/deploy/npm-ci.mjs --prefer-offline --workspaces=false --no-audit --no-fund
189189 - name: Apply pending migrations
190190 env:
191191 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+5−5
100100 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
101101 restore-keys: npm-full-${{ runner.os }}-
102102 - name: Install
103− run: npm ci --prefer-offline --no-audit --no-fund
103+ run: node scripts/deploy/npm-ci.mjs --prefer-offline --no-audit --no-fund
104104 - name: Build and sign
105105 env:
106106 TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.DESKTOP_SIGNING_KEY }}
138138 }
139139 "$env:USERPROFILE\.cargo\bin" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
140140 - name: Install
141− run: npm ci --no-audit --no-fund
141+ run: node scripts/deploy/npm-ci.mjs --no-audit --no-fund
142142 - name: Build and sign
143143 env:
144144 TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.DESKTOP_SIGNING_KEY }}
215215 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
216216 restore-keys: npm-full-${{ runner.os }}-
217217 - name: Install
218− run: npm ci --prefer-offline --no-audit --no-fund
218+ run: node scripts/deploy/npm-ci.mjs --prefer-offline --no-audit --no-fund
219219 - name: Build and sign
220220 env:
221221 TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.DESKTOP_SIGNING_KEY }}
243243 steps:
244244 - uses: actions/checkout@v5
245245 - name: Install
246− run: npm ci --no-audit --no-fund
246+ run: node scripts/deploy/npm-ci.mjs --no-audit --no-fund
247247 - name: Build and sign, for Apple silicon and Intel
248248 env:
249249 TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.DESKTOP_SIGNING_KEY }}
294294 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
295295 restore-keys: npm-full-${{ runner.os }}-
296296 - name: Install Wrangler
297− run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
297+ run: node scripts/deploy/npm-ci.mjs --prefer-offline --workspaces=false --no-audit --no-fund
298298 - name: The manifest
299299 run: node scripts/desktop-release.mjs manifest --notes "${{ inputs.notes }}"
300300 - name: Publish
+1−1
6464 restore-keys: npm-full-${{ runner.os }}-
6565 # EAS reads app.config.ts through the app's own `expo`.
6666 - name: Install the app's dependencies
67− run: npm ci --prefer-offline --no-audit --no-fund -w @g1t/mobile
67+ run: node scripts/deploy/npm-ci.mjs --prefer-offline --no-audit --no-fund -w @g1t/mobile
6868 - name: Build on EAS
6969 working-directory: apps/mobile
7070 env:
+1−1
7272 # Wrangler, from the root, and the app's own (EAS reads app.config.ts
7373 # through its `expo`).
7474 - name: Install Wrangler and the app
75− run: npm ci --prefer-offline --no-audit --no-fund -w @g1t/mobile --include-workspace-root
75+ run: node scripts/deploy/npm-ci.mjs --prefer-offline --no-audit --no-fund -w @g1t/mobile --include-workspace-root
7676 - name: Build on EAS
7777 if: ${{ !inputs.build }}
7878 env:
+1−1
6767 steps:
6868 - uses: actions/checkout@v5
6969 - name: Install Wrangler
70− run: npm ci --workspaces=false --no-audit --no-fund
70+ run: node scripts/deploy/npm-ci.mjs --workspaces=false --no-audit --no-fund
7171 - name: Build and push
7272 env:
7373 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1−1
251251 key: npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
252252 restore-keys: npm-full-${{ runner.os }}-
253253 - name: Install Wrangler
254− run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
254+ run: node scripts/deploy/npm-ci.mjs --prefer-offline --workspaces=false --no-audit --no-fund
255255 - name: Publish to g1t.sh/downloads/runner
256256 env:
257257 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+551−78
2424 use g1t_actions::permissions::{Access, TokenDefault};
2525 let job = |name: &str, id: &str| {
2626 let workflow = read(name);
27− let job = workflow.jobs.iter().find(|job| job.id == id).unwrap().clone();
27+ let job = workflow
28+ .jobs
29+ .iter()
30+ .find(|job| job.id == id)
31+ .unwrap()
32+ .clone();
2833 job.permissions(&workflow, TokenDefault::Restricted)
2934 };
3035 // CI and Deploy only read: nothing they do writes with the token.
3136 // The actions release pushes with its own secret, never the job's token.
32− for (name, id) in [("ci.yml", "rust"), ("deploy.yml", "core"), ("runner-release.yml", "binaries"), ("actions-release.yml", "publish")] {
37+ for (name, id) in [
38+ ("ci.yml", "rust"),
39+ ("deploy.yml", "core"),
40+ ("runner-release.yml", "binaries"),
41+ ("actions-release.yml", "publish"),
42+ ] {
3343 let permissions = job(name, id);
34− assert!(permissions.listed().iter().all(|(_, access)| *access <= Access::Read), "{name} {id}");
44+ assert!(
45+ permissions
46+ .listed()
47+ .iter()
48+ .all(|(_, access)| *access <= Access::Read),
49+ "{name} {id}"
50+ );
3551 }
3652 // The runner base pushes a branch and opens a pull request.
3753 let base = job("runner-base.yml", "build");
5470 }
5571 let name = path.file_name().unwrap().to_string_lossy().to_string();
5672 let workflow = read(&name);
57− let unsupported: Vec<_> = workflow.notes.iter().filter(|n| n.severity != Severity::Info).collect();
73+ let unsupported: Vec<_> = workflow
74+ .notes
75+ .iter()
76+ .filter(|n| n.severity != Severity::Info)
77+ .collect();
5878 assert!(unsupported.is_empty(), "{name}: {unsupported:?}");
5979 count += 1;
6080 }
6282 }
6383
6484 /// The plan job's outputs for a deploy of `stages` (each with its jobs).
65−fn plan_outputs(migrate: bool, core: &[(&str, &str, bool)], edge: &[(&str, &str, bool)], front: &[(&str, &str, bool)]) -> Value {
85+fn plan_outputs(
86+ migrate: bool,
87+ core: &[(&str, &str, bool)],
88+ edge: &[(&str, &str, bool)],
89+ front: &[(&str, &str, bool)],
90+) -> Value {
6691 let matrix = |jobs: &[(&str, &str, bool)]| {
6792 let include: Vec<Value> = if jobs.is_empty() {
6893 vec![json!({ "group": "none", "units": "" })]
6994 } else {
70− jobs.iter().map(|(group, units, rust)| json!({ "group": group, "units": units, "rust": rust })).collect()
95+ jobs.iter()
96+ .map(|(group, units, rust)| json!({ "group": group, "units": units, "rust": rust }))
97+ .collect()
7198 };
7299 json!({ "include": include }).to_string()
73100 };
87114 /// decides it (services/actions/src/plan.rs `decide`, with
88115 /// `expr::job_status`): a need that was skipped is not a failure, and a
89116 /// failure anywhere before the job is.
90−fn starts(workflow: &Workflow, job: &str, needs: &[(&str, &str)], outputs: &Value, inputs: Value, cancelled: bool) -> bool {
117+fn starts(
118+ workflow: &Workflow,
119+ job: &str,
120+ needs: &[(&str, &str)],
121+ outputs: &Value,
122+ inputs: Value,
123+ cancelled: bool,
124+) -> bool {
91125 starts_after(workflow, job, needs, outputs, inputs, cancelled, false)
92126 }
93127
94128 /// `starts`, where `failed_before` says a job further back failed (one the
95129 /// needs were skipped for).
96−fn starts_after(workflow: &Workflow, job: &str, needs: &[(&str, &str)], outputs: &Value, inputs: Value, cancelled: bool, failed_before: bool) -> bool {
130+fn starts_after(
131+ workflow: &Workflow,
132+ job: &str,
133+ needs: &[(&str, &str)],
134+ outputs: &Value,
135+ inputs: Value,
136+ cancelled: bool,
137+ failed_before: bool,
138+) -> bool {
97139 let job = workflow.jobs.iter().find(|j| j.id == job).unwrap();
98140 let mut needs_ctx = Map::new();
99141 let mut results = Vec::new();
100142 for need in &job.needs {
101− let result = needs.iter().find(|(name, _)| name == need).map(|(_, r)| *r).unwrap_or("success");
143+ let result = needs
144+ .iter()
145+ .find(|(name, _)| name == need)
146+ .map(|(_, r)| *r)
147+ .unwrap_or("success");
102148 results.push(result);
103− let outputs = if need == "plan" { outputs.clone() } else { json!({}) };
104− needs_ctx.insert(need.clone(), json!({ "result": result, "outputs": outputs }));
149+ let outputs = if need == "plan" {
150+ outputs.clone()
151+ } else {
152+ json!({})
153+ };
154+ needs_ctx.insert(
155+ need.clone(),
156+ json!({ "result": result, "outputs": outputs }),
157+ );
105158 }
106159 let status = expr::job_status(results, failed_before, cancelled);
107160 let mut contexts = Map::new();
108161 contexts.insert("needs".into(), Value::Object(needs_ctx));
109162 contexts.insert("inputs".into(), inputs);
110− contexts.insert("github".into(), json!({ "event_name": "push", "ref": "refs/heads/main" }));
111− let scope = Scope { contexts: &contexts, status, hash_files: None };
163+ contexts.insert(
164+ "github".into(),
165+ json!({ "event_name": "push", "ref": "refs/heads/main" }),
166+ );
167+ let scope = Scope {
168+ contexts: &contexts,
169+ status,
170+ hash_files: None,
171+ };
112172 expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap()
113173 }
114174
125185 let concurrency = deploy.concurrency.as_ref().unwrap();
126186 assert_eq!(concurrency.group, "deploy-production");
127187 assert_eq!(concurrency.cancel_in_progress, json!(false));
128− assert_eq!(deploy.job_order(), ["check", "npm-cache", "plan", "migrate", "core", "edge", "front", "smoke"]);
188+ assert_eq!(
189+ deploy.job_order(),
190+ [
191+ "check",
192+ "npm-cache",
193+ "plan",
194+ "migrate",
195+ "core",
196+ "edge",
197+ "front",
198+ "smoke"
199+ ]
200+ );
129201 // Nothing waits for npm's downloads to be saved.
130− assert!(deploy.jobs.iter().all(|job| !job.needs.iter().any(|n| n == "npm-cache")));
202+ assert!(
203+ deploy
204+ .jobs
205+ .iter()
206+ .all(|job| !job.needs.iter().any(|n| n == "npm-cache"))
207+ );
131208 // The stages share their steps (a YAML alias), and read the token only
132209 // where they deploy.
133210 let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len();
142219 fn installs_everything(run: &str) -> bool {
143220 run.split('\n').any(|line| {
144221 let line = line.trim();
145− line.starts_with("npm ci") && !line.contains(" -w ") && !line.contains("--workspace")
222+ // Installs go through npm-ci.mjs, which runs `npm ci` again when a connection drops.
223+ (line.starts_with("npm ci") || line.starts_with("node scripts/deploy/npm-ci.mjs"))
224+ && !line.contains(" -w ")
225+ && !line.contains("--workspace")
146226 })
147227 }
148228
160240 }
161241 let name = path.file_name().unwrap().to_string_lossy().to_string();
162242 for job in read(&name).jobs {
163− let whole = job.steps.iter().any(|s| s.run.as_deref().is_some_and(installs_everything));
243+ let whole = job
244+ .steps
245+ .iter()
246+ .any(|s| s.run.as_deref().is_some_and(installs_everything));
164247 for step in &job.steps {
165− let Some(uses) = step.uses.as_deref() else { continue };
166− if !uses.starts_with("actions/cache") || step.raw["with"]["path"] != json!("~/.npm") {
248+ let Some(uses) = step.uses.as_deref() else {
249+ continue;
250+ };
251+ if !uses.starts_with("actions/cache") || step.raw["with"]["path"] != json!("~/.npm")
252+ {
167253 continue;
168254 }
169255 let key = step.raw["with"]["key"].as_str().unwrap_or_default();
170− assert_eq!(key, "npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}", "{name} {}", job.id);
256+ assert_eq!(
257+ key, "npm-full-${{ runner.os }}-${{ hashFiles('package-lock.json') }}",
258+ "{name} {}",
259+ job.id
260+ );
171261 if let Some(prefix) = step.raw["with"]["restore-keys"].as_str() {
172262 assert_eq!(prefix, "npm-full-${{ runner.os }}-", "{name} {}", job.id);
173263 }
174− let saves = uses.starts_with("actions/cache@") || uses.starts_with("actions/cache/save@");
175− assert!(!saves || whole, "{name} {}: saves npm's cache but installs only part of the lockfile", job.id);
264+ let saves =
265+ uses.starts_with("actions/cache@") || uses.starts_with("actions/cache/save@");
266+ assert!(
267+ !saves || whole,
268+ "{name} {}: saves npm's cache but installs only part of the lockfile",
269+ job.id
270+ );
176271 if saves {
177272 saving.push(format!("{name} {}", job.id));
178273 }
180275 }
181276 }
182277 // main's whole cache is kept by the deploy workflow, beside its check.
183− assert!(saving.contains(&"deploy.yml npm-cache".to_owned()), "{saving:?}");
278+ assert!(
279+ saving.contains(&"deploy.yml npm-cache".to_owned()),
280+ "{saving:?}"
281+ );
184282 }
185283
186284 #[test]
187285 fn deploy_stages_follow_one_another() {
188286 let deploy = read("deploy.yml");
189− let all = plan_outputs(true, &[("rust", "events,repos", true), ("ts", "projects", false)], &[("rust", "api", true)], &[("web", "web", false)]);
287+ let all = plan_outputs(
288+ true,
289+ &[("rust", "events,repos", true), ("ts", "projects", false)],
290+ &[("rust", "api", true)],
291+ &[("web", "web", false)],
292+ );
190293 let push = json!({});
191294
192295 // Everything succeeds: each stage runs after the last.
193296 assert!(starts(&deploy, "migrate", &[], &all, push.clone(), false));
194− assert!(starts(&deploy, "core", &[("migrate", "success")], &all, push.clone(), false));
195− assert!(starts(&deploy, "edge", &[("migrate", "success"), ("core", "success")], &all, push.clone(), false));
196− assert!(starts(&deploy, "front", &[("migrate", "success"), ("core", "success"), ("edge", "success")], &all, push.clone(), false));
297+ assert!(starts(
298+ &deploy,
299+ "core",
300+ &[("migrate", "success")],
301+ &all,
302+ push.clone(),
303+ false
304+ ));
305+ assert!(starts(
306+ &deploy,
307+ "edge",
308+ &[("migrate", "success"), ("core", "success")],
309+ &all,
310+ push.clone(),
311+ false
312+ ));
313+ assert!(starts(
314+ &deploy,
315+ "front",
316+ &[
317+ ("migrate", "success"),
318+ ("core", "success"),
319+ ("edge", "success")
320+ ],
321+ &all,
322+ push.clone(),
323+ false
324+ ));
197325
198326 // No migrations: the migrate job is skipped, and core still runs.
199− let none = plan_outputs(false, &[("ts", "projects", false)], &[], &[("web", "web", false)]);
327+ let none = plan_outputs(
328+ false,
329+ &[("ts", "projects", false)],
330+ &[],
331+ &[("web", "web", false)],
332+ );
200333 assert!(!starts(&deploy, "migrate", &[], &none, push.clone(), false));
201− assert!(starts(&deploy, "core", &[("migrate", "skipped")], &none, push.clone(), false));
334+ assert!(starts(
335+ &deploy,
336+ "core",
337+ &[("migrate", "skipped")],
338+ &none,
339+ push.clone(),
340+ false
341+ ));
202342 // An empty stage is skipped, and the next one still runs.
203− assert!(!starts(&deploy, "edge", &[("migrate", "skipped"), ("core", "success")], &none, push.clone(), false));
204− assert!(starts(&deploy, "front", &[("migrate", "skipped"), ("core", "success"), ("edge", "skipped")], &none, push.clone(), false));
343+ assert!(!starts(
344+ &deploy,
345+ "edge",
346+ &[("migrate", "skipped"), ("core", "success")],
347+ &none,
348+ push.clone(),
349+ false
350+ ));
351+ assert!(starts(
352+ &deploy,
353+ "front",
354+ &[
355+ ("migrate", "skipped"),
356+ ("core", "success"),
357+ ("edge", "skipped")
358+ ],
359+ &none,
360+ push.clone(),
361+ false
362+ ));
205363
206364 // A failure stops every later stage.
207− assert!(!starts(&deploy, "core", &[("migrate", "failure")], &all, push.clone(), false));
208− assert!(!starts(&deploy, "edge", &[("migrate", "success"), ("core", "failure")], &all, push.clone(), false));
209− assert!(!starts(&deploy, "front", &[("migrate", "success"), ("core", "success"), ("edge", "failure")], &all, push.clone(), false));
210− assert!(!starts(&deploy, "front", &[("migrate", "success"), ("core", "failure"), ("edge", "skipped")], &all, push.clone(), false));
365+ assert!(!starts(
366+ &deploy,
367+ "core",
368+ &[("migrate", "failure")],
369+ &all,
370+ push.clone(),
371+ false
372+ ));
373+ assert!(!starts(
374+ &deploy,
375+ "edge",
376+ &[("migrate", "success"), ("core", "failure")],
377+ &all,
378+ push.clone(),
379+ false
380+ ));
381+ assert!(!starts(
382+ &deploy,
383+ "front",
384+ &[
385+ ("migrate", "success"),
386+ ("core", "success"),
387+ ("edge", "failure")
388+ ],
389+ &all,
390+ push.clone(),
391+ false
392+ ));
393+ assert!(!starts(
394+ &deploy,
395+ "front",
396+ &[
397+ ("migrate", "success"),
398+ ("core", "failure"),
399+ ("edge", "skipped")
400+ ],
401+ &all,
402+ push.clone(),
403+ false
404+ ));
211405 // A cancelled run starts nothing more.
212− assert!(!starts(&deploy, "edge", &[("migrate", "success"), ("core", "success")], &all, push.clone(), true));
406+ assert!(!starts(
407+ &deploy,
408+ "edge",
409+ &[("migrate", "success"), ("core", "success")],
410+ &all,
411+ push.clone(),
412+ true
413+ ));
213414 // Check and plan run side by side: plan waits for nothing.
214415 let plan = deploy.jobs.iter().find(|j| j.id == "plan").unwrap();
215416 assert!(plan.needs.is_empty(), "{:?}", plan.needs);
216417 for id in ["migrate", "core", "edge", "front", "smoke"] {
217418 let job = deploy.jobs.iter().find(|j| j.id == id).unwrap();
218− assert!(job.needs.iter().any(|n| n == "check") && job.needs.iter().any(|n| n == "plan"), "{id}: {:?}", job.needs);
419+ assert!(
420+ job.needs.iter().any(|n| n == "check") && job.needs.iter().any(|n| n == "plan"),
421+ "{id}: {:?}",
422+ job.needs
423+ );
219424 }
220425 // A failed check, though plan succeeded: nothing migrates or deploys,
221426 // and failure() still sees the check's failure through skipped jobs.
222− assert!(!starts(&deploy, "migrate", &[("check", "failure"), ("plan", "success")], &all, push.clone(), false));
223− assert!(!starts(&deploy, "core", &[("check", "failure"), ("plan", "success"), ("migrate", "skipped")], &all, push.clone(), false));
224− assert!(!starts(&deploy, "front", &[("check", "failure"), ("plan", "success"), ("migrate", "skipped"), ("core", "skipped"), ("edge", "skipped")], &all, push.clone(), false));
225− let skipped = [("migrate", "skipped"), ("core", "skipped"), ("edge", "skipped")];
226− assert!(!starts_after(&deploy, "front", &skipped, &all, push.clone(), false, true));
227− assert!(!starts(&deploy, "smoke", &[("check", "failure"), ("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], &all, push.clone(), false));
427+ assert!(!starts(
428+ &deploy,
429+ "migrate",
430+ &[("check", "failure"), ("plan", "success")],
431+ &all,
432+ push.clone(),
433+ false
434+ ));
435+ assert!(!starts(
436+ &deploy,
437+ "core",
438+ &[
439+ ("check", "failure"),
440+ ("plan", "success"),
441+ ("migrate", "skipped")
442+ ],
443+ &all,
444+ push.clone(),
445+ false
446+ ));
447+ assert!(!starts(
448+ &deploy,
449+ "front",
450+ &[
451+ ("check", "failure"),
452+ ("plan", "success"),
453+ ("migrate", "skipped"),
454+ ("core", "skipped"),
455+ ("edge", "skipped")
456+ ],
457+ &all,
458+ push.clone(),
459+ false
460+ ));
461+ let skipped = [
462+ ("migrate", "skipped"),
463+ ("core", "skipped"),
464+ ("edge", "skipped"),
465+ ];
466+ assert!(!starts_after(
467+ &deploy,
468+ "front",
469+ &skipped,
470+ &all,
471+ push.clone(),
472+ false,
473+ true
474+ ));
475+ assert!(!starts(
476+ &deploy,
477+ "smoke",
478+ &[
479+ ("check", "failure"),
480+ ("core", "skipped"),
481+ ("edge", "skipped"),
482+ ("front", "skipped")
483+ ],
484+ &all,
485+ push.clone(),
486+ false
487+ ));
228488 // A failed plan stops everything too.
229− assert!(!starts(&deploy, "migrate", &[("plan", "failure")], &all, push.clone(), false));
230− assert!(!starts(&deploy, "core", &[("plan", "failure"), ("migrate", "skipped")], &all, push.clone(), false));
489+ assert!(!starts(
490+ &deploy,
491+ "migrate",
492+ &[("plan", "failure")],
493+ &all,
494+ push.clone(),
495+ false
496+ ));
497+ assert!(!starts(
498+ &deploy,
499+ "core",
500+ &[("plan", "failure"), ("migrate", "skipped")],
501+ &all,
502+ push.clone(),
503+ false
504+ ));
231505
232506 // Smoke follows the last stage that ran, and not a failed one.
233− assert!(starts(&deploy, "smoke", &[("core", "success"), ("edge", "success"), ("front", "success")], &all, push.clone(), false));
234− assert!(starts(&deploy, "smoke", &[("core", "success"), ("edge", "skipped"), ("front", "success")], &none, push.clone(), false));
235− assert!(!starts(&deploy, "smoke", &[("core", "success"), ("edge", "failure"), ("front", "skipped")], &all, push.clone(), false));
507+ assert!(starts(
508+ &deploy,
509+ "smoke",
510+ &[
511+ ("core", "success"),
512+ ("edge", "success"),
513+ ("front", "success")
514+ ],
515+ &all,
516+ push.clone(),
517+ false
518+ ));
519+ assert!(starts(
520+ &deploy,
521+ "smoke",
522+ &[
523+ ("core", "success"),
524+ ("edge", "skipped"),
525+ ("front", "success")
526+ ],
527+ &none,
528+ push.clone(),
529+ false
530+ ));
531+ assert!(!starts(
532+ &deploy,
533+ "smoke",
534+ &[
535+ ("core", "success"),
536+ ("edge", "failure"),
537+ ("front", "skipped")
538+ ],
539+ &all,
540+ push.clone(),
541+ false
542+ ));
236543 // Nothing deployed: nothing to smoke-test.
237544 let nothing = plan_outputs(false, &[], &[], &[]);
238− assert!(!starts(&deploy, "smoke", &[("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], &nothing, push.clone(), false));
545+ assert!(!starts(
546+ &deploy,
547+ "smoke",
548+ &[
549+ ("core", "skipped"),
550+ ("edge", "skipped"),
551+ ("front", "skipped")
552+ ],
553+ &nothing,
554+ push.clone(),
555+ false
556+ ));
239557
240558 // A dry run plans and stops.
241559 let dry = json!({ "dry_run": true, "units": "", "all": false });
242560 assert!(!starts(&deploy, "migrate", &[], &all, dry.clone(), false));
243− assert!(!starts(&deploy, "core", &[("migrate", "skipped")], &all, dry.clone(), false));
244− assert!(!starts(&deploy, "smoke", &[("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], &all, dry, false));
561+ assert!(!starts(
562+ &deploy,
563+ "core",
564+ &[("migrate", "skipped")],
565+ &all,
566+ dry.clone(),
567+ false
568+ ));
569+ assert!(!starts(
570+ &deploy,
571+ "smoke",
572+ &[
573+ ("core", "skipped"),
574+ ("edge", "skipped"),
575+ ("front", "skipped")
576+ ],
577+ &all,
578+ dry,
579+ false
580+ ));
245581 }
246582
247583 #[test]
248584 fn deploy_stage_matrices_come_from_the_plan() {
249585 let deploy = read("deploy.yml");
250− let outputs = plan_outputs(false, &[("rust-1", "events,work", true), ("rust-2", "repos", true), ("ts", "projects,og", false)], &[], &[]);
586+ let outputs = plan_outputs(
587+ false,
588+ &[
589+ ("rust-1", "events,work", true),
590+ ("rust-2", "repos", true),
591+ ("ts", "projects,og", false),
592+ ],
593+ &[],
594+ &[],
595+ );
251596 let core = deploy.jobs.iter().find(|j| j.id == "core").unwrap();
252597 assert!(!core.fail_fast);
253598 assert_eq!(core.max_parallel, Some(4));
254599 let mut contexts = Map::new();
255− contexts.insert("needs".into(), json!({ "plan": { "result": "success", "outputs": outputs } }));
256− let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
600+ contexts.insert(
601+ "needs".into(),
602+ json!({ "plan": { "result": "success", "outputs": outputs } }),
603+ );
604+ let scope = Scope {
605+ contexts: &contexts,
606+ status: Status::Success,
607+ hash_files: None,
608+ };
257609 let value = expr::interpolate_value(core.matrix.as_ref().unwrap(), &scope).unwrap();
258610 let jobs = matrix::expand(&value).unwrap();
259611 let groups: Vec<(&str, &str, bool)> = jobs
260612 .iter()
261− .map(|c| (c["group"].as_str().unwrap(), c["units"].as_str().unwrap(), c["rust"].as_bool().unwrap()))
613+ .map(|c| {
614+ (
615+ c["group"].as_str().unwrap(),
616+ c["units"].as_str().unwrap(),
617+ c["rust"].as_bool().unwrap(),
618+ )
619+ })
262620 .collect();
263− assert_eq!(groups, [("rust-1", "events,work", true), ("rust-2", "repos", true), ("ts", "projects,og", false)]);
621+ assert_eq!(
622+ groups,
623+ [
624+ ("rust-1", "events,work", true),
625+ ("rust-2", "repos", true),
626+ ("ts", "projects,og", false)
627+ ]
628+ );
264629 }
265630
266631 #[test]
270635 let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
271636 let on = |rust: bool, image: bool| {
272637 let mut contexts = Map::new();
273− contexts.insert("matrix".into(), json!({ "group": "g", "units": "u", "rust": rust, "image": image }));
274− let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
638+ contexts.insert(
639+ "matrix".into(),
640+ json!({ "group": "g", "units": "u", "rust": rust, "image": image }),
641+ );
642+ let scope = Scope {
643+ contexts: &contexts,
644+ status: Status::Success,
645+ hash_files: None,
646+ };
275647 expr::interpolate_value(&job.runs_on, &scope).unwrap()
276648 };
277649 assert_eq!(on(true, false), json!("g1t-4core"), "{stage}");
289661 fn step_runs(step: &workflow::Step, matrix: Value, status: Status) -> bool {
290662 let mut contexts = Map::new();
291663 contexts.insert("matrix".into(), matrix);
292− let scope = Scope { contexts: &contexts, status, hash_files: None };
664+ let scope = Scope {
665+ contexts: &contexts,
666+ status,
667+ hash_files: None,
668+ };
293669 expr::condition(step.condition.as_deref().unwrap_or_default(), &scope).unwrap()
294670 }
295671
296672 #[test]
297673 fn rust_builds_go_through_sccache_before_cargo_and_report_after() {
298674 let step = |job: &workflow::Job, run: &str| -> (usize, workflow::Step) {
299− let at = job.steps.iter().position(|s| s.run.as_deref() == Some(run)).unwrap_or_else(|| panic!("{}: no step runs {run}", job.id));
675+ let at = job
676+ .steps
677+ .iter()
678+ .position(|s| s.run.as_deref() == Some(run))
679+ .unwrap_or_else(|| panic!("{}: no step runs {run}", job.id));
300680 (at, job.steps[at].clone())
301681 };
302682 let deploy = read("deploy.yml");
306686 let (stats_at, stats) = step(job, "bash scripts/sccache.sh stats");
307687 // Before anything runs Cargo (worker-build's install, the build),
308688 // and the statistics last.
309− let install_step = job.steps.iter().position(|s| s.name.as_deref() == Some("Install")).unwrap();
689+ let install_step = job
690+ .steps
691+ .iter()
692+ .position(|s| s.name.as_deref() == Some("Install"))
693+ .unwrap();
310694 assert!(install_at < install_step, "{stage}");
311695 assert_eq!(stats_at, job.steps.len() - 1, "{stage}");
312− for (rust, image, uses) in [(true, false, true), (false, true, true), (false, false, false)] {
696+ for (rust, image, uses) in [
697+ (true, false, true),
698+ (false, true, true),
699+ (false, false, false),
700+ ] {
313701 let matrix = json!({ "group": "g", "units": "u", "rust": rust, "image": image });
314− assert_eq!(step_runs(&install, matrix.clone(), Status::Success), uses, "{stage} rust={rust} image={image}");
315− assert_eq!(step_runs(&stats, matrix.clone(), Status::Success), uses, "{stage}");
702+ assert_eq!(
703+ step_runs(&install, matrix.clone(), Status::Success),
704+ uses,
705+ "{stage} rust={rust} image={image}"
706+ );
707+ assert_eq!(
708+ step_runs(&stats, matrix.clone(), Status::Success),
709+ uses,
710+ "{stage}"
711+ );
316712 // A failed build still says what was cached.
317713 assert_eq!(step_runs(&stats, matrix, Status::Failure), uses, "{stage}");
318714 }
331727 let job = ci.jobs.iter().find(|j| j.id == id).unwrap();
332728 for (event, expected) in [("push", on_push), ("pull_request", true)] {
333729 let mut contexts = Map::new();
334− contexts.insert("github".into(), json!({ "event_name": event, "ref": "refs/heads/main" }));
335− let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
336− assert_eq!(expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap(), expected, "{id} on {event}");
730+ contexts.insert(
731+ "github".into(),
732+ json!({ "event_name": event, "ref": "refs/heads/main" }),
733+ );
734+ let scope = Scope {
735+ contexts: &contexts,
736+ status: Status::Success,
737+ hash_files: None,
738+ };
739+ assert_eq!(
740+ expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap(),
741+ expected,
742+ "{id} on {event}"
743+ );
337744 }
338745 }
339746 // The download is pinned by version and checksum.
340747 let script = std::fs::read_to_string(workflows_dir().join("../../scripts/sccache.sh")).unwrap();
341748 assert!(script.contains("VERSION=0.18.0"));
342− assert!(script.lines().any(|line| line.strip_prefix("SHA256=").is_some_and(|sum| sum.len() == 64)));
749+ assert!(script.lines().any(|line| {
750+ line.strip_prefix("SHA256=")
751+ .is_some_and(|sum| sum.len() == 64)
752+ }));
343753 assert!(script.contains("sha256sum -c"));
344754 assert!(script.contains("RUSTC_WRAPPER="));
345755 assert!(script.contains("GITHUB_STEP_SUMMARY"));
361771 let mut finders = Vec::new();
362772 for entry in std::fs::read_dir(&actions).unwrap() {
363773 let dir = entry.unwrap().path();
364− let manifest = std::fs::read_to_string(dir.join("action.yml")).unwrap_or_else(|_| panic!("{} has no action.yml", dir.display()));
774+ let manifest = std::fs::read_to_string(dir.join("action.yml"))
775+ .unwrap_or_else(|_| panic!("{} has no action.yml", dir.display()));
365776 let manifest: serde_yaml::Value = serde_yaml::from_str(&manifest).unwrap();
366− assert_eq!(manifest["runs"]["using"].as_str(), Some("composite"), "{}", dir.display());
777+ assert_eq!(
778+ manifest["runs"]["using"].as_str(),
779+ Some("composite"),
780+ "{}",
781+ dir.display()
782+ );
367783 assert!(dir.join("README.md").exists(), "{}", dir.display());
368784 finders.push(std::fs::read_to_string(dir.join("runner.sh")).unwrap());
369785 }
370786 assert!(finders.len() >= 2);
371− assert!(finders.windows(2).all(|pair| pair[0] == pair[1]), "actions/*/runner.sh differ");
372− let deploy: serde_yaml::Value = serde_yaml::from_str(&std::fs::read_to_string(actions.join("deploy/action.yml")).unwrap()).unwrap();
373− assert_eq!(deploy["inputs"]["token"]["default"].as_str(), Some("${{ github.token }}"));
787+ assert!(
788+ finders.windows(2).all(|pair| pair[0] == pair[1]),
789+ "actions/*/runner.sh differ"
790+ );
791+ let deploy: serde_yaml::Value =
792+ serde_yaml::from_str(&std::fs::read_to_string(actions.join("deploy/action.yml")).unwrap())
793+ .unwrap();
794+ assert_eq!(
795+ deploy["inputs"]["token"]["default"].as_str(),
796+ Some("${{ github.token }}")
797+ );
374798 for output in ["url", "deployment-id"] {
375799 assert!(deploy["outputs"][output]["value"].is_string(), "{output}");
376800 }
388812
389813 #[test]
390814 fn the_conformance_workflow_is_the_same_on_github_and_on_g1t() {
391− let root = workflows_dir().parent().unwrap().parent().unwrap().to_path_buf();
815+ let root = workflows_dir()
816+ .parent()
817+ .unwrap()
818+ .parent()
819+ .unwrap()
820+ .to_path_buf();
392821 let ours = std::fs::read_to_string(root.join(".g1t/workflows/conformance.yml")).unwrap();
393822 let theirs = std::fs::read_to_string(root.join(".github/workflows/conformance.yml")).unwrap();
394− assert_eq!(ours, theirs, "copy .g1t/workflows/conformance.yml over .github/workflows/conformance.yml");
823+ assert_eq!(
824+ ours, theirs,
825+ "copy .g1t/workflows/conformance.yml over .github/workflows/conformance.yml"
826+ );
395827 // It runs only by hand, and reads nothing it could change.
396828 let workflow = read("conformance.yml");
397− assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["workflow_dispatch"]);
829+ assert_eq!(
830+ workflow
831+ .triggers
832+ .iter()
833+ .map(|t| t.event.as_str())
834+ .collect::<Vec<_>>(),
835+ ["workflow_dispatch"]
836+ );
398837 assert!(!ours.contains("secrets."));
399838 }
400839
412851 }
413852 let source = std::fs::read_to_string(&path).unwrap();
414853 for (at, _) in source.match_indices("secrets.") {
415− let name: String = source[at + "secrets.".len()..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
854+ let name: String = source[at + "secrets.".len()..]
855+ .chars()
856+ .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
857+ .collect();
416858 let reserved = name.starts_with("G1T_") || name.starts_with("GITHUB_");
417− assert!(!reserved || name == "G1T_TOKEN" || name == "GITHUB_TOKEN", "{} reads secrets.{name}, a name g1t keeps for its own", path.display());
859+ assert!(
860+ !reserved || name == "G1T_TOKEN" || name == "GITHUB_TOKEN",
861+ "{} reads secrets.{name}, a name g1t keeps for its own",
862+ path.display()
863+ );
864+ }
865+ }
866+}
867+
868+#[test]
869+fn every_install_runs_again_when_a_connection_drops() {
870+ // npm does not retry a body that breaks off midway; npm-ci.mjs does.
871+ for entry in std::fs::read_dir(workflows_dir()).unwrap() {
872+ let path = entry.unwrap().path();
873+ if path.extension().and_then(|e| e.to_str()) != Some("yml") {
874+ continue;
875+ }
876+ let name = path.file_name().unwrap().to_string_lossy().to_string();
877+ for job in read(&name).jobs {
878+ for step in &job.steps {
879+ let Some(run) = step.run.as_deref() else {
880+ continue;
881+ };
882+ for line in run.lines() {
883+ assert!(
884+ !line.trim().starts_with("npm ci"),
885+ "{name} {}: `{}` should be `node scripts/deploy/npm-ci.mjs …`",
886+ job.id,
887+ line.trim()
888+ );
889+ }
890+ }
418891 }
419892 }
420893 }
+2−1
479479 const units = selected(stack, opts);
480480 const args = npmCiArgs(units, npmWorkspace());
481481 log(`npm ${args.join(" ")}`);
482− const done = await exec(`npm ${args.join(" ")}`, [], { cwd: ROOT, shell: true, onLine: (line) => log(line) });
482+ // Through npm-ci.mjs, which runs it again when a connection drops.
483+ const done = await exec(`node scripts/deploy/npm-ci.mjs ${args.slice(1).join(" ")}`, [], { cwd: ROOT, shell: true, onLine: (line) => log(line) });
483484 return done.code === 0;
484485 }
485486
+93−0
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+
15+import { spawn } from "node:child_process";
16+import { fileURLToPath } from "node:url";
17+
18+export const ATTEMPTS = 3;
19+export const WAITS_MS = [5_000, 15_000];
20+/** How long an install may print nothing before it counts as stalled. */
21+export 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. */
24+const 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+ */
31+export 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. */
38+export function npmArgs(flags) {
39+ return ["ci", ...flags];
40+}
41+
42+/** One install: its exit code, what it printed, and whether it stalled. */
43+function 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. */
75+export 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+
91+if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
92+ process.exitCode = await npmCi(process.argv.slice(2));
93+}
+59−0
1+import { test } from "node:test";
2+import assert from "node:assert/strict";
3+import { npmCi, retryable } from "./npm-ci.mjs";
4+
5+const quiet = () => {};
6+const noSleep = async () => {};
7+
8+/** A fake npm that answers each try in turn. */
9+function tries(...answers) {
10+ const seen = [];
11+ const run = async (args) => {
12+ seen.push(args);
13+ return { stalled: false, output: "", ...answers[seen.length - 1] };
14+ };
15+ return { seen, run };
16+}
17+
18+test("a body that broke off is worth another try, a lockfile out of step is not", () => {
19+ assert.equal(retryable("npm error code ECONNRESET\nnpm error network aborted"), true);
20+ assert.equal(retryable("npm error request to https://registry.npmjs.org/x failed, reason: socket hang up"), true);
21+ assert.equal(retryable("npm error code EAI_AGAIN"), true);
22+ assert.equal(retryable("npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync"), false);
23+ assert.equal(retryable("npm error code E404"), false);
24+});
25+
26+test("an install that ends without saying why, or stalls, is tried again", () => {
27+ assert.equal(retryable(""), true);
28+ assert.equal(retryable("added 12 packages"), true);
29+ assert.equal(retryable("npm error code E404", { stalled: true }), true);
30+});
31+
32+test("a dropped connection is tried again until the install passes", async () => {
33+ const waits = [];
34+ const { seen, run } = tries({ code: 1, output: "npm error code ECONNRESET" }, { code: 0 });
35+ const code = await npmCi(["--prefer-offline", "--no-audit"], { run, sleep: async (ms) => waits.push(ms), waits: [5, 15], log: quiet });
36+ assert.equal(code, 0);
37+ assert.equal(seen.length, 2);
38+ assert.deepEqual(seen[1], ["ci", "--prefer-offline", "--no-audit"]);
39+ assert.deepEqual(waits, [5]);
40+});
41+
42+test("a real error fails at once", async () => {
43+ const { seen, run } = tries({ code: 1, output: "npm error code ERESOLVE" });
44+ assert.equal(await npmCi([], { run, sleep: noSleep, log: quiet }), 1);
45+ assert.equal(seen.length, 1);
46+});
47+
48+test("it gives up after its tries, with npm's exit code", async () => {
49+ const reset = { code: 7, output: "npm error network aborted" };
50+ const { seen, run } = tries(reset, reset, reset, reset);
51+ assert.equal(await npmCi([], { run, sleep: noSleep, attempts: 3, log: quiet }), 7);
52+ assert.equal(seen.length, 3);
53+});
54+
55+test("a stalled install is run again", async () => {
56+ const { seen, run } = tries({ code: 1, stalled: true }, { code: 0 });
57+ assert.equal(await npmCi([], { run, sleep: noSleep, log: quiet }), 0);
58+ assert.equal(seen.length, 2);
59+});