diff --git a/docs/site/docs-en/reference/errors.md b/docs/site/docs-en/reference/errors.md index b69b7061..031920ff 100644 --- a/docs/site/docs-en/reference/errors.md +++ b/docs/site/docs-en/reference/errors.md @@ -490,6 +490,23 @@ A lifecycle hook exited with a non-zero status code and `on_failure` is set to ` +## Publisher Errors + +### E6101: Publisher misconfigured + + + +A publisher could not start because its setup is incomplete: a `registry` that is not declared under `workspace.registries`, a `tokenEnv` variable that is not set, a build context, chart or asset file that does not exist, or a `trustedPublishing` setup outside GitHub Actions. The message names the publisher and the missing piece. Nothing was published. + +### E6102: Publish failed + + + +The publish step ran and failed: `cargo publish`, `npm publish`, `twine upload`, `docker buildx`, `helm push`, `gh release upload` or a webhook returned an error. The message carries the tool's own error, including the lines that explain its cause. For cargo, FerrFlow retries a few times when the error looks like registry index lag after a dependency was just published, and reports E6102 once the retries are spent. + + + ## Query Errors ### E7001: No packages configured diff --git a/docs/site/docs-fr/reference/errors.md b/docs/site/docs-fr/reference/errors.md index 38d03e08..1e99fc5f 100644 --- a/docs/site/docs-fr/reference/errors.md +++ b/docs/site/docs-fr/reference/errors.md @@ -360,6 +360,23 @@ Un hook a échoué avec `on_failure: "abort"`. +## Publishers + +### E6101 : Publisher mal configuré + + + +Un publisher n'a pas pu démarrer parce que sa configuration est incomplète : un `registry` absent de `workspace.registries`, une variable `tokenEnv` non définie, un contexte de build, un chart ou un fichier d'asset introuvable, ou un `trustedPublishing` utilisé hors de GitHub Actions. Le message nomme le publisher et l'élément manquant. Rien n'a été publié. + +### E6102 : Publication échouée + + + +L'étape de publication a tourné et a échoué : `cargo publish`, `npm publish`, `twine upload`, `docker buildx`, `helm push`, `gh release upload` ou un webhook a renvoyé une erreur. Le message reprend l'erreur de l'outil, avec les lignes qui en expliquent la cause. Pour cargo, FerrFlow réessaie quelques fois quand l'erreur ressemble à un retard d'index du registre juste après la publication d'une dépendance, et signale E6102 une fois les essais épuisés. + + + ## Query ### E7001 : Aucun package configuré diff --git a/src/error_code.rs b/src/error_code.rs index 52399393..5d802f54 100644 --- a/src/error_code.rs +++ b/src/error_code.rs @@ -348,6 +348,8 @@ pub const VERSIONING_INVALID_SEMVER: ErrorCode = ErrorCode(5010); #[allow(dead_code)] pub const HOOK_FAILED: ErrorCode = ErrorCode(6001); +pub const PUBLISHER_MISCONFIGURED: ErrorCode = ErrorCode(6101); +pub const PUBLISH_FAILED: ErrorCode = ErrorCode(6102); #[allow(dead_code)] pub const QUERY_NO_PACKAGES: ErrorCode = ErrorCode(7001); diff --git a/src/publishers/cargo.rs b/src/publishers/cargo.rs index ac1c0aa4..1c8ba187 100644 --- a/src/publishers/cargo.rs +++ b/src/publishers/cargo.rs @@ -24,7 +24,7 @@ pub fn run( .ok_or_else(|| anyhow!( "publisher cargo: registry `{name}` is not declared under `workspace.registries`" )) - .error_code(error_code::CONFIG_INVALID_PATH)?; + .error_code(error_code::PUBLISHER_MISCONFIGURED)?; if let Some(env_name) = &r.token_env && std::env::var(env_name).is_err() { @@ -32,7 +32,7 @@ pub fn run( "publisher cargo:{name}: env var `{env_name}` is not set; \ export the registry token before running `ferrflow release`" )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISHER_MISCONFIGURED); } } @@ -90,9 +90,9 @@ pub fn run( "cargo publish failed for {} on {}: {}", ctx.package_name, registry_label, - first_meaningful_line(&stderr, &stdout) + failure_report(&stderr, &stdout) )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISH_FAILED); } } @@ -149,18 +149,13 @@ fn classify_transient(stderr: &str) -> bool { needles.iter().any(|n| lower.contains(n)) } -fn first_meaningful_line(stderr: &str, stdout: &str) -> String { - for src in [stderr, stdout] { - for line in src.lines().rev() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - if trimmed.starts_with("\u{1b}[") { - continue; - } - if trimmed.starts_with("error:") || trimmed.starts_with("warning:") { - return trimmed.to_string(); +fn failure_report(stderr: &str, stdout: &str) -> String { + let stderr = strip_ansi(stderr); + let stdout = strip_ansi(stdout); + for marker in ["error:", "warning:"] { + for src in [&stderr, &stdout] { + if let Some(block) = last_block_from(src, marker) { + return block; } } } @@ -168,9 +163,42 @@ fn first_meaningful_line(stderr: &str, stdout: &str) -> String { .lines() .rfind(|l| !l.trim().is_empty()) .unwrap_or("(no output)") + .trim() .to_string() } +fn last_block_from(text: &str, marker: &str) -> Option { + let lines: Vec<&str> = text.lines().collect(); + let start = lines + .iter() + .rposition(|l| l.trim_start().starts_with(marker))?; + let block: Vec<&str> = lines[start..] + .iter() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .collect(); + Some(block.join("\n")) +} + +fn strip_ansi(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(c) = chars.next() { + if c != '\u{1b}' { + out.push(c); + continue; + } + if chars.next() == Some('[') { + for c in chars.by_ref() { + if ('@'..='~').contains(&c) { + break; + } + } + } + } + out +} + /// The crate name cargo publishes under, which is `[package].name` in the /// manifest and not the FerrFlow package name. /// @@ -311,11 +339,31 @@ mod tests { } #[test] - fn first_meaningful_line_picks_error_over_spinner() { + fn failure_report_picks_error_over_spinner() { let stderr = "\u{1b}[2K\u{1b}[K\nerror: failed to publish: cargo lock conflict\n exit code 101\n"; - let line = first_meaningful_line(stderr, ""); - assert!(line.contains("cargo lock conflict")); + let report = failure_report(stderr, ""); + assert!(report.starts_with("error: failed to publish: cargo lock conflict")); + } + + #[test] + fn failure_report_keeps_the_cause_when_cargo_colours_its_output() { + let stderr = "\u{1b}[1m\u{1b}[92m Packaging\u{1b}[0m core v2.0.0\n\ + \u{1b}[1m\u{1b}[91merror\u{1b}[0m: failed to prepare local package for uploading\n\ + \n\ + Caused by:\n \ + no matching package named `core` found\n \ + location searched: forgejo index\n \ + required by package `gapline v2.0.0 (/workspace/gapline/cli)`\n"; + + assert_eq!( + failure_report(stderr, ""), + "error: failed to prepare local package for uploading\n\ + Caused by:\n\ + no matching package named `core` found\n\ + location searched: forgejo index\n\ + required by package `gapline v2.0.0 (/workspace/gapline/cli)`" + ); } #[test] @@ -458,23 +506,29 @@ mod tests { } #[test] - fn first_meaningful_line_prefers_the_last_error_or_warning() { - let stderr = - " Packaging foo v1.0.0\nerror: first problem\n Uploading\nwarning: last word\n"; - assert_eq!(first_meaningful_line(stderr, ""), "warning: last word"); + fn failure_report_prefers_an_error_to_a_later_warning() { + let stderr = " Packaging foo v1.0.0\nerror: first problem\n Uploading\n"; + assert_eq!( + failure_report(stderr, ""), + "error: first problem\nUploading" + ); + assert_eq!( + failure_report(" Packaging foo\nwarning: last word\n", ""), + "warning: last word" + ); } #[test] - fn first_meaningful_line_falls_back_to_stdout_then_the_last_stderr_line() { + fn failure_report_falls_back_to_stdout_then_the_last_stderr_line() { assert_eq!( - first_meaningful_line(" Updating index\n", "error: from stdout\n"), + failure_report(" Updating index\n", "error: from stdout\n"), "error: from stdout" ); assert_eq!( - first_meaningful_line(" Updating index\n Uploading foo\n\n", "done\n"), - " Uploading foo" + failure_report(" Updating index\n Uploading foo\n\n", "done\n"), + "Uploading foo" ); - assert_eq!(first_meaningful_line("", ""), "(no output)"); + assert_eq!(failure_report("", ""), "(no output)"); } #[test] diff --git a/src/publishers/docker.rs b/src/publishers/docker.rs index 762dfbe9..b2955193 100644 --- a/src/publishers/docker.rs +++ b/src/publishers/docker.rs @@ -45,7 +45,7 @@ pub fn run( "publisher docker: build context {} does not exist", context_path.display() )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISHER_MISCONFIGURED); } let output = buildx_command( @@ -70,7 +70,7 @@ pub fn run( ctx.package_name, stderr.trim() )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISH_FAILED); } if matches!(sign, DockerSign::Sigstore) { @@ -90,7 +90,7 @@ pub fn run( "cosign sign failed for {target}: {}", stderr.trim() )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISH_FAILED); } } diff --git a/src/publishers/github_release_asset.rs b/src/publishers/github_release_asset.rs index e530dad4..488abcde 100644 --- a/src/publishers/github_release_asset.rs +++ b/src/publishers/github_release_asset.rs @@ -16,7 +16,7 @@ pub fn run( "publisher github-release-asset: file {} does not exist", asset_path.display() )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISHER_MISCONFIGURED); } if ctx.dry_run { @@ -43,7 +43,7 @@ pub fn run( asset_path.display(), String::from_utf8_lossy(&output.stderr).trim() )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISH_FAILED); } Ok(PublishOutcome::Published { diff --git a/src/publishers/helm.rs b/src/publishers/helm.rs index 5a5ed759..d9b4a43d 100644 --- a/src/publishers/helm.rs +++ b/src/publishers/helm.rs @@ -16,7 +16,7 @@ pub fn run( "publisher helm: chart directory {} does not exist", chart_dir.display() )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISHER_MISCONFIGURED); } let chart_name = read_chart_name(&chart_dir)?; @@ -44,7 +44,7 @@ pub fn run( "helm package failed for {chart_name}: {}", String::from_utf8_lossy(&pkg.stderr).trim() )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISH_FAILED); } let tgz_name = format!("{chart_name}-{}.tgz", ctx.new_version); @@ -53,7 +53,7 @@ pub fn run( return Err(anyhow!( "expected {tgz_name} after helm package but it's not there" )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISH_FAILED); } let push = Command::new("helm") @@ -71,7 +71,7 @@ pub fn run( }); } return Err(anyhow!("helm push failed for {oci_ref}: {}", stderr.trim())) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISH_FAILED); } Ok(PublishOutcome::Published { url: Some(oci_ref) }) @@ -93,7 +93,7 @@ fn read_chart_name(chart_dir: &std::path::Path) -> Result { "could not parse `name:` from {}", chart_yaml.display() )) - .error_code(error_code::CONFIG_INVALID_PATH) + .error_code(error_code::PUBLISHER_MISCONFIGURED) } fn helm_chart_exists(oci_ref: &str) -> bool { diff --git a/src/publishers/npm.rs b/src/publishers/npm.rs index e5b17d3d..60964350 100644 --- a/src/publishers/npm.rs +++ b/src/publishers/npm.rs @@ -22,7 +22,7 @@ pub fn run( .ok_or_else(|| anyhow!( "publisher npm: registry `{name}` is not declared under `workspace.registries`" )) - .error_code(error_code::CONFIG_INVALID_PATH)?; + .error_code(error_code::PUBLISHER_MISCONFIGURED)?; if let Some(env_name) = &r.token_env && std::env::var(env_name).is_err() { @@ -30,7 +30,7 @@ pub fn run( "publisher npm:{name}: env var `{env_name}` is not set; \ export the registry token before running `ferrflow release`" )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISHER_MISCONFIGURED); } Some(r) } @@ -88,7 +88,7 @@ pub fn run( registry_label, first_meaningful_line(&stderr, &stdout) )) - .error_code(error_code::CONFIG_INVALID_PATH) + .error_code(error_code::PUBLISH_FAILED) } #[allow(clippy::too_many_arguments)] diff --git a/src/publishers/pypi.rs b/src/publishers/pypi.rs index 827efdfd..cd89636f 100644 --- a/src/publishers/pypi.rs +++ b/src/publishers/pypi.rs @@ -45,7 +45,7 @@ fn resolve_registry<'a>( "publisher pypi: registry `{name}` is not declared under `workspace.registries`" ) }) - .error_code(error_code::CONFIG_INVALID_PATH)?; + .error_code(error_code::PUBLISHER_MISCONFIGURED)?; let Some(env_name) = &r.token_env else { return Ok(r); }; @@ -54,14 +54,14 @@ fn resolve_registry<'a>( "publisher pypi:{name}: `trustedPublishing` and the registry `tokenEnv` \ (`{env_name}`) both configure authentication; keep one" )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISHER_MISCONFIGURED); } if std::env::var(env_name).is_err() { return Err(anyhow!( "publisher pypi:{name}: env var `{env_name}` is not set; \ export the registry token before running `ferrflow release`" )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISHER_MISCONFIGURED); } Ok(r) } @@ -87,7 +87,7 @@ fn build_dist(ctx: &PublishContext<'_>) -> Result<()> { ctx.package_name, first_meaningful_line(&stderr, &stdout) )) - .error_code(error_code::CONFIG_INVALID_PATH) + .error_code(error_code::PUBLISH_FAILED) } fn twine_command( @@ -172,7 +172,7 @@ fn upload_with_retry( registry_label, first_meaningful_line(&stderr, &stdout) )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISH_FAILED); } } diff --git a/src/publishers/pypi_oidc.rs b/src/publishers/pypi_oidc.rs index 2b7779b1..e3a8e1d0 100644 --- a/src/publishers/pypi_oidc.rs +++ b/src/publishers/pypi_oidc.rs @@ -30,11 +30,11 @@ pub fn mint(repository_url: Option<&str>) -> Result { .header("Accept", "application/json") .call() .context("publisher pypi: requesting a GitHub OIDC token failed") - .error_code(error_code::CONFIG_INVALID_PATH)? + .error_code(error_code::PUBLISH_FAILED)? .body_mut() .read_json() .context("publisher pypi: the GitHub OIDC token response was not the expected JSON") - .error_code(error_code::CONFIG_INVALID_PATH)?; + .error_code(error_code::PUBLISH_FAILED)?; let minted: MintedTokenResponse = agent .post(&endpoint) @@ -48,11 +48,11 @@ pub fn mint(repository_url: Option<&str>) -> Result { for the project" ) }) - .error_code(error_code::CONFIG_INVALID_PATH)? + .error_code(error_code::PUBLISH_FAILED)? .body_mut() .read_json() .context("publisher pypi: the mint-token response was not the expected JSON") - .error_code(error_code::CONFIG_INVALID_PATH)?; + .error_code(error_code::PUBLISH_FAILED)?; Ok(minted.token) } @@ -65,7 +65,7 @@ fn required_env(name: &str) -> Result { only when the job declares `permissions: id-token: write`" ) }) - .error_code(error_code::CONFIG_INVALID_PATH) + .error_code(error_code::PUBLISHER_MISCONFIGURED) } fn audience_url(base: &str) -> String { @@ -87,7 +87,7 @@ fn mint_endpoint(repository_url: Option<&str>) -> Result { token endpoint from, got `{raw}`" ) }) - .error_code(error_code::CONFIG_INVALID_PATH)?; + .error_code(error_code::PUBLISHER_MISCONFIGURED)?; Ok(format!("https://{host}/_/oidc/mint-token")) } diff --git a/src/publishers/webhook.rs b/src/publishers/webhook.rs index 496985a5..6e0a6ac1 100644 --- a/src/publishers/webhook.rs +++ b/src/publishers/webhook.rs @@ -33,14 +33,14 @@ pub fn run( let response = req .send_json(payload) .with_context(|| format!("POST {interpolated_url}")) - .error_code(error_code::CONFIG_INVALID_PATH)?; + .error_code(error_code::PUBLISH_FAILED)?; let status = response.status(); if !status.is_success() { return Err(anyhow!( "webhook POST {interpolated_url} returned HTTP {status}" )) - .error_code(error_code::CONFIG_INVALID_PATH); + .error_code(error_code::PUBLISH_FAILED); } Ok(PublishOutcome::Published { @@ -259,7 +259,7 @@ mod tests { assert!(format!("{err:#}").contains(&url), "{err:#}"); assert_eq!( crate::error_code::code_from_error(&err).as_deref(), - Some("E1018") + Some("E6102") ); }