diff --git a/Cargo.lock b/Cargo.lock index bc5206aaa..3b5643a37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,20 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-id" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55e62faa820045efacb144fd9bcb16e62a5960ffc4bc270aaff7b78f0fcdcaa" +dependencies = [ + "reqwest", + "reqwest-middleware", + "secrecy", + "serde", + "serde_json", + "thiserror 2.0.16", +] + [[package]] name = "anes" version = "0.1.6" @@ -811,7 +825,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1270,7 +1284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3596,7 +3610,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3750,6 +3764,15 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "secret-service" version = "5.0.0" @@ -6017,6 +6040,7 @@ dependencies = [ name = "uv-publish" version = "0.1.0" dependencies = [ + "ambient-id", "astral-tokio-tar", "async-compression", "base64 0.22.1", @@ -6795,7 +6819,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d52099ae0..c56ec72f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,7 @@ uv-virtualenv = { path = "crates/uv-virtualenv" } uv-warnings = { path = "crates/uv-warnings" } uv-workspace = { path = "crates/uv-workspace" } +ambient-id = { version = "0.0.5" } anstream = { version = "0.6.15" } anyhow = { version = "1.0.89" } arcstr = { version = "1.2.0" } diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 196509877..1f9c91b4e 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -6635,11 +6635,12 @@ pub struct PublishArgs { )] pub token: Option, - /// Configure using trusted publishing through GitHub Actions. + /// Configure trusted publishing. /// - /// 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). + /// By default, uv checks for trusted publishing when running in a supported environment, but + /// ignores it if it isn't configured. + /// + /// uv's supported environments for trusted publishing include GitHub Actions and GitLab CI/CD. #[arg(long)] pub trusted_publishing: Option, diff --git a/crates/uv-configuration/src/trusted_publishing.rs b/crates/uv-configuration/src/trusted_publishing.rs index 7e97cfe26..0756e5140 100644 --- a/crates/uv-configuration/src/trusted_publishing.rs +++ b/crates/uv-configuration/src/trusted_publishing.rs @@ -5,7 +5,9 @@ use serde::{Deserialize, Serialize}; #[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. + /// Attempt trusted publishing when we're in a supported environment, continue if that fails. + /// + /// Supported environments include GitHub Actions and GitLab CI/CD. #[default] Automatic, // Force trusted publishing. diff --git a/crates/uv-publish/Cargo.toml b/crates/uv-publish/Cargo.toml index 17029763c..67e48353f 100644 --- a/crates/uv-publish/Cargo.toml +++ b/crates/uv-publish/Cargo.toml @@ -27,6 +27,7 @@ uv-redacted = { workspace = true } uv-static = { workspace = true } uv-warnings = { workspace = true } +ambient-id = { workspace = true } astral-tokio-tar = { workspace = true } async-compression = { workspace = true } base64 = { workspace = true } @@ -42,12 +43,17 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } -tokio-util = { workspace = true , features = ["io"] } +tokio-util = { workspace = true, features = ["io"] } tracing = { workspace = true } url = { workspace = true } [dev-dependencies] insta = { workspace = true } +[features] +# Test only feature to enable non-HTTPS URL handling +# in unit tests. +test = [] + [lints] workspace = true diff --git a/crates/uv-publish/src/lib.rs b/crates/uv-publish/src/lib.rs index 2792b0cb5..5ea430f2f 100644 --- a/crates/uv-publish/src/lib.rs +++ b/crates/uv-publish/src/lib.rs @@ -3,7 +3,7 @@ mod trusted_publishing; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime}; -use std::{env, fmt, io}; +use std::{fmt, io}; use fs_err::tokio::File; use futures::TryStreamExt; @@ -21,7 +21,6 @@ use tokio::io::{AsyncReadExt, BufReader}; use tokio::sync::Semaphore; use tokio_util::io::ReaderStream; use tracing::{Level, debug, enabled, trace, warn}; -use trusted_publishing::TrustedPublishingToken; use url::Url; use uv_auth::{Credentials, PyxTokenStore}; @@ -38,10 +37,9 @@ use uv_fs::{ProgressReader, Simplified}; use uv_metadata::read_metadata_async_seek; use uv_pypi_types::{HashAlgorithm, HashDigest, Metadata23, MetadataError}; use uv_redacted::DisplaySafeUrl; -use uv_static::EnvVars; -use uv_warnings::{warn_user, warn_user_once}; +use uv_warnings::warn_user; -use crate::trusted_publishing::TrustedPublishingError; +use crate::trusted_publishing::{TrustedPublishingError, TrustedPublishingToken}; #[derive(Error, Debug)] pub enum PublishError { @@ -324,26 +322,20 @@ pub async fn check_trusted_publishing( { return Ok(TrustedPublishResult::Skipped); } - // If we aren't in GitHub Actions, we can't use trusted publishing. - if env::var(EnvVars::GITHUB_ACTIONS) != Ok("true".to_string()) { - return Ok(TrustedPublishResult::Skipped); - } - // 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" - ); + + debug!("Attempting to get a token for trusted publishing"); + // Attempt to get a token for trusted publishing. match trusted_publishing::get_token(registry, client.for_host(registry).raw_client()) .await { - Ok(token) => Ok(TrustedPublishResult::Configured(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(TrustedPublishResult::Ignored(err)) - } + // Success: we have a token for trusted publishing. + Ok(Some(token)) => Ok(TrustedPublishResult::Configured(token)), + // Failed to discover an ambient OIDC token. + Ok(None) => Ok(TrustedPublishResult::Ignored( + TrustedPublishingError::NoToken, + )), + // Hard failure during OIDC discovery or token exchange. + Err(err) => Ok(TrustedPublishResult::Ignored(err)), } } TrustedPublishing::Always => { @@ -363,15 +355,15 @@ pub async fn check_trusted_publishing( return Err(PublishError::MixedCredentials(conflicts.join(" and "))); } - if env::var(EnvVars::GITHUB_ACTIONS) != Ok("true".to_string()) { - warn_user_once!( - "Trusted publishing was requested, but you're not in GitHub Actions." - ); - } - - let token = + let Some(token) = trusted_publishing::get_token(registry, client.for_host(registry).raw_client()) - .await?; + .await? + else { + return Err(PublishError::TrustedPublishing( + TrustedPublishingError::NoToken, + )); + }; + Ok(TrustedPublishResult::Configured(token)) } TrustedPublishing::Never => Ok(TrustedPublishResult::Skipped), diff --git a/crates/uv-publish/src/trusted_publishing.rs b/crates/uv-publish/src/trusted_publishing.rs index 4e45f924a..4e6a9d79c 100644 --- a/crates/uv-publish/src/trusted_publishing.rs +++ b/crates/uv-publish/src/trusted_publishing.rs @@ -1,13 +1,11 @@ -//! Trusted publishing (via OIDC) with GitHub actions. +//! Trusted publishing (via OIDC) with GitHub Actions and GitLab CI. use base64::Engine; use base64::prelude::BASE64_URL_SAFE_NO_PAD; -use reqwest::{StatusCode, header}; +use reqwest::StatusCode; use reqwest_middleware::ClientWithMiddleware; use serde::{Deserialize, Serialize}; use std::env; -use std::env::VarError; -use std::ffi::OsString; use std::fmt::Display; use thiserror::Error; use tracing::{debug, trace}; @@ -17,12 +15,19 @@ use uv_static::EnvVars; #[derive(Debug, Error)] pub enum TrustedPublishingError { - #[error("Environment variable {0} not set, is the `id-token: write` permission missing?")] - MissingEnvVar(&'static str), - #[error("Environment variable {0} is not valid UTF-8: `{1:?}`")] - InvalidEnvVar(&'static str, OsString), #[error(transparent)] Url(#[from] url::ParseError), + #[error("Failed to obtain OIDC token: is the `id-token: write` permission missing?")] + GitHubPermissions(#[source] ambient_id::Error), + /// A hard failure during OIDC token discovery. + #[error("Failed to discover OIDC token")] + Discovery(#[source] ambient_id::Error), + /// A soft failure during OIDC token discovery. + /// + /// In practice, this usually means the user attempted to force trusted + /// publishing outside of something like GitHub Actions or GitLab CI. + #[error("No OIDC token discovered: are you in a supported trusted publishing environment?")] + NoToken, #[error("Failed to fetch: `{0}`")] Reqwest(DisplaySafeUrl, #[source] reqwest::Error), #[error("Failed to fetch: `{0}`")] @@ -38,15 +43,6 @@ pub enum TrustedPublishingError { InvalidOidcToken(StatusCode, String), } -impl TrustedPublishingError { - fn from_var_err(env_var: &'static str, err: VarError) -> Self { - match err { - VarError::NotPresent => Self::MissingEnvVar(env_var), - VarError::NotUnicode(os_string) => Self::InvalidEnvVar(env_var, os_string), - } - } -} - #[derive(Deserialize)] #[serde(transparent)] pub struct TrustedPublishingToken(String); @@ -63,12 +59,6 @@ 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 { @@ -94,34 +84,39 @@ pub struct OidcTokenClaims { } /// Returns the short-lived token to use for uploading. +/// +/// Return states: +/// - `Ok(Some(token))`: Successfully obtained a trusted publishing token. +/// - `Ok(None)`: Not in a supported CI environment for trusted publishing. +/// - `Err(...)`: An error occurred while trying to obtain the token. pub(crate) async fn get_token( registry: &DisplaySafeUrl, client: &ClientWithMiddleware, -) -> Result { - // If this fails, we can skip the audience request. - let oidc_token_request_token = - env::var(EnvVars::ACTIONS_ID_TOKEN_REQUEST_TOKEN).map_err(|err| { - TrustedPublishingError::from_var_err(EnvVars::ACTIONS_ID_TOKEN_REQUEST_TOKEN, err) - })?; - - // Request 1: Get the audience +) -> Result, TrustedPublishingError> { + // Get the OIDC token's audience from the registry. 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?; + // Perform ambient OIDC token discovery. + // Depending on the host (GitHub Actions, GitLab CI, etc.) + // this may perform additional network requests. + let oidc_token = get_oidc_token(&audience, client).await?; - // Request 3: Get the publishing token from PyPI. - let publish_token = get_publish_token(registry, &oidc_token, client).await?; + // Exchange the OIDC token for a short-lived upload token, + // if OIDC token discovery succeeded. + if let Some(oidc_token) = oidc_token { + let publish_token = get_publish_token(registry, oidc_token, client).await?; - debug!("Received token, using trusted publishing"); + // If we're on GitHub Actions, mask the exchanged token in logs. + #[allow(clippy::print_stdout)] + if env::var(EnvVars::GITHUB_ACTIONS) == Ok("true".to_string()) { + println!("::add-mask::{publish_token}"); + } - // Tell GitHub Actions to mask the token in any console logs. - #[allow(clippy::print_stdout)] - if env::var(EnvVars::GITHUB_ACTIONS) == Ok("true".to_string()) { - println!("::add-mask::{}", &publish_token); + Ok(Some(publish_token)) + } else { + // Not in a supported CI environment for trusted publishing. + Ok(None) } - - Ok(publish_token) } async fn get_audience( @@ -130,8 +125,17 @@ async fn get_audience( ) -> Result { // `pypa/gh-action-pypi-publish` uses `netloc` (RFC 1808), which is deprecated for authority // (RFC 3986). - let audience_url = - DisplaySafeUrl::parse(&format!("https://{}/_/oidc/audience", registry.authority()))?; + // Prefer HTTPS for OIDC discovery; allow HTTP only in test builds + let scheme: &str = if cfg!(feature = "test") { + registry.scheme() + } else { + "https" + }; + let audience_url = DisplaySafeUrl::parse(&format!( + "{}://{}/_/oidc/audience", + scheme, + registry.authority() + ))?; debug!("Querying the trusted publishing audience from {audience_url}"); let response = client .get(Url::from(audience_url.clone())) @@ -148,33 +152,24 @@ async fn get_audience( Ok(audience.audience) } +/// Perform ambient OIDC token discovery. async fn get_oidc_token( audience: &str, - oidc_token_request_token: &str, client: &ClientWithMiddleware, -) -> Result { - let oidc_token_url = env::var(EnvVars::ACTIONS_ID_TOKEN_REQUEST_URL).map_err(|err| { - TrustedPublishingError::from_var_err(EnvVars::ACTIONS_ID_TOKEN_REQUEST_URL, err) - })?; - let mut oidc_token_url = DisplaySafeUrl::parse(&oidc_token_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(Url::from(oidc_token_url.clone())) - .header(header::AUTHORIZATION, authorization) - .send() - .await - .map_err(|err| TrustedPublishingError::ReqwestMiddleware(oidc_token_url.clone(), err))?; - let oidc_token: OidcToken = response - .error_for_status() - .map_err(|err| TrustedPublishingError::Reqwest(oidc_token_url.clone(), err))? - .json() - .await - .map_err(|err| TrustedPublishingError::Reqwest(oidc_token_url.clone(), err))?; - Ok(oidc_token.value) +) -> Result, TrustedPublishingError> { + let detector = ambient_id::Detector::new_with_client(client.clone()); + + match detector.detect(audience).await { + Ok(token) => Ok(token), + // Specialize the error case insufficient permissions error case, + // since we can offer the user a hint about fixing their permissions. + Err( + err @ ambient_id::Error::GitHubActions( + ambient_id::GitHubError::InsufficientPermissions(_), + ), + ) => Err(TrustedPublishingError::GitHubPermissions(err)), + Err(err) => Err(TrustedPublishingError::Discovery(err)), + } } /// Parse the JSON Web Token that the OIDC token is. @@ -191,16 +186,23 @@ fn decode_oidc_token(oidc_token: &str) -> Option { async fn get_publish_token( registry: &DisplaySafeUrl, - oidc_token: &str, + oidc_token: ambient_id::IdToken, client: &ClientWithMiddleware, ) -> Result { + // Prefer HTTPS for OIDC minting; allow HTTP only in test builds + let scheme: &str = if cfg!(feature = "test") { + registry.scheme() + } else { + "https" + }; let mint_token_url = DisplaySafeUrl::parse(&format!( - "https://{}/_/oidc/mint-token", + "{}://{}/_/oidc/mint-token", + scheme, registry.authority() ))?; debug!("Querying the trusted publishing upload token from {mint_token_url}"); let mint_token_payload = MintTokenRequest { - token: oidc_token.to_string(), + token: oidc_token.reveal().to_string(), }; let response = client .post(Url::from(mint_token_url.clone())) @@ -220,7 +222,7 @@ async fn get_publish_token( let publish_token: PublishToken = serde_json::from_slice(&body)?; Ok(publish_token.token) } else { - match decode_oidc_token(oidc_token) { + match decode_oidc_token(oidc_token.reveal()) { Some(claims) => { // An error here means that something is misconfigured, e.g. a typo in the PyPI // configuration, so we're showing the body and the JWT claims for more context, see diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index a66f3c7ca..64bdaf11b 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -2308,11 +2308,12 @@ pub struct PublishOptions { )] pub publish_url: Option, - /// Configure trusted publishing via GitHub Actions. + /// Configure trusted publishing. /// - /// 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). + /// By default, uv checks for trusted publishing when running in a supported environment, but + /// ignores it if it isn't configured. + /// + /// uv's supported environments for trusted publishing include GitHub Actions and GitLab CI/CD. #[option( default = "automatic", value_type = "str", diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 47879b488..06bfc3cc2 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -621,14 +621,17 @@ impl EnvVars { #[attr_hidden] pub const GIT_CEILING_DIRECTORIES: &'static str = "GIT_CEILING_DIRECTORIES"; - /// Used for trusted publishing via `uv publish`. + /// Indicates that the current process is running in GitHub Actions. + /// + /// `uv publish` may attempt trusted publishing flows when set + /// to `true`. pub const GITHUB_ACTIONS: &'static str = "GITHUB_ACTIONS"; - /// Used for trusted publishing via `uv publish`. Contains the oidc token url. - pub const ACTIONS_ID_TOKEN_REQUEST_URL: &'static str = "ACTIONS_ID_TOKEN_REQUEST_URL"; - - /// Used for trusted publishing via `uv publish`. Contains the oidc request token. - pub const ACTIONS_ID_TOKEN_REQUEST_TOKEN: &'static str = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"; + /// Indicates that the current process is running in GitLab CI. + /// + /// `uv publish` may attempt trusted publishing flows when set + /// to `true`. + pub const GITLAB_CI: &'static str = "GITLAB_CI"; /// Sets the encoding for standard I/O streams (e.g., PYTHONIOENCODING=utf-8). #[attr_hidden] diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 3066b5de9..0e76ff1f2 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -121,6 +121,8 @@ self-replace = { workspace = true } windows = { workspace = true } [dev-dependencies] +uv-publish = { workspace = true, features = ["test"] } + assert_cmd = { workspace = true } assert_fs = { workspace = true } backon = { workspace = true } diff --git a/crates/uv/tests/it/publish.rs b/crates/uv/tests/it/publish.rs index 9f3a4e91e..d2601ab69 100644 --- a/crates/uv/tests/it/publish.rs +++ b/crates/uv/tests/it/publish.rs @@ -78,9 +78,7 @@ fn mixed_credentials() { .arg("always") .arg("../../scripts/links/ok-1.0.0-py3-none-any.whl") // Emulate CI - .env(EnvVars::GITHUB_ACTIONS, "true") - // Just to make sure - .env_remove(EnvVars::ACTIONS_ID_TOKEN_REQUEST_TOKEN), @r###" + .env(EnvVars::GITHUB_ACTIONS, "true"), @r###" success: false exit_code: 2 ----- stdout ----- @@ -104,9 +102,7 @@ fn missing_trusted_publishing_permission() { .arg("always") .arg("../../scripts/links/ok-1.0.0-py3-none-any.whl") // Emulate CI - .env(EnvVars::GITHUB_ACTIONS, "true") - // Just to make sure - .env_remove(EnvVars::ACTIONS_ID_TOKEN_REQUEST_TOKEN), @r###" + .env(EnvVars::GITHUB_ACTIONS, "true"), @r" success: false exit_code: 2 ----- stdout ----- @@ -114,8 +110,10 @@ fn missing_trusted_publishing_permission() { ----- stderr ----- Publishing 1 file to https://test.pypi.org/legacy/ error: Failed to obtain token for trusted publishing - Caused by: Environment variable ACTIONS_ID_TOKEN_REQUEST_TOKEN not set, is the `id-token: write` permission missing? - "### + Caused by: Failed to obtain OIDC token: is the `id-token: write` permission missing? + Caused by: GitHub Actions detection error + Caused by: insufficient permissions: missing ACTIONS_ID_TOKEN_REQUEST_URL + " ); } @@ -130,9 +128,7 @@ fn no_credentials() { .arg("https://test.pypi.org/legacy/") .arg("../../scripts/links/ok-1.0.0-py3-none-any.whl") // Emulate CI - .env(EnvVars::GITHUB_ACTIONS, "true") - // Just to make sure - .env_remove(EnvVars::ACTIONS_ID_TOKEN_REQUEST_TOKEN), @r" + .env(EnvVars::GITHUB_ACTIONS, "true"), @r" success: false exit_code: 2 ----- stdout ----- @@ -141,7 +137,9 @@ fn no_credentials() { Publishing 1 file to https://test.pypi.org/legacy/ Note: Neither credentials nor keyring are configured, and there was an error fetching the trusted publishing token. If you don't want to use trusted publishing, you can ignore this error, but you need to provide credentials. error: Trusted publishing failed - Caused by: Environment variable ACTIONS_ID_TOKEN_REQUEST_TOKEN not set, is the `id-token: write` permission missing? + Caused by: Failed to obtain OIDC token: is the `id-token: write` permission missing? + Caused by: GitHub Actions detection error + Caused by: insufficient permissions: missing ACTIONS_ID_TOKEN_REQUEST_URL Uploading ok-1.0.0-py3-none-any.whl ([SIZE]) error: Failed to publish `../../scripts/links/ok-1.0.0-py3-none-any.whl` to https://test.pypi.org/legacy/ Caused by: Failed to send POST request @@ -500,3 +498,111 @@ async fn read_index_credential_env_vars_for_check_url() { " ); } + +/// Native GitLab CI trusted publishing using `PYPI_ID_TOKEN` +#[tokio::test] +async fn gitlab_trusted_publishing_pypi_id_token() { + let context = TestContext::new("3.12"); + + let server = MockServer::start().await; + + // Audience endpoint (PyPI) + Mock::given(method("GET")) + .and(path("/_/oidc/audience")) + .respond_with( + ResponseTemplate::new(200).set_body_raw("{\"audience\":\"pypi\"}", "application/json"), + ) + .mount(&server) + .await; + + // Mint token endpoint returns a short-lived API token + Mock::given(method("POST")) + .and(path("/_/oidc/mint-token")) + .respond_with( + ResponseTemplate::new(200).set_body_raw("{\"token\":\"apitoken\"}", "application/json"), + ) + .mount(&server) + .await; + + // Upload endpoint requires the minted token as Basic auth + Mock::given(method("POST")) + .and(path("/upload")) + .and(basic_auth("__token__", "apitoken")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + uv_snapshot!(context.filters(), context.publish() + .arg("--trusted-publishing") + .arg("always") + .arg("--publish-url") + .arg(format!("{}/upload", server.uri())) + .arg("../../scripts/links/ok-1.0.0-py3-none-any.whl") + .env(EnvVars::GITLAB_CI, "true") + .env_remove(EnvVars::GITHUB_ACTIONS) + .env("PYPI_ID_TOKEN", "gitlab-oidc-jwt"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Publishing 1 file to http://[LOCALHOST]/upload + Uploading ok-1.0.0-py3-none-any.whl ([SIZE]) + " + ); +} + +/// Native GitLab CI trusted publishing using `TESTPYPI_ID_TOKEN` +#[tokio::test] +async fn gitlab_trusted_publishing_testpypi_id_token() { + let context = TestContext::new("3.12"); + + let server = MockServer::start().await; + + // Audience endpoint (TestPyPI) + Mock::given(method("GET")) + .and(path("/_/oidc/audience")) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw("{\"audience\":\"testpypi\"}", "application/json"), + ) + .mount(&server) + .await; + + // Mint token endpoint returns a short-lived API token + Mock::given(method("POST")) + .and(path("/_/oidc/mint-token")) + .respond_with( + ResponseTemplate::new(200).set_body_raw("{\"token\":\"apitoken\"}", "application/json"), + ) + .mount(&server) + .await; + + // Upload endpoint requires the minted token as Basic auth + Mock::given(method("POST")) + .and(path("/upload")) + .and(basic_auth("__token__", "apitoken")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + uv_snapshot!(context.filters(), context.publish() + .arg("--trusted-publishing") + .arg("always") + .arg("--publish-url") + .arg(format!("{}/upload", server.uri())) + .arg("../../scripts/links/ok-1.0.0-py3-none-any.whl") + // Emulate GitLab CI with TESTPYPI_ID_TOKEN present + .env(EnvVars::GITLAB_CI, "true") + .env_remove(EnvVars::GITHUB_ACTIONS) + .env("TESTPYPI_ID_TOKEN", "gitlab-oidc-jwt"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Publishing 1 file to http://[LOCALHOST]/upload + Uploading ok-1.0.0-py3-none-any.whl ([SIZE]) + " + ); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c494cb4d2..a63d2222e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5876,11 +5876,12 @@ uv publish --publish-url https://upload.pypi.org/legacy/ --check-url https://pyp

Repeating this option, e.g., -qq, will enable a silent mode in which uv will write no output to stdout.

--token, -t token

The token for the upload.

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).

+

May also be set with the UV_PUBLISH_TOKEN environment variable.

--trusted-publishing trusted-publishing

Configure trusted publishing.

+

By default, uv checks for trusted publishing when running in a supported environment, but ignores it if it isn't configured.

+

uv's supported environments for trusted publishing include GitHub Actions and GitLab CI/CD.

Possible values:

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

The username for the upload

diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 2f40cc923..bf902a4fd 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -553,14 +553,6 @@ Note that `setuptools` and `wheel` are not included in Python 3.12+ environments uv also reads the following externally defined environment variables: -### `ACTIONS_ID_TOKEN_REQUEST_TOKEN` - -Used for trusted publishing via `uv publish`. Contains the oidc request token. - -### `ACTIONS_ID_TOKEN_REQUEST_URL` - -Used for trusted publishing via `uv publish`. Contains the oidc token url. - ### `ALL_PROXY` General proxy for all network requests. @@ -610,7 +602,17 @@ See [force-color.org](https://force-color.org). ### `GITHUB_ACTIONS` -Used for trusted publishing via `uv publish`. +Indicates that the current process is running in GitHub Actions. + +`uv publish` may attempt trusted publishing flows when set +to `true`. + +### `GITLAB_CI` + +Indicates that the current process is running in GitLab CI. + +`uv publish` may attempt trusted publishing flows when set +to `true`. ### `HF_TOKEN` diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 73ad03805..cc746b294 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -2095,11 +2095,12 @@ 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. +Configure trusted publishing. -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). +By default, uv checks for trusted publishing when running in a supported environment, but +ignores it if it isn't configured. + +uv's supported environments for trusted publishing include GitHub Actions and GitLab CI/CD. **Default value**: `automatic` diff --git a/uv.schema.json b/uv.schema.json index 2de1f1a46..627680ca3 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -586,7 +586,7 @@ ] }, "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\nif it isn't configured or the workflow doesn't have enough permissions (e.g., a pull request\nfrom a fork).", + "description": "Configure trusted publishing.\n\nBy default, uv checks for trusted publishing when running in a supported environment, but\nignores it if it isn't configured.\n\nuv's supported environments for trusted publishing include GitHub Actions and GitLab CI/CD.", "anyOf": [ { "$ref": "#/definitions/TrustedPublishing" @@ -2742,7 +2742,7 @@ ] }, { - "description": "Try trusted publishing when we're already in GitHub Actions, continue if that fails.", + "description": "Attempt trusted publishing when we're in a supported environment, continue if that fails.\n\nSupported environments include GitHub Actions and GitLab CI/CD.", "type": "string", "const": "automatic" }