Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 20 additions & 0 deletions docs/site/docs-en/reference/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,26 @@ More than one config file was found in the project root (e.g. both `ferrflow.jso

Running `ferrflow init` when a config file already exists.

### E1024: Versioned file does not exist

<span id="e1024"></span>

A package that this run would release lists a `versionedFiles` entry whose file is not on disk. The release is stopped rather than tagging a version no manifest carries.

The usual cause is a path written relative to the package instead of the repository root. `package.path` is not a prefix that FerrFlow adds for you:

```toml
[[package]]
name = "api"
path = "packages/api"

[[package.versioned_files]]
path = "Cargo.toml" # wrong, looked up at the repository root
# path = "packages/api/Cargo.toml" # right
```

The error names the path it probably meant. `ferrflow validate` reports the same problem for every configured package, including ones this run would not touch.

## Validation Errors

### E1100: Invalid repo spec
Expand Down
20 changes: 20 additions & 0 deletions docs/site/docs-fr/reference/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,26 @@ Plusieurs fichiers de config trouv\u00e9s dans le r\u00e9pertoire.

`ferrflow init` lanc\u00e9 alors qu'un fichier de config existe d\u00e9j\u00e0.

### E1024 : Fichier versionne introuvable

<span id="e1024"></span>

Un package que cette execution allait publier declare une entree `versionedFiles` dont le fichier n'est pas sur le disque. La release est interrompue au lieu de poser un tag qu'aucun manifeste ne porte.

La cause habituelle est un chemin ecrit relativement au package plutot qu'a la racine du depot. `package.path` n'est pas un prefixe que FerrFlow ajoute pour vous :

```toml
[[package]]
name = "api"
path = "packages/api"

[[package.versioned_files]]
path = "Cargo.toml" # faux, cherche a la racine du depot
# path = "packages/api/Cargo.toml" # correct
```

L'erreur indique le chemin qu'elle suppose correct. `ferrflow validate` signale le meme probleme pour tous les packages configures, y compris ceux que cette execution n'aurait pas touches.

## Erreurs de validation

### E1100 : Spec de repo invalide
Expand Down
3 changes: 3 additions & 0 deletions src/error_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ pub const CONFIG_DUPLICATE_PACKAGE: ErrorCode = ErrorCode(1022);
#[allow(dead_code)]
pub const CONFIG_MISSING_PACKAGE_PATH: ErrorCode = ErrorCode(1023);

#[allow(dead_code)]
pub const CONFIG_MISSING_VERSIONED_FILE: ErrorCode = ErrorCode(1024);

#[allow(dead_code)]
pub const VALIDATE_INVALID_REPO_SPEC: ErrorCode = ErrorCode(1100);
#[allow(dead_code)]
Expand Down
141 changes: 140 additions & 1 deletion src/monorepo/run/plan.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use anyhow::Result;
use anyhow::{Result, anyhow};

use crate::changelog::GitLog;
use crate::config::{Config, OrphanedTagStrategy, PackageConfig, VersioningStrategy};
use crate::conventional_commits::{BumpType, determine_bump};
use crate::error_code::{self, ErrorCodeExt};
use crate::formats::read_version;
use crate::git::{
Repository, TagIndex, find_highest_semver_tag_with_cache, get_changed_files_for_commit,
Expand Down Expand Up @@ -269,6 +270,34 @@ pub(super) fn commits_for_package(
Ok(scope_commits_to_package(repo, pkg, inputs, commits))
}

fn ensure_versioned_files_exist(pkg: &PackageConfig, root: &Path) -> Result<()> {
for vf in &pkg.versioned_files {
if root.join(&vf.path).exists() {
continue;
}
return Err(anyhow!(
"package \"{name}\": versioned file \"{path}\" does not exist, so this release \
would create a tag no manifest carries.\n \
Paths in versionedFiles are relative to the repository root, not to the \
package's own path. Did you mean \"{suggestion}\"?",
name = pkg.name,
path = vf.path,
suggestion = suggested_versioned_path(pkg, &vf.path),
))
.error_code(error_code::CONFIG_MISSING_VERSIONED_FILE);
Comment on lines +285 to +294

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the "Did you mean" clause is printed unconditionally, including when the suggestion is byte-identical to the path the user wrote. That is the majority case once this lands: a package at packages/api with versionedFiles: ["packages/api/Cargo.toml"] whose file was renamed or deleted gets versioned file "packages/api/Cargo.toml" does not exist [...] Did you mean "packages/api/Cargo.toml"?. Same for a root package (path = "."). Only print the hint when it says something new:

Suggested change
return Err(anyhow!(
"package \"{name}\": versioned file \"{path}\" does not exist, so this release \
would create a tag no manifest carries.\n \
Paths in versionedFiles are relative to the repository root, not to the \
package's own path. Did you mean \"{suggestion}\"?",
name = pkg.name,
path = vf.path,
suggestion = suggested_versioned_path(pkg, &vf.path),
))
.error_code(error_code::CONFIG_MISSING_VERSIONED_FILE);
let suggestion = suggested_versioned_path(pkg, &vf.path);
let hint = if suggestion == vf.path {
String::new()
} else {
format!(
"\n Paths in versionedFiles are relative to the repository root, not to \
the package's own path. Did you mean \"{suggestion}\"?"
)
};
return Err(anyhow!(
"package \"{name}\": versioned file \"{path}\" does not exist, so this release \
would create a tag no manifest carries.{hint}",
name = pkg.name,
path = vf.path,
))
.error_code(error_code::CONFIG_MISSING_VERSIONED_FILE);

The path.starts_with(prefix) test in suggested_versioned_path is also a string prefix rather than a path prefix, so package api matches apiv2/Cargo.toml and skips the suggestion. Harmless with the above, since the message then just drops the hint.

}
Ok(())
}

fn suggested_versioned_path(pkg: &PackageConfig, path: &str) -> String {
let prefix = pkg.path.trim_end_matches('/');
if prefix.is_empty() || prefix == "." || path.starts_with(prefix) {
path.to_string()
} else {
format!("{prefix}/{path}")
}
}

pub(super) fn compute_plan(
repo: &Repository,
pkg: &PackageConfig,
Expand Down Expand Up @@ -300,6 +329,8 @@ pub(super) fn compute_plan(
tags_for_package(inputs.all_tags, &tag_search_prefix)
});

ensure_versioned_files_exist(pkg, inputs.root)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this sits above the NoNewCommits, NoReleasableCommits and VersionUnchanged returns, so it fails packages this run would not write, not just the ones it would.

Concretely: a package touched only by chore(api): bump lint config currently ends as Skipped { reason: NoReleasableCommits } and the release proceeds. After this change it returns Err, and one_package_compute_error_aborts_collection shows a single package's error aborts the whole collection. So one stale versionedFiles entry on a package nobody is releasing turns every future release into a hard failure, which is the same class of breakage the description gives as the reason for scoping the check.

Fix: move the call down to immediately before the final Ok(PackagePlan::Bump(...)). file_source already tolerates a missing file through .ok(), current_version then falls back to the tag, and the plan is refused at the point where it would actually tag and write, so the issue's repro still fails with the same message. A fourth test (touched package, chore-only commit, missing file, expect a skip rather than an error) would pin the new position.


let file_source = pkg.versioned_files.first().and_then(|vf| {
read_version(vf, inputs.root)
.ok()
Expand Down Expand Up @@ -734,6 +765,114 @@ mod tests {
);
}

fn missing_file_fixture(pkg_path: &str, versioned_path: &str) -> (Fixture, Vec<String>) {
let (dir, repo) = init_repo();
let root = dir.path().to_path_buf();
write_pkg(&root, "api", "2.4.0");
write_pkg(&root, "sdk", "1.0.0");
write_config_raw(
&root,
"",
&format!(
r#"{{"name":"api","path":"{pkg_path}","versionedFiles":[{{"path":"{versioned_path}","format":"toml"}}]}},
{{"name":"sdk","path":"sdk","versionedFiles":[{{"path":"sdk/Cargo.toml","format":"toml"}}]}}"#

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: an_untouched_package_is_skipped_before_its_files_are_checked has no teeth as written. write_pkg(&root, "sdk", "1.0.0") creates sdk/Cargo.toml, so sdk's versioned file exists and the test passes whether the check is scoped to touched packages or run repository-wide, which is the one thing it claims to pin. Point sdk at a file that is absent:

Suggested change
{{"name":"sdk","path":"sdk","versionedFiles":[{{"path":"sdk/Cargo.toml","format":"toml"}}]}}"#
{{"name":"sdk","path":"sdk","versionedFiles":[{{"path":"sdk/Missing.toml","format":"toml"}}]}}"#

The other two tests only plan api, so they are unaffected.

),
);
git(&root, &["add", "-A"]);
commit_file(&root, "seed.txt", "x", "chore: seed", 1_950_000_000);
commit_file(
&root,
"api/endpoint.rs",
"x",
"feat(api): add an endpoint",
1_950_000_100,
);
let fx = build_fixture(root, dir, repo);
let changed_files = get_changed_files(&fx.repo).unwrap();
(fx, changed_files)
}

fn plan_result(fx: &Fixture, changed_files: &[String], name: &str) -> Result<PackagePlan> {
let all_tags = collect_all_tags(&fx.repo);
let head_ancestors = build_head_ancestors(&fx.repo).ok();
let tag_index = TagIndex::build(&fx.repo).ok();
let prerelease_ctx = PrereleaseContext::resolve(None, "main", None).unwrap();
let forced: Vec<Forced<'_>> = Vec::new();
let inputs = build_inputs(
fx,
&tag_index,
&head_ancestors,
&all_tags,
&prerelease_ctx,
&forced,
changed_files,
);
let pkg = fx
.config
.packages
.iter()
.find(|p| p.name == name)
.expect("package in fixture");
compute_plan(&fx.repo, pkg, &inputs)
}

#[test]
fn a_versioned_file_that_does_not_exist_fails_the_plan_rather_than_bumping_nothing() {
// versionedFiles paths are relative to the repository root. Giving one
// relative to the package used to plan a bump, tag it, and write no
// file, leaving the repo tagged at a version no manifest carries.
let (fx, changed) = missing_file_fixture("api", "Cargo.toml");

let err = match plan_result(&fx, &changed, "api") {
Ok(plan) => panic!(
"a missing versioned file must fail, got {:?}",
plan.summary()
),
Err(err) => err,
};
let msg = format!("{err:?}");
assert!(msg.contains("does not exist"), "{msg}");
assert!(
msg.contains("api/Cargo.toml"),
"the error should point at the repo-root path it probably meant: {msg}"
);
}

#[test]
fn a_versioned_file_that_exists_still_plans_normally() {
let (fx, changed) = missing_file_fixture("api", "api/Cargo.toml");

let plan = plan_result(&fx, &changed, "api")
.unwrap_or_else(|e| panic!("a correct config must still plan: {e:?}"));
assert!(
matches!(plan, PackagePlan::Bump(_)),
"expected a bump, got {:?}",
plan.summary()
);
}

#[test]
fn an_untouched_package_is_skipped_before_its_files_are_checked() {
// Scoping the check to packages this run would actually write keeps a
// partial or sparse checkout from failing a release for a package it
// was never going to touch.
let (fx, changed) = missing_file_fixture("api", "Cargo.toml");

let plan = plan_result(&fx, &changed, "sdk")
.unwrap_or_else(|e| panic!("an untouched package must not fail: {e:?}"));
assert!(
matches!(
plan,
PackagePlan::Skipped {
reason: SkipReason::NotTouched,
..
}
),
"expected sdk to be skipped, got {:?}",
plan.summary()
);
}

fn write_config_raw(dir: &Path, workspace: &str, packages: &str) {
std::fs::write(
dir.join(".ferrflow"),
Expand Down
Loading