Repository: prefix-dev/pixi (2026-04-08T12:28:09Z)

Issue:
[exclude-newer]: Workspace level takes precedence over channel
### Checks

- [x] I have checked that this issue has not already been reported.

- [x] I have confirmed this bug exists on the [latest version](https://github.com/prefix-dev/pixi/releases/latest) of pixi, using `pixi --version`.


### Reproducible example

On current unreleased main:
```toml
[workspace]
channels = [
    { channel = "conda-forge", exclude-newer = "3y"},
    { channel = "bioconda", exclude-newer = "1y"},
    { channel = "https://prefix.dev/github-releases", exclude-newer = "2d"}] # doesn't work
platforms = ["osx-arm64", "linux-64", "linux-aarch64", "osx-64", "win-64"]
# Takes precedence over the channel-level setting
exclude-newer = "1y"

[dependencies]
python = "*"
chainsaw = "*" # from github-releases
```

Results in:
```
❯ pixi lock
Error:   × failed to solve requirements of environment 'default' for platform 'osx-arm64'
  ├─▶   × failed to solve the environment
  │   
  ╰─▶ Cannot solve the request because of: The following packages are incompatible
      └─ chainsaw * cannot be installed because there are no viable options:
         └─ chainsaw 0.2.5 | 0.2.6 | 0.2.7 | 0.2.8 | 0.2.9 | 0.2.10 | 0.2.11 | 0.2.12 | 0.2.13 | 0.2.14 is excluded because the package is uploaded after the cutoff date of 2025-04-08 04:56:48.943953
      UT
```

### Issue description

The `workspace.exclude-newer` overrides the channel version, which I believe to be wrong.

### Expected behavior

It should be `workspace.exclude-newer` > `workspace.channels.exclude-newer` > `[(pypi-)exclude-newer]` IMO.


Discussion:

Patch:
diff --git a/crates/pixi_core/src/environment/conda_prefix.rs b/crates/pixi_core/src/environment/conda_prefix.rs
index 0c9d53e9c0..7634bae627 100644
--- a/crates/pixi_core/src/environment/conda_prefix.rs
+++ b/crates/pixi_core/src/environment/conda_prefix.rs
@@ -73,7 +73,7 @@ impl CondaPrefixUpdaterBuilder<'_> {
         let variant_config = self.group.workspace().variants(self.platform)?;
         let exclude_newer = self
             .group
-            .exclude_newer_config_resolved()
+            .exclude_newer_config_resolved_with_channel_config(&self.group.channel_config())
             .into_diagnostic()?;
 
         Ok(CondaPrefixUpdater::new(
diff --git a/crates/pixi_core/src/lock_file/satisfiability/mod.rs b/crates/pixi_core/src/lock_file/satisfiability/mod.rs
index a8ddb0a706..02f699d794 100644
--- a/crates/pixi_core/src/lock_file/satisfiability/mod.rs
+++ b/crates/pixi_core/src/lock_file/satisfiability/mod.rs
@@ -154,17 +154,14 @@ impl Display for ExcludeNewerMismatch {
 }
 
 fn verify_exclude_newer(
-    environment: &Environment<'_>,
+    exclude_newer: Option<&rattler_solve::ExcludeNewer>,
     locked_environment: &rattler_lock::Environment<'_>,
 ) -> Result<(), ExcludeNewerMismatch> {
-    for (_platform, packages) in locked_environment.conda_packages_by_platform() {
-        let Some(exclude_newer) = environment
-            .exclude_newer_config()
-            .expect("environment channels were already validated")
-        else {
-            continue;
-        };
+    let Some(exclude_newer) = exclude_newer else {
+        return Ok(());
+    };
 
+    for (_platform, packages) in locked_environment.conda_packages_by_platform() {
         for package in packages {
             let record = package.record();
             let channel = package
@@ -639,7 +636,11 @@ pub fn verify_environment_satisfiability(
         });
     }
 
-    if let Err(err) = verify_exclude_newer(environment, &locked_environment) {
+    let exclude_newer = environment
+        .exclude_newer_config_resolved_with_channel_config(&config)?
+        .map(rattler_solve::ExcludeNewer::from);
+
+    if let Err(err) = verify_exclude_newer(exclude_newer.as_ref(), &locked_environment) {
         return Err(EnvironmentUnsat::ExcludeNewerMismatch(err));
     }
 
diff --git a/crates/pixi_core/src/lock_file/update.rs b/crates/pixi_core/src/lock_file/update.rs
index fd0243eac1..7881e6c280 100644
--- a/crates/pixi_core/src/lock_file/update.rs
+++ b/crates/pixi_core/src/lock_file/update.rs
@@ -2264,7 +2264,7 @@ async fn spawn_solve_conda_environment_task(
     // Get the channel configuration
     let channel_config = group.workspace().channel_config();
     let exclude_newer = group
-        .exclude_newer_config_resolved()
+        .exclude_newer_config_resolved_with_channel_config(&channel_config)
         .map_err(SolveCondaEnvironmentError::from)
         .map_err(CommandDispatcherError::Failed)?;
 
diff --git a/crates/pixi_manifest/src/features_ext.rs b/crates/pixi_manifest/src/features_ext.rs
index 1b4eb062d5..ad2103e049 100644
--- a/crates/pixi_manifest/src/features_ext.rs
+++ b/crates/pixi_manifest/src/features_ext.rs
@@ -124,50 +124,21 @@ pub trait FeaturesExt<'source>: HasWorkspaceManifest<'source> + HasFeaturesIter<
     fn exclude_newer_config_resolved(
         &self,
     ) -> Result<Option<ResolvedExcludeNewer>, ParseChannelError> {
-        let mut exclude_newer = self
-            .exclude_newer_raw()
-            .map(|config| ResolvedExcludeNewer::from_datetime(config.cutoff()));
-
-        for channel in self.prioritized_channels().into_values() {
-            let Some(channel_exclude_newer) = channel.exclude_newer else {
-                continue;
-            };
-
-            let config = exclude_newer.get_or_insert_with(|| {
-                ResolvedExcludeNewer::from_datetime(DateTime::<Utc>::MAX_UTC)
-            });
-
-            *config = match channel_exclude_newer {
-                ExcludeNewer::Timestamp(dt) => config
-                    .clone()
-                    .with_channel_cutoff(channel.channel.to_string(), dt),
-                ExcludeNewer::Duration(duration) => config.clone().with_channel_cutoff(
-                    channel.channel.to_string(),
-                    ExcludeNewer::Duration(duration).cutoff(),
-                ),
-            };
-        }
-
-        for (name, package_exclude_newer) in &self
-            .workspace_manifest()
-            .workspace
-            .exclude_newer_package_overrides
-        {
-            let config = exclude_newer.get_or_insert_with(|| {
-                ResolvedExcludeNewer::from_datetime(DateTime::<Utc>::MAX_UTC)
-            });
-
-            *config = match package_exclude_newer {
-                ExcludeNewer::Timestamp(dt) => {
-                    config.clone().with_package_cutoff(name.clone(), *dt)
-                }
-                ExcludeNewer::Duration(duration) => config
-                    .clone()
-                    .with_package_cutoff(name.clone(), ExcludeNewer::Duration(*duration).cutoff()),
-            };
-        }
+        exclude_newer_config_resolved_impl(self, |channel| Ok(channel.channel.to_string()))
+    }
 
-        Ok(exclude_newer)
+    /// Returns the effective exclude-newer configuration keyed by resolved channel URLs.
+    fn exclude_newer_config_resolved_with_channel_config(
+        &self,
+        channel_config: &ChannelConfig,
+    ) -> Result<Option<ResolvedExcludeNewer>, ParseChannelError> {
+        exclude_newer_config_resolved_impl(self, |channel| {
+            Ok(channel
+                .channel
+                .clone()
+                .into_base_url(channel_config)?
+                .to_string())
+        })
     }
 
     /// Returns the effective PyPI exclude-newer solver configuration with absolute cutoffs.
@@ -377,3 +348,51 @@ impl<'source, FeatureCollection> FeaturesExt<'source> for FeatureCollection wher
     FeatureCollection: HasWorkspaceManifest<'source> + HasFeaturesIter<'source>
 {
 }
+
+fn exclude_newer_config_resolved_impl<'source, T, F>(
+    features: &T,
+    mut channel_key: F,
+) -> Result<Option<ResolvedExcludeNewer>, ParseChannelError>
+where
+    T: FeaturesExt<'source> + ?Sized,
+    F: FnMut(&PrioritizedChannel) -> Result<String, ParseChannelError>,
+{
+    let mut exclude_newer = features
+        .exclude_newer_raw()
+        .map(|config| ResolvedExcludeNewer::from_datetime(config.cutoff()));
+
+    for channel in features.prioritized_channels().into_values() {
+        let Some(channel_exclude_newer) = channel.exclude_newer else {
+            continue;
+        };
+
+        let channel_key = channel_key(channel)?;
+        let config = exclude_newer
+            .get_or_insert_with(|| ResolvedExcludeNewer::from_datetime(DateTime::<Utc>::MAX_UTC));
+
+        *config = match channel_exclude_newer {
+            ExcludeNewer::Timestamp(dt) => config.clone().with_channel_cutoff(channel_key, dt),
+            ExcludeNewer::Duration(duration) => config
+                .clone()
+                .with_channel_cutoff(channel_key, ExcludeNewer::Duration(duration).cutoff()),
+        };
+    }
+
+    for (name, package_exclude_newer) in &features
+        .workspace_manifest()
+        .workspace
+        .exclude_newer_package_overrides
+    {
+        let config = exclude_newer
+            .get_or_insert_with(|| ResolvedExcludeNewer::from_datetime(DateTime::<Utc>::MAX_UTC));
+
+        *config = match package_exclude_newer {
+            ExcludeNewer::Timestamp(dt) => config.clone().with_package_cutoff(name.clone(), *dt),
+            ExcludeNewer::Duration(duration) => config
+                .clone()
+                .with_package_cutoff(name.clone(), ExcludeNewer::Duration(*duration).cutoff()),
+        };
+    }
+
+    Ok(exclude_newer)
+}


Test patch:
diff --git a/crates/pixi/tests/integration_rust/install_tests.rs b/crates/pixi/tests/integration_rust/install_tests.rs
index b91b8f350b..3b413839d6 100644
--- a/crates/pixi/tests/integration_rust/install_tests.rs
+++ b/crates/pixi/tests/integration_rust/install_tests.rs
@@ -1574,6 +1574,44 @@ async fn test_exclude_newer_per_package_dependency_override() {
     ));
 }
 
+#[tokio::test]
+async fn test_exclude_newer_url_channel_override() {
+    setup_tracing();
+
+    let mut package_database = MockRepoData::default();
+    package_database.add_package(
+        Package::build("foo", "2")
+            .with_timestamp("2020-12-02T07:00:00Z".parse().unwrap())
+            .finish(),
+    );
+
+    let channel = package_database.into_channel().await.unwrap();
+    let pixi = PixiControl::from_manifest(&format!(
+        r#"
+    [workspace]
+    name = "test-exclude-newer-url-channel-override"
+    channels = [{{ channel = "{channel}", exclude-newer = "0d" }}]
+    platforms = ["{platform}"]
+    exclude-newer = "2015-12-02T02:07:43Z"
+
+    [dependencies]
+    foo = "*"
+    "#,
+        channel = channel.url(),
+        platform = Platform::current()
+    ))
+    .unwrap();
+
+    pixi.lock().await.unwrap();
+
+    let lock = pixi.lock_file().await.unwrap();
+    assert!(lock.contains_match_spec(
+        consts::DEFAULT_ENVIRONMENT_NAME,
+        Platform::current(),
+        "foo ==2"
+    ));
+}
+
 #[tokio::test]
 async fn test_exclude_newer_per_package_constraint_override() {
     setup_tracing();