Skip to content

Commit a6648ab

Browse files
committed
feat(update): add minimumReleaseAge and default to 1 day
Refs #302
1 parent 89d5654 commit a6648ab

18 files changed

Lines changed: 415 additions & 12 deletions

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ syncpack fix -h
8484

8585
### [update](https://syncpack.dev/command/update)
8686

87-
Update packages to the latest versions from the npm registry, wherever they are in your monorepo.<br/>Semver range preferences are preserved when updating.
87+
Update packages to the latest versions from the npm registry, wherever they are in your monorepo, including pnpm catalog entries in `pnpm-workspace.yaml`.<br/>Semver range preferences are preserved when updating.
8888

8989
#### Examples
9090

@@ -99,6 +99,10 @@ syncpack update --target patch
9999
syncpack update --check --source 'packages/pingu/package.json'
100100
# Update dependencies and devDependencies in the whole monorepo
101101
syncpack update --dependency-types dev,prod
102+
# Update only pnpm catalog entries in pnpm-workspace.yaml
103+
syncpack update --dependency-types pnpmCatalog
104+
# Update only the named pnpm catalog 'react18'
105+
syncpack update --dependency-types 'pnpmCatalog:react18'
102106
# Only update dependencies with a semver range specifier (^, ~, etc.)
103107
syncpack update --specifier-types range
104108
# Update dependencies where name exactly matches 'react'

npm/syncpack.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ export interface RcFile {
1515
indent?: string;
1616
/** @see https://syncpack.dev/config/max-concurrent-requests */
1717
maxConcurrentRequests?: number;
18+
/**
19+
* Skip dependency updates published less than this many minutes ago.
20+
* `0` disables the filter. When omitted, the value from the project's
21+
* `pnpm-workspace.yaml` is used; if neither is set, defaults to `1440`
22+
* (one day). Setting it here always overrides the pnpm value.
23+
* @see https://pnpm.io/settings#minimumreleaseage
24+
*/
25+
minimumReleaseAge?: number;
1826
/** @see https://syncpack.dev/semver-groups */
1927
semverGroups?: SemverGroup.Any[];
2028
/** @see https://syncpack.dev/config/sort-az */

site/astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export default defineConfig({
110110
'config/format-repository',
111111
'config/indent',
112112
'config/max-concurrent-requests',
113+
'config/minimum-release-age',
113114
{ label: 'semverGroups', link: '/semver-groups/' },
114115
'config/sort-az',
115116
'config/sort-exports',

site/remark-plugins/link-aliases.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export function linkAliases() {
1414
CONFIG_FORMAT_BUGS: '/config/format-bugs/',
1515
CONFIG_FORMAT_REPOSITORY: '/config/format-repository/',
1616
CONFIG_INDENT: '/config/indent/',
17+
CONFIG_MINIMUM_RELEASE_AGE: '/config/minimum-release-age/',
1718
CONFIG_SEMVER_GROUPS: '/semver-groups/',
1819
CONFIG_SORT_AZ: '/config/sort-az/',
1920
CONFIG_SORT_EXPORTS: '/config/sort-exports/',
@@ -64,6 +65,7 @@ export function linkAliases() {
6465
HREF_PACKAGE_MANAGER: 'https://nodejs.org/api/packages.html#packagemanager',
6566
HREF_PEER_DEPENDENCIES: 'https://docs.npmjs.com/cli/v11/configuring-npm/package-json#peerDependencies',
6667
HREF_PNPM: 'https://pnpm.js.org/',
68+
HREF_PNPM_MINIMUM_RELEASE_AGE: 'https://pnpm.io/settings#minimumreleaseage',
6769
HREF_PNPM_OVERRIDES: 'https://pnpm.io/settings#overrides',
6870
HREF_RESOLUTIONS: 'https://docs.npmjs.com/cli/v11/configuring-npm/package-json#resolutions',
6971
HREF_SYNCPACK_GITHUB_ACTION:

site/src/content/docs/command/update.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
---
22
title: update
33
description: Update dependency versions to latest releases from the npm registry
4+
sidebar:
5+
badge: Updated
46
---
57

68
import { Badge } from "@astrojs/starlight/components";
@@ -19,7 +21,7 @@ import SourceOption from "@partials/option/source.mdx";
1921
import SpecifierTypesOption from "@partials/option/specifier-types.mdx";
2022
import TargetOption from "@partials/option/target.mdx";
2123

22-
Update dependencies in your monorepo to newer versions from the npm registry. Checks for available updates and modifies package.json files to use them. Unlike `fix` which synchronises versions across packages, `update` fetches the latest published versions. Use `--target` to control update strategy (latest, minor, patch).
24+
Update dependencies in your monorepo to newer versions from the npm registry. Checks for available updates and modifies package.json files — and `pnpm-workspace.yaml` catalog entries when present — to use them. Unlike `fix` which synchronises versions across packages, `update` fetches the latest published versions. Use `--target` to control update strategy (latest, minor, patch). Versions newer than [minimumReleaseAge](CONFIG_MINIMUM_RELEASE_AGE) are excluded by default to reduce supply chain attack risk.
2325

2426
## Examples
2527

@@ -34,6 +36,10 @@ syncpack update --target patch
3436
syncpack update --check --source 'packages/pingu/package.json'
3537
# Update dependencies and devDependencies in the whole monorepo
3638
syncpack update --dependency-types dev,prod
39+
# Update only pnpm catalog entries in pnpm-workspace.yaml
40+
syncpack update --dependency-types pnpmCatalog
41+
# Update only the named pnpm catalog 'react18'
42+
syncpack update --dependency-types 'pnpmCatalog:react18'
3743
# Only update dependencies with a semver range specifier (^, ~, etc.)
3844
syncpack update --specifier-types range
3945
# Update dependencies where name exactly matches 'react'

site/src/content/docs/config/custom-types.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
---
22
title: customTypes
33
description: Define custom package.json properties to manage beyond standard dependency types
4+
sidebar:
5+
badge: Updated
46
---
57

68
import { Badge } from "@astrojs/starlight/components";
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
title: minimumReleaseAge
3+
description: Skip updates to dependency versions published more recently than this threshold
4+
sidebar:
5+
badge: New
6+
---
7+
8+
When using the [update](COMMAND_UPDATE) command, the minimum age (in minutes) a published version must reach before syncpack will consider it as an available update. Reduces supply chain attack risk by ignoring versions that have been on the registry for less time than the configured window — most malicious releases are detected and unpublished within an hour.
9+
10+
When this option is omitted from the [rcfile](TERM_RCFILE), syncpack reads `minimumReleaseAge` from the project's `pnpm-workspace.yaml` if present. When neither is set, it defaults to `1440` (one day). Setting `0` disables the filter.
11+
12+
## Default Value
13+
14+
```json title=".syncpackrc.json"
15+
{
16+
"minimumReleaseAge": 1440
17+
}
18+
```
19+
20+
## Examples
21+
22+
Wait one week before considering a published version:
23+
24+
```json title=".syncpackrc.json"
25+
{
26+
"minimumReleaseAge": 10080
27+
}
28+
```
29+
30+
Disable the filter and consider all published versions:
31+
32+
```json title=".syncpackrc.json"
33+
{
34+
"minimumReleaseAge": 0
35+
}
36+
```
37+
38+
Inherit the value already configured for [pnpm](HREF_PNPM_MINIMUM_RELEASE_AGE) by omitting it from the rcfile:
39+
40+
```yaml title="pnpm-workspace.yaml"
41+
minimumReleaseAge: 1440
42+
```

src/rcfile.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ fn default_max_concurrent_requests() -> usize {
109109
12
110110
}
111111

112+
/// Default `minimumReleaseAge` (one day in minutes) used when neither the
113+
/// rcfile nor `pnpm-workspace.yaml` provides a value. Resolution lives in
114+
/// `rcfile::from_disk::resolve_minimum_release_age`.
115+
pub(crate) const DEFAULT_MINIMUM_RELEASE_AGE: u64 = 1440;
116+
112117
fn default_true() -> bool {
113118
true
114119
}
@@ -213,6 +218,11 @@ pub(crate) struct RawRcfile {
213218
pub indent: Option<String>,
214219
#[serde(default = "default_max_concurrent_requests")]
215220
pub max_concurrent_requests: usize,
221+
/// User-supplied value from the rcfile. `None` means "fall back to
222+
/// pnpm-workspace.yaml or the default" — resolution happens in
223+
/// `from_disk::resolve_minimum_release_age`.
224+
#[serde(default)]
225+
pub minimum_release_age: Option<u64>,
216226
#[serde(default)]
217227
pub semver_groups: Vec<AnySemverGroup>,
218228
#[serde(default = "default_sort_az")]
@@ -358,6 +368,10 @@ impl TryFrom<RawRcfile> for Rcfile {
358368
format_repository: raw.format_repository,
359369
indent: raw.indent,
360370
max_concurrent_requests: raw.max_concurrent_requests,
371+
// `from_disk` re-resolves this against pnpm-workspace.yaml. The
372+
// `try_from`-only paths (tests, `Rcfile::default()`) get the
373+
// default here so consumers always see a `u64`.
374+
minimum_release_age: raw.minimum_release_age.unwrap_or(DEFAULT_MINIMUM_RELEASE_AGE),
361375
semver_groups,
362376
sort_az: raw.sort_az,
363377
sort_exports: raw.sort_exports,
@@ -378,6 +392,10 @@ pub struct Rcfile {
378392
pub format_repository: bool,
379393
pub indent: Option<String>,
380394
pub max_concurrent_requests: usize,
395+
/// Skip dependency updates published less than this many minutes ago.
396+
/// `0` disables age filtering. Resolved with precedence:
397+
/// rcfile → `pnpm-workspace.yaml` → `DEFAULT_MINIMUM_RELEASE_AGE`.
398+
pub minimum_release_age: u64,
381399
pub semver_groups: Vec<SemverGroup>,
382400
pub sort_az: Vec<String>,
383401
pub sort_exports: Vec<String>,

src/rcfile/from_disk.rs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
use {
22
crate::{
33
cli::Cli,
4-
disk::{detect_formatting, DetectedFormatting, Disk, DiskIo, DiskIoError, File, NodeJsError},
4+
disk::{detect_formatting, json_view, DetectedFormatting, Disk, DiskIo, DiskIoError, File, NodeJsError},
55
errors::UnsupportedConfigErrors,
66
rcfile::{
77
from_disk::javascript::{get_javascript_contents, JsResult},
8-
RawRcfile, Rcfile,
8+
RawRcfile, Rcfile, DEFAULT_MINIMUM_RELEASE_AGE,
99
},
1010
},
1111
log::debug,
@@ -16,6 +16,10 @@ use {
1616
#[path = "javascript.rs"]
1717
mod javascript;
1818

19+
#[cfg(test)]
20+
#[path = "from_disk_test.rs"]
21+
mod from_disk_test;
22+
1923
#[derive(Debug, Error)]
2024
pub enum JsRcfileError {
2125
#[error(transparent)]
@@ -165,8 +169,10 @@ impl Rcfile {
165169
return Err(RcfileError::UnsupportedConfig(UnsupportedConfigErrors(config_errors)));
166170
}
167171

172+
let rcfile_minimum_release_age = raw_rcfile.minimum_release_age;
168173
match Rcfile::try_from(raw_rcfile) {
169-
Ok(rcfile) => {
174+
Ok(mut rcfile) => {
175+
rcfile.minimum_release_age = resolve_minimum_release_age(rcfile_minimum_release_age, disk);
170176
debug!("Config discovery completed in {:?}", start.elapsed());
171177
return Ok(File {
172178
filepath,
@@ -183,12 +189,34 @@ impl Rcfile {
183189
}
184190

185191
debug!("No config file found, using defaults");
192+
let rcfile = Rcfile {
193+
minimum_release_age: resolve_minimum_release_age(None, disk),
194+
..Rcfile::default()
195+
};
186196
debug!("Config discovery completed in {:?}", start.elapsed());
187197
Ok(File {
188198
filepath: disk.cwd.join(".syncpackrc"),
189199
formatting: DetectedFormatting::default(),
190-
contents: Rcfile::default(),
200+
contents: rcfile,
191201
dirty: false,
192202
})
193203
}
194204
}
205+
206+
/// Resolve the effective `minimumReleaseAge` (in minutes). Precedence:
207+
/// 1. value from the rcfile (any user-set value, including `0`)
208+
/// 2. value from `pnpm-workspace.yaml`
209+
/// 3. `DEFAULT_MINIMUM_RELEASE_AGE` (1 day)
210+
pub(crate) fn resolve_minimum_release_age(rcfile_value: Option<u64>, disk: &Disk) -> u64 {
211+
if let Some(value) = rcfile_value {
212+
return value;
213+
}
214+
if let Some(yaml) = &disk.pnpm_workspace {
215+
let json = json_view(yaml);
216+
if let Some(value) = json.get("minimumReleaseAge").and_then(|v| v.as_u64()) {
217+
debug!("Using minimumReleaseAge={value} from pnpm-workspace.yaml");
218+
return value;
219+
}
220+
}
221+
DEFAULT_MINIMUM_RELEASE_AGE
222+
}

src/rcfile/from_disk_test.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
use {
2+
crate::{
3+
disk::Disk,
4+
rcfile::{from_disk::resolve_minimum_release_age, DEFAULT_MINIMUM_RELEASE_AGE},
5+
test::mock::pnpm_yaml_file_from_str,
6+
},
7+
std::path::PathBuf,
8+
};
9+
10+
fn empty_disk() -> Disk {
11+
Disk {
12+
cwd: PathBuf::from("/test"),
13+
lerna_json: None,
14+
package_json_files: Vec::new(),
15+
package_json_root_idx: None,
16+
package_manager: None,
17+
pnpm_workspace: None,
18+
}
19+
}
20+
21+
#[test]
22+
fn rcfile_value_wins_over_pnpm_yaml() {
23+
let mut disk = empty_disk();
24+
disk.pnpm_workspace = Some(pnpm_yaml_file_from_str("minimumReleaseAge: 60\n"));
25+
assert_eq!(resolve_minimum_release_age(Some(120), &disk), 120);
26+
}
27+
28+
#[test]
29+
fn rcfile_value_of_zero_wins_over_pnpm_yaml() {
30+
let mut disk = empty_disk();
31+
disk.pnpm_workspace = Some(pnpm_yaml_file_from_str("minimumReleaseAge: 60\n"));
32+
assert_eq!(resolve_minimum_release_age(Some(0), &disk), 0);
33+
}
34+
35+
#[test]
36+
fn pnpm_yaml_used_when_rcfile_silent() {
37+
let mut disk = empty_disk();
38+
disk.pnpm_workspace = Some(pnpm_yaml_file_from_str("minimumReleaseAge: 60\n"));
39+
assert_eq!(resolve_minimum_release_age(None, &disk), 60);
40+
}
41+
42+
#[test]
43+
fn pnpm_yaml_zero_used_when_rcfile_silent() {
44+
let mut disk = empty_disk();
45+
disk.pnpm_workspace = Some(pnpm_yaml_file_from_str("minimumReleaseAge: 0\n"));
46+
assert_eq!(resolve_minimum_release_age(None, &disk), 0);
47+
}
48+
49+
#[test]
50+
fn falls_back_to_default_when_rcfile_silent_and_pnpm_yaml_silent() {
51+
let mut disk = empty_disk();
52+
disk.pnpm_workspace = Some(pnpm_yaml_file_from_str("packages:\n - 'pkgs/*'\n"));
53+
assert_eq!(resolve_minimum_release_age(None, &disk), DEFAULT_MINIMUM_RELEASE_AGE);
54+
}
55+
56+
#[test]
57+
fn falls_back_to_default_when_no_pnpm_yaml() {
58+
let disk = empty_disk();
59+
assert_eq!(resolve_minimum_release_age(None, &disk), DEFAULT_MINIMUM_RELEASE_AGE);
60+
}

0 commit comments

Comments
 (0)