From 0353d437f1df619a6b5e532e2c82ad1803079fa7 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Fri, 16 Oct 2026 08:11:47 -0400 Subject: [PATCH 1/4] fix(cache): honor untrackedEnv in bulk environment queries Apply the existing untracked contract to bulk queries while retaining explicit reads and non-UTF-8 validation. Builds on the approach proposed in voidzero-dev/vite-task#693. Co-authored-by: Codex (GPT-6) --- CHANGELOG.md | 1 + crates/vt/src/session/cache/mod.rs | 8 +- crates/vt/src/session/cache/remote.rs | 2 +- crates/vt/src/session/cache/validation.rs | 5 +- crates/vt/src/session/execute/cache_update.rs | 16 +- crates/vt/src/session/execute/fingerprint.rs | 119 +++++++++++-- .../ipc_client_test/scripts/fetch_envs.mjs | 10 +- .../fixtures/ipc_client_test/snapshots.toml | 165 ++++++++++++++++++ ...xplicit_read_overrides_untracked_config.md | 36 ++++ ...y_prefix_preserves_negated_dependencies.md | 40 +++++ ...tch_envs_queries_ignore_untracked_names.md | 51 ++++++ .../fixtures/ipc_client_test/vite-task.json | 24 +++ crates/vt_graph/src/config/mod.rs | 1 + packages/vite-task-client/src/index.d.ts | 4 +- packages/vite-task-client/src/index.js | 4 +- 15 files changed, 459 insertions(+), 27 deletions(-) create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_explicit_read_overrides_untracked_config.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_empty_prefix_preserves_negated_dependencies.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_queries_ignore_untracked_names.md diff --git a/CHANGELOG.md b/CHANGELOG.md index edd3132ec..1bc117382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** Broad environment queries now honor `cache.untrackedEnv`, so changes to CI metadata such as GitHub Actions' orchestration ID no longer invalidate cached builds. Explicit single-variable reads remain tracked ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). - **Fixed** On Linux, concurrent system calls in tracked programs, such as `pnpm dlx`, no longer cause a stack overflow ([#854](https://github.com/voidzero-dev/vite-task/pull/854)). - **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program. On macOS, text after a `#` in the shebang line is now ignored, as it is when the script runs on its own ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). - **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)). diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index 2caefd817..690faaef8 100644 --- a/crates/vt/src/session/cache/mod.rs +++ b/crates/vt/src/session/cache/mod.rs @@ -398,6 +398,7 @@ impl ExecutionCache { if let Some(cache_value) = self.get_by_cache_key(cache_key).await? { if let Some(mismatch) = cache_value.validate( &cache_metadata.unfiltered_envs, + &cache_metadata.spawn_fingerprint.env_fingerprints().untracked_env_config, globbed_inputs, workspace_root, )? { @@ -445,7 +446,12 @@ impl ExecutionCache { .fetch(remote_config, cache_key, &cache_metadata.execution_cache_key, cancel_token) .await; let validate = |cache_value: &CacheEntryValue| { - cache_value.validate(&cache_metadata.unfiltered_envs, globbed_inputs, workspace_root) + cache_value.validate( + &cache_metadata.unfiltered_envs, + &cache_metadata.spawn_fingerprint.env_fingerprints().untracked_env_config, + globbed_inputs, + workspace_root, + ) }; let Restore { value: cache_value, blob_id } = match remote::resolve(fetched, cache_key, validate) { diff --git a/crates/vt/src/session/cache/remote.rs b/crates/vt/src/session/cache/remote.rs index f45f874d5..d49e4f695 100644 --- a/crates/vt/src/session/cache/remote.rs +++ b/crates/vt/src/session/cache/remote.rs @@ -502,7 +502,7 @@ mod tests { ) -> impl FnOnce(&CacheEntryValue) -> anyhow::Result> { move |value| { let workspace_root = vt_path::current_dir().unwrap(); - value.validate(&FxHashMap::default(), &globbed_inputs, &workspace_root) + value.validate(&FxHashMap::default(), &[], &globbed_inputs, &workspace_root) } } diff --git a/crates/vt/src/session/cache/validation.rs b/crates/vt/src/session/cache/validation.rs index 859afec7d..e224986d2 100644 --- a/crates/vt/src/session/cache/validation.rs +++ b/crates/vt/src/session/cache/validation.rs @@ -4,6 +4,7 @@ use std::{collections::BTreeMap, ffi::OsStr, sync::Arc}; use rustc_hash::FxHashMap; use vt_casefold::EnvName; +use vt_glob::env::EnvGlobSet; use vt_path::{AbsolutePath, RelativePathBuf}; use super::{CacheEntryKey, CacheEntryValue, FingerprintMismatch, InputChangeKind}; @@ -20,6 +21,7 @@ impl CacheEntryValue { pub(crate) fn validate( &self, unfiltered_envs: &FxHashMap>, Arc>, + untracked_env_config: &[vt_str::Str], globbed_inputs: &BTreeMap, workspace_root: &AbsolutePath, ) -> anyhow::Result> { @@ -27,8 +29,9 @@ impl CacheEntryValue { return Ok(Some(mismatch)); } + let untracked_env = EnvGlobSet::new(untracked_env_config)?; self.post_run_fingerprint - .validate(workspace_root, unfiltered_envs) + .validate(workspace_root, unfiltered_envs, &untracked_env) .map(|mismatch| mismatch.map(FingerprintMismatch::from)) } } diff --git a/crates/vt/src/session/execute/cache_update.rs b/crates/vt/src/session/execute/cache_update.rs index 0909839ce..7886cfe05 100644 --- a/crates/vt/src/session/execute/cache_update.rs +++ b/crates/vt/src/session/execute/cache_update.rs @@ -5,6 +5,7 @@ use std::{collections::BTreeMap, sync::Arc, time::Duration}; use rustc_hash::FxHashSet; use tokio_util::sync::CancellationToken; +use vt_glob::env::EnvGlobSet; use vt_path::{AbsolutePath, RelativePathBuf}; use vt_plan::cache_metadata::{CacheMetadata, EnvValueHash}; use vt_server::Reports; @@ -282,7 +283,7 @@ fn collect_tracked_reports( reports .map(|reports| { let tracked_envs = collect_tracked_envs(reports, metadata)?; - let tracked_env_queries = collect_tracked_env_queries(reports)?; + let tracked_env_queries = collect_tracked_env_queries(reports, metadata)?; Ok::<_, anyhow::Error>((tracked_envs, tracked_env_queries)) }) .transpose() @@ -346,8 +347,14 @@ fn collect_tracked_envs( } /// Select tool-reported bulk env query records to embed in the post-run -/// fingerprint. The full match-set is stored as value hashes. -fn collect_tracked_env_queries(reports: &Reports) -> anyhow::Result { +/// fingerprint. `untrackedEnv` names remain visible to the tool but their +/// values are not fingerprinted, just as for the spawned environment. +fn collect_tracked_env_queries( + reports: &Reports, + metadata: &CacheMetadata, +) -> anyhow::Result { + let untracked_env = + EnvGlobSet::new(metadata.spawn_fingerprint.env_fingerprints().untracked_env_config.iter())?; let mut tracked_env_queries = BTreeMap::new(); for (query, record) in &reports.tracked_get_envs { @@ -360,6 +367,9 @@ fn collect_tracked_env_queries(reports: &Reports) -> anyhow::Result>, Arc>, + untracked_env: &EnvGlobSet, ) -> anyhow::Result> { let input_mismatch = self.inferred_inputs.par_iter().find_map_any( |(input_relative_path, path_fingerprint)| { @@ -204,7 +208,7 @@ impl PostRunFingerprint { } for (query, stored_matches) in &self.tracked_env_queries { - let current_matches = match match_env_query(query, unfiltered_envs)? { + let current_matches = match match_env_query(query, unfiltered_envs, untracked_env)? { EnvQueryValidation::Matches(matches) => matches, EnvQueryValidation::NonUtf8Value(mismatch) => { return Ok(Some(PostRunMismatch::TrackedEnvQuery { @@ -213,7 +217,14 @@ impl PostRunFingerprint { })); } }; - if let Some(mismatch) = first_env_glob_mismatch(stored_matches, ¤t_matches) { + // Older entries can include untracked names. Apply the same policy + // to both sides so those entries can still be replayed. + let stored_matches = stored_matches + .iter() + .filter(|(name, _)| !untracked_env.is_match(name.as_str())) + .map(|(name, value)| (name.clone(), *value)) + .collect(); + if let Some(mismatch) = first_env_glob_mismatch(&stored_matches, ¤t_matches) { return Ok(Some(PostRunMismatch::TrackedEnvQuery { query: query.clone(), mismatch, @@ -231,21 +242,25 @@ impl PostRunFingerprint { fn match_env_query( query: &TrackedEnvQuery, envs: &FxHashMap>, Arc>, + untracked_env: &EnvGlobSet, ) -> anyhow::Result { Ok(match query { TrackedEnvQuery::Glob(pattern) => { let glob = vt_glob::env::EnvGlob::new(pattern.as_str())?; - collect_matching_envs(envs, |name| glob.is_match(name)) - } - TrackedEnvQuery::Prefix(prefix) => { - collect_matching_envs(envs, |name| EnvName::from_ref(name).starts_with(prefix.as_str())) + collect_matching_envs(envs, |name| glob.is_match(name), untracked_env) } + TrackedEnvQuery::Prefix(prefix) => collect_matching_envs( + envs, + |name| EnvName::from_ref(name).starts_with(prefix.as_str()), + untracked_env, + ), }) } fn collect_matching_envs( envs: &FxHashMap>, Arc>, is_match: impl Fn(&str) -> bool, + untracked_env: &EnvGlobSet, ) -> EnvQueryValidation { let mut matches = BTreeMap::new(); for (name, value) in envs { @@ -260,6 +275,11 @@ fn collect_matching_envs( name: Str::from(name_str), }); }; + // The client rejects non-UTF-8 values even for untracked names, so + // validate representation before omitting the value dependency. + if untracked_env.is_match(name_str) { + continue; + } matches.insert(Str::from(name_str), EnvValueHash::new(value_str)); } EnvQueryValidation::Matches(matches) @@ -516,6 +536,66 @@ mod tests { OsString::from_wide(&[0xD800]) } + fn no_untracked() -> EnvGlobSet { + EnvGlobSet::new(std::iter::empty::<&str>()).expect("empty patterns") + } + + #[test] + fn validate_ignores_untracked_values_in_older_bulk_query_entries() { + for query in [TrackedEnvQuery::Glob(Str::from("*")), TrackedEnvQuery::Prefix(Str::from(""))] + { + let stored_matches = BTreeMap::from([ + (Str::from("VITE_TITLE"), EnvValueHash::new("title")), + (Str::from("ACTIONS_ORCHESTRATION_ID"), EnvValueHash::new("old-run")), + ]); + let fingerprint = PostRunFingerprint { + tracked_env_queries: BTreeMap::from([(query, stored_matches)]), + ..PostRunFingerprint::default() + }; + let unfiltered_envs = FxHashMap::from_iter([ + ( + EnvName::new(Arc::::from(OsStr::new("VITE_TITLE"))), + Arc::::from(OsStr::new("title")), + ), + ( + EnvName::new(Arc::::from(OsStr::new("ACTIONS_ORCHESTRATION_ID"))), + Arc::::from(OsStr::new("new-run")), + ), + ]); + let untracked = EnvGlobSet::new(["ACTIONS_*"]).expect("valid patterns"); + let workspace_root = vt_path::current_dir().expect("cwd"); + assert!( + fingerprint + .validate(&workspace_root, &unfiltered_envs, &untracked) + .expect("validation succeeds") + .is_none() + ); + } + } + + #[test] + fn validate_rejects_non_utf8_bulk_values_even_when_untracked() { + let fingerprint = PostRunFingerprint { + tracked_env_queries: BTreeMap::from([( + TrackedEnvQuery::Prefix(Str::from("")), + BTreeMap::new(), + )]), + ..PostRunFingerprint::default() + }; + let unfiltered_envs = FxHashMap::from_iter([( + EnvName::new(Arc::::from(OsStr::new("PROBE_NOISE"))), + Arc::::from(non_utf8_os_string()), + )]); + let untracked = EnvGlobSet::new(["PROBE_*"]).expect("valid patterns"); + let workspace_root = vt_path::current_dir().expect("cwd"); + assert!(matches!( + fingerprint + .validate(&workspace_root, &unfiltered_envs, &untracked) + .expect("validation succeeds"), + Some(PostRunMismatch::TrackedEnvQuery { mismatch: EnvMismatch::Changed { .. }, .. }) + )); + } + #[test] fn validate_errors_on_current_non_utf8_tracked_env_value() { let mut tracked_envs = BTreeMap::new(); @@ -530,7 +610,7 @@ mod tests { let workspace_root = vt_path::current_dir().expect("cwd"); let err = fingerprint - .validate(&workspace_root, &unfiltered_envs) + .validate(&workspace_root, &unfiltered_envs, &no_untracked()) .expect_err("non-UTF-8 tracked env values must error"); assert!(err.to_string().contains("tracked env value for PROBE_ENV is not valid UTF-8")); @@ -550,8 +630,9 @@ mod tests { ); let workspace_root = vt_path::current_dir().expect("cwd"); - let mismatch = - fingerprint.validate(&workspace_root, &unfiltered_envs).expect("validation succeeds"); + let mismatch = fingerprint + .validate(&workspace_root, &unfiltered_envs, &no_untracked()) + .expect("validation succeeds"); match mismatch { Some(PostRunMismatch::TrackedEnvQuery { @@ -585,8 +666,9 @@ mod tests { ); let workspace_root = vt_path::current_dir().expect("cwd"); - let mismatch = - fingerprint.validate(&workspace_root, &unfiltered_envs).expect("validation succeeds"); + let mismatch = fingerprint + .validate(&workspace_root, &unfiltered_envs, &no_untracked()) + .expect("validation succeeds"); assert!(mismatch.is_none()); } @@ -605,8 +687,9 @@ mod tests { ); let workspace_root = vt_path::current_dir().expect("cwd"); - let mismatch = - fingerprint.validate(&workspace_root, &unfiltered_envs).expect("validation succeeds"); + let mismatch = fingerprint + .validate(&workspace_root, &unfiltered_envs, &no_untracked()) + .expect("validation succeeds"); assert!(mismatch.is_none()); } @@ -624,8 +707,9 @@ mod tests { ); let workspace_root = vt_path::current_dir().expect("cwd"); - let mismatch = - fingerprint.validate(&workspace_root, &unfiltered_envs).expect("validation succeeds"); + let mismatch = fingerprint + .validate(&workspace_root, &unfiltered_envs, &no_untracked()) + .expect("validation succeeds"); assert_eq!(mismatch.is_none(), cfg!(windows)); } @@ -646,8 +730,9 @@ mod tests { ); let workspace_root = vt_path::current_dir().expect("cwd"); - let mismatch = - fingerprint.validate(&workspace_root, &unfiltered_envs).expect("validation succeeds"); + let mismatch = fingerprint + .validate(&workspace_root, &unfiltered_envs, &no_untracked()) + .expect("validation succeeds"); assert_eq!(mismatch.is_none(), cfg!(windows)); } diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs index 66342b899..ef19f878d 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs @@ -3,9 +3,15 @@ import { getEnvs } from '@voidzero-dev/vite-task-client'; const args = process.argv.slice(2); const tracked = !args.includes('--untracked'); const prefixIndex = args.indexOf('--prefix'); -const query = prefixIndex === -1 ? 'PROBE_*' : { prefix: args[prefixIndex + 1] ?? 'PROBE_' }; +const globIndex = args.indexOf('--glob'); +const glob = globIndex === -1 ? 'PROBE_*' : (args[globIndex + 1] ?? 'PROBE_*'); +const query = prefixIndex === -1 ? glob : { prefix: args[prefixIndex + 1] ?? 'PROBE_' }; const matches = getEnvs(query, { tracked }); -const sorted = Object.entries(matches).sort(([a], [b]) => a.localeCompare(b)); +const printPrefixIndex = args.indexOf('--print-prefix'); +const printPrefix = printPrefixIndex === -1 ? '' : (args[printPrefixIndex + 1] ?? ''); +const sorted = Object.entries(matches) + .filter(([name]) => name.startsWith(printPrefix)) + .sort(([a], [b]) => a.localeCompare(b)); for (const [key, value] of sorted) { console.log(`${key}=${value}`); diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml index 64f809c2f..3bc0b41a5 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml @@ -670,3 +670,168 @@ steps = [ ], ], comment = "cache hit: changed match set was requested with tracked: false" }, ] + +[[e2e]] +name = "fetch_envs_queries_ignore_untracked_names" +comment = """ +Bulk glob and literal-prefix queries still serve untracked variables, but changing configured noise or the default ACTIONS_ORCHESTRATION_ID replays the cached result. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-envs-untracked-noise", + ], envs = [ + [ + "PROBE_A", + "a", + ], + [ + "PROBE_NOISE_ID", + "run-1", + ], + [ + "ACTIONS_ORCHESTRATION_ID", + "run-1", + ], + ], comment = "populate: the glob serves both tracked values and untracked noise" }, + { argv = [ + "vt", + "run", + "fetch-envs-untracked-noise", + ], envs = [ + [ + "PROBE_A", + "a", + ], + [ + "PROBE_NOISE_ID", + "run-2", + ], + [ + "ACTIONS_ORCHESTRATION_ID", + "run-2", + ], + ], comment = "cache hit: configured and default untracked values changed" }, + { argv = [ + "vt", + "run", + "fetch-envs-prefix-untracked-noise", + ], envs = [ + [ + "PROBE_A", + "a", + ], + [ + "PROBE_NOISE_ID", + "run-1", + ], + ], comment = "populate: the literal-prefix query serves its untracked match too" }, + { argv = [ + "vt", + "run", + "fetch-envs-prefix-untracked-noise", + ], envs = [ + [ + "PROBE_A", + "a", + ], + [ + "PROBE_NOISE_ID", + "run-2", + ], + ], comment = "cache hit: literal-prefix queries honor the same exclusion" }, +] + +[[e2e]] +name = "fetch_envs_empty_prefix_preserves_negated_dependencies" +comment = """ +An empty prefix queries every ambient variable. Negating VITE_* from untrackedEnv keeps its values, additions and removals as cache dependencies. Only VITE_* results are printed so platform-specific ambient values stay out of the snapshot. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-envs-all-untracked-except-vite", + ], envs = [ + [ + "VITE_FEATURE", + "first", + ], + ], comment = "populate: VITE_FEATURE is excluded from the untracked wildcard" }, + { argv = [ + "vt", + "run", + "fetch-envs-all-untracked-except-vite", + ], envs = [ + [ + "VITE_FEATURE", + "second", + ], + ], comment = "cache miss: a negated dependency changed" }, + { argv = [ + "vt", + "run", + "fetch-envs-all-untracked-except-vite", + ], envs = [ + [ + "VITE_FEATURE", + "second", + ], + [ + "VITE_ADDED", + "added", + ], + ], comment = "cache miss: a negated dependency was added" }, + { argv = [ + "vt", + "run", + "fetch-envs-all-untracked-except-vite", + ], envs = [ + [ + "VITE_FEATURE", + "second", + ], + ], comment = "cache miss: a negated dependency was removed" }, +] + +[[e2e]] +name = "fetch_env_explicit_read_overrides_untracked_config" +comment = """ +An explicit tracked getEnv call remains a dependency even when the task declares the same name in untrackedEnv. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-env-explicitly-untracked", + ], envs = [ + [ + "PROBE_ENV", + "first", + ], + ], comment = "populate: getEnv explicitly records the otherwise untracked value" }, + { argv = [ + "vt", + "run", + "fetch-env-explicitly-untracked", + ], envs = [ + [ + "PROBE_ENV", + "first", + ], + ], comment = "cache hit: the explicit dependency is unchanged" }, + { argv = [ + "vt", + "run", + "fetch-env-explicitly-untracked", + ], envs = [ + [ + "PROBE_ENV", + "second", + ], + ], comment = "cache miss: an explicit getEnv read stays tracked" }, +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_explicit_read_overrides_untracked_config.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_explicit_read_overrides_untracked_config.md new file mode 100644 index 000000000..31a4298df --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_explicit_read_overrides_untracked_config.md @@ -0,0 +1,36 @@ +# fetch_env_explicit_read_overrides_untracked_config + +An explicit tracked getEnv call remains a dependency even when the task declares the same name in untrackedEnv. + +## `PROBE_ENV=first vt run fetch-env-explicitly-untracked` + +populate: getEnv explicitly records the otherwise untracked value + +``` +$ node scripts/fetch_env.mjs PROBE_ENV +served PROBE_ENV=first +process.env PROBE_ENV=first +``` + +## `PROBE_ENV=first vt run fetch-env-explicitly-untracked` + +cache hit: the explicit dependency is unchanged + +``` +$ node scripts/fetch_env.mjs PROBE_ENV ◉ cache hit, replaying +served PROBE_ENV=first +process.env PROBE_ENV=first + +--- +vt run: cache hit. +``` + +## `PROBE_ENV=second vt run fetch-env-explicitly-untracked` + +cache miss: an explicit getEnv read stays tracked + +``` +$ node scripts/fetch_env.mjs PROBE_ENV ○ cache miss: env 'PROBE_ENV' changed, executing +served PROBE_ENV=second +process.env PROBE_ENV=second +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_empty_prefix_preserves_negated_dependencies.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_empty_prefix_preserves_negated_dependencies.md new file mode 100644 index 000000000..21e7daab9 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_empty_prefix_preserves_negated_dependencies.md @@ -0,0 +1,40 @@ +# fetch_envs_empty_prefix_preserves_negated_dependencies + +An empty prefix queries every ambient variable. Negating VITE_* from untrackedEnv keeps its values, additions and removals as cache dependencies. Only VITE_* results are printed so platform-specific ambient values stay out of the snapshot. + +## `VITE_FEATURE=first vt run fetch-envs-all-untracked-except-vite` + +populate: VITE_FEATURE is excluded from the untracked wildcard + +``` +$ node scripts/fetch_envs.mjs --prefix "" --print-prefix VITE_ +VITE_FEATURE=first +``` + +## `VITE_FEATURE=second vt run fetch-envs-all-untracked-except-vite` + +cache miss: a negated dependency changed + +``` +$ node scripts/fetch_envs.mjs --prefix "" --print-prefix VITE_ ○ cache miss: env 'VITE_FEATURE' changed, executing +VITE_FEATURE=second +``` + +## `VITE_FEATURE=second VITE_ADDED=added vt run fetch-envs-all-untracked-except-vite` + +cache miss: a negated dependency was added + +``` +$ node scripts/fetch_envs.mjs --prefix "" --print-prefix VITE_ ○ cache miss: env 'VITE_ADDED' added, executing +VITE_ADDED=added +VITE_FEATURE=second +``` + +## `VITE_FEATURE=second vt run fetch-envs-all-untracked-except-vite` + +cache miss: a negated dependency was removed + +``` +$ node scripts/fetch_envs.mjs --prefix "" --print-prefix VITE_ ○ cache miss: env 'VITE_ADDED' removed, executing +VITE_FEATURE=second +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_queries_ignore_untracked_names.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_queries_ignore_untracked_names.md new file mode 100644 index 000000000..de6fbf85b --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_queries_ignore_untracked_names.md @@ -0,0 +1,51 @@ +# fetch_envs_queries_ignore_untracked_names + +Bulk glob and literal-prefix queries still serve untracked variables, but changing configured noise or the default ACTIONS_ORCHESTRATION_ID replays the cached result. + +## `PROBE_A=a PROBE_NOISE_ID=run-1 ACTIONS_ORCHESTRATION_ID=run-1 vt run fetch-envs-untracked-noise` + +populate: the glob serves both tracked values and untracked noise + +``` +$ node scripts/fetch_envs.mjs --glob "{PROBE_*,ACTIONS_ORCHESTRATION_ID}" +ACTIONS_ORCHESTRATION_ID=run-1 +PROBE_A=a +PROBE_NOISE_ID=run-1 +``` + +## `PROBE_A=a PROBE_NOISE_ID=run-2 ACTIONS_ORCHESTRATION_ID=run-2 vt run fetch-envs-untracked-noise` + +cache hit: configured and default untracked values changed + +``` +$ node scripts/fetch_envs.mjs --glob "{PROBE_*,ACTIONS_ORCHESTRATION_ID}" ◉ cache hit, replaying +ACTIONS_ORCHESTRATION_ID=run-1 +PROBE_A=a +PROBE_NOISE_ID=run-1 + +--- +vt run: cache hit. +``` + +## `PROBE_A=a PROBE_NOISE_ID=run-1 vt run fetch-envs-prefix-untracked-noise` + +populate: the literal-prefix query serves its untracked match too + +``` +$ node scripts/fetch_envs.mjs --prefix +PROBE_A=a +PROBE_NOISE_ID=run-1 +``` + +## `PROBE_A=a PROBE_NOISE_ID=run-2 vt run fetch-envs-prefix-untracked-noise` + +cache hit: literal-prefix queries honor the same exclusion + +``` +$ node scripts/fetch_envs.mjs --prefix ◉ cache hit, replaying +PROBE_A=a +PROBE_NOISE_ID=run-1 + +--- +vt run: cache hit. +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json index 1ff9e059e..0e45a48c0 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json @@ -89,6 +89,30 @@ "command": "node scripts/fetch_envs.mjs --prefix PROBE_*", "cache": true }, + "fetch-envs-untracked-noise": { + "command": "node scripts/fetch_envs.mjs --glob \"{PROBE_*,ACTIONS_ORCHESTRATION_ID}\"", + "cache": { + "untrackedEnv": ["PROBE_NOISE_*"] + } + }, + "fetch-envs-prefix-untracked-noise": { + "command": "node scripts/fetch_envs.mjs --prefix", + "cache": { + "untrackedEnv": ["PROBE_NOISE_*"] + } + }, + "fetch-envs-all-untracked-except-vite": { + "command": "node scripts/fetch_envs.mjs --prefix \"\" --print-prefix VITE_", + "cache": { + "untrackedEnv": ["*", "!VITE_*"] + } + }, + "fetch-env-explicitly-untracked": { + "command": "node scripts/fetch_env.mjs PROBE_ENV", + "cache": { + "untrackedEnv": ["PROBE_ENV"] + } + }, "fetch-env-untracked": { "command": "node scripts/fetch_env.mjs --untracked PROBE_ENV", "cache": true diff --git a/crates/vt_graph/src/config/mod.rs b/crates/vt_graph/src/config/mod.rs index 967ef82a1..b75c6e35d 100644 --- a/crates/vt_graph/src/config/mod.rs +++ b/crates/vt_graph/src/config/mod.rs @@ -443,6 +443,7 @@ pub const DEFAULT_UNTRACKED_ENV: &[&str] = &[ // GitHub Actions "GITHUB_*", "RUNNER_*", + "ACTIONS_ORCHESTRATION_ID", // https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-cloud-providers#using-custom-actions // ACTIONS_ID_TOKEN_REQUEST_TOKEN is covered by *_TOKEN below. "ACTIONS_ID_TOKEN_REQUEST_URL", diff --git a/packages/vite-task-client/src/index.d.ts b/packages/vite-task-client/src/index.d.ts index f61f2882a..399101a99 100644 --- a/packages/vite-task-client/src/index.d.ts +++ b/packages/vite-task-client/src/index.d.ts @@ -49,7 +49,9 @@ export function getEnv(name: string, options?: { * * With `tracked: true` (the default) the runner records the pattern as a * dependency, so adding, removing, or changing a matching env invalidates - * this run's cache entry. + * this run's cache entry. Names matching the task's `cache.untrackedEnv` + * patterns are still returned, but their values do not invalidate the cache. + * Use `getEnv` for an explicit dependency on one of those names. * * Has no effect on `process.env`; the caller decides what to do with the * returned values. Returns an empty object when not running inside a runner. diff --git a/packages/vite-task-client/src/index.js b/packages/vite-task-client/src/index.js index 7a135f65d..bc767ff46 100644 --- a/packages/vite-task-client/src/index.js +++ b/packages/vite-task-client/src/index.js @@ -114,7 +114,9 @@ export function getEnv(name, options) { * * With `tracked: true` (the default) the runner records the pattern as a * dependency, so adding, removing, or changing a matching env invalidates - * this run's cache entry. + * this run's cache entry. Names matching the task's `cache.untrackedEnv` + * patterns are still returned, but their values do not invalidate the cache. + * Use `getEnv` for an explicit dependency on one of those names. * * Has no effect on `process.env`; the caller decides what to do with the * returned values. Returns an empty object when not running inside a runner. From 7a5f17f613abb1083c8cc9ed15cfd2a34c78f7f0 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Fri, 16 Oct 2026 08:12:24 -0400 Subject: [PATCH 2/4] docs: link environment cache fix to its pull request Co-authored-by: Codex (GPT-6) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc117382..7b8554057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** Broad environment queries now honor `cache.untrackedEnv`, so changes to CI metadata such as GitHub Actions' orchestration ID no longer invalidate cached builds. Explicit single-variable reads remain tracked ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). +- **Fixed** Broad environment queries now honor `cache.untrackedEnv`, so changes to CI metadata such as GitHub Actions' orchestration ID no longer invalidate cached builds. Explicit single-variable reads remain tracked ([#856](https://github.com/voidzero-dev/vite-task/pull/856)). - **Fixed** On Linux, concurrent system calls in tracked programs, such as `pnpm dlx`, no longer cause a stack overflow ([#854](https://github.com/voidzero-dev/vite-task/pull/854)). - **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program. On macOS, text after a `#` in the shebang line is now ignored, as it is when the script runs on its own ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). - **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)). From b929010faf28e7224dd44a29ba4354498c77eef2 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Fri, 16 Oct 2026 18:01:11 -0400 Subject: [PATCH 3/4] fix(cache): preserve tool dependencies unless explicitly untracked Keep complete environment observations and apply explicit task overrides consistently during validation. Preserve built-in passthrough defaults as tool dependencies and add a real Vite stale-bundle regression. Co-authored-by: Codex (GPT-6) --- CHANGELOG.md | 2 +- crates/vt/src/session/cache/mod.rs | 4 +- crates/vt/src/session/cache/validation.rs | 6 +- crates/vt/src/session/execute/cache_update.rs | 17 +-- crates/vt/src/session/execute/fingerprint.rs | 73 ++++++------- .../fixtures/ipc_client_test/snapshots.toml | 101 ++++++++++++++++-- ...clared_input_overrides_untracked_config.md | 23 ++++ ...xplicit_read_overrides_untracked_config.md | 36 ------- ...v_reads_honor_explicit_untracked_config.md | 47 ++++++++ ...tch_envs_queries_ignore_untracked_names.md | 4 +- ...emoved_ignore_uses_original_observation.md | 40 +++++++ .../fixtures/ipc_client_test/vite-task.json | 23 +++- .../fixtures/vite_build_cache/snapshots.toml | 49 +++++++++ .../vite_token_env_change_rebuilds_bundle.md | 43 ++++++++ .../fixtures/vite_build_cache/src/main.js | 1 + crates/vt_graph/run-config.ts | 2 + crates/vt_graph/src/config/mod.rs | 10 +- crates/vt_graph/src/config/user.rs | 2 + crates/vt_plan/src/cache_metadata.rs | 6 ++ crates/vt_plan/src/envs.rs | 1 + crates/vt_plan/src/plan.rs | 46 +++++++- packages/vite-task-client/src/index.d.ts | 9 +- packages/vite-task-client/src/index.js | 9 +- 23 files changed, 439 insertions(+), 115 deletions(-) create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_declared_input_overrides_untracked_config.md delete mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_explicit_read_overrides_untracked_config.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_reads_honor_explicit_untracked_config.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_removed_ignore_uses_original_observation.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_token_env_change_rebuilds_bundle.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8554057..5c8e1298b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** Broad environment queries now honor `cache.untrackedEnv`, so changes to CI metadata such as GitHub Actions' orchestration ID no longer invalidate cached builds. Explicit single-variable reads remain tracked ([#856](https://github.com/voidzero-dev/vite-task/pull/856)). +- **Fixed** Tool-reported environment reads now honor explicitly configured `cache.untrackedEnv` patterns, allowing tasks to ignore volatile CI metadata. Built-in passthrough defaults still track values consumed by tools, and values declared in `cache.env` remain cache dependencies ([#856](https://github.com/voidzero-dev/vite-task/pull/856)). - **Fixed** On Linux, concurrent system calls in tracked programs, such as `pnpm dlx`, no longer cause a stack overflow ([#854](https://github.com/voidzero-dev/vite-task/pull/854)). - **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program. On macOS, text after a `#` in the shebang line is now ignored, as it is when the script runs on its own ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). - **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)). diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index 690faaef8..dc636fca6 100644 --- a/crates/vt/src/session/cache/mod.rs +++ b/crates/vt/src/session/cache/mod.rs @@ -398,7 +398,7 @@ impl ExecutionCache { if let Some(cache_value) = self.get_by_cache_key(cache_key).await? { if let Some(mismatch) = cache_value.validate( &cache_metadata.unfiltered_envs, - &cache_metadata.spawn_fingerprint.env_fingerprints().untracked_env_config, + &cache_metadata.explicit_untracked_env, globbed_inputs, workspace_root, )? { @@ -448,7 +448,7 @@ impl ExecutionCache { let validate = |cache_value: &CacheEntryValue| { cache_value.validate( &cache_metadata.unfiltered_envs, - &cache_metadata.spawn_fingerprint.env_fingerprints().untracked_env_config, + &cache_metadata.explicit_untracked_env, globbed_inputs, workspace_root, ) diff --git a/crates/vt/src/session/cache/validation.rs b/crates/vt/src/session/cache/validation.rs index e224986d2..dee486aa7 100644 --- a/crates/vt/src/session/cache/validation.rs +++ b/crates/vt/src/session/cache/validation.rs @@ -4,7 +4,6 @@ use std::{collections::BTreeMap, ffi::OsStr, sync::Arc}; use rustc_hash::FxHashMap; use vt_casefold::EnvName; -use vt_glob::env::EnvGlobSet; use vt_path::{AbsolutePath, RelativePathBuf}; use super::{CacheEntryKey, CacheEntryValue, FingerprintMismatch, InputChangeKind}; @@ -21,7 +20,7 @@ impl CacheEntryValue { pub(crate) fn validate( &self, unfiltered_envs: &FxHashMap>, Arc>, - untracked_env_config: &[vt_str::Str], + explicit_untracked_env: &[vt_str::Str], globbed_inputs: &BTreeMap, workspace_root: &AbsolutePath, ) -> anyhow::Result> { @@ -29,9 +28,8 @@ impl CacheEntryValue { return Ok(Some(mismatch)); } - let untracked_env = EnvGlobSet::new(untracked_env_config)?; self.post_run_fingerprint - .validate(workspace_root, unfiltered_envs, &untracked_env) + .validate(workspace_root, unfiltered_envs, explicit_untracked_env) .map(|mismatch| mismatch.map(FingerprintMismatch::from)) } } diff --git a/crates/vt/src/session/execute/cache_update.rs b/crates/vt/src/session/execute/cache_update.rs index 7886cfe05..4f59ee17e 100644 --- a/crates/vt/src/session/execute/cache_update.rs +++ b/crates/vt/src/session/execute/cache_update.rs @@ -5,7 +5,6 @@ use std::{collections::BTreeMap, sync::Arc, time::Duration}; use rustc_hash::FxHashSet; use tokio_util::sync::CancellationToken; -use vt_glob::env::EnvGlobSet; use vt_path::{AbsolutePath, RelativePathBuf}; use vt_plan::cache_metadata::{CacheMetadata, EnvValueHash}; use vt_server::Reports; @@ -283,7 +282,7 @@ fn collect_tracked_reports( reports .map(|reports| { let tracked_envs = collect_tracked_envs(reports, metadata)?; - let tracked_env_queries = collect_tracked_env_queries(reports, metadata)?; + let tracked_env_queries = collect_tracked_env_queries(reports)?; Ok::<_, anyhow::Error>((tracked_envs, tracked_env_queries)) }) .transpose() @@ -347,14 +346,9 @@ fn collect_tracked_envs( } /// Select tool-reported bulk env query records to embed in the post-run -/// fingerprint. `untrackedEnv` names remain visible to the tool but their -/// values are not fingerprinted, just as for the spawned environment. -fn collect_tracked_env_queries( - reports: &Reports, - metadata: &CacheMetadata, -) -> anyhow::Result { - let untracked_env = - EnvGlobSet::new(metadata.spawn_fingerprint.env_fingerprints().untracked_env_config.iter())?; +/// fingerprint. Keep complete observations so removing an `untrackedEnv` +/// override can revalidate previously ignored dependencies. +fn collect_tracked_env_queries(reports: &Reports) -> anyhow::Result { let mut tracked_env_queries = BTreeMap::new(); for (query, record) in &reports.tracked_get_envs { @@ -367,9 +361,6 @@ fn collect_tracked_env_queries( let value_str = value.to_str().ok_or_else(|| { anyhow::anyhow!("tracked env match value for {name_str} is not valid UTF-8") })?; - if untracked_env.is_match(name_str) { - continue; - } matches.insert(Str::from(name_str), EnvValueHash::new(value_str)); } let query = match query { diff --git a/crates/vt/src/session/execute/fingerprint.rs b/crates/vt/src/session/execute/fingerprint.rs index da732e20c..decfc9ce2 100644 --- a/crates/vt/src/session/execute/fingerprint.rs +++ b/crates/vt/src/session/execute/fingerprint.rs @@ -143,8 +143,8 @@ impl PostRunFingerprint { /// unfiltered env context used by runner-aware IPC. `unfiltered_envs` must /// be the same plan env context that served the original `getEnv` request, /// not the filtered env passed to the spawned process. - /// Bulk queries omit `untrackedEnv` names from value comparisons. Explicit - /// single-name reads remain dependencies even when the name is untracked. + /// User-declared `untrackedEnv` patterns override both single-name and + /// bulk dependencies. Built-in passthrough defaults do not. /// /// Returns `Some(mismatch)` if anything changed, `None` if all valid. /// Returns an error if a tracked env is currently present but cannot be @@ -155,7 +155,7 @@ impl PostRunFingerprint { &self, base_dir: &AbsolutePath, unfiltered_envs: &FxHashMap>, Arc>, - untracked_env: &EnvGlobSet, + explicit_untracked_env: &[Str], ) -> anyhow::Result> { let input_mismatch = self.inferred_inputs.par_iter().find_map_any( |(input_relative_path, path_fingerprint)| { @@ -190,6 +190,11 @@ impl PostRunFingerprint { return result.map(Some); } + let has_env_reads = !self.tracked_envs.is_empty() || !self.tracked_env_queries.is_empty(); + let untracked_env = (has_env_reads && !explicit_untracked_env.is_empty()) + .then(|| EnvGlobSet::new(explicit_untracked_env)) + .transpose()?; + for (name, stored_value) in &self.tracked_envs { let current_value = unfiltered_envs .get(EnvName::from_ref(OsStr::new(name.as_str()))) @@ -200,6 +205,9 @@ impl PostRunFingerprint { Ok::<_, anyhow::Error>(EnvValueHash::new(value_str)) }) .transpose()?; + if untracked_env.as_ref().is_some_and(|patterns| patterns.is_match(name.as_str())) { + continue; + } if let Some(mismatch) = EnvMismatch::compare(name, stored_value.as_ref(), current_value.as_ref()) { @@ -208,7 +216,7 @@ impl PostRunFingerprint { } for (query, stored_matches) in &self.tracked_env_queries { - let current_matches = match match_env_query(query, unfiltered_envs, untracked_env)? { + let current_matches = match match_env_query(query, unfiltered_envs)? { EnvQueryValidation::Matches(matches) => matches, EnvQueryValidation::NonUtf8Value(mismatch) => { return Ok(Some(PostRunMismatch::TrackedEnvQuery { @@ -217,14 +225,9 @@ impl PostRunFingerprint { })); } }; - // Older entries can include untracked names. Apply the same policy - // to both sides so those entries can still be replayed. - let stored_matches = stored_matches - .iter() - .filter(|(name, _)| !untracked_env.is_match(name.as_str())) - .map(|(name, value)| (name.clone(), *value)) - .collect(); - if let Some(mismatch) = first_env_glob_mismatch(&stored_matches, ¤t_matches) { + if let Some(mismatch) = + first_env_glob_mismatch(stored_matches, ¤t_matches, untracked_env.as_ref()) + { return Ok(Some(PostRunMismatch::TrackedEnvQuery { query: query.clone(), mismatch, @@ -242,25 +245,21 @@ impl PostRunFingerprint { fn match_env_query( query: &TrackedEnvQuery, envs: &FxHashMap>, Arc>, - untracked_env: &EnvGlobSet, ) -> anyhow::Result { Ok(match query { TrackedEnvQuery::Glob(pattern) => { let glob = vt_glob::env::EnvGlob::new(pattern.as_str())?; - collect_matching_envs(envs, |name| glob.is_match(name), untracked_env) + collect_matching_envs(envs, |name| glob.is_match(name)) + } + TrackedEnvQuery::Prefix(prefix) => { + collect_matching_envs(envs, |name| EnvName::from_ref(name).starts_with(prefix.as_str())) } - TrackedEnvQuery::Prefix(prefix) => collect_matching_envs( - envs, - |name| EnvName::from_ref(name).starts_with(prefix.as_str()), - untracked_env, - ), }) } fn collect_matching_envs( envs: &FxHashMap>, Arc>, is_match: impl Fn(&str) -> bool, - untracked_env: &EnvGlobSet, ) -> EnvQueryValidation { let mut matches = BTreeMap::new(); for (name, value) in envs { @@ -275,11 +274,6 @@ fn collect_matching_envs( name: Str::from(name_str), }); }; - // The client rejects non-UTF-8 values even for untracked names, so - // validate representation before omitting the value dependency. - if untracked_env.is_match(name_str) { - continue; - } matches.insert(Str::from(name_str), EnvValueHash::new(value_str)); } EnvQueryValidation::Matches(matches) @@ -295,9 +289,12 @@ enum EnvQueryValidation { fn first_env_glob_mismatch( stored: &BTreeMap, current: &BTreeMap, + untracked_env: Option<&EnvGlobSet>, ) -> Option { - let mut stored_iter = stored.iter(); - let mut current_iter = current.iter(); + let is_tracked = + |name: &Str| !untracked_env.is_some_and(|patterns| patterns.is_match(name.as_str())); + let mut stored_iter = stored.iter().filter(|(name, _)| is_tracked(name)); + let mut current_iter = current.iter().filter(|(name, _)| is_tracked(name)); let mut s = stored_iter.next(); let mut c = current_iter.next(); @@ -536,12 +533,8 @@ mod tests { OsString::from_wide(&[0xD800]) } - fn no_untracked() -> EnvGlobSet { - EnvGlobSet::new(std::iter::empty::<&str>()).expect("empty patterns") - } - #[test] - fn validate_ignores_untracked_values_in_older_bulk_query_entries() { + fn validate_applies_current_overrides_to_complete_bulk_query_entries() { for query in [TrackedEnvQuery::Glob(Str::from("*")), TrackedEnvQuery::Prefix(Str::from(""))] { let stored_matches = BTreeMap::from([ @@ -562,7 +555,7 @@ mod tests { Arc::::from(OsStr::new("new-run")), ), ]); - let untracked = EnvGlobSet::new(["ACTIONS_*"]).expect("valid patterns"); + let untracked = [Str::from("ACTIONS_*")]; let workspace_root = vt_path::current_dir().expect("cwd"); assert!( fingerprint @@ -586,7 +579,7 @@ mod tests { EnvName::new(Arc::::from(OsStr::new("PROBE_NOISE"))), Arc::::from(non_utf8_os_string()), )]); - let untracked = EnvGlobSet::new(["PROBE_*"]).expect("valid patterns"); + let untracked = [Str::from("PROBE_*")]; let workspace_root = vt_path::current_dir().expect("cwd"); assert!(matches!( fingerprint @@ -610,7 +603,7 @@ mod tests { let workspace_root = vt_path::current_dir().expect("cwd"); let err = fingerprint - .validate(&workspace_root, &unfiltered_envs, &no_untracked()) + .validate(&workspace_root, &unfiltered_envs, &[]) .expect_err("non-UTF-8 tracked env values must error"); assert!(err.to_string().contains("tracked env value for PROBE_ENV is not valid UTF-8")); @@ -631,7 +624,7 @@ mod tests { let workspace_root = vt_path::current_dir().expect("cwd"); let mismatch = fingerprint - .validate(&workspace_root, &unfiltered_envs, &no_untracked()) + .validate(&workspace_root, &unfiltered_envs, &[]) .expect("validation succeeds"); match mismatch { @@ -667,7 +660,7 @@ mod tests { let workspace_root = vt_path::current_dir().expect("cwd"); let mismatch = fingerprint - .validate(&workspace_root, &unfiltered_envs, &no_untracked()) + .validate(&workspace_root, &unfiltered_envs, &[]) .expect("validation succeeds"); assert!(mismatch.is_none()); @@ -688,7 +681,7 @@ mod tests { let workspace_root = vt_path::current_dir().expect("cwd"); let mismatch = fingerprint - .validate(&workspace_root, &unfiltered_envs, &no_untracked()) + .validate(&workspace_root, &unfiltered_envs, &[]) .expect("validation succeeds"); assert!(mismatch.is_none()); @@ -708,7 +701,7 @@ mod tests { let workspace_root = vt_path::current_dir().expect("cwd"); let mismatch = fingerprint - .validate(&workspace_root, &unfiltered_envs, &no_untracked()) + .validate(&workspace_root, &unfiltered_envs, &[]) .expect("validation succeeds"); assert_eq!(mismatch.is_none(), cfg!(windows)); @@ -731,7 +724,7 @@ mod tests { let workspace_root = vt_path::current_dir().expect("cwd"); let mismatch = fingerprint - .validate(&workspace_root, &unfiltered_envs, &no_untracked()) + .validate(&workspace_root, &unfiltered_envs, &[]) .expect("validation succeeds"); assert_eq!(mismatch.is_none(), cfg!(windows)); diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml index 3bc0b41a5..00632e148 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml @@ -674,7 +674,7 @@ steps = [ [[e2e]] name = "fetch_envs_queries_ignore_untracked_names" comment = """ -Bulk glob and literal-prefix queries still serve untracked variables, but changing configured noise or the default ACTIONS_ORCHESTRATION_ID replays the cached result. +Bulk glob and literal-prefix queries still serve explicitly ignored variables, but changing those values replays the cached result. """ ignore = true steps = [ @@ -713,7 +713,7 @@ steps = [ "ACTIONS_ORCHESTRATION_ID", "run-2", ], - ], comment = "cache hit: configured and default untracked values changed" }, + ], comment = "cache hit: both explicitly ignored values changed" }, { argv = [ "vt", "run", @@ -798,9 +798,9 @@ steps = [ ] [[e2e]] -name = "fetch_env_explicit_read_overrides_untracked_config" +name = "fetch_env_reads_honor_explicit_untracked_config" comment = """ -An explicit tracked getEnv call remains a dependency even when the task declares the same name in untrackedEnv. +Explicit untrackedEnv applies equally to getEnv and an exact-name getEnvs query. """ ignore = true steps = [ @@ -813,25 +813,110 @@ steps = [ "PROBE_ENV", "first", ], - ], comment = "populate: getEnv explicitly records the otherwise untracked value" }, + ], comment = "populate: getEnv serves the explicitly ignored value" }, { argv = [ "vt", "run", "fetch-env-explicitly-untracked", + ], envs = [ + [ + "PROBE_ENV", + "second", + ], + ], comment = "cache hit: explicit untrackedEnv suppresses the scalar dependency" }, + { argv = [ + "vt", + "run", + "fetch-envs-exact-explicitly-untracked", ], envs = [ [ "PROBE_ENV", "first", ], - ], comment = "cache hit: the explicit dependency is unchanged" }, + ], comment = "populate: getEnvs serves the same exact-name query" }, { argv = [ "vt", "run", - "fetch-env-explicitly-untracked", + "fetch-envs-exact-explicitly-untracked", + ], envs = [ + [ + "PROBE_ENV", + "second", + ], + ], comment = "cache hit: the bulk query honors the same explicit policy" }, +] + +[[e2e]] +name = "fetch_env_declared_input_overrides_untracked_config" +comment = """ +A declared cache.env input still invalidates when the same name is explicitly listed in untrackedEnv. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-env-declared-and-untracked", + ], envs = [ + [ + "PROBE_ENV", + "first", + ], + ], comment = "populate: cache.env explicitly fingerprints the value" }, + { argv = [ + "vt", + "run", + "fetch-env-declared-and-untracked", ], envs = [ [ "PROBE_ENV", "second", ], - ], comment = "cache miss: an explicit getEnv read stays tracked" }, + ], comment = "cache miss: declared cache.env takes precedence over untrackedEnv" }, +] + +[[e2e]] +name = "fetch_envs_removed_ignore_uses_original_observation" +comment = """ +Removing an explicit NEXT_* ignore leaves the merged built-in passthrough policy unchanged. The original observed value must survive an ignored cache hit so the next run detects its change. Explicit empty file inputs and outputs isolate the environment policy change. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-envs-removed-ignore", + ], envs = [ + [ + "NEXT_PUBLIC_API_URL", + "first", + ], + ], comment = "populate: record the first value even though it is explicitly ignored" }, + { argv = [ + "vt", + "run", + "fetch-envs-removed-ignore", + ], envs = [ + [ + "NEXT_PUBLIC_API_URL", + "second", + ], + ], comment = "cache hit: explicit ignore permits the changed value" }, + { argv = [ + "vtt", + "replace-file-content", + "vite-task.json", + '"untrackedEnv": ["NEXT_*"]', + '"untrackedEnv": []', + ], comment = "remove the explicit ignore without changing the merged passthrough policy" }, + { argv = [ + "vt", + "run", + "fetch-envs-removed-ignore", + ], envs = [ + [ + "NEXT_PUBLIC_API_URL", + "second", + ], + ], comment = "cache miss: the original observation is now a dependency" }, ] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_declared_input_overrides_untracked_config.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_declared_input_overrides_untracked_config.md new file mode 100644 index 000000000..35c5fc5d1 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_declared_input_overrides_untracked_config.md @@ -0,0 +1,23 @@ +# fetch_env_declared_input_overrides_untracked_config + +A declared cache.env input still invalidates when the same name is explicitly listed in untrackedEnv. + +## `PROBE_ENV=first vt run fetch-env-declared-and-untracked` + +populate: cache.env explicitly fingerprints the value + +``` +$ node scripts/fetch_env.mjs PROBE_ENV +served PROBE_ENV=first +process.env PROBE_ENV=first +``` + +## `PROBE_ENV=second vt run fetch-env-declared-and-untracked` + +cache miss: declared cache.env takes precedence over untrackedEnv + +``` +$ node scripts/fetch_env.mjs PROBE_ENV ○ cache miss: env 'PROBE_ENV' changed, executing +served PROBE_ENV=second +process.env PROBE_ENV=second +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_explicit_read_overrides_untracked_config.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_explicit_read_overrides_untracked_config.md deleted file mode 100644 index 31a4298df..000000000 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_explicit_read_overrides_untracked_config.md +++ /dev/null @@ -1,36 +0,0 @@ -# fetch_env_explicit_read_overrides_untracked_config - -An explicit tracked getEnv call remains a dependency even when the task declares the same name in untrackedEnv. - -## `PROBE_ENV=first vt run fetch-env-explicitly-untracked` - -populate: getEnv explicitly records the otherwise untracked value - -``` -$ node scripts/fetch_env.mjs PROBE_ENV -served PROBE_ENV=first -process.env PROBE_ENV=first -``` - -## `PROBE_ENV=first vt run fetch-env-explicitly-untracked` - -cache hit: the explicit dependency is unchanged - -``` -$ node scripts/fetch_env.mjs PROBE_ENV ◉ cache hit, replaying -served PROBE_ENV=first -process.env PROBE_ENV=first - ---- -vt run: cache hit. -``` - -## `PROBE_ENV=second vt run fetch-env-explicitly-untracked` - -cache miss: an explicit getEnv read stays tracked - -``` -$ node scripts/fetch_env.mjs PROBE_ENV ○ cache miss: env 'PROBE_ENV' changed, executing -served PROBE_ENV=second -process.env PROBE_ENV=second -``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_reads_honor_explicit_untracked_config.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_reads_honor_explicit_untracked_config.md new file mode 100644 index 000000000..4f43f1470 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_reads_honor_explicit_untracked_config.md @@ -0,0 +1,47 @@ +# fetch_env_reads_honor_explicit_untracked_config + +Explicit untrackedEnv applies equally to getEnv and an exact-name getEnvs query. + +## `PROBE_ENV=first vt run fetch-env-explicitly-untracked` + +populate: getEnv serves the explicitly ignored value + +``` +$ node scripts/fetch_env.mjs PROBE_ENV +served PROBE_ENV=first +process.env PROBE_ENV=first +``` + +## `PROBE_ENV=second vt run fetch-env-explicitly-untracked` + +cache hit: explicit untrackedEnv suppresses the scalar dependency + +``` +$ node scripts/fetch_env.mjs PROBE_ENV ◉ cache hit, replaying +served PROBE_ENV=first +process.env PROBE_ENV=first + +--- +vt run: cache hit. +``` + +## `PROBE_ENV=first vt run fetch-envs-exact-explicitly-untracked` + +populate: getEnvs serves the same exact-name query + +``` +$ node scripts/fetch_envs.mjs --glob PROBE_ENV +PROBE_ENV=first +``` + +## `PROBE_ENV=second vt run fetch-envs-exact-explicitly-untracked` + +cache hit: the bulk query honors the same explicit policy + +``` +$ node scripts/fetch_envs.mjs --glob PROBE_ENV ◉ cache hit, replaying +PROBE_ENV=first + +--- +vt run: cache hit. +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_queries_ignore_untracked_names.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_queries_ignore_untracked_names.md index de6fbf85b..1fddf9cb4 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_queries_ignore_untracked_names.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_queries_ignore_untracked_names.md @@ -1,6 +1,6 @@ # fetch_envs_queries_ignore_untracked_names -Bulk glob and literal-prefix queries still serve untracked variables, but changing configured noise or the default ACTIONS_ORCHESTRATION_ID replays the cached result. +Bulk glob and literal-prefix queries still serve explicitly ignored variables, but changing those values replays the cached result. ## `PROBE_A=a PROBE_NOISE_ID=run-1 ACTIONS_ORCHESTRATION_ID=run-1 vt run fetch-envs-untracked-noise` @@ -15,7 +15,7 @@ PROBE_NOISE_ID=run-1 ## `PROBE_A=a PROBE_NOISE_ID=run-2 ACTIONS_ORCHESTRATION_ID=run-2 vt run fetch-envs-untracked-noise` -cache hit: configured and default untracked values changed +cache hit: both explicitly ignored values changed ``` $ node scripts/fetch_envs.mjs --glob "{PROBE_*,ACTIONS_ORCHESTRATION_ID}" ◉ cache hit, replaying diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_removed_ignore_uses_original_observation.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_removed_ignore_uses_original_observation.md new file mode 100644 index 000000000..760a6216e --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_removed_ignore_uses_original_observation.md @@ -0,0 +1,40 @@ +# fetch_envs_removed_ignore_uses_original_observation + +Removing an explicit NEXT_* ignore leaves the merged built-in passthrough policy unchanged. The original observed value must survive an ignored cache hit so the next run detects its change. Explicit empty file inputs and outputs isolate the environment policy change. + +## `NEXT_PUBLIC_API_URL=first vt run fetch-envs-removed-ignore` + +populate: record the first value even though it is explicitly ignored + +``` +$ node scripts/fetch_envs.mjs --prefix NEXT_PUBLIC_ +NEXT_PUBLIC_API_URL=first +``` + +## `NEXT_PUBLIC_API_URL=second vt run fetch-envs-removed-ignore` + +cache hit: explicit ignore permits the changed value + +``` +$ node scripts/fetch_envs.mjs --prefix NEXT_PUBLIC_ ◉ cache hit, replaying +NEXT_PUBLIC_API_URL=first + +--- +vt run: cache hit. +``` + +## `vtt replace-file-content vite-task.json '"untrackedEnv": ["NEXT_*"]' '"untrackedEnv": []'` + +remove the explicit ignore without changing the merged passthrough policy + +``` +``` + +## `NEXT_PUBLIC_API_URL=second vt run fetch-envs-removed-ignore` + +cache miss: the original observation is now a dependency + +``` +$ node scripts/fetch_envs.mjs --prefix NEXT_PUBLIC_ ○ cache miss: env 'NEXT_PUBLIC_API_URL' changed, executing +NEXT_PUBLIC_API_URL=second +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json index 0e45a48c0..7bfe51cc3 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json @@ -92,7 +92,7 @@ "fetch-envs-untracked-noise": { "command": "node scripts/fetch_envs.mjs --glob \"{PROBE_*,ACTIONS_ORCHESTRATION_ID}\"", "cache": { - "untrackedEnv": ["PROBE_NOISE_*"] + "untrackedEnv": ["PROBE_NOISE_*", "ACTIONS_ORCHESTRATION_ID"] } }, "fetch-envs-prefix-untracked-noise": { @@ -113,6 +113,27 @@ "untrackedEnv": ["PROBE_ENV"] } }, + "fetch-envs-exact-explicitly-untracked": { + "command": "node scripts/fetch_envs.mjs --glob PROBE_ENV", + "cache": { + "untrackedEnv": ["PROBE_ENV"] + } + }, + "fetch-env-declared-and-untracked": { + "command": "node scripts/fetch_env.mjs PROBE_ENV", + "cache": { + "env": ["PROBE_ENV"], + "untrackedEnv": ["PROBE_ENV"] + } + }, + "fetch-envs-removed-ignore": { + "command": "node scripts/fetch_envs.mjs --prefix NEXT_PUBLIC_", + "cache": { + "input": [], + "output": [], + "untrackedEnv": ["NEXT_*"] + } + }, "fetch-env-untracked": { "command": "node scripts/fetch_env.mjs --untracked PROBE_ENV", "cache": true diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml index 4248aa961..085111de2 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml @@ -100,6 +100,55 @@ steps = [ ], comment = "cache-bravo label is now in the bundle" }, ] +[[e2e]] +name = "vite_token_env_change_rebuilds_bundle" +comment = """ +Vite's default VITE_ prefix makes VITE_MAPBOX_TOKEN a bundle input even though *_TOKEN is a default untracked environment pattern. Changing only the ambient token must invalidate the cache and replace the old token in the emitted JavaScript without manual cache configuration. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "--cache", + "build", + ], envs = [ + [ + "VITE_MAPBOX_TOKEN", + "mapbox-token-alpha", + ], + ], comment = "first run: the ambient token is substituted into the bundle" }, + { argv = [ + "vtt", + "grep-file", + "dist/assets/main.js", + "mapbox-token-alpha", + ], comment = "the first bundle contains the first token" }, + { argv = [ + "vt", + "run", + "--cache", + "build", + ], envs = [ + [ + "VITE_MAPBOX_TOKEN", + "mapbox-token-bravo", + ], + ], comment = "cache miss: the token changed while every source file stayed the same" }, + { argv = [ + "vtt", + "grep-file", + "dist/assets/main.js", + "mapbox-token-alpha", + ], comment = "the rebuilt bundle no longer contains the first token" }, + { argv = [ + "vtt", + "grep-file", + "dist/assets/main.js", + "mapbox-token-bravo", + ], comment = "the rebuilt bundle contains the new token" }, +] + [[e2e]] name = "vite_build_cache_is_portable_across_workspace_roots" comment = """ diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_token_env_change_rebuilds_bundle.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_token_env_change_rebuilds_bundle.md new file mode 100644 index 000000000..f791511ba --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_token_env_change_rebuilds_bundle.md @@ -0,0 +1,43 @@ +# vite_token_env_change_rebuilds_bundle + +Vite's default VITE_ prefix makes VITE_MAPBOX_TOKEN a bundle input even though *_TOKEN is a default untracked environment pattern. Changing only the ambient token must invalidate the cache and replace the old token in the emitted JavaScript without manual cache configuration. + +## `VITE_MAPBOX_TOKEN=mapbox-token-alpha vt run --cache build` + +first run: the ambient token is substituted into the bundle + +``` +$ vite build +``` + +## `vtt grep-file dist/assets/main.js mapbox-token-alpha` + +the first bundle contains the first token + +``` +dist/assets/main.js: found "mapbox-token-alpha" +``` + +## `VITE_MAPBOX_TOKEN=mapbox-token-bravo vt run --cache build` + +cache miss: the token changed while every source file stayed the same + +``` +$ vite build ○ cache miss: env 'VITE_MAPBOX_TOKEN' changed, executing +``` + +## `vtt grep-file dist/assets/main.js mapbox-token-alpha` + +the rebuilt bundle no longer contains the first token + +``` +dist/assets/main.js: missing "mapbox-token-alpha" +``` + +## `vtt grep-file dist/assets/main.js mapbox-token-bravo` + +the rebuilt bundle contains the new token + +``` +dist/assets/main.js: found "mapbox-token-bravo" +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/src/main.js b/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/src/main.js index 918439bfb..f1145b84f 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/src/main.js +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/src/main.js @@ -1,3 +1,4 @@ // Vite replaces this at build time from keys matching `envPrefix` (`VITE_` by // default), so the e2e can verify that glob-tracked env changes rebuild output. console.log(import.meta.env.VITE_CACHE_LABEL); +console.log(import.meta.env.VITE_MAPBOX_TOKEN); diff --git a/crates/vt_graph/run-config.ts b/crates/vt_graph/run-config.ts index b32282aef..dda95012c 100644 --- a/crates/vt_graph/run-config.ts +++ b/crates/vt_graph/run-config.ts @@ -64,6 +64,8 @@ export type TaskCacheConfig = { env?: Array, /** * Environment variable names to be passed to the task without fingerprinting. + * Explicit patterns also override tool-reported env dependencies, unless + * the value is fingerprinted by `env` or a command-prefix assignment. */ untrackedEnv?: Array, /** diff --git a/crates/vt_graph/src/config/mod.rs b/crates/vt_graph/src/config/mod.rs index b75c6e35d..766515a94 100644 --- a/crates/vt_graph/src/config/mod.rs +++ b/crates/vt_graph/src/config/mod.rs @@ -65,8 +65,10 @@ impl ResolvedTaskOptions { let cache_config = match user_options.cache_config.unwrap_or_default().into_enabled() { None => None, Some(enabled_cache_config) => { + let explicit_untracked_env: Arc<[Str]> = + enabled_cache_config.untracked_env.unwrap_or_default().into(); let mut untracked_env: FxHashSet = - enabled_cache_config.untracked_env.unwrap_or_default().into_iter().collect(); + explicit_untracked_env.iter().cloned().collect(); untracked_env.extend(DEFAULT_UNTRACKED_ENV.iter().copied().map(Str::from)); let input_config = ResolvedGlobConfig::from_user_config( @@ -89,6 +91,7 @@ impl ResolvedTaskOptions { .map(|e| e.into_vec().into_iter().collect()) .unwrap_or_default(), untracked_env, + explicit_untracked_env, }, input_config, output_config, @@ -327,6 +330,10 @@ pub struct EnvConfig { pub fingerprinted_envs: FxHashSet, /// environment variable names to be passed to the task without fingerprinting, with defaults populated pub untracked_env: FxHashSet, + /// User-declared overrides for tool-reported dependencies. Built-in + /// passthrough defaults must not suppress values consumed by tools. + #[serde(skip)] + pub explicit_untracked_env: Arc<[Str]>, } #[derive(Debug, thiserror::Error)] @@ -443,7 +450,6 @@ pub const DEFAULT_UNTRACKED_ENV: &[&str] = &[ // GitHub Actions "GITHUB_*", "RUNNER_*", - "ACTIONS_ORCHESTRATION_ID", // https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-cloud-providers#using-custom-actions // ACTIONS_ID_TOKEN_REQUEST_TOKEN is covered by *_TOKEN below. "ACTIONS_ID_TOKEN_REQUEST_URL", diff --git a/crates/vt_graph/src/config/user.rs b/crates/vt_graph/src/config/user.rs index b6fa8c1c1..a7fce51dd 100644 --- a/crates/vt_graph/src/config/user.rs +++ b/crates/vt_graph/src/config/user.rs @@ -206,6 +206,8 @@ pub struct EnabledCacheConfig { pub env: Option>, /// Environment variable names to be passed to the task without fingerprinting. + /// Explicit patterns also override tool-reported env dependencies, unless + /// the value is fingerprinted by `env` or a command-prefix assignment. pub untracked_env: Option>, /// Files to include in the cache fingerprint. diff --git a/crates/vt_plan/src/cache_metadata.rs b/crates/vt_plan/src/cache_metadata.rs index d544e78f1..3fa12dafd 100644 --- a/crates/vt_plan/src/cache_metadata.rs +++ b/crates/vt_plan/src/cache_metadata.rs @@ -68,6 +68,12 @@ pub struct CacheMetadata { /// mirrors the ambient environment and would make snapshots noisy. #[serde(skip)] pub unfiltered_envs: Arc>, Arc>>, + + /// User-declared overrides for tool-reported env dependencies. Kept out + /// of the cache key: complete observations are stored and revalidated + /// under the current policy, including when an override is removed. + #[serde(skip)] + pub explicit_untracked_env: Arc<[Str]>, } /// Fingerprint for spawn execution that affects caching. diff --git a/crates/vt_plan/src/envs.rs b/crates/vt_plan/src/envs.rs index 336540d3a..30dd77500 100644 --- a/crates/vt_plan/src/envs.rs +++ b/crates/vt_plan/src/envs.rs @@ -190,6 +190,7 @@ mod tests { EnvConfig { fingerprinted_envs: fingerprinted.iter().map(|s| Str::from(*s)).collect(), untracked_env: untracked.iter().map(|s| Str::from(*s)).collect(), + explicit_untracked_env: untracked.iter().map(|s| Str::from(*s)).collect(), } } diff --git a/crates/vt_plan/src/plan.rs b/crates/vt_plan/src/plan.rs index c76ba93f8..af3ba76cc 100644 --- a/crates/vt_plan/src/plan.rs +++ b/crates/vt_plan/src/plan.rs @@ -485,7 +485,12 @@ fn resolve_synthetic_cache_config( workspace_path, ) .map_err(Error::ResolveTaskConfig)? - .cache_config) + .cache_config + .map(|mut config| { + // Handler defaults pass envs through without overriding tracked reads. + config.env_config.explicit_untracked_env = Arc::from([]); + config + })) } ParentCacheConfig::Disabled => Ok(Option::None), ParentCacheConfig::Inherited(mut parent_config) => { @@ -697,6 +702,7 @@ fn plan_spawn_execution( None }, unfiltered_envs: Arc::clone(envs), + explicit_untracked_env: Arc::clone(&cache_config.env_config.explicit_untracked_env), }); } } @@ -951,7 +957,7 @@ fn concurrency_limit_from_env( #[cfg(test)] mod tests { - use std::collections::BTreeSet; + use std::{collections::BTreeSet, sync::Arc}; use rustc_hash::FxHashSet; use vt_graph::config::{ @@ -983,6 +989,7 @@ mod tests { env_config: EnvConfig { fingerprinted_envs: FxHashSet::default(), untracked_env: FxHashSet::default(), + explicit_untracked_env: Arc::from([]), }, input_config: ResolvedGlobConfig { includes_auto, @@ -1001,6 +1008,41 @@ mod tests { items.iter().map(|s| Str::from(*s)).collect() } + #[test] + fn synthetic_untracked_env_preserves_user_policy() { + let (pkg, ws) = test_paths(); + let synthetic = || { + UserCacheConfig::with_config(EnabledCacheConfig { + env: None, + untracked_env: Some(vec!["HANDLER_*".into()]), + input: None, + output: None, + remote: None, + }) + }; + let top_level = + resolve_synthetic_cache_config(ParentCacheConfig::None, synthetic(), &pkg, &ws) + .unwrap() + .unwrap(); + assert!(top_level.env_config.untracked_env.contains("HANDLER_*")); + assert!(top_level.env_config.explicit_untracked_env.is_empty()); + + let mut parent = parent_config(true, &[]); + parent.env_config.untracked_env.insert("USER_*".into()); + parent.env_config.explicit_untracked_env = Arc::from([Str::from("USER_*")]); + let inherited = resolve_synthetic_cache_config( + ParentCacheConfig::Inherited(parent), + synthetic(), + &pkg, + &ws, + ) + .unwrap() + .unwrap(); + assert!(inherited.env_config.untracked_env.contains("HANDLER_*")); + assert!(inherited.env_config.untracked_env.contains("USER_*")); + assert_eq!(inherited.env_config.explicit_untracked_env, Arc::from([Str::from("USER_*")])); + } + #[test] fn synthetic_input_none_preserves_parent() { let (pkg, ws) = test_paths(); diff --git a/packages/vite-task-client/src/index.d.ts b/packages/vite-task-client/src/index.d.ts index 399101a99..1dd43b42c 100644 --- a/packages/vite-task-client/src/index.d.ts +++ b/packages/vite-task-client/src/index.d.ts @@ -30,6 +30,9 @@ export function disableCache(): void; * * With `tracked: true` (the default) the runner records `name` as a * dependency, so a change to its value invalidates this run's cache entry. + * An explicit task `cache.untrackedEnv` pattern overrides this tracking; + * built-in passthrough defaults do not. Values declared in `cache.env` + * remain cache dependencies. * * Has no effect on `process.env`; the caller decides what to do with the * returned value. Returns `undefined` when not running inside a runner. @@ -50,8 +53,10 @@ export function getEnv(name: string, options?: { * With `tracked: true` (the default) the runner records the pattern as a * dependency, so adding, removing, or changing a matching env invalidates * this run's cache entry. Names matching the task's `cache.untrackedEnv` - * patterns are still returned, but their values do not invalidate the cache. - * Use `getEnv` for an explicit dependency on one of those names. + * patterns explicitly configured by the user are still returned, but their + * addition, removal, or value changes do not invalidate the cache. Built-in + * passthrough defaults do not override tracking. Values declared in + * `cache.env` remain cache dependencies. * * Has no effect on `process.env`; the caller decides what to do with the * returned values. Returns an empty object when not running inside a runner. diff --git a/packages/vite-task-client/src/index.js b/packages/vite-task-client/src/index.js index bc767ff46..66ec4d3ed 100644 --- a/packages/vite-task-client/src/index.js +++ b/packages/vite-task-client/src/index.js @@ -92,6 +92,9 @@ export function disableCache() { * * With `tracked: true` (the default) the runner records `name` as a * dependency, so a change to its value invalidates this run's cache entry. + * An explicit task `cache.untrackedEnv` pattern overrides this tracking; + * built-in passthrough defaults do not. Values declared in `cache.env` + * remain cache dependencies. * * Has no effect on `process.env`; the caller decides what to do with the * returned value. Returns `undefined` when not running inside a runner. @@ -115,8 +118,10 @@ export function getEnv(name, options) { * With `tracked: true` (the default) the runner records the pattern as a * dependency, so adding, removing, or changing a matching env invalidates * this run's cache entry. Names matching the task's `cache.untrackedEnv` - * patterns are still returned, but their values do not invalidate the cache. - * Use `getEnv` for an explicit dependency on one of those names. + * patterns explicitly configured by the user are still returned, but their + * addition, removal, or value changes do not invalidate the cache. Built-in + * passthrough defaults do not override tracking. Values declared in + * `cache.env` remain cache dependencies. * * Has no effect on `process.env`; the caller decides what to do with the * returned values. Returns an empty object when not running inside a runner. From 7c373ac2c0cd3593c2448386f032565defc7734c Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Fri, 16 Oct 2026 18:10:17 -0400 Subject: [PATCH 4/4] docs(cache): disclose explicit untrackedEnv behavior change Co-authored-by: Codex (GPT-6) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c8e1298b..d0997381b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** Tool-reported environment reads now honor explicitly configured `cache.untrackedEnv` patterns, allowing tasks to ignore volatile CI metadata. Built-in passthrough defaults still track values consumed by tools, and values declared in `cache.env` remain cache dependencies ([#856](https://github.com/voidzero-dev/vite-task/pull/856)). +- **Changed** Explicit `cache.untrackedEnv` patterns now override environment dependencies reported by tools. To keep those variables tracked, declare them in `cache.env` or negate their `untrackedEnv` patterns with `!`. Built-in passthrough defaults do not cancel tool-reported tracking ([#856](https://github.com/voidzero-dev/vite-task/pull/856)). - **Fixed** On Linux, concurrent system calls in tracked programs, such as `pnpm dlx`, no longer cause a stack overflow ([#854](https://github.com/voidzero-dev/vite-task/pull/854)). - **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program. On macOS, text after a `#` in the shebang line is now ignored, as it is when the script runs on its own ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). - **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)).