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
5 changes: 5 additions & 0 deletions .changelog/good-clouds-buzz.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
changelogs: patch
---

Added support for unified versioning in root changelog format by implicitly treating all workspace packages as a fixed group, merging duplicate version headings and deduplicating changelog entries. Added Rust workspace version inheritance support for reading and writing versions via `version.workspace = true`.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

113 changes: 110 additions & 3 deletions src/changelog_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::error::Result;
use crate::plan::PackageRelease;
use crate::workspace::Workspace;
use chrono::Utc;
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
use std::process::Command;

Expand Down Expand Up @@ -471,11 +472,117 @@ pub fn write_changelogs_with_date(
}
}
ChangelogFormat::Root => {
// Group releases by version so fixed-group packages sharing the same
// version get a single heading instead of duplicate `## version` blocks.
let mut by_version: BTreeMap<String, Vec<&PackageRelease>> = BTreeMap::new();
for release in releases {
by_version
.entry(release.new_version.to_string())
.or_default()
.push(release);
}

let mut combined_entry = String::new();

for release in releases {
let entry = generate_entry_with_date(release, changelogs, changelog_dir, date);
combined_entry.push_str(&entry);
for (version, group) in &by_version {
if group.len() == 1 {
// Single release at this version — use existing per-package generation.
let entry = generate_entry_with_date(group[0], changelogs, changelog_dir, date);
combined_entry.push_str(&entry);
} else {
// Multiple releases share this version — merge into one heading
// and deduplicate changelog entries that appear in multiple packages.
combined_entry.push_str(&format!("## {} ({})\n\n", version, date));

let github_url = get_github_url();

let mut major_changes = Vec::new();
let mut minor_changes = Vec::new();
let mut patch_changes = Vec::new();
let mut seen_changelog_ids: HashSet<&str> = HashSet::new();

for release in group {
for changelog in changelogs {
if !release.changelog_ids.contains(&changelog.id) {
continue;
}
if !seen_changelog_ids.insert(&changelog.id) {
continue;
}

// Find the highest bump level for this changelog across
// all packages in the group.
let bump = group
.iter()
.filter(|r| r.changelog_ids.contains(&changelog.id))
.flat_map(|r| {
changelog
.releases
.iter()
.filter(|rel| rel.package == r.name)
.map(|rel| rel.bump)
})
.max()
.unwrap_or(BumpType::Patch);

let summary = changelog.summary.trim().to_string();

let (link_info, authors) = github_url
.as_ref()
.and_then(|base| {
let info = changelog_entry::get_commit_info(
changelog_dir,
&changelog.id,
)?;
let link_info = if let Some(pr) = info.pr_number {
Some((format!("{}/pull/{}", base, pr), format!("#{}", pr)))
} else {
let short_sha =
&info.commit_sha[..7.min(info.commit_sha.len())];
Some((
format!("{}/commit/{}", base, short_sha),
short_sha.to_string(),
))
};
Some((link_info, info.authors))
})
.unwrap_or((None, Vec::new()));

let change = ChangeWithMeta {
summary,
link: link_info,
authors,
};
match bump {
BumpType::Major => major_changes.push(change),
BumpType::Minor => minor_changes.push(change),
BumpType::Patch => patch_changes.push(change),
}
}
}

if !major_changes.is_empty() {
combined_entry.push_str("### Major Changes\n\n");
for change in major_changes {
write_change_lines(&mut combined_entry, &change);
}
combined_entry.push('\n');
}
if !minor_changes.is_empty() {
combined_entry.push_str("### Minor Changes\n\n");
for change in minor_changes {
write_change_lines(&mut combined_entry, &change);
}
combined_entry.push('\n');
}
if !patch_changes.is_empty() {
combined_entry.push_str("### Patch Changes\n\n");
for change in patch_changes {
write_change_lines(&mut combined_entry, &change);
}
combined_entry.push('\n');
}
}
}

let changelog_path = workspace.root.join("CHANGELOG.md");
Expand Down
129 changes: 128 additions & 1 deletion src/ecosystems/rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,25 @@ impl EcosystemAdapter for RustAdapter {
let content = std::fs::read_to_string(manifest_path)?;
let doc: DocumentMut = content.parse()?;

// Check if version uses workspace inheritance (version.workspace = true)
if Self::is_workspace_inherited(&doc, "version") {
let root = Self::find_workspace_root(manifest_path)?;
let root_manifest = root.join("Cargo.toml");
let root_content = std::fs::read_to_string(&root_manifest)?;
let root_doc: DocumentMut = root_content.parse()?;

let version_str = root_doc
.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
crate::error::Error::VersionNotFound(root_manifest.display().to_string())
})?;

return Ok(version_str.parse()?);
}

let version_str = doc["package"]["version"].as_str().ok_or_else(|| {
crate::error::Error::VersionNotFound(manifest_path.display().to_string())
})?;
Expand All @@ -69,8 +88,22 @@ impl EcosystemAdapter for RustAdapter {

fn write_version(manifest_path: &Path, version: &Version) -> Result<()> {
let content = std::fs::read_to_string(manifest_path)?;
let mut doc: DocumentMut = content.parse()?;
let doc: DocumentMut = content.parse()?;

// If version is inherited from workspace, write to root Cargo.toml instead
if Self::is_workspace_inherited(&doc, "version") {
let root = Self::find_workspace_root(manifest_path)?;
let root_manifest = root.join("Cargo.toml");
let root_content = std::fs::read_to_string(&root_manifest)?;
let mut root_doc: DocumentMut = root_content.parse()?;

root_doc["workspace"]["package"]["version"] = toml_edit::value(version.to_string());

std::fs::write(&root_manifest, root_doc.to_string())?;
return Ok(());
}

let mut doc: DocumentMut = content.parse()?;
doc["package"]["version"] = toml_edit::value(version.to_string());

std::fs::write(manifest_path, doc.to_string())?;
Expand Down Expand Up @@ -182,6 +215,43 @@ impl EcosystemAdapter for RustAdapter {
}

impl RustAdapter {
/// Check if a field in `[package]` uses workspace inheritance (e.g., `version.workspace = true`).
fn is_workspace_inherited(doc: &DocumentMut, field: &str) -> bool {
doc.get("package")
.and_then(|p| p.get(field))
.and_then(|v| v.as_table_like())
.and_then(|t| t.get("workspace"))
.and_then(|w| w.as_bool())
.unwrap_or(false)
}

/// Walk up from a crate's manifest to find the workspace root containing `[workspace]`.
fn find_workspace_root(manifest_path: &Path) -> Result<std::path::PathBuf> {
let mut current = manifest_path
.parent()
.ok_or_else(|| crate::error::Error::VersionNotFound("no parent directory".to_string()))?
.to_path_buf();

loop {
let candidate = current.join("Cargo.toml");
if candidate.exists() && candidate != manifest_path {
let content = std::fs::read_to_string(&candidate)?;
if content.contains("[workspace]") {
return Ok(current);
}
}

match current.parent() {
Some(parent) => current = parent.to_path_buf(),
None => {
return Err(crate::error::Error::VersionNotFound(
"workspace root not found".to_string(),
));
}
}
}
}

fn update_dep_version_in_item(dep: &mut toml_edit::Item, new_version: &Version) -> bool {
if let Some(table) = dep.as_inline_table_mut() {
if table.contains_key("version") {
Expand Down Expand Up @@ -461,6 +531,63 @@ my-dep = { version = \"1.0.0\" }\n";
assert_eq!(result, PublishResult::Skipped(SkipReason::NotPublishable));
}

#[test]
fn test_read_version_workspace_inherited() {
let dir = TempDir::new().unwrap();

// Root Cargo.toml with workspace.package.version
std::fs::write(
dir.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/foo\"]\n\n[workspace.package]\nversion = \"2.5.0\"\n",
)
.unwrap();

// Crate with version.workspace = true
let crate_dir = dir.path().join("crates").join("foo");
std::fs::create_dir_all(&crate_dir).unwrap();
let manifest = crate_dir.join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"foo\"\nversion.workspace = true\n",
)
.unwrap();

let version = RustAdapter::read_version(&manifest).unwrap();
assert_eq!(version, Version::new(2, 5, 0));
}

#[test]
fn test_write_version_workspace_inherited() {
let dir = TempDir::new().unwrap();

// Root Cargo.toml with workspace.package.version
std::fs::write(
dir.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/foo\"]\n\n[workspace.package]\nversion = \"1.0.0\"\n",
)
.unwrap();

// Crate with version.workspace = true
let crate_dir = dir.path().join("crates").join("foo");
std::fs::create_dir_all(&crate_dir).unwrap();
let manifest = crate_dir.join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"foo\"\nversion.workspace = true\n",
)
.unwrap();

RustAdapter::write_version(&manifest, &Version::new(3, 0, 0)).unwrap();

// Root should be updated
let root_content = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(root_content.contains("version = \"3.0.0\""));

// Crate should be untouched
let crate_content = std::fs::read_to_string(&manifest).unwrap();
assert!(crate_content.contains("version.workspace = true"));
}

#[test]
fn publish_failed_error_includes_context() {
let err = crate::error::Error::PublishFailed(
Expand Down
26 changes: 21 additions & 5 deletions src/plan.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::BumpType;
use crate::changelog_entry::Changelog;
use crate::config::{Config, DependentBump};
use crate::config::{ChangelogFormat, Config, DependentBump};
use crate::graph::DependencyGraph;
use crate::workspace::Workspace;
use semver::Version;
Expand Down Expand Up @@ -56,16 +56,32 @@ pub fn assemble(workspace: &Workspace, changelogs: Vec<Changelog>, config: &Conf
}
}

for group in &config.fixed {
let max_bump = group
.members
// Build effective fixed groups: explicit ones plus, when using root format,
// an implicit group of all non-ignored workspace packages.
let mut fixed_groups: Vec<Vec<String>> =
config.fixed.iter().map(|g| g.members.clone()).collect();

if config.changelog.format == ChangelogFormat::Root {
let all_members: Vec<String> = workspace
.package_names()
.into_iter()
.filter(|n| !config.ignore.contains(&n.to_string()))
.map(|n| n.to_string())
.collect();
if all_members.len() > 1 {
fixed_groups.push(all_members);
}
}

for members in &fixed_groups {
let max_bump = members
.iter()
.filter_map(|m| bump_map.get(m))
.max()
.copied();

if let Some(bump) = max_bump {
for member in &group.members {
for member in members {
if !config.ignore.contains(member) {
bump_map.insert(member.clone(), bump);
}
Expand Down
6 changes: 0 additions & 6 deletions tests/fixtures/root-changelog/expected/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
# Changelog

## 0.2.1 (2025-01-15)

### Patch Changes

- Added streaming support.

## 0.3.0 (2025-01-15)

### Minor Changes
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/root-fixed-group/changelog/add-close.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
wallet: patch
---

Add session close progress output.
6 changes: 6 additions & 0 deletions tests/fixtures/root-fixed-group/changelog/fix-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
common: patch
wallet: patch
---

Fix authentication token refresh logic.
5 changes: 5 additions & 0 deletions tests/fixtures/root-fixed-group/changelog/fix-payment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
request: patch
---

Fix payment challenge parsing.
7 changes: 7 additions & 0 deletions tests/fixtures/root-fixed-group/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
dependent_bump = "none"

[[fixed]]
members = ["common", "wallet", "request"]

[changelog]
format = "root"
10 changes: 10 additions & 0 deletions tests/fixtures/root-fixed-group/expected/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Changelog

## 0.1.1 (2025-01-15)

### Patch Changes

- Fix authentication token refresh logic.
- Fix payment challenge parsing.
- Add session close progress output.

Loading
Loading