Skip to content

Commit fb756aa

Browse files
committed
Merge main into Pipenv re-vendor fix
Brings the branch up to date with main (pnpm global-store, gem VEX bundle path and macOS vexctl cache fixes) so the PR is validated against the current base. Assisted-by: Claude Code:claude-opus-5-5
2 parents ff5fb6c + 9c43dfc commit fb756aa

12 files changed

Lines changed: 1183 additions & 53 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ jobs:
240240
os: [macos-latest, windows-latest]
241241
runs-on: ${{ matrix.os }}
242242
timeout-minutes: 50
243+
env:
244+
VEXCTL_VERSION: v0.3.0
243245
steps:
244246
- name: Checkout
245247
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -264,6 +266,7 @@ jobs:
264266
run: cargo build --workspace
265267

266268
- name: Install Go (for vexctl)
269+
id: go
267270
# The `vex` subcommand emits OpenVEX documents; tests/e2e_vex.rs
268271
# validates the output with vexctl when it's on PATH. vexctl is
269272
# a Go binary distributed via `go install`. Setting up Go here
@@ -279,6 +282,19 @@ jobs:
279282
go-version: '1.24'
280283
cache: false
281284

285+
- name: Restore vexctl (macOS)
286+
# The macOS compile below took a median 110s (max 168s) of every
287+
# macOS `test` job and is the step's only network flake source
288+
# (sum.golang.org resets). Its output depends only on the vexctl
289+
# version, the Go toolchain and the runner, so it is cached under
290+
# exactly those. Saved from main only, like the cargo cache.
291+
id: vexctl-cache
292+
if: runner.os == 'macOS'
293+
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
294+
with:
295+
path: ${{ runner.temp }}/vexctl-bin
296+
key: vexctl-${{ env.VEXCTL_VERSION }}-go${{ steps.go.outputs.go-version }}-${{ runner.os }}-${{ runner.arch }}
297+
282298
- name: Install vexctl
283299
# Linux / Windows: the v0.3.0 release binary, checked against the
284300
# sha256 pinned here (from the release's vexctl_checksums.txt).
@@ -292,25 +308,26 @@ jobs:
292308
# sum.golang.org checksum tiles, and transient INTERNAL_ERROR
293309
# stream resets there have failed this step on otherwise-green
294310
# runs. The backoff rides out short resets; a persistent outage
295-
# still fails loudly on the last attempt.
311+
# still fails loudly on the last attempt. A binary restored by the
312+
# step above skips the compile.
296313
#
297314
# Either way the binary's directory goes on PATH so
298315
# `Command::new("vexctl")` in the tests resolves.
299316
shell: bash
300317
env:
301-
VEXCTL_VERSION: v0.3.0
302318
VEXCTL_SHA256_LINUX_AMD64: cd7f8b57d20642166ed4eb3dd1fd849bbb30bbd7c5b9f5b0316b6003b57ceaba
303319
VEXCTL_SHA256_WINDOWS_AMD64: 346fb3104b656fe1a407cbae88ea29a636b36f4569ec58a5a8dadca7343993ed
320+
CACHE_HIT: ${{ steps.vexctl-cache.outputs.cache-hit }}
304321
run: |
305322
set -euo pipefail
323+
dir="$RUNNER_TEMP/vexctl-bin"
324+
mkdir -p "$dir"
306325
case "$RUNNER_OS" in
307326
Linux) asset=vexctl-linux-amd64 bin=vexctl sha="$VEXCTL_SHA256_LINUX_AMD64" ;;
308327
Windows) asset=vexctl-windows-amd64.exe bin=vexctl.exe sha="$VEXCTL_SHA256_WINDOWS_AMD64" ;;
309328
*) asset='' ;;
310329
esac
311330
if [ -n "$asset" ]; then
312-
dir="$RUNNER_TEMP/vexctl-bin"
313-
mkdir -p "$dir"
314331
curl -fsSL --retry 5 --retry-all-errors -o "$dir/$bin" \
315332
"https://github.com/openvex/vexctl/releases/download/$VEXCTL_VERSION/$asset"
316333
echo "$sha $dir/$bin" | sha256sum -c -
@@ -319,18 +336,31 @@ jobs:
319336
echo "$dir" >> "$GITHUB_PATH"
320337
exit 0
321338
fi
322-
for attempt in 1 2 3 4 5; do
323-
if go install "github.com/openvex/vexctl@$VEXCTL_VERSION"; then
324-
break
325-
fi
326-
if [ "$attempt" = 5 ]; then
327-
echo "::error::go install vexctl failed on all 5 attempts"
328-
exit 1
329-
fi
330-
echo "::warning::go install vexctl attempt $attempt failed; retrying"
331-
sleep $((attempt * 20))
332-
done
333-
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
339+
if [ "$CACHE_HIT" != true ]; then
340+
for attempt in 1 2 3 4 5; do
341+
if GOBIN="$dir" go install "github.com/openvex/vexctl@$VEXCTL_VERSION"; then
342+
break
343+
fi
344+
if [ "$attempt" = 5 ]; then
345+
echo "::error::go install vexctl failed on all 5 attempts"
346+
exit 1
347+
fi
348+
echo "::warning::go install vexctl attempt $attempt failed; retrying"
349+
sleep $((attempt * 20))
350+
done
351+
fi
352+
"$dir/vexctl" version
353+
echo "$dir" >> "$GITHUB_PATH"
354+
355+
- name: Save vexctl (macOS)
356+
if: >-
357+
runner.os == 'macOS'
358+
&& github.ref == 'refs/heads/main'
359+
&& steps.vexctl-cache.outputs.cache-hit != 'true'
360+
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
361+
with:
362+
path: ${{ runner.temp }}/vexctl-bin
363+
key: ${{ steps.vexctl-cache.outputs.cache-primary-key }}
334364

335365
- name: Run tests
336366
# Default features only: `--all-features` would also RUN the

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 6 additions & 2 deletions
Large diffs are not rendered by default.

‎crates/socket-patch-cli/src/commands/scan/hosted.rs‎

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,14 @@ struct StaleInstallOutcome {
192192
/// behind silently reinstates the stale bytes) — while a SHARED gem-env
193193
/// home affects every project on the machine, so that flavor prefers moving
194194
/// the project to a local bundle path and only conditionally names the
195-
/// shared files.
195+
/// shared files. `project_local` is the caller's call: under the project
196+
/// root, or under the project's own `.bundle/config` path even when that
197+
/// path sits outside the tree (#709).
196198
fn gem_stale_install_warning(
197199
purl: &str,
198200
gem_dir: &Path,
199201
leaf: &str,
200-
cwd: &Path,
202+
project_local: bool,
201203
project_cache_gem: Option<&Path>,
202204
) -> serde_json::Value {
203205
let home = gem_dir
@@ -215,7 +217,7 @@ fn gem_stale_install_warning(
215217
paths.push(extra.display().to_string());
216218
}
217219
let list = paths.join(", ");
218-
let detail = if gem_dir.starts_with(cwd) {
220+
let detail = if project_local {
219221
format!(
220222
"{purl} was switched to its hosted patch, but a stale \
221223
UNPATCHED install is already materialized at {} — `bundle install` \
@@ -293,7 +295,9 @@ async fn installed_stale_positive_evidence(
293295
/// * Discovery is [`socket_patch_core::crawlers::RubyCrawler`] — the same
294296
/// installed-gem APIs `apply` uses, honoring `--global`/`--global-prefix`
295297
/// exactly like scan's own discovery; layouts the crawler grows into are
296-
/// covered automatically.
298+
/// covered automatically. A `.bundle/config` path the containment guard
299+
/// refuses as a write root is still READ here
300+
/// (`verification_only_gem_paths`): bundler installs into it.
297301
/// * Records are found BY UUID (the fetch key, stable across purl
298302
/// spellings): this run's fetched records first, then the redirect
299303
/// ledger's persisted ones — a re-scan whose `/patches/view` fetch failed
@@ -364,7 +368,17 @@ async fn gem_stale_install_warnings(
364368
global,
365369
global_prefix,
366370
};
367-
let gem_paths = crawler.get_gem_paths(&options).await.unwrap_or_default();
371+
let mut gem_paths = crawler.get_gem_paths(&options).await.unwrap_or_default();
372+
// A `.bundle/config` path outside the project is refused as a write
373+
// root, yet bundler installs into and loads from it: read it too, or a
374+
// stale materialization there never warns (#709). It is this project's
375+
// own bundle path, so it takes the project-local remedy.
376+
let config_stores = crawler.verification_only_gem_paths(&options).await;
377+
for gems_dir in &config_stores {
378+
if !gem_paths.contains(gems_dir) {
379+
gem_paths.push(gems_dir.clone());
380+
}
381+
}
368382
// Every candidate's installed dir in every gem home, one blocking pass
369383
// (and at most one listing) per home — the per-candidate lookups the
370384
// loop below consumes, in the same (candidate, home) order.
@@ -419,8 +433,10 @@ async fn gem_stale_install_warnings(
419433
if j.patched || !j.positive {
420434
continue;
421435
}
436+
let project_local =
437+
dir.starts_with(cwd) || config_stores.iter().any(|store| dir.starts_with(store));
422438
let mut folded_cache: Option<std::path::PathBuf> = None;
423-
if dir.starts_with(cwd) {
439+
if project_local {
424440
let project_cache = app_cache.join(format!("{}.gem", j.leaf));
425441
if project_cache.is_file() {
426442
let proven_patched = match (
@@ -446,7 +462,7 @@ async fn gem_stale_install_warnings(
446462
&j.purl,
447463
dir,
448464
&j.leaf,
449-
cwd,
465+
project_local,
450466
folded_cache.as_deref(),
451467
));
452468
out.stale_purls.insert(j.purl.clone());
@@ -3291,7 +3307,13 @@ mod tests {
32913307
let cwd = PathBuf::from("proj");
32923308
let home = gem_home(&cwd);
32933309
let gem_dir = home.join("gems").join(GEM_LEAF);
3294-
let w = gem_stale_install_warning(GEM_PURL, &gem_dir, GEM_LEAF, &cwd, None);
3310+
let w = gem_stale_install_warning(
3311+
GEM_PURL,
3312+
&gem_dir,
3313+
GEM_LEAF,
3314+
gem_dir.starts_with(&cwd),
3315+
None,
3316+
);
32953317
let detail = detail_of(&w);
32963318
assert!(detail.contains(GEM_PURL), "{detail}");
32973319
assert!(detail.contains(&gem_dir.display().to_string()), "{detail}");
@@ -3326,7 +3348,13 @@ mod tests {
33263348
let cwd = PathBuf::from("proj");
33273349
let home = PathBuf::from("shared-gem-home").join("ruby").join("3.3.0");
33283350
let gem_dir = home.join("gems").join(GEM_LEAF);
3329-
let w = gem_stale_install_warning(GEM_PURL, &gem_dir, GEM_LEAF, &cwd, None);
3351+
let w = gem_stale_install_warning(
3352+
GEM_PURL,
3353+
&gem_dir,
3354+
GEM_LEAF,
3355+
gem_dir.starts_with(&cwd),
3356+
None,
3357+
);
33303358
let detail = detail_of(&w);
33313359
assert!(detail.contains("shared gem home"), "{detail}");
33323360
assert!(
@@ -3356,7 +3384,13 @@ mod tests {
33563384
.join("vendor")
33573385
.join("cache")
33583386
.join(format!("{GEM_LEAF}.gem"));
3359-
let w = gem_stale_install_warning(GEM_PURL, &gem_dir, GEM_LEAF, &cwd, Some(&committed));
3387+
let w = gem_stale_install_warning(
3388+
GEM_PURL,
3389+
&gem_dir,
3390+
GEM_LEAF,
3391+
gem_dir.starts_with(&cwd),
3392+
Some(&committed),
3393+
);
33603394
let detail = detail_of(&w);
33613395
assert!(
33623396
detail.contains(&committed.display().to_string()),
@@ -3484,6 +3518,57 @@ mod tests {
34843518
assert!(out.warnings.is_empty());
34853519
}
34863520

3521+
/// #709: a `.bundle/config` `path` outside the project is refused as an
3522+
/// install (write) root, but bundler still installs into and loads
3523+
/// from it — so the probe must read it, or a stale materialization
3524+
/// there never warns and the purl stays in the same-run `--vex`
3525+
/// `assume_applied` set.
3526+
#[tokio::test]
3527+
async fn gem_stale_probe_reads_refused_out_of_tree_config_path() {
3528+
let proj = tempfile::tempdir().unwrap();
3529+
let outside = tempfile::tempdir().unwrap();
3530+
std::fs::write(proj.path().join("Gemfile"), b"gem \"stale-unit\"\n").unwrap();
3531+
std::fs::create_dir_all(proj.path().join(".bundle")).unwrap();
3532+
std::fs::write(
3533+
proj.path().join(".bundle").join("config"),
3534+
format!("---\nBUNDLE_PATH: \"{}\"\n", outside.path().display()),
3535+
)
3536+
.unwrap();
3537+
// Bundler's scoped layout under the configured root.
3538+
let gem_dir = outside
3539+
.path()
3540+
.join("ruby")
3541+
.join("3.3.0")
3542+
.join("gems")
3543+
.join(GEM_LEAF);
3544+
std::fs::create_dir_all(gem_dir.join("lib")).unwrap();
3545+
std::fs::write(gem_dir.join("lib").join("stale_unit.rb"), GEM_UPSTREAM).unwrap();
3546+
3547+
let out = probe(proj.path(), &one_confirmed(), &one_record()).await;
3548+
assert_eq!(out.warnings.len(), 1, "{:?}", out.warnings);
3549+
let detail = detail_of(&out.warnings[0]);
3550+
assert!(detail.contains(&gem_dir.display().to_string()), "{detail}");
3551+
// The project's own bundle path, not a machine-wide gem home: the
3552+
// verified delete-list remedy, not the shared-home caveat.
3553+
assert!(detail.contains("Remove the stale"), "{detail}");
3554+
assert!(!detail.contains("shared gem home"), "{detail}");
3555+
assert_eq!(
3556+
out.stale_purls,
3557+
std::collections::BTreeSet::from([GEM_PURL.to_string()])
3558+
);
3559+
// Read-only: the refused root is never written.
3560+
assert_eq!(
3561+
std::fs::read(gem_dir.join("lib").join("stale_unit.rb")).unwrap(),
3562+
GEM_UPSTREAM
3563+
);
3564+
3565+
// A patched materialization there stays quiet.
3566+
std::fs::write(gem_dir.join("lib").join("stale_unit.rb"), GEM_PATCHED).unwrap();
3567+
let out = probe(proj.path(), &one_confirmed(), &one_record()).await;
3568+
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
3569+
assert!(out.stale_purls.is_empty());
3570+
}
3571+
34873572
/// FALSE-POSITIVE hardening: an install whose record file is MISSING
34883573
/// (or unreadable — same NotFound class) is not positive evidence, so
34893574
/// the probe stays quiet instead of prescribing deletion on a tree it

‎crates/socket-patch-cli/src/ecosystem_dispatch.rs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,11 @@ pub(crate) fn npm_paths_by_identity_in(
610610
/// [`find_packages_for_rollback_reusing`]. Only the root discovery is
611611
/// reused: each root is still searched by `find_by_purls`, so copy choice
612612
/// and order are unchanged. A snapshot taken with other options is ignored.
613+
///
614+
/// Read-only by contract (its one caller is `vex`), so it also searches the
615+
/// gem stores under a refused out-of-tree `.bundle/config` path
616+
/// (`RubyCrawler::verification_only_gem_paths`), which the write paths never
617+
/// see.
613618
pub async fn find_manifest_package_copies_reusing(
614619
purls: &[String],
615620
common: &GlobalArgs,
@@ -635,6 +640,22 @@ pub async fn find_manifest_package_copies_reusing(
635640
*paths = with_store_peer_variant_copies(std::mem::take(paths)).await;
636641
}
637642
}
643+
// Verification also READS a `.bundle/config` bundle path the crawler
644+
// refused as a write root (it resolves outside the project): bundler
645+
// installs into and loads from it, so a copy there must verify too —
646+
// skipping it would leave an unpatched gem looking "not installed",
647+
// which the hosted lockfile basis then attests (#709).
648+
if let Some(gem_purls) = partitioned.get(&Ecosystem::Gem) {
649+
let stores = RubyCrawler
650+
.verification_only_gem_paths(&crawler_options)
651+
.await;
652+
let bases = dedup_qualified_purls(gem_purls);
653+
for store in &stores {
654+
if let Ok(found) = RubyCrawler.find_by_purls(store, &bases).await {
655+
merge_qualified(&mut copies, gem_purls, found);
656+
}
657+
}
658+
}
638659
copies
639660
}
640661

‎crates/socket-patch-cli/tests/apply/main.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ mod in_process_gem_multicopy;
2020
mod in_process_npm_multicopy;
2121
mod in_process_variant_apply_failure;
2222
mod lockfile_only_skip;
23+
mod pnpm_global_virtual_store;

0 commit comments

Comments
 (0)