Skip to content

feat: -Zmin-publish-age (RFC 3923)#17012

Open
weihanglo wants to merge 14 commits into
rust-lang:masterfrom
weihanglo:min-publish-age
Open

feat: -Zmin-publish-age (RFC 3923)#17012
weihanglo wants to merge 14 commits into
rust-lang:masterfrom
weihanglo:min-publish-age

Conversation

@weihanglo

@weihanglo weihanglo commented May 19, 2026

Copy link
Copy Markdown
Member

View all comments

What does this PR try to resolve?

This implements RFC 3923 min-publish-age (rust-lang/rfcs#3923, #17009).

The policy lives entirely at the resolver layer. A PublishAgePolicy is built before resolution and applied wherever a version filer needs to be applied

Where the filter is applied

Because the policy is enforced in the resolver, every code path that selects a version outside the resolver must apply it explicitly. Full audit:

Site Kind Filtered?
resolver (dep_cache::query) selects (resolution) yes
cargo add (get_latest_dependency) selects (writes Cargo.toml) yes
cargo add path/git (select_package) non-registry source no
cargo add features (populate_available_features) enumerates features only no
cargo install (select_dep_pkg) selects (install target) no
cargo update --breaking (upgrade_dependency) selects (writes Cargo.toml) no
cargo update report (report_latest) displays "available: vX" no
future-incompat (get_updates) displays "newer versions" no
cargo info (query_summaries) displays no

How to test and review this PR?

29 tests in tests/testsuite/min_publish_age.rs cover:

  • feature gate + ignored-config warnings
  • deny/allow logic and "0" disabling
  • per-registry config precedence
  • lockfile and [patch] preservation of too-new versions
  • cargo install / cargo add / cargo update --breaking selection paths

Open questions

  • The version 1.99.0 is too new (published 2d ago) message currenly show the time span in always down to minute, and up to day precise. It is annoying that you would get something like 2d 8h 23m long. We might want to round it more for better UX. I personally would leave it for follow-up.
  • The registry.min-publish-age / registries.min-publish-age precedence rule basically follows how registry.token is handle. For registry.min-publish-age we only check if the default registyr is crates.io for consistency with token, though it is open to change.

@rustbot

rustbot commented May 19, 2026

Copy link
Copy Markdown
Collaborator

r? @epage

rustbot has assigned @epage.
They will have a look at your PR within the next two weeks and either review your PR or reassign to another reviewer.

Use r? to explicitly pick a reviewer

Why was this reviewer chosen?

The reviewer was selected based on:

  • Owners of files modified in this PR: @ehuss, @epage, @weihanglo
  • @ehuss, @epage, @weihanglo expanded to ehuss, epage, weihanglo
  • Random selection from ehuss, epage

@rustbot rustbot added A-cli Area: Command-line interface, option parsing, etc. A-configuration Area: cargo config files and env vars A-dependency-resolution Area: dependency resolution and the resolver A-directory-source Area: directory sources (vendoring) A-documenting-cargo-itself Area: Cargo's documentation A-future-incompat Area: future incompatible reporting A-git Area: anything dealing with git A-interacts-with-crates.io Area: interaction with registries A-overrides Area: general issues with overriding dependencies (patch, replace, paths) A-registries Area: registries A-source-replacement Area: [source] replacement A-unstable Area: nightly unstable support A-workspaces Area: workspaces Command-clean Command-install Command-publish Command-vendor S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. labels May 19, 2026
@weihanglo weihanglo marked this pull request as draft May 19, 2026 14:58
@rustbot rustbot removed the S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. label May 19, 2026
@weihanglo

Copy link
Copy Markdown
Member Author

Found something wrong. Wait a sec.

@weihanglo weihanglo force-pushed the min-publish-age branch 2 times, most recently from 2477e6c to bc3e9c0 Compare May 19, 2026 15:24
Comment thread src/cargo/core/resolver/errors.rs Outdated
Comment on lines +294 to +298
IndexSummary::TooNew(summary, age) => {
let opts = jiff::SpanRound::new()
.largest(jiff::Unit::Day)
.smallest(jiff::Unit::Minute)
.relative(jiff::SpanRelativeTo::days_are_24_hours());

@weihanglo weihanglo May 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version 1.99.0 is too new (published 2d ago) message currenly show the time span in always down to minute, and up to day precise. It is annoying that you would get something like 2d 8h 23m long. We might want to round it more for better UX. I personally would leave it for follow-up.

View changes since the review

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@clouatre commented in #17012 (review):

The largest=Day, smallest=Minute rounding produces output like published 2d 8h 23m ago. Suggest simplifying to a single unit based on magnitude: >= 2 days rounds to nearest day (published 3 days ago), < 2 days rounds to nearest hour (published 11 hours ago). The sub-day precision is noise at the day scale and the user cannot act on the minutes component.

For context: we currently enforce this in CI with a bash script that diffs Cargo.lock against the base branch and calls the crates.io API to check publish timestamps on net-new crates. This feature would replace that entirely. The error message is the main user-facing surface, so getting the formatting right matters.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it here so it is better to discuss in a thread.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plan to leave it in a follow-up since the PR is large in size already

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since the PR is large in size already

imo this would be much faster to review if it was broken up even further.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ab157fe

}
}

fn warn_unused_min_publish_age(gctx: &GlobalContext) -> CargoResult<()> {

@weihanglo weihanglo May 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We warn all unused min-publish-age all at once, regardless of the precedence rule.

View changes since the review

@weihanglo weihanglo May 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't do an extensive testing for different age span. If it is needed let me know

View changes since the review

@weihanglo weihanglo force-pushed the min-publish-age branch 2 times, most recently from 1ea1f55 to ddba374 Compare May 19, 2026 15:43

@clouatre clouatre left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The largest=Day, smallest=Minute rounding produces output like published 2d 8h 23m ago. Suggest simplifying to a single unit based on magnitude: >= 2 days rounds to nearest day (published 3 days ago), < 2 days rounds to nearest hour (published 11 hours ago). The sub-day precision is noise at the day scale and the user cannot act on the minutes component.

For context: we currently enforce this in CI with a bash script that diffs Cargo.lock against the base branch and calls the crates.io API to check publish timestamps on net-new crates. This feature would replace that entirely. The error message is the main user-facing surface, so getting the formatting right matters.

View changes since this review

@weihanglo weihanglo marked this pull request as ready for review June 12, 2026 17:18
@rustbot rustbot added the S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. label Jun 12, 2026

@epage epage left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having to step away; will have to pick it up later

View changes since this review

// `min-publish-age` policy must be applied here too. Per RFC 3923 it uses
// `for_install`: `resolver.incompatible-publish-age` does not apply to
// `cargo install`, so `allow` cannot disable the check here.
let publish_age = PublishAgePolicy::for_install(gctx)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought cargo install was to be untouched?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there was some ambiguity in the last minute edit: rust-lang/rfcs@6dca5a1.

We can remove this install check if we don't want for now, and discuss before stabilization.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intention when I signed off on the RFC was that cargo install would not change its behavior which would match what we did with MSRV aware resolver. [registry] sets values but not what to do with them. That is defined in the [resolver] table which does not apply to cargo install.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I somehow missed that edit and had been pushing through the RFC process for the exact opposite.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@weihanglo weihanglo Jun 13, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version before the edit:

cargo install also skips pubtime-incompatible versions
as the [resolver] table is documented as not applying to cargo install.

My interpretation of "skip" was the same as the edited version.

It feels more nature. The rationale:

  • min-publish-age is a registry level config. It also seems nature to apply by default
  • [resolver] is a global resolver behavior control over other the default registry config.

For example, if in the future we have [[registry.denylist]] then it would also apply to cargo install by default, while resolver.allow-denylist = "deny" (what a bad name) would override registry config. That said, we have globally unique pkgid so we probably won't have denylist under registry.

Anyway, I'll remove that part.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, we had been talking past each other a lot there. For example, I think I had edited in a word and thought you were saying that any of the logic in this RFC would be skipped for cargo install.

/// See the [`invocation_time`] field doc for details.
///
/// [`invocation_time`]: GlobalContext::invocation_time
pub fn invocation_time(&self) -> jiff::Timestamp {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does gc have a "now" that can also use this?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me see if we can split this off.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked into this. gc does have its own now from global_cache_tracker::now() While it is currently pretty close to startup time, GC in the future can be run multiple times other than that. Let's keep them separate for now.

Comment thread tests/testsuite/min_publish_age.rs Outdated
/// Mocked "now" for deterministic publish-age comparisons.
const NOW: &str = "2006-08-08T00:00:00Z";

fn setup_packages() {

@epage epage Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

publish_packages to better clarify intent?

View changes since the review

.env("__CARGO_TEST_INVOCATION_TIME", NOW)
.run();

p.cargo("update -Zunstable-options -Zmin-publish-age --breaking bar")

@epage epage Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I wouldn't consider it worth including --breaking in this

View changes since the review

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough. Whoever using --breaking may not afraid of breaks. Will abandon the change and document it in the big table in the PR description.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo --breaking is in too broken of a state.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept the tests there just in case we won't forget they are related in the future. Snapshot updates are cheap thanks to snapbox :)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather dump the tests personally and just note this in the tracking issue.

If nothing else, what do we name these tests? Currently, they describe the intended behavior but the test bodies won't match that.

Comment thread tests/testsuite/min_publish_age.rs
Also warn when `-Zmin-publish-age` is absent
Implement the `-Zmin-publish-age` filter at the resolver layer.

The resolver filters out versions that are too new,
unless pinned by a lock file or a `[patch]` entry.
When resolution fails because every candidate is newer
than the configured `min-publish-age`,
report "version X is too new (published N ago)"
instead of the generic "version X is unavailable".
Since policy is now in resolver layer,
any query doesn't use resolver should enforce the policy if needed.
`cargo add` is one of these places.
Report `published 3 days ago` / `published 11 hours ago`
instead of a multi-unit span like `2d 8h 23m ago`.

Sub-day precision is noise.

@weihanglo weihanglo left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All review comments are addressed/replied.

  • cargo install handling is dropped
  • cargo update --breaking handling is dropped
  • Updated `"published 13h 5m 20s" message to a single unit.

View changes since this review

Comment thread tests/testsuite/min_publish_age.rs Outdated
Comment on lines +13 to +17
fn setup_packages() {
fn publish_packages() {

@epage epage Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed this in the wrong commit?

View changes since the review

Comment on lines +684 to +685
#[cargo_test]
fn cargo_install_filters_too_new() {

@epage epage Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are removing cargo install support, then these test names don't reflect expected behavior: that cargo install is unaffected

View changes since the review

Comment on lines +201 to +202
let global = parse(gctx.get::<Option<String>>("registry.global-min-publish-age")?);
let crates_io = parse(gctx.get::<Option<String>>("registry.min-publish-age")?);

@epage epage Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I take it [registry] is one of those tables that we don't load as a struct but per-field? Or is this related to being unstable?

View changes since the review

Comment on lines +192 to +202
let parse = |raw: Option<String>| -> Option<Duration> {
let raw = raw?;
// A configured value of `"0"` disables the threshold.
if raw == "0" {
return None;
}
maybe_parse_time_span(&raw)
};

let global = parse(gctx.get::<Option<String>>("registry.global-min-publish-age")?);
let crates_io = parse(gctx.get::<Option<String>>("registry.min-publish-age")?);

@epage epage Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe_parse_time_span returns None on error but here we are treating None as "unset". See also invalid_publish_age_value which shows no errors which I would expect it would.

View changes since the review

Comment on lines +230 to +240
/// 1. `registries.<name>.min-publish-age`
/// 2. `registry.min-publish-age` (default registry)
/// 3. `registry.global-min-publish-age`
fn min_age(&self, source_id: SourceId) -> Option<Duration> {
let specific = if let Some(name) = source_id.alt_registry_key() {
self.per_registry.get(name).copied()
} else if source_id.is_crates_io() {
self.crates_io
} else {
None
};

@epage epage Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you do registries.crates-io?

View changes since the review

} else {
None
};
specific.or(self.global)

@epage epage Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"0" means "use latest" but is translated to None above and None is treated here as "unset" and falls back to another source

View changes since the review

/// 1. `registries.<name>.min-publish-age`
/// 2. `registry.min-publish-age` (default registry)
/// 3. `registry.global-min-publish-age`
fn min_age(&self, source_id: SourceId) -> Option<Duration> {

@epage epage Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: As this is a helper for too_new, personally, i would put too_new first

See also https://epage.github.io/dev/rust-style/#m-caller-callee

View changes since the review

Comment thread tests/testsuite/min_publish_age.rs Outdated
[UPGRADING] bar ^1 -> ^2
[ERROR] failed to select a version for the requirement `bar = "^2.1.0"`
version 2.1.0 is unavailable
version 2.1.0 is too new (published 1d ago)

@epage epage Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the RFC, we suggested

$ cargo update
error: failed to select a version for the requirement `some-package = "^1.3"`
  version 1.3.0 is too new (published 2 days ago, minimum age 14 days)
help: to preserve the min-publish-age, downgrade the version requirement to `"1.1"`
help: to use `"1.3"` anyways, re-resolve with `CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=allow`
  • listing the publish age helps to know which value applied
  • give next steps both to respect min publish age and to override it

View changes since the review

use crate::core::Workspace;
use crate::core::dependency::DepKind;
use crate::core::registry::PackageRegistry;
use crate::core::resolver::PublishAgePolicy;

@epage epage Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the resolver be home to different policies like this? At first, it was surprising to me. Now, I somewhat get it.

View changes since the review

possibilities.retain(|s| publish_age.too_new(s).is_none());
if possibilities.is_empty() && has_candidates {
anyhow::bail!(
"all versions of crate `{dependency}` are too new per `min-publish-age`"

@epage epage Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we provide more context, like we do with the resolver?

View changes since the review

required by package `foo v0.0.0 ([ROOT]/foo)`
[ADDING] bar v1.0.0 to dependencies
[LOCKING] 1 package to latest compatible version
[ADDING] bar v1.0.0 (available: v1.1.0)

@epage epage Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RFC called for these to provide more context, like

  Adding some-package v1.2.3 (available: v1.3.0, published 2 days ago)

similar to what we do with msrv, to give users context as to why an old version may be used.

View changes since the review

@epage

epage commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Finally was able to sit down and go through the commits. Might also be good to go through the final test state after things get updated.

Comment on lines 253 to +258
/// Creation time of this config, used to output the total build time
creation_time: Instant,
/// Wall-clock time of this cargo invocation.
///
/// Currently used as the reference time for `min-publish-age` and `-Zbuild-analysis`.
invocation_time: jiff::Timestamp,

@epage epage Jun 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How different are creation_time and invocation_time?

View changes since the review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-cli Area: Command-line interface, option parsing, etc. A-configuration Area: cargo config files and env vars A-dependency-resolution Area: dependency resolution and the resolver A-directory-source Area: directory sources (vendoring) A-documenting-cargo-itself Area: Cargo's documentation A-future-incompat Area: future incompatible reporting A-git Area: anything dealing with git A-interacts-with-crates.io Area: interaction with registries A-overrides Area: general issues with overriding dependencies (patch, replace, paths) A-registries Area: registries A-source-replacement Area: [source] replacement A-unstable Area: nightly unstable support A-workspaces Area: workspaces Command-add Command-clean Command-install Command-publish Command-update Command-vendor S-waiting-on-author Status: The marked PR is awaiting some action (such as code changes) from the PR author. S-waiting-on-review Status: Awaiting review from the assignee but also interested parties.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants