Implement trusted publishing (#7548)

Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
konsti 2024-09-24 18:07:20 +02:00 committed by GitHub
parent c053dc84f4
commit 205bf8cabe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 502 additions and 40 deletions

View File

@ -979,6 +979,9 @@ jobs:
env: env:
# No dbus in GitHub Actions # No dbus in GitHub Actions
PYTHON_KEYRING_BACKEND: keyrings.alt.file.PlaintextKeyring PYTHON_KEYRING_BACKEND: keyrings.alt.file.PlaintextKeyring
permissions:
# For trusted publishing
id-token: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:

2
Cargo.lock generated
View File

@ -5030,8 +5030,10 @@ dependencies = [
"tracing", "tracing",
"url", "url",
"uv-client", "uv-client",
"uv-configuration",
"uv-fs", "uv-fs",
"uv-metadata", "uv-metadata",
"uv-warnings",
] ]
[[package]] [[package]]

View File

@ -14,7 +14,7 @@ use url::Url;
use uv_cache::CacheArgs; use uv_cache::CacheArgs;
use uv_configuration::{ use uv_configuration::{
ConfigSettingEntry, ExportFormat, IndexStrategy, KeyringProviderType, PackageNameSpecifier, ConfigSettingEntry, ExportFormat, IndexStrategy, KeyringProviderType, PackageNameSpecifier,
TargetTriple, TrustedHost, TargetTriple, TrustedHost, TrustedPublishing,
}; };
use uv_normalize::{ExtraName, PackageName}; use uv_normalize::{ExtraName, PackageName};
use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; use uv_python::{PythonDownloads, PythonPreference, PythonVersion};
@ -4347,6 +4347,14 @@ pub struct PublishArgs {
)] )]
pub token: Option<String>, pub token: Option<String>,
/// 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<TrustedPublishing>,
/// Attempt to use `keyring` for authentication for remote requirements files. /// Attempt to use `keyring` for authentication for remote requirements files.
/// ///
/// At present, only `--keyring-provider subprocess` is supported, which configures uv to /// At present, only `--keyring-provider subprocess` is supported, which configures uv to

View File

@ -25,6 +25,19 @@ use crate::middleware::OfflineMiddleware;
use crate::tls::read_identity; use crate::tls::read_identity;
use crate::Connectivity; 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`]. /// A builder for an [`BaseClient`].
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BaseClientBuilder<'a> { pub struct BaseClientBuilder<'a> {
@ -36,7 +49,7 @@ pub struct BaseClientBuilder<'a> {
client: Option<Client>, client: Option<Client>,
markers: Option<&'a MarkerEnvironment>, markers: Option<&'a MarkerEnvironment>,
platform: Option<&'a Platform>, platform: Option<&'a Platform>,
only_authenticated: bool, auth_integration: AuthIntegration,
} }
impl Default for BaseClientBuilder<'_> { impl Default for BaseClientBuilder<'_> {
@ -56,7 +69,7 @@ impl BaseClientBuilder<'_> {
client: None, client: None,
markers: None, markers: None,
platform: None, platform: None,
only_authenticated: false, auth_integration: AuthIntegration::default(),
} }
} }
} }
@ -111,8 +124,8 @@ impl<'a> BaseClientBuilder<'a> {
} }
#[must_use] #[must_use]
pub fn only_authenticated(mut self, only_authenticated: bool) -> Self { pub fn auth_integration(mut self, auth_integration: AuthIntegration) -> Self {
self.only_authenticated = only_authenticated; self.auth_integration = auth_integration;
self self
} }
@ -162,7 +175,7 @@ impl<'a> BaseClientBuilder<'a> {
debug!("Using request timeout of {timeout}s"); debug!("Using request timeout of {timeout}s");
// Create a secure client that validates certificates. // Create a secure client that validates certificates.
let client = self.create_client( let raw_client = self.create_client(
&user_agent_string, &user_agent_string,
timeout, timeout,
ssl_cert_file_exists, ssl_cert_file_exists,
@ -170,7 +183,7 @@ impl<'a> BaseClientBuilder<'a> {
); );
// Create an insecure client that accepts invalid certificates. // Create an insecure client that accepts invalid certificates.
let dangerous_client = self.create_client( let raw_dangerous_client = self.create_client(
&user_agent_string, &user_agent_string,
timeout, timeout,
ssl_cert_file_exists, ssl_cert_file_exists,
@ -178,15 +191,34 @@ impl<'a> BaseClientBuilder<'a> {
); );
// Wrap in any relevant middleware and handle connectivity. // Wrap in any relevant middleware and handle connectivity.
let client = self.apply_middleware(client); let client = self.apply_middleware(raw_client.clone());
let dangerous_client = self.apply_middleware(dangerous_client); 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 { BaseClient {
connectivity: self.connectivity, connectivity: self.connectivity,
allow_insecure_host: self.allow_insecure_host.clone(), allow_insecure_host: self.allow_insecure_host.clone(),
client, client,
dangerous_client, dangerous_client,
timeout, raw_client: existing.raw_client.clone(),
raw_dangerous_client: existing.raw_dangerous_client.clone(),
timeout: existing.timeout,
} }
} }
@ -253,11 +285,22 @@ impl<'a> BaseClientBuilder<'a> {
} }
// Initialize the authentication middleware to set headers. // Initialize the authentication middleware to set headers.
client = client.with( match self.auth_integration {
AuthMiddleware::new() AuthIntegration::Default => {
.with_keyring(self.keyring.to_provider()) client = client
.with_only_authenticated(self.only_authenticated), .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() client.build()
} }
@ -275,6 +318,10 @@ pub struct BaseClient {
client: ClientWithMiddleware, client: ClientWithMiddleware,
/// The underlying HTTP client that accepts invalid certificates. /// The underlying HTTP client that accepts invalid certificates.
dangerous_client: ClientWithMiddleware, 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. /// The connectivity mode to use.
connectivity: Connectivity, connectivity: Connectivity,
/// Configured client timeout, in seconds. /// Configured client timeout, in seconds.
@ -297,6 +344,11 @@ impl BaseClient {
self.client.clone() 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. /// Selects the appropriate client based on the host's trustworthiness.
pub fn for_host(&self, url: &Url) -> &ClientWithMiddleware { pub fn for_host(&self, url: &Url) -> &ClientWithMiddleware {
if self if self

View File

@ -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 cached_client::{CacheControl, CachedClient, CachedClientError, DataWithCachePolicy};
pub use error::{Error, ErrorKind, WrappedReqwestError}; pub use error::{Error, ErrorKind, WrappedReqwestError};
pub use flat_index::{FlatIndexClient, FlatIndexEntries, FlatIndexError}; pub use flat_index::{FlatIndexClient, FlatIndexEntries, FlatIndexError};

View File

@ -16,6 +16,7 @@ pub use preview::*;
pub use sources::*; pub use sources::*;
pub use target_triple::*; pub use target_triple::*;
pub use trusted_host::*; pub use trusted_host::*;
pub use trusted_publishing::*;
mod authentication; mod authentication;
mod build_options; mod build_options;
@ -35,3 +36,4 @@ mod preview;
mod sources; mod sources;
mod target_triple; mod target_triple;
mod trusted_host; mod trusted_host;
mod trusted_publishing;

View File

@ -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,
}

View File

@ -13,8 +13,10 @@ license.workspace = true
distribution-filename = { workspace = true } distribution-filename = { workspace = true }
pypi-types = { workspace = true } pypi-types = { workspace = true }
uv-client = { workspace = true } uv-client = { workspace = true }
uv-configuration = { workspace = true }
uv-fs = { workspace = true } uv-fs = { workspace = true }
uv-metadata = { workspace = true } uv-metadata = { workspace = true }
uv-warnings = { workspace = true }
async-compression = { workspace = true } async-compression = { workspace = true }
base64 = { workspace = true } base64 = { workspace = true }

View File

@ -1,3 +1,6 @@
mod trusted_publishing;
use crate::trusted_publishing::TrustedPublishingError;
use base64::prelude::BASE64_STANDARD; use base64::prelude::BASE64_STANDARD;
use base64::Engine; use base64::Engine;
use distribution_filename::{DistFilename, SourceDistExtension, SourceDistFilename}; use distribution_filename::{DistFilename, SourceDistExtension, SourceDistFilename};
@ -9,26 +12,29 @@ use pypi_types::{Metadata23, MetadataError};
use reqwest::header::AUTHORIZATION; use reqwest::header::AUTHORIZATION;
use reqwest::multipart::Part; use reqwest::multipart::Part;
use reqwest::{Body, Response, StatusCode}; use reqwest::{Body, Response, StatusCode};
use reqwest_middleware::RequestBuilder; use reqwest_middleware::{ClientWithMiddleware, RequestBuilder};
use rustc_hash::FxHashSet; use rustc_hash::FxHashSet;
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::io::BufReader; use std::io::BufReader;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::{fmt, io}; use std::{env, fmt, io};
use thiserror::Error; use thiserror::Error;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use tracing::{debug, enabled, trace, Level}; use tracing::{debug, enabled, trace, Level};
use url::Url; use url::Url;
use uv_client::BaseClient; use uv_configuration::{KeyringProviderType, TrustedPublishing};
use uv_fs::{ProgressReader, Simplified}; use uv_fs::{ProgressReader, Simplified};
use uv_metadata::read_metadata_async_seek; use uv_metadata::read_metadata_async_seek;
use uv_warnings::warn_user_once;
pub use trusted_publishing::TrustedPublishingToken;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum PublishError { pub enum PublishError {
#[error("Invalid publish path: `{0}`")] #[error("The publish path is not a valid glob pattern: `{0}`")]
Pattern(String, #[source] PatternError), Pattern(String, #[source] PatternError),
/// [`GlobError`] is a wrapped io error. /// [`GlobError`] is a wrapped io error.
#[error(transparent)] #[error(transparent)]
@ -43,6 +49,8 @@ pub enum PublishError {
PublishPrepare(PathBuf, #[source] Box<PublishPrepareError>), PublishPrepare(PathBuf, #[source] Box<PublishPrepareError>),
#[error("Failed to publish `{}` to {}", _0.user_display(), _1)] #[error("Failed to publish `{}` to {}", _0.user_display(), _1)]
PublishSend(PathBuf, Url, #[source] PublishSendError), 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. /// Failure to get the metadata for a specific file.
@ -211,6 +219,57 @@ pub fn files_for_publishing(
Ok(files) 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<Option<TrustedPublishingToken>, 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. /// Upload a file to a registry.
/// ///
/// Returns `true` if the file was newly uploaded and `false` if it already existed. /// Returns `true` if the file was newly uploaded and `false` if it already existed.
@ -218,7 +277,7 @@ pub async fn upload(
file: &Path, file: &Path,
filename: &DistFilename, filename: &DistFilename,
registry: &Url, registry: &Url,
client: &BaseClient, client: &ClientWithMiddleware,
username: Option<&str>, username: Option<&str>,
password: Option<&str>, password: Option<&str>,
reporter: Arc<impl Reporter>, reporter: Arc<impl Reporter>,
@ -403,7 +462,7 @@ async fn build_request(
file: &Path, file: &Path,
filename: &DistFilename, filename: &DistFilename,
registry: &Url, registry: &Url,
client: &BaseClient, client: &ClientWithMiddleware,
username: Option<&str>, username: Option<&str>,
password: Option<&str>, password: Option<&str>,
form_metadata: Vec<(&'static str, String)>, form_metadata: Vec<(&'static str, String)>,
@ -441,7 +500,6 @@ async fn build_request(
}; };
let mut request = client let mut request = client
.client()
.post(url) .post(url)
.multipart(form) .multipart(form)
// Ask PyPI for a structured error messages instead of HTML-markup error messages. // Ask PyPI for a structured error messages instead of HTML-markup error messages.
@ -626,7 +684,7 @@ mod tests {
&file, &file,
&filename, &filename,
&Url::parse("https://example.org/upload").unwrap(), &Url::parse("https://example.org/upload").unwrap(),
&BaseClientBuilder::new().build(), &BaseClientBuilder::new().build().client(),
Some("ferris"), Some("ferris"),
Some("F3RR!S"), Some("F3RR!S"),
form_metadata, form_metadata,
@ -769,7 +827,7 @@ mod tests {
&file, &file,
&filename, &filename,
&Url::parse("https://example.org/upload").unwrap(), &Url::parse("https://example.org/upload").unwrap(),
&BaseClientBuilder::new().build(), &BaseClientBuilder::new().build().client(),
Some("ferris"), Some("ferris"),
Some("F3RR!S"), Some("F3RR!S"),
form_metadata, form_metadata,

View File

@ -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<TrustedPublishingToken> 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<TrustedPublishingToken, TrustedPublishingError> {
// 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<String, TrustedPublishingError> {
// `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::<Audience>().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<String, TrustedPublishingError> {
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<TrustedPublishingToken, TrustedPublishingError> {
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(),
))
}
}

View File

@ -5,7 +5,9 @@ use url::Url;
use distribution_types::IndexUrl; use distribution_types::IndexUrl;
use install_wheel_rs::linker::LinkMode; use install_wheel_rs::linker::LinkMode;
use pypi_types::SupportedEnvironments; 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_python::{PythonDownloads, PythonPreference, PythonVersion};
use uv_resolver::{AnnotationStyle, ExcludeNewer, PrereleaseMode, ResolutionMode}; use uv_resolver::{AnnotationStyle, ExcludeNewer, PrereleaseMode, ResolutionMode};
@ -85,6 +87,7 @@ impl_combine_or!(ResolutionMode);
impl_combine_or!(String); impl_combine_or!(String);
impl_combine_or!(SupportedEnvironments); impl_combine_or!(SupportedEnvironments);
impl_combine_or!(TargetTriple); impl_combine_or!(TargetTriple);
impl_combine_or!(TrustedPublishing);
impl_combine_or!(bool); impl_combine_or!(bool);
impl<T> Combine for Option<Vec<T>> { impl<T> Combine for Option<Vec<T>> {

View File

@ -9,7 +9,7 @@ use url::Url;
use uv_cache_info::CacheKey; use uv_cache_info::CacheKey;
use uv_configuration::{ use uv_configuration::{
ConfigSettings, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple, ConfigSettings, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple,
TrustedHost, TrustedHost, TrustedPublishing,
}; };
use uv_macros::{CombineOptions, OptionsMetadata}; use uv_macros::{CombineOptions, OptionsMetadata};
use uv_normalize::{ExtraName, PackageName}; use uv_normalize::{ExtraName, PackageName};
@ -1501,6 +1501,7 @@ pub struct OptionsWire {
no_binary: Option<bool>, no_binary: Option<bool>,
no_binary_package: Option<Vec<PackageName>>, no_binary_package: Option<Vec<PackageName>>,
publish_url: Option<Url>, publish_url: Option<Url>,
trusted_publishing: Option<TrustedPublishing>,
pip: Option<PipOptions>, pip: Option<PipOptions>,
cache_keys: Option<Vec<CacheKey>>, cache_keys: Option<Vec<CacheKey>>,
@ -1569,6 +1570,7 @@ impl From<OptionsWire> for Options {
constraint_dependencies, constraint_dependencies,
environments, environments,
publish_url, publish_url,
trusted_publishing,
workspace: _, workspace: _,
sources: _, sources: _,
dev_dependencies: _, dev_dependencies: _,
@ -1616,7 +1618,10 @@ impl From<OptionsWire> for Options {
no_binary, no_binary,
no_binary_package, no_binary_package,
}, },
publish: PublishOptions { publish_url }, publish: PublishOptions {
publish_url,
trusted_publishing,
},
pip, pip,
cache_keys, cache_keys,
override_dependencies, override_dependencies,
@ -1642,4 +1647,18 @@ pub struct PublishOptions {
"# "#
)] )]
pub publish_url: Option<Url>, pub publish_url: Option<Url>,
/// 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<TrustedPublishing>,
} }

View File

@ -7,13 +7,14 @@ use std::fmt::Write;
use std::sync::Arc; use std::sync::Arc;
use tracing::info; use tracing::info;
use url::Url; use url::Url;
use uv_client::{BaseClientBuilder, Connectivity}; use uv_client::{AuthIntegration, BaseClientBuilder, Connectivity};
use uv_configuration::{KeyringProviderType, TrustedHost}; use uv_configuration::{KeyringProviderType, TrustedHost, TrustedPublishing};
use uv_publish::{files_for_publishing, upload}; use uv_publish::{check_trusted_publishing, files_for_publishing, upload};
pub(crate) async fn publish( pub(crate) async fn publish(
paths: Vec<String>, paths: Vec<String>,
publish_url: Url, publish_url: Url,
trusted_publishing: TrustedPublishing,
keyring_provider: KeyringProviderType, keyring_provider: KeyringProviderType,
allow_insecure_host: Vec<TrustedHost>, allow_insecure_host: Vec<TrustedHost>,
username: Option<String>, username: Option<String>,
@ -33,16 +34,42 @@ pub(crate) async fn publish(
n => writeln!(printer.stderr(), "Publishing {n} files {publish_url}")?, n => writeln!(printer.stderr(), "Publishing {n} files {publish_url}")?,
} }
let client = BaseClientBuilder::new() // * For the uploads themselves, we can't use retries due to
// Don't try cloning the request for retries. // https://github.com/seanmonstar/reqwest/issues/2416, but for trusted publishing, we want
// https://github.com/seanmonstar/reqwest/issues/2416 // 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) .retries(0)
.keyring(keyring_provider) .keyring(keyring_provider)
.native_tls(native_tls) .native_tls(native_tls)
.allow_insecure_host(allow_insecure_host) .allow_insecure_host(allow_insecure_host)
// Don't try cloning the request to make an unauthenticated request first. // Don't try cloning the request to make an unauthenticated request first.
.only_authenticated(true) .auth_integration(AuthIntegration::OnlyAuthenticated)
.build(); .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 { for (file, filename) in files {
let size = fs_err::metadata(&file)?.len(); let size = fs_err::metadata(&file)?.len();
@ -58,7 +85,7 @@ pub(crate) async fn publish(
&file, &file,
&filename, &filename,
&publish_url, &publish_url,
&client, &upload_client.client(),
username.as_deref(), username.as_deref(),
password.as_deref(), password.as_deref(),
// Needs to be an `Arc` because the reqwest `Body` static lifetime requirement // Needs to be an `Arc` because the reqwest `Body` static lifetime requirement

View File

@ -1099,6 +1099,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
username, username,
password, password,
publish_url, publish_url,
trusted_publishing,
keyring_provider, keyring_provider,
allow_insecure_host, allow_insecure_host,
} = PublishSettings::resolve(args, filesystem); } = PublishSettings::resolve(args, filesystem);
@ -1106,6 +1107,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
commands::publish( commands::publish(
files, files,
publish_url, publish_url,
trusted_publishing,
keyring_provider, keyring_provider,
allow_insecure_host, allow_insecure_host,
username, username,

View File

@ -25,7 +25,8 @@ use uv_client::Connectivity;
use uv_configuration::{ use uv_configuration::{
BuildOptions, Concurrency, ConfigSettings, DevMode, EditableMode, ExportFormat, BuildOptions, Concurrency, ConfigSettings, DevMode, EditableMode, ExportFormat,
ExtrasSpecification, HashCheckingMode, IndexStrategy, InstallOptions, KeyringProviderType, 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_normalize::PackageName;
use uv_python::{Prefix, PythonDownloads, PythonPreference, PythonVersion, Target}; use uv_python::{Prefix, PythonDownloads, PythonPreference, PythonVersion, Target};
@ -2436,6 +2437,7 @@ pub(crate) struct PublishSettings {
// Both CLI and configuration. // Both CLI and configuration.
pub(crate) publish_url: Url, pub(crate) publish_url: Url,
pub(crate) trusted_publishing: TrustedPublishing,
pub(crate) keyring_provider: KeyringProviderType, pub(crate) keyring_provider: KeyringProviderType,
pub(crate) allow_insecure_host: Vec<TrustedHost>, pub(crate) allow_insecure_host: Vec<TrustedHost>,
} }
@ -2449,7 +2451,10 @@ impl PublishSettings {
.map(FilesystemOptions::into_options) .map(FilesystemOptions::into_options)
.unwrap_or_default(); .unwrap_or_default();
let PublishOptions { publish_url } = publish; let PublishOptions {
publish_url,
trusted_publishing,
} = publish;
let ResolverInstallerOptions { let ResolverInstallerOptions {
keyring_provider, keyring_provider,
allow_insecure_host, allow_insecure_host,
@ -2471,6 +2476,9 @@ impl PublishSettings {
.publish_url .publish_url
.combine(publish_url) .combine(publish_url)
.unwrap_or_else(|| Url::parse(PYPI_PUBLISH_URL).unwrap()), .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: args
.keyring_provider .keyring_provider
.combine(keyring_provider) .combine(keyring_provider)

View File

@ -194,7 +194,7 @@ fn invalid_pyproject_toml_option_unknown_field() -> Result<()> {
| |
2 | unknown = "field" 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] Resolved in [TIME]
Audited in [TIME] Audited in [TIME]

View File

@ -3150,7 +3150,7 @@ fn resolve_config_file() -> anyhow::Result<()> {
| |
1 | [project] 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`
"### "###
); );

View File

@ -38,6 +38,11 @@ $ uv publish
Set a PyPI token with `--token` or `UV_PUBLISH_TOKEN`, or set a username with `--username` or 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`. `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 !!! note
PyPI does not support publishing with username and password anymore, instead you need to PyPI does not support publishing with username and password anymore, instead you need to

View File

@ -6913,6 +6913,19 @@ uv publish [OPTIONS] [FILES]...
<p>Using a token is equivalent to passing <code>__token__</code> as <code>--username</code> and the token as <code>--password</code>. password.</p> <p>Using a token is equivalent to passing <code>__token__</code> as <code>--username</code> and the token as <code>--password</code>. password.</p>
<p>May also be set with the <code>UV_PUBLISH_TOKEN</code> environment variable.</p> <p>May also be set with the <code>UV_PUBLISH_TOKEN</code> environment variable.</p>
</dd><dt><code>--trusted-publishing</code> <i>trusted-publishing</i></dt><dd><p>Configure using trusted publishing through GitHub Actions.</p>
<p>By default, uv checks for trusted publishing when running in GitHub Actions, but ignores it if it isn&#8217;t configured or the workflow doesn&#8217;t have enough permissions (e.g., a pull request from a fork).</p>
<p>Possible values:</p>
<ul>
<li><code>automatic</code>: Try trusted publishing when we&#8217;re already in GitHub Actions, continue if that fails</li>
<li><code>always</code></li>
<li><code>never</code></li>
</ul>
</dd><dt><code>--username</code>, <code>-u</code> <i>username</i></dt><dd><p>The username for the upload</p> </dd><dt><code>--username</code>, <code>-u</code> <i>username</i></dt><dd><p>The username for the upload</p>
<p>May also be set with the <code>UV_PUBLISH_USERNAME</code> environment variable.</p> <p>May also be set with the <code>UV_PUBLISH_USERNAME</code> environment variable.</p>

View File

@ -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 } ### [`upgrade`](#upgrade) {: #upgrade }
Allow package upgrades, ignoring pinned versions in any existing output file. Allow package upgrades, ignoring pinned versions in any existing output file.

View File

@ -29,6 +29,9 @@ keyring set https://test.pypi.org/legacy/?astral-test-keyring __token__
The query parameter a horrible hack stolen from The query parameter a horrible hack stolen from
https://github.com/pypa/twine/issues/565#issue-555219267 https://github.com/pypa/twine/issues/565#issue-555219267
to prevent the other projects from implicitly using the same credentials. 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 import os
@ -47,6 +50,7 @@ project_urls = {
"astral-test-token": "https://test.pypi.org/simple/astral-test-token/", "astral-test-token": "https://test.pypi.org/simple/astral-test-token/",
"astral-test-password": "https://test.pypi.org/simple/astral-test-password/", "astral-test-password": "https://test.pypi.org/simple/astral-test-password/",
"astral-test-keyring": "https://test.pypi.org/simple/astral-test-keyring/", "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", "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), cwd=cwd.joinpath(project_name),
env=env, 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: else:
raise ValueError(f"Unknown project name: {project_name}") raise ValueError(f"Unknown project name: {project_name}")

29
uv.schema.json generated
View File

@ -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": { "upgrade": {
"description": "Allow package upgrades, ignoring pinned versions in any existing output file.", "description": "Allow package upgrades, ignoring pinned versions in any existing output file.",
"type": [ "type": [
@ -1586,6 +1597,24 @@
"TrustedHost": { "TrustedHost": {
"description": "A host or host-port pair.", "description": "A host or host-port pair.",
"type": "string" "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"
]
}
]
} }
} }
} }