diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index abc74c0e5..ad4b179e2 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -68,4 +68,4 @@ jobs: git config user.email "fabro-releases[bot]@users.noreply.github.com" git remote set-url origin \ "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - bin/dev/release.sh nightly + cargo dev release nightly diff --git a/AGENTS.md b/AGENTS.md index a02fc8e78..a87a387c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,9 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24) - `cargo dev docker-build` — builds the local Docker image from the current tree using the release pipeline's cargo-zigbuild approach. Honors `--arch amd64|arm64`, `--tag ` (default `fabro`), `--compile-only` (stages `docker-context//fabro` without `docker build`), and `--dry-run` (prints the Docker commands without running them). Prefer this over writing a throwaway Dockerfile; the release pipeline, `Dockerfile`, and this command share the same binary layout. - Refresh the embedded SPA before rebuilding the image after any `apps/fabro-web` change: `scripts/refresh-fabro-spa.sh` runs the bun build and copies `dist/` into `lib/crates/fabro-spa/assets/`. Skipping this step produces a Docker image whose Rust binary embeds a stale SPA bundle. +### Release automation +- `cargo dev release [nightly]` — creates the next stable release or nightly prerelease tag. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation. + ### Marketing site (apps/marketing) - `cd apps/marketing && bun run dev` — start Astro dev server - `cd apps/marketing && bun run build` — production build diff --git a/Cargo.lock b/Cargo.lock index f06448dfb..21ad125e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1778,6 +1778,7 @@ version = "0.213.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", + "chrono", "clap", "regex", "tempfile", diff --git a/bin/dev/release.sh b/bin/dev/release.sh deleted file mode 100755 index d89554d49..000000000 --- a/bin/dev/release.sh +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -die() { - printf '%s\n' "$1" >&2 - exit 1 -} - -release_date() { - printf '%s\n' "${FABRO_RELEASE_DATE:-$(date "+%Y-%m-%d")}" -} - -days_since_2026() { - local target_date="$1" - python3 -c "from datetime import date; print((date.fromisoformat('$target_date') - date(2026, 1, 1)).days)" -} - -next_base_version() { - local date minor patch - date="$1" - minor=$(( $(days_since_2026 "$date") + 100 )) - patch=0 - - while git rev-parse "v0.${minor}.${patch}" >/dev/null 2>&1; do - patch=$((patch + 1)) - done - - printf '0.%s.%s\n' "$minor" "$patch" -} - -validate_prerelease_label() { - local label="$1" - case "$label" in - nightly) ;; - *) - die "invalid pre-release label: $label (expected: nightly)" - ;; - esac -} - -compute_release_version() { - local base_version="$1" - local prerelease_label="$2" - - if [[ -z "$prerelease_label" ]]; then - printf '%s\n' "$base_version" - return 0 - fi - - local prerelease_number=0 - while git rev-parse "v${base_version}-${prerelease_label}.${prerelease_number}" >/dev/null 2>&1; do - prerelease_number=$((prerelease_number + 1)) - done - - printf '%s-%s.%s\n' "$base_version" "$prerelease_label" "$prerelease_number" -} - -parse_args() { - DRY_RUN=0 - SKIP_TESTS=0 - PRERELEASE_LABEL="" - - while [[ $# -gt 0 ]]; do - case "$1" in - --dry-run) - DRY_RUN=1 - ;; - --skip-tests) - SKIP_TESTS=1 - ;; - --help|-h) - cat <<'EOF' -Usage: bin/dev/release.sh [nightly] [--dry-run] [--skip-tests] - -Create the next stable release from main, or compute a prerelease tag. - -Before tagging, runs the same release-mode test smoke the CI release -workflow does for the native target, with SEGMENT_WRITE_KEY baked in so -telemetry-active code paths are exercised. This catches regressions -like "uninstall recreates ~/.fabro via telemetry" or "sender tests -assume no write key" before we burn a 60-minute CI cycle. Skip only if -you've already run `cargo nextest run --workspace --release` yourself. -EOF - exit 0 - ;; - --*) - die "unknown option: $1" - ;; - *) - if [[ -n "$PRERELEASE_LABEL" ]]; then - die "expected at most one pre-release label" - fi - validate_prerelease_label "$1" - PRERELEASE_LABEL="$1" - ;; - esac - shift - done -} - -verify_spa_assets() { - local repo_root - repo_root="$(git rev-parse --show-toplevel)" - - "${repo_root}/scripts/refresh-fabro-spa.sh" - - if ! git -C "$repo_root" diff --exit-code -- lib/crates/fabro-spa/assets >/dev/null; then - die "fabro-spa assets are stale. Commit the refreshed assets before releasing." - fi -} - -verify_release_tests() { - if [[ "$SKIP_TESTS" == "1" ]]; then - echo "--skip-tests set, skipping release-mode test smoke" - return 0 - fi - - echo "Running release-mode test smoke (SEGMENT_WRITE_KEY baked in)..." - SEGMENT_WRITE_KEY="fake-for-local-smoke" \ - cargo nextest run \ - --workspace \ - --release \ - --profile ci \ - --status-level slow -} - -main() { - parse_args "$@" - - verify_spa_assets - verify_release_tests - - local repo_root cargo_toml current_version base_version new_version tag - repo_root="$(git rev-parse --show-toplevel)" - cargo_toml="${repo_root}/Cargo.toml" - - current_version=$(grep -m1 '^version = ' "$cargo_toml" | sed 's/version = "\(.*\)"/\1/') - echo "Current version: $current_version" - - base_version=$(next_base_version "$(release_date)") - new_version=$(compute_release_version "$base_version" "$PRERELEASE_LABEL") - tag="v$new_version" - - echo "Releasing $new_version (tag $tag)" - - if [[ "$DRY_RUN" == "1" ]]; then - return 0 - fi - - sed -i.bak "s/^version = \"$current_version\"/version = \"$new_version\"/" "$cargo_toml" - rm "${cargo_toml}.bak" - echo "Updated $cargo_toml" - - cargo update --workspace - echo "Updated Cargo.lock" - - git add "$cargo_toml" Cargo.lock - git commit -m "Bump version to $new_version" - git tag -a "$tag" -m "$tag" - git push origin main "$tag" - - echo "" - echo "Released $tag" - echo "Watch the build: https://github.com/fabro-sh/fabro/actions" -} - -main "$@" diff --git a/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md b/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md index 519313fc0..dfa8f1513 100644 --- a/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md +++ b/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md @@ -666,7 +666,7 @@ Solid arrows are real dependencies. Dashed lines show phases that are independen --- -- [ ] **Unit 5.4: Port `release.sh` to `fabro-dev release`** +- [x] **Unit 5.4: Port `release.sh` to `fabro-dev release`** **Goal:** Replace `bin/dev/release.sh` with a subcommand. diff --git a/lib/crates/fabro-dev/Cargo.toml b/lib/crates/fabro-dev/Cargo.toml index 48f88a5d5..208d845ae 100644 --- a/lib/crates/fabro-dev/Cargo.toml +++ b/lib/crates/fabro-dev/Cargo.toml @@ -15,6 +15,7 @@ workspace = true [dependencies] anyhow.workspace = true +chrono.workspace = true clap.workspace = true regex.workspace = true tracing-subscriber.workspace = true diff --git a/lib/crates/fabro-dev/src/commands/mod.rs b/lib/crates/fabro-dev/src/commands/mod.rs index 58d44b6e9..37ac97026 100644 --- a/lib/crates/fabro-dev/src/commands/mod.rs +++ b/lib/crates/fabro-dev/src/commands/mod.rs @@ -3,12 +3,11 @@ use clap::Args; mod check_boundary; mod docker_build; +mod release; pub(crate) use check_boundary::{CheckBoundaryArgs, check_boundary}; pub(crate) use docker_build::{DockerBuildArgs, docker_build}; - -#[derive(Debug, Args)] -pub(crate) struct ReleaseArgs; +pub(crate) use release::{ReleaseArgs, release}; #[derive(Debug, Args)] pub(crate) struct RefreshSpaArgs; @@ -16,10 +15,6 @@ pub(crate) struct RefreshSpaArgs; #[derive(Debug, Args)] pub(crate) struct CheckSpaBudgetsArgs; -pub(crate) fn release(_args: ReleaseArgs) -> Result<()> { - not_yet_implemented("release") -} - pub(crate) fn refresh_spa(_args: RefreshSpaArgs) -> Result<()> { not_yet_implemented("refresh-spa") } diff --git a/lib/crates/fabro-dev/src/commands/release.rs b/lib/crates/fabro-dev/src/commands/release.rs new file mode 100644 index 000000000..993fd5f50 --- /dev/null +++ b/lib/crates/fabro-dev/src/commands/release.rs @@ -0,0 +1,422 @@ +use std::fmt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use anyhow::{Context, Result, bail}; +use chrono::{Local, NaiveDate}; +use clap::{Args, ValueEnum}; + +const RELEASE_EPOCH: &str = "2026-01-01"; +const RELEASE_TEST_SEGMENT_WRITE_KEY: &str = "fake-for-local-smoke"; + +#[derive(Debug, Args)] +pub(crate) struct ReleaseArgs { + /// Pre-release label to create. + #[arg(value_enum)] + prerelease_label: Option, + /// Print planned release steps without mutating git or running Cargo. + #[arg(long)] + dry_run: bool, + /// Skip the release-mode test smoke. + #[arg(long)] + skip_tests: bool, + /// Release date to use for version computation. + #[arg(long, value_name = "YYYY-MM-DD", env = "FABRO_RELEASE_DATE")] + release_date: Option, + /// Repository root to release. + #[arg(long, hide = true)] + root: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum PrereleaseLabel { + Nightly, +} + +impl fmt::Display for PrereleaseLabel { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nightly => formatter.write_str("nightly"), + } + } +} + +struct ReleasePlan { + prerelease_label: Option, + release_date: NaiveDate, + dry_run: bool, + skip_tests: bool, + root: PathBuf, +} + +#[expect( + clippy::print_stdout, + reason = "dev release command reports progress and dry-run commands directly" +)] +pub(crate) fn release(args: ReleaseArgs) -> Result<()> { + let plan = ReleasePlan { + prerelease_label: args.prerelease_label, + release_date: args + .release_date + .unwrap_or_else(|| Local::now().date_naive()), + dry_run: args.dry_run, + skip_tests: args.skip_tests, + root: args.root.unwrap_or_else(workspace_root), + }; + + let cargo_toml = plan.root.join("Cargo.toml"); + let current_version = read_current_version(&cargo_toml)?; + println!("Current version: {current_version}"); + + let base_version = plan.next_base_version()?; + let new_version = plan.compute_release_version(&base_version)?; + let tag = format!("v{new_version}"); + println!("Releasing {new_version} (tag {tag})"); + + if plan.dry_run { + plan.print_dry_run(¤t_version, &new_version, &tag); + return Ok(()); + } + + plan.ensure_clean_worktree()?; + plan.verify_spa_assets()?; + plan.verify_release_tests()?; + update_version(&cargo_toml, ¤t_version, &new_version)?; + println!("Updated {}", cargo_toml.display()); + + plan.run_command( + &PlannedCommand::new("cargo") + .arg("update") + .arg("--workspace"), + )?; + println!("Updated Cargo.lock"); + + plan.run_command( + &PlannedCommand::new("git") + .arg("add") + .arg("Cargo.toml") + .arg("Cargo.lock"), + )?; + plan.run_command( + &PlannedCommand::new("git") + .arg("commit") + .arg("-m") + .arg(format!("Bump version to {new_version}")), + )?; + plan.run_command( + &PlannedCommand::new("git") + .arg("tag") + .arg("-a") + .arg(&tag) + .arg("-m") + .arg(&tag), + )?; + plan.run_command( + &PlannedCommand::new("git") + .arg("push") + .arg("origin") + .arg("main") + .arg(&tag), + )?; + + println!(); + println!("Released {tag}"); + println!("Watch the build: https://github.com/fabro-sh/fabro/actions"); + + Ok(()) +} + +impl ReleasePlan { + fn next_base_version(&self) -> Result { + let epoch = NaiveDate::parse_from_str(RELEASE_EPOCH, "%Y-%m-%d") + .expect("release epoch should be a valid date"); + let days_since_epoch = self.release_date.signed_duration_since(epoch).num_days(); + if days_since_epoch < 0 { + bail!( + "release date {} predates {RELEASE_EPOCH}", + self.release_date + ); + } + + let minor = days_since_epoch + 100; + let mut patch = 0; + loop { + let version = format!("0.{minor}.{patch}"); + if !self.tag_exists(&format!("v{version}"))? { + return Ok(version); + } + patch += 1; + } + } + + fn compute_release_version(&self, base_version: &str) -> Result { + let Some(prerelease_label) = self.prerelease_label else { + return Ok(base_version.to_string()); + }; + + let mut prerelease_number = 0; + loop { + let version = format!("{base_version}-{prerelease_label}.{prerelease_number}"); + if !self.tag_exists(&format!("v{version}"))? { + return Ok(version); + } + prerelease_number += 1; + } + } + + fn ensure_clean_worktree(&self) -> Result<()> { + let output = self.capture_command( + &PlannedCommand::new("git") + .arg("status") + .arg("--porcelain") + .arg("--untracked-files=all"), + )?; + if !output.status.success() { + bail!( + "failed to inspect working tree: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + if !output.stdout.is_empty() { + bail!("working tree is dirty; commit or stash changes before releasing"); + } + + Ok(()) + } + + fn verify_spa_assets(&self) -> Result<()> { + self.run_command(&Self::refresh_spa_command())?; + let output = self.capture_command(&Self::spa_assets_diff_command())?; + if !output.status.success() { + bail!("fabro-spa assets are stale. Commit the refreshed assets before releasing."); + } + + Ok(()) + } + + #[expect( + clippy::print_stdout, + reason = "dev release command reports release test progress directly" + )] + fn verify_release_tests(&self) -> Result<()> { + if self.skip_tests { + println!("--skip-tests set, skipping release-mode test smoke"); + return Ok(()); + } + + println!("Running release-mode test smoke (SEGMENT_WRITE_KEY baked in)..."); + self.run_command(&Self::release_tests_command()) + } + + #[expect( + clippy::print_stdout, + reason = "dev release command reports dry-run commands directly" + )] + fn print_dry_run(&self, current_version: &str, new_version: &str, tag: &str) { + println!("DRY RUN: would verify SPA assets:"); + println!("{}", Self::refresh_spa_command().to_shell_line()); + println!("{}", Self::spa_assets_diff_command().to_shell_line()); + + if self.skip_tests { + println!("--skip-tests set, would skip release-mode test smoke"); + } else { + println!("DRY RUN: would run release-mode test smoke:"); + println!("{}", Self::release_tests_command().to_shell_line()); + } + + println!("DRY RUN: would update Cargo.toml version {current_version} -> {new_version}"); + for command in [ + PlannedCommand::new("cargo") + .arg("update") + .arg("--workspace"), + PlannedCommand::new("git") + .arg("add") + .arg("Cargo.toml") + .arg("Cargo.lock"), + PlannedCommand::new("git") + .arg("commit") + .arg("-m") + .arg(format!("Bump version to {new_version}")), + PlannedCommand::new("git") + .arg("tag") + .arg("-a") + .arg(tag) + .arg("-m") + .arg(tag), + PlannedCommand::new("git") + .arg("push") + .arg("origin") + .arg("main") + .arg(tag), + ] { + println!("{}", command.to_shell_line()); + } + } + + fn refresh_spa_command() -> PlannedCommand { + PlannedCommand::new("scripts/refresh-fabro-spa.sh") + } + + fn spa_assets_diff_command() -> PlannedCommand { + PlannedCommand::new("git") + .arg("diff") + .arg("--exit-code") + .arg("--") + .arg("lib/crates/fabro-spa/assets") + } + + fn release_tests_command() -> PlannedCommand { + PlannedCommand::new("cargo") + .env("SEGMENT_WRITE_KEY", RELEASE_TEST_SEGMENT_WRITE_KEY) + .arg("nextest") + .arg("run") + .arg("--workspace") + .arg("--release") + .arg("--profile") + .arg("ci") + .arg("--status-level") + .arg("slow") + } + + fn tag_exists(&self, tag: &str) -> Result { + let output = self.capture_command( + &PlannedCommand::new("git") + .arg("rev-parse") + .arg("--verify") + .arg("--quiet") + .arg(format!("refs/tags/{tag}")), + )?; + Ok(output.status.success()) + } + + fn run_command(&self, planned: &PlannedCommand) -> Result<()> { + let status = command(planned) + .current_dir(&self.root) + .status() + .with_context(|| format!("running {}", planned.to_shell_line()))?; + if !status.success() { + bail!("command failed with {status}: {}", planned.to_shell_line()); + } + + Ok(()) + } + + fn capture_command(&self, planned: &PlannedCommand) -> Result { + command(planned) + .current_dir(&self.root) + .output() + .with_context(|| format!("running {}", planned.to_shell_line())) + } +} + +#[expect( + clippy::disallowed_methods, + reason = "dev release reads the workspace manifest synchronously" +)] +fn read_current_version(cargo_toml: &Path) -> Result { + let contents = std::fs::read_to_string(cargo_toml) + .with_context(|| format!("reading {}", cargo_toml.display()))?; + for line in contents.lines().map(str::trim) { + let Some(rest) = line.strip_prefix("version = \"") else { + continue; + }; + let Some(version) = rest.strip_suffix('"') else { + continue; + }; + return Ok(version.to_string()); + } + + bail!( + "could not find workspace package version in {}", + cargo_toml.display() + ) +} + +#[expect( + clippy::disallowed_methods, + reason = "dev release updates the workspace manifest synchronously" +)] +fn update_version(cargo_toml: &Path, current_version: &str, new_version: &str) -> Result<()> { + let contents = std::fs::read_to_string(cargo_toml) + .with_context(|| format!("reading {}", cargo_toml.display()))?; + let needle = format!("version = \"{current_version}\""); + let replacement = format!("version = \"{new_version}\""); + if !contents.contains(&needle) { + bail!( + "could not find current version {current_version} in {}", + cargo_toml.display() + ); + } + + std::fs::write(cargo_toml, contents.replacen(&needle, &replacement, 1)) + .with_context(|| format!("writing {}", cargo_toml.display())) +} + +#[expect( + clippy::disallowed_methods, + reason = "dev release builds synchronous subprocess commands" +)] +fn command(planned: &PlannedCommand) -> Command { + let mut command = Command::new(&planned.program); + command.args(&planned.args); + for (key, value) in &planned.env { + command.env(key, value); + } + command +} + +struct PlannedCommand { + program: String, + args: Vec, + env: Vec<(String, String)>, +} + +impl PlannedCommand { + fn new(program: impl Into) -> Self { + Self { + program: program.into(), + args: Vec::new(), + env: Vec::new(), + } + } + + fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.push((key.into(), value.into())); + self + } + + fn to_shell_line(&self) -> String { + self.env + .iter() + .map(|(key, value)| format!("{}={}", shell_arg(key), shell_arg(value))) + .chain(std::iter::once(shell_arg(&self.program))) + .chain(self.args.iter().map(shell_arg)) + .collect::>() + .join(" ") + } +} + +fn shell_arg(arg: impl AsRef) -> String { + let arg = arg.as_ref(); + if arg + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || "_-./:=@".contains(ch)) + { + return arg.to_string(); + } + + format!("'{}'", arg.replace('\'', "'\\''")) +} + +fn workspace_root() -> PathBuf { + let mut root = Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf(); + root.pop(); + root.pop(); + root.pop(); + root +} diff --git a/lib/crates/fabro-dev/tests/it/main.rs b/lib/crates/fabro-dev/tests/it/main.rs index b80d4d1f1..466ae4ce7 100644 --- a/lib/crates/fabro-dev/tests/it/main.rs +++ b/lib/crates/fabro-dev/tests/it/main.rs @@ -3,6 +3,7 @@ use std::process::{Command, Output}; mod check_boundary; mod docker_build; +mod release; fn fabro_dev() -> assert_cmd::Command { assert_cmd::cargo::cargo_bin_cmd!("fabro-dev") diff --git a/lib/crates/fabro-dev/tests/it/release.rs b/lib/crates/fabro-dev/tests/it/release.rs new file mode 100644 index 000000000..15fc0621d --- /dev/null +++ b/lib/crates/fabro-dev/tests/it/release.rs @@ -0,0 +1,220 @@ +use std::path::Path; +use std::process::Command; + +fn fabro_dev() -> assert_cmd::Command { + assert_cmd::cargo::cargo_bin_cmd!("fabro-dev") +} + +fn output_text(bytes: &[u8]) -> String { + String::from_utf8(bytes.to_vec()).expect("command output should be valid utf-8") +} + +#[expect( + clippy::disallowed_methods, + reason = "integration tests stage temporary release fixture repositories with sync std::fs::write" +)] +fn write_file(path: &Path, contents: &str) { + std::fs::write(path, contents).expect("writing fixture file"); +} + +#[expect( + clippy::disallowed_methods, + reason = "integration tests intentionally shell out to git in temporary fixture repositories" +)] +fn git(root: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(root) + .output() + .expect("git should run"); + assert!( + output.status.success(), + "git {:?} failed\nstdout:\n{}\nstderr:\n{}", + args, + output_text(&output.stdout), + output_text(&output.stderr) + ); +} + +fn release_fixture() -> tempfile::TempDir { + let fixture = tempfile::tempdir().expect("creating fixture"); + write_file( + &fixture.path().join("Cargo.toml"), + r#"[workspace] +members = [] + +[workspace.package] +version = "0.1.0" +"#, + ); + git(fixture.path(), &["init"]); + git(fixture.path(), &["config", "user.name", "Release Test"]); + git(fixture.path(), &[ + "config", + "user.email", + "release-test@example.com", + ]); + git(fixture.path(), &["add", "Cargo.toml"]); + git(fixture.path(), &["commit", "-m", "initial"]); + fixture +} + +#[test] +fn help_lists_release_flags() { + let output = fabro_dev() + .args(["release", "--help"]) + .assert() + .success() + .get_output() + .clone(); + let stdout = output_text(&output.stdout); + + for flag in ["--dry-run", "--skip-tests", "--release-date"] { + assert!( + stdout.contains(flag), + "release help should list {flag}:\n{stdout}" + ); + } +} + +#[test] +fn dry_run_computes_stable_version_from_date() { + let fixture = release_fixture(); + + let output = fabro_dev() + .args([ + "release", + "--root", + fixture + .path() + .to_str() + .expect("fixture path should be utf-8"), + "--release-date", + "2026-01-01", + "--dry-run", + ]) + .assert() + .success() + .get_output() + .clone(); + let stdout = output_text(&output.stdout); + + assert!( + stdout.contains("Releasing 0.100.0 (tag v0.100.0)"), + "dry-run should compute base version from date:\n{stdout}" + ); + assert!( + stdout.contains("git tag -a v0.100.0 -m v0.100.0"), + "dry-run should print release tag command:\n{stdout}" + ); +} + +#[test] +fn dry_run_increments_existing_prerelease_number() { + let fixture = release_fixture(); + git(fixture.path(), &["tag", "v0.100.0-nightly.0"]); + + let output = fabro_dev() + .args([ + "release", + "--root", + fixture + .path() + .to_str() + .expect("fixture path should be utf-8"), + "--release-date", + "2026-01-01", + "--dry-run", + "nightly", + ]) + .assert() + .success() + .get_output() + .clone(); + let stdout = output_text(&output.stdout); + + assert!( + stdout.contains("Releasing 0.100.0-nightly.1 (tag v0.100.0-nightly.1)"), + "dry-run should increment existing nightly tag:\n{stdout}" + ); +} + +#[test] +fn invalid_prerelease_label_fails_with_clap_error() { + let output = fabro_dev() + .args(["release", "beta"]) + .assert() + .failure() + .code(2) + .get_output() + .clone(); + let stderr = output_text(&output.stderr); + + assert!( + stderr.contains("invalid value 'beta'"), + "invalid prerelease label should be rejected by clap:\n{stderr}" + ); +} + +#[test] +fn dry_run_reports_skip_tests_without_running_release_tests() { + let fixture = release_fixture(); + + let output = fabro_dev() + .args([ + "release", + "--root", + fixture + .path() + .to_str() + .expect("fixture path should be utf-8"), + "--release-date", + "2026-01-01", + "--dry-run", + "--skip-tests", + ]) + .assert() + .success() + .get_output() + .clone(); + let stdout = output_text(&output.stdout); + + assert!( + stdout.contains("--skip-tests set, would skip release-mode test smoke"), + "dry-run should report skip-tests behavior:\n{stdout}" + ); + assert!( + !stdout.contains("cargo nextest run"), + "dry-run with skip-tests should not print release test command:\n{stdout}" + ); +} + +#[test] +fn dirty_worktree_errors_unless_dry_run() { + let fixture = release_fixture(); + write_file(&fixture.path().join("dirty.txt"), "dirty\n"); + + let output = fabro_dev() + .args([ + "release", + "--root", + fixture + .path() + .to_str() + .expect("fixture path should be utf-8"), + "--release-date", + "2026-01-01", + "--skip-tests", + ]) + .assert() + .failure() + .code(1) + .get_output() + .clone(); + let stderr = output_text(&output.stderr); + + assert!( + stderr.contains("working tree is dirty"), + "dirty non-dry-run release should fail before mutating:\n{stderr}" + ); +}