diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55a171ae3028..bd990f29b3b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -979,6 +979,9 @@ jobs: env: # No dbus in GitHub Actions PYTHON_KEYRING_BACKEND: keyrings.alt.file.PlaintextKeyring + permissions: + # For trusted publishing + id-token: write steps: - uses: actions/checkout@v4 with: diff --git a/Cargo.lock b/Cargo.lock index 8b2df3bfa728..f15a04f23c44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5030,8 +5030,10 @@ dependencies = [ "tracing", "url", "uv-client", + "uv-configuration", "uv-fs", "uv-metadata", + "uv-warnings", ] [[package]] diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index eb6a304fc6d2..9399f94e0c2a 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -14,7 +14,7 @@ use url::Url; use uv_cache::CacheArgs; use uv_configuration::{ ConfigSettingEntry, ExportFormat, IndexStrategy, KeyringProviderType, PackageNameSpecifier, - TargetTriple, TrustedHost, + TargetTriple, TrustedHost, TrustedPublishing, }; use uv_normalize::{ExtraName, PackageName}; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; @@ -4347,6 +4347,14 @@ pub struct PublishArgs { )] pub token: Option, + /// Configure using trusted publishing through GitHub Actions. + /// + /// By default, uv checks for trusted publishing when running in GitHub Actions, but ignores it + /// if it isn't configured or the workflow doesn't have enough permissions (e.g., a pull request + /// from a fork). + #[arg(long)] + pub trusted_publishing: Option, + /// Attempt to use `keyring` for authentication for remote requirements files. /// /// At present, only `--keyring-provider subprocess` is supported, which configures uv to diff --git a/crates/uv-client/src/base_client.rs b/crates/uv-client/src/base_client.rs index c9a2b5d596a5..4dc6ecde7415 100644 --- a/crates/uv-client/src/base_client.rs +++ b/crates/uv-client/src/base_client.rs @@ -25,6 +25,19 @@ use crate::middleware::OfflineMiddleware; use crate::tls::read_identity; use crate::Connectivity; +/// Selectively skip parts or the entire auth middleware. +#[derive(Debug, Clone, Copy, Default)] +pub enum AuthIntegration { + /// Run the full auth middleware, including sending an unauthenticated request first. + #[default] + Default, + /// Send only an authenticated request without cloning and sending an unauthenticated request + /// first. Errors if no credentials were found. + OnlyAuthenticated, + /// Skip the auth middleware entirely. The caller is responsible for managing authentication. + NoAuthMiddleware, +} + /// A builder for an [`BaseClient`]. #[derive(Debug, Clone)] pub struct BaseClientBuilder<'a> { @@ -36,7 +49,7 @@ pub struct BaseClientBuilder<'a> { client: Option, markers: Option<&'a MarkerEnvironment>, platform: Option<&'a Platform>, - only_authenticated: bool, + auth_integration: AuthIntegration, } impl Default for BaseClientBuilder<'_> { @@ -56,7 +69,7 @@ impl BaseClientBuilder<'_> { client: None, markers: None, platform: None, - only_authenticated: false, + auth_integration: AuthIntegration::default(), } } } @@ -111,8 +124,8 @@ impl<'a> BaseClientBuilder<'a> { } #[must_use] - pub fn only_authenticated(mut self, only_authenticated: bool) -> Self { - self.only_authenticated = only_authenticated; + pub fn auth_integration(mut self, auth_integration: AuthIntegration) -> Self { + self.auth_integration = auth_integration; self } @@ -162,7 +175,7 @@ impl<'a> BaseClientBuilder<'a> { debug!("Using request timeout of {timeout}s"); // Create a secure client that validates certificates. - let client = self.create_client( + let raw_client = self.create_client( &user_agent_string, timeout, ssl_cert_file_exists, @@ -170,7 +183,7 @@ impl<'a> BaseClientBuilder<'a> { ); // Create an insecure client that accepts invalid certificates. - let dangerous_client = self.create_client( + let raw_dangerous_client = self.create_client( &user_agent_string, timeout, ssl_cert_file_exists, @@ -178,18 +191,37 @@ impl<'a> BaseClientBuilder<'a> { ); // Wrap in any relevant middleware and handle connectivity. - let client = self.apply_middleware(client); - let dangerous_client = self.apply_middleware(dangerous_client); + let client = self.apply_middleware(raw_client.clone()); + let dangerous_client = self.apply_middleware(raw_dangerous_client.clone()); BaseClient { connectivity: self.connectivity, allow_insecure_host: self.allow_insecure_host.clone(), client, + raw_client, dangerous_client, + raw_dangerous_client, timeout, } } + /// Share the underlying client between two different middleware configurations. + pub fn wrap_existing(&self, existing: &BaseClient) -> BaseClient { + // Wrap in any relevant middleware and handle connectivity. + let client = self.apply_middleware(existing.raw_client.clone()); + let dangerous_client = self.apply_middleware(existing.raw_dangerous_client.clone()); + + BaseClient { + connectivity: self.connectivity, + allow_insecure_host: self.allow_insecure_host.clone(), + client, + dangerous_client, + raw_client: existing.raw_client.clone(), + raw_dangerous_client: existing.raw_dangerous_client.clone(), + timeout: existing.timeout, + } + } + fn create_client( &self, user_agent: &str, @@ -253,11 +285,22 @@ impl<'a> BaseClientBuilder<'a> { } // Initialize the authentication middleware to set headers. - client = client.with( - AuthMiddleware::new() - .with_keyring(self.keyring.to_provider()) - .with_only_authenticated(self.only_authenticated), - ); + match self.auth_integration { + AuthIntegration::Default => { + client = client + .with(AuthMiddleware::new().with_keyring(self.keyring.to_provider())); + } + AuthIntegration::OnlyAuthenticated => { + client = client.with( + AuthMiddleware::new() + .with_keyring(self.keyring.to_provider()) + .with_only_authenticated(true), + ); + } + AuthIntegration::NoAuthMiddleware => { + // The downstream code uses custom auth logic. + } + } client.build() } @@ -275,6 +318,10 @@ pub struct BaseClient { client: ClientWithMiddleware, /// The underlying HTTP client that accepts invalid certificates. dangerous_client: ClientWithMiddleware, + /// The HTTP client without middleware. + raw_client: Client, + /// The HTTP client that accepts invalid certificates without middleware. + raw_dangerous_client: Client, /// The connectivity mode to use. connectivity: Connectivity, /// Configured client timeout, in seconds. @@ -297,6 +344,11 @@ impl BaseClient { self.client.clone() } + /// The underlying [`Client`] without middleware. + pub fn raw_client(&self) -> Client { + self.raw_client.clone() + } + /// Selects the appropriate client based on the host's trustworthiness. pub fn for_host(&self, url: &Url) -> &ClientWithMiddleware { if self diff --git a/crates/uv-client/src/lib.rs b/crates/uv-client/src/lib.rs index dce88b0acccf..fadf86aed78f 100644 --- a/crates/uv-client/src/lib.rs +++ b/crates/uv-client/src/lib.rs @@ -1,4 +1,4 @@ -pub use base_client::{BaseClient, BaseClientBuilder}; +pub use base_client::{AuthIntegration, BaseClient, BaseClientBuilder}; pub use cached_client::{CacheControl, CachedClient, CachedClientError, DataWithCachePolicy}; pub use error::{Error, ErrorKind, WrappedReqwestError}; pub use flat_index::{FlatIndexClient, FlatIndexEntries, FlatIndexError}; diff --git a/crates/uv-configuration/src/lib.rs b/crates/uv-configuration/src/lib.rs index fd2b6e1c43a8..ef93807bffd6 100644 --- a/crates/uv-configuration/src/lib.rs +++ b/crates/uv-configuration/src/lib.rs @@ -16,6 +16,7 @@ pub use preview::*; pub use sources::*; pub use target_triple::*; pub use trusted_host::*; +pub use trusted_publishing::*; mod authentication; mod build_options; @@ -35,3 +36,4 @@ mod preview; mod sources; mod target_triple; mod trusted_host; +mod trusted_publishing; diff --git a/crates/uv-configuration/src/trusted_publishing.rs b/crates/uv-configuration/src/trusted_publishing.rs new file mode 100644 index 000000000000..7e97cfe26b47 --- /dev/null +++ b/crates/uv-configuration/src/trusted_publishing.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum TrustedPublishing { + /// Try trusted publishing when we're already in GitHub Actions, continue if that fails. + #[default] + Automatic, + // Force trusted publishing. + Always, + // Never try to get a trusted publishing token. + Never, +} diff --git a/crates/uv-publish/Cargo.toml b/crates/uv-publish/Cargo.toml index 20224a4a2bc6..377dce0b4ba2 100644 --- a/crates/uv-publish/Cargo.toml +++ b/crates/uv-publish/Cargo.toml @@ -13,8 +13,10 @@ license.workspace = true distribution-filename = { workspace = true } pypi-types = { workspace = true } uv-client = { workspace = true } +uv-configuration = { workspace = true } uv-fs = { workspace = true } uv-metadata = { workspace = true } +uv-warnings = { workspace = true } async-compression = { workspace = true } base64 = { workspace = true } diff --git a/crates/uv-publish/src/lib.rs b/crates/uv-publish/src/lib.rs index cbe0e9c34e2d..5b8ede6d1c96 100644 --- a/crates/uv-publish/src/lib.rs +++ b/crates/uv-publish/src/lib.rs @@ -1,3 +1,6 @@ +mod trusted_publishing; + +use crate::trusted_publishing::TrustedPublishingError; use base64::prelude::BASE64_STANDARD; use base64::Engine; use distribution_filename::{DistFilename, SourceDistExtension, SourceDistFilename}; @@ -9,26 +12,29 @@ use pypi_types::{Metadata23, MetadataError}; use reqwest::header::AUTHORIZATION; use reqwest::multipart::Part; use reqwest::{Body, Response, StatusCode}; -use reqwest_middleware::RequestBuilder; +use reqwest_middleware::{ClientWithMiddleware, RequestBuilder}; use rustc_hash::FxHashSet; use serde::Deserialize; use sha2::{Digest, Sha256}; use std::io::BufReader; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::{fmt, io}; +use std::{env, fmt, io}; use thiserror::Error; use tokio::io::AsyncReadExt; use tokio_util::io::ReaderStream; use tracing::{debug, enabled, trace, Level}; use url::Url; -use uv_client::BaseClient; +use uv_configuration::{KeyringProviderType, TrustedPublishing}; use uv_fs::{ProgressReader, Simplified}; use uv_metadata::read_metadata_async_seek; +use uv_warnings::warn_user_once; + +pub use trusted_publishing::TrustedPublishingToken; #[derive(Error, Debug)] pub enum PublishError { - #[error("Invalid publish path: `{0}`")] + #[error("The publish path is not a valid glob pattern: `{0}`")] Pattern(String, #[source] PatternError), /// [`GlobError`] is a wrapped io error. #[error(transparent)] @@ -43,6 +49,8 @@ pub enum PublishError { PublishPrepare(PathBuf, #[source] Box), #[error("Failed to publish `{}` to {}", _0.user_display(), _1)] PublishSend(PathBuf, Url, #[source] PublishSendError), + #[error("Failed to obtain token for trusted publishing")] + TrustedPublishing(#[from] TrustedPublishingError), } /// Failure to get the metadata for a specific file. @@ -211,6 +219,57 @@ pub fn files_for_publishing( Ok(files) } +/// If applicable, attempt obtaining a token for trusted publishing. +pub async fn check_trusted_publishing( + username: Option<&str>, + password: Option<&str>, + keyring_provider: KeyringProviderType, + trusted_publishing: TrustedPublishing, + registry: &Url, + client: &ClientWithMiddleware, +) -> Result, PublishError> { + match trusted_publishing { + TrustedPublishing::Automatic => { + // If the user provided credentials, use those. + if username.is_some() + || password.is_some() + || keyring_provider != KeyringProviderType::Disabled + { + return Ok(None); + } + // If we aren't in GitHub Actions, we can't use trusted publishing. + if env::var("GITHUB_ACTIONS") != Ok("true".to_string()) { + return Ok(None); + } + // We could check for credentials from the keyring or netrc the auth middleware first, but + // given that we are in GitHub Actions we check for trusted publishing first. + debug!("Running on GitHub Actions without explicit credentials, checking for trusted publishing"); + match trusted_publishing::get_token(registry, client).await { + Ok(token) => Ok(Some(token)), + Err(err) => { + // TODO(konsti): It would be useful if we could differentiate between actual errors + // such as connection errors and warn for them while ignoring errors from trusted + // publishing not being configured. + debug!("Could not obtain trusted publishing credentials, skipping: {err}"); + Ok(None) + } + } + } + TrustedPublishing::Always => { + debug!("Using trusted publishing for GitHub Actions"); + if env::var("GITHUB_ACTIONS") != Ok("true".to_string()) { + warn_user_once!( + "Trusted publishing was requested, but you're not in GitHub Actions." + ); + } + + let token = trusted_publishing::get_token(registry, client).await?; + Ok(Some(token)) + } + TrustedPublishing::Never => Ok(None), + } +} + /// Upload a file to a registry. /// /// Returns `true` if the file was newly uploaded and `false` if it already existed. @@ -218,7 +277,7 @@ pub async fn upload( file: &Path, filename: &DistFilename, registry: &Url, - client: &BaseClient, + client: &ClientWithMiddleware, username: Option<&str>, password: Option<&str>, reporter: Arc, @@ -403,7 +462,7 @@ async fn build_request( file: &Path, filename: &DistFilename, registry: &Url, - client: &BaseClient, + client: &ClientWithMiddleware, username: Option<&str>, password: Option<&str>, form_metadata: Vec<(&'static str, String)>, @@ -441,7 +500,6 @@ async fn build_request( }; let mut request = client - .client() .post(url) .multipart(form) // Ask PyPI for a structured error messages instead of HTML-markup error messages. @@ -626,7 +684,7 @@ mod tests { &file, &filename, &Url::parse("https://example.org/upload").unwrap(), - &BaseClientBuilder::new().build(), + &BaseClientBuilder::new().build().client(), Some("ferris"), Some("F3RR!S"), form_metadata, @@ -769,7 +827,7 @@ mod tests { &file, &filename, &Url::parse("https://example.org/upload").unwrap(), - &BaseClientBuilder::new().build(), + &BaseClientBuilder::new().build().client(), Some("ferris"), Some("F3RR!S"), form_metadata, diff --git a/crates/uv-publish/src/trusted_publishing.rs b/crates/uv-publish/src/trusted_publishing.rs new file mode 100644 index 000000000000..dad1b3223be4 --- /dev/null +++ b/crates/uv-publish/src/trusted_publishing.rs @@ -0,0 +1,169 @@ +//! Trusted publishing (via OIDC) with GitHub actions. + +use reqwest::{header, StatusCode}; +use reqwest_middleware::ClientWithMiddleware; +use serde::{Deserialize, Serialize}; +use std::env; +use std::env::VarError; +use std::fmt::Display; +use thiserror::Error; +use tracing::{debug, trace}; +use url::Url; + +#[derive(Debug, Error)] +pub enum TrustedPublishingError { + #[error(transparent)] + Var(#[from] VarError), + #[error(transparent)] + Url(#[from] url::ParseError), + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + #[error(transparent)] + ReqwestMiddleware(#[from] reqwest_middleware::Error), + #[error(transparent)] + SerdeJson(#[from] serde_json::error::Error), + #[error( + "PyPI returned error code {0}, is trusted publishing correctly configured?\nResponse: {1}" + )] + Pypi(StatusCode, String), +} + +#[derive(Deserialize)] +#[serde(transparent)] +pub struct TrustedPublishingToken(String); + +impl From for String { + fn from(token: TrustedPublishingToken) -> Self { + token.0 + } +} + +impl Display for TrustedPublishingToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// The response from querying `https://pypi.org/_/oidc/audience`. +#[derive(Deserialize)] +struct Audience { + audience: String, +} + +/// The response from querying `$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi`. +#[derive(Deserialize)] +struct OidcToken { + value: String, +} + +/// The body for querying `$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi`. +#[derive(Serialize)] +struct MintTokenRequest { + token: String, +} + +/// The response from querying `$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi`. +#[derive(Deserialize)] +struct PublishToken { + token: TrustedPublishingToken, +} + +/// Returns the short-lived token to use for uploading. +pub(crate) async fn get_token( + registry: &Url, + client: &ClientWithMiddleware, +) -> Result { + // If this fails, we can skip the audience request. + let oidc_token_request_token = env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN")?; + + // Request 1: Get the audience + let audience = get_audience(registry, client).await?; + + // Request 2: Get the OIDC token from GitHub. + let oidc_token = get_oidc_token(&audience, &oidc_token_request_token, client).await?; + + // Request 3: Get the publishing token from PyPI. + let publish_token = get_publish_token(registry, &oidc_token, client).await?; + + debug!("Received token, using trusted publishing"); + + // Tell GitHub Actions to mask the token in any console logs. + #[allow(clippy::print_stdout)] + if env::var("GITHUB_ACTIONS") == Ok("true".to_string()) { + println!("::add-mask::{}", &publish_token); + } + + Ok(publish_token) +} + +async fn get_audience( + registry: &Url, + client: &ClientWithMiddleware, +) -> Result { + // `pypa/gh-action-pypi-publish` uses `netloc` (RFC 1808), which is deprecated for authority + // (RFC 3986). + let audience_url = Url::parse(&format!("https://{}/_/oidc/audience", registry.authority()))?; + debug!("Querying the trusted publishing audience from {audience_url}"); + let response = client.get(audience_url).send().await?; + let audience = response.error_for_status()?.json::().await?; + trace!("The audience is `{}`", &audience.audience); + Ok(audience.audience) +} + +async fn get_oidc_token( + audience: &str, + oidc_token_request_token: &str, + client: &ClientWithMiddleware, +) -> Result { + let mut oidc_token_url = Url::parse(&env::var("ACTIONS_ID_TOKEN_REQUEST_URL")?)?; + oidc_token_url + .query_pairs_mut() + .append_pair("audience", audience); + debug!("Querying the trusted publishing OIDC token from {oidc_token_url}"); + let authorization = format!("bearer {oidc_token_request_token}"); + let response = client + .get(oidc_token_url) + .header(header::AUTHORIZATION, authorization) + .send() + .await?; + let oidc_token: OidcToken = response.error_for_status()?.json().await?; + Ok(oidc_token.value) +} + +async fn get_publish_token( + registry: &Url, + oidc_token: &str, + client: &ClientWithMiddleware, +) -> Result { + let mint_token_url = Url::parse(&format!( + "https://{}/_/oidc/mint-token", + registry.authority() + ))?; + debug!("Querying the trusted publishing upload token from {mint_token_url}"); + let mint_token_payload = MintTokenRequest { + token: oidc_token.to_string(), + }; + let response = client + .post(mint_token_url) + .body(serde_json::to_vec(&mint_token_payload)?) + .send() + .await?; + + // reqwest's implementation of `.json()` also goes through `.bytes()` + let status = response.status(); + let body = response.bytes().await?; + + if status.is_success() { + let publish_token: PublishToken = serde_json::from_slice(&body)?; + Ok(publish_token.token) + } else { + // An error here means that something is misconfigured, e.g. a typo in the PyPI + // configuration, so we're showing the body for more context, see + // https://docs.pypi.org/trusted-publishers/troubleshooting/#token-minting + // for what the body can mean. + Err(TrustedPublishingError::Pypi( + status, + String::from_utf8_lossy(&body).to_string(), + )) + } +} diff --git a/crates/uv-settings/src/combine.rs b/crates/uv-settings/src/combine.rs index a4e53e6451f3..32e7261e6c44 100644 --- a/crates/uv-settings/src/combine.rs +++ b/crates/uv-settings/src/combine.rs @@ -5,7 +5,9 @@ use url::Url; use distribution_types::IndexUrl; use install_wheel_rs::linker::LinkMode; use pypi_types::SupportedEnvironments; -use uv_configuration::{ConfigSettings, IndexStrategy, KeyringProviderType, TargetTriple}; +use uv_configuration::{ + ConfigSettings, IndexStrategy, KeyringProviderType, TargetTriple, TrustedPublishing, +}; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; use uv_resolver::{AnnotationStyle, ExcludeNewer, PrereleaseMode, ResolutionMode}; @@ -85,6 +87,7 @@ impl_combine_or!(ResolutionMode); impl_combine_or!(String); impl_combine_or!(SupportedEnvironments); impl_combine_or!(TargetTriple); +impl_combine_or!(TrustedPublishing); impl_combine_or!(bool); impl Combine for Option> { diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index b75f9099dee1..acd8fa5a9032 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -9,7 +9,7 @@ use url::Url; use uv_cache_info::CacheKey; use uv_configuration::{ ConfigSettings, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple, - TrustedHost, + TrustedHost, TrustedPublishing, }; use uv_macros::{CombineOptions, OptionsMetadata}; use uv_normalize::{ExtraName, PackageName}; @@ -1501,6 +1501,7 @@ pub struct OptionsWire { no_binary: Option, no_binary_package: Option>, publish_url: Option, + trusted_publishing: Option, pip: Option, cache_keys: Option>, @@ -1569,6 +1570,7 @@ impl From for Options { constraint_dependencies, environments, publish_url, + trusted_publishing, workspace: _, sources: _, dev_dependencies: _, @@ -1616,7 +1618,10 @@ impl From for Options { no_binary, no_binary_package, }, - publish: PublishOptions { publish_url }, + publish: PublishOptions { + publish_url, + trusted_publishing, + }, pip, cache_keys, override_dependencies, @@ -1642,4 +1647,18 @@ pub struct PublishOptions { "# )] pub publish_url: Option, + + /// Configure trusted publishing via GitHub Actions. + /// + /// By default, uv checks for trusted publishing when running in GitHub Actions, but ignores it + /// if it isn't configured or the workflow doesn't have enough permissions (e.g., a pull request + /// from a fork). + #[option( + default = "automatic", + value_type = "str", + example = r#" + trusted-publishing = "always" + "# + )] + pub trusted_publishing: Option, } diff --git a/crates/uv/src/commands/publish.rs b/crates/uv/src/commands/publish.rs index b675721ce61b..ca52af6f8cc7 100644 --- a/crates/uv/src/commands/publish.rs +++ b/crates/uv/src/commands/publish.rs @@ -7,13 +7,14 @@ use std::fmt::Write; use std::sync::Arc; use tracing::info; use url::Url; -use uv_client::{BaseClientBuilder, Connectivity}; -use uv_configuration::{KeyringProviderType, TrustedHost}; -use uv_publish::{files_for_publishing, upload}; +use uv_client::{AuthIntegration, BaseClientBuilder, Connectivity}; +use uv_configuration::{KeyringProviderType, TrustedHost, TrustedPublishing}; +use uv_publish::{check_trusted_publishing, files_for_publishing, upload}; pub(crate) async fn publish( paths: Vec, publish_url: Url, + trusted_publishing: TrustedPublishing, keyring_provider: KeyringProviderType, allow_insecure_host: Vec, username: Option, @@ -33,16 +34,42 @@ pub(crate) async fn publish( n => writeln!(printer.stderr(), "Publishing {n} files {publish_url}")?, } - let client = BaseClientBuilder::new() - // Don't try cloning the request for retries. - // https://github.com/seanmonstar/reqwest/issues/2416 + // * For the uploads themselves, we can't use retries due to + // https://github.com/seanmonstar/reqwest/issues/2416, but for trusted publishing, we want + // retires. + // * We want to allow configuring TLS for the registry, while for trusted publishing we know the + // defaults are correct. + // * For the uploads themselves, we know we need an authorization header and we can't nor + // shouldn't try cloning the request to make an unauthenticated request first, but we want + // keyring integration. For trusted publishing, we use an OIDC auth routine without keyring + // or other auth integration. + let upload_client = BaseClientBuilder::new() .retries(0) .keyring(keyring_provider) .native_tls(native_tls) .allow_insecure_host(allow_insecure_host) // Don't try cloning the request to make an unauthenticated request first. - .only_authenticated(true) + .auth_integration(AuthIntegration::OnlyAuthenticated) .build(); + let oidc_client = BaseClientBuilder::new() + .auth_integration(AuthIntegration::NoAuthMiddleware) + .wrap_existing(&upload_client); + + // If applicable, attempt obtaining a token for trusted publishing. + let trusted_publishing_token = check_trusted_publishing( + username.as_deref(), + password.as_deref(), + keyring_provider, + trusted_publishing, + &publish_url, + &oidc_client.client(), + ) + .await?; + let (username, password) = if let Some(password) = trusted_publishing_token { + (Some("__token__".to_string()), Some(password.into())) + } else { + (username, password) + }; for (file, filename) in files { let size = fs_err::metadata(&file)?.len(); @@ -58,7 +85,7 @@ pub(crate) async fn publish( &file, &filename, &publish_url, - &client, + &upload_client.client(), username.as_deref(), password.as_deref(), // Needs to be an `Arc` because the reqwest `Body` static lifetime requirement diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 47ca9f41c4a5..e81b5b09089d 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1099,6 +1099,7 @@ async fn run(cli: Cli) -> Result { username, password, publish_url, + trusted_publishing, keyring_provider, allow_insecure_host, } = PublishSettings::resolve(args, filesystem); @@ -1106,6 +1107,7 @@ async fn run(cli: Cli) -> Result { commands::publish( files, publish_url, + trusted_publishing, keyring_provider, allow_insecure_host, username, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index ab4ab552fc54..c05f5799ff56 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -25,7 +25,8 @@ use uv_client::Connectivity; use uv_configuration::{ BuildOptions, Concurrency, ConfigSettings, DevMode, EditableMode, ExportFormat, ExtrasSpecification, HashCheckingMode, IndexStrategy, InstallOptions, KeyringProviderType, - NoBinary, NoBuild, PreviewMode, Reinstall, SourceStrategy, TargetTriple, TrustedHost, Upgrade, + NoBinary, NoBuild, PreviewMode, Reinstall, SourceStrategy, TargetTriple, TrustedHost, + TrustedPublishing, Upgrade, }; use uv_normalize::PackageName; use uv_python::{Prefix, PythonDownloads, PythonPreference, PythonVersion, Target}; @@ -2436,6 +2437,7 @@ pub(crate) struct PublishSettings { // Both CLI and configuration. pub(crate) publish_url: Url, + pub(crate) trusted_publishing: TrustedPublishing, pub(crate) keyring_provider: KeyringProviderType, pub(crate) allow_insecure_host: Vec, } @@ -2449,7 +2451,10 @@ impl PublishSettings { .map(FilesystemOptions::into_options) .unwrap_or_default(); - let PublishOptions { publish_url } = publish; + let PublishOptions { + publish_url, + trusted_publishing, + } = publish; let ResolverInstallerOptions { keyring_provider, allow_insecure_host, @@ -2471,6 +2476,9 @@ impl PublishSettings { .publish_url .combine(publish_url) .unwrap_or_else(|| Url::parse(PYPI_PUBLISH_URL).unwrap()), + trusted_publishing: trusted_publishing + .combine(args.trusted_publishing) + .unwrap_or_default(), keyring_provider: args .keyring_provider .combine(keyring_provider) diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index 6bbb6419eea2..1c1205c5b83f 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -194,7 +194,7 @@ fn invalid_pyproject_toml_option_unknown_field() -> Result<()> { | 2 | unknown = "field" | ^^^^^^^ - unknown field `unknown`, expected one of `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `dependency-metadata`, `config-settings`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `publish-url`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `environments`, `workspace`, `sources`, `dev-dependencies`, `managed`, `package` + unknown field `unknown`, expected one of `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `dependency-metadata`, `config-settings`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `publish-url`, `trusted-publishing`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `environments`, `workspace`, `sources`, `dev-dependencies`, `managed`, `package` Resolved in [TIME] Audited in [TIME] diff --git a/crates/uv/tests/show_settings.rs b/crates/uv/tests/show_settings.rs index ab039424b1f5..dcad6fac7834 100644 --- a/crates/uv/tests/show_settings.rs +++ b/crates/uv/tests/show_settings.rs @@ -3150,7 +3150,7 @@ fn resolve_config_file() -> anyhow::Result<()> { | 1 | [project] | ^^^^^^^ - unknown field `project`, expected one of `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `dependency-metadata`, `config-settings`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `publish-url`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `environments`, `workspace`, `sources`, `dev-dependencies`, `managed`, `package` + unknown field `project`, expected one of `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `dependency-metadata`, `config-settings`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `publish-url`, `trusted-publishing`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `environments`, `workspace`, `sources`, `dev-dependencies`, `managed`, `package` "### ); diff --git a/docs/guides/publish.md b/docs/guides/publish.md index cae0acb0a92c..9c455259904f 100644 --- a/docs/guides/publish.md +++ b/docs/guides/publish.md @@ -38,6 +38,11 @@ $ uv publish Set a PyPI token with `--token` or `UV_PUBLISH_TOKEN`, or set a username with `--username` or `UV_PUBLISH_USERNAME` and password with `--password` or `UV_PUBLISH_PASSWORD`. +!!! info + + For publishing to PyPI from GitHub Actions, you don't need to set any credentials. Instead, + [add a trusted publisher to the PyPI project](https://docs.pypi.org/trusted-publishers/adding-a-publisher/). + !!! note PyPI does not support publishing with username and password anymore, instead you need to diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9ec4a70d9b3d..414143434c22 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -6913,6 +6913,19 @@ uv publish [OPTIONS] [FILES]...

Using a token is equivalent to passing __token__ as --username and the token as --password. password.

May also be set with the UV_PUBLISH_TOKEN environment variable.

+
--trusted-publishing trusted-publishing

Configure using trusted publishing through GitHub Actions.

+ +

By default, uv checks for trusted publishing when running in GitHub Actions, but ignores it if it isn’t configured or the workflow doesn’t have enough permissions (e.g., a pull request from a fork).

+ +

Possible values:

+ +
    +
  • automatic: Try trusted publishing when we’re already in GitHub Actions, continue if that fails
  • + +
  • always
  • + +
  • never
  • +
--username, -u username

The username for the upload

May also be set with the UV_PUBLISH_USERNAME environment variable.

diff --git a/docs/reference/settings.md b/docs/reference/settings.md index c0b5dd6dc7df..4e05ccb40aa9 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -1246,6 +1246,35 @@ By default, uv will use the latest compatible version of each package (`highest` --- +### [`trusted-publishing`](#trusted-publishing) {: #trusted-publishing } + +Configure trusted publishing via GitHub Actions. + +By default, uv checks for trusted publishing when running in GitHub Actions, but ignores it +if it isn't configured or the workflow doesn't have enough permissions (e.g., a pull request +from a fork). + +**Default value**: `automatic` + +**Type**: `str` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.uv] + trusted-publishing = "always" + ``` +=== "uv.toml" + + ```toml + + trusted-publishing = "always" + ``` + +--- + ### [`upgrade`](#upgrade) {: #upgrade } Allow package upgrades, ignoring pinned versions in any existing output file. diff --git a/scripts/publish/test_publish.py b/scripts/publish/test_publish.py index 26733fbec1ce..d15a3b04f786 100644 --- a/scripts/publish/test_publish.py +++ b/scripts/publish/test_publish.py @@ -29,6 +29,9 @@ The query parameter a horrible hack stolen from https://github.com/pypa/twine/issues/565#issue-555219267 to prevent the other projects from implicitly using the same credentials. + +**astral-test-trusted-publishing** +This one only works in GitHub Actions on astral-sh/uv in `ci.yml` - sorry! """ import os @@ -47,6 +50,7 @@ "astral-test-token": "https://test.pypi.org/simple/astral-test-token/", "astral-test-password": "https://test.pypi.org/simple/astral-test-password/", "astral-test-keyring": "https://test.pypi.org/simple/astral-test-keyring/", + "astral-test-trusted-publishing": "https://test.pypi.org/simple/astral-test-trusted-publishing/", "astral-test-gitlab-pat": "https://gitlab.com/api/v4/projects/61853105/packages/pypi/simple/astral-test-gitlab-pat", } @@ -147,6 +151,18 @@ def publish_project(project_name: str, uv: Path): cwd=cwd.joinpath(project_name), env=env, ) + elif project_name == "astral-test-trusted-publishing": + check_call( + [ + uv, + "publish", + "--publish-url", + "https://test.pypi.org/legacy/", + "--trusted-publishing", + "always", + ], + cwd=cwd.joinpath(project_name), + ) else: raise ValueError(f"Unknown project name: {project_name}") diff --git a/uv.schema.json b/uv.schema.json index fd96bf5436de..645ffc4582fb 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -398,6 +398,17 @@ } ] }, + "trusted-publishing": { + "description": "Configure trusted publishing via GitHub Actions.\n\nBy default, uv checks for trusted publishing when running in GitHub Actions, but ignores it if it isn't configured or the workflow doesn't have enough permissions (e.g., a pull request from a fork).", + "anyOf": [ + { + "$ref": "#/definitions/TrustedPublishing" + }, + { + "type": "null" + } + ] + }, "upgrade": { "description": "Allow package upgrades, ignoring pinned versions in any existing output file.", "type": [ @@ -1586,6 +1597,24 @@ "TrustedHost": { "description": "A host or host-port pair.", "type": "string" + }, + "TrustedPublishing": { + "oneOf": [ + { + "type": "string", + "enum": [ + "always", + "never" + ] + }, + { + "description": "Try trusted publishing when we're already in GitHub Actions, continue if that fails.", + "type": "string", + "enum": [ + "automatic" + ] + } + ] } } } \ No newline at end of file