Skip to content

Commit 90d901d

Browse files
feat: enable default minimum dependency age (#35458)
Enables a minimum dependency age by default so npm version resolution skips releases published within the last 24 hours (1440 minutes) when nothing else is configured. This adds a small cooldown that mitigates installing a freshly published, potentially compromised version the moment it lands. The default sits at the bottom of the existing precedence chain, so it only applies when no age is set anywhere else. Resolution order is: the CLI flag, then `minimumDependencyAge` in `deno.json`, then `min-release-age` in `.npmrc`, then the `NPM_CONFIG_MIN_RELEASE_AGE` / `npm_config_min_release_age` environment variables, and finally the 1440 minute default. `NPM_CONFIG_MIN_RELEASE_AGE` is now read as an npm-style fallback for `min-release-age`, with an explicit `.npmrc` value still taking precedence over it. A numeric `0` (and `false`) for `minimumDependencyAge` now disables the filter, matching the existing string `"0"` behavior and avoiding surprises when a user's clock is off. Because the default is opt-out, the shared local npm fixture test environments set `NPM_CONFIG_MIN_RELEASE_AGE=0` so existing specs keep resolving the latest fixture versions. The new integration tests remove that override to cover the real product default, the explicit-disable path, and the env var fallback. --------- Co-authored-by: Nathan Whitaker <nathan@deno.com>
1 parent 2894458 commit 90d901d

6 files changed

Lines changed: 187 additions & 18 deletions

File tree

libs/config/deno_json/mod.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2646,9 +2646,15 @@ impl ConfigFile {
26462646
{
26472647
match value {
26482648
serde_json::Value::Null => Ok(None),
2649-
serde_json::Value::Number(minutes) => Ok(Some(
2650-
NewestDependencyDate::Enabled(parse_number(minutes, sys)?),
2651-
)),
2649+
serde_json::Value::Number(minutes) => {
2650+
if minutes.as_i64() == Some(0) {
2651+
Ok(Some(NewestDependencyDate::Disabled))
2652+
} else {
2653+
Ok(Some(NewestDependencyDate::Enabled(parse_number(
2654+
minutes, sys,
2655+
)?)))
2656+
}
2657+
}
26522658
serde_json::Value::String(value) => Ok(Some(
26532659
crate::util::parse_minutes_duration_or_date(sys, value)?,
26542660
)),
@@ -3268,6 +3274,19 @@ mod tests {
32683274
assert!(config.age.is_some());
32693275
assert!(config.exclude.is_empty());
32703276

3277+
// Numeric 0 disables the filter.
3278+
let config = parse(r#"{ "minimumDependencyAge": 0 }"#);
3279+
assert_eq!(config.age, Some(NewestDependencyDate::Disabled));
3280+
assert!(config.exclude.is_empty());
3281+
3282+
let config = parse(r#"{ "minimumDependencyAge": "0" }"#);
3283+
assert_eq!(config.age, Some(NewestDependencyDate::Disabled));
3284+
assert!(config.exclude.is_empty());
3285+
3286+
let config = parse(r#"{ "minimumDependencyAge": false }"#);
3287+
assert_eq!(config.age, Some(NewestDependencyDate::Disabled));
3288+
assert!(config.exclude.is_empty());
3289+
32713290
// Unknown field still errors.
32723291
let specifier = Url::parse("file:///deno/deno.json").unwrap();
32733292
let config_file = ConfigFile::new(

libs/npmrc/lib.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ impl NpmRc {
7979
let mut registry = None;
8080
let mut scope_registries: HashMap<String, String> = HashMap::new();
8181
let mut registry_configs: HashMap<String, RegistryConfig> = HashMap::new();
82-
let mut min_release_age_days: Option<u64> = None;
82+
let mut min_release_age_days = min_release_age_days_from_env(sys);
8383

8484
for kv_or_section in kv_or_sections {
8585
match kv_or_section {
@@ -254,6 +254,19 @@ impl NpmRc {
254254
}
255255
}
256256

257+
pub fn min_release_age_days_from_env(sys: &impl EnvVar) -> Option<u64> {
258+
for env_var_name in
259+
["NPM_CONFIG_MIN_RELEASE_AGE", "npm_config_min_release_age"]
260+
{
261+
if let Ok(value) = sys.env_var(env_var_name)
262+
&& let Ok(days) = value.trim().parse::<u64>()
263+
{
264+
return Some(days);
265+
}
266+
}
267+
None
268+
}
269+
257270
fn get_scope_name(package_name: &str) -> Option<&str> {
258271
let no_at_pkg_name = package_name.strip_prefix('@')?;
259272
no_at_pkg_name.split_once('/').map(|(scope, _)| scope)
@@ -851,6 +864,16 @@ registry=${VAR_FOUND}
851864
sys.env_set_var("MIN_AGE", "7");
852865
let npm_rc = NpmRc::parse(&sys, "min-release-age=${MIN_AGE}").unwrap();
853866
assert_eq!(npm_rc.min_release_age_days, Some(7));
867+
868+
// npm config environment variable
869+
let sys = InMemorySys::default();
870+
sys.env_set_var("NPM_CONFIG_MIN_RELEASE_AGE", "4");
871+
let npm_rc = NpmRc::parse(&sys, "").unwrap();
872+
assert_eq!(npm_rc.min_release_age_days, Some(4));
873+
874+
// .npmrc value takes precedence over the environment fallback.
875+
let npm_rc = NpmRc::parse(&sys, "min-release-age=5").unwrap();
876+
assert_eq!(npm_rc.min_release_age_days, Some(5));
854877
}
855878

856879
#[test]

libs/resolver/factory.rs

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ use crate::workspace::SloppyImportsOptions;
9393
use crate::workspace::WorkspaceNpmLinkPackagesRc;
9494
use crate::workspace::WorkspaceResolver;
9595

96+
const DEFAULT_MINIMUM_DEPENDENCY_AGE_MINUTES: i64 = 1440;
97+
9698
// todo(https://github.com/rust-lang/rust/issues/109737): remove once_cell after get_or_try_init is stabilized
9799
#[cfg(feature = "sync")]
98100
type Deferred<T> = once_cell::sync::OnceCell<T>;
@@ -1015,20 +1017,14 @@ impl<TSys: WorkspaceFactorySys> ResolverFactory<TSys> {
10151017
let workspace = &workspace_factory.workspace_directory()?.workspace;
10161018
workspace.minimum_dependency_age(workspace_factory.sys())?
10171019
};
1018-
// fall back to .npmrc's `min-release-age` when not configured in deno.json
1019-
if config.age.is_none()
1020-
&& let Some(days) = workspace_factory.npmrc()?.min_release_age_days
1021-
{
1022-
let now = chrono::DateTime::<chrono::Utc>::from(
1023-
workspace_factory.sys().sys_time_now(),
1020+
if config.age.is_none() {
1021+
apply_minimum_dependency_age_fallbacks(
1022+
&mut config,
1023+
workspace_factory.npmrc()?.min_release_age_days,
1024+
chrono::DateTime::<chrono::Utc>::from(
1025+
workspace_factory.sys().sys_time_now(),
1026+
),
10241027
);
1025-
config.age = Some(if days == 0 {
1026-
NewestDependencyDate::Disabled
1027-
} else {
1028-
NewestDependencyDate::Enabled(
1029-
now - chrono::Duration::days(days as i64),
1030-
)
1031-
});
10321028
}
10331029
if let Some(newest_dependency_date) =
10341030
config.age.and_then(|d| d.into_option())
@@ -1306,6 +1302,29 @@ impl<TSys: WorkspaceFactorySys> ResolverFactory<TSys> {
13061302
}
13071303
}
13081304

1305+
fn apply_minimum_dependency_age_fallbacks(
1306+
config: &mut MinimumDependencyAgeConfig,
1307+
npmrc_min_release_age_days: Option<u64>,
1308+
now: chrono::DateTime<chrono::Utc>,
1309+
) {
1310+
if config.age.is_some() {
1311+
return;
1312+
}
1313+
1314+
// Fall back to .npmrc's `min-release-age` when not configured in deno.json.
1315+
config.age = Some(if let Some(days) = npmrc_min_release_age_days {
1316+
if days == 0 {
1317+
NewestDependencyDate::Disabled
1318+
} else {
1319+
NewestDependencyDate::Enabled(now - chrono::Duration::days(days as i64))
1320+
}
1321+
} else {
1322+
NewestDependencyDate::Enabled(
1323+
now - chrono::Duration::minutes(DEFAULT_MINIMUM_DEPENDENCY_AGE_MINUTES),
1324+
)
1325+
});
1326+
}
1327+
13091328
/// Parses npm overrides from a workspace's root package.json.
13101329
///
13111330
/// Returns `NpmOverrides::default()` if no overrides are present or if parsing fails.
@@ -1334,3 +1353,66 @@ pub fn npm_overrides_from_workspace(
13341353
}
13351354
}
13361355
}
1356+
1357+
#[cfg(test)]
1358+
mod tests {
1359+
use chrono::TimeZone;
1360+
use deno_config::deno_json::MinimumDependencyAgeConfig;
1361+
use deno_config::deno_json::NewestDependencyDate;
1362+
1363+
use super::apply_minimum_dependency_age_fallbacks;
1364+
1365+
fn now() -> chrono::DateTime<chrono::Utc> {
1366+
chrono::Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap()
1367+
}
1368+
1369+
#[test]
1370+
fn minimum_dependency_age_falls_back_to_default() {
1371+
let mut config = MinimumDependencyAgeConfig::default();
1372+
apply_minimum_dependency_age_fallbacks(&mut config, None, now());
1373+
assert_eq!(
1374+
config.age,
1375+
Some(NewestDependencyDate::Enabled(
1376+
now() - chrono::Duration::minutes(1440)
1377+
))
1378+
);
1379+
}
1380+
1381+
#[test]
1382+
fn minimum_dependency_age_preserves_exclude_with_default() {
1383+
let mut config = MinimumDependencyAgeConfig {
1384+
age: None,
1385+
exclude: vec!["npm:chalk".to_string()],
1386+
};
1387+
apply_minimum_dependency_age_fallbacks(&mut config, None, now());
1388+
assert_eq!(
1389+
config.age,
1390+
Some(NewestDependencyDate::Enabled(
1391+
now() - chrono::Duration::minutes(1440)
1392+
))
1393+
);
1394+
assert_eq!(config.exclude, vec!["npm:chalk".to_string()]);
1395+
}
1396+
1397+
#[test]
1398+
fn minimum_dependency_age_prefers_npmrc_over_default() {
1399+
let mut config = MinimumDependencyAgeConfig::default();
1400+
apply_minimum_dependency_age_fallbacks(&mut config, Some(2), now());
1401+
assert_eq!(
1402+
config.age,
1403+
Some(NewestDependencyDate::Enabled(
1404+
now() - chrono::Duration::days(2)
1405+
))
1406+
);
1407+
}
1408+
1409+
#[test]
1410+
fn minimum_dependency_age_preserves_explicit_disable() {
1411+
let mut config = MinimumDependencyAgeConfig {
1412+
age: Some(NewestDependencyDate::Disabled),
1413+
exclude: Vec::new(),
1414+
};
1415+
apply_minimum_dependency_age_fallbacks(&mut config, Some(2), now());
1416+
assert_eq!(config.age, Some(NewestDependencyDate::Disabled));
1417+
}
1418+
}

libs/resolver/npmrc.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,6 @@ pub fn create_default_npmrc(sys: &impl EnvVar) -> ResolvedNpmRc {
238238
},
239239
)]),
240240
registry_configs: Default::default(),
241-
min_release_age_days: None,
241+
min_release_age_days: deno_npmrc::min_release_age_days_from_env(sys),
242242
}
243243
}

tests/integration/pm_tests.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,26 @@ fn add_npm() {
186186
}));
187187
}
188188

189+
#[test]
190+
fn add_npm_latest_default_minimum_dependency_age_downgrades() {
191+
let context = pm_context_builder().build();
192+
let temp_dir = context.temp_dir().path();
193+
194+
let output = context
195+
.new_command()
196+
.env_remove("NPM_CONFIG_MIN_RELEASE_AGE")
197+
.args("add npm:@denotest/min-release-age-latest@latest")
198+
.run();
199+
output.assert_exit_code(0);
200+
let output = output.combined_output();
201+
assert_contains!(output, "Add npm:@denotest/min-release-age-latest@1.0.0");
202+
temp_dir.join("deno.json").assert_matches_json(json!({
203+
"imports": {
204+
"@denotest/min-release-age-latest": "npm:@denotest/min-release-age-latest@^1.0.0"
205+
}
206+
}));
207+
}
208+
189209
#[test]
190210
fn add_npm_latest_minimum_dependency_age_downgrades() {
191211
let context = pm_context_builder().build();
@@ -209,6 +229,29 @@ fn add_npm_latest_minimum_dependency_age_downgrades() {
209229
}));
210230
}
211231

232+
#[test]
233+
fn add_npm_latest_minimum_dependency_age_disabled() {
234+
let context = pm_context_builder().build();
235+
let temp_dir = context.temp_dir().path();
236+
temp_dir.join("deno.json").write_json(&json!({
237+
"minimumDependencyAge": false,
238+
}));
239+
240+
let output = context
241+
.new_command()
242+
.args("add npm:@denotest/min-release-age-latest@latest")
243+
.run();
244+
output.assert_exit_code(0);
245+
let output = output.combined_output();
246+
assert_contains!(output, "Add npm:@denotest/min-release-age-latest@2.0.0");
247+
temp_dir.join("deno.json").assert_matches_json(json!({
248+
"minimumDependencyAge": false,
249+
"imports": {
250+
"@denotest/min-release-age-latest": "npm:@denotest/min-release-age-latest@^2.0.0"
251+
}
252+
}));
253+
}
254+
212255
#[test]
213256
fn add_npm_latest_npmrc_min_release_age_downgrades() {
214257
let context = pm_context_builder().build();

tests/util/lib/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ pub static IS_CI: Lazy<bool> = Lazy::new(|| std::env::var("CI").is_ok());
6363
pub fn env_vars_for_npm_tests() -> Vec<(String, String)> {
6464
vec![
6565
("NPM_CONFIG_REGISTRY".to_string(), npm_registry_url()),
66+
("NPM_CONFIG_MIN_RELEASE_AGE".to_string(), "0".to_string()),
6667
("JSR_NPM_URL".to_string(), npm_jsr_registry_url()),
6768
("NODEJS_ORG_MIRROR".to_string(), nodejs_org_mirror_url()),
6869
("NO_COLOR".to_string(), "1".to_string()),
@@ -137,6 +138,7 @@ pub fn env_vars_for_jsr_provenance_tests() -> Vec<(String, String)> {
137138
pub fn env_vars_for_jsr_npm_tests() -> Vec<(String, String)> {
138139
vec![
139140
("NPM_CONFIG_REGISTRY".to_string(), npm_registry_url()),
141+
("NPM_CONFIG_MIN_RELEASE_AGE".to_string(), "0".to_string()),
140142
("JSR_NPM_URL".to_string(), npm_jsr_registry_url()),
141143
("JSR_URL".to_string(), jsr_registry_url()),
142144
(

0 commit comments

Comments
 (0)