Support Gitlab CI/CD as a trusted publisher (#15583)

Co-authored-by: William Woodruff <william@astral.sh>
This commit is contained in:
Harsh Pratap Singh 2025-09-11 20:05:04 +05:30 committed by GitHub
parent cbb713f705
commit 5f2871e695
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 296 additions and 152 deletions

32
Cargo.lock generated
View File

@ -43,6 +43,20 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 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]] [[package]]
name = "anes" name = "anes"
version = "0.1.6" version = "0.1.6"
@ -811,7 +825,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [ dependencies = [
"lazy_static", "lazy_static",
"windows-sys 0.48.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@ -1270,7 +1284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.59.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@ -3596,7 +3610,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.9.4", "linux-raw-sys 0.9.4",
"windows-sys 0.59.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@ -3750,6 +3764,15 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "secrecy"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
dependencies = [
"zeroize",
]
[[package]] [[package]]
name = "secret-service" name = "secret-service"
version = "5.0.0" version = "5.0.0"
@ -6017,6 +6040,7 @@ dependencies = [
name = "uv-publish" name = "uv-publish"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ambient-id",
"astral-tokio-tar", "astral-tokio-tar",
"async-compression", "async-compression",
"base64 0.22.1", "base64 0.22.1",
@ -6795,7 +6819,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]

View File

@ -77,6 +77,7 @@ uv-virtualenv = { path = "crates/uv-virtualenv" }
uv-warnings = { path = "crates/uv-warnings" } uv-warnings = { path = "crates/uv-warnings" }
uv-workspace = { path = "crates/uv-workspace" } uv-workspace = { path = "crates/uv-workspace" }
ambient-id = { version = "0.0.5" }
anstream = { version = "0.6.15" } anstream = { version = "0.6.15" }
anyhow = { version = "1.0.89" } anyhow = { version = "1.0.89" }
arcstr = { version = "1.2.0" } arcstr = { version = "1.2.0" }

View File

@ -6635,11 +6635,12 @@ pub struct PublishArgs {
)] )]
pub token: Option<String>, pub token: Option<String>,
/// 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 /// By default, uv checks for trusted publishing when running in a supported environment, but
/// if it isn't configured or the workflow doesn't have enough permissions (e.g., a pull request /// ignores it if it isn't configured.
/// from a fork). ///
/// uv's supported environments for trusted publishing include GitHub Actions and GitLab CI/CD.
#[arg(long)] #[arg(long)]
pub trusted_publishing: Option<TrustedPublishing>, pub trusted_publishing: Option<TrustedPublishing>,

View File

@ -5,7 +5,9 @@ use serde::{Deserialize, Serialize};
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum TrustedPublishing { 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] #[default]
Automatic, Automatic,
// Force trusted publishing. // Force trusted publishing.

View File

@ -27,6 +27,7 @@ uv-redacted = { workspace = true }
uv-static = { workspace = true } uv-static = { workspace = true }
uv-warnings = { workspace = true } uv-warnings = { workspace = true }
ambient-id = { workspace = true }
astral-tokio-tar = { workspace = true } astral-tokio-tar = { workspace = true }
async-compression = { workspace = true } async-compression = { workspace = true }
base64 = { workspace = true } base64 = { workspace = true }
@ -42,12 +43,17 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tokio-util = { workspace = true , features = ["io"] } tokio-util = { workspace = true, features = ["io"] }
tracing = { workspace = true } tracing = { workspace = true }
url = { workspace = true } url = { workspace = true }
[dev-dependencies] [dev-dependencies]
insta = { workspace = true } insta = { workspace = true }
[features]
# Test only feature to enable non-HTTPS URL handling
# in unit tests.
test = []
[lints] [lints]
workspace = true workspace = true

View File

@ -3,7 +3,7 @@ mod trusted_publishing;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use std::{env, fmt, io}; use std::{fmt, io};
use fs_err::tokio::File; use fs_err::tokio::File;
use futures::TryStreamExt; use futures::TryStreamExt;
@ -21,7 +21,6 @@ use tokio::io::{AsyncReadExt, BufReader};
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use tracing::{Level, debug, enabled, trace, warn}; use tracing::{Level, debug, enabled, trace, warn};
use trusted_publishing::TrustedPublishingToken;
use url::Url; use url::Url;
use uv_auth::{Credentials, PyxTokenStore}; use uv_auth::{Credentials, PyxTokenStore};
@ -38,10 +37,9 @@ use uv_fs::{ProgressReader, Simplified};
use uv_metadata::read_metadata_async_seek; use uv_metadata::read_metadata_async_seek;
use uv_pypi_types::{HashAlgorithm, HashDigest, Metadata23, MetadataError}; use uv_pypi_types::{HashAlgorithm, HashDigest, Metadata23, MetadataError};
use uv_redacted::DisplaySafeUrl; use uv_redacted::DisplaySafeUrl;
use uv_static::EnvVars; use uv_warnings::warn_user;
use uv_warnings::{warn_user, warn_user_once};
use crate::trusted_publishing::TrustedPublishingError; use crate::trusted_publishing::{TrustedPublishingError, TrustedPublishingToken};
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum PublishError { pub enum PublishError {
@ -324,26 +322,20 @@ pub async fn check_trusted_publishing(
{ {
return Ok(TrustedPublishResult::Skipped); 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()) { debug!("Attempting to get a token for trusted publishing");
return Ok(TrustedPublishResult::Skipped); // Attempt to get a token for trusted publishing.
}
// 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.for_host(registry).raw_client()) match trusted_publishing::get_token(registry, client.for_host(registry).raw_client())
.await .await
{ {
Ok(token) => Ok(TrustedPublishResult::Configured(token)), // Success: we have a token for trusted publishing.
Err(err) => { Ok(Some(token)) => Ok(TrustedPublishResult::Configured(token)),
// TODO(konsti): It would be useful if we could differentiate between actual errors // Failed to discover an ambient OIDC token.
// such as connection errors and warn for them while ignoring errors from trusted Ok(None) => Ok(TrustedPublishResult::Ignored(
// publishing not being configured. TrustedPublishingError::NoToken,
debug!("Could not obtain trusted publishing credentials, skipping: {err}"); )),
Ok(TrustedPublishResult::Ignored(err)) // Hard failure during OIDC discovery or token exchange.
} Err(err) => Ok(TrustedPublishResult::Ignored(err)),
} }
} }
TrustedPublishing::Always => { TrustedPublishing::Always => {
@ -363,15 +355,15 @@ pub async fn check_trusted_publishing(
return Err(PublishError::MixedCredentials(conflicts.join(" and "))); return Err(PublishError::MixedCredentials(conflicts.join(" and ")));
} }
if env::var(EnvVars::GITHUB_ACTIONS) != Ok("true".to_string()) { let Some(token) =
warn_user_once!(
"Trusted publishing was requested, but you're not in GitHub Actions."
);
}
let token =
trusted_publishing::get_token(registry, client.for_host(registry).raw_client()) trusted_publishing::get_token(registry, client.for_host(registry).raw_client())
.await?; .await?
else {
return Err(PublishError::TrustedPublishing(
TrustedPublishingError::NoToken,
));
};
Ok(TrustedPublishResult::Configured(token)) Ok(TrustedPublishResult::Configured(token))
} }
TrustedPublishing::Never => Ok(TrustedPublishResult::Skipped), TrustedPublishing::Never => Ok(TrustedPublishResult::Skipped),

View File

@ -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::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD; use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use reqwest::{StatusCode, header}; use reqwest::StatusCode;
use reqwest_middleware::ClientWithMiddleware; use reqwest_middleware::ClientWithMiddleware;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::env; use std::env;
use std::env::VarError;
use std::ffi::OsString;
use std::fmt::Display; use std::fmt::Display;
use thiserror::Error; use thiserror::Error;
use tracing::{debug, trace}; use tracing::{debug, trace};
@ -17,12 +15,19 @@ use uv_static::EnvVars;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum TrustedPublishingError { 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)] #[error(transparent)]
Url(#[from] url::ParseError), 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}`")] #[error("Failed to fetch: `{0}`")]
Reqwest(DisplaySafeUrl, #[source] reqwest::Error), Reqwest(DisplaySafeUrl, #[source] reqwest::Error),
#[error("Failed to fetch: `{0}`")] #[error("Failed to fetch: `{0}`")]
@ -38,15 +43,6 @@ pub enum TrustedPublishingError {
InvalidOidcToken(StatusCode, String), 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)] #[derive(Deserialize)]
#[serde(transparent)] #[serde(transparent)]
pub struct TrustedPublishingToken(String); pub struct TrustedPublishingToken(String);
@ -63,12 +59,6 @@ struct Audience {
audience: String, 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`. /// The body for querying `$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi`.
#[derive(Serialize)] #[derive(Serialize)]
struct MintTokenRequest { struct MintTokenRequest {
@ -94,34 +84,39 @@ pub struct OidcTokenClaims {
} }
/// Returns the short-lived token to use for uploading. /// 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( pub(crate) async fn get_token(
registry: &DisplaySafeUrl, registry: &DisplaySafeUrl,
client: &ClientWithMiddleware, client: &ClientWithMiddleware,
) -> Result<TrustedPublishingToken, TrustedPublishingError> { ) -> Result<Option<TrustedPublishingToken>, TrustedPublishingError> {
// If this fails, we can skip the audience request. // Get the OIDC token's audience from the registry.
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
let audience = get_audience(registry, client).await?; let audience = get_audience(registry, client).await?;
// Request 2: Get the OIDC token from GitHub. // Perform ambient OIDC token discovery.
let oidc_token = get_oidc_token(&audience, &oidc_token_request_token, client).await?; // 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. // Exchange the OIDC token for a short-lived upload token,
let publish_token = get_publish_token(registry, &oidc_token, client).await?; // 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.
// Tell GitHub Actions to mask the token in any console logs.
#[allow(clippy::print_stdout)] #[allow(clippy::print_stdout)]
if env::var(EnvVars::GITHUB_ACTIONS) == Ok("true".to_string()) { if env::var(EnvVars::GITHUB_ACTIONS) == Ok("true".to_string()) {
println!("::add-mask::{}", &publish_token); println!("::add-mask::{publish_token}");
} }
Ok(publish_token) Ok(Some(publish_token))
} else {
// Not in a supported CI environment for trusted publishing.
Ok(None)
}
} }
async fn get_audience( async fn get_audience(
@ -130,8 +125,17 @@ async fn get_audience(
) -> Result<String, TrustedPublishingError> { ) -> Result<String, TrustedPublishingError> {
// `pypa/gh-action-pypi-publish` uses `netloc` (RFC 1808), which is deprecated for authority // `pypa/gh-action-pypi-publish` uses `netloc` (RFC 1808), which is deprecated for authority
// (RFC 3986). // (RFC 3986).
let audience_url = // Prefer HTTPS for OIDC discovery; allow HTTP only in test builds
DisplaySafeUrl::parse(&format!("https://{}/_/oidc/audience", registry.authority()))?; 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}"); debug!("Querying the trusted publishing audience from {audience_url}");
let response = client let response = client
.get(Url::from(audience_url.clone())) .get(Url::from(audience_url.clone()))
@ -148,33 +152,24 @@ async fn get_audience(
Ok(audience.audience) Ok(audience.audience)
} }
/// Perform ambient OIDC token discovery.
async fn get_oidc_token( async fn get_oidc_token(
audience: &str, audience: &str,
oidc_token_request_token: &str,
client: &ClientWithMiddleware, client: &ClientWithMiddleware,
) -> Result<String, TrustedPublishingError> { ) -> Result<Option<ambient_id::IdToken>, TrustedPublishingError> {
let oidc_token_url = env::var(EnvVars::ACTIONS_ID_TOKEN_REQUEST_URL).map_err(|err| { let detector = ambient_id::Detector::new_with_client(client.clone());
TrustedPublishingError::from_var_err(EnvVars::ACTIONS_ID_TOKEN_REQUEST_URL, err)
})?; match detector.detect(audience).await {
let mut oidc_token_url = DisplaySafeUrl::parse(&oidc_token_url)?; Ok(token) => Ok(token),
oidc_token_url // Specialize the error case insufficient permissions error case,
.query_pairs_mut() // since we can offer the user a hint about fixing their permissions.
.append_pair("audience", audience); Err(
debug!("Querying the trusted publishing OIDC token from {oidc_token_url}"); err @ ambient_id::Error::GitHubActions(
let authorization = format!("bearer {oidc_token_request_token}"); ambient_id::GitHubError::InsufficientPermissions(_),
let response = client ),
.get(Url::from(oidc_token_url.clone())) ) => Err(TrustedPublishingError::GitHubPermissions(err)),
.header(header::AUTHORIZATION, authorization) Err(err) => Err(TrustedPublishingError::Discovery(err)),
.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)
} }
/// Parse the JSON Web Token that the OIDC token is. /// Parse the JSON Web Token that the OIDC token is.
@ -191,16 +186,23 @@ fn decode_oidc_token(oidc_token: &str) -> Option<OidcTokenClaims> {
async fn get_publish_token( async fn get_publish_token(
registry: &DisplaySafeUrl, registry: &DisplaySafeUrl,
oidc_token: &str, oidc_token: ambient_id::IdToken,
client: &ClientWithMiddleware, client: &ClientWithMiddleware,
) -> Result<TrustedPublishingToken, TrustedPublishingError> { ) -> Result<TrustedPublishingToken, TrustedPublishingError> {
// 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!( let mint_token_url = DisplaySafeUrl::parse(&format!(
"https://{}/_/oidc/mint-token", "{}://{}/_/oidc/mint-token",
scheme,
registry.authority() registry.authority()
))?; ))?;
debug!("Querying the trusted publishing upload token from {mint_token_url}"); debug!("Querying the trusted publishing upload token from {mint_token_url}");
let mint_token_payload = MintTokenRequest { let mint_token_payload = MintTokenRequest {
token: oidc_token.to_string(), token: oidc_token.reveal().to_string(),
}; };
let response = client let response = client
.post(Url::from(mint_token_url.clone())) .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)?; let publish_token: PublishToken = serde_json::from_slice(&body)?;
Ok(publish_token.token) Ok(publish_token.token)
} else { } else {
match decode_oidc_token(oidc_token) { match decode_oidc_token(oidc_token.reveal()) {
Some(claims) => { Some(claims) => {
// An error here means that something is misconfigured, e.g. a typo in the PyPI // 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 // configuration, so we're showing the body and the JWT claims for more context, see

View File

@ -2308,11 +2308,12 @@ pub struct PublishOptions {
)] )]
pub publish_url: Option<DisplaySafeUrl>, pub publish_url: Option<DisplaySafeUrl>,
/// Configure trusted publishing via GitHub Actions. /// Configure trusted publishing.
/// ///
/// By default, uv checks for trusted publishing when running in GitHub Actions, but ignores it /// By default, uv checks for trusted publishing when running in a supported environment, but
/// if it isn't configured or the workflow doesn't have enough permissions (e.g., a pull request /// ignores it if it isn't configured.
/// from a fork). ///
/// uv's supported environments for trusted publishing include GitHub Actions and GitLab CI/CD.
#[option( #[option(
default = "automatic", default = "automatic",
value_type = "str", value_type = "str",

View File

@ -621,14 +621,17 @@ impl EnvVars {
#[attr_hidden] #[attr_hidden]
pub const GIT_CEILING_DIRECTORIES: &'static str = "GIT_CEILING_DIRECTORIES"; 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"; pub const GITHUB_ACTIONS: &'static str = "GITHUB_ACTIONS";
/// Used for trusted publishing via `uv publish`. Contains the oidc token url. /// Indicates that the current process is running in GitLab CI.
pub const ACTIONS_ID_TOKEN_REQUEST_URL: &'static str = "ACTIONS_ID_TOKEN_REQUEST_URL"; ///
/// `uv publish` may attempt trusted publishing flows when set
/// Used for trusted publishing via `uv publish`. Contains the oidc request token. /// to `true`.
pub const ACTIONS_ID_TOKEN_REQUEST_TOKEN: &'static str = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"; pub const GITLAB_CI: &'static str = "GITLAB_CI";
/// Sets the encoding for standard I/O streams (e.g., PYTHONIOENCODING=utf-8). /// Sets the encoding for standard I/O streams (e.g., PYTHONIOENCODING=utf-8).
#[attr_hidden] #[attr_hidden]

View File

@ -121,6 +121,8 @@ self-replace = { workspace = true }
windows = { workspace = true } windows = { workspace = true }
[dev-dependencies] [dev-dependencies]
uv-publish = { workspace = true, features = ["test"] }
assert_cmd = { workspace = true } assert_cmd = { workspace = true }
assert_fs = { workspace = true } assert_fs = { workspace = true }
backon = { workspace = true } backon = { workspace = true }

View File

@ -78,9 +78,7 @@ fn mixed_credentials() {
.arg("always") .arg("always")
.arg("../../scripts/links/ok-1.0.0-py3-none-any.whl") .arg("../../scripts/links/ok-1.0.0-py3-none-any.whl")
// Emulate CI // Emulate CI
.env(EnvVars::GITHUB_ACTIONS, "true") .env(EnvVars::GITHUB_ACTIONS, "true"), @r###"
// Just to make sure
.env_remove(EnvVars::ACTIONS_ID_TOKEN_REQUEST_TOKEN), @r###"
success: false success: false
exit_code: 2 exit_code: 2
----- stdout ----- ----- stdout -----
@ -104,9 +102,7 @@ fn missing_trusted_publishing_permission() {
.arg("always") .arg("always")
.arg("../../scripts/links/ok-1.0.0-py3-none-any.whl") .arg("../../scripts/links/ok-1.0.0-py3-none-any.whl")
// Emulate CI // Emulate CI
.env(EnvVars::GITHUB_ACTIONS, "true") .env(EnvVars::GITHUB_ACTIONS, "true"), @r"
// Just to make sure
.env_remove(EnvVars::ACTIONS_ID_TOKEN_REQUEST_TOKEN), @r###"
success: false success: false
exit_code: 2 exit_code: 2
----- stdout ----- ----- stdout -----
@ -114,8 +110,10 @@ fn missing_trusted_publishing_permission() {
----- stderr ----- ----- stderr -----
Publishing 1 file to https://test.pypi.org/legacy/ Publishing 1 file to https://test.pypi.org/legacy/
error: Failed to obtain token for trusted publishing 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("https://test.pypi.org/legacy/")
.arg("../../scripts/links/ok-1.0.0-py3-none-any.whl") .arg("../../scripts/links/ok-1.0.0-py3-none-any.whl")
// Emulate CI // Emulate CI
.env(EnvVars::GITHUB_ACTIONS, "true") .env(EnvVars::GITHUB_ACTIONS, "true"), @r"
// Just to make sure
.env_remove(EnvVars::ACTIONS_ID_TOKEN_REQUEST_TOKEN), @r"
success: false success: false
exit_code: 2 exit_code: 2
----- stdout ----- ----- stdout -----
@ -141,7 +137,9 @@ fn no_credentials() {
Publishing 1 file to https://test.pypi.org/legacy/ 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. 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 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]) 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/ 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 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])
"
);
}

View File

@ -5876,11 +5876,12 @@ uv publish --publish-url https://upload.pypi.org/legacy/ --check-url https://pyp
<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which uv will write no output to stdout.</p> <p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which uv will write no output to stdout.</p>
</dd><dt id="uv-publish--token"><a href="#uv-publish--token"><code>--token</code></a>, <code>-t</code> <i>token</i></dt><dd><p>The token for the upload.</p> </dd><dt id="uv-publish--token"><a href="#uv-publish--token"><code>--token</code></a>, <code>-t</code> <i>token</i></dt><dd><p>The token for the upload.</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>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></dd><dt id="uv-publish--trusted-publishing"><a href="#uv-publish--trusted-publishing"><code>--trusted-publishing</code></a> <i>trusted-publishing</i></dt><dd><p>Configure using trusted publishing through GitHub Actions.</p> <p>May also be set with the <code>UV_PUBLISH_TOKEN</code> environment variable.</p></dd><dt id="uv-publish--trusted-publishing"><a href="#uv-publish--trusted-publishing"><code>--trusted-publishing</code></a> <i>trusted-publishing</i></dt><dd><p>Configure trusted publishing.</p>
<p>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).</p> <p>By default, uv checks for trusted publishing when running in a supported environment, but ignores it if it isn't configured.</p>
<p>uv's supported environments for trusted publishing include GitHub Actions and GitLab CI/CD.</p>
<p>Possible values:</p> <p>Possible values:</p>
<ul> <ul>
<li><code>automatic</code>: Try trusted publishing when we're already in GitHub Actions, continue if that fails</li> <li><code>automatic</code>: Attempt trusted publishing when we're in a supported environment, continue if that fails</li>
<li><code>always</code></li> <li><code>always</code></li>
<li><code>never</code></li> <li><code>never</code></li>
</ul></dd><dt id="uv-publish--username"><a href="#uv-publish--username"><code>--username</code></a>, <code>-u</code> <i>username</i></dt><dd><p>The username for the upload</p> </ul></dd><dt id="uv-publish--username"><a href="#uv-publish--username"><code>--username</code></a>, <code>-u</code> <i>username</i></dt><dd><p>The username for the upload</p>

View File

@ -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: 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` ### `ALL_PROXY`
General proxy for all network requests. General proxy for all network requests.
@ -610,7 +602,17 @@ See [force-color.org](https://force-color.org).
### `GITHUB_ACTIONS` ### `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` ### `HF_TOKEN`

View File

@ -2095,11 +2095,12 @@ By default, uv will use the latest compatible version of each package (`highest`
### [`trusted-publishing`](#trusted-publishing) {: #trusted-publishing } ### [`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 By default, uv checks for trusted publishing when running in a supported environment, but
if it isn't configured or the workflow doesn't have enough permissions (e.g., a pull request ignores it if it isn't configured.
from a fork).
uv's supported environments for trusted publishing include GitHub Actions and GitLab CI/CD.
**Default value**: `automatic` **Default value**: `automatic`

4
uv.schema.json generated
View File

@ -586,7 +586,7 @@
] ]
}, },
"trusted-publishing": { "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": [ "anyOf": [
{ {
"$ref": "#/definitions/TrustedPublishing" "$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", "type": "string",
"const": "automatic" "const": "automatic"
} }