Skip to content

Commit 0044000

Browse files
authored
Better trusted publishing error story (#8633)
1 parent e86c52d commit 0044000

File tree

4 files changed

+136
-26
lines changed

4 files changed

+136
-26
lines changed

crates/uv-publish/src/lib.rs

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ pub enum PublishError {
5454
PublishSend(PathBuf, Url, #[source] PublishSendError),
5555
#[error("Failed to obtain token for trusted publishing")]
5656
TrustedPublishing(#[from] TrustedPublishingError),
57+
#[error("{0} are not allowed when using trusted publishing")]
58+
MixedCredentials(String),
5759
}
5860

5961
/// Failure to get the metadata for a specific file.
@@ -239,6 +241,15 @@ pub fn files_for_publishing(
239241
Ok(files)
240242
}
241243

244+
pub enum TrustedPublishResult {
245+
/// We didn't check for trusted publishing.
246+
Skipped,
247+
/// We checked for trusted publishing and found a token.
248+
Configured(TrustedPublishingToken),
249+
/// We checked for optional trusted publishing, but it didn't succeed.
250+
Ignored(TrustedPublishingError),
251+
}
252+
242253
/// If applicable, attempt obtaining a token for trusted publishing.
243254
pub async fn check_trusted_publishing(
244255
username: Option<&str>,
@@ -247,46 +258,61 @@ pub async fn check_trusted_publishing(
247258
trusted_publishing: TrustedPublishing,
248259
registry: &Url,
249260
client: &BaseClient,
250-
) -> Result<Option<TrustedPublishingToken>, PublishError> {
261+
) -> Result<TrustedPublishResult, PublishError> {
251262
match trusted_publishing {
252263
TrustedPublishing::Automatic => {
253264
// If the user provided credentials, use those.
254265
if username.is_some()
255266
|| password.is_some()
256267
|| keyring_provider != KeyringProviderType::Disabled
257268
{
258-
return Ok(None);
269+
return Ok(TrustedPublishResult::Skipped);
259270
}
260271
// If we aren't in GitHub Actions, we can't use trusted publishing.
261272
if env::var(EnvVars::GITHUB_ACTIONS) != Ok("true".to_string()) {
262-
return Ok(None);
273+
return Ok(TrustedPublishResult::Skipped);
263274
}
264275
// We could check for credentials from the keyring or netrc the auth middleware first, but
265276
// given that we are in GitHub Actions we check for trusted publishing first.
266277
debug!("Running on GitHub Actions without explicit credentials, checking for trusted publishing");
267278
match trusted_publishing::get_token(registry, client.for_host(registry)).await {
268-
Ok(token) => Ok(Some(token)),
279+
Ok(token) => Ok(TrustedPublishResult::Configured(token)),
269280
Err(err) => {
270281
// TODO(konsti): It would be useful if we could differentiate between actual errors
271282
// such as connection errors and warn for them while ignoring errors from trusted
272283
// publishing not being configured.
273284
debug!("Could not obtain trusted publishing credentials, skipping: {err}");
274-
Ok(None)
285+
Ok(TrustedPublishResult::Ignored(err))
275286
}
276287
}
277288
}
278289
TrustedPublishing::Always => {
279290
debug!("Using trusted publishing for GitHub Actions");
291+
292+
let mut conflicts = Vec::new();
293+
if username.is_some() {
294+
conflicts.push("a username");
295+
}
296+
if password.is_some() {
297+
conflicts.push("a password");
298+
}
299+
if keyring_provider != KeyringProviderType::Disabled {
300+
conflicts.push("the keyring");
301+
}
302+
if !conflicts.is_empty() {
303+
return Err(PublishError::MixedCredentials(conflicts.join(" and ")));
304+
}
305+
280306
if env::var(EnvVars::GITHUB_ACTIONS) != Ok("true".to_string()) {
281307
warn_user_once!(
282308
"Trusted publishing was requested, but you're not in GitHub Actions."
283309
);
284310
}
285311

286312
let token = trusted_publishing::get_token(registry, client.for_host(registry)).await?;
287-
Ok(Some(token))
313+
Ok(TrustedPublishResult::Configured(token))
288314
}
289-
TrustedPublishing::Never => Ok(None),
315+
TrustedPublishing::Never => Ok(TrustedPublishResult::Skipped),
290316
}
291317
}
292318

crates/uv-publish/src/trusted_publishing.rs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,6 @@ impl TrustedPublishingError {
4545
#[serde(transparent)]
4646
pub struct TrustedPublishingToken(String);
4747

48-
impl From<TrustedPublishingToken> for String {
49-
fn from(token: TrustedPublishingToken) -> Self {
50-
token.0
51-
}
52-
}
53-
5448
impl Display for TrustedPublishingToken {
5549
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5650
write!(f, "{}", self.0)

crates/uv/src/commands/publish.rs

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ use anyhow::{bail, Context, Result};
55
use console::Term;
66
use owo_colors::OwoColorize;
77
use std::fmt::Write;
8+
use std::iter;
89
use std::sync::Arc;
910
use std::time::Duration;
1011
use tracing::info;
1112
use url::Url;
1213
use uv_client::{AuthIntegration, BaseClientBuilder, Connectivity, DEFAULT_RETRIES};
1314
use uv_configuration::{KeyringProviderType, TrustedHost, TrustedPublishing};
14-
use uv_publish::{check_trusted_publishing, files_for_publishing, upload};
15+
use uv_publish::{check_trusted_publishing, files_for_publishing, upload, TrustedPublishResult};
1516

1617
pub(crate) async fn publish(
1718
paths: Vec<String>,
@@ -71,15 +72,16 @@ pub(crate) async fn publish(
7172
)
7273
.await?;
7374

74-
let (username, password) = if let Some(password) = trusted_publishing_token {
75-
(Some("__token__".to_string()), Some(password.into()))
76-
} else {
77-
if username.is_none() && password.is_none() {
78-
prompt_username_and_password()?
75+
let (username, password) =
76+
if let TrustedPublishResult::Configured(password) = &trusted_publishing_token {
77+
(Some("__token__".to_string()), Some(password.to_string()))
7978
} else {
80-
(username, password)
81-
}
82-
};
79+
if username.is_none() && password.is_none() {
80+
prompt_username_and_password()?
81+
} else {
82+
(username, password)
83+
}
84+
};
8385

8486
if password.is_some() && username.is_none() {
8587
bail!(
@@ -89,6 +91,35 @@ pub(crate) async fn publish(
8991
);
9092
}
9193

94+
if username.is_none() && password.is_none() && keyring_provider == KeyringProviderType::Disabled
95+
{
96+
if let TrustedPublishResult::Ignored(err) = trusted_publishing_token {
97+
// The user has configured something incorrectly:
98+
// * The user forgot to configure credentials.
99+
// * The user forgot to forward the secrets as env vars (or used the wrong ones).
100+
// * The trusted publishing configuration is wrong.
101+
writeln!(
102+
printer.stderr(),
103+
"Note: Neither credentials nor keyring are configured, and there was an error \
104+
fetching the trusted publishing token. If you don't want to use trusted \
105+
publishing, you can ignore this error, but you need to provide credentials."
106+
)?;
107+
writeln!(
108+
printer.stderr(),
109+
"{}: {err}",
110+
"Trusted publishing error".red().bold()
111+
)?;
112+
for source in iter::successors(std::error::Error::source(&err), |&err| err.source()) {
113+
writeln!(
114+
printer.stderr(),
115+
" {}: {}",
116+
"Caused by".red().bold(),
117+
source.to_string().trim()
118+
)?;
119+
}
120+
}
121+
}
122+
92123
for (file, raw_filename, filename) in files {
93124
let size = fs_err::metadata(&file)?.len();
94125
let (bytes, unit) = human_readable_bytes(size);

crates/uv/tests/it/publish.rs

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,43 @@ fn invalid_token() {
5353
);
5454
}
5555

56+
/// Emulate a missing `permission` `id-token: write` situation.
57+
#[test]
58+
fn mixed_credentials() {
59+
let context = TestContext::new("3.12");
60+
61+
uv_snapshot!(context.filters(), context.publish()
62+
.arg("--username")
63+
.arg("ferris")
64+
.arg("--password")
65+
.arg("ZmVycmlz")
66+
.arg("--publish-url")
67+
.arg("https://test.pypi.org/legacy/")
68+
.arg("--trusted-publishing")
69+
.arg("always")
70+
.arg("../../scripts/links/ok-1.0.0-py3-none-any.whl")
71+
// Emulate CI
72+
.env(EnvVars::GITHUB_ACTIONS, "true")
73+
// Just to make sure
74+
.env_remove(EnvVars::ACTIONS_ID_TOKEN_REQUEST_TOKEN), @r###"
75+
success: false
76+
exit_code: 2
77+
----- stdout -----
78+
79+
----- stderr -----
80+
warning: `uv publish` is experimental and may change without warning
81+
Publishing 1 file to https://test.pypi.org/legacy/
82+
error: a username and a password are not allowed when using trusted publishing
83+
"###
84+
);
85+
}
86+
87+
/// Emulate a missing `permission` `id-token: write` situation.
5688
#[test]
5789
fn missing_trusted_publishing_permission() {
5890
let context = TestContext::new("3.12");
5991

6092
uv_snapshot!(context.filters(), context.publish()
61-
.arg("-u")
62-
.arg("__token__")
63-
.arg("-p")
64-
.arg("dummy")
6593
.arg("--publish-url")
6694
.arg("https://test.pypi.org/legacy/")
6795
.arg("--trusted-publishing")
@@ -83,3 +111,34 @@ fn missing_trusted_publishing_permission() {
83111
"###
84112
);
85113
}
114+
115+
/// Check the error when there are no credentials provided on GitHub Actions. Is it an incorrect
116+
/// trusted publishing configuration?
117+
#[test]
118+
fn no_credentials() {
119+
let context = TestContext::new("3.12");
120+
121+
uv_snapshot!(context.filters(), context.publish()
122+
.arg("--publish-url")
123+
.arg("https://test.pypi.org/legacy/")
124+
.arg("../../scripts/links/ok-1.0.0-py3-none-any.whl")
125+
// Emulate CI
126+
.env(EnvVars::GITHUB_ACTIONS, "true")
127+
// Just to make sure
128+
.env_remove(EnvVars::ACTIONS_ID_TOKEN_REQUEST_TOKEN), @r###"
129+
success: false
130+
exit_code: 2
131+
----- stdout -----
132+
133+
----- stderr -----
134+
warning: `uv publish` is experimental and may change without warning
135+
Publishing 1 file to https://test.pypi.org/legacy/
136+
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.
137+
Trusted publishing error: Environment variable ACTIONS_ID_TOKEN_REQUEST_TOKEN not set, is the `id-token: write` permission missing?
138+
Uploading ok-1.0.0-py3-none-any.whl ([SIZE])
139+
error: Failed to publish `../../scripts/links/ok-1.0.0-py3-none-any.whl` to https://test.pypi.org/legacy/
140+
Caused by: Failed to send POST request
141+
Caused by: Missing credentials for https://test.pypi.org/legacy/
142+
"###
143+
);
144+
}

0 commit comments

Comments
 (0)