Skip to content

Commit fd7c243

Browse files
CI: fail pnpm e2e legs that skip every test (#1054) (#1400)
* CI: fail pnpm e2e legs that skip every test The pnpm-family e2e suites return early with a SKIP line when pnpm or corepack is missing, and libtest reports those tests as passed. Their CI rows set no gate, so a runner without pnpm would turn the leg green without running anything; with corepack hidden, the PR row's 8 tests "passed" in 0.02s. The Linux rows of e2e_redirect_pnpm_build, e2e_safety_pnpm and e2e_redirect_rush_sim now set SOCKET_PATCH_PNPM_E2E_REQUIRED, and the three suites fail instead of skipping under it. A bare major pin (pnpm@10) now passes the version check when the installed release is inside that major. Empty values count as unset, since the shared step passes '' on every other leg. A script test pins the gate to those rows and suites. Refs #1054 Assisted-by: Claude Code:claude-opus-5-5 * CI: gate pnpm rows with a matrix key Review follow-up. The pnpm gate is now a per-row pnpm_required key, like npm_required, instead of an exact-suite-name match in the shared step, so a multi-suite row can't silently lose it. Also: e2e_safety_pnpm uses one skip helper, rush_sim's corepack probe doc is back on its function, the pnpm_build docs no longer call the non-@10 legs opportunistic, and e2e_vendor_pnpm_build treats an empty gate as unset like its siblings. The script test now checks the row key and that each suite reads the gate. Refs #1054 Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1fb8901 commit fd7c243

6 files changed

Lines changed: 103 additions & 21 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1259,7 +1259,7 @@ jobs:
12591259
# inside the same suite runs in pnpm-compatibility.yml, hence the
12601260
# skip. Node 24 (step below): the
12611261
# corepack pnpm@10/11 legs require it.
1262-
- {os: ubuntu-latest, suite: e2e_redirect_pnpm_build, test_filter: '--ignored --skip pnpm_pinned_matrix'}
1262+
- {os: ubuntu-latest, suite: e2e_redirect_pnpm_build, test_filter: '--ignored --skip pnpm_pinned_matrix', pnpm_required: '1'}
12631263
# Real-uv hosted/vendored capstones ending in manifest-less VEX:
12641264
# the oldest and newest line + the 0.5.x boundary (0.5.4 still
12651265
# re-resolves a transitive override / rejects a repointed
@@ -1699,6 +1699,10 @@ jobs:
16991699
# other real-toolchain capstone. All EMPTY on legs that do not set
17001700
# the matching matrix key, which the suites treat as unset.
17011701
SOCKET_PATCH_NPM_E2E_REQUIRED: ${{ matrix.npm_required || '' }}
1702+
# `pnpm_required` does the same for the pnpm-family suites
1703+
# (corepack pnpm / pnpm on PATH). Set on the Linux rows only; the
1704+
# Windows and macOS rows have not been checked against it yet.
1705+
SOCKET_PATCH_PNPM_E2E_REQUIRED: ${{ matrix.pnpm_required || '' }}
17021706
SOCKET_PATCH_CARGO_E2E_REQUIRED: ${{ matrix.suite == 'e2e_safety_cargo_build' && '1' || '' }}
17031707
SOCKET_PATCH_UV_E2E_REQUIRED: ${{ matrix.uv != '' && '1' || '' }}
17041708
SOCKET_PATCH_UV_E2E_VERSION: ${{ matrix.uv }}
@@ -1829,8 +1833,10 @@ jobs:
18291833
- {os: ubuntu-latest, suite: e2e_redirect_gem_build e2e_vendor_gem_build, ruby: '3.4', bundler: '4.0.15'}
18301834
- os: ubuntu-latest
18311835
suite: e2e_safety_pnpm
1836+
pnpm_required: '1'
18321837
- os: ubuntu-latest
18331838
suite: e2e_redirect_rush_sim
1839+
pnpm_required: '1'
18341840
- os: ubuntu-latest
18351841
suite: e2e_redirect_bun_build e2e_vendor_bun_build mode_migration_bun
18361842
bun: '1.1.45'

‎crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,10 @@ const TOKEN: &str = "22222222-2222-4222-8222-222222222222";
9393
/// Marker prepended to the dep's entry point by the synthetic patch.
9494
const MARKER: &str = "/* SOCKET-PATCHED */\n";
9595
const GHSA: &str = "GHSA-redirect-pnpm";
96-
/// Pinned pnpm majors via corepack — @10 is the required leg, the others are
97-
/// opportunistic (the vendor capstone's ladder convention). @7 and @8 are the
98-
/// legacy-lock legs: they emit lockfileVersion 5.4 / 6.0, proving the v5/v6
99-
/// rewrite installs for real.
96+
/// Pinned pnpm majors via corepack. Locally a leg whose major corepack can't
97+
/// provide skips; under `SOCKET_PATCH_PNPM_E2E_REQUIRED` (the Linux CI row)
98+
/// every leg is required. @7 and @8 are the legacy-lock legs: they emit
99+
/// lockfileVersion 5.4 / 6.0, proving the v5/v6 rewrite installs for real.
100100
const PNPM_PRIMARY: &str = "pnpm@10";
101101
const PNPM_SECONDARY: &str = "pnpm@9";
102102
const PNPM_TERTIARY: &str = "pnpm@11";
@@ -130,6 +130,13 @@ fn pnpm_command(pm: &str) -> Command {
130130
}
131131
}
132132

133+
/// Set by every CI leg that runs this suite: a missing toolchain or a
134+
/// failed fixture install is then a failure, never a skip. The workflow
135+
/// passes an EMPTY value on legs that run other suites, so empty = unset.
136+
fn pnpm_required() -> bool {
137+
std::env::var_os("SOCKET_PATCH_PNPM_E2E_REQUIRED").is_some_and(|v| !v.is_empty())
138+
}
139+
133140
fn has_corepack_pm(pm: &str) -> bool {
134141
let probe = tempfile::tempdir().unwrap();
135142
let mut cmd = pnpm_command(pm);
@@ -138,13 +145,16 @@ fn has_corepack_pm(pm: &str) -> bool {
138145
cmd.env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0");
139146
let output = cmd.output();
140147
let ok = output.as_ref().is_ok_and(|o| o.status.success());
141-
if std::env::var_os("SOCKET_PATCH_PNPM_E2E_REQUIRED").is_some() {
148+
if pnpm_required() {
142149
assert!(ok, "required pnpm toolchain unavailable: {output:?}");
143150
if let Ok(output) = output {
144-
assert_eq!(
145-
String::from_utf8_lossy(&output.stdout).trim(),
146-
pm.strip_prefix("pnpm@").unwrap(),
147-
"matrix must run the pinned version"
151+
// An exact pin (the pinned matrix) must match exactly; a bare
152+
// major (`pnpm@10`, the e2e legs) must resolve inside it.
153+
let got = String::from_utf8_lossy(&output.stdout).trim().to_string();
154+
let want = pm.strip_prefix("pnpm@").unwrap();
155+
assert!(
156+
got == want || got.starts_with(&format!("{want}.")),
157+
"matrix must run the pinned version: want {want}, got {got}"
148158
);
149159
}
150160
}
@@ -523,7 +533,7 @@ async fn redirect_scanned_pnpm_project(
523533
);
524534
if !install.status.success() {
525535
assert!(
526-
std::env::var_os("SOCKET_PATCH_PNPM_E2E_REQUIRED").is_none(),
536+
!pnpm_required(),
527537
"required {pm} fixture install failed: {:?}",
528538
install
529539
);
@@ -1136,7 +1146,7 @@ async fn pnpm_get_uuid_hosted_fresh_checkout_frozen_install() {
11361146
assert_fixture_manifestless_vex(&fx, &fresh, "pnpm10 get-uuid");
11371147
}
11381148

1139-
/// Opportunistic pnpm@9 leg (the vendor capstone's secondary convention):
1149+
/// pnpm@9 leg (the vendor capstone's secondary convention):
11401150
/// same positive chain, no tamper twin needed — @10 already carries it.
11411151
#[tokio::test(flavor = "multi_thread")]
11421152
#[serial_test::serial]

‎crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@ const VULNS: &[(&str, &[&str])] = &[(GHSA, &["CVE-2026-5151"])];
5959

6060
// ── self-contained helpers ────────────────────────────────────────────
6161

62+
/// Set by the CI legs that run this suite: a missing corepack pnpm is then
63+
/// a failure, never a skip. Empty (the value on other legs) = unset. The
64+
/// tier-2 `RUSH_E2E` opt-in skip is not affected.
65+
fn pnpm_required() -> bool {
66+
std::env::var_os("SOCKET_PATCH_PNPM_E2E_REQUIRED").is_some_and(|v| !v.is_empty())
67+
}
68+
69+
/// Print the SKIP line for a missing toolchain, or panic under
70+
/// [`pnpm_required`].
71+
fn skip_missing_toolchain(msg: &str) {
72+
assert!(!pnpm_required(), "required toolchain unavailable: {msg}");
73+
println!("SKIP {msg}");
74+
}
75+
6276
/// Probe corepack from a NEUTRAL temp dir (a `packageManager` field in an
6377
/// ancestor package.json — e.g. this monorepo root — otherwise makes corepack
6478
/// refuse a different manager).
@@ -400,7 +414,7 @@ fn assert_rush_manifestless_vex(root: &Path, patch_server: &str, patched: &[u8],
400414
#[ignore = "wall-bound real-rush/pnpm install (~70s); runs on all 3 OSes as an e2e CI matrix leg"]
401415
async fn rush_hosted_scan_then_simulated_pnpm_install_lands_patched_bytes() {
402416
if !has_corepack_pm("pnpm@9") {
403-
println!("SKIP e2e_redirect_rush_sim: `corepack pnpm@9` unavailable");
417+
skip_missing_toolchain("e2e_redirect_rush_sim: `corepack pnpm@9` unavailable");
404418
return;
405419
}
406420

@@ -516,7 +530,7 @@ fn assert_rush_stale_store_not_attested(
516530
#[ignore = "wall-bound real-rush/pnpm install (~70s); runs on all 3 OSes as an e2e CI matrix leg"]
517531
async fn rush_hosted_tampered_tarball_fails_simulated_install() {
518532
if !has_corepack_pm("pnpm@9") {
519-
println!("SKIP e2e_redirect_rush_sim (tampered): `corepack pnpm@9` unavailable");
533+
skip_missing_toolchain("e2e_redirect_rush_sim (tampered): `corepack pnpm@9` unavailable");
520534
return;
521535
}
522536

@@ -581,7 +595,7 @@ async fn rush_hosted_real_rush_update_install() {
581595
return;
582596
}
583597
if !has_command("npm") || !has_corepack_pm("pnpm@9") {
584-
println!("SKIP e2e_redirect_rush_sim (tier2): npm / corepack pnpm@9 unavailable");
598+
skip_missing_toolchain("e2e_redirect_rush_sim (tier2): npm / corepack pnpm@9 unavailable");
585599
return;
586600
}
587601

‎crates/socket-patch-cli/tests/e2e_safety_pnpm.rs‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@ const PATCH_UUID: &str = "00000000-0000-4000-8000-00000000e2e5";
3939
/// Pinned: published npm tarballs are immutable.
4040
const BEFORE_HASH: &str = "311f1e893e6eac502693fad8617dcf5353a043ccc0f7b4ba9fe385e838b67a10";
4141

42+
/// Set by the CI legs that run this suite: pnpm missing from PATH is then a
43+
/// failure, never a skip. Empty (the value on other legs) = unset.
44+
fn pnpm_required() -> bool {
45+
std::env::var_os("SOCKET_PATCH_PNPM_E2E_REQUIRED").is_some_and(|v| !v.is_empty())
46+
}
47+
48+
/// Print the SKIP line for a missing pnpm, or panic under [`pnpm_required`].
49+
fn skip_missing_pnpm() {
50+
assert!(
51+
!pnpm_required(),
52+
"required toolchain unavailable: pnpm not on PATH"
53+
);
54+
eprintln!("SKIP: pnpm not on PATH");
55+
}
56+
4257
/// Appended to the pristine `index.js` to form the patched bytes.
4358
const PATCH_MARKER: &[u8] = b"\n// socket-patch e2e_safety_pnpm synthetic patch\n";
4459

@@ -235,7 +250,7 @@ fn file_identity(path: &Path) -> (u64, u64) {
235250
#[ignore]
236251
fn pnpm_install_produces_symlinked_layout() {
237252
if !has_command("pnpm") {
238-
eprintln!("SKIP: pnpm not on PATH");
253+
skip_missing_pnpm();
239254
return;
240255
}
241256
let root = tempfile::tempdir().unwrap();
@@ -299,7 +314,7 @@ fn pnpm_install_produces_symlinked_layout() {
299314
#[ignore]
300315
fn apply_in_a_does_not_mutate_b_or_store() {
301316
if !has_command("pnpm") {
302-
eprintln!("SKIP: pnpm not on PATH");
317+
skip_missing_pnpm();
303318
return;
304319
}
305320
let root = tempfile::tempdir().unwrap();
@@ -403,7 +418,7 @@ fn apply_in_a_does_not_mutate_b_or_store() {
403418
#[ignore]
404419
fn pnpm_install_in_b_does_not_revert_a() {
405420
if !has_command("pnpm") {
406-
eprintln!("SKIP: pnpm not on PATH");
421+
skip_missing_pnpm();
407422
return;
408423
}
409424
let root = tempfile::tempdir().unwrap();
@@ -518,7 +533,7 @@ fn pnpm_install_in_b_does_not_revert_a() {
518533
#[ignore]
519534
fn apply_in_pnpm_project_emits_layout_note() {
520535
if !has_command("pnpm") {
521-
eprintln!("SKIP: pnpm not on PATH");
536+
skip_missing_pnpm();
522537
return;
523538
}
524539
let root = tempfile::tempdir().unwrap();

‎crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,10 @@ fn socket_bin() -> PathBuf {
108108
}
109109

110110
/// Set by the pinned-matrix workflow: a missing toolchain or a failed
111-
/// fixture install is then a failure, never a skip.
111+
/// fixture install is then a failure, never a skip. Empty = unset, as in the
112+
/// sibling suites (ci.yml's shared e2e step passes '' on other legs).
112113
fn pnpm_required() -> bool {
113-
std::env::var_os("SOCKET_PATCH_PNPM_E2E_REQUIRED").is_some()
114+
std::env::var_os("SOCKET_PATCH_PNPM_E2E_REQUIRED").is_some_and(|v| !v.is_empty())
114115
}
115116

116117
/// `corepack <pm>`, or — in the pinned matrix — the provisioned pnpm

‎scripts/tests/test_ci_e2e_tiers.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,42 @@ def test_compat_workflow_builds_its_own_binaries(self):
271271
self.assertIn("gradle-probe", text)
272272

273273

274+
class PnpmRequiredGate(unittest.TestCase):
275+
"""The pnpm-family suites soft-skip when pnpm is missing, and libtest
276+
counts a skip as `ok`, so their Linux rows must set the fail-instead-of-
277+
skip gate (#1054)."""
278+
279+
suites = {"e2e_redirect_pnpm_build", "e2e_safety_pnpm", "e2e_redirect_rush_sim"}
280+
jobs = ("e2e", "e2e-full", "e2e-extended", "e2e-windows", "e2e-macos")
281+
282+
def test_run_step_reads_the_row_key(self):
283+
run = dict(rows_mod.steps(JOBS["e2e"]))["Run e2e tests"]
284+
self.assertIn("SOCKET_PATCH_PNPM_E2E_REQUIRED: ${{ matrix.pnpm_required || '' }}", run)
285+
286+
def test_every_linux_pnpm_row_sets_the_gate(self):
287+
gated = set()
288+
for job in self.jobs:
289+
for row in rows(job):
290+
if self.suites & set(row["suite"].split()) and row["os"].startswith("ubuntu"):
291+
with self.subTest(job=job, row=row):
292+
self.assertEqual(row.get("pnpm_required"), "1")
293+
gated |= self.suites & set(row["suite"].split())
294+
elif "pnpm_required" in row:
295+
self.fail(f"{job} row outside the checked set sets pnpm_required: {row}")
296+
self.assertEqual(gated, self.suites)
297+
self.assertIn("e2e_redirect_pnpm_build", {r["suite"] for r in rows("e2e") if r.get("pnpm_required")},
298+
"the PR tier must keep its gated pnpm row")
299+
300+
def test_every_gated_suite_honours_the_gate(self):
301+
for suite in sorted(self.suites):
302+
with self.subTest(suite=suite):
303+
src = (TESTS / f"{suite}.rs").read_text(encoding="utf-8")
304+
# Empty means unset: the step passes '' on every other leg.
305+
self.assertIn('var_os("SOCKET_PATCH_PNPM_E2E_REQUIRED").is_some_and(|v| !v.is_empty())', src)
306+
# ...and every skip path goes through it.
307+
self.assertGreater(len(re.findall(r"pnpm_required\(\)", src)), 1, "gate defined but never read")
308+
309+
274310
class PdmCapstone(unittest.TestCase):
275311
job = rows_mod.jobs(PDM.read_text(encoding="utf-8"))["capstone"]
276312

0 commit comments

Comments
 (0)