diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml new file mode 100644 index 0000000000000..33a5aa0d67938 --- /dev/null +++ b/.github/workflows/fork-release.yml @@ -0,0 +1,165 @@ +# fork: publish per-PR uv binaries (linux x86_64, macOS aarch64). +# Each PR has one release tagged `pr-`; assets overwrite on each push. +name: Fork release + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: write + +env: + CARGO_INCREMENTAL: 0 + CARGO_NET_RETRY: 10 + CARGO_TERM_COLOR: always + RUSTUP_MAX_RETRIES: 10 + +jobs: + build-linux-x86_64: + name: Build linux x86_64 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: Build uv + uvx + run: cargo build --profile release --bin uv --bin uvx + - name: Package tarball + run: | + mkdir -p dist + tar -czvf dist/uv-x86_64-unknown-linux-gnu.tar.gz \ + -C target/release uv uvx + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: uv-x86_64-unknown-linux-gnu + path: dist/uv-x86_64-unknown-linux-gnu.tar.gz + retention-days: 1 + + build-macos-aarch64: + name: Build macOS aarch64 + runs-on: macos-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: Build uv + uvx + run: cargo build --profile release --bin uv --bin uvx + - name: Package tarball + run: | + mkdir -p dist + tar -czvf dist/uv-aarch64-apple-darwin.tar.gz \ + -C target/release uv uvx + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: uv-aarch64-apple-darwin + path: dist/uv-aarch64-apple-darwin.tar.gz + retention-days: 1 + + publish-release: + name: Publish PR release + needs: [build-linux-x86_64, build-macos-aarch64] + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_TITLE: ${{ github.event.pull_request.title }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true + - name: Write release notes + id: notes + run: | + TAG="pr-${PR_NUMBER}" + SHA_SHORT="$(echo "$PR_HEAD_SHA" | cut -c1-7)" + DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "sha_short=$SHA_SHORT" >> "$GITHUB_OUTPUT" + cat > RELEASE_NOTES.md </dev/null || true + \`\`\` + + Make sure \`\$HOME/.local/bin\` is on your \`PATH\`. + EOF + - name: Upsert PR release + run: | + TAG="${{ steps.notes.outputs.tag }}" + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + # Force-move the existing tag to the new PR head SHA, then refresh + # notes and assets in place. + gh api --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/$TAG" \ + -f sha="$PR_HEAD_SHA" -F force=true >/dev/null + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" \ + --title "$TAG" \ + --notes-file RELEASE_NOTES.md + gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber \ + dist/uv-x86_64-unknown-linux-gnu.tar.gz \ + dist/uv-aarch64-apple-darwin.tar.gz + else + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$PR_HEAD_SHA" \ + --title "$TAG" \ + --notes-file RELEASE_NOTES.md \ + dist/uv-x86_64-unknown-linux-gnu.tar.gz \ + dist/uv-aarch64-apple-darwin.tar.gz + fi diff --git a/crates/uv-distribution-types/src/index_url.rs b/crates/uv-distribution-types/src/index_url.rs index 5de8cce93070b..273c22b700f6e 100644 --- a/crates/uv-distribution-types/src/index_url.rs +++ b/crates/uv-distribution-types/src/index_url.rs @@ -26,6 +26,32 @@ static DEFAULT_INDEX: LazyLock = LazyLock::new(|| { )))) }); +// fork: default index resolved through UV_INDEX_PROXIES so that +// build-system.requires resolution uses the proxy; see astral-sh/uv#6349. +static DEFAULT_INDEX_PROXIED: LazyLock> = LazyLock::new(|| { + let value = std::env::var("UV_INDEX_PROXIES").ok()?; + let pypi = PYPI_URL.to_string(); + for entry in value.split(',') { + let entry = entry.trim(); + let delimiter_pos = entry + .find(":https://") + .or_else(|| entry.find(":http://")) + .filter(|&pos| pos > 0)?; + let canonical = entry[..delimiter_pos].trim(); + let proxy = entry[delimiter_pos + 1..].trim(); + if canonical == pypi { + let proxy_url = DisplaySafeUrl::parse(proxy).ok()?; + tracing::debug!( + "Resolving default index `{canonical}` to proxy `{proxy}` for build resolution" + ); + return Some(Index::from_index_url(IndexUrl::Url(Arc::new( + VerbatimUrl::from_url(proxy_url), + )))); + } + } + None +}); + /// The URL of an index to use for fetching packages (e.g., PyPI). #[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)] pub enum IndexUrl { @@ -313,6 +339,10 @@ impl<'a> IndexLocations { .iter() .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name))) .find(|index| index.default) + // fork: prefer proxy-resolved default index so that + // build-system.requires resolution uses the proxy; + // see astral-sh/uv#6349. + .or_else(|| DEFAULT_INDEX_PROXIED.as_ref()) .or_else(|| Some(&DEFAULT_INDEX)) } } @@ -520,6 +550,9 @@ impl<'a> IndexUrls { .iter() .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name))) .find(|index| index.default) + // fork: prefer proxy-resolved default index; + // see astral-sh/uv#6349. + .or_else(|| DEFAULT_INDEX_PROXIED.as_ref()) .or_else(|| Some(&DEFAULT_INDEX)) } } diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 893e75074e946..255b76f6335cf 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -72,6 +72,8 @@ mod export; mod installable; mod map; mod tree; +// fork: rewrite proxy registry URLs via UV_INDEX_PROXIES; see astral-sh/uv#6349. +mod url_preservation; /// The current version of the lockfile format. pub const VERSION: u32 = 1; @@ -1837,6 +1839,12 @@ impl Lock { .collect::>() }); + // fork: add canonical URLs from UV_INDEX_PROXIES so that the + // satisfies check recognizes them as valid remote indexes. + if let Some(remotes) = remotes.as_mut() { + url_preservation::canonical_urls(remotes); + } + let mut locals = indexes.map(|locations| { locations .allowed_indexes() @@ -3270,8 +3278,11 @@ impl Package { zstd: None, }); + // fork: resolve canonical URL back to proxy for fetching; + // see astral-sh/uv#6349. + let resolved_url = url_preservation::proxy_url(url); let index = IndexUrl::from(VerbatimUrl::from_url( - url.to_url().map_err(LockErrorKind::InvalidUrl)?, + resolved_url.to_url().map_err(LockErrorKind::InvalidUrl)?, )); let reg_dist = RegistrySourceDist { @@ -3566,8 +3577,11 @@ impl Package { pub fn index(&self, root: &Path) -> Result, LockError> { match &self.id.source { Source::Registry(RegistrySource::Url(url)) => { + // fork: resolve canonical URL back to proxy for fetching; + // see astral-sh/uv#6349. + let resolved_url = url_preservation::proxy_url(url); let index = IndexUrl::from(VerbatimUrl::from_url( - url.to_url().map_err(LockErrorKind::InvalidUrl)?, + resolved_url.to_url().map_err(LockErrorKind::InvalidUrl)?, )); Ok(Some(index)) } @@ -5054,8 +5068,11 @@ impl Wheel { }) .map(Box::new), }); + // fork: resolve canonical URL back to proxy for fetching; + // see astral-sh/uv#6349. + let resolved_url = url_preservation::proxy_url(url); let index = IndexUrl::from(VerbatimUrl::from_url( - url.to_url().map_err(LockErrorKind::InvalidUrl)?, + resolved_url.to_url().map_err(LockErrorKind::InvalidUrl)?, )); Ok(RegistryBuiltWheel { filename, diff --git a/crates/uv-resolver/src/lock/url_preservation.rs b/crates/uv-resolver/src/lock/url_preservation.rs new file mode 100644 index 0000000000000..6ee69c9f2b6b9 --- /dev/null +++ b/crates/uv-resolver/src/lock/url_preservation.rs @@ -0,0 +1,342 @@ +//! fork: rewrite proxy registry URLs in `uv.lock` to their canonical counterparts. +//! +//! When `UV_DEFAULT_INDEX` points at an internal PyPI mirror/proxy, upstream +//! `uv lock` bakes the proxy URL into every `source.registry` field in +//! `uv.lock`. This creates noisy diffs and breaks portability across +//! environments that use different mirrors. +//! +//! The `UV_INDEX_PROXIES` environment variable provides a mapping from +//! canonical URLs to proxy URLs. After resolution, [`Lock::rewrite_proxy_urls`] +//! replaces every proxy registry URL with its canonical counterpart in the +//! lockfile, keeping it stable regardless of which mirror resolved the package. +//! +//! Format: `:,:` +//! +//! Example: +//! UV_INDEX_PROXIES=https://pypi.org/simple:https://pypi-proxy.example.com/simple + +use std::collections::BTreeSet; + +use tracing::{debug, trace}; +use uv_distribution_types::UrlString; +use uv_small_str::SmallString; + +use super::{Lock, PackageId, RegistrySource, Source}; + +/// A single canonical ↔ proxy URL mapping. +struct ProxyMapping { + canonical: UrlString, + proxy: UrlString, +} + +/// Parse `UV_INDEX_PROXIES` into a list of mappings. +/// +/// Format: `canonical:proxy,canonical2:proxy2` +fn parse_proxy_mappings() -> Vec { + let Some(value) = std::env::var("UV_INDEX_PROXIES").ok() else { + return Vec::new(); + }; + value + .split(',') + .filter_map(|entry| { + let entry = entry.trim(); + if entry.is_empty() { + return None; + } + // Split on `:https://` or `:http://` to avoid splitting the scheme. + let delimiter_pos = entry + .find(":https://") + .or_else(|| entry.find(":http://")) + .filter(|&pos| pos > 0)?; + let canonical = entry[..delimiter_pos].trim(); + let proxy = entry[delimiter_pos + 1..].trim(); + if canonical.is_empty() || proxy.is_empty() { + return None; + } + Some(ProxyMapping { + canonical: UrlString::new(SmallString::from(canonical)), + proxy: UrlString::new(SmallString::from(proxy)), + }) + }) + .collect() +} + +/// Add canonical URLs from `UV_INDEX_PROXIES` to the set of known remote +/// indexes so that `satisfies()` recognizes lockfile entries written with +/// canonical URLs as valid. +pub(super) fn canonical_urls(remotes: &mut BTreeSet) { + for mapping in parse_proxy_mappings() { + remotes.insert(mapping.canonical); + } +} + +/// Resolve a canonical registry URL back to its proxy URL using +/// `UV_INDEX_PROXIES`. This is the reverse of [`Lock::rewrite_proxy_urls`]: +/// the lockfile stores canonical URLs, but at install time we need to fetch +/// from the proxy that is actually reachable. +/// +/// Returns the original URL unchanged if no mapping matches. +pub(super) fn proxy_url(url: &UrlString) -> UrlString { + for mapping in parse_proxy_mappings() { + if *url == mapping.canonical { + trace!( + "Resolving canonical registry URL `{url}` to proxy `{}`", + mapping.proxy + ); + return mapping.proxy; + } + } + url.clone() +} + +impl Lock { + /// Rewrite proxy registry URLs to their canonical counterparts based on + /// the `UV_INDEX_PROXIES` environment variable. + pub fn rewrite_proxy_urls(&mut self) { + let mappings = parse_proxy_mappings(); + if mappings.is_empty() { + return; + } + + for mapping in &mappings { + debug!( + "Rewriting proxy registry URLs: `{}` → `{}`", + mapping.proxy, mapping.canonical + ); + } + + for package in &mut self.packages { + apply_proxy_mapping(&mut package.id, &mappings); + for dep in &mut package.dependencies { + apply_proxy_mapping(&mut dep.package_id, &mappings); + } + for deps in package.optional_dependencies.values_mut() { + for dep in deps { + apply_proxy_mapping(&mut dep.package_id, &mappings); + } + } + for deps in package.dependency_groups.values_mut() { + for dep in deps { + apply_proxy_mapping(&mut dep.package_id, &mappings); + } + } + } + + // Rebuild `by_id` since we mutated `Package.id.source`. + self.by_id.clear(); + for (index, package) in self.packages.iter().enumerate() { + self.by_id.insert(package.id.clone(), index); + } + } +} + +/// Replace proxy URL with its canonical counterpart on a [`PackageId`]. +fn apply_proxy_mapping(id: &mut PackageId, mappings: &[ProxyMapping]) { + let Source::Registry(RegistrySource::Url(url)) = &mut id.source else { + return; + }; + for mapping in mappings { + if *url == mapping.proxy { + trace!( + "Rewriting proxy registry URL `{url}` to canonical `{}`", + mapping.canonical + ); + *url = mapping.canonical.clone(); + return; + } + } +} + +#[cfg(test)] +mod tests { + use super::super::Lock; + use uv_distribution_types::UrlString; + use uv_small_str::SmallString; + + fn make_lock(registry: &str, file_prefix: &str) -> Lock { + let data = format!( + r#" +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = {{ registry = "{registry}" }} +sdist = {{ url = "{file_prefix}/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }} +wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }}] +"# + ); + toml::from_str(&data).expect("parse lock") + } + + fn make_lock_with_dependency(registry: &str, file_prefix: &str) -> Lock { + let data = format!( + r#" +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.3.0" +source = {{ registry = "{registry}" }} +sdist = {{ url = "{file_prefix}/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642 }} +wheels = [{{ url = "{file_prefix}/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584 }}] + +[[package.dependencies]] +name = "iniconfig" +version = "2.0.0" +source = {{ registry = "{registry}" }} + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = {{ registry = "{registry}" }} +sdist = {{ url = "{file_prefix}/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }} +wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }}] +"# + ); + toml::from_str(&data).expect("parse lock") + } + + #[test] + fn rewrites_proxy_to_canonical() { + std::env::set_var( + "UV_INDEX_PROXIES", + "https://pypi.org/simple:https://mirror.example.com/simple", + ); + let mut lock = make_lock( + "https://mirror.example.com/simple", + "https://mirror.example.com/files/iniconfig", + ); + + lock.rewrite_proxy_urls(); + + let rendered = lock.to_toml().expect("serialize lock"); + assert!( + rendered.contains(r#"registry = "https://pypi.org/simple""#), + "registry URL should be rewritten to canonical:\n{rendered}" + ); + // File URLs are not rewritten — only registry sources. + assert!( + rendered.contains("https://mirror.example.com/files/iniconfig"), + "file URLs should be left as-is:\n{rendered}" + ); + std::env::remove_var("UV_INDEX_PROXIES"); + } + + #[test] + fn rewrites_dependency_package_ids() { + std::env::set_var( + "UV_INDEX_PROXIES", + "https://pypi.org/simple:https://mirror.example.com/simple", + ); + let mut lock = make_lock_with_dependency( + "https://mirror.example.com/simple", + "https://mirror.example.com/files", + ); + + lock.rewrite_proxy_urls(); + + for package in &lock.packages { + for dep in &package.dependencies { + if let super::Source::Registry(super::RegistrySource::Url(url)) = + &dep.package_id.source + { + assert!( + !url.to_string().contains("mirror.example.com"), + "dependency {} still references mirror URL {url:?}", + dep.package_id.name + ); + } + } + } + + // find_by_id must work after rewrite (by_id was rebuilt). + for package in &lock.packages { + for dep in &package.dependencies { + let resolved = lock.find_by_id(&dep.package_id); + assert_eq!(resolved.id.name, dep.package_id.name); + } + } + std::env::remove_var("UV_INDEX_PROXIES"); + } + + #[test] + fn no_env_var_is_noop() { + std::env::remove_var("UV_INDEX_PROXIES"); + let mut lock = make_lock( + "https://mirror.example.com/simple", + "https://mirror.example.com/files/iniconfig", + ); + + lock.rewrite_proxy_urls(); + + let rendered = lock.to_toml().expect("serialize lock"); + assert!( + rendered.contains(r#"registry = "https://mirror.example.com/simple""#), + "registry URL should be unchanged without env var:\n{rendered}" + ); + } + + #[test] + fn no_match_leaves_urls_untouched() { + std::env::set_var( + "UV_INDEX_PROXIES", + "https://pypi.org/simple:https://other-proxy.example.com/simple", + ); + let mut lock = make_lock( + "https://mirror.example.com/simple", + "https://mirror.example.com/files/iniconfig", + ); + + lock.rewrite_proxy_urls(); + + let rendered = lock.to_toml().expect("serialize lock"); + assert!( + rendered.contains(r#"registry = "https://mirror.example.com/simple""#), + "non-matching registry URL should be unchanged:\n{rendered}" + ); + std::env::remove_var("UV_INDEX_PROXIES"); + } + + #[test] + fn proxy_url_resolves_canonical_to_proxy() { + std::env::set_var( + "UV_INDEX_PROXIES", + "https://pypi.org/simple:https://mirror.example.com/simple", + ); + + let canonical = UrlString::new(SmallString::from("https://pypi.org/simple")); + let resolved = super::proxy_url(&canonical); + assert_eq!( + resolved.to_string(), + "https://mirror.example.com/simple", + "canonical URL should resolve to proxy URL" + ); + + // Non-matching URL should be returned unchanged. + let other = UrlString::new(SmallString::from("https://other.example.com/simple")); + let resolved = super::proxy_url(&other); + assert_eq!( + resolved.to_string(), + "https://other.example.com/simple", + "non-matching URL should be returned unchanged" + ); + + std::env::remove_var("UV_INDEX_PROXIES"); + } + + #[test] + fn proxy_url_noop_without_env() { + std::env::remove_var("UV_INDEX_PROXIES"); + + let url = UrlString::new(SmallString::from("https://pypi.org/simple")); + let resolved = super::proxy_url(&url); + assert_eq!( + resolved.to_string(), + "https://pypi.org/simple", + "URL should be unchanged without env var" + ); + } +} diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 344b072d5b81e..98f6ba94fc1d6 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -434,7 +434,8 @@ impl<'env> LockOperation<'env> { }; // Perform the lock operation. - let result = Box::pin(do_lock( + // fork: was `let result` — now mutable for URL preservation below. + let mut result = Box::pin(do_lock( target, interpreter, existing, @@ -452,10 +453,25 @@ impl<'env> LockOperation<'env> { )) .await?; - // If the lockfile changed, write it to disk. - if !matches!(self.mode, LockMode::DryRun(_)) { - if let LockResult::Changed(_, lock) = &result { - target.commit(lock).await?; + // fork: rewrite proxy registry URLs to their canonical + // counterparts based on UV_INDEX_PROXIES; see astral-sh/uv#6349. + match &mut result { + LockResult::Changed(previous, lock) => { + lock.rewrite_proxy_urls(); + if previous.as_ref().is_some_and(|prev| prev == lock) { + // URL rewrite made the new lock identical to the + // previous one — skip the write to avoid touching mtime. + } else if !matches!(self.mode, LockMode::DryRun(_)) { + target.commit(lock).await?; + } + } + LockResult::Unchanged(lock) => { + let before = lock.to_toml()?; + lock.rewrite_proxy_urls(); + let after = lock.to_toml()?; + if before != after && !matches!(self.mode, LockMode::DryRun(_)) { + target.commit(lock).await?; + } } }