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
22 changes: 21 additions & 1 deletion .github/workflows/cargo-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,27 @@ on:
- "v[0-9]+.[0-9]+.[0-9]+-*"

jobs:
verify-version:
name: Verify Cargo.toml matches the tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Compare Cargo.toml version with tag
shell: bash
run: |
VERSION=$(grep -m1 'version\s*=' Cargo.toml | cut -d'"' -f2 | cut -d"'" -f2)
TAG="$GITHUB_REF_NAME"
if [ "v$VERSION" != "$TAG" ]; then
echo "::error::Tag $TAG points at a tree whose Cargo.toml says $VERSION."
echo "The published binaries would report $VERSION to --version, and self-update would keep offering $TAG forever."
echo "Bump Cargo.toml to ${TAG#v} on the release branch, then move or recreate the tag."
exit 1
fi
echo "Cargo.toml version $VERSION matches tag $TAG"

create-release:
needs: verify-version
runs-on: ubuntu-latest
outputs:
is_prerelease: ${{ steps.release_type.outputs.is_prerelease }}
Expand Down Expand Up @@ -54,7 +74,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

release:
needs: create-release
needs: [verify-version, create-release]
name: Release for ${{ matrix.target }}
runs-on: ${{ matrix.os }}

Expand Down
15 changes: 11 additions & 4 deletions docs/src/content/docs/reference/tag.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ committy --non-interactive tag --publish --confirm-publish
# Pre-release flow
committy --non-interactive tag --prerelease --prerelease-suffix beta

# Bump versions in config files when tagging
committy --non-interactive tag --bump-files
# Tag without touching version files
committy --non-interactive tag --no-bump-files
```

## Flags (TagCommand)
Expand All @@ -40,7 +40,13 @@ committy --non-interactive tag --bump-files
Confirm creation without prompting (interactive path). If omitted, you'll be prompted.

- __--bump-files__ (alias: `-b`)
Auto-bump registered version files to the new version and commit them.
Bump registered version files to the new version and commit them. This is the default,
so the flag is only needed for compatibility with existing scripts.

- __--no-bump-files__
Leave version files untouched. Note that the tag then points at a commit whose
`Cargo.toml`/`package.json` still carries the previous version, so binaries built from
it report the old version.

- __--output__ `<text|json>`
Output format. Defaults to `text`.
Expand Down Expand Up @@ -77,7 +83,8 @@ committy --non-interactive tag --bump-files
- When `--name` is provided: creates that tag locally.
- Non-interactive without `--name`: calculates the new tag from commit log using regex rules in config and options above, then creates it locally.
- `--publish --confirm-publish` is required before the tag or any version-bump commit is pushed to `origin`.
- `--bump-files`: updates common version files and commits them (message: `chore: bump version to <x.y.z>`). Remote push still requires `--publish --confirm-publish`.
- Version files are bumped by default: common version files are updated and committed (message: `chore: bump version to <x.y.z>`) before the tag is created, so the tag contains the new version. Pass `--no-bump-files` to opt out. Remote push still requires `--publish --confirm-publish`.
- In multi-package repositories the same applies per package; the bump commit message lists each package as `<name>@<version>`.
- Fetch behavior: default is fetch unless `--no-fetch`; `--fetch` forces fetch.

## JSON output
Expand Down
103 changes: 85 additions & 18 deletions src/cli/commands/tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,16 @@ pub struct TagCommand {
#[structopt(
short = "b",
long = "bump-files",
help = "Want to auto bump the config to the new version (y/N)"
help = "Bump version files to the new version (enabled by default)"
)]
bump_config_files: bool,

#[structopt(
long = "no-bump-files",
help = "Do not touch version files when tagging"
)]
no_bump_config_files: bool,

#[structopt(flatten)]
tag_options: git::TagGeneratorOptions,

Expand Down Expand Up @@ -80,6 +86,12 @@ impl Command for TagCommand {
));
}

if self.bump_config_files && self.no_bump_config_files {
return Err(CliError::InputError(
"Use either --bump-files or --no-bump-files, not both".to_string(),
));
}

let default_repo_path = PathBuf::from(".");
let effective_repo_path = if self.repo_path != default_repo_path {
self.repo_path.clone()
Expand Down Expand Up @@ -120,7 +132,7 @@ impl Command for TagCommand {
// Explicit tag name provided - use legacy flow
let version_manager = git::TagGenerator::new(
tag_options.clone(),
self.bump_config_files,
self.should_bump_files(),
git_command_config.clone(),
);
version_manager.create_and_push_tag(&version_manager.open_repository()?, name)?;
Expand Down Expand Up @@ -152,7 +164,7 @@ impl Command for TagCommand {
// In non-interactive mode, auto-calculate and act based on options
let mut version_manager = git::TagGenerator::new(
tag_options.clone(),
self.bump_config_files,
self.should_bump_files(),
git_command_config.clone(),
);
version_manager.run()?;
Expand Down Expand Up @@ -186,7 +198,7 @@ impl Command for TagCommand {
}
let mut version_manager = git::TagGenerator::new(
tag_options,
self.bump_config_files,
self.should_bump_files(),
git_command_config.clone(),
);
version_manager.run()?;
Expand Down Expand Up @@ -218,7 +230,7 @@ impl Command for TagCommand {
"is_pre_release",
Value::from(version_manager.is_pre_release),
),
("allow_bump_files", Value::from(self.bump_config_files)),
("allow_bump_files", Value::from(self.should_bump_files())),
]),
))
{
Expand All @@ -238,6 +250,12 @@ impl Command for TagCommand {
}

impl TagCommand {
/// Version files are bumped unless the caller opts out with `--no-bump-files`.
/// `--bump-files` is still accepted so existing scripts keep working.
fn should_bump_files(&self) -> bool {
!self.no_bump_config_files
}

/// Execute multi-package tag operation
fn execute_multi_package(
&self,
Expand Down Expand Up @@ -298,13 +316,23 @@ impl TagCommand {
return Ok(());
}

// Step 5: Update version files per package
if self.bump_config_files {
self.apply_version_updates(repo_path, &version_updates)?;
info!(
"✅ Updated version files for {} package(s)",
version_updates.len()
);
// Step 5: Update version files per package, and commit them so the tag we
// create below actually points at the bumped versions.
if self.should_bump_files() {
let updated_files = self.apply_version_updates(repo_path, &version_updates)?;
if !updated_files.is_empty() {
info!(
"✅ Updated version files for {} package(s)",
version_updates.len()
);
self.commit_version_updates(
repo_path,
&updated_files,
&version_updates,
git_command_config,
)?;
info!("✅ Committed version changes");
}
}

// Step 6: Update dependencies if requested
Expand Down Expand Up @@ -488,7 +516,8 @@ impl TagCommand {
&self,
repo_path: &Path,
updates: &[crate::versioning::manager::VersionUpdate],
) -> Result<(), CliError> {
) -> Result<Vec<PathBuf>, CliError> {
let mut updated_files = Vec::new();
for update in updates {
// Find package config to get version file
let config = RepositoryConfig::try_load(repo_path)
Expand Down Expand Up @@ -516,16 +545,54 @@ impl TagCommand {

let updated = content.replace(&update.old_version, &update.new_version);

// Nothing to stage or commit when the file already carries the new version.
if updated == content {
continue;
}

fs::write(&version_file, updated)
.map_err(|e| CliError::Generic(format!("Failed to write version file: {}", e)))?;

// Stage the updated file
if let Err(e) = git::stage_file(&version_file) {
debug!("Failed to stage {}: {}", version_file.display(), e);
}
updated_files.push(version_file);
}

Ok(())
Ok(updated_files)
}

/// Stage and commit the bumped version files so the tags created afterwards
/// point at a commit that actually contains them.
fn commit_version_updates(
&self,
repo_path: &Path,
updated_files: &[PathBuf],
updates: &[crate::versioning::manager::VersionUpdate],
git_command_config: &git::GitCommandConfig,
) -> Result<(), CliError> {
let relative_files: Vec<String> = updated_files
.iter()
.map(|file| {
file.strip_prefix(repo_path)
.unwrap_or(file)
.display()
.to_string()
})
.collect();
let mut add_args = vec!["add", "--"];
add_args.extend(relative_files.iter().map(String::as_str));
git::run_git(
repo_path,
&add_args,
"stage version updates",
git_command_config,
)?;

let summary = updates
.iter()
.map(|update| format!("{}@{}", update.package_name, update.new_version))
.collect::<Vec<_>>()
.join(", ");
let message = format!("chore: bump version to {summary}");
git::commit_changes_in_with_config(repo_path, &message, false, git_command_config)
}

/// Apply dependency updates to other packages
Expand Down
16 changes: 11 additions & 5 deletions src/version/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ impl VersionFile {
})
}

pub fn update_version(&self, new_version: &str) -> Result<(), CliError> {
/// Rewrites the version in this file, returning `true` when the contents
/// actually changed. A file that already carries `new_version` is left alone
/// so callers do not try to commit an empty diff.
pub fn update_version(&self, new_version: &str) -> Result<bool, CliError> {
if !self.path.exists() {
return Ok(()); // Skip if file doesn't exist
return Ok(false); // Skip if file doesn't exist
}

let content = std::fs::read_to_string(&self.path).map_err(CliError::IoError)?;
Expand All @@ -30,9 +33,13 @@ impl VersionFile {
.replace_all(&content, &self.format.replace("{}", version_without_v))
.to_string();

if new_content == content {
return Ok(false);
}

std::fs::write(&self.path, new_content).map_err(CliError::IoError)?;

Ok(())
Ok(true)
}
}

Expand Down Expand Up @@ -114,8 +121,7 @@ impl VersionManager {
let mut updated_files = Vec::new();

for file in &self.version_files {
if file.path.exists() {
file.update_version(new_version)?;
if file.path.exists() && file.update_version(new_version)? {
updated_files.push(file.path.clone());
}
}
Expand Down
38 changes: 38 additions & 0 deletions tests/release_workflow_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//! The release workflow is the last place a version mistake can be caught before
//! binaries reach users, so the guard itself is worth pinning down.

use std::fs;
use std::path::Path;

fn release_workflow() -> String {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".github/workflows/cargo-release.yml");
fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
}

#[test]
fn release_workflow_verifies_cargo_version_against_the_tag() {
let workflow = release_workflow();

assert!(
workflow.contains("verify-version:"),
"the release workflow must carry a job that compares Cargo.toml with the tag"
);
assert!(
workflow.contains(r#"if [ "v$VERSION" != "$TAG" ]; then"#),
"the guard must compare the Cargo.toml version with the tag name"
);
}

#[test]
fn every_release_job_waits_for_the_version_guard() {
let workflow = release_workflow();

assert!(
workflow.contains("needs: verify-version"),
"create-release must not publish a release before the version guard passes"
);
assert!(
workflow.contains("needs: [verify-version, create-release]"),
"the build matrix must not upload assets before the version guard passes"
);
}
Loading
Loading