From a1636c663e72e09ed457ef06fabe99023114ab65 Mon Sep 17 00:00:00 2001 From: konstin Date: Wed, 18 Sep 2024 20:45:25 +0200 Subject: [PATCH] Add redundant check Update crates/uv-settings/src/Implement trusted publishing Co-authored-by: Charlie Marsh --- .github/workflows/ci.yml | 3 + Cargo.lock | 2 + crates/uv-cli/src/lib.rs | 10 +- crates/uv-client/src/base_client.rs | 78 +++++++-- crates/uv-client/src/lib.rs | 2 +- crates/uv-configuration/src/lib.rs | 2 + .../src/trusted_publishing.rs | 15 ++ crates/uv-publish/Cargo.toml | 2 + crates/uv-publish/src/lib.rs | 74 +++++++-- crates/uv-publish/src/trusted_publishing.rs | 152 ++++++++++++++++++ crates/uv-settings/src/combine.rs | 5 +- crates/uv-settings/src/settings.rs | 23 ++- crates/uv/src/commands/publish.rs | 43 ++++- crates/uv/src/lib.rs | 2 + crates/uv/src/settings.rs | 12 +- crates/uv/tests/pip_install.rs | 2 +- crates/uv/tests/show_settings.rs | 2 +- docs/guides/publish.md | 5 + docs/reference/cli.md | 13 ++ docs/reference/settings.md | 29 ++++ scripts/publish/test_publish.py | 16 ++ uv.schema.json | 29 ++++ 22 files changed, 482 insertions(+), 39 deletions(-) create mode 100644 crates/uv-configuration/src/trusted_publishing.rs create mode 100644 crates/uv-publish/src/trusted_publishing.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55a171ae3028e..bd990f29b3b08 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 c707253d7e21a..bed9813c512b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5029,8 +5029,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 eb6a304fc6d29..9399f94e0c2af 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 c9a2b5d596a59..4dc6ecde74158 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 dce88b0acccf0..fadf86aed78f9 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 fd2b6e1c43a8b..ef93807bffd69 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 0000000000000..7e97cfe26b47b --- /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 6e186b978dd33..5d1ed29e8adc8 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 76b00b942a4b2..8190f3d18a92e 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,24 +12,25 @@ 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::{fmt, io}; +use std::{env, fmt, io}; use thiserror::Error; use tokio::io::AsyncReadExt; use tracing::{debug, enabled, trace, Level}; use url::Url; -use uv_client::BaseClient; +use uv_configuration::{KeyringProviderType, TrustedPublishing}; use uv_fs::Simplified; use uv_metadata::read_metadata_async_seek; +use uv_warnings::warn_user_once; #[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)] @@ -41,6 +45,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. @@ -202,6 +208,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. @@ -209,7 +266,7 @@ pub async fn upload( file: &Path, filename: &DistFilename, registry: &Url, - client: &BaseClient, + client: &ClientWithMiddleware, username: Option<&str>, password: Option<&str>, ) -> Result { @@ -392,7 +449,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)>, @@ -425,7 +482,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. @@ -598,7 +654,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, @@ -740,7 +796,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 0000000000000..a2236b0cc3d2f --- /dev/null +++ b/crates/uv-publish/src/trusted_publishing.rs @@ -0,0 +1,152 @@ +//! 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 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), +} + +/// 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: String, +} + +/// 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 a4e53e6451f3c..32e7261e6c444 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 b75f9099dee1f..acd8fa5a90322 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 84b5be894446f..32e3559c71e4c 100644 --- a/crates/uv/src/commands/publish.rs +++ b/crates/uv/src/commands/publish.rs @@ -5,13 +5,14 @@ use owo_colors::OwoColorize; use std::fmt::Write; 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, @@ -31,16 +32,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)) + } else { + (username, password) + }; for (file, filename) in files { let size = fs_err::metadata(&file)?.len(); @@ -55,7 +82,7 @@ pub(crate) async fn publish( &file, &filename, &publish_url, - &client, + &upload_client.client(), username.as_deref(), password.as_deref(), ) diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 47ca9f41c4a50..e81b5b09089d3 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 ab4ab552fc54a..c05f5799ff56b 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 6bbb6419eea2e..1c1205c5b83f6 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 ab039424b1f54..dcad6fac78347 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 cae0acb0a92ce..9c455259904f8 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 9ec4a70d9b3da..414143434c226 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 c0b5dd6dc7dfa..4e05ccb40aa99 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 26733fbec1ce8..d15a3b04f7867 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 fd96bf5436de2..645ffc4582fbf 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