Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/site/docs-en/reference/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,23 @@ A lifecycle hook exited with a non-zero status code and `on_failure` is set to `
<aside class="ferr-aside ferr-aside--tip"><div class="ferr-aside__body"><p>Check the hook command output, or set <code>on_failure: &quot;continue&quot;</code> to ignore failures.</p>
</div></aside>

## Publisher Errors

### E6101: Publisher misconfigured

<span id="e6101"></span>

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

<span id="e6102"></span>

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.

<aside class="ferr-aside ferr-aside--tip"><div class="ferr-aside__body"><p>Run the same command by hand from the package directory, for example <code>cargo publish --dry-run</code>, to see the full output.</p>
</div></aside>

## Query Errors

### E7001: No packages configured
Expand Down
17 changes: 17 additions & 0 deletions docs/site/docs-fr/reference/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,23 @@ Un hook a échoué avec `on_failure: "abort"`.
<aside class="ferr-aside ferr-aside--tip"><div class="ferr-aside__body"><p>Vérifiez la commande du hook, ou mettez <code>on_failure: &quot;continue&quot;</code>.</p>
</div></aside>

## Publishers

### E6101 : Publisher mal configuré

<span id="e6101"></span>

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

<span id="e6102"></span>

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.

<aside class="ferr-aside ferr-aside--tip"><div class="ferr-aside__body"><p>Lancez la même commande à la main depuis le dossier du package, par exemple <code>cargo publish --dry-run</code>, pour voir la sortie complète.</p>
</div></aside>

## Query

### E7001 : Aucun package configuré
Expand Down
2 changes: 2 additions & 0 deletions src/error_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
110 changes: 82 additions & 28 deletions src/publishers/cargo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ 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()
{
return Err(anyhow!(
"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);
}
}

Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -149,28 +149,56 @@ 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;
}
}
}
stderr
.lines()
.rfind(|l| !l.trim().is_empty())
.unwrap_or("(no output)")
.trim()
.to_string()
}

fn last_block_from(text: &str, marker: &str) -> Option<String> {
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.
///
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
6 changes: 3 additions & 3 deletions src/publishers/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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) {
Expand All @@ -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);
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/publishers/github_release_asset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
10 changes: 5 additions & 5 deletions src/publishers/helm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;

Expand Down Expand Up @@ -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);
Expand All @@ -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")
Expand All @@ -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) })
Expand All @@ -93,7 +93,7 @@ fn read_chart_name(chart_dir: &std::path::Path) -> Result<String> {
"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 {
Expand Down
6 changes: 3 additions & 3 deletions src/publishers/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ 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()
{
return Err(anyhow!(
"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)
}
Expand Down Expand Up @@ -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)]
Expand Down
10 changes: 5 additions & 5 deletions src/publishers/pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand All @@ -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)
}
Expand All @@ -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(
Expand Down Expand Up @@ -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);
}
}

Expand Down
Loading
Loading