Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions .github/workflows/fork-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# fork: publish per-PR uv binaries (linux x86_64, macOS aarch64).
# Each PR has one release tagged `pr-<number>`; 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 <<EOF
# uv (mlflow fork) — PR #${PR_NUMBER}

Binaries built from [PR #${PR_NUMBER}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}).
Assets are overwritten on every push to the PR.

- Commit: \`${SHA_SHORT}\` (\`${PR_HEAD_SHA}\`)
- Built: ${DATE}

## Fork behavior

This fork rewrites proxy registry URLs in \`uv.lock\` to their canonical counterparts
via the \`UV_INDEX_PROXIES\` environment variable. This prevents noisy diffs and keeps
the lockfile portable across environments that use different PyPI mirrors.
See [astral-sh/uv#6349](https://github.com/astral-sh/uv/issues/6349).

## Setup

Set \`UV_INDEX_PROXIES\` with \`canonical:proxy\` mappings (comma-separated for multiple):

\`\`\`bash
export UV_INDEX_PROXIES=https://pypi.org/simple:https://your-pypi-proxy.example.com/simple
\`\`\`

Then use \`uv lock\`, \`uv add\`, etc. as normal. The lockfile will always contain the
canonical URL (\`https://pypi.org/simple\`) regardless of which mirror resolved the package.

## Install

### Linux (x86_64)

\`\`\`bash
tmpdir="\$(mktemp -d)" && trap "rm -rf \"\$tmpdir\"" EXIT
curl -L -o "\$tmpdir/uv.tar.gz" \\
${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}/uv-x86_64-unknown-linux-gnu.tar.gz
tar -xzf "\$tmpdir/uv.tar.gz" -C "\$tmpdir"
install -m 0755 "\$tmpdir/uv" "\$tmpdir/uvx" "\$HOME/.local/bin/"
\`\`\`

### macOS (Apple Silicon)

\`\`\`bash
tmpdir="\$(mktemp -d)" && trap "rm -rf \"\$tmpdir\"" EXIT
curl -L -o "\$tmpdir/uv.tar.gz" \\
${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}/uv-aarch64-apple-darwin.tar.gz
tar -xzf "\$tmpdir/uv.tar.gz" -C "\$tmpdir"
install -m 0755 "\$tmpdir/uv" "\$tmpdir/uvx" "\$HOME/.local/bin/"
xattr -d com.apple.quarantine "\$HOME/.local/bin/uv" "\$HOME/.local/bin/uvx" 2>/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
160 changes: 160 additions & 0 deletions crates/uv-distribution-types/src/artifact_proxies.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//! fork: rewrite proxy artifact URLs to their canonical counterparts.
//!
//! A proxy index serves the Simple API from its own host, and the artifact
//! URLs it advertises point back at that same host (e.g.
//! `https://pypi-proxy.example.com/packages/...`). Those URLs are baked into
//! `uv.lock`, which makes the lockfile non-portable across environments that
//! use different mirrors, and forces every download through the proxy.
//!
//! The `UV_ARTIFACT_PROXIES` environment variable provides a mapping from
//! canonical artifact base URLs to proxy base URLs. Matching URLs are rewritten
//! to the canonical base as soon as they are parsed out of an index response,
//! so both resolution downloads and the URLs recorded in `uv.lock` use the
//! canonical host.
//!
//! Unlike [`crate::IndexUrl`] mappings, these are *prefix* mappings: every
//! artifact has a distinct path below the base.
//!
//! Format: `<canonical>:<proxy>,<canonical2>:<proxy2>`
//!
//! Example:
//! UV_ARTIFACT_PROXIES=https://files.pythonhosted.org/packages:https://pypi-proxy.example.com/packages

use std::sync::LazyLock;

use tracing::trace;
use uv_small_str::SmallString;

/// A single canonical ↔ proxy artifact base URL mapping.
struct ArtifactProxy {
canonical: String,
proxy: String,
}

/// The mappings configured via `UV_ARTIFACT_PROXIES`.
static ARTIFACT_PROXIES: LazyLock<Vec<ArtifactProxy>> = LazyLock::new(|| {
let Ok(value) = std::env::var("UV_ARTIFACT_PROXIES") else {
return Vec::new();
};
parse_mappings(&value)
});

/// Parse a raw `UV_ARTIFACT_PROXIES` value into a list of mappings.
///
/// Entries that are empty or malformed are skipped.
fn parse_mappings(value: &str) -> Vec<ArtifactProxy> {
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().trim_end_matches('/');
let proxy = entry[delimiter_pos + 1..].trim().trim_end_matches('/');
if canonical.is_empty() || proxy.is_empty() {
return None;
}
Some(ArtifactProxy {
canonical: canonical.to_string(),
proxy: proxy.to_string(),
})
})
.collect()
}

/// Rewrite an artifact URL served by a proxy to its canonical equivalent.
///
/// Returns the URL unchanged if no mapping applies.
pub(crate) fn canonicalize(url: SmallString) -> SmallString {
rewrite(url, &ARTIFACT_PROXIES)
}

/// Rewrite `url` using `mappings`, returning it unchanged if no prefix matches.
fn rewrite(url: SmallString, mappings: &[ArtifactProxy]) -> SmallString {
for mapping in mappings {
// Only rewrite at a path boundary, so that a proxy base of
// `https://example.com/packages` does not match
// `https://example.com/packages-other/...`.
let Some(rest) = url.strip_prefix(&mapping.proxy) else {
continue;
};
if !rest.is_empty() && !rest.starts_with('/') {
continue;
}
let canonical = SmallString::from(format!("{}{rest}", mapping.canonical).as_str());
trace!("Rewriting proxy artifact URL `{url}` to canonical `{canonical}`");
return canonical;
}
url
}

#[cfg(test)]
mod tests {
use super::{parse_mappings, rewrite};
use uv_small_str::SmallString;

const MAPPING: &str =
"https://files.pythonhosted.org/packages:https://pypi-proxy.example.com/packages";

fn rewritten(url: &str, value: &str) -> String {
rewrite(SmallString::from(url), &parse_mappings(value)).to_string()
}

#[test]
fn rewrites_proxy_artifact_to_canonical() {
assert_eq!(
rewritten(
"https://pypi-proxy.example.com/packages/e1/04/abc/foo-1.0-py3-none-any.whl",
MAPPING,
),
"https://files.pythonhosted.org/packages/e1/04/abc/foo-1.0-py3-none-any.whl",
);
}

#[test]
fn rewrites_metadata_sidecar() {
assert_eq!(
rewritten(
"https://pypi-proxy.example.com/packages/e1/04/abc/foo-1.0-py3-none-any.whl.metadata",
MAPPING,
),
"https://files.pythonhosted.org/packages/e1/04/abc/foo-1.0-py3-none-any.whl.metadata",
);
}

#[test]
fn leaves_other_hosts_untouched() {
let url = "https://other.example.com/packages/foo-1.0-py3-none-any.whl";
assert_eq!(rewritten(url, MAPPING), url);
}

#[test]
fn does_not_match_partial_path_segment() {
// `/packages-internal` must not match a `/packages` base.
let url = "https://pypi-proxy.example.com/packages-internal/foo-1.0-py3-none-any.whl";
assert_eq!(rewritten(url, MAPPING), url);
}

#[test]
fn no_mappings_is_noop() {
let url = "https://pypi-proxy.example.com/packages/foo-1.0-py3-none-any.whl";
assert_eq!(rewritten(url, ""), url);
}

#[test]
fn trailing_slash_in_config_is_normalized() {
assert_eq!(
rewritten(
"https://pypi-proxy.example.com/packages/foo-1.0-py3-none-any.whl",
"https://files.pythonhosted.org/packages/:https://pypi-proxy.example.com/packages/",
),
"https://files.pythonhosted.org/packages/foo-1.0-py3-none-any.whl",
);
}
}
6 changes: 5 additions & 1 deletion crates/uv-distribution-types/src/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,11 @@ impl FileLocation {
/// that page.
pub fn new(url: SmallString, base: &SmallString) -> Self {
match split_scheme(&url) {
Some(..) => Self::AbsoluteUrl(UrlString::new(url)),
// fork: rewrite proxy artifact URLs to their canonical counterparts, so that
// downloads and `uv.lock` both use the canonical host; see astral-sh/uv#6349.
Some(..) => {
Self::AbsoluteUrl(UrlString::new(crate::artifact_proxies::canonicalize(url)))
}
None => Self::RelativeUrl(base.clone(), url),
}
}
Expand Down
30 changes: 30 additions & 0 deletions crates/uv-distribution-types/src/index_url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,32 @@ static DEFAULT_INDEX: LazyLock<Index> = 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<Option<Index>> = 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 {
Expand Down Expand Up @@ -319,6 +345,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))
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/uv-distribution-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ pub use crate::traits::*;

mod annotation;
mod any;
// fork: rewrite proxy artifact URLs via UV_ARTIFACT_PROXIES; see astral-sh/uv#6349.
mod artifact_proxies;
mod build_info;
mod build_requires;
mod buildable;
Expand Down
Loading
Loading