diff --git a/Cargo.lock b/Cargo.lock index cdd5eb86b9..b530c92366 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7337,10 +7337,12 @@ dependencies = [ "crossterm", "directories", "flate2", + "glob", "junction", "node-semver", "owo-colors", "oxc_resolver", + "petgraph 0.8.3", "serde", "serde_json", "serial_test", @@ -7480,6 +7482,7 @@ dependencies = [ "serde_json", "serial_test", "supports-color 3.0.2", + "thiserror 2.0.18", "tracing-subscriber", "vite_path", "vite_str", diff --git a/README.md b/README.md index 6d1d728941..ef50ae9f92 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,16 @@ Vite+ is the unified entry point for local web development. It combines [Vite](h - **`vp build`:** Build applications for production with Vite + Rolldown - **`vp run`:** Execute monorepo tasks with caching and dependency-aware scheduling - **`vp pack`:** Build libraries for npm publishing or standalone app binaries +- **`vp release`:** Version and publish workspace packages with native publish preflight during `--dry-run`, release checks before real publishes by default, retry-friendly exact version overrides via `--version`, optional changelog generation via `--changelog`, prerelease channels like `--preid alpha` / `beta` / `rc`, and `--projects` order respected between independent packages - **`vp create` / `vp migrate`:** Scaffold new projects and migrate existing ones All of this is configured from your project root and works across Vite's framework ecosystem. Vite+ is fully open-source under the MIT license. +`vp release` detects likely checks from `build`, `pack`, `prepack`, `prepublishOnly`, `prepare`, and `vitePlus.release.checkScripts`. Real releases run those checks before publishing unless you pass `--no-run-checks`; dry-runs stay lightweight by default and can opt in with `--run-checks`. `--dry-run` also runs the native publisher in dry-run mode from a temporary release manifest state when the git worktree is clean. Use `--yes` for CI or other non-interactive runs, and `--version ` when retrying a partial publish at an exact version. + +Real releases always create git tags after a successful publish. When every released package shares the same target version, `vp release` also creates a repository-level `v` tag so GitHub Releases and repo-wide release notes can follow the same watermark. Preview-only flags such as `--skip-publish` and `--no-git-tag` are therefore limited to `--dry-run`. + ## Getting Started Install Vite+ globally as `vp`: @@ -120,6 +125,7 @@ Use `vp migrate` to migrate to Vite+. It merges tool-specific config files such - **build** - Build for production - **pack** - Build libraries +- **release** - Version and publish workspace packages, with optional changelog generation - **preview** - Preview production build #### Manage Dependencies diff --git a/crates/vite_global_cli/Cargo.toml b/crates/vite_global_cli/Cargo.toml index fa1b693d69..9dd530dc98 100644 --- a/crates/vite_global_cli/Cargo.toml +++ b/crates/vite_global_cli/Cargo.toml @@ -29,6 +29,8 @@ tracing = { workspace = true } owo-colors = { workspace = true } oxc_resolver = { workspace = true } crossterm = { workspace = true } +glob = { workspace = true } +petgraph = { workspace = true } vite_error = { workspace = true } vite_install = { workspace = true } vite_js_runtime = { workspace = true } diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index ec591d4d42..05460dc391 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -613,6 +613,74 @@ pub enum Commands { args: Vec, }, + /// Version workspace packages locally, then publish them from trusted-publishing CI with readiness checks and optional changelog generation + Release { + /// Preview the release plan without changing files or publishing + #[arg(long)] + dry_run: bool, + + /// During dry-runs, omit publish simulation and show only versioning/changelog actions + #[arg(long)] + skip_publish: bool, + + /// Treat this release as the first one and ignore existing release tags + #[arg(long)] + first_release: bool, + + /// Generate root and per-package changelogs + #[arg(long, overrides_with = "no_changelog")] + changelog: bool, + + /// Skip changelog generation + #[arg(long, overrides_with = "changelog")] + no_changelog: bool, + + /// Override the computed release version. Useful when retrying a partial publish. + #[arg(long, value_name = "VERSION")] + version: Option, + + /// Publish a prerelease using the provided identifier (for example: alpha, beta, rc) + #[arg(long, value_name = "TAG")] + preid: Option, + + /// Legacy TOTP code for npm 2FA publish flows. Prefer trusted publishing or passkey/security-key auth when possible. + #[arg(long, value_name = "OTP")] + otp: Option, + + /// Release only matching workspace packages. When multiple values are provided, + /// their order is used as a tie-breaker between independent packages. + #[arg(long, value_name = "PATTERN", value_delimiter = ',')] + projects: Option>, + + /// Create git tags for released packages + #[arg(long, overrides_with = "no_git_tag")] + git_tag: bool, + + /// Skip git tag creation in preview mode + #[arg(long, overrides_with = "git_tag")] + no_git_tag: bool, + + /// Create a git commit for release changes + #[arg(long, overrides_with = "no_git_commit")] + git_commit: bool, + + /// Skip the release commit + #[arg(long, overrides_with = "git_commit")] + no_git_commit: bool, + + /// Run detected release checks before publishing. Real releases do this by default. + #[arg(long, overrides_with = "no_run_checks")] + run_checks: bool, + + /// Skip release checks before publishing + #[arg(long, overrides_with = "run_checks")] + no_run_checks: bool, + + /// Skip the final confirmation prompt + #[arg(long, short = 'y', alias = "force")] + yes: bool, + }, + /// Run tasks #[command(disable_help_flag = true)] Run { @@ -2032,6 +2100,45 @@ pub async fn run_command_with_options( commands::delegate::execute(cwd, "pack", &args).await } + Commands::Release { + dry_run, + skip_publish, + first_release, + changelog, + no_changelog, + version, + preid, + otp, + projects, + git_tag: _, + no_git_tag, + git_commit: _, + no_git_commit, + run_checks, + no_run_checks, + yes, + } => { + let run_checks = if dry_run { run_checks } else { !no_run_checks || run_checks }; + commands::release::execute( + cwd, + commands::release::ReleaseOptions { + dry_run, + skip_publish, + first_release, + changelog: changelog && !no_changelog, + version, + preid, + otp, + projects, + git_tag: !no_git_tag, + git_commit: !no_git_commit, + run_checks, + yes, + }, + ) + .await + } + Commands::Run { args } => { if help::maybe_print_unified_delegate_help("run", &args, render_options.show_header) { return Ok(ExitStatus::default()); diff --git a/crates/vite_global_cli/src/command_picker.rs b/crates/vite_global_cli/src/command_picker.rs index dc31289b9c..f9ed6a3b2a 100644 --- a/crates/vite_global_cli/src/command_picker.rs +++ b/crates/vite_global_cli/src/command_picker.rs @@ -82,6 +82,12 @@ const COMMANDS: &[CommandEntry] = &[ append_help: false, }, CommandEntry { label: "pack", command: "pack", summary: "Build library.", append_help: false }, + CommandEntry { + label: "release", + command: "release", + summary: "Version and publish workspace packages with readiness checks, confirmation, and optional changelog generation.", + append_help: false, + }, CommandEntry { label: "preview", command: "preview", diff --git a/crates/vite_global_cli/src/commands/env/exec.rs b/crates/vite_global_cli/src/commands/env/exec.rs index 1de04d75b3..e850c6cfe0 100644 --- a/crates/vite_global_cli/src/commands/env/exec.rs +++ b/crates/vite_global_cli/src/commands/env/exec.rs @@ -193,10 +193,43 @@ fn exit_status(code: i32) -> ExitStatus { #[cfg(test)] mod tests { + use std::ffi::OsString; + use serial_test::serial; + use tempfile::TempDir; use super::*; + struct VpHomeGuard { + original_vp_home: Option, + _temp_dir: TempDir, + } + + impl VpHomeGuard { + fn new() -> Self { + let temp_dir = TempDir::new().unwrap(); + let original_vp_home = std::env::var_os(env_vars::VP_HOME); + // SAFETY: This test helper is only used from serial tests and restores the + // process-global environment before dropping. + unsafe { + std::env::set_var(env_vars::VP_HOME, temp_dir.path()); + } + Self { original_vp_home, _temp_dir: temp_dir } + } + } + + impl Drop for VpHomeGuard { + fn drop(&mut self) { + // SAFETY: We restore the original process-global environment captured in new(). + unsafe { + match &self.original_vp_home { + Some(value) => std::env::set_var(env_vars::VP_HOME, value), + None => std::env::remove_var(env_vars::VP_HOME), + } + } + } + } + #[tokio::test] async fn test_execute_missing_command() { let result = execute(Some("20.18.0"), None, &[]).await; @@ -208,10 +241,11 @@ mod tests { #[tokio::test] #[serial] async fn test_execute_node_version() { + let _guard = VpHomeGuard::new(); // Run 'node --version' with a specific Node.js version let command = vec!["node".to_string(), "--version".to_string()]; let result = execute(Some("20.18.0"), None, &command).await; - assert!(result.is_ok()); + assert!(result.is_ok(), "{result:?}"); let status = result.unwrap(); assert!(status.success()); } diff --git a/crates/vite_global_cli/src/commands/mod.rs b/crates/vite_global_cli/src/commands/mod.rs index 7d0f45a839..fa7536c8cf 100644 --- a/crates/vite_global_cli/src/commands/mod.rs +++ b/crates/vite_global_cli/src/commands/mod.rs @@ -157,6 +157,7 @@ pub mod install; pub mod link; pub mod outdated; pub mod pm; +pub mod release; pub mod remove; pub mod unlink; pub mod update; diff --git a/crates/vite_global_cli/src/commands/pm.rs b/crates/vite_global_cli/src/commands/pm.rs index 85b8320594..1b5106f076 100644 --- a/crates/vite_global_cli/src/commands/pm.rs +++ b/crates/vite_global_cli/src/commands/pm.rs @@ -183,6 +183,7 @@ pub async fn execute_pm_subcommand( tag: tag.as_deref(), access: access.as_deref(), otp: otp.as_deref(), + provenance: None, no_git_checks, publish_branch: publish_branch.as_deref(), report_summary, diff --git a/crates/vite_global_cli/src/commands/release/first_publish.rs b/crates/vite_global_cli/src/commands/release/first_publish.rs new file mode 100644 index 0000000000..fe2c758d86 --- /dev/null +++ b/crates/vite_global_cli/src/commands/release/first_publish.rs @@ -0,0 +1,962 @@ +//! First-release guidance is intentionally modeled as a tiny inline DSL. +//! +//! The goal here is twofold: +//! +//! 1. Keep the full checklist visible in one place so maintainers can scan the entire +//! first-publish experience without jumping through a long chain of helper functions. +//! 2. Stay extremely conservative on runtime cost even though this code is primarily +//! user-facing text generation. +//! +//! The macros below expand directly into a fixed checklist structure rather than building an +//! intermediate template language at runtime. That keeps the declaration readable while also +//! avoiding: +//! +//! - `format!`-heavy string construction +//! - repeated temporary `Vec` / `String` creation for static content +//! - fragmented step builder functions that make the overall checklist harder to audit +//! +//! The resulting flow is: +//! +//! - `first_publish_checklist!` declares the entire checklist as a fixed array of steps +//! - `ChecklistStep` / `ChecklistLine` store only the minimal renderable structure +//! - `print_checklist` reuses a single `String` buffer while streaming output line by line +//! +//! This is intentionally not a generic templating system. It is a small, purpose-built, +//! allocation-aware representation tailored to the handful of first-publish messages we need +//! to render. + +use super::*; + +const CHECKLIST_STEP_PREFIX: &str = " "; +const CHECKLIST_ITEM_PREFIX: &str = " - "; +const DEFAULT_PUBLISH_WORKFLOW_PATH: &str = ".github/workflows/publish.yml"; +const PUBLISH_WORKFLOW_TEMPLATE: &str = include_str!("templates/publish.yml"); +const PACKAGE_MANAGER_SETUP_TOKEN: &str = " - __PACKAGE_MANAGER_SETUP__\n"; +const INSTALL_COMMAND_TOKEN: &str = "__INSTALL_COMMAND__"; +const RELEASE_COMMAND_TOKEN: &str = "__RELEASE_COMMAND__"; +const FIRST_RELEASE_COMMAND_TOKEN: &str = "__FIRST_RELEASE_COMMAND__"; +const COREPACK_SETUP_STEP: &str = " - name: Enable Corepack\n run: corepack enable\n"; +const BUN_SETUP_STEP: &str = " - name: Setup Bun\n uses: oven-sh/setup-bun@v2\n"; + +/// Declares a checklist step in a compact, template-like form. +/// +/// This macro exists so that the first-publish checklist can be read top-to-bottom as a +/// single declarative block. It expands straight into `ChecklistStep::new`, so there is no +/// runtime template parsing or second-pass interpretation cost. +macro_rules! step { + ($title:expr, [$( $line:expr ),* $(,)?] $(,)?) => { + ChecklistStep::new($title, [$( $line ),*]) + }; +} + +/// Emits a static text line. +/// +/// Static strings stay borrowed all the way through rendering, which lets the checklist carry +/// explanatory text without allocating per line. +macro_rules! text { + ($text:expr $(,)?) => { + Some(ChecklistLine::static_text($text)) + }; +} + +/// Emits a key/value line where both sides are static. +/// +/// This is the cheapest path through the checklist DSL because both key and value can remain +/// borrowed until the final buffered write. +macro_rules! kv_static { + ($key:expr, $value:expr $(,)?) => { + Some(ChecklistLine::key_value_static($key, $value)) + }; +} + +/// Emits a key/value line whose value is borrowed from existing guidance state. +/// +/// Borrowing here matters because several values, such as the workflow path, already live in +/// `FirstPublishGuidance`; cloning them just to print one checklist would be unnecessary work. +macro_rules! kv_borrowed { + ($key:expr, $value:expr $(,)?) => { + Some(ChecklistLine::key_value_borrowed($key, $value)) + }; +} + +/// Emits a key/value line that owns its rendered value. +/// +/// This is reserved for lines that genuinely need a synthesized `String`, such as inline-code +/// wrappers or comma-joined package lists. Keeping this explicit makes it easier to audit +/// where allocations still happen. +macro_rules! kv_owned { + ($key:expr, $value:expr $(,)?) => { + Some(ChecklistLine::key_value_owned($key, $value)) + }; +} + +/// Emits an owned key/value line only when an optional source value exists. +/// +/// The render closure runs only on the populated path, which keeps optional checklist lines +/// concise without forcing the surrounding step to split into multiple helper functions. +macro_rules! maybe_kv_owned { + ($key:expr, $value:expr, |$binding:ident| $render:expr $(,)?) => { + $value.map(|$binding| ChecklistLine::key_value_owned($key, $render)) + }; +} + +/// Emits a static text line behind a boolean gate. +/// +/// This keeps conditional checklist entries inline with their neighboring lines, which is +/// useful for preserving the “entire template in one screen” property of this module. +macro_rules! when_text { + ($condition:expr, $text:expr $(,)?) => { + ($condition).then_some(ChecklistLine::static_text($text)) + }; +} + +/// Emits an owned key/value line behind a boolean gate. +/// +/// The checklist uses this for diagnostics such as missing repository metadata, where we only +/// want to pay the join/allocation cost when there is something actionable to show. +macro_rules! when_kv_owned { + ($condition:expr, $key:expr, $value:expr $(,)?) => { + ($condition).then(|| ChecklistLine::key_value_owned($key, $value)) + }; +} + +/// Declares the full first-publish checklist as a single fixed array. +/// +/// This macro is the main readability/performance tradeoff point for the module: +/// +/// - Readability: every step is visible in one contiguous block, so reviewers can understand +/// the entire checklist without chasing helper functions. +/// - Performance: the macro expands to a fixed `[ChecklistStep; 5]`, avoiding a top-level +/// dynamic `Vec` allocation for the checklist itself. +/// +/// The helpers used inside the block (`kv_*`, `when_*`, `text!`) are intentionally tiny so the +/// callsite still reads like a declarative template rather than imperative push-based code. +macro_rules! first_publish_checklist { + ($guidance:expr, $options:expr $(,)?) => {{ + let guidance = $guidance; + let options = $options; + let has_repository_issues = !guidance.packages_missing_repository.is_empty() + || !guidance.packages_mismatched_repository.is_empty(); + + [ + step!( + "Commit a GitHub Actions publish workflow that runs on a GitHub-hosted runner.", + [ + kv_borrowed!("Workflow file", &guidance.workflow_path), + when_text!( + guidance.workflow_template_created, + "A starter workflow was scaffolded locally from the built-in template. Review it before committing.", + ), + kv_static!("Trigger", "`workflow_dispatch`"), + maybe_kv_owned!( + "Suggested dispatch ref", + guidance.dispatch_ref_hint.as_deref(), + |dispatch_ref| render_inline_code(dispatch_ref) + ), + text!( + "After review, add any automatic push trigger using the branch or tag rule that matches your release boundary.", + ), + kv_static!( + "Required workflow permissions", + "`contents: write` and `id-token: write`", + ), + text!( + "Push the generated release commit and tags back to origin so the release watermark survives CI.", + ), + ], + ), + step!( + "Configure npm Trusted Publishing for each package you are releasing.", + [ + Some(match guidance.github_repo.as_deref() { + Some(repo) => ChecklistLine::key_value_owned("Repository", render_inline_code(repo)), + None => ChecklistLine::key_value_static("Repository", "`/`"), + }), + kv_owned!( + "Workflow filename in npm", + render_inline_code(workflow_filename(&guidance.workflow_path)), + ), + text!("npm requires the repository and workflow values to match exactly."), + text!("Trusted publishing currently works for public npm packages and scopes."), + text!("For any manual maintainer fallback, prefer npm passkeys/security-key 2FA instead of TOTP codes or long-lived publish tokens."), + ], + ), + step!( + "Make sure each package.json has a matching `repository` entry.", + [ + when_text!( + !has_repository_issues, + "Looks good for the packages in this release.", + ), + when_kv_owned!( + !guidance.packages_missing_repository.is_empty(), + "Missing `repository`", + join_string_slice(&guidance.packages_missing_repository, ", "), + ), + when_kv_owned!( + !guidance.packages_mismatched_repository.is_empty(), + "Repository does not match git remote", + join_string_slice(&guidance.packages_mismatched_repository, ", "), + ), + ], + ), + step!( + "For the first public publish of scoped packages, set `publishConfig.access` to `public`.", + [ + when_text!( + guidance.scoped_packages_missing_public_access.is_empty(), + "No obvious access issues detected." + ), + when_kv_owned!( + !guidance.scoped_packages_missing_public_access.is_empty(), + "Missing `publishConfig.access = \"public\"`", + join_string_slice(&guidance.scoped_packages_missing_public_access, ", "), + ), + ], + ), + step!( + "Validate the release flow from CI before the first real publish.", + [ + kv_owned!("Dry run", render_release_command(options, true, true)), + kv_owned!( + "Trusted publish from GitHub Actions", + render_release_command(options, false, false), + ), + text!( + "Trusted publishing covers publish itself. If CI also installs private packages, use a separate read-only npm token for install steps.", + ), + text!( + "If you ever need an interactive fallback outside CI, prefer a passkey/security-key prompt over `--otp`.", + ), + ], + ), + ] + }}; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ChecklistText<'a> { + Static(&'static str), + Borrowed(&'a str), + Owned(String), +} + +impl ChecklistText<'_> { + /// Writes a previously classified text fragment into the shared render buffer. + /// + /// The checklist renderer deliberately reuses a single `String`, so each line component + /// writes directly into that buffer instead of allocating a brand new line string. + fn write_into(&self, buffer: &mut String) { + match self { + Self::Static(value) => buffer.push_str(value), + Self::Borrowed(value) => buffer.push_str(value), + Self::Owned(value) => buffer.push_str(value), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ChecklistLine<'a> { + Text(ChecklistText<'a>), + KeyValue { key: &'static str, value: ChecklistText<'a> }, +} + +impl ChecklistLine<'_> { + /// Serializes a single line into the shared render buffer. + /// + /// This stays intentionally tiny because the hot path is simple: append the line prefix, + /// then stream the already-prepared content into the same buffer. + fn write_into(&self, buffer: &mut String) { + match self { + Self::Text(text) => text.write_into(buffer), + Self::KeyValue { key, value } => { + buffer.push_str(key); + buffer.push_str(": "); + value.write_into(buffer); + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ChecklistStep<'a> { + title: &'static str, + lines: Vec>, +} + +impl<'a> ChecklistStep<'a> { + /// Builds a step from an iterator of optional lines. + /// + /// Accepting `Option` lets the declarative macros keep conditional lines + /// inline without falling back to imperative `push` code. The constructor uses the + /// iterator's lower-bound size hint to preallocate just once for the common case. + fn new(title: &'static str, lines: I) -> Self + where + I: IntoIterator>>, + { + let iter = lines.into_iter(); + let (lower, _) = iter.size_hint(); + let mut collected = Vec::with_capacity(lower); + for line in iter { + if let Some(line) = line { + collected.push(line); + } + } + Self { title, lines: collected } + } +} + +impl<'a> ChecklistLine<'a> { + fn static_text(text: &'static str) -> Self { + Self::Text(ChecklistText::Static(text)) + } + + fn key_value_static(key: &'static str, value: &'static str) -> Self { + Self::KeyValue { key, value: ChecklistText::Static(value) } + } + + fn key_value_borrowed(key: &'static str, value: &'a str) -> Self { + Self::KeyValue { key, value: ChecklistText::Borrowed(value) } + } + + fn key_value_owned(key: &'static str, value: String) -> Self { + Self::KeyValue { key, value: ChecklistText::Owned(value) } + } +} + +/// Collects repository/workflow/package metadata needed by the first-publish checklist. +/// +/// The checklist rendering path is intentionally pure and declarative, so any filesystem or +/// git-derived facts are gathered ahead of time into `FirstPublishGuidance`. +pub(super) fn collect_first_publish_guidance( + cwd: &AbsolutePath, + release_plans: &[PackageReleasePlan], +) -> FirstPublishGuidance { + let github_repo = detect_github_repo(cwd); + let dispatch_ref_hint = detect_dispatch_ref_hint(cwd); + let workflow_path = find_release_workflow_path(cwd); + + let mut guidance = FirstPublishGuidance { + github_repo: github_repo.clone(), + dispatch_ref_hint, + workflow_path, + ..Default::default() + }; + + for plan in release_plans { + if plan.name.starts_with('@') && plan.access.as_deref() != Some("public") { + guidance.scoped_packages_missing_public_access.push(plan.name.clone()); + } + + match plan.repository_url.as_deref() { + Some(repository_url) => { + if let Some(expected_repo) = github_repo.as_deref() + && parse_github_repo_slug(repository_url).as_deref() != Some(expected_repo) + { + guidance.packages_mismatched_repository.push(plan.name.clone()); + } + } + None => guidance.packages_missing_repository.push(plan.name.clone()), + } + } + + guidance +} + +/// Scaffolds a trusted-publishing workflow template when the repository has no release workflow. +/// +/// Existing `publish.*` / `release.*` workflows always win. The scaffold is only written when the +/// repository has no obvious workflow at all, which avoids surprising users with duplicate jobs. +pub(super) fn ensure_first_publish_workflow_template( + cwd: &AbsolutePath, + package_manager: PackageManagerType, + guidance: &mut FirstPublishGuidance, +) -> Result<(), Error> { + if find_existing_release_workflow_path(cwd).is_some() { + return Ok(()); + } + + let workflow_path = default_publish_workflow_path(); + let workflows_dir = cwd.join(".github/workflows"); + fs::create_dir_all(workflows_dir.as_path()).map_err(|error| { + let mut message = String::from("create .github/workflows directory: "); + push_display(&mut message, error); + Error::UserMessage(message.into()) + })?; + + let rendered = render_publish_workflow_template(package_manager); + let workflow_file_path = cwd.join(workflow_path); + fs::write(&workflow_file_path, rendered).map_err(|error| { + let mut message = String::from("write "); + message.push_str(workflow_path); + message.push_str(": "); + push_display(&mut message, error); + Error::UserMessage(message.into()) + })?; + + guidance.workflow_path = workflow_path.to_owned(); + guidance.workflow_template_created = true; + + let mut message = String::from("Scaffolded "); + message.push_str(workflow_path); + message + .push_str(" from the built-in trusted-publishing template. Review it before committing."); + output::success(&message); + + Ok(()) +} + +/// Renders the first-publish checklist using the declarative checklist DSL above. +/// +/// Keeping the checklist materialization next to this callsite makes the overall flow easy to +/// inspect, while `print_checklist` keeps the indentation and numbering details centralized. +pub(super) fn print_first_publish_guidance( + guidance: &FirstPublishGuidance, + options: &ReleaseOptions, +) { + let checklist = first_publish_checklist!(guidance, options); + print_checklist( + "First publish checklist:", + "This run uses --first-release, so there are a few one-time setup steps:", + &checklist, + ); +} + +/// Renders a concrete `vp release` example command for checklist output. +/// +/// This path deliberately avoids `format!` so the user-facing examples follow the same +/// allocation discipline as the rest of the release command. +pub(super) fn render_release_command( + options: &ReleaseOptions, + dry_run: bool, + include_skip_publish: bool, +) -> String { + let mut command = String::from("vp release"); + if options.first_release { + command.push_str(" --first-release"); + } + if options.changelog { + command.push_str(" --changelog"); + } + if let Some(version) = options.version.as_deref() { + command.push_str(" --version "); + command.push_str(version); + } + if let Some(preid) = options.preid.as_deref() { + command.push_str(" --preid "); + command.push_str(preid); + } + if let Some(projects) = options.projects.as_ref() + && !projects.is_empty() + { + command.push_str(" --projects "); + push_joined(&mut command, projects.iter().map(String::as_str), ","); + } + if !options.git_tag { + command.push_str(" --no-git-tag"); + } + if !options.git_commit { + command.push_str(" --no-git-commit"); + } + if include_skip_publish && options.skip_publish { + command.push_str(" --skip-publish"); + } + if dry_run { + command.push_str(" --dry-run"); + } else { + if !options.run_checks { + command.push_str(" --no-run-checks"); + } + command.push_str(" --yes"); + } + + command +} + +/// Streams checklist lines to the output layer with a single reusable buffer. +/// +/// Building the full output eagerly would be simpler, but reusing one `String` keeps this path +/// cheap and makes allocation behavior very obvious during review. +fn print_checklist(heading: &str, intro: &str, checklist: &[ChecklistStep<'_>]) { + output::raw(""); + output::info(heading); + + let mut line = String::with_capacity(256); + line.push_str(CHECKLIST_STEP_PREFIX); + line.push_str(intro); + output::raw(&line); + + for (index, step) in checklist.iter().enumerate() { + line.clear(); + line.push_str(CHECKLIST_STEP_PREFIX); + push_display(&mut line, index + 1); + line.push_str(". "); + line.push_str(step.title); + output::raw(&line); + + for item in &step.lines { + line.clear(); + line.push_str(CHECKLIST_ITEM_PREFIX); + item.write_into(&mut line); + output::raw(&line); + } + } +} + +/// Wraps a value in backticks using one tightly-sized owned buffer. +fn render_inline_code(value: &str) -> String { + let mut rendered = String::with_capacity(value.len() + 2); + rendered.push('`'); + rendered.push_str(value); + rendered.push('`'); + rendered +} + +/// Joins a borrowed slice of owned strings with a precomputed output capacity. +fn join_string_slice(values: &[String], separator: &str) -> String { + if values.is_empty() { + return String::new(); + } + + let separator_bytes = separator.len(); + let total_len = values.iter().map(String::len).sum::() + + separator_bytes * values.len().saturating_sub(1); + let mut joined = String::with_capacity(total_len); + push_joined(&mut joined, values.iter().map(String::as_str), separator); + joined +} + +fn render_publish_workflow_template(package_manager: PackageManagerType) -> String { + let package_manager_setup = package_manager_setup_step(package_manager); + let install_command = package_manager_install_command(package_manager); + let mut rendered = String::from(PUBLISH_WORKFLOW_TEMPLATE); + rendered = rendered.replace(PACKAGE_MANAGER_SETUP_TOKEN, package_manager_setup); + rendered = rendered.replace(INSTALL_COMMAND_TOKEN, install_command); + rendered = rendered.replace(RELEASE_COMMAND_TOKEN, "vp release --yes"); + rendered.replace(FIRST_RELEASE_COMMAND_TOKEN, "vp release --first-release --yes") +} + +fn package_manager_setup_step(package_manager: PackageManagerType) -> &'static str { + match package_manager { + PackageManagerType::Pnpm | PackageManagerType::Yarn => COREPACK_SETUP_STEP, + PackageManagerType::Bun => BUN_SETUP_STEP, + PackageManagerType::Npm => "", + } +} + +fn package_manager_install_command(package_manager: PackageManagerType) -> &'static str { + match package_manager { + PackageManagerType::Pnpm => "pnpm install --frozen-lockfile", + PackageManagerType::Yarn => "yarn install --immutable", + PackageManagerType::Npm => "npm ci", + PackageManagerType::Bun => "bun install --frozen-lockfile", + } +} + +fn find_release_workflow_path(cwd: &AbsolutePath) -> String { + find_existing_release_workflow_path(cwd) + .unwrap_or_else(|| default_publish_workflow_path().to_owned()) +} + +fn find_existing_release_workflow_path(cwd: &AbsolutePath) -> Option { + for candidate in [ + ".github/workflows/publish.yml", + ".github/workflows/publish.yaml", + ".github/workflows/release.yml", + ".github/workflows/release.yaml", + ] { + if cwd.join(candidate).as_path().exists() { + return Some(candidate.to_owned()); + } + } + + let workflows_dir = cwd.join(".github/workflows"); + if let Ok(entries) = fs::read_dir(workflows_dir.as_path()) { + let mut best_path: Option = None; + for entry in entries.filter_map(Result::ok) { + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + let lowercase = file_name.to_ascii_lowercase(); + let is_workflow_manifest = lowercase.ends_with(".yml") || lowercase.ends_with(".yaml"); + let mentions_publish = lowercase.contains("publish"); + let mentions_release = lowercase.contains("release"); + if !is_workflow_manifest || (!mentions_publish && !mentions_release) { + continue; + } + + let mut path = String::with_capacity(file_name.len() + 18); + path.push_str(".github/workflows/"); + path.push_str(&file_name); + let should_replace = best_path.as_ref().map_or(true, |best| { + let best_lowercase = best.to_ascii_lowercase(); + let best_mentions_publish = best_lowercase.contains("publish"); + (mentions_publish && !best_mentions_publish) + || (mentions_publish == best_mentions_publish && path < *best) + }); + if should_replace { + best_path = Some(path); + } + } + return best_path; + } + + None +} + +const fn default_publish_workflow_path() -> &'static str { + DEFAULT_PUBLISH_WORKFLOW_PATH +} + +fn workflow_filename(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +fn detect_github_repo(cwd: &AbsolutePath) -> Option { + let remote = capture_git(cwd, ["config", "--get", "remote.origin.url"]).ok()?; + parse_github_repo_slug(&remote) +} + +fn detect_dispatch_ref_hint(cwd: &AbsolutePath) -> Option { + if let Ok(tag) = capture_git(cwd, ["describe", "--tags", "--exact-match"]) { + let tag = tag.trim(); + if !tag.is_empty() { + return Some(tag.to_owned()); + } + } + + if let Ok(default_head) = + capture_git(cwd, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]) + { + let branch = default_head.strip_prefix("origin/").unwrap_or(&default_head).trim(); + if !branch.is_empty() { + return Some(branch.to_owned()); + } + } + + let branch = capture_git(cwd, ["branch", "--show-current"]).ok()?; + let branch = branch.trim(); + (!branch.is_empty()).then(|| branch.to_owned()) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use vite_path::AbsolutePathBuf; + + use super::*; + + fn render_checklist_lines(checklist: &[ChecklistStep<'_>]) -> Vec { + let mut rendered = Vec::new(); + let mut line = String::with_capacity(256); + + for (index, step) in checklist.iter().enumerate() { + line.clear(); + line.push_str(CHECKLIST_STEP_PREFIX); + push_display(&mut line, index + 1); + line.push_str(". "); + line.push_str(step.title); + rendered.push(line.clone()); + + for item in &step.lines { + line.clear(); + line.push_str(CHECKLIST_ITEM_PREFIX); + item.write_into(&mut line); + rendered.push(line.clone()); + } + } + + rendered + } + + #[test] + fn first_publish_checklist_is_declared_in_stable_step_order() { + let guidance = FirstPublishGuidance { + github_repo: Some("voidzero-dev/vite-plus".into()), + dispatch_ref_hint: Some("main".into()), + workflow_path: ".github/workflows/publish.yml".into(), + ..Default::default() + }; + + let checklist = first_publish_checklist!( + &guidance, + &ReleaseOptions { + dry_run: false, + skip_publish: false, + first_release: true, + changelog: false, + version: None, + preid: None, + otp: None, + projects: None, + git_tag: true, + git_commit: true, + run_checks: true, + yes: false, + }, + ); + + let lines = render_checklist_lines(&checklist); + assert_eq!( + lines[0], + " 1. Commit a GitHub Actions publish workflow that runs on a GitHub-hosted runner." + ); + assert!(lines.iter().any(|line| line.contains("Repository: `voidzero-dev/vite-plus`"))); + assert!( + lines.iter().any(|line| line.contains("Dry run: vp release --first-release --dry-run")) + ); + assert!(lines.iter().any(|line| { + line.contains("Trusted publish from GitHub Actions: vp release --first-release --yes") + })); + } + + #[test] + fn first_publish_checklist_surfaces_package_issues_compactly() { + let guidance = FirstPublishGuidance { + workflow_path: ".github/workflows/publish.yml".into(), + packages_missing_repository: vec!["@scope/pkg-a".into(), "@scope/pkg-b".into()], + packages_mismatched_repository: vec!["@scope/pkg-c".into()], + scoped_packages_missing_public_access: vec!["@scope/pkg-a".into()], + ..Default::default() + }; + + let checklist = first_publish_checklist!( + &guidance, + &ReleaseOptions { + dry_run: false, + skip_publish: false, + first_release: true, + changelog: true, + version: None, + preid: Some("beta".into()), + otp: None, + projects: Some(vec!["@scope/pkg-a".into()]), + git_tag: false, + git_commit: false, + run_checks: true, + yes: false, + }, + ); + + let lines = render_checklist_lines(&checklist); + assert!( + lines + .iter() + .any(|line| line.contains("Missing `repository`: @scope/pkg-a, @scope/pkg-b")) + ); + assert!( + lines + .iter() + .any(|line| line.contains("Repository does not match git remote: @scope/pkg-c")) + ); + assert!(lines.iter().any(|line| { + line.contains("Missing `publishConfig.access = \"public\"`: @scope/pkg-a") + })); + assert!(lines.iter().any(|line| { + line.contains( + "Dry run: vp release --first-release --changelog --preid beta --projects @scope/pkg-a --no-git-tag --no-git-commit --dry-run", + ) + })); + } + + #[test] + fn first_publish_checklist_surfaces_scaffolded_workflow_note() { + let guidance = FirstPublishGuidance { + workflow_path: ".github/workflows/publish.yml".into(), + workflow_template_created: true, + ..Default::default() + }; + + let checklist = first_publish_checklist!(&guidance, &make_release_options_for_tests()); + let lines = render_checklist_lines(&checklist); + + assert!(lines.iter().any(|line| { + line.contains("A starter workflow was scaffolded locally from the built-in template") + })); + } + + #[test] + fn render_release_command_omits_skip_publish_for_real_publish_examples() { + let command = render_release_command( + &ReleaseOptions { + dry_run: false, + skip_publish: true, + first_release: false, + changelog: false, + version: None, + preid: None, + otp: Some("123456".into()), + projects: Some(Vec::new()), + git_tag: true, + git_commit: true, + run_checks: true, + yes: false, + }, + false, + false, + ); + + assert_eq!(command, "vp release --yes"); + } + + #[test] + fn render_release_command_preserves_no_git_commit_for_checklist_examples() { + let command = render_release_command( + &ReleaseOptions { + dry_run: false, + skip_publish: false, + first_release: true, + changelog: false, + version: None, + preid: None, + otp: None, + projects: None, + git_tag: true, + git_commit: false, + run_checks: true, + yes: false, + }, + true, + true, + ); + + assert_eq!(command, "vp release --first-release --no-git-commit --dry-run"); + } + + #[test] + fn render_release_command_includes_version_override() { + let command = render_release_command( + &ReleaseOptions { + dry_run: false, + skip_publish: false, + first_release: false, + changelog: false, + version: Some("1.2.3".into()), + preid: None, + otp: None, + projects: None, + git_tag: true, + git_commit: true, + run_checks: true, + yes: false, + }, + false, + false, + ); + + assert_eq!(command, "vp release --version 1.2.3 --yes"); + } + + #[test] + fn workflow_detection_prefers_publish_filename_when_both_exist() { + let temp_dir = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let workflows_dir = cwd.join(".github/workflows"); + fs::create_dir_all(workflows_dir.as_path()).unwrap(); + fs::write(workflows_dir.join("release.yml").as_path(), "name: Release\n").unwrap(); + fs::write(workflows_dir.join("publish.yml").as_path(), "name: Publish\n").unwrap(); + + assert_eq!(find_release_workflow_path(&cwd), ".github/workflows/publish.yml"); + } + + #[test] + fn workflow_detection_uses_release_filename_when_publish_is_missing() { + let temp_dir = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let workflows_dir = cwd.join(".github/workflows"); + fs::create_dir_all(workflows_dir.as_path()).unwrap(); + fs::write(workflows_dir.join("release.yaml").as_path(), "name: Release\n").unwrap(); + + assert_eq!(find_release_workflow_path(&cwd), ".github/workflows/release.yaml"); + } + + #[test] + fn workflow_detection_defaults_to_publish_filename_when_missing() { + let temp_dir = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + assert_eq!(find_release_workflow_path(&cwd), ".github/workflows/publish.yml"); + } + + #[test] + fn workflow_template_creation_writes_publish_workflow_when_missing() { + let temp_dir = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let mut guidance = FirstPublishGuidance { + dispatch_ref_hint: Some("release-main".into()), + workflow_path: ".github/workflows/publish.yml".into(), + ..Default::default() + }; + + ensure_first_publish_workflow_template(&cwd, PackageManagerType::Pnpm, &mut guidance) + .unwrap(); + + let created = std::fs::read_to_string(cwd.join(".github/workflows/publish.yml")).unwrap(); + assert!(guidance.workflow_template_created); + assert!(created.contains("workflow_dispatch:")); + assert!(created.contains("")); + assert!(created.contains("")); + assert!(created.contains("run: corepack enable")); + assert!(created.contains("run: pnpm install --frozen-lockfile")); + assert!(created.contains("git config user.name \"github-actions[bot]\"")); + assert!(created.contains("vp release --first-release --yes")); + assert!(created.contains("vp release --yes")); + assert!(created.contains("git push origin HEAD")); + assert!(created.contains("git push origin --tags")); + assert!( + created + .contains("gh release create \"$repo_tag\" --generate-notes --title \"$repo_tag\"") + ); + assert!(!created.contains(PACKAGE_MANAGER_SETUP_TOKEN)); + assert!(!created.contains(INSTALL_COMMAND_TOKEN)); + } + + #[test] + fn workflow_template_creation_skips_existing_release_workflow() { + let temp_dir = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let workflows_dir = cwd.join(".github/workflows"); + fs::create_dir_all(workflows_dir.as_path()).unwrap(); + fs::write(workflows_dir.join("release.yml").as_path(), "name: Release\n").unwrap(); + let mut guidance = FirstPublishGuidance { + workflow_path: ".github/workflows/release.yml".into(), + ..Default::default() + }; + + ensure_first_publish_workflow_template(&cwd, PackageManagerType::Pnpm, &mut guidance) + .unwrap(); + + assert!(!guidance.workflow_template_created); + assert!(!cwd.join(".github/workflows/publish.yml").as_path().exists()); + assert_eq!( + std::fs::read_to_string(cwd.join(".github/workflows/release.yml")).unwrap(), + "name: Release\n" + ); + } + + #[test] + fn workflow_template_creation_uses_bun_specific_setup() { + let rendered = render_publish_workflow_template(PackageManagerType::Bun); + + assert!(rendered.contains("uses: oven-sh/setup-bun@v2")); + assert!(rendered.contains("run: bun install --frozen-lockfile")); + assert!(!rendered.contains("run: corepack enable")); + } + + fn make_release_options_for_tests() -> ReleaseOptions { + ReleaseOptions { + dry_run: false, + skip_publish: false, + first_release: true, + changelog: false, + version: None, + preid: None, + otp: None, + projects: None, + git_tag: true, + git_commit: true, + run_checks: true, + yes: false, + } + } +} diff --git a/crates/vite_global_cli/src/commands/release/manager.rs b/crates/vite_global_cli/src/commands/release/manager.rs new file mode 100644 index 0000000000..0beb0c1506 --- /dev/null +++ b/crates/vite_global_cli/src/commands/release/manager.rs @@ -0,0 +1,684 @@ +//! Release workflow orchestration. +//! +//! This module is the top-level state machine for `vp release`. It intentionally owns the +//! sequence of high-level phases: +//! +//! 1. detect workspace/package-manager context +//! 2. build a release plan +//! 3. present readiness/security information +//! 4. run dry-run preflight or the real publish flow +//! 5. finalize local git state +//! +//! Lower-level details such as git history inspection, file rewrites, and rendering live in +//! neighboring modules. Keeping the phase transitions here makes it easier to audit the control +//! flow and the fail-closed security checks in one place. + +use super::*; + +/// Workspace-scoped data loaded once before package selection and release planning. +struct ReleaseWorkspace { + package_manager: PackageManager, + workspace_root_path: AbsolutePathBuf, + workspace_manifest: Option, + package_graph: WorkspacePackageGraph, + workspace_packages: Vec, +} + +/// Immutable release plan plus the data needed to execute it. +/// +/// This type sits at the boundary between planning and execution. After it is constructed, the +/// remaining workflow should not need to rediscover workspace state. +struct PreparedRelease { + package_manager: PackageManager, + workspace_root_path: AbsolutePathBuf, + workspace_manifest: Option, + release_plans: Vec, + repository_tag_name: Option, + manifest_edits: Vec, + root_commits: Vec, +} + +/// Coordinator for one `vp release` invocation. +/// +/// The manager owns runtime-only context such as the current working directory and detected +/// trusted-publishing environment, then delegates specialized work to sibling modules. +struct ReleaseManager { + cwd: AbsolutePathBuf, + options: ReleaseOptions, + trusted_publish_context: TrustedPublishContext, +} + +impl ReleaseManager { + /// Constructs a manager and snapshots the current trusted-publishing environment. + fn new(cwd: AbsolutePathBuf, options: ReleaseOptions) -> Self { + Self { cwd, options, trusted_publish_context: TrustedPublishContext::detect() } + } + + /// Executes the full release workflow, returning early when no packages need a release. + async fn run(self) -> Result { + validate_release_options(&self.options)?; + validate_trusted_publish_context(&self.options, &self.trusted_publish_context)?; + + let workspace = self.load_workspace().await?; + let Some(release) = self.prepare_release(workspace)? else { + return Ok(ExitStatus::default()); + }; + + let readiness_report = self.present_release(&release)?; + self.validate_release_security_posture(&release)?; + self.validate_publish_protocol_safety(&release)?; + + if self.options.dry_run { + self.run_dry_run(&release).await + } else { + self.run_release(&release, &readiness_report).await + } + } + + async fn load_workspace(&self) -> Result { + prepend_js_runtime_to_path_env(&self.cwd).await?; + let package_manager = build_package_manager(&self.cwd).await?; + + let (workspace_root, _) = vite_workspace::find_workspace_root(&self.cwd)?; + let workspace_root_path = workspace_root.path.to_absolute_path_buf(); + let package_graph = vite_workspace::load_package_graph(&workspace_root)?; + let workspace_manifest = read_workspace_manifest(&workspace_root_path)?; + let workspace_packages = load_workspace_packages(&package_graph)?; + let orphaned_released_packages = + collect_orphaned_released_packages(&workspace_root_path, &workspace_packages)?; + if !orphaned_released_packages.is_empty() { + let mut message = String::from( + "Previously released packages no longer map to an active workspace package: ", + ); + push_joined(&mut message, orphaned_released_packages.iter().map(String::as_str), ", "); + message.push_str(". Add `vitePlus.release.previousNames` / `retiredNames` metadata where appropriate and consider deprecating removed packages on the registry."); + output::warn(&message); + } + + Ok(ReleaseWorkspace { + package_manager, + workspace_root_path, + workspace_manifest, + package_graph, + workspace_packages, + }) + } + + fn prepare_release( + &self, + workspace: ReleaseWorkspace, + ) -> Result, Error> { + let publishable_package_count = workspace.workspace_packages.len(); + let selected = select_workspace_packages( + &workspace.workspace_packages, + self.options.projects.as_deref(), + )?; + if selected.is_empty() { + output::warn("No publishable packages matched the release selection."); + return Ok(None); + } + + let ordered = topological_sort_selected_packages(&workspace.package_graph, &selected); + let (release_plans, root_commits) = + self.build_release_plans(&workspace.workspace_root_path, ordered)?; + if release_plans.is_empty() { + output::warn("No releasable package changes were found."); + return Ok(None); + } + + let repository_tag_name = (selected.len() == publishable_package_count) + .then(|| shared_repository_release_tag(&release_plans)) + .flatten(); + let manifest_edits = build_manifest_edits(&release_plans)?; + Ok(Some(PreparedRelease { + package_manager: workspace.package_manager, + workspace_root_path: workspace.workspace_root_path, + workspace_manifest: workspace.workspace_manifest, + release_plans, + repository_tag_name, + manifest_edits, + root_commits, + })) + } + + /// Builds per-package release plans and the root changelog commit set. + fn build_release_plans( + &self, + workspace_root_path: &AbsolutePath, + ordered: Vec, + ) -> Result<(Vec, Vec), Error> { + let version_override = + self.options.version.as_deref().map(Version::parse).transpose().map_err(|error| { + let mut message = String::from("Invalid `--version` value: "); + push_display(&mut message, error); + Error::UserMessage(message.into()) + })?; + let mut release_plans = Vec::with_capacity(ordered.len()); + let mut root_commits = Vec::new(); + let mut seen_commit_hashes = HashSet::new(); + + for package in ordered { + let uses_tag_version_source = package.manifest.version.trim() == "0.0.0"; + let latest_package_tag = if self.options.first_release { + None + } else { + find_latest_package_tag(workspace_root_path, &package.known_names)? + }; + let previous_tag = if latest_package_tag.is_some() || !uses_tag_version_source { + latest_package_tag + } else if self.options.first_release { + None + } else { + find_latest_repository_release_tag(workspace_root_path)? + }; + let latest_package_version = if self.options.first_release { + None + } else { + find_latest_package_version(workspace_root_path, &package.known_names)? + }; + let latest_repository_version = + if self.options.first_release || !uses_tag_version_source { + None + } else { + find_latest_repository_release_version(workspace_root_path)? + }; + let latest_stable_version = if self.options.first_release { + None + } else { + let latest_package_stable_version = + find_latest_stable_package_version(workspace_root_path, &package.known_names)?; + if latest_package_stable_version.is_some() || !uses_tag_version_source { + latest_package_stable_version + } else { + find_latest_repository_stable_release_version(workspace_root_path)? + } + }; + + let commits = collect_package_commits( + workspace_root_path, + &package.release_paths, + previous_tag.as_deref(), + )?; + + let Some(level) = highest_release_level(&commits) else { + let mut message = String::from("Skipping "); + message.push_str(&package.name); + message.push_str(" because no releasable conventional commits were found."); + output::note(&message); + continue; + }; + + let current_version = if uses_tag_version_source { + latest_package_version + .or(latest_repository_version) + .unwrap_or_else(|| Version::new(0, 0, 0)) + } else { + Version::parse(&package.manifest.version).map_err(|e| { + let mut message = String::from("Package '"); + message.push_str(&package.name); + message.push_str("' has an invalid version '"); + message.push_str(&package.manifest.version); + message.push_str("': "); + push_display(&mut message, e); + Error::UserMessage(message.into()) + })? + }; + let level = effective_release_level(¤t_version, level); + let next_version = match version_override.as_ref() { + Some(version) => version.clone(), + None => next_release_version( + ¤t_version, + level, + latest_stable_version.as_ref(), + self.options.preid.as_deref(), + )?, + }; + + for commit in &commits { + if seen_commit_hashes.insert(commit.hash.clone()) { + root_commits.push(commit.clone()); + } + } + let tag_name = package_tag_name(&package.name, &next_version); + + release_plans.push(PackageReleasePlan { + name: package.name.clone(), + known_names: package.known_names.clone(), + retired_names: package.retired_names.clone(), + package_path: package.package_path.clone(), + manifest_path: package.manifest_path.clone(), + manifest_contents: package.manifest_contents, + manifest: package.manifest.clone(), + current_version, + next_version, + level, + commits, + changelog_path: package.package_path.join("CHANGELOG.md"), + access: package.manifest.publish_config.access.clone(), + publish_tag: package.manifest.publish_config.tag.clone(), + publish_provenance: package.manifest.publish_config.provenance, + repository_url: package.manifest.repository_url().map(ToOwned::to_owned), + protocol_summary: package.manifest.dependency_protocol_summary(), + tag_name, + scripts: package.manifest.scripts.keys().cloned().collect(), + check_scripts: package.manifest.vite_plus.release.check_scripts.clone(), + }); + } + + if self.options.version.is_some() + && let Some(first_version) = + release_plans.first().map(|plan| plan.current_version.clone()) + && release_plans.iter().any(|plan| plan.current_version != first_version) + { + return Err(Error::UserMessage( + "`vp release --version` currently requires all selected packages to share the same current version. Narrow the selection with `--projects` if you need to retry only part of the release." + .into(), + )); + } + + Ok((release_plans, root_commits)) + } + + /// Renders the user-facing release/readiness summaries and returns the computed report. + fn present_release(&self, release: &PreparedRelease) -> Result { + print_release_plan(&release.release_plans, &self.options); + if self.options.first_release { + let mut guidance = collect_first_publish_guidance( + &release.workspace_root_path, + &release.release_plans, + ); + ensure_first_publish_workflow_template( + &release.workspace_root_path, + release.package_manager.client, + &mut guidance, + )?; + print_first_publish_guidance(&guidance, &self.options); + } + let readiness_report = collect_release_readiness_report( + release.workspace_manifest.as_ref(), + &release.release_plans, + &self.options, + &self.trusted_publish_context, + ); + print_release_readiness_report(&readiness_report); + Ok(readiness_report) + } + + /// Rejects package-manager/protocol combinations that are known to publish unsafe manifests. + fn validate_publish_protocol_safety(&self, release: &PreparedRelease) -> Result<(), Error> { + if self.options.skip_publish { + return Ok(()); + } + + let mut protocol_issues = String::new(); + for plan in &release.release_plans { + let protocols = + unsupported_publish_protocols(&release.package_manager, plan.protocol_summary); + if protocols.is_empty() { + continue; + } + if !protocol_issues.is_empty() { + protocol_issues.push_str(", "); + } + protocol_issues.push_str(&plan.name); + protocol_issues.push_str(" ("); + push_joined(&mut protocol_issues, protocols.into_iter(), ", "); + protocol_issues.push(')'); + } + + if protocol_issues.is_empty() { + return Ok(()); + } + + let mut message = String::from("Publishing with "); + push_display(&mut message, release.package_manager.client); + message.push_str(" is unsafe because these packages still contain unsupported publish-time dependency protocols: "); + message.push_str(&protocol_issues); + message.push_str(". Use a package manager with native publish rewriting support where available, or rerun with `--dry-run --skip-publish` to preview versioning without publishing."); + if self.options.dry_run { + output::warn(&message); + Ok(()) + } else { + Err(Error::UserMessage(message.into())) + } + } + + /// Enforces hardened release policy before any real publish happens. + /// + /// This gate is intentionally fail-closed. If a real release cannot satisfy provenance and + /// repository metadata expectations, the workflow should stop before touching the registry. + fn validate_release_security_posture(&self, release: &PreparedRelease) -> Result<(), Error> { + if self.options.dry_run { + return Ok(()); + } + + let mut issues = Vec::new(); + let provenance_disabled: Vec<&str> = release + .release_plans + .iter() + .filter(|plan| matches!(plan.publish_provenance, Some(false))) + .map(|plan| plan.name.as_str()) + .collect(); + if !provenance_disabled.is_empty() { + let mut message = String::from( + "`publishConfig.provenance = false` is not allowed for hardened releases: ", + ); + push_joined(&mut message, provenance_disabled.into_iter(), ", "); + issues.push(message); + } + + let missing_repository: Vec<&str> = release + .release_plans + .iter() + .filter(|plan| plan.repository_url.is_none()) + .map(|plan| plan.name.as_str()) + .collect(); + if !missing_repository.is_empty() { + let mut message = + String::from("Repository metadata is required for trusted publishing provenance: "); + push_joined(&mut message, missing_repository.into_iter(), ", "); + issues.push(message); + } + + if let Some(expected_repository) = self.trusted_publish_context.repository.as_deref() { + let mismatched_repository: Vec<&str> = release + .release_plans + .iter() + .filter(|plan| { + plan.repository_url + .as_deref() + .and_then(parse_github_repo_slug) + .map_or(true, |slug| slug != expected_repository) + }) + .map(|plan| plan.name.as_str()) + .collect(); + if !mismatched_repository.is_empty() { + let mut message = String::from( + "Package `repository` metadata must match the trusted publishing repository `", + ); + message.push_str(expected_repository); + message.push_str("`: "); + push_joined(&mut message, mismatched_repository.into_iter(), ", "); + issues.push(message); + } + } + + if issues.is_empty() { + return Ok(()); + } + + let mut message = String::from("Security policy rejected this release: "); + push_joined(&mut message, issues.iter().map(String::as_str), " | "); + Err(Error::UserMessage(message.into())) + } + + /// Executes a no-write preview of the release, including publish preflight when possible. + async fn run_dry_run(&self, release: &PreparedRelease) -> Result { + let artifact_summary = summarize_release_artifacts( + &release.release_plans, + &release.manifest_edits, + self.options.changelog, + ); + print_dry_run_actions( + &release.release_plans, + release.repository_tag_name.as_deref(), + &release.package_manager, + &self.options, + artifact_summary, + &self.trusted_publish_context, + ); + + let readiness_report = collect_release_readiness_report( + release.workspace_manifest.as_ref(), + &release.release_plans, + &self.options, + &self.trusted_publish_context, + ); + let check_status = run_release_checks( + &release.package_manager, + &release.workspace_root_path, + &release.release_plans, + &readiness_report, + &self.options, + ) + .await?; + if !check_status.success() { + return Ok(check_status); + } + + let publish_status = if self.options.skip_publish { + DryRunPublishStatus::SkippedByOption + } else if is_clean_git_worktree(&release.workspace_root_path)? { + let status = run_publish_preflight( + &release.package_manager, + &release.release_plans, + &release.manifest_edits, + &self.options, + &self.trusted_publish_context, + ) + .await?; + if !status.success() { + print_dry_run_summary( + artifact_summary, + DryRunPublishStatus::Failed, + &self.options, + release.release_plans.len(), + release.repository_tag_name.as_deref(), + &self.trusted_publish_context, + ); + return Ok(status); + } + DryRunPublishStatus::Succeeded + } else { + output::warn( + "Skipping native publish dry-run because the git worktree is dirty. Rerun from a clean tree for full publish preflight.", + ); + DryRunPublishStatus::SkippedDirtyWorktree + }; + + print_dry_run_summary( + artifact_summary, + publish_status, + &self.options, + release.release_plans.len(), + release.repository_tag_name.as_deref(), + &self.trusted_publish_context, + ); + + Ok(ExitStatus::default()) + } + + /// Executes the real publish flow and finalizes local release artifacts on success. + async fn run_release( + &self, + release: &PreparedRelease, + readiness_report: &ReleaseReadinessReport, + ) -> Result { + ensure_clean_worktree(&release.workspace_root_path)?; + if !self.options.yes + && !confirm_release(&release.release_plans, readiness_report, &self.options)? + { + return Ok(ExitStatus::default()); + } + + let check_status = run_release_checks( + &release.package_manager, + &release.workspace_root_path, + &release.release_plans, + readiness_report, + &self.options, + ) + .await?; + if !check_status.success() { + return Ok(check_status); + } + + let preflight_status = run_publish_preflight( + &release.package_manager, + &release.release_plans, + &release.manifest_edits, + &self.options, + &self.trusted_publish_context, + ) + .await?; + if !preflight_status.success() { + return Ok(preflight_status); + } + + let publish_status = publish_packages( + &release.package_manager, + &release.release_plans, + &release.manifest_edits, + &self.options, + &self.trusted_publish_context, + ) + .await?; + if !publish_status.success() { + return Ok(publish_status); + } + + let artifact_summary = summarize_release_artifacts( + &release.release_plans, + &release.manifest_edits, + self.options.changelog, + ); + let release_date = Local::now().format("%Y-%m-%d").to_string(); + let artifact_edits = build_release_artifact_edits( + &release.workspace_root_path, + &release.release_plans, + &release.manifest_edits, + &release.root_commits, + &release_date, + self.options.changelog, + )?; + let changed_files: Vec = + artifact_edits.iter().map(|edit| edit.path.clone()).collect(); + apply_release_artifact_edits(&artifact_edits)?; + + let commit_message = + self.options.git_commit.then(|| release_commit_message(&release.release_plans)); + if self.options.git_commit { + if let Err(error) = git_add_paths(&release.workspace_root_path, &changed_files) { + return Err(artifact_rollback_error( + "stage release changes", + error, + &artifact_edits, + true, + )); + } + let commit_message = commit_message.as_deref().expect("commit message should exist"); + if let Err(error) = git_commit(&release.workspace_root_path, commit_message) { + return Err(artifact_rollback_error( + "create release commit", + error, + &artifact_edits, + true, + )); + } + let mut message = String::from("Created release commit: "); + message.push_str(commit_message); + output::success(&message); + } + + let mut created_tag_names = Vec::new(); + if self.options.git_tag { + for plan in &release.release_plans { + if let Err(error) = git_tag(&release.workspace_root_path, &plan.tag_name) { + return Err(tag_rollback_error( + &release.workspace_root_path, + error, + &created_tag_names, + )); + } + created_tag_names.push(plan.tag_name.clone()); + let mut message = String::from("Created git tag "); + message.push_str(&plan.tag_name); + output::success(&message); + } + if let Some(repository_tag_name) = release.repository_tag_name.as_deref() { + if let Err(error) = git_tag(&release.workspace_root_path, repository_tag_name) { + return Err(tag_rollback_error( + &release.workspace_root_path, + error, + &created_tag_names, + )); + } + created_tag_names.push(repository_tag_name.to_owned()); + let mut message = String::from("Created repository git tag "); + message.push_str(repository_tag_name); + output::success(&message); + } + } + + print_release_completion_summary( + artifact_summary, + release.release_plans.len(), + commit_message.as_deref(), + release.repository_tag_name.as_deref(), + created_tag_names.len(), + &self.trusted_publish_context, + ); + Ok(ExitStatus::default()) + } +} + +/// Formats an execution error while rolling back final local release artifacts when possible. +fn artifact_rollback_error( + context: &str, + error: Error, + artifact_edits: &[ReleaseArtifactEdit], + mention_git_index: bool, +) -> Error { + let rollback_result = rollback_release_artifact_edits(artifact_edits); + let mut message = String::from(context); + message.push_str(": "); + push_display(&mut message, error); + match rollback_result { + Ok(()) => { + message.push_str(". Local release files were rolled back."); + if mention_git_index { + message + .push_str(" If git staged any paths before the failure, inspect `git status`."); + } + } + Err(rollback_error) => { + message.push_str(" | rollback error: "); + push_display(&mut message, rollback_error); + } + } + Error::UserMessage(message.into()) +} + +/// Formats a tag-creation error and removes any tags created by the current run. +fn tag_rollback_error( + workspace_root_path: &AbsolutePath, + error: Error, + created_tag_names: &[String], +) -> Error { + let rollback_result = rollback_created_git_tags(workspace_root_path, created_tag_names); + let mut message = String::from("create release tag: "); + push_display(&mut message, error); + match rollback_result { + Ok(()) => { + if !created_tag_names.is_empty() { + message.push_str(". Previously created release tags from this run were removed."); + } + message.push_str( + " The release commit remains in git; inspect it before retrying tag creation.", + ); + } + Err(rollback_error) => { + message.push_str(" | tag rollback error: "); + push_display(&mut message, rollback_error); + } + } + Error::UserMessage(message.into()) +} + +/// Public entrypoint used by the parent `release` command module. +pub(super) async fn execute_release( + cwd: AbsolutePathBuf, + options: ReleaseOptions, +) -> Result { + ReleaseManager::new(cwd, options).run().await +} diff --git a/crates/vite_global_cli/src/commands/release/mod.rs b/crates/vite_global_cli/src/commands/release/mod.rs new file mode 100644 index 0000000000..71df31a5e4 --- /dev/null +++ b/crates/vite_global_cli/src/commands/release/mod.rs @@ -0,0 +1,587 @@ +//! Workspace release workflow for versioning, changelog generation, and coordinated publishing. +//! +//! References: +//! - SemVer 2.0.0: https://semver.org/spec/v2.0.0.html +//! - SemVer FAQ for `0.y.z`: https://semver.org/#faq +//! - Conventional Commits 1.0.0: https://www.conventionalcommits.org/en/v1.0.0/#specification +//! - Conventional Commits FAQ: https://www.conventionalcommits.org/en/v1.0.0/#faq + +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + fmt::{self, Write as _}, + fs, + io::{IsTerminal, Write}, + process::ExitStatus, +}; + +use chrono::Local; +use glob::Pattern; +use petgraph::visit::EdgeRef; +use vite_install::{ + PackageManager, + commands::{publish::PublishCommandOptions, script::ScriptCommandOptions}, + package_manager::PackageManagerType, +}; +use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_shared::{ + DependencyProtocolSummary, PackageJsonError, PackageManifest, Version, VersionBump, + VersionPattern, build_prerelease, bump_version, capture_git, is_clean_git_worktree, output, + parse_conventional_commit, parse_github_repo_slug, parse_version_pattern, prerelease_channel, + prerelease_number, read_package_manifest, replace_dependency_version_ranges, + replace_top_level_string_property, run_git, strip_prerelease, +}; +use vite_workspace::{DependencyType, PackageInfo, PackageNodeIndex}; + +use crate::error::Error; + +/// Shared release state and policy helpers. +/// +/// This module intentionally keeps the release pipeline's common vocabulary at the module root: +/// +/// - CLI/config inputs (`ReleaseOptions`) +/// - release planning records (`WorkspacePackage`, `PackageReleasePlan`, `CommitInfo`) +/// - file mutation descriptors (`ManifestEdit`, `ReleaseArtifactEdit`) +/// - readiness/security state (`ReleaseReadinessReport`, `TrustedPublishContext`) +/// +/// Keeping these concepts here makes the higher-level modules easier to read: +/// +/// - `planning` computes these values +/// - `reporting` renders them +/// - `storage` persists or rolls them back +/// - `manager` orchestrates the full workflow by moving between these states +/// +/// This root module deliberately avoids heavy I/O in the shared helpers below. Expensive work +/// should stay in the specialized sibling modules. + +/// User-controlled knobs for `vp release`. +/// +/// These values are parsed from the CLI layer and then treated as immutable input for the rest of +/// the release pipeline. +#[derive(Debug, Clone)] +pub struct ReleaseOptions { + /// When `true`, compute and print the release plan without mutating files or creating tags. + pub dry_run: bool, + /// During `--dry-run`, suppress native publish simulation and show only local artifact changes. + pub skip_publish: bool, + /// Ignores prior release tags and treats the selected packages as unpublished. + pub first_release: bool, + /// Enables root and per-package changelog generation. + pub changelog: bool, + /// Exact version to publish instead of auto-computing the next release. + pub version: Option, + /// Optional prerelease dist-tag/channel override such as `alpha`, `beta`, or `rc`. + pub preid: Option, + /// Legacy TOTP override for package-manager publish commands. + pub otp: Option, + /// Optional package-selection filter. The original order is preserved as a tiebreaker. + pub projects: Option>, + /// Whether local git tags should be created after a successful release. + pub git_tag: bool, + /// Whether local release artifacts should be committed. + pub git_commit: bool, + /// Whether release checks should run before publish. + pub run_checks: bool, + /// Skips the interactive confirmation prompt. + pub yes: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PrereleaseTag { + Standard(VersionBump), + Custom(String), +} + +impl PrereleaseTag { + fn parse(value: &str) -> Self { + match value { + "alpha" => Self::Standard(VersionBump::Alpha), + "beta" => Self::Standard(VersionBump::Beta), + "rc" => Self::Standard(VersionBump::Rc), + _ => Self::Custom(value.to_owned()), + } + } + + fn as_str(&self) -> &str { + match self { + Self::Standard(level) => { + debug_assert!(matches!( + level, + VersionBump::Alpha | VersionBump::Beta | VersionBump::Rc + )); + level.as_str() + } + Self::Custom(value) => value, + } + } +} + +/// Conventional-commit information extracted from git history for release planning. +#[derive(Debug, Clone)] +struct CommitInfo { + /// Full commit SHA used for de-duplication and changelog links. + pub hash: String, + /// Short SHA printed in changelogs and summaries. + pub short_hash: String, + /// The human-readable commit subject. + pub subject: String, + /// Semantic-release level inferred from the commit message/body. + pub level: VersionBump, +} + +/// A publishable workspace package after workspace discovery but before version planning. +#[derive(Debug, Clone)] +struct WorkspacePackage { + /// Stable graph node id used for dependency ordering. + pub node: PackageNodeIndex, + /// Current package name from `package.json`. + pub name: String, + /// Current plus historical names that should still match release tags. + pub known_names: Vec, + /// Names that used to exist but should no longer map back to this package. + pub retired_names: Vec, + /// Paths whose git history should contribute to this package's release notes. + pub release_paths: Vec, + /// Original user selection order used to break ties between independent packages. + pub selection_order: usize, + /// Absolute path to the package manifest. + pub manifest_path: AbsolutePathBuf, + /// Absolute path to the package directory. + pub package_path: AbsolutePathBuf, + /// Original raw `package.json` contents used for edit/rollback operations. + pub manifest_contents: String, + /// Parsed manifest subset used by release logic. + pub manifest: PackageManifest, +} + +/// Fully planned release information for one package. +/// +/// By the time a `PackageReleasePlan` exists, commit classification, next-version calculation, +/// publish metadata selection, and tag naming have already been resolved. +#[derive(Debug, Clone)] +struct PackageReleasePlan { + pub name: String, + pub known_names: Vec, + pub retired_names: Vec, + pub package_path: AbsolutePathBuf, + pub manifest_path: AbsolutePathBuf, + pub manifest_contents: String, + pub manifest: PackageManifest, + pub current_version: Version, + pub next_version: Version, + pub level: VersionBump, + pub commits: Vec, + pub changelog_path: AbsolutePathBuf, + /// Publish-time access level, typically `public` for scoped public packages. + pub access: Option, + /// Dist-tag that should be used if the CLI does not override it. + pub publish_tag: Option, + /// Explicit provenance preference from `publishConfig`. + pub publish_provenance: Option, + /// Normalized repository URL used for trusted-publishing validation. + pub repository_url: Option, + /// Summary of special dependency protocols that may require package-manager rewriting. + pub protocol_summary: DependencyProtocolSummary, + /// Final git tag name that will be created on success. + pub tag_name: String, + /// Scripts available on the package, used for readiness reporting. + pub scripts: Vec, + /// Release-specific script names configured under `vitePlus.release.checkScripts`. + pub check_scripts: Vec, +} + +/// Temporary package-manifest rewrite used during publish preflight and publish execution. +#[derive(Debug, Clone)] +struct ManifestEdit { + /// Package label used in error messages. + pub package: String, + /// Path to the manifest being rewritten. + pub path: AbsolutePathBuf, + /// Original contents restored after preflight/publish completes. + pub original_contents: String, + /// Publish-time rewritten contents. + pub updated_contents: String, +} + +/// Final release artifact mutation applied after publish succeeds. +/// +/// Unlike `ManifestEdit`, this type is used for the durable local changes that should remain in +/// git after the release completes, such as version bumps and changelog entries. +#[derive(Debug, Clone)] +struct ReleaseArtifactEdit { + /// Human-readable artifact description used in rollback errors. + pub label: String, + /// File path to update. + pub path: AbsolutePathBuf, + /// Pre-existing file contents, or `None` when the file is generated by the release. + pub original_contents: Option, + /// Desired file contents after the release finalizes locally. + pub updated_contents: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct FirstPublishGuidance { + pub github_repo: Option, + pub dispatch_ref_hint: Option, + pub workflow_path: String, + pub workflow_template_created: bool, + pub packages_missing_repository: Vec, + pub packages_mismatched_repository: Vec, + pub scoped_packages_missing_public_access: Vec, +} + +/// Combined readiness summary rendered before a release is confirmed. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ReleaseReadinessReport { + /// Release-related scripts found at the workspace root. + pub workspace_scripts: Vec, + /// Release-related scripts found on individual packages. + pub package_scripts: Vec, + /// Trusted-publishing posture detected for this run. + pub trusted_publish: TrustedPublishReadiness, + /// Human-readable warnings that should be reviewed before continuing. + pub warnings: Vec, +} + +/// Release-related scripts detected for one package. +#[derive(Debug, Clone, PartialEq, Eq)] +struct PackageReadiness { + pub package: String, + pub scripts: Vec, +} + +/// CI provider whose environment variables match npm trusted publishing support. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TrustedPublishProvider { + GitHubActions, + GitLabCi, + CircleCi, +} + +impl TrustedPublishProvider { + #[must_use] + const fn as_str(self) -> &'static str { + match self { + Self::GitHubActions => "GitHub Actions", + Self::GitLabCi => "GitLab CI", + Self::CircleCi => "CircleCI", + } + } +} + +/// Runner type relevant to provenance-capable trusted publishing. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum TrustedPublishRunnerEnvironment { + GitHubHosted, + SelfHosted, + #[default] + Unknown, +} + +/// Detected CI/trusted-publishing context for the current process. +/// +/// This structure is intentionally small and serializable-by-thought: it contains only the bits of +/// environment metadata the release flow needs for policy checks, user-facing summaries, and +/// provenance defaults. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct TrustedPublishContext { + /// CI provider inferred from process environment. + pub provider: Option, + /// Whether the current job is running on infrastructure compatible with trusted publishing. + pub runner_environment: TrustedPublishRunnerEnvironment, + /// Repository slug exposed by the CI provider. + pub repository: Option, + /// Human-readable workflow/job name. + pub workflow_name: Option, + /// Provider-specific workflow reference used to reconstruct the workflow file path. + pub workflow_ref: Option, +} + +impl TrustedPublishContext { + /// Detects the current trusted-publishing context from process environment variables. + #[must_use] + fn detect() -> Self { + Self::from_env(|key| std::env::var(key).ok()) + } + + /// Environment-backed constructor used by production code and unit tests alike. + #[must_use] + fn from_env(mut get: F) -> Self + where + F: FnMut(&str) -> Option, + { + let github_actions = get("GITHUB_ACTIONS"); + if github_actions.as_deref() == Some("true") { + return Self { + provider: Some(TrustedPublishProvider::GitHubActions), + runner_environment: match get("RUNNER_ENVIRONMENT").as_deref() { + Some("github-hosted") => TrustedPublishRunnerEnvironment::GitHubHosted, + Some("self-hosted") => TrustedPublishRunnerEnvironment::SelfHosted, + _ => TrustedPublishRunnerEnvironment::Unknown, + }, + repository: get("GITHUB_REPOSITORY"), + workflow_name: get("GITHUB_WORKFLOW"), + workflow_ref: get("GITHUB_WORKFLOW_REF"), + }; + } + + if get("GITLAB_CI").as_deref() == Some("true") { + return Self { provider: Some(TrustedPublishProvider::GitLabCi), ..Self::default() }; + } + + if get("CIRCLECI").as_deref() == Some("true") { + return Self { provider: Some(TrustedPublishProvider::CircleCi), ..Self::default() }; + } + + Self::default() + } + + /// Returns whether the current environment is eligible for npm trusted publishing at all. + #[must_use] + const fn supports_trusted_publishing(&self) -> bool { + match self.provider { + Some(TrustedPublishProvider::GitHubActions) => { + !matches!(self.runner_environment, TrustedPublishRunnerEnvironment::SelfHosted) + } + Some(TrustedPublishProvider::GitLabCi | TrustedPublishProvider::CircleCi) => true, + None => false, + } + } + + /// Returns whether the current environment can emit npm provenance attestations. + /// + /// The hardened release policy is stricter than baseline trusted-publishing support: it + /// requires provenance-capable infrastructure, not just OIDC-based auth. + #[must_use] + const fn supports_publish_provenance(&self) -> bool { + match self.provider { + Some(TrustedPublishProvider::GitHubActions) => { + matches!(self.runner_environment, TrustedPublishRunnerEnvironment::GitHubHosted) + } + Some(TrustedPublishProvider::GitLabCi) => true, + Some(TrustedPublishProvider::CircleCi) | None => false, + } + } + + /// Human-readable environment label used in release summaries and validation errors. + #[must_use] + fn environment_summary(&self) -> String { + match self.provider { + Some(TrustedPublishProvider::GitHubActions) => { + let mut summary = String::from("GitHub Actions"); + match self.runner_environment { + TrustedPublishRunnerEnvironment::GitHubHosted => { + summary.push_str(" on a GitHub-hosted runner"); + } + TrustedPublishRunnerEnvironment::SelfHosted => { + summary.push_str(" on a self-hosted runner"); + } + TrustedPublishRunnerEnvironment::Unknown => { + summary.push_str(" (runner environment unavailable)"); + } + } + summary + } + Some(provider) => { + let mut summary = String::from(provider.as_str()); + summary.push_str(" (trusted publish eligible environment detected)"); + summary + } + None => String::from("not detected (local shell or unsupported CI provider)"), + } + } + + /// Best-effort reconstruction of the workflow file path from provider metadata. + #[must_use] + fn workflow_path(&self) -> Option { + let workflow_ref = self.workflow_ref.as_deref()?; + let (_, suffix) = workflow_ref.split_once("/.github/workflows/")?; + let (filename, _) = suffix.split_once('@')?; + let mut path = String::from(".github/workflows/"); + path.push_str(filename); + Some(path) + } +} + +/// Trusted-publishing-specific readiness details folded into the broader release report. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct TrustedPublishReadiness { + /// Detected execution context. + pub context: TrustedPublishContext, + /// Packages that explicitly disable provenance and therefore violate hardened release policy. + pub packages_with_provenance_disabled: Vec, + /// Whether the operator supplied a legacy TOTP code. + pub uses_legacy_otp: bool, +} + +/// Compact count of local release artifacts touched by the current plan. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ReleaseArtifactSummary { + pub manifest_file_count: usize, + pub changelog_file_count: usize, +} + +impl ReleaseArtifactSummary { + #[must_use] + const fn total_file_count(self) -> usize { + self.manifest_file_count + self.changelog_file_count + } +} + +/// Result of dry-run publish simulation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DryRunPublishStatus { + SkippedByOption, + SkippedDirtyWorktree, + Failed, + Succeeded, +} + +const DEFAULT_RELEASE_CHECK_SCRIPTS: [&str; 5] = + ["build", "pack", "prepack", "prepublishOnly", "prepare"]; + +type WorkspacePackageGraph = + petgraph::graph::DiGraph; + +fn push_display(buffer: &mut String, value: T) +where + T: fmt::Display, +{ + let _ = write!(buffer, "{value}"); +} + +fn push_joined<'a, I>(buffer: &mut String, values: I, separator: &str) +where + I: IntoIterator, +{ + let mut first = true; + for value in values { + if !first { + buffer.push_str(separator); + } + first = false; + buffer.push_str(value); + } +} + +/// Rejects option combinations that would produce ambiguous or unsafe release boundaries. +fn validate_release_options(options: &ReleaseOptions) -> Result<(), Error> { + if let Some(version) = options.version.as_deref() { + Version::parse(version).map_err(|error| { + let mut message = String::from("Invalid `--version` value '"); + message.push_str(version); + message.push_str("': "); + push_display(&mut message, error); + Error::UserMessage(message.into()) + })?; + } + if options.version.is_some() && options.preid.is_some() { + return Err(Error::UserMessage( + "`vp release --version` cannot be combined with `--preid` because the exact target version already determines the prerelease channel." + .into(), + )); + } + + if options.git_tag && !options.git_commit && !options.dry_run { + return Err(Error::UserMessage( + "`vp release --no-git-commit --git-tag` is not supported because tags would not point to the release changes." + .into(), + )); + } + + if !options.git_tag && !options.dry_run { + return Err(Error::UserMessage( + "`vp release --no-git-tag` is only supported with `--dry-run` because git tags are the release watermark used to avoid re-releasing the same commits." + .into(), + )); + } + + if options.skip_publish && !options.dry_run { + return Err(Error::UserMessage( + "`vp release --skip-publish` is only supported with `--dry-run` because vite-plus treats a successful publish plus git tags as the release boundary." + .into(), + )); + } + + Ok(()) +} + +/// Rejects real publishes that do not meet the hardened trusted-publishing requirements. +fn validate_trusted_publish_context( + options: &ReleaseOptions, + context: &TrustedPublishContext, +) -> Result<(), Error> { + if options.dry_run { + return Ok(()); + } + + if context.supports_publish_provenance() { + return Ok(()); + } + + let mut message = String::from( + "Real `vp release` publishes are intended to run from npm trusted-publishing CI, but this environment is ", + ); + message.push_str(&context.environment_summary()); + message.push_str( + ". Run `vp release --dry-run` locally to verify the plan, then rerun the real publish from CI with OIDC (`id-token: write`) and provenance enabled.", + ); + message.push_str( + " If you need an interactive maintainer fallback outside CI, prefer npm passkey/security-key auth over long-lived tokens or `--otp`.", + ); + + if matches!(context.provider, Some(TrustedPublishProvider::GitHubActions)) + && matches!(context.runner_environment, TrustedPublishRunnerEnvironment::SelfHosted) + { + message.push_str( + " npm trusted publishing does not support GitHub Actions self-hosted runners.", + ); + } + if matches!(context.provider, Some(TrustedPublishProvider::CircleCi)) { + message.push_str( + " CircleCI trusted publishing does not currently generate npm provenance attestations, so this hardened release flow rejects it.", + ); + } + + Err(Error::UserMessage(message.into())) +} + +/// Resolves the effective dist-tag for a package release. +fn resolved_publish_tag<'a>( + plan: &'a PackageReleasePlan, + options: &'a ReleaseOptions, +) -> Option<&'a str> { + options + .preid + .as_deref() + .or_else(|| prerelease_channel(&plan.next_version)) + .or(plan.publish_tag.as_deref()) +} + +/// Resolves the effective provenance preference for a package release. +/// +/// Package-level opt-outs win, otherwise provenance defaults to `true` when the runtime context +/// is capable of generating trustworthy attestations. +fn resolved_publish_provenance( + plan: &PackageReleasePlan, + context: &TrustedPublishContext, +) -> Option { + plan.publish_provenance.or_else(|| context.supports_publish_provenance().then_some(true)) +} + +mod first_publish; +mod manager; +mod planning; +mod protocols; +mod reporting; +mod storage; +#[cfg(test)] +mod tests; + +use self::{ + first_publish::*, manager::execute_release, planning::*, protocols::*, reporting::*, storage::*, +}; +use super::{build_package_manager, prepend_js_runtime_to_path_env}; + +pub async fn execute(cwd: AbsolutePathBuf, options: ReleaseOptions) -> Result { + execute_release(cwd, options).await +} diff --git a/crates/vite_global_cli/src/commands/release/planning.rs b/crates/vite_global_cli/src/commands/release/planning.rs new file mode 100644 index 0000000000..985f7fda69 --- /dev/null +++ b/crates/vite_global_cli/src/commands/release/planning.rs @@ -0,0 +1,642 @@ +//! Release planning, git-history inspection, and tag naming. +//! +//! This module turns discovered workspace packages into deterministic release plans. Its main +//! jobs are to: +//! +//! - normalize historical package identity across renames and moves +//! - collect conventional commits since the previous release watermark +//! - infer semantic version bumps and prerelease progression +//! - preserve a stable package order that respects both dependencies and user selection order +//! +//! It may read git state, but it intentionally does not mutate the worktree or print summaries. + +use std::{ + cmp::{Ordering, Reverse}, + collections::BinaryHeap, +}; + +use super::*; + +const GIT_LOG_FIELD_SEPARATOR: char = '\u{001f}'; +const GIT_LOG_RECORD_SEPARATOR: char = '\u{001e}'; +const GIT_LOG_FORMAT: &str = "--format=%H%x1f%s%x1f%b%x1e"; +const GIT_LOG_PART_COUNT: usize = 3; +const SHORT_COMMIT_HASH_LEN: usize = 7; +const REPOSITORY_RELEASE_TAG_PREFIX: &str = "v"; + +/// Encodes the git tag layout used as the durable release watermark in this repository. +#[derive(Debug, Clone, Copy)] +struct ReleaseTagFormat { + namespace: &'static str, + version_prefix: &'static str, +} + +impl ReleaseTagFormat { + fn parse_components(self, tag_name: &str) -> Option<(&str, &str)> { + let trimmed = tag_name.strip_prefix(self.namespace)?; + let (package_path, version) = trimmed.rsplit_once(self.version_prefix)?; + if package_path.is_empty() || version.is_empty() { + return None; + } + + Some((package_path, version)) + } + + fn git_tag_list_args(self, package_names: &[String]) -> Vec { + let mut args = Vec::with_capacity(package_names.len() + 3); + args.push(String::from("tag")); + args.push(String::from("--list")); + args.push(String::from("--sort=-creatordate")); + for package_name in package_names { + args.push(self.package_tag_pattern(package_name)); + } + args + } + + fn package_tag_pattern(self, package_name: &str) -> String { + let sanitized = self.sanitize_package_name(package_name); + let mut pattern = String::with_capacity( + self.namespace.len() + sanitized.len() + self.version_prefix.len() + 1, + ); + pattern.push_str(self.namespace); + pattern.push_str(sanitized); + pattern.push_str(self.version_prefix); + pattern.push('*'); + pattern + } + + fn all_tags_pattern(self) -> String { + let mut pattern = + String::with_capacity(self.namespace.len() + self.version_prefix.len() + 2); + pattern.push_str(self.namespace); + pattern.push('*'); + pattern.push_str(self.version_prefix); + pattern.push('*'); + pattern + } + + fn format_tag(self, package_name: &str, version: &Version) -> String { + let sanitized = self.sanitize_package_name(package_name); + let mut tag = String::with_capacity( + self.namespace.len() + sanitized.len() + self.version_prefix.len() + 12, + ); + tag.push_str(self.namespace); + tag.push_str(sanitized); + tag.push_str(self.version_prefix); + push_display(&mut tag, version); + tag + } + + fn parse_version(self, tag_name: &str) -> Option { + let (_, version) = self.parse_components(tag_name)?; + Version::parse(version).ok() + } + + fn parse_package_name(self, tag_name: &str) -> Option { + let (package_path, version) = self.parse_components(tag_name)?; + Version::parse(version).ok()?; + + let mut segments = package_path.split('/'); + let first = segments.next()?; + let second = segments.next(); + if first.is_empty() { + return None; + } + + match second { + None => Some(first.to_owned()), + Some(second) if !second.is_empty() && segments.next().is_none() => { + let mut package_name = String::with_capacity(package_path.len() + 1); + package_name.push('@'); + package_name.push_str(package_path); + Some(package_name) + } + _ => None, + } + } + + fn sanitize_package_name(self, package_name: &str) -> &str { + package_name.trim_start_matches('@') + } +} + +const RELEASE_TAG_FORMAT: ReleaseTagFormat = + ReleaseTagFormat { namespace: "release/", version_prefix: "/v" }; + +/// Priority-queue entry used to produce a stable topological ordering for selected packages. +#[derive(Debug, Clone, Eq, PartialEq)] +struct ReleaseQueueEntry { + selection_order: usize, + name: String, + node: PackageNodeIndex, +} + +impl Ord for ReleaseQueueEntry { + fn cmp(&self, other: &Self) -> Ordering { + self.selection_order + .cmp(&other.selection_order) + .then_with(|| self.name.cmp(&other.name)) + .then_with(|| self.node.index().cmp(&other.node.index())) + } +} + +impl PartialOrd for ReleaseQueueEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +pub(super) fn load_workspace_packages( + package_graph: &WorkspacePackageGraph, +) -> Result, Error> { + let mut packages = Vec::new(); + + for node in package_graph.node_indices() { + let package = &package_graph[node]; + if package.path.as_str().is_empty() { + continue; + } + + let manifest_path = package.absolute_path.join("package.json"); + let document = read_package_manifest(&manifest_path)?; + let vite_shared::PackageManifestDocument { contents: manifest_contents, manifest } = + document; + if manifest.private { + continue; + } + + let known_names = unique_strings( + std::iter::once(manifest.name.clone()) + .chain(manifest.vite_plus.release.previous_names.iter().cloned()), + ); + let retired_names = + unique_strings(manifest.vite_plus.release.retired_names.iter().cloned()); + let release_paths = unique_strings( + std::iter::once(package.path.as_str().to_owned()) + .chain(manifest.vite_plus.release.tracked_paths.iter().cloned()) + .chain(manifest.vite_plus.release.previous_paths.iter().cloned()), + ); + + packages.push(WorkspacePackage { + node, + name: manifest.name.clone(), + known_names, + retired_names, + release_paths, + selection_order: usize::MAX, + manifest_path, + package_path: package.absolute_path.to_absolute_path_buf(), + manifest_contents, + manifest, + }); + } + + Ok(packages) +} + +pub(super) fn select_workspace_packages( + packages: &[WorkspacePackage], + projects: Option<&[String]>, +) -> Result, Error> { + let Some(projects) = projects else { + return Ok(packages.to_vec()); + }; + + let patterns: Vec = projects + .iter() + .map(|pattern| { + Pattern::new(pattern).map_err(|e| { + let mut message = String::from("Invalid project pattern '"); + message.push_str(pattern); + message.push_str("': "); + push_display(&mut message, e); + Error::UserMessage(message.into()) + }) + }) + .collect::>()?; + + let mut matched_patterns = vec![false; patterns.len()]; + let mut selected = Vec::new(); + + for package in packages { + let mut match_order: Option = None; + for (index, pattern) in patterns.iter().enumerate() { + if package.known_names.iter().any(|name| pattern.matches(name)) { + matched_patterns[index] = true; + match_order = Some(match_order.map_or(index, |current| current.min(index))); + } + } + if let Some(selection_order) = match_order { + let mut package = package.clone(); + package.selection_order = selection_order; + selected.push(package); + } + } + + for (index, matched) in matched_patterns.into_iter().enumerate() { + if !matched { + let mut message = String::from("No publishable packages matched '"); + message.push_str(&projects[index]); + message.push('\''); + output::warn(&message); + } + } + + Ok(selected) +} + +pub(super) fn topological_sort_selected_packages( + package_graph: &WorkspacePackageGraph, + selected: &[WorkspacePackage], +) -> Vec { + let selected_set: HashSet = + selected.iter().map(|package| package.node).collect(); + let by_node: HashMap = + selected.iter().cloned().map(|package| (package.node, package)).collect(); + let mut pending_dependencies: HashMap = + selected.iter().map(|package| (package.node, 0usize)).collect(); + let mut dependents: HashMap> = + selected.iter().map(|package| (package.node, Vec::new())).collect(); + + for edge in package_graph.edge_references() { + let source = edge.source(); + let target = edge.target(); + if selected_set.contains(&source) && selected_set.contains(&target) { + *pending_dependencies + .get_mut(&source) + .expect("selected package should have dependency counter") += 1; + dependents + .get_mut(&target) + .expect("selected package should have dependent list") + .push(source); + } + } + + let mut available = BinaryHeap::new(); + for (&node, &count) in &pending_dependencies { + if count == 0 { + let package = by_node.get(&node).expect("selected package should exist"); + available.push(Reverse(release_queue_entry(package))); + } + } + + let mut scheduled = HashSet::with_capacity(selected.len()); + let mut ordered_nodes = Vec::with_capacity(selected.len()); + + while ordered_nodes.len() < selected.len() { + let next = available + .pop() + .map(|Reverse(entry)| entry.node) + .unwrap_or_else(|| select_cycle_breaker(selected, &scheduled)); + + if !scheduled.insert(next) { + continue; + } + ordered_nodes.push(next); + + if let Some(node_dependents) = dependents.get(&next) { + for dependent in node_dependents { + if let Some(count) = pending_dependencies.get_mut(dependent) { + if *count == 0 { + continue; + } + + *count -= 1; + if *count == 0 && !scheduled.contains(dependent) { + let package = + by_node.get(dependent).expect("selected package should exist"); + available.push(Reverse(release_queue_entry(package))); + } + } + } + } + } + + ordered_nodes.into_iter().filter_map(|node| by_node.get(&node).cloned()).collect() +} + +pub(super) fn find_latest_package_tag( + cwd: &AbsolutePath, + package_names: &[String], +) -> Result, Error> { + let stdout = capture_git(cwd, RELEASE_TAG_FORMAT.git_tag_list_args(package_names))?; + Ok(stdout.lines().map(str::trim).find(|line| !line.is_empty()).map(ToOwned::to_owned)) +} + +pub(super) fn find_latest_stable_package_version( + cwd: &AbsolutePath, + package_names: &[String], +) -> Result, Error> { + let stdout = capture_git(cwd, RELEASE_TAG_FORMAT.git_tag_list_args(package_names))?; + Ok(stdout + .lines() + .map(str::trim) + .filter_map(|tag_name| RELEASE_TAG_FORMAT.parse_version(tag_name)) + .find(|version| !version.has_prerelease())) +} + +pub(super) fn find_latest_package_version( + cwd: &AbsolutePath, + package_names: &[String], +) -> Result, Error> { + let stdout = capture_git(cwd, RELEASE_TAG_FORMAT.git_tag_list_args(package_names))?; + Ok(stdout + .lines() + .map(str::trim) + .filter_map(|tag_name| RELEASE_TAG_FORMAT.parse_version(tag_name)) + .next()) +} + +pub(super) fn find_latest_repository_release_tag( + cwd: &AbsolutePath, +) -> Result, Error> { + let stdout = capture_git(cwd, ["tag", "--list", "--sort=-creatordate", "v*"])?; + Ok(stdout + .lines() + .map(str::trim) + .find(|tag_name| parse_repository_release_version(tag_name).is_some()) + .map(ToOwned::to_owned)) +} + +pub(super) fn find_latest_repository_release_version( + cwd: &AbsolutePath, +) -> Result, Error> { + let stdout = capture_git(cwd, ["tag", "--list", "--sort=-creatordate", "v*"])?; + Ok(stdout.lines().map(str::trim).filter_map(parse_repository_release_version).next()) +} + +pub(super) fn find_latest_repository_stable_release_version( + cwd: &AbsolutePath, +) -> Result, Error> { + let stdout = capture_git(cwd, ["tag", "--list", "--sort=-creatordate", "v*"])?; + Ok(stdout + .lines() + .map(str::trim) + .filter_map(parse_repository_release_version) + .find(|version| !version.has_prerelease())) +} + +pub(super) fn collect_package_commits( + cwd: &AbsolutePath, + package_paths: &[String], + since_tag: Option<&str>, +) -> Result, Error> { + let mut args = Vec::with_capacity(package_paths.len() + 5); + args.push(String::from("log")); + args.push(String::from("--reverse")); + args.push(String::from(GIT_LOG_FORMAT)); + if let Some(tag) = since_tag { + let mut range = String::with_capacity(tag.len() + 6); + range.push_str(tag); + range.push_str("..HEAD"); + args.push(range); + } + args.push(String::from("--")); + for package_path in package_paths { + args.push(package_path.clone()); + } + + let stdout = capture_git(cwd, args)?; + let mut commits = Vec::new(); + + for record in stdout.split(GIT_LOG_RECORD_SEPARATOR) { + let trimmed = record.trim(); + if trimmed.is_empty() { + continue; + } + + let mut parts = trimmed.splitn(GIT_LOG_PART_COUNT, GIT_LOG_FIELD_SEPARATOR); + let hash = parts.next().unwrap_or_default().trim(); + let subject = parts.next().unwrap_or_default().trim(); + let body = parts.next().unwrap_or_default().trim(); + if hash.is_empty() || subject.is_empty() { + continue; + } + + if let Some(level) = classify_commit(subject, body) { + commits.push(CommitInfo { + hash: hash.to_owned(), + short_hash: hash.get(..SHORT_COMMIT_HASH_LEN).unwrap_or(hash).to_owned(), + subject: subject.to_owned(), + level, + }); + } + } + + Ok(commits) +} + +pub(super) fn classify_commit(subject: &str, body: &str) -> Option { + let commit = parse_conventional_commit(subject, body)?; + if commit.breaking { + return Some(VersionBump::Major); + } + + match commit.kind { + "feat" => Some(VersionBump::Minor), + "fix" | "perf" | "refactor" | "revert" => Some(VersionBump::Patch), + _ => None, + } +} + +pub(super) fn highest_release_level(commits: &[CommitInfo]) -> Option { + commits.iter().map(|commit| commit.level).max() +} + +pub(super) fn effective_release_level(current: &Version, level: VersionBump) -> VersionBump { + // Conventional Commits marks breaking changes as MAJOR, but SemVer treats `0.y.z` as + // initial development where API compatibility can still move on the minor line. + // https://www.conventionalcommits.org/en/v1.0.0/#specification + // https://semver.org/#faq + if current.major == 0 && level == VersionBump::Major { VersionBump::Minor } else { level } +} + +pub(super) fn next_release_version( + current: &Version, + level: VersionBump, + stable_baseline: Option<&Version>, + prerelease_tag: Option<&str>, +) -> Result { + if !level.is_version_bump() { + let mut message = String::from("Invalid release level '"); + message.push_str(level.as_str()); + message.push_str("' for version bump calculation."); + return Err(Error::UserMessage(message.into())); + } + + let current_base = strip_prerelease(current); + let target_base = if !current.has_prerelease() { + bump_version(¤t_base, level) + } else { + let baseline = stable_baseline.cloned().unwrap_or_else(|| Version::new(0, 0, 0)); + match release_line_level(&baseline, ¤t_base) { + Some(existing_line) if level <= existing_line => current_base.clone(), + _ => bump_version(¤t_base, level), + } + }; + + let mut next = target_base.clone(); + next.clear_build(); + + let prerelease_tag = prerelease_tag.map(PrereleaseTag::parse); + + if let Some(prerelease_tag) = prerelease_tag.as_ref() { + let prerelease_number = if target_base == current_base + && prerelease_channel(current) == Some(prerelease_tag.as_str()) + { + prerelease_number(current).map_or(0, |number| number + 1) + } else { + 0 + }; + next.set_prerelease(Some(prerelease_with_number(prerelease_tag, prerelease_number)?)); + } else { + next.set_prerelease(None); + } + + Ok(next) +} + +pub(super) fn package_tag_name(package_name: &str, version: &Version) -> String { + RELEASE_TAG_FORMAT.format_tag(package_name, version) +} + +pub(super) fn repository_tag_name(version: &Version) -> String { + let mut tag = String::with_capacity(REPOSITORY_RELEASE_TAG_PREFIX.len() + 12); + tag.push_str(REPOSITORY_RELEASE_TAG_PREFIX); + push_display(&mut tag, version); + tag +} + +pub(super) fn shared_repository_release_tag( + release_plans: &[PackageReleasePlan], +) -> Option { + let version = &release_plans.first()?.next_version; + release_plans + .iter() + .all(|plan| plan.next_version == *version) + .then(|| repository_tag_name(version)) +} + +pub(super) fn collect_orphaned_released_packages( + cwd: &AbsolutePath, + packages: &[WorkspacePackage], +) -> Result, Error> { + let stdout = capture_git(cwd, ["tag", "--list", &RELEASE_TAG_FORMAT.all_tags_pattern()])?; + let known_names: HashSet<&str> = packages + .iter() + .flat_map(|package| { + package + .known_names + .iter() + .map(String::as_str) + .chain(package.retired_names.iter().map(String::as_str)) + }) + .collect(); + + Ok(collect_orphaned_released_package_names( + stdout.lines().map(str::trim).filter(|tag| !tag.is_empty()), + &known_names, + )) +} + +pub(super) fn unique_strings(values: I) -> Vec +where + I: IntoIterator, +{ + let iter = values.into_iter(); + let (lower, _) = iter.size_hint(); + let mut seen = HashSet::with_capacity(lower); + let mut ordered = Vec::with_capacity(lower); + for value in iter { + if seen.insert(value.clone()) { + ordered.push(value); + } + } + ordered +} + +pub(super) fn collect_orphaned_released_package_names<'a, I>( + tags: I, + known_names: &HashSet<&str>, +) -> Vec +where + I: IntoIterator, +{ + let mut orphaned = HashSet::new(); + for tag in tags { + if let Some(package_name) = RELEASE_TAG_FORMAT.parse_package_name(tag) + && !known_names.contains(package_name.as_str()) + { + orphaned.insert(package_name); + } + } + + let mut orphaned = orphaned.into_iter().collect::>(); + orphaned.sort(); + orphaned +} + +#[cfg(test)] +pub(super) fn parse_package_name_from_release_tag(tag_name: &str) -> Option { + RELEASE_TAG_FORMAT.parse_package_name(tag_name) +} + +#[cfg(test)] +pub(super) fn parse_repository_release_tag_version_for_tests(tag_name: &str) -> Option { + parse_repository_release_version(tag_name) +} + +fn release_line_level(stable_baseline: &Version, target_base: &Version) -> Option { + if target_base.major > stable_baseline.major { + Some(VersionBump::Major) + } else if target_base.minor > stable_baseline.minor { + Some(VersionBump::Minor) + } else if target_base.patch > stable_baseline.patch { + Some(VersionBump::Patch) + } else { + None + } +} + +fn prerelease_with_number(prerelease_tag: &PrereleaseTag, number: u64) -> Result { + build_prerelease(prerelease_tag.as_str(), number).map_err(|e| { + let mut message = String::from("Invalid prerelease identifier '"); + message.push_str(prerelease_tag.as_str()); + message.push_str("': "); + push_display(&mut message, e); + Error::UserMessage(message.into()) + }) +} + +fn parse_repository_release_version(tag_name: &str) -> Option { + let version = Version::parse(tag_name.strip_prefix(REPOSITORY_RELEASE_TAG_PREFIX)?).ok()?; + if version.has_prerelease() && prerelease_number(&version).is_none() { + return None; + } + + Some(version) +} + +fn release_queue_entry(package: &WorkspacePackage) -> ReleaseQueueEntry { + ReleaseQueueEntry { + selection_order: package.selection_order, + name: package.name.clone(), + node: package.node, + } +} + +fn select_cycle_breaker( + selected: &[WorkspacePackage], + scheduled: &HashSet, +) -> PackageNodeIndex { + selected + .iter() + .filter(|package| !scheduled.contains(&package.node)) + .min_by(|left, right| compare_release_priority(left, right)) + .map(|package| package.node) + .expect("there should be an unscheduled package when ordering is incomplete") +} + +fn compare_release_priority(left: &WorkspacePackage, right: &WorkspacePackage) -> Ordering { + left.selection_order.cmp(&right.selection_order).then_with(|| left.name.cmp(&right.name)) +} diff --git a/crates/vite_global_cli/src/commands/release/protocols.rs b/crates/vite_global_cli/src/commands/release/protocols.rs new file mode 100644 index 0000000000..1aaedcb16c --- /dev/null +++ b/crates/vite_global_cli/src/commands/release/protocols.rs @@ -0,0 +1,56 @@ +//! Publish-protocol compatibility checks. +//! +//! Some dependency protocols are safe to keep in a monorepo but unsafe to ship to the registry +//! unless the selected package manager rewrites them during publish. This module centralizes that +//! compatibility matrix so the release flow can reject unsafe combinations before any publish +//! attempt begins. + +use super::*; + +/// Returns dependency protocols that are unsafe for the selected publisher to ship as-is. +pub(super) fn unsupported_publish_protocols( + package_manager: &PackageManager, + summary: DependencyProtocolSummary, +) -> Vec<&'static str> { + // Publish-time protocol rewriting differs across package managers, so release stays + // conservative and only allows protocols that the selected native publisher documents. + // npm workspaces: https://docs.npmjs.com/cli/v11/using-npm/workspaces/ + // pnpm workspaces/catalogs: https://pnpm.io/workspaces / https://pnpm.io/catalogs + // Yarn workspace protocol: https://yarnpkg.com/protocol/workspace + // Bun workspaces/catalogs: https://bun.sh/docs/pm/workspaces / https://bun.sh/docs/pm/catalogs + let mut protocols = Vec::new(); + + if summary.workspace && !supports_publish_rewrite(package_manager) { + protocols.push("workspace:"); + } + if summary.catalog && !supports_publish_rewrite(package_manager) { + protocols.push("catalog:"); + } + if summary.file { + protocols.push("file:"); + } + if summary.link { + protocols.push("link:"); + } + if summary.portal { + protocols.push("portal:"); + } + if summary.patch { + protocols.push("patch:"); + } + if summary.jsr { + protocols.push("jsr:"); + } + + protocols +} + +/// Returns whether the package manager is known to rewrite workspace/catalog-style references at +/// publish time. +fn supports_publish_rewrite(package_manager: &PackageManager) -> bool { + match package_manager.client { + PackageManagerType::Pnpm | PackageManagerType::Bun => true, + PackageManagerType::Yarn => !package_manager.version.starts_with("1."), + PackageManagerType::Npm => false, + } +} diff --git a/crates/vite_global_cli/src/commands/release/reporting.rs b/crates/vite_global_cli/src/commands/release/reporting.rs new file mode 100644 index 0000000000..23c8be71b6 --- /dev/null +++ b/crates/vite_global_cli/src/commands/release/reporting.rs @@ -0,0 +1,627 @@ +//! User-facing release reports, summaries, and prompts. +//! +//! This module is responsible for taking already-computed release state and turning it into +//! operator-facing output: +//! +//! - planned release listings +//! - readiness/security summaries +//! - dry-run action previews +//! - completion summaries and confirmation prompts +//! +//! Keeping reporting here helps the rest of the release flow stay focused on planning, +//! validation, and mutation rather than line-by-line terminal formatting. +//! +//! The tiny macros below act as a purpose-built output DSL. They keep indentation and +//! key/value separators declarative without paying the readability cost of hand-assembling +//! `" ..."` / `" - ..."` strings at every callsite. + +use super::*; + +const ITEM_INDENT: &str = " "; +const DETAIL_INDENT: &str = " "; +const BULLET_INDENT: &str = " - "; +const KEY_VALUE_SEPARATOR: &str = ": "; +const LIST_SEPARATOR: &str = ", "; + +macro_rules! info_section { + ($heading:expr $(,)?) => {{ + output::raw(""); + output::info($heading); + }}; +} + +macro_rules! raw_prefixed_line { + ($prefix:expr, $($part:expr),+ $(,)?) => {{ + let mut line = String::from($prefix); + $(push_display(&mut line, $part);)+ + output::raw(&line); + }}; +} + +macro_rules! raw_item_line { + ($($part:expr),+ $(,)?) => { + raw_prefixed_line!(ITEM_INDENT, $($part),+); + }; +} + +macro_rules! raw_detail_line { + ($($part:expr),+ $(,)?) => { + raw_prefixed_line!(DETAIL_INDENT, $($part),+); + }; +} + +macro_rules! raw_bullet_line { + ($($part:expr),+ $(,)?) => { + raw_prefixed_line!(BULLET_INDENT, $($part),+); + }; +} + +macro_rules! raw_item_kv { + ($label:expr, $value:expr $(,)?) => { + raw_item_line!($label, KEY_VALUE_SEPARATOR, $value); + }; +} + +macro_rules! raw_detail_kv { + ($label:expr, $value:expr $(,)?) => { + raw_detail_line!($label, KEY_VALUE_SEPARATOR, $value); + }; +} + +macro_rules! raw_joined_kv_line { + ($prefix:expr, $label:expr, $values:expr $(,)?) => {{ + let mut line = String::from($prefix); + line.push_str($label); + line.push_str(KEY_VALUE_SEPARATOR); + push_joined(&mut line, $values, LIST_SEPARATOR); + output::raw(&line); + }}; +} + +macro_rules! raw_item_joined_kv { + ($label:expr, $values:expr $(,)?) => { + raw_joined_kv_line!(ITEM_INDENT, $label, $values); + }; +} + +macro_rules! raw_detail_joined_kv { + ($label:expr, $values:expr $(,)?) => { + raw_joined_kv_line!(DETAIL_INDENT, $label, $values); + }; +} + +macro_rules! raw_bullet_joined_kv { + ($label:expr, $values:expr $(,)?) => { + raw_joined_kv_line!(BULLET_INDENT, $label, $values); + }; +} + +/// Prints the high-level version plan for all selected packages. +pub(super) fn print_release_plan(release_plans: &[PackageReleasePlan], options: &ReleaseOptions) { + output::info("Planned releases:"); + for plan in release_plans { + raw_item_line!( + &plan.name, + ' ', + &plan.current_version, + " -> ", + &plan.next_version, + " (", + plan.level.as_str(), + ')', + ); + if plan.known_names.len() > 1 { + raw_detail_joined_kv!( + "previous names", + plan.known_names[1..].iter().map(String::as_str), + ); + } + if !plan.retired_names.is_empty() { + raw_detail_joined_kv!( + "retired package names", + plan.retired_names.iter().map(String::as_str), + ); + } + } + + if options.dry_run { + output::note("Dry run enabled; no files will be changed."); + } + if !options.changelog { + output::note("Changelog generation is disabled for this run."); + } + if let Some(version) = options.version.as_deref() { + let mut line = String::from("Target version overridden with --version "); + line.push_str(version); + line.push('.'); + output::note(&line); + } + if !options.dry_run && options.skip_publish { + output::note("Publishing disabled for this run."); + } + if options.run_checks { + output::note("Release checks will run before publish."); + } else { + output::note("Release checks are disabled for this run."); + } +} + +/// Builds the readiness report shown before confirmation or dry-run completion. +/// +/// The report intentionally mixes ergonomics and policy: +/// +/// - script discovery helps maintainers understand what checks exist +/// - trusted-publishing details explain what environment was detected +/// - warnings surface conditions that would weaken or block a hardened release +pub(super) fn collect_release_readiness_report( + workspace_manifest: Option<&PackageManifest>, + release_plans: &[PackageReleasePlan], + options: &ReleaseOptions, + trusted_publish_context: &TrustedPublishContext, +) -> ReleaseReadinessReport { + let mut report = ReleaseReadinessReport::default(); + + if let Some(workspace_manifest) = workspace_manifest { + report.workspace_scripts = collect_available_release_scripts( + "Workspace root", + |candidate| workspace_manifest.has_script(candidate), + &workspace_manifest.vite_plus.release.check_scripts, + false, + &mut report.warnings, + ); + } + + for plan in release_plans { + let scripts = collect_available_release_scripts( + &plan.name, + |candidate| contains_script(&plan.scripts, candidate), + &plan.check_scripts, + true, + &mut report.warnings, + ); + if !scripts.is_empty() { + report.package_scripts.push(PackageReadiness { package: plan.name.clone(), scripts }); + } + } + + if report.workspace_scripts.is_empty() && report.package_scripts.is_empty() { + report.warnings.push( + "No explicit build / pack / prepack / prepublishOnly / prepare scripts or `vitePlus.release.checkScripts` were detected for this release.".into(), + ); + } + + report.trusted_publish = TrustedPublishReadiness { + context: trusted_publish_context.clone(), + packages_with_provenance_disabled: release_plans + .iter() + .filter(|plan| matches!(plan.publish_provenance, Some(false))) + .map(|plan| plan.name.clone()) + .collect(), + uses_legacy_otp: options.otp.is_some(), + }; + if !report.trusted_publish.context.supports_publish_provenance() { + report.warnings.push( + "This environment cannot produce the npm provenance attestations required by the hardened release policy. Use `vp release --dry-run` locally and publish from GitHub Actions on a GitHub-hosted runner or from GitLab CI shared runners." + .into(), + ); + } + if !report.trusted_publish.packages_with_provenance_disabled.is_empty() { + let mut warning = String::from( + "`publishConfig.provenance = false` is not allowed for hardened releases: ", + ); + push_joined( + &mut warning, + report.trusted_publish.packages_with_provenance_disabled.iter().map(String::as_str), + ", ", + ); + report.warnings.push(warning); + } + let packages_missing_repository: Vec<&str> = release_plans + .iter() + .filter(|plan| plan.repository_url.is_none()) + .map(|plan| plan.name.as_str()) + .collect(); + if !packages_missing_repository.is_empty() { + let mut warning = + String::from("Trusted publishing provenance requires `repository` metadata: "); + push_joined(&mut warning, packages_missing_repository.into_iter(), ", "); + report.warnings.push(warning); + } + + report +} + +/// Renders the readiness report in a compact terminal-friendly form. +pub(super) fn print_release_readiness_report(report: &ReleaseReadinessReport) { + info_section!("Pre-release readiness:"); + + if !report.workspace_scripts.is_empty() { + raw_item_joined_kv!( + "workspace scripts", + report.workspace_scripts.iter().map(String::as_str) + ); + } + + if report.package_scripts.is_empty() { + raw_item_kv!("package scripts", "none detected"); + } else { + raw_item_line!("package scripts:"); + for package in &report.package_scripts { + raw_bullet_joined_kv!(&package.package, package.scripts.iter().map(String::as_str)); + } + } + + raw_item_line!("trusted publishing:"); + raw_detail_kv!("environment", report.trusted_publish.context.environment_summary()); + if let Some(repository) = report.trusted_publish.context.repository.as_deref() { + raw_detail_kv!("repository", repository); + } + if let Some(workflow_name) = report.trusted_publish.context.workflow_name.as_deref() { + raw_detail_kv!("workflow", workflow_name); + } + if let Some(workflow_path) = report.trusted_publish.context.workflow_path() { + raw_detail_kv!("workflow file", workflow_path); + } + if report.trusted_publish.packages_with_provenance_disabled.is_empty() { + raw_detail_kv!( + "provenance", + if report.trusted_publish.context.supports_trusted_publishing() { + "enabled by default for real releases from trusted-publishing CI" + } else { + "will be enabled automatically when the same release runs from trusted-publishing CI" + }, + ); + } else { + let mut line = String::from(DETAIL_INDENT); + line.push_str("provenance"); + line.push_str(KEY_VALUE_SEPARATOR); + line.push_str("disabled via `publishConfig.provenance = false` for "); + push_joined( + &mut line, + report.trusted_publish.packages_with_provenance_disabled.iter().map(String::as_str), + LIST_SEPARATOR, + ); + output::raw(&line); + } + raw_detail_kv!( + "interactive fallback", + "prefer npm passkey/security-key auth; use `--otp` only for legacy TOTP fallback", + ); + + output::note(if report.workspace_scripts.is_empty() && report.package_scripts.is_empty() { + "No release checks were detected automatically for this run." + } else { + "Release checks can run from these detected scripts; real releases do so by default and dry-runs can opt in with `--run-checks`." + }); + output::note("Review this summary, then confirm to continue."); + if !report.trusted_publish.context.supports_trusted_publishing() { + output::note( + "Local dry-runs validate packaging and publish command shape, but OIDC auth and trusted-publishing provenance are only exercised from CI.", + ); + } + if matches!( + report.trusted_publish.context.provider, + Some(TrustedPublishProvider::GitHubActions) + ) && matches!( + report.trusted_publish.context.runner_environment, + TrustedPublishRunnerEnvironment::SelfHosted + ) { + output::warn( + "GitHub Actions self-hosted runners are not supported by npm trusted publishing.", + ); + } + if report.trusted_publish.uses_legacy_otp { + output::warn( + "`--otp` is a legacy TOTP path. Prefer trusted publishing first, then passkey/security-key auth for any interactive fallback.", + ); + } + + for warning in &report.warnings { + output::warn(warning); + } +} + +/// Prints the concrete actions a dry-run would perform. +pub(super) fn print_dry_run_actions( + release_plans: &[PackageReleasePlan], + repository_tag_name: Option<&str>, + package_manager: &PackageManager, + options: &ReleaseOptions, + artifact_summary: ReleaseArtifactSummary, + trusted_publish_context: &TrustedPublishContext, +) { + let commit_message = release_commit_message(release_plans); + let mut line = String::from("Would update "); + push_display(&mut line, artifact_summary.total_file_count()); + line.push_str(" release file(s) ("); + push_display(&mut line, artifact_summary.manifest_file_count); + line.push_str(" manifests"); + if artifact_summary.changelog_file_count > 0 { + line.push_str(", "); + push_display(&mut line, artifact_summary.changelog_file_count); + line.push_str(" changelogs"); + } + line.push(')'); + output::note(&line); + if !options.changelog { + output::note("Would skip changelog generation because --changelog was not provided."); + } + if options.git_commit { + let mut line = String::from("Would create release commit: "); + line.push_str(&commit_message); + output::note(&line); + } + if options.git_tag { + for plan in release_plans { + let mut line = String::from("Would create git tag "); + line.push_str(&plan.tag_name); + output::note(&line); + } + if let Some(repository_tag_name) = repository_tag_name { + let mut line = String::from("Would create repository git tag "); + line.push_str(repository_tag_name); + output::note(&line); + } + } + if options.yes { + output::note("Would skip the final confirmation because --yes was provided."); + } else { + output::note( + "Would print the release summary and ask for confirmation before changing files.", + ); + } + if options.run_checks { + output::note("Would run detected release checks before publish."); + } else { + output::note("Would skip release checks for this run."); + } + if options.skip_publish { + output::note("Would skip publishing because --skip-publish was provided."); + return; + } + + for plan in release_plans { + let publish_options = package_manager.resolve_publish_command(&PublishCommandOptions { + dry_run: true, + tag: resolved_publish_tag(plan, options), + access: plan.access.as_deref(), + otp: options.otp.as_deref(), + provenance: resolved_publish_provenance(plan, trusted_publish_context), + ..Default::default() + }); + let mut line = String::from("Would publish "); + line.push_str(&plan.name); + line.push('@'); + push_display(&mut line, &plan.next_version); + line.push_str(" with: "); + line.push_str(&publish_options.bin_path); + if !publish_options.args.is_empty() { + line.push(' '); + push_joined(&mut line, publish_options.args.iter().map(String::as_str), " "); + } + output::note(&line); + } +} + +/// Prints the final dry-run summary after optional publish preflight completes. +pub(super) fn print_dry_run_summary( + artifact_summary: ReleaseArtifactSummary, + publish_status: DryRunPublishStatus, + options: &ReleaseOptions, + package_count: usize, + repository_tag_name: Option<&str>, + trusted_publish_context: &TrustedPublishContext, +) { + info_section!("Dry-run summary:"); + + raw_item_kv!("packages planned", package_count); + + let mut line = String::from(ITEM_INDENT); + line.push_str("files covered"); + line.push_str(KEY_VALUE_SEPARATOR); + push_display(&mut line, artifact_summary.total_file_count()); + line.push_str(" total ("); + push_display(&mut line, artifact_summary.manifest_file_count); + line.push_str(" manifests"); + if artifact_summary.changelog_file_count > 0 { + line.push_str(", "); + push_display(&mut line, artifact_summary.changelog_file_count); + line.push_str(" changelogs"); + } + line.push(')'); + output::raw(&line); + + let mut line = String::from(ITEM_INDENT); + line.push_str("native publish check"); + line.push_str(KEY_VALUE_SEPARATOR); + line.push_str(match publish_status { + DryRunPublishStatus::SkippedByOption => "skipped by --skip-publish", + DryRunPublishStatus::SkippedDirtyWorktree => "skipped because the git worktree was dirty", + DryRunPublishStatus::Failed => "failed", + DryRunPublishStatus::Succeeded => "succeeded", + }); + output::raw(&line); + + raw_item_kv!("trusted publish target", trusted_publish_context.environment_summary(),); + raw_item_kv!("repository tag", repository_tag_name.unwrap_or("not planned")); + raw_item_kv!( + "interactive fallback auth", + "passkey/security-key preferred; `--otp` remains legacy-only", + ); + + raw_item_line!("rollback coverage:"); + raw_bullet_line!( + "temporary package.json rewrites are restored after publish checks and publish" + ); + raw_bullet_line!("final release files are applied transactionally before the release commit"); + if options.git_tag { + raw_bullet_line!("partially created local release tags are removed if tag creation fails"); + } + if !trusted_publish_context.supports_trusted_publishing() { + raw_bullet_line!( + "OIDC auth and provenance are not exercised in this local preview; rerun from CI for the full trusted-publish path" + ); + } +} + +/// Prints the final summary after a real release succeeds locally. +pub(super) fn print_release_completion_summary( + artifact_summary: ReleaseArtifactSummary, + package_count: usize, + commit_message: Option<&str>, + repository_tag_name: Option<&str>, + created_tag_count: usize, + trusted_publish_context: &TrustedPublishContext, +) { + info_section!("Release completion summary:"); + + raw_item_kv!("published packages", package_count); + + let mut line = String::from(ITEM_INDENT); + line.push_str("files updated"); + line.push_str(KEY_VALUE_SEPARATOR); + push_display(&mut line, artifact_summary.total_file_count()); + line.push_str(" total ("); + push_display(&mut line, artifact_summary.manifest_file_count); + line.push_str(" manifests"); + if artifact_summary.changelog_file_count > 0 { + line.push_str(", "); + push_display(&mut line, artifact_summary.changelog_file_count); + line.push_str(" changelogs"); + } + line.push(')'); + output::raw(&line); + + raw_item_kv!("release commit", commit_message.unwrap_or("skipped")); + raw_item_kv!("repository tag", repository_tag_name.unwrap_or("not created")); + raw_item_kv!("git tags created", created_tag_count); + raw_item_kv!("trusted publishing", trusted_publish_context.environment_summary()); + raw_item_kv!( + "manual fallback auth", + "passkey/security-key preferred; legacy `--otp` should rarely be needed", + ); + + raw_item_line!("rollback coverage:"); + raw_bullet_line!( + "publish-time package.json rewrites were restored before finalizing local files" + ); + raw_bullet_line!("final release files were written transactionally before git operations"); + if created_tag_count > 0 { + raw_bullet_line!( + "local release tags can be rolled back if a later tag creation step fails" + ); + output::note( + "Push the new release commit and tags to origin so future `vp release` runs can reuse the published watermark.", + ); + } +} + +/// Prompts the operator to confirm the release plan. +/// +/// The confirmation step lives in reporting rather than orchestration so that the entire +/// terminal interaction remains in one place. +pub(super) fn confirm_release( + release_plans: &[PackageReleasePlan], + readiness_report: &ReleaseReadinessReport, + options: &ReleaseOptions, +) -> Result { + if !std::io::stdin().is_terminal() { + return Err(Error::UserMessage( + "Cannot prompt for confirmation: stdin is not a TTY. Use --yes to skip confirmation." + .into(), + )); + } + + info_section!("Release summary:"); + raw_item_kv!("packages", release_plans.len()); + raw_item_kv!("publish", if options.skip_publish { "no" } else { "yes" }); + raw_item_kv!("changelog", if options.changelog { "yes" } else { "no" }); + raw_item_kv!("git commit", if options.git_commit { "yes" } else { "no" }); + raw_item_kv!("git tags", if options.git_tag { "yes" } else { "no" }); + raw_item_kv!("release checks", if options.run_checks { "yes" } else { "no" }); + raw_item_kv!( + "trusted publishing", + readiness_report.trusted_publish.context.environment_summary(), + ); + raw_item_kv!("prerelease tag", options.preid.as_deref().unwrap_or("stable")); + if let Some(version) = options.version.as_deref() { + raw_item_kv!("version override", version); + } + if !readiness_report.warnings.is_empty() { + let mut warning = String::new(); + push_display(&mut warning, readiness_report.warnings.len()); + warning.push_str(" pre-release warning(s) need review before continuing."); + output::warn(&warning); + } + + output::raw_inline("Continue with this release? [Y/n] "); + #[expect(clippy::disallowed_types)] + let mut input = String::new(); + std::io::stdout().flush()?; + std::io::stdin().read_line(&mut input)?; + + match input.trim().to_ascii_lowercase().as_str() { + "" | "y" | "yes" => Ok(true), + _ => { + output::info("Aborted."); + Ok(false) + } + } +} + +/// Collects scripts worth surfacing in the readiness summary for one scope. +fn collect_available_release_scripts( + scope: &str, + mut has_script: F, + configured_scripts: &[String], + warn_when_missing: bool, + warnings: &mut Vec, +) -> Vec +where + F: FnMut(&str) -> bool, +{ + let mut scripts = + Vec::with_capacity(DEFAULT_RELEASE_CHECK_SCRIPTS.len() + configured_scripts.len()); + + for candidate in DEFAULT_RELEASE_CHECK_SCRIPTS { + if has_script(candidate) { + push_unique_script(&mut scripts, candidate); + } + } + + for configured_script in configured_scripts { + if has_script(configured_script) { + push_unique_owned_script(&mut scripts, configured_script); + } else { + let mut warning = String::new(); + warning.push_str(scope); + warning.push_str(" declares `vitePlus.release.checkScripts` entry '"); + warning.push_str(configured_script); + warning.push_str("' but no matching script exists."); + warnings.push(warning); + } + } + + if warn_when_missing && scripts.is_empty() && configured_scripts.is_empty() { + let mut warning = String::new(); + warning.push_str(scope); + warning.push_str(" does not expose obvious pre-release checks (`build`, `pack`, `prepack`, `prepublishOnly`, `prepare`, or `vitePlus.release.checkScripts`). Double-check build and pack steps before publishing."); + warnings.push(warning); + } + + scripts +} + +fn contains_script(available_scripts: &[String], candidate: &str) -> bool { + available_scripts.iter().any(|script| script == candidate) +} + +fn push_unique_script(scripts: &mut Vec, candidate: &str) { + if !scripts.iter().any(|script| script == candidate) { + scripts.push(candidate.to_string()); + } +} + +fn push_unique_owned_script(scripts: &mut Vec, candidate: &str) { + push_unique_script(scripts, candidate); +} diff --git a/crates/vite_global_cli/src/commands/release/storage.rs b/crates/vite_global_cli/src/commands/release/storage.rs new file mode 100644 index 0000000000..7f44767ff9 --- /dev/null +++ b/crates/vite_global_cli/src/commands/release/storage.rs @@ -0,0 +1,807 @@ +//! Release persistence, rollback, and side-effect helpers. +//! +//! This module contains the mutating half of the release workflow: +//! +//! - reading manifests +//! - computing final artifact edits +//! - applying and rolling back file mutations +//! - invoking git and native publish commands +//! +//! The orchestration layer calls into these functions only after planning and validation have +//! completed, so the code here can stay focused on careful state transitions and rollback paths. + +use super::*; + +const DIRECT_PUBLISH_PROTOCOL_PREFIXES: [&str; 6] = + ["catalog:", "file:", "link:", "portal:", "patch:", "jsr:"]; +const PROGRESS_INDENT: &str = " "; + +macro_rules! raw_progress_line { + ($($part:expr),+ $(,)?) => {{ + let mut line = String::from(PROGRESS_INDENT); + $(push_display(&mut line, $part);)+ + output::raw(&line); + }}; +} + +/// Reads the root workspace manifest when one exists. +pub(super) fn read_workspace_manifest( + cwd: &AbsolutePath, +) -> Result, Error> { + let manifest_path = cwd.join("package.json"); + match read_package_manifest(&manifest_path) { + Ok(document) => Ok(Some(document.manifest)), + Err(PackageJsonError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(None) + } + Err(error) => Err(error.into()), + } +} + +/// Fails closed when the current worktree is dirty. +pub(super) fn ensure_clean_worktree(cwd: &AbsolutePath) -> Result<(), Error> { + if is_clean_git_worktree(cwd)? { + return Ok(()); + } + + Err(Error::UserMessage( + "Refusing to run `vp release` with a dirty git worktree. Commit, stash, or rerun with `--dry-run` first." + .into(), + )) +} + +/// Builds one package changelog section for the current release. +pub(super) fn build_package_changelog_section( + release_date: &str, + version: &Version, + commits: &[CommitInfo], +) -> String { + let mut section = String::new(); + section.push_str("## "); + push_display(&mut section, version); + section.push_str(" - "); + section.push_str(release_date); + section.push_str("\n\n"); + for commit in commits { + section.push_str("- "); + section.push_str(&commit.subject); + section.push_str(" ("); + section.push_str(&commit.short_hash); + section.push_str(")\n"); + } + section.push('\n'); + section +} + +/// Builds the root changelog section summarizing all packages in the release. +pub(super) fn build_root_changelog_section( + release_date: &str, + release_plans: &[PackageReleasePlan], + commits: &[CommitInfo], +) -> String { + let mut section = String::from("## Release "); + section.push_str(release_date); + section.push_str("\n\n"); + section.push_str("### Packages\n\n"); + for plan in release_plans { + section.push_str("- "); + section.push_str(&plan.name); + section.push('@'); + push_display(&mut section, &plan.next_version); + section.push('\n'); + } + section.push('\n'); + section.push_str("### Changes\n\n"); + for commit in commits { + section.push_str("- "); + section.push_str(&commit.subject); + section.push_str(" ("); + section.push_str(&commit.short_hash); + section.push_str(")\n"); + } + section.push('\n'); + section +} + +/// Computes temporary manifest rewrites used during publish preflight and publish execution. +pub(super) fn build_manifest_edits( + release_plans: &[PackageReleasePlan], +) -> Result, Error> { + let release_versions: HashMap<&str, &Version> = + release_plans.iter().map(|plan| (plan.name.as_str(), &plan.next_version)).collect(); + let mut edits = Vec::with_capacity(release_plans.len()); + + for plan in release_plans { + let updated_contents = build_updated_manifest_contents(plan, &release_versions)?; + edits.push(ManifestEdit { + package: plan.name.clone(), + path: plan.manifest_path.clone(), + original_contents: plan.manifest_contents.clone(), + updated_contents, + }); + } + + Ok(edits) +} + +/// Counts how many durable local artifacts a release will update. +pub(super) fn summarize_release_artifacts( + release_plans: &[PackageReleasePlan], + manifest_edits: &[ManifestEdit], + include_changelog: bool, +) -> ReleaseArtifactSummary { + ReleaseArtifactSummary { + manifest_file_count: manifest_edits.len(), + changelog_file_count: if include_changelog { release_plans.len() + 1 } else { 0 }, + } +} + +/// Builds the final artifact edits that should remain in git after a successful release. +pub(super) fn build_release_artifact_edits( + workspace_root_path: &AbsolutePath, + release_plans: &[PackageReleasePlan], + manifest_edits: &[ManifestEdit], + root_commits: &[CommitInfo], + release_date: &str, + include_changelog: bool, +) -> Result, Error> { + let mut edits = Vec::with_capacity( + manifest_edits.len() + if include_changelog { release_plans.len() + 1 } else { 0 }, + ); + + if include_changelog { + let root_changelog_path = workspace_root_path.join("CHANGELOG.md"); + let root_section = build_root_changelog_section(release_date, release_plans, root_commits); + edits.push(build_changelog_artifact_edit( + root_changelog_path, + root_section, + String::from("workspace changelog"), + )?); + } + + for edit in manifest_edits { + edits.push(ReleaseArtifactEdit { + label: { + let mut label = String::from("package manifest for "); + label.push_str(&edit.package); + label + }, + path: edit.path.clone(), + original_contents: Some(edit.original_contents.clone()), + updated_contents: edit.updated_contents.clone(), + }); + } + + if include_changelog { + for plan in release_plans { + let section = + build_package_changelog_section(release_date, &plan.next_version, &plan.commits); + let mut label = String::from("package changelog for "); + label.push_str(&plan.name); + edits.push(build_changelog_artifact_edit(plan.changelog_path.clone(), section, label)?); + } + } + + Ok(edits) +} + +/// Runs native publisher dry-runs while temporarily rewriting manifests. +pub(super) async fn run_publish_preflight( + package_manager: &PackageManager, + release_plans: &[PackageReleasePlan], + manifest_edits: &[ManifestEdit], + options: &ReleaseOptions, + trusted_publish_context: &TrustedPublishContext, +) -> Result { + if options.skip_publish { + output::note("Skipping publish preflight because --skip-publish was provided."); + return Ok(ExitStatus::default()); + } + + output::raw(""); + output::info("Publish preflight:"); + apply_manifest_edits(manifest_edits, false)?; + let preflight_result = run_publish_preflight_inner( + package_manager, + release_plans, + options, + trusted_publish_context, + ) + .await; + let restore_result = apply_manifest_edits(manifest_edits, true); + + match (preflight_result, restore_result) { + (Ok(status), Ok(())) => Ok(status), + (Err(error), Ok(())) => Err(error), + (Ok(_), Err(restore_error)) => Err(restore_error), + (Err(error), Err(restore_error)) => { + let mut message = String::from( + "publish preflight failed and vite-plus could not restore package.json files cleanly: ", + ); + push_display(&mut message, error); + message.push_str(" | restore error: "); + push_display(&mut message, restore_error); + Err(Error::UserMessage(message.into())) + } + } +} + +/// Runs configured release checks before publish when the release policy requires them. +pub(super) async fn run_release_checks( + package_manager: &PackageManager, + workspace_root_path: &AbsolutePath, + release_plans: &[PackageReleasePlan], + readiness_report: &ReleaseReadinessReport, + options: &ReleaseOptions, +) -> Result { + if !options.run_checks { + output::note("Release checks are disabled for this run."); + return Ok(ExitStatus::default()); + } + + if readiness_report.workspace_scripts.is_empty() && readiness_report.package_scripts.is_empty() + { + return Err(Error::UserMessage( + "No release checks were detected for this release. Add `build` / `pack` / `prepack` / `prepublishOnly` / `prepare` scripts or configure `vitePlus.release.checkScripts`, then rerun `vp release`. Use `--no-run-checks` only if you intentionally want to skip this safeguard." + .into(), + )); + } + + output::raw(""); + output::info("Running release checks:"); + + for script in &readiness_report.workspace_scripts { + raw_progress_line!("workspace script `", script, '`'); + let status = package_manager + .run_script_command( + &ScriptCommandOptions { + scripts: std::slice::from_ref(script), + pass_through_args: None, + }, + workspace_root_path, + ) + .await?; + if !status.success() { + return Ok(status); + } + } + + let package_scripts: HashMap<&str, &PackageReadiness> = readiness_report + .package_scripts + .iter() + .map(|readiness| (readiness.package.as_str(), readiness)) + .collect(); + for plan in release_plans { + let Some(readiness) = package_scripts.get(plan.name.as_str()) else { + continue; + }; + + for script in &readiness.scripts { + raw_progress_line!(&plan.name, " script `", script, '`'); + let status = package_manager + .run_script_command( + &ScriptCommandOptions { + scripts: std::slice::from_ref(script), + pass_through_args: None, + }, + &plan.package_path, + ) + .await?; + if !status.success() { + return Ok(status); + } + } + } + + output::success("Release checks succeeded."); + Ok(ExitStatus::default()) +} + +/// Executes the real publish flow while guaranteeing manifest restoration afterward. +pub(super) async fn publish_packages( + package_manager: &PackageManager, + release_plans: &[PackageReleasePlan], + manifest_edits: &[ManifestEdit], + options: &ReleaseOptions, + trusted_publish_context: &TrustedPublishContext, +) -> Result { + debug_assert!( + !options.skip_publish, + "validate_release_options should reject real releases with --skip-publish" + ); + + output::raw(""); + output::info("Publishing packages:"); + apply_manifest_edits(manifest_edits, false)?; + let publish_result = + publish_packages_inner(package_manager, release_plans, options, trusted_publish_context) + .await; + let restore_result = apply_manifest_edits(manifest_edits, true); + + match (publish_result, restore_result) { + (Ok((published_count, status)), Ok(())) => { + if !status.success() && published_count > 0 { + let mut message = String::from("Publish stopped after "); + push_display(&mut message, published_count); + message.push_str(" package(s) were already published. Local package.json files were restored, so inspect the registry state before retrying."); + let remaining_packages: Vec<&str> = release_plans[published_count..] + .iter() + .map(|plan| plan.name.as_str()) + .collect(); + if !remaining_packages.is_empty() { + message.push_str(" Remaining selection: "); + push_joined(&mut message, remaining_packages.iter().copied(), ", "); + message.push('.'); + message.push_str(" Rerun with `--projects` narrowed to the remaining packages"); + let mut unique_versions = release_plans[published_count..] + .iter() + .map(|plan| plan.next_version.to_string()) + .collect::>(); + unique_versions.sort(); + unique_versions.dedup(); + if unique_versions.len() == 1 { + message.push_str(" and `--version "); + message.push_str(&unique_versions[0]); + message.push('`'); + } + message.push('.'); + } + output::warn(&message); + } + Ok(status) + } + (Err(error), Ok(())) => Err(error), + (Ok((_published_count, _status)), Err(restore_error)) => Err(restore_error), + (Err(error), Err(restore_error)) => { + let mut message = String::from( + "publish failed and vite-plus could not restore package.json files cleanly: ", + ); + push_display(&mut message, error); + message.push_str(" | restore error: "); + push_display(&mut message, restore_error); + Err(Error::UserMessage(message.into())) + } + } +} + +/// Applies final release artifacts transactionally, rolling back already-written files on error. +pub(super) fn apply_release_artifact_edits(edits: &[ReleaseArtifactEdit]) -> Result<(), Error> { + let mut applied_count = 0usize; + for edit in edits { + if let Err(error) = write_release_artifact_edit(edit) { + let rollback_result = rollback_applied_release_artifact_edits(&edits[..applied_count]); + return Err(release_artifact_error("write release artifacts", error, rollback_result)); + } + applied_count += 1; + } + + Ok(()) +} + +/// Rolls back final release artifacts in reverse application order. +pub(super) fn rollback_release_artifact_edits(edits: &[ReleaseArtifactEdit]) -> Result<(), Error> { + rollback_applied_release_artifact_edits(edits) +} + +/// Formats the local release commit message from the package set. +pub(super) fn release_commit_message(release_plans: &[PackageReleasePlan]) -> String { + if release_plans.len() <= 3 { + let mut message = String::from("chore(release): publish "); + let mut first = true; + for plan in release_plans { + if !first { + message.push_str(", "); + } + first = false; + message.push_str(&plan.name); + message.push('@'); + push_display(&mut message, &plan.next_version); + } + message + } else { + let mut message = String::from("chore(release): publish "); + push_display(&mut message, release_plans.len()); + message.push_str(" packages"); + message + } +} + +/// Stages the final release artifacts. +pub(super) fn git_add_paths(cwd: &AbsolutePath, paths: &[AbsolutePathBuf]) -> Result<(), Error> { + let mut args = Vec::with_capacity(paths.len() + 1); + args.push(String::from("add")); + for path in paths { + args.push(path.as_path().to_string_lossy().to_string()); + } + run_git(cwd, args).map_err(|err| { + let mut message = String::from("stage release changes: "); + push_display(&mut message, err); + Error::UserMessage(message.into()) + }) +} + +/// Creates the local release commit. +pub(super) fn git_commit(cwd: &AbsolutePath, message: &str) -> Result<(), Error> { + run_git(cwd, ["commit", "-m", message]).map_err(|err| { + let mut error_message = String::from("create release commit: "); + push_display(&mut error_message, err); + Error::UserMessage(error_message.into()) + }) +} + +/// Creates one local release watermark tag. +pub(super) fn git_tag(cwd: &AbsolutePath, tag_name: &str) -> Result<(), Error> { + run_git(cwd, ["tag", tag_name]).map_err(|err| { + let mut message = String::from("create release tag: "); + push_display(&mut message, err); + Error::UserMessage(message.into()) + }) +} + +/// Deletes one local release watermark tag. +pub(super) fn git_delete_tag(cwd: &AbsolutePath, tag_name: &str) -> Result<(), Error> { + run_git(cwd, ["tag", "-d", tag_name]).map_err(|err| { + let mut message = String::from("delete release tag: "); + push_display(&mut message, err); + Error::UserMessage(message.into()) + }) +} + +/// Removes tags created by the current release attempt in reverse order. +pub(super) fn rollback_created_git_tags( + cwd: &AbsolutePath, + tag_names: &[String], +) -> Result<(), Error> { + for tag_name in tag_names.iter().rev() { + git_delete_tag(cwd, tag_name)?; + } + Ok(()) +} + +/// Prepends a generated changelog section while preserving the conventional heading structure. +pub(super) fn prepend_changelog_section(existing: &str, section: &str) -> String { + if let Some(rest) = existing.strip_prefix("# Changelog") { + let rest = rest.trim_start_matches('\n'); + if rest.is_empty() { + return prepend_changelog_heading(section); + } + let mut updated = prepend_changelog_heading(section); + updated.push_str(rest); + return updated; + } + + let mut updated = prepend_changelog_heading(section); + updated.push_str(existing.trim_start()); + updated +} + +fn prepend_changelog_heading(section: &str) -> String { + let mut updated = String::from("# Changelog\n\n"); + updated.push_str(section); + updated +} + +fn build_changelog_artifact_edit( + path: AbsolutePathBuf, + section: String, + label: String, +) -> Result { + let (original_contents, updated_contents) = match fs::read_to_string(&path) { + Ok(existing) => { + let updated = prepend_changelog_section(&existing, §ion); + (Some(existing), updated) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + (None, prepend_changelog_heading(§ion)) + } + Err(err) => return Err(err.into()), + }; + + Ok(ReleaseArtifactEdit { label, path, original_contents, updated_contents }) +} + +async fn run_publish_preflight_inner( + package_manager: &PackageManager, + release_plans: &[PackageReleasePlan], + options: &ReleaseOptions, + trusted_publish_context: &TrustedPublishContext, +) -> Result { + for plan in release_plans { + raw_progress_line!("checking ", &plan.name, '@', &plan.next_version); + + let publish_options = PublishCommandOptions { + dry_run: true, + tag: resolved_publish_tag(plan, options), + access: plan.access.as_deref(), + otp: options.otp.as_deref(), + provenance: resolved_publish_provenance(plan, trusted_publish_context), + ..Default::default() + }; + let status = + package_manager.run_publish_command(&publish_options, &plan.package_path).await?; + if !status.success() { + return Ok(status); + } + } + + output::success("Publish preflight succeeded."); + Ok(ExitStatus::default()) +} + +async fn publish_packages_inner( + package_manager: &PackageManager, + release_plans: &[PackageReleasePlan], + options: &ReleaseOptions, + trusted_publish_context: &TrustedPublishContext, +) -> Result<(usize, ExitStatus), Error> { + let mut published_count = 0usize; + + for plan in release_plans { + let mut message = String::from("Publishing "); + message.push_str(&plan.name); + message.push('@'); + push_display(&mut message, &plan.next_version); + output::info(&message); + + let publish_options = PublishCommandOptions { + dry_run: false, + tag: resolved_publish_tag(plan, options), + access: plan.access.as_deref(), + otp: options.otp.as_deref(), + provenance: resolved_publish_provenance(plan, trusted_publish_context), + ..Default::default() + }; + let status = + package_manager.run_publish_command(&publish_options, &plan.package_path).await?; + if !status.success() { + return Ok((published_count, status)); + } + + published_count += 1; + } + + Ok((published_count, ExitStatus::default())) +} + +pub(super) fn apply_manifest_edits( + manifest_edits: &[ManifestEdit], + restore_original: bool, +) -> Result<(), Error> { + let mut applied_edits = 0usize; + for edit in manifest_edits { + let contents = + if restore_original { &edit.original_contents } else { &edit.updated_contents }; + if let Err(error) = fs::write(&edit.path, contents) { + let rollback_result = + rollback_manifest_edits(&manifest_edits[..applied_edits], !restore_original); + let mut message = String::from("write release manifest for "); + message.push_str(&edit.package); + message.push_str(": "); + push_display(&mut message, error); + match rollback_result { + Ok(()) => { + message.push_str(". Previously written release manifests were rolled back."); + } + Err(rollback_error) => { + message.push_str(" | rollback error: "); + push_display(&mut message, rollback_error); + } + } + return Err(Error::UserMessage(message.into())); + } + applied_edits += 1; + } + + Ok(()) +} + +fn write_release_artifact_edit(edit: &ReleaseArtifactEdit) -> Result<(), Error> { + fs::write(&edit.path, &edit.updated_contents).map_err(|error| { + let mut message = String::from("write "); + message.push_str(&edit.label); + message.push_str(": "); + push_display(&mut message, error); + Error::UserMessage(message.into()) + }) +} + +fn rollback_manifest_edits( + manifest_edits: &[ManifestEdit], + restore_original: bool, +) -> Result<(), Error> { + for edit in manifest_edits.iter().rev() { + let contents = + if restore_original { &edit.original_contents } else { &edit.updated_contents }; + fs::write(&edit.path, contents).map_err(|error| { + let mut message = String::from("restore release manifest for "); + message.push_str(&edit.package); + message.push_str(": "); + push_display(&mut message, error); + Error::UserMessage(message.into()) + })?; + } + + Ok(()) +} + +fn rollback_applied_release_artifact_edits(edits: &[ReleaseArtifactEdit]) -> Result<(), Error> { + for edit in edits.iter().rev() { + restore_release_artifact_edit(edit)?; + } + + Ok(()) +} + +fn restore_release_artifact_edit(edit: &ReleaseArtifactEdit) -> Result<(), Error> { + match &edit.original_contents { + Some(contents) => fs::write(&edit.path, contents).map_err(|error| { + let mut message = String::from("restore "); + message.push_str(&edit.label); + message.push_str(": "); + push_display(&mut message, error); + Error::UserMessage(message.into()) + }), + None => match fs::remove_file(&edit.path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => { + let mut message = String::from("remove generated "); + message.push_str(&edit.label); + message.push_str(": "); + push_display(&mut message, error); + Err(Error::UserMessage(message.into())) + } + }, + } +} + +fn release_artifact_error( + context: &str, + error: Error, + rollback_result: Result<(), Error>, +) -> Error { + let mut message = String::from(context); + message.push_str(": "); + push_display(&mut message, error); + match rollback_result { + Ok(()) => { + message.push_str(". Local release files were rolled back."); + } + Err(rollback_error) => { + message.push_str(" | rollback error: "); + push_display(&mut message, rollback_error); + } + } + Error::UserMessage(message.into()) +} + +fn build_updated_manifest_contents( + plan: &PackageReleasePlan, + release_versions: &HashMap<&str, &Version>, +) -> Result { + let next_version = plan.next_version.to_string(); + let mut updated = replace_top_level_string_property( + &plan.manifest_contents, + "version", + &plan.manifest.version, + &next_version, + )?; + + let dependency_updates = collect_dependency_version_updates(plan, release_versions)?; + if !dependency_updates.is_empty() { + updated = + replace_dependency_version_ranges(&updated, &dependency_updates).map_err(|error| { + let mut message = String::from("update dependency ranges for "); + message.push_str(&plan.name); + message.push_str(": "); + push_display(&mut message, error); + Error::UserMessage(message.into()) + })?; + } + + Ok(updated) +} + +fn collect_dependency_version_updates( + plan: &PackageReleasePlan, + release_versions: &HashMap<&str, &Version>, +) -> Result>, Error> { + let mut updates = BTreeMap::new(); + for (section_name, dependencies) in [ + ("dependencies", &plan.manifest.dependencies), + ("devDependencies", &plan.manifest.dev_dependencies), + ("peerDependencies", &plan.manifest.peer_dependencies), + ("optionalDependencies", &plan.manifest.optional_dependencies), + ] { + collect_dependency_section_updates( + &plan.name, + section_name, + dependencies, + release_versions, + &mut updates, + )?; + } + Ok(updates) +} + +fn collect_dependency_section_updates( + package_name: &str, + section_name: &str, + dependencies: &BTreeMap, + release_versions: &HashMap<&str, &Version>, + updates: &mut BTreeMap>, +) -> Result<(), Error> { + for (dependency_name, spec) in dependencies { + let Some(next_version) = release_versions.get(dependency_name.as_str()) else { + continue; + }; + let Some(updated_spec) = rewrite_released_dependency_spec( + package_name, + section_name, + dependency_name, + spec, + next_version, + )? + else { + continue; + }; + updates + .entry(section_name.to_string()) + .or_default() + .insert(dependency_name.clone(), updated_spec); + } + + Ok(()) +} + +fn rewrite_released_dependency_spec( + package_name: &str, + section_name: &str, + dependency_name: &str, + spec: &str, + next_version: &Version, +) -> Result, Error> { + if is_publish_protocol_reference(spec) { + return Ok(None); + } + + match parse_version_pattern(spec) { + Ok(VersionPattern::Any) => Ok(None), + Ok(VersionPattern::Version { prefix, .. }) => { + let mut updated = String::from(prefix.as_str()); + push_display(&mut updated, next_version); + Ok(Some(updated)) + } + Ok(VersionPattern::Token(prefix)) => { + let mut message = String::from("Package '"); + message.push_str(package_name); + message.push_str("' depends on released workspace package '"); + message.push_str(dependency_name); + message.push_str("' via unsupported "); + message.push_str(section_name); + message.push_str(" range '"); + message.push_str(spec); + message.push_str("'. Bare '"); + message.push_str(prefix.as_str()); + message.push_str("' tokens are not supported for publishable internal dependencies."); + Err(Error::UserMessage(message.into())) + } + Err(_) => { + let mut message = String::from("Package '"); + message.push_str(package_name); + message.push_str("' depends on released workspace package '"); + message.push_str(dependency_name); + message.push_str("' via unsupported "); + message.push_str(section_name); + message.push_str(" range '"); + message.push_str(spec); + message.push_str("'. Use `workspace:` or a simple exact/^/~ version."); + Err(Error::UserMessage(message.into())) + } + } +} + +fn is_publish_protocol_reference(spec: &str) -> bool { + spec.contains("workspace:") + || DIRECT_PUBLISH_PROTOCOL_PREFIXES.iter().any(|prefix| spec.starts_with(prefix)) +} diff --git a/crates/vite_global_cli/src/commands/release/templates/publish.yml b/crates/vite_global_cli/src/commands/release/templates/publish.yml new file mode 100644 index 0000000000..4900e9aa1b --- /dev/null +++ b/crates/vite_global_cli/src/commands/release/templates/publish.yml @@ -0,0 +1,76 @@ +name: Publish + +on: + workflow_dispatch: + inputs: + first_release: + description: Set to true only for the very first publish + required: false + default: false + type: boolean + # Optional automation examples after you confirm your release boundary: + # push: + # branches: + # - + # tags: + # - + +permissions: + contents: write + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Install Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version: '22' + cache: true + + - __PACKAGE_MANAGER_SETUP__ + - name: Install dependencies + run: __INSTALL_COMMAND__ + + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Publish release + shell: bash + env: + FIRST_RELEASE: ${{ github.event_name == 'workflow_dispatch' && inputs.first_release || 'false' }} + run: | + if [[ "${FIRST_RELEASE}" == "true" ]]; then + __FIRST_RELEASE_COMMAND__ + else + __RELEASE_COMMAND__ + fi + + - name: Push release commit and tags + run: | + git push origin HEAD + git push origin --tags + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + repo_tag="$(git tag --points-at HEAD 'v*' | head -n1)" + if [[ -z "$repo_tag" ]]; then + echo "No repository tag was created by vp release; skipping GitHub Release." + exit 0 + fi + if gh release view "$repo_tag" >/dev/null 2>&1; then + echo "GitHub Release already exists for $repo_tag; skipping." + exit 0 + fi + gh release create "$repo_tag" --generate-notes --title "$repo_tag" diff --git a/crates/vite_global_cli/src/commands/release/tests.rs b/crates/vite_global_cli/src/commands/release/tests.rs new file mode 100644 index 0000000000..00459b7e83 --- /dev/null +++ b/crates/vite_global_cli/src/commands/release/tests.rs @@ -0,0 +1,1241 @@ +//! Unit tests for the split release modules. +//! +//! The release flow spans planning, security validation, git tag handling, manifest rewriting, +//! and user-facing summaries. These tests intentionally stay close to the module boundary rather +//! than trying to boot a full end-to-end workspace, so they can validate the core rules quickly +//! even when external workspace fixtures are unavailable. + +use std::path::PathBuf; + +use vite_path::{AbsolutePathBuf, RelativePathBuf}; +use vite_str::Str; +use vite_workspace::{PackageInfo, PackageJson, PackageNodeIndex}; + +use super::*; + +fn test_absolute_path(suffix: &str) -> AbsolutePathBuf { + #[cfg(windows)] + let base = PathBuf::from(format!("C:\\workspace{}", suffix.replace('/', "\\"))); + #[cfg(not(windows))] + let base = PathBuf::from(format!("/workspace{suffix}")); + AbsolutePathBuf::new(base).unwrap() +} + +fn build_test_package_graph() +-> petgraph::graph::DiGraph { + let mut graph = petgraph::graph::DiGraph::default(); + + let _root = graph.add_node(PackageInfo { + package_json: PackageJson { name: "root".into(), ..Default::default() }, + path: RelativePathBuf::default(), + absolute_path: test_absolute_path("").into(), + }); + let pkg_a = graph.add_node(PackageInfo { + package_json: PackageJson { name: "pkg-a".into(), ..Default::default() }, + path: RelativePathBuf::try_from("packages/pkg-a").unwrap(), + absolute_path: test_absolute_path("/packages/pkg-a").into(), + }); + let pkg_b = graph.add_node(PackageInfo { + package_json: PackageJson { name: "pkg-b".into(), ..Default::default() }, + path: RelativePathBuf::try_from("packages/pkg-b").unwrap(), + absolute_path: test_absolute_path("/packages/pkg-b").into(), + }); + let pkg_c = graph.add_node(PackageInfo { + package_json: PackageJson { name: "pkg-c".into(), ..Default::default() }, + path: RelativePathBuf::try_from("packages/pkg-c").unwrap(), + absolute_path: test_absolute_path("/packages/pkg-c").into(), + }); + + graph.add_edge(pkg_a, pkg_b, DependencyType::Normal); + + let _ = pkg_c; + graph +} + +fn test_package_manager(client: PackageManagerType, version: &str) -> PackageManager { + PackageManager { + client, + package_name: client.to_string().into(), + version: Str::from(version), + hash: None, + bin_name: client.to_string().into(), + workspace_root: test_absolute_path(""), + is_monorepo: false, + install_dir: test_absolute_path("/install"), + } +} + +fn make_workspace_package( + graph: &petgraph::graph::DiGraph, + node: PackageNodeIndex, + selection_order: usize, +) -> WorkspacePackage { + let package = &graph[node]; + WorkspacePackage { + node, + name: package.package_json.name.to_string(), + known_names: vec![package.package_json.name.to_string()], + retired_names: Vec::new(), + release_paths: vec![package.path.as_str().to_owned()], + selection_order, + manifest_path: package.absolute_path.join("package.json"), + package_path: package.absolute_path.to_absolute_path_buf(), + manifest_contents: r#"{"name":"pkg","version":"1.0.0"}"#.into(), + manifest: PackageManifest { + name: package.package_json.name.to_string(), + version: "1.0.0".into(), + ..Default::default() + }, + } +} + +fn make_release_plan(name: &str, scripts: &[&str], check_scripts: &[&str]) -> PackageReleasePlan { + PackageReleasePlan { + name: name.to_string(), + known_names: vec![name.to_string()], + retired_names: Vec::new(), + package_path: test_absolute_path(&format!("/packages/{name}")), + manifest_path: test_absolute_path(&format!("/packages/{name}/package.json")), + manifest_contents: format!(r#"{{"name":"{name}","version":"1.0.0"}}"#), + manifest: PackageManifest { + name: name.to_string(), + version: "1.0.0".into(), + ..Default::default() + }, + current_version: Version::parse("1.0.0").unwrap(), + next_version: Version::parse("1.0.1").unwrap(), + level: VersionBump::Patch, + commits: Vec::new(), + changelog_path: test_absolute_path(&format!("/packages/{name}/CHANGELOG.md")), + access: None, + publish_tag: None, + publish_provenance: None, + repository_url: Some(String::from("https://github.com/voidzero-dev/vite-plus.git")), + protocol_summary: DependencyProtocolSummary::default(), + tag_name: format!("release/{name}/v1.0.1"), + scripts: scripts.iter().map(|script| (*script).to_string()).collect(), + check_scripts: check_scripts.iter().map(|script| (*script).to_string()).collect(), + } +} + +fn make_release_options() -> ReleaseOptions { + ReleaseOptions { + dry_run: false, + skip_publish: false, + first_release: false, + changelog: false, + version: None, + preid: None, + otp: None, + projects: None, + git_tag: true, + git_commit: true, + run_checks: true, + yes: false, + } +} + +fn github_hosted_trusted_publish_context() -> TrustedPublishContext { + TrustedPublishContext::from_env(|key| match key { + "GITHUB_ACTIONS" => Some(String::from("true")), + "RUNNER_ENVIRONMENT" => Some(String::from("github-hosted")), + "GITHUB_REPOSITORY" => Some(String::from("voidzero-dev/vite-plus")), + "GITHUB_WORKFLOW" => Some(String::from("Release")), + "GITHUB_WORKFLOW_REF" => Some(String::from( + "voidzero-dev/vite-plus/.github/workflows/release.yml@refs/heads/main", + )), + _ => None, + }) +} + +fn gitlab_trusted_publish_context() -> TrustedPublishContext { + TrustedPublishContext::from_env(|key| match key { + "GITLAB_CI" => Some(String::from("true")), + _ => None, + }) +} + +fn circleci_trusted_publish_context() -> TrustedPublishContext { + TrustedPublishContext::from_env(|key| match key { + "CIRCLECI" => Some(String::from("true")), + _ => None, + }) +} + +#[test] +fn parse_github_repo_slug_supports_common_remote_formats() { + assert_eq!( + parse_github_repo_slug("git@github.com:voidzero-dev/vite-plus.git"), + Some("voidzero-dev/vite-plus".into()) + ); + assert_eq!( + parse_github_repo_slug("https://github.com/voidzero-dev/vite-plus.git"), + Some("voidzero-dev/vite-plus".into()) + ); + assert_eq!( + parse_github_repo_slug("github:voidzero-dev/vite-plus"), + Some("voidzero-dev/vite-plus".into()) + ); + assert_eq!(parse_github_repo_slug("https://example.com/acme/repo.git"), None); +} + +#[test] +fn repository_url_reads_string_and_object_forms() { + let manifest: PackageManifest = serde_json::from_str( + r#"{ + "name": "@scope/pkg-a", + "version": "1.0.0", + "repository": "https://github.com/voidzero-dev/vite-plus.git" + }"#, + ) + .unwrap(); + assert_eq!(manifest.repository_url(), Some("https://github.com/voidzero-dev/vite-plus.git")); + + let manifest: PackageManifest = serde_json::from_str( + r#"{ + "name": "@scope/pkg-b", + "version": "1.0.0", + "repository": { + "type": "git", + "url": "git@github.com:voidzero-dev/vite-plus.git" + } + }"#, + ) + .unwrap(); + assert_eq!(manifest.repository_url(), Some("git@github.com:voidzero-dev/vite-plus.git")); +} + +#[test] +fn render_release_command_keeps_relevant_flags() { + let command = render_release_command( + &ReleaseOptions { + dry_run: false, + skip_publish: true, + first_release: true, + changelog: true, + version: None, + preid: Some("alpha".into()), + otp: None, + projects: Some(vec!["@scope/pkg-a".into(), "@scope/pkg-b".into()]), + git_tag: false, + git_commit: true, + run_checks: true, + yes: false, + }, + true, + true, + ); + + assert_eq!( + command, + "vp release --first-release --changelog --preid alpha --projects @scope/pkg-a,@scope/pkg-b --no-git-tag --skip-publish --dry-run" + ); +} + +#[test] +fn render_release_command_uses_yes_for_non_interactive_runs() { + let command = render_release_command( + &ReleaseOptions { + dry_run: false, + skip_publish: false, + first_release: false, + changelog: false, + version: None, + preid: None, + otp: None, + projects: None, + git_tag: true, + git_commit: true, + run_checks: true, + yes: false, + }, + false, + true, + ); + + assert_eq!(command, "vp release --yes"); +} + +#[test] +fn validate_release_options_rejects_real_skip_publish() { + let error = validate_release_options(&ReleaseOptions { + dry_run: false, + skip_publish: true, + first_release: false, + changelog: false, + version: None, + preid: None, + otp: None, + projects: None, + git_tag: true, + git_commit: true, + run_checks: true, + yes: false, + }) + .unwrap_err(); + + assert!(error.to_string().contains("--skip-publish")); +} + +#[test] +fn validate_release_options_rejects_real_no_git_tag() { + let error = validate_release_options(&ReleaseOptions { + dry_run: false, + skip_publish: false, + first_release: false, + changelog: false, + version: None, + preid: None, + otp: None, + projects: None, + git_tag: false, + git_commit: true, + run_checks: true, + yes: false, + }) + .unwrap_err(); + + assert!(error.to_string().contains("--no-git-tag")); +} + +#[test] +fn validate_release_options_rejects_real_git_tag_without_commit() { + let error = validate_release_options(&ReleaseOptions { + dry_run: false, + skip_publish: false, + first_release: false, + changelog: false, + version: None, + preid: None, + otp: None, + projects: None, + git_tag: true, + git_commit: false, + run_checks: true, + yes: false, + }) + .unwrap_err(); + + assert!(error.to_string().contains("--no-git-commit")); + assert!(error.to_string().contains("--git-tag")); +} + +#[test] +fn validate_release_options_allows_preview_only_flags_in_dry_run() { + assert!( + validate_release_options(&ReleaseOptions { + dry_run: true, + skip_publish: true, + first_release: true, + changelog: true, + version: None, + preid: Some("beta".into()), + otp: None, + projects: Some(vec!["pkg-a".into()]), + git_tag: false, + git_commit: false, + run_checks: false, + yes: false, + }) + .is_ok() + ); +} + +#[test] +fn validate_release_options_rejects_version_with_preid() { + let error = validate_release_options(&ReleaseOptions { + dry_run: false, + skip_publish: false, + first_release: false, + changelog: false, + version: Some("1.2.3-alpha.0".into()), + preid: Some("alpha".into()), + otp: None, + projects: None, + git_tag: true, + git_commit: true, + run_checks: true, + yes: false, + }) + .unwrap_err(); + + assert!(error.to_string().contains("--version")); + assert!(error.to_string().contains("--preid")); +} + +#[test] +fn resolved_publish_tag_prefers_cli_preid_over_manifest_tag() { + let mut plan = make_release_plan("pkg-a", &[], &[]); + plan.publish_tag = Some("next".into()); + + let options = ReleaseOptions { + dry_run: false, + skip_publish: false, + first_release: false, + changelog: false, + version: None, + preid: Some("beta".into()), + otp: None, + projects: None, + git_tag: true, + git_commit: true, + run_checks: true, + yes: false, + }; + + assert_eq!(resolved_publish_tag(&plan, &options), Some("beta")); +} + +#[test] +fn resolved_publish_tag_falls_back_to_manifest_tag() { + let mut plan = make_release_plan("pkg-a", &[], &[]); + plan.publish_tag = Some("next".into()); + + assert_eq!(resolved_publish_tag(&plan, &make_release_options()), Some("next")); +} + +#[test] +fn resolved_publish_tag_uses_prerelease_channel_from_target_version() { + let mut plan = make_release_plan("pkg-a", &[], &[]); + plan.next_version = Version::parse("1.0.1-beta.2").unwrap(); + plan.publish_tag = Some("next".into()); + + assert_eq!(resolved_publish_tag(&plan, &make_release_options()), Some("beta")); +} + +#[test] +fn resolved_publish_tag_supports_custom_prerelease_channels() { + let mut plan = make_release_plan("pkg-a", &[], &[]); + plan.next_version = Version::parse("1.0.1-canary.0").unwrap(); + + assert_eq!(resolved_publish_tag(&plan, &make_release_options()), Some("canary")); +} + +#[test] +fn build_manifest_edits_updates_simple_internal_dependency_ranges() { + let mut pkg_a = make_release_plan("pkg-a", &[], &[]); + pkg_a.current_version = Version::parse("1.0.0").unwrap(); + pkg_a.next_version = Version::parse("1.1.0").unwrap(); + pkg_a.tag_name = "release/pkg-a/v1.1.0".into(); + pkg_a.manifest.version = "1.0.0".into(); + pkg_a.manifest_contents = r#"{ + "name": "pkg-a", + "version": "1.0.0" +} +"# + .into(); + + let mut pkg_b = make_release_plan("pkg-b", &[], &[]); + pkg_b.current_version = Version::parse("1.0.0").unwrap(); + pkg_b.next_version = Version::parse("1.0.1").unwrap(); + pkg_b.tag_name = "release/pkg-b/v1.0.1".into(); + pkg_b.manifest.version = "1.0.0".into(); + pkg_b.manifest.dependencies.insert("pkg-a".into(), "^1.0.0".into()); + pkg_b.manifest_contents = r#"{ + "name": "pkg-b", + "version": "1.0.0", + "dependencies": { + "pkg-a": "^1.0.0" + } +} +"# + .into(); + + let edits = build_manifest_edits(&[pkg_a, pkg_b]).unwrap(); + assert_eq!(edits.len(), 2); + assert!(edits[1].updated_contents.contains(r#""pkg-a": "^1.1.0""#)); + assert!(edits[1].updated_contents.contains(r#""version": "1.0.1""#)); +} + +#[test] +fn build_manifest_edits_rejects_complex_internal_dependency_ranges() { + let mut pkg_a = make_release_plan("pkg-a", &[], &[]); + pkg_a.current_version = Version::parse("1.0.0").unwrap(); + pkg_a.next_version = Version::parse("1.1.0").unwrap(); + pkg_a.tag_name = "release/pkg-a/v1.1.0".into(); + pkg_a.manifest.version = "1.0.0".into(); + + let mut pkg_b = make_release_plan("pkg-b", &[], &[]); + pkg_b.manifest.dependencies.insert("pkg-a".into(), ">=1.0.0 <2.0.0".into()); + + let error = build_manifest_edits(&[pkg_a, pkg_b]).unwrap_err(); + assert!(error.to_string().contains("Use `workspace:` or a simple exact/^/~ version")); +} + +#[test] +fn build_manifest_edits_supports_tag_sourced_zero_versions() { + let mut plan = make_release_plan("pkg-a", &[], &[]); + plan.current_version = Version::parse("0.0.0-16aec32").unwrap(); + plan.next_version = Version::parse("1.2.3").unwrap(); + plan.manifest.version = "0.0.0".into(); + plan.manifest_contents = r#"{ + "name": "pkg-a", + "version": "0.0.0" +} +"# + .into(); + + let edits = build_manifest_edits(&[plan]).unwrap(); + assert_eq!(edits.len(), 1); + assert!(edits[0].updated_contents.contains(r#""version": "1.2.3""#)); +} + +#[test] +fn readiness_report_collects_workspace_and_package_scripts() { + let workspace_manifest: PackageManifest = serde_json::from_str( + r#"{ + "scripts": { + "build": "pnpm -r build", + "release:verify": "pnpm test" + }, + "vitePlus": { + "release": { + "checkScripts": ["release:verify"] + } + } + }"#, + ) + .unwrap(); + let plans = vec![make_release_plan("pkg-a", &["build", "prepack"], &[])]; + + let report = collect_release_readiness_report( + Some(&workspace_manifest), + &plans, + &make_release_options(), + &github_hosted_trusted_publish_context(), + ); + assert_eq!(report.workspace_scripts, vec!["build", "release:verify"]); + assert_eq!(report.package_scripts.len(), 1); + assert_eq!(report.package_scripts[0].package, "pkg-a"); + assert_eq!(report.package_scripts[0].scripts, vec!["build", "prepack"]); + assert!(report.warnings.is_empty()); +} + +#[test] +fn readiness_report_warns_for_missing_custom_scripts() { + let plans = vec![make_release_plan("pkg-a", &["build"], &["release:verify"])]; + + let report = collect_release_readiness_report( + None, + &plans, + &make_release_options(), + &github_hosted_trusted_publish_context(), + ); + + assert_eq!(report.package_scripts[0].scripts, vec!["build"]); + assert_eq!(report.warnings.len(), 1); + assert!(report.warnings[0].contains("release:verify")); +} + +#[test] +fn readiness_report_warns_when_no_obvious_checks_exist() { + let plans = vec![make_release_plan("pkg-a", &[], &[])]; + + let report = collect_release_readiness_report( + None, + &plans, + &make_release_options(), + &github_hosted_trusted_publish_context(), + ); + + assert!(report.package_scripts.is_empty()); + assert_eq!(report.warnings.len(), 2); + assert!(report.warnings[0].contains("pkg-a")); + assert!(report.warnings[1].contains("No explicit build / pack / prepack")); +} + +#[test] +fn classify_conventional_commits() { + assert_eq!(classify_commit("feat(cli): add release", ""), Some(VersionBump::Minor)); + assert_eq!(classify_commit("fix(core): handle null", ""), Some(VersionBump::Patch)); + assert_eq!(classify_commit("fix!: break behavior", ""), Some(VersionBump::Major)); + assert_eq!( + classify_commit("chore: cleanup", "BREAKING CHANGE: changed the API"), + Some(VersionBump::Major) + ); + assert_eq!(classify_commit("docs: update readme", ""), None); +} + +#[test] +fn bump_version_supports_prerelease() { + let current = Version::parse("1.2.3").unwrap(); + assert_eq!( + next_release_version(¤t, VersionBump::Patch, None, None).unwrap().to_string(), + "1.2.4" + ); + assert_eq!( + next_release_version(¤t, VersionBump::Minor, None, Some("beta")).unwrap().to_string(), + "1.3.0-beta.0" + ); +} + +#[test] +fn zero_major_breaking_changes_are_downgraded_to_minor_bumps() { + let current = Version::parse("0.4.2").unwrap(); + assert_eq!(effective_release_level(¤t, VersionBump::Major), VersionBump::Minor); + assert_eq!( + next_release_version( + ¤t, + effective_release_level(¤t, VersionBump::Major), + None, + None + ) + .unwrap() + .to_string(), + "0.5.0" + ); +} + +#[test] +fn stable_major_versions_keep_breaking_changes_as_major_bumps() { + let current = Version::parse("1.4.2").unwrap(); + assert_eq!(effective_release_level(¤t, VersionBump::Major), VersionBump::Major); +} + +#[test] +fn prerelease_on_same_channel_increments_sequence() { + let stable = Version::parse("1.0.0").unwrap(); + let current = Version::parse("1.1.0-alpha.0").unwrap(); + assert_eq!( + next_release_version(¤t, VersionBump::Patch, Some(&stable), Some("alpha")) + .unwrap() + .to_string(), + "1.1.0-alpha.1" + ); +} + +#[test] +fn prerelease_can_escalate_to_higher_release_line() { + let stable = Version::parse("1.0.0").unwrap(); + let current = Version::parse("1.0.1-alpha.0").unwrap(); + assert_eq!( + next_release_version(¤t, VersionBump::Minor, Some(&stable), Some("alpha")) + .unwrap() + .to_string(), + "1.1.0-alpha.0" + ); +} + +#[test] +fn prerelease_can_switch_channels_without_bumping_base_again() { + let stable = Version::parse("1.0.0").unwrap(); + let current = Version::parse("1.1.0-alpha.2").unwrap(); + assert_eq!( + next_release_version(¤t, VersionBump::Patch, Some(&stable), Some("beta")) + .unwrap() + .to_string(), + "1.1.0-beta.0" + ); +} + +#[test] +fn prerelease_supports_rc_channel() { + let stable = Version::parse("1.0.0").unwrap(); + let current = Version::parse("1.1.0-beta.2").unwrap(); + assert_eq!( + next_release_version(¤t, VersionBump::Patch, Some(&stable), Some("rc")) + .unwrap() + .to_string(), + "1.1.0-rc.0" + ); + let current = Version::parse("1.1.0-rc.0").unwrap(); + assert_eq!( + next_release_version(¤t, VersionBump::Patch, Some(&stable), Some("rc")) + .unwrap() + .to_string(), + "1.1.0-rc.1" + ); +} + +#[test] +fn stable_release_from_prerelease_keeps_current_target_version() { + let stable = Version::parse("1.0.0").unwrap(); + let current = Version::parse("1.1.0-beta.2").unwrap(); + assert_eq!( + next_release_version(¤t, VersionBump::Patch, Some(&stable), None) + .unwrap() + .to_string(), + "1.1.0" + ); +} + +#[test] +fn zero_major_prerelease_breaking_changes_stay_on_minor_line() { + let stable = Version::parse("0.1.0").unwrap(); + let current = Version::parse("0.2.0-alpha.1").unwrap(); + let level = effective_release_level(¤t, VersionBump::Major); + assert_eq!(level, VersionBump::Minor); + assert_eq!( + next_release_version(¤t, level, Some(&stable), Some("alpha")).unwrap().to_string(), + "0.2.0-alpha.2" + ); +} + +#[test] +fn prerelease_levels_cannot_be_used_as_bump_levels() { + let current = Version::parse("1.0.0").unwrap(); + let error = next_release_version(¤t, VersionBump::Alpha, None, None).unwrap_err(); + assert!(error.to_string().contains("Invalid release level 'alpha'")); +} + +#[test] +fn replace_top_level_string_property_only_updates_top_level_field() { + let contents = r#"{ + "version": "1.0.0", + "nested": { + "version": "should-stay" + } +} +"#; + + let updated = replace_top_level_string_property(contents, "version", "1.0.0", "2.0.0").unwrap(); + assert!(updated.contains(r#""version": "2.0.0""#)); + assert!(updated.contains(r#""version": "should-stay""#)); +} + +#[test] +fn prepend_changelog_section_reuses_existing_heading() { + let existing = "# Changelog\n\n## 0.1.0 - 2026-01-01\n\n- existing\n"; + let prepended = prepend_changelog_section(existing, "## 0.2.0 - 2026-02-01\n\n- new\n\n"); + assert!(prepended.starts_with("# Changelog\n\n## 0.2.0 - 2026-02-01")); + assert!(prepended.contains("## 0.1.0 - 2026-01-01")); +} + +#[test] +fn prepend_changelog_section_adds_heading_when_missing() { + let existing = "## 0.1.0 - 2026-01-01\n\n- existing\n"; + let prepended = prepend_changelog_section(existing, "## 0.2.0 - 2026-02-01\n\n- new\n\n"); + assert!(prepended.starts_with("# Changelog\n\n## 0.2.0 - 2026-02-01")); + assert!(prepended.contains("## 0.1.0 - 2026-01-01")); +} + +#[test] +fn prepend_changelog_section_handles_heading_without_existing_entries() { + let prepended = + prepend_changelog_section("# Changelog\n", "## 0.2.0 - 2026-02-01\n\n- new\n\n"); + assert_eq!(prepended, "# Changelog\n\n## 0.2.0 - 2026-02-01\n\n- new\n\n"); +} + +#[test] +fn summarize_release_artifacts_counts_manifests_and_changelogs() { + let plans = vec![make_release_plan("pkg-a", &[], &[]), make_release_plan("pkg-b", &[], &[])]; + let manifest_edits = vec![ + ManifestEdit { + package: "pkg-a".into(), + path: test_absolute_path("/packages/pkg-a/package.json"), + original_contents: "{}".into(), + updated_contents: r#"{"version":"1.0.1"}"#.into(), + }, + ManifestEdit { + package: "pkg-b".into(), + path: test_absolute_path("/packages/pkg-b/package.json"), + original_contents: "{}".into(), + updated_contents: r#"{"version":"1.0.1"}"#.into(), + }, + ]; + + let summary = summarize_release_artifacts(&plans, &manifest_edits, true); + assert_eq!(summary.manifest_file_count, 2); + assert_eq!(summary.changelog_file_count, 3); + assert_eq!(summary.total_file_count(), 5); +} + +#[test] +fn apply_manifest_edits_rolls_back_partial_updates() { + let temp_dir = tempfile::tempdir().unwrap(); + let workspace_root = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let good_path = workspace_root.join("packages/pkg-a/package.json"); + std::fs::create_dir_all(good_path.as_path().parent().unwrap()).unwrap(); + std::fs::write(&good_path, "{\"version\":\"1.0.0\"}").unwrap(); + + let bad_path = workspace_root.join("missing/pkg-b/package.json"); + let edits = vec![ + ManifestEdit { + package: "pkg-a".into(), + path: good_path.clone(), + original_contents: "{\"version\":\"1.0.0\"}".into(), + updated_contents: "{\"version\":\"1.0.1\"}".into(), + }, + ManifestEdit { + package: "pkg-b".into(), + path: bad_path, + original_contents: "{\"version\":\"1.0.0\"}".into(), + updated_contents: "{\"version\":\"1.0.1\"}".into(), + }, + ]; + + let error = apply_manifest_edits(&edits, false).unwrap_err(); + assert!(error.to_string().contains("rolled back")); + assert_eq!(std::fs::read_to_string(&good_path).unwrap(), "{\"version\":\"1.0.0\"}"); +} + +#[test] +fn apply_manifest_edits_rolls_back_partial_restore_to_updated_state() { + let temp_dir = tempfile::tempdir().unwrap(); + let workspace_root = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let good_path = workspace_root.join("packages/pkg-a/package.json"); + std::fs::create_dir_all(good_path.as_path().parent().unwrap()).unwrap(); + std::fs::write(&good_path, "{\"version\":\"1.0.1\"}").unwrap(); + + let bad_path = workspace_root.join("missing/pkg-b/package.json"); + let edits = vec![ + ManifestEdit { + package: "pkg-a".into(), + path: good_path.clone(), + original_contents: "{\"version\":\"1.0.0\"}".into(), + updated_contents: "{\"version\":\"1.0.1\"}".into(), + }, + ManifestEdit { + package: "pkg-b".into(), + path: bad_path, + original_contents: "{\"version\":\"1.0.0\"}".into(), + updated_contents: "{\"version\":\"1.0.1\"}".into(), + }, + ]; + + let error = apply_manifest_edits(&edits, true).unwrap_err(); + assert!(error.to_string().contains("rolled back")); + assert_eq!(std::fs::read_to_string(&good_path).unwrap(), "{\"version\":\"1.0.1\"}"); +} + +#[test] +fn rollback_release_artifact_edits_restores_and_removes_files() { + let temp_dir = tempfile::tempdir().unwrap(); + let workspace_root = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + let existing_path = workspace_root.join("existing.txt"); + std::fs::write(&existing_path, "before").unwrap(); + let generated_path = workspace_root.join("generated.txt"); + std::fs::write(&generated_path, "after").unwrap(); + + let edits = vec![ + ReleaseArtifactEdit { + label: "existing file".into(), + path: existing_path.clone(), + original_contents: Some(String::from("before")), + updated_contents: String::from("after"), + }, + ReleaseArtifactEdit { + label: "generated file".into(), + path: generated_path.clone(), + original_contents: None, + updated_contents: String::from("after"), + }, + ]; + + rollback_release_artifact_edits(&edits).unwrap(); + + assert_eq!(std::fs::read_to_string(&existing_path).unwrap(), "before"); + assert!(!generated_path.as_path().exists()); +} + +#[test] +fn trusted_publish_context_detects_github_actions_hosted_runner() { + let context = github_hosted_trusted_publish_context(); + + assert!(context.supports_trusted_publishing()); + assert!(context.supports_publish_provenance()); + assert_eq!(context.workflow_path().as_deref(), Some(".github/workflows/release.yml")); + assert_eq!(context.repository.as_deref(), Some("voidzero-dev/vite-plus")); +} + +#[test] +fn trusted_publish_context_detects_gitlab_ci() { + let context = gitlab_trusted_publish_context(); + + assert!(context.supports_trusted_publishing()); + assert!(context.supports_publish_provenance()); + assert!(context.workflow_path().is_none()); + assert!(context.environment_summary().contains("GitLab CI")); +} + +#[test] +fn trusted_publish_context_detects_circleci_without_provenance() { + let context = circleci_trusted_publish_context(); + + assert!(context.supports_trusted_publishing()); + assert!(!context.supports_publish_provenance()); + assert!(context.environment_summary().contains("CircleCI")); +} + +#[test] +fn trusted_publish_context_parses_publish_workflow_ref() { + let context = TrustedPublishContext::from_env(|key| match key { + "GITHUB_ACTIONS" => Some(String::from("true")), + "RUNNER_ENVIRONMENT" => Some(String::from("github-hosted")), + "GITHUB_WORKFLOW_REF" => Some(String::from( + "voidzero-dev/vite-plus/.github/workflows/publish.yml@refs/heads/main", + )), + _ => None, + }); + + assert_eq!(context.workflow_path().as_deref(), Some(".github/workflows/publish.yml")); +} + +#[test] +fn trusted_publish_context_returns_no_workflow_path_for_unexpected_ref_shape() { + let context = TrustedPublishContext::from_env(|key| match key { + "GITHUB_ACTIONS" => Some(String::from("true")), + "RUNNER_ENVIRONMENT" => Some(String::from("github-hosted")), + "GITHUB_WORKFLOW_REF" => Some(String::from("voidzero-dev/vite-plus@refs/heads/main")), + _ => None, + }); + + assert!(context.workflow_path().is_none()); +} + +#[test] +fn trusted_publish_context_rejects_github_actions_self_hosted_runner() { + let context = TrustedPublishContext::from_env(|key| match key { + "GITHUB_ACTIONS" => Some(String::from("true")), + "RUNNER_ENVIRONMENT" => Some(String::from("self-hosted")), + _ => None, + }); + + assert!(!context.supports_trusted_publishing()); + assert!(!context.supports_publish_provenance()); +} + +#[test] +fn validate_trusted_publish_context_rejects_local_real_release() { + let error = validate_trusted_publish_context( + &make_release_options(), + &TrustedPublishContext::default(), + ) + .unwrap_err(); + assert!(error.to_string().contains("trusted-publishing CI")); +} + +#[test] +fn validate_trusted_publish_context_allows_local_dry_run() { + let mut options = make_release_options(); + options.dry_run = true; + + assert!(validate_trusted_publish_context(&options, &TrustedPublishContext::default()).is_ok()); +} + +#[test] +fn validate_trusted_publish_context_rejects_circleci_without_provenance() { + let context = circleci_trusted_publish_context(); + + let error = validate_trusted_publish_context(&make_release_options(), &context).unwrap_err(); + assert!(error.to_string().contains("provenance attestations")); +} + +#[test] +fn validate_trusted_publish_context_allows_gitlab_real_release() { + assert!( + validate_trusted_publish_context( + &make_release_options(), + &gitlab_trusted_publish_context() + ) + .is_ok() + ); +} + +#[test] +fn resolved_publish_provenance_defaults_to_true_in_trusted_publish_ci() { + let plan = make_release_plan("pkg-a", &[], &[]); + let context = github_hosted_trusted_publish_context(); + + assert_eq!(resolved_publish_provenance(&plan, &context), Some(true)); +} + +#[test] +fn resolved_publish_provenance_respects_explicit_opt_out() { + let mut plan = make_release_plan("pkg-a", &[], &[]); + plan.publish_provenance = Some(false); + + assert_eq!( + resolved_publish_provenance(&plan, &github_hosted_trusted_publish_context()), + Some(false) + ); +} + +#[test] +fn resolved_publish_provenance_stays_none_without_capable_environment() { + let plan = make_release_plan("pkg-a", &[], &[]); + assert_eq!(resolved_publish_provenance(&plan, &TrustedPublishContext::default()), None); +} + +#[test] +fn readiness_report_tracks_provenance_opt_out_and_legacy_otp() { + let mut options = make_release_options(); + options.otp = Some(String::from("123456")); + let mut plan = make_release_plan("pkg-a", &["build"], &[]); + plan.publish_provenance = Some(false); + + let report = collect_release_readiness_report( + None, + &[plan], + &options, + &github_hosted_trusted_publish_context(), + ); + + assert_eq!(report.trusted_publish.packages_with_provenance_disabled, vec!["pkg-a"]); + assert!(report.trusted_publish.uses_legacy_otp); +} + +#[test] +fn readiness_report_warns_when_environment_cannot_emit_provenance() { + let report = collect_release_readiness_report( + None, + &[make_release_plan("pkg-a", &["build"], &[])], + &make_release_options(), + &TrustedPublishContext::default(), + ); + + assert!( + report + .warnings + .iter() + .any(|warning| warning.contains("cannot produce the npm provenance attestations")) + ); +} + +#[test] +fn package_tags_are_scoped_and_safe() { + let version = Version::parse("1.0.0").unwrap(); + assert_eq!(package_tag_name("@scope/pkg-a", &version), "release/scope/pkg-a/v1.0.0"); +} + +#[test] +fn release_tags_roundtrip_scoped_and_unscoped_package_names() { + assert_eq!( + parse_package_name_from_release_tag("release/scope/pkg-a/v1.0.0"), + Some("@scope/pkg-a".into()) + ); + assert_eq!(parse_package_name_from_release_tag("release/pkg-b/v2.0.0"), Some("pkg-b".into())); +} + +#[test] +fn invalid_release_tags_are_ignored() { + assert_eq!(parse_package_name_from_release_tag("release//v1.0.0"), None); + assert_eq!(parse_package_name_from_release_tag("release/pkg-a/not-a-version"), None); + assert_eq!(parse_package_name_from_release_tag("pkg-a@1.0.0"), None); +} + +#[test] +fn repository_release_tags_accept_stable_and_numbered_prereleases() { + assert_eq!( + parse_repository_release_tag_version_for_tests("v1.2.3").unwrap().to_string(), + "1.2.3" + ); + assert_eq!( + parse_repository_release_tag_version_for_tests("v1.2.4-alpha.1").unwrap().to_string(), + "1.2.4-alpha.1" + ); + assert_eq!( + parse_repository_release_tag_version_for_tests("v1.2.5-canary.0").unwrap().to_string(), + "1.2.5-canary.0" + ); + assert!(parse_repository_release_tag_version_for_tests("v0.0.0-16aec32").is_none()); + assert!( + parse_repository_release_tag_version_for_tests("v0.0.0-0bfcc90f.20260209-0731").is_none() + ); + assert!(parse_repository_release_tag_version_for_tests("release/pkg-a/v1.0.0").is_none()); +} + +#[test] +fn shared_repository_release_tag_uses_lockstep_version() { + let mut pkg_a = make_release_plan("pkg-a", &[], &[]); + pkg_a.next_version = Version::parse("1.2.3").unwrap(); + + let mut pkg_b = make_release_plan("pkg-b", &[], &[]); + pkg_b.next_version = Version::parse("1.2.3").unwrap(); + + assert_eq!(shared_repository_release_tag(&[pkg_a, pkg_b]), Some(String::from("v1.2.3"))); +} + +#[test] +fn shared_repository_release_tag_is_skipped_for_mixed_versions() { + let mut pkg_a = make_release_plan("pkg-a", &[], &[]); + pkg_a.next_version = Version::parse("1.2.3").unwrap(); + + let mut pkg_b = make_release_plan("pkg-b", &[], &[]); + pkg_b.next_version = Version::parse("1.2.4").unwrap(); + + assert_eq!(shared_repository_release_tag(&[pkg_a, pkg_b]), None); +} + +#[test] +fn publish_protocol_matrix_prefers_native_workspace_and_catalog_rewrites() { + let pnpm = test_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let summary = + DependencyProtocolSummary { workspace: true, catalog: true, ..Default::default() }; + + assert!(unsupported_publish_protocols(&pnpm, summary).is_empty()); + + let npm = test_package_manager(PackageManagerType::Npm, "11.0.0"); + assert_eq!(unsupported_publish_protocols(&npm, summary), vec!["workspace:", "catalog:"]); +} + +#[test] +fn publish_protocol_matrix_allows_workspace_and_catalog_for_bun() { + let bun = test_package_manager(PackageManagerType::Bun, "1.2.0"); + let summary = + DependencyProtocolSummary { workspace: true, catalog: true, ..Default::default() }; + + assert!(unsupported_publish_protocols(&bun, summary).is_empty()); +} + +#[test] +fn publish_protocol_matrix_rejects_workspace_and_catalog_for_yarn1() { + let yarn = test_package_manager(PackageManagerType::Yarn, "1.22.0"); + let summary = + DependencyProtocolSummary { workspace: true, catalog: true, ..Default::default() }; + + assert_eq!(unsupported_publish_protocols(&yarn, summary), vec!["workspace:", "catalog:"]); +} + +#[test] +fn file_protocol_remains_blocked_even_with_native_publishers() { + let pnpm = test_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let summary = DependencyProtocolSummary { file: true, ..Default::default() }; + assert_eq!(unsupported_publish_protocols(&pnpm, summary), vec!["file:"]); +} + +#[test] +fn project_selection_matches_previous_package_names() { + let graph = build_test_package_graph(); + let mut nodes = graph.node_indices().filter(|&node| !graph[node].path.as_str().is_empty()); + let pkg_a = nodes.next().unwrap(); + + let mut package = make_workspace_package(&graph, pkg_a, usize::MAX); + package.known_names.push("@scope/old-pkg-a".into()); + + let selected = + select_workspace_packages(&[package], Some(&["@scope/old-pkg-*".into()])).unwrap(); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].name, "pkg-a"); +} + +#[test] +fn release_commit_message_lists_up_to_three_packages() { + let plans = vec![ + make_release_plan("pkg-a", &[], &[]), + make_release_plan("pkg-b", &[], &[]), + make_release_plan("pkg-c", &[], &[]), + ]; + + assert_eq!( + release_commit_message(&plans), + "chore(release): publish pkg-a@1.0.1, pkg-b@1.0.1, pkg-c@1.0.1" + ); +} + +#[test] +fn release_commit_message_summarizes_larger_release_sets() { + let plans = vec![ + make_release_plan("pkg-a", &[], &[]), + make_release_plan("pkg-b", &[], &[]), + make_release_plan("pkg-c", &[], &[]), + make_release_plan("pkg-d", &[], &[]), + ]; + + assert_eq!(release_commit_message(&plans), "chore(release): publish 4 packages"); +} + +#[test] +fn unique_strings_preserves_order() { + let values = + unique_strings(vec!["a".to_string(), "b".to_string(), "a".to_string(), "c".to_string()]); + assert_eq!(values, vec!["a", "b", "c"]); +} + +#[test] +fn orphaned_released_packages_ignore_known_and_retired_names() { + let known = HashSet::from(["pkg-a", "@scope/pkg-b", "@scope/old-pkg-c"]); + let orphaned = collect_orphaned_released_package_names( + [ + "release/pkg-a/v1.0.0", + "release/scope/pkg-b/v1.0.0", + "release/scope/old-pkg-c/v0.9.0", + "release/scope/pkg-d/v2.0.0", + ], + &known, + ); + assert_eq!(orphaned, vec!["@scope/pkg-d"]); +} + +#[test] +fn orphaned_released_packages_ignore_invalid_tag_shapes() { + let known = HashSet::from(["pkg-a"]); + let orphaned = collect_orphaned_released_package_names( + ["not-a-tag", "release//v1.0.0", "release/pkg-a/not-a-version"], + &known, + ); + assert!(orphaned.is_empty()); +} + +#[test] +fn project_order_breaks_ties_between_independent_packages() { + let graph = build_test_package_graph(); + let mut nodes = graph.node_indices().filter(|&node| !graph[node].path.as_str().is_empty()); + let pkg_a = nodes.next().unwrap(); + let pkg_b = nodes.next().unwrap(); + let pkg_c = nodes.next().unwrap(); + + let selected = vec![ + make_workspace_package(&graph, pkg_a, 2), + make_workspace_package(&graph, pkg_b, 0), + make_workspace_package(&graph, pkg_c, 1), + ]; + + let ordered = topological_sort_selected_packages(&graph, &selected); + let names: Vec<&str> = ordered.iter().map(|package| package.name.as_str()).collect(); + + assert_eq!(names, vec!["pkg-b", "pkg-c", "pkg-a"]); +} + +#[test] +fn dependency_order_wins_over_requested_project_order() { + let graph = build_test_package_graph(); + let mut nodes = graph.node_indices().filter(|&node| !graph[node].path.as_str().is_empty()); + let pkg_a = nodes.next().unwrap(); + let pkg_b = nodes.next().unwrap(); + + let selected = + vec![make_workspace_package(&graph, pkg_a, 0), make_workspace_package(&graph, pkg_b, 1)]; + + let ordered = topological_sort_selected_packages(&graph, &selected); + let names: Vec<&str> = ordered.iter().map(|package| package.name.as_str()).collect(); + + assert_eq!(names, vec!["pkg-b", "pkg-a"]); +} + +#[test] +fn cycle_breaker_prefers_selection_order_for_cycles() { + let mut graph = build_test_package_graph(); + let mut nodes = graph.node_indices().filter(|&node| !graph[node].path.as_str().is_empty()); + let pkg_a = nodes.next().unwrap(); + let pkg_b = nodes.next().unwrap(); + + graph.add_edge(pkg_b, pkg_a, DependencyType::Normal); + + let selected = + vec![make_workspace_package(&graph, pkg_a, 1), make_workspace_package(&graph, pkg_b, 0)]; + + let ordered = topological_sort_selected_packages(&graph, &selected); + let names: Vec<&str> = ordered.iter().map(|package| package.name.as_str()).collect(); + + assert_eq!(names, vec!["pkg-b", "pkg-a"]); +} + +#[test] +fn cycle_breaker_uses_package_name_when_selection_order_matches() { + let mut graph = build_test_package_graph(); + let mut nodes = graph.node_indices().filter(|&node| !graph[node].path.as_str().is_empty()); + let pkg_a = nodes.next().unwrap(); + let pkg_b = nodes.next().unwrap(); + + graph.add_edge(pkg_b, pkg_a, DependencyType::Normal); + + let selected = + vec![make_workspace_package(&graph, pkg_a, 0), make_workspace_package(&graph, pkg_b, 0)]; + + let ordered = topological_sort_selected_packages(&graph, &selected); + let names: Vec<&str> = ordered.iter().map(|package| package.name.as_str()).collect(); + + assert_eq!(names, vec!["pkg-a", "pkg-b"]); +} diff --git a/crates/vite_global_cli/src/error.rs b/crates/vite_global_cli/src/error.rs index 870e7801f7..fbd79c63e6 100644 --- a/crates/vite_global_cli/src/error.rs +++ b/crates/vite_global_cli/src/error.rs @@ -2,6 +2,7 @@ use std::io; +use vite_shared::{GitError, PackageJsonError}; use vite_str::Str; /// Error type for the global CLI. @@ -58,3 +59,22 @@ pub enum Error { #[error("Unsupported integrity format: {0} (only sha512 is supported)")] UnsupportedIntegrity(Str), } + +impl From for Error { + fn from(value: GitError) -> Self { + match value { + GitError::Io(err) => Self::CommandExecution(err), + GitError::Command(message) => Self::UserMessage(message.into()), + } + } +} + +impl From for Error { + fn from(value: PackageJsonError) -> Self { + match value { + PackageJsonError::Io(err) => Self::CommandExecution(err), + PackageJsonError::Json(err) => Self::JsonError(err), + PackageJsonError::Message(message) => Self::UserMessage(message.into()), + } + } +} diff --git a/crates/vite_global_cli/src/help.rs b/crates/vite_global_cli/src/help.rs index e3e1e956f8..5ab1938890 100644 --- a/crates/vite_global_cli/src/help.rs +++ b/crates/vite_global_cli/src/help.rs @@ -78,6 +78,7 @@ fn documentation_url_for_command_path(command_path: &[&str]) -> Option<&'static ["cache"] => Some("https://viteplus.dev/guide/cache"), ["build" | "preview"] => Some("https://viteplus.dev/guide/build"), ["pack"] => Some("https://viteplus.dev/guide/pack"), + ["release"] => None, ["env", ..] => Some("https://viteplus.dev/guide/env"), ["upgrade"] => Some("https://viteplus.dev/guide/upgrade"), _ => None, @@ -461,6 +462,10 @@ pub fn top_level_help_doc() -> HelpDoc { row("build", "Build for production"), row("pack", "Build library"), row("preview", "Preview production build"), + row( + "release", + "Version and publish workspace packages with readiness checks, confirmation, and optional changelog generation", + ), ], ), section_rows( diff --git a/crates/vite_install/src/commands/mod.rs b/crates/vite_install/src/commands/mod.rs index 948880f6cb..7e34c6c2bb 100644 --- a/crates/vite_install/src/commands/mod.rs +++ b/crates/vite_install/src/commands/mod.rs @@ -20,6 +20,7 @@ pub mod prune; pub mod publish; pub mod rebuild; pub mod remove; +pub mod script; pub mod search; pub mod token; pub mod unlink; diff --git a/crates/vite_install/src/commands/publish.rs b/crates/vite_install/src/commands/publish.rs index 185c1d329f..f140dfc1a0 100644 --- a/crates/vite_install/src/commands/publish.rs +++ b/crates/vite_install/src/commands/publish.rs @@ -1,3 +1,10 @@ +//! Publish command resolution for npm-compatible package managers. +//! +//! The release flow delegates publish execution to this module so that package-manager-specific +//! argument quirks, environment wiring, and provenance handling stay centralized. The output of +//! this module is intentionally explicit: a resolved binary, argument list, and environment map +//! that higher-level callers can inspect during dry-runs or execute during real publishes. + use std::{collections::HashMap, process::ExitStatus}; use vite_command::run_command; @@ -10,25 +17,43 @@ use crate::package_manager::{ }; /// Options for the publish command. +/// +/// This structure describes publish intent in package-manager-neutral terms. Resolution into a +/// concrete CLI command happens later in `PackageManager::resolve_publish_command`. #[derive(Debug, Default)] pub struct PublishCommandOptions<'a> { + /// Optional tarball/directory target passed to the underlying publisher. pub target: Option<&'a str>, + /// Whether the resolved command should only simulate publishing. pub dry_run: bool, + /// Dist-tag to publish under. pub tag: Option<&'a str>, + /// Access level, typically `public` for first publish of scoped public packages. pub access: Option<&'a str>, + /// Legacy TOTP code for npm 2FA flows. pub otp: Option<&'a str>, + /// npm provenance preference propagated through environment configuration when supported. + pub provenance: Option, + /// Disables git checks when the package manager supports it. pub no_git_checks: bool, + /// Branch restriction for publishers that support release branches. pub publish_branch: Option<&'a str>, + /// Requests a publish summary file/output when supported. pub report_summary: bool, + /// Forces publish when the package manager exposes such a flag. pub force: bool, + /// Requests machine-readable JSON output when supported. pub json: bool, + /// Enables recursive/workspace publish mode when supported. pub recursive: bool, + /// Package-manager-native workspace filters. pub filters: Option<&'a [String]>, + /// Reserved passthrough arguments for future publish extensions. pub pass_through_args: Option<&'a [String]>, } impl PackageManager { - /// Run the publish command with the package manager. + /// Runs the resolved publish command with the package manager. #[must_use] pub async fn run_publish_command( &self, @@ -40,12 +65,21 @@ impl PackageManager { .await } - /// Resolve the publish command. - /// All yarn versions delegate to npm publish. + /// Resolves the publish command into a concrete executable, argument vector, and env map. + /// + /// Prefer native publish commands when they provide better protocol handling, especially for + /// workspace/catalog-style references that may need publisher-specific rewriting. #[must_use] pub fn resolve_publish_command(&self, options: &PublishCommandOptions) -> ResolveCommandResult { - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); + let mut envs = HashMap::with_capacity(2); + envs.insert(String::from("PATH"), format_path_env(self.get_bin_prefix())); + if let Some(provenance) = options.provenance { + envs.insert( + String::from("NPM_CONFIG_PROVENANCE"), + if provenance { String::from("true") } else { String::from("false") }, + ); + } + let mut args: Vec = Vec::with_capacity(16); let bin_name: String; @@ -53,7 +87,8 @@ impl PackageManager { PackageManagerType::Pnpm => { bin_name = "pnpm".into(); - // pnpm: --filter must come before command + // pnpm treats filtering as a global option, so it must appear before `publish`. + // https://pnpm.io/cli/publish if let Some(filters) = options.filters { for filter in filters { args.push("--filter".into()); @@ -111,17 +146,18 @@ impl PackageManager { args.push("--recursive".into()); } } - PackageManagerType::Npm | PackageManagerType::Yarn => { - // Yarn always delegates to npm + PackageManagerType::Npm => { bin_name = "npm".into(); args.push("publish".into()); + // npm workspace selection is expressed with per-workspace flags after the command. + // https://docs.npmjs.com/cli/v11/commands/npm-publish + // https://docs.npmjs.com/cli/v11/using-npm/workspaces/ if options.recursive { args.push("--workspaces".into()); } - // npm: --workspace comes after command (maps from --filter) if let Some(filters) = options.filters { for filter in filters { args.push("--workspace".into()); @@ -168,7 +204,118 @@ impl PackageManager { output::warn("--json not supported by npm, ignoring flag"); } } + PackageManagerType::Yarn => { + // Modern Yarn has its own publish surface (`yarn npm publish`), while Yarn 1 falls + // back to npm semantics. Keep the native path when it preserves documented behavior. + // https://yarnpkg.com/cli/npm/publish + let can_use_native_yarn = !self.version.starts_with("1.") + && options.target.is_none() + && !options.recursive + && options.filters.map_or(true, |filters| filters.is_empty()) + && options.publish_branch.is_none() + && !options.report_summary + && options + .pass_through_args + .map_or(true, |pass_through_args| pass_through_args.is_empty()) + && !options.force; + + if can_use_native_yarn { + bin_name = "yarn".into(); + args.push("npm".into()); + args.push("publish".into()); + + if options.dry_run { + args.push("--dry-run".into()); + } + + if let Some(tag) = options.tag { + args.push("--tag".into()); + args.push(tag.to_string()); + } + + if let Some(access) = options.access { + args.push("--access".into()); + args.push(access.to_string()); + } + + if let Some(otp) = options.otp { + args.push("--otp".into()); + args.push(otp.to_string()); + } + + if options.no_git_checks { + output::warn( + "--no-git-checks not supported by yarn npm publish, ignoring flag", + ); + } + + if options.json { + args.push("--json".into()); + } + } else { + bin_name = "npm".into(); + + args.push("publish".into()); + + if options.recursive { + args.push("--workspaces".into()); + } + + if let Some(filters) = options.filters { + for filter in filters { + args.push("--workspace".into()); + args.push(filter.clone()); + } + } + + if let Some(target) = options.target { + args.push(target.to_string()); + } + + if options.dry_run { + args.push("--dry-run".into()); + } + + if let Some(tag) = options.tag { + args.push("--tag".into()); + args.push(tag.to_string()); + } + + if let Some(access) = options.access { + args.push("--access".into()); + args.push(access.to_string()); + } + + if let Some(otp) = options.otp { + args.push("--otp".into()); + args.push(otp.to_string()); + } + + if options.force { + args.push("--force".into()); + } + + if options.publish_branch.is_some() { + output::warn( + "--publish-branch not supported by yarn native publish flow, falling back to npm publish", + ); + } + + if options.report_summary { + output::warn( + "--report-summary not supported by yarn native publish flow, falling back to npm publish", + ); + } + + if options.json { + output::warn("--json not supported by npm, ignoring flag"); + } + } + } PackageManagerType::Bun => { + // Bun exposes its own `publish` command, but does not currently mirror npm/pnpm's + // workspace/filter feature set, so unsupported flags are surfaced explicitly. + // https://bun.sh/docs/pm/cli/publish bin_name = "bun".into(); args.push("publish".into()); @@ -291,10 +438,18 @@ mod tests { } #[test] - fn test_yarn2_publish_uses_npm() { + fn test_yarn2_publish_uses_yarn_npm_publish() { let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); let result = pm.resolve_publish_command(&PublishCommandOptions::default()); - assert_eq!(result.bin_path, "npm"); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["npm", "publish"]); + } + + #[test] + fn test_bun_publish_uses_native_command() { + let pm = create_mock_package_manager(PackageManagerType::Bun, "1.2.0"); + let result = pm.resolve_publish_command(&PublishCommandOptions::default()); + assert_eq!(result.bin_path, "bun"); assert_eq!(result.args, vec!["publish"]); } @@ -305,8 +460,8 @@ mod tests { tag: Some("beta"), ..Default::default() }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish", "--tag", "beta"]); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["npm", "publish", "--tag", "beta"]); } #[test] @@ -367,6 +522,18 @@ mod tests { assert_eq!(result.args, vec!["publish", "--workspace", "app"]); } + #[test] + fn test_yarn_modern_publish_with_filter_falls_back_to_npm() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let filters = vec!["app".to_string()]; + let result = pm.resolve_publish_command(&PublishCommandOptions { + filters: Some(&filters), + ..Default::default() + }); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["publish", "--workspace", "app"]); + } + #[test] fn test_pnpm_publish_json() { let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); @@ -461,4 +628,208 @@ mod tests { assert_eq!(result.bin_path, "npm"); assert_eq!(result.args, vec!["publish", "--otp", "999999"]); } + + #[test] + fn test_yarn_modern_publish_json_uses_native_command() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = + pm.resolve_publish_command(&PublishCommandOptions { json: true, ..Default::default() }); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["npm", "publish", "--json"]); + } + + #[test] + fn test_yarn_modern_publish_with_target_falls_back_to_npm() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_publish_command(&PublishCommandOptions { + target: Some("./dist/pkg.tgz"), + ..Default::default() + }); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["publish", "./dist/pkg.tgz"]); + } + + #[test] + fn test_yarn_modern_publish_recursive_falls_back_to_npm() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_publish_command(&PublishCommandOptions { + recursive: true, + ..Default::default() + }); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["publish", "--workspaces"]); + } + + #[test] + fn test_yarn_modern_publish_branch_falls_back_to_npm() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_publish_command(&PublishCommandOptions { + publish_branch: Some("main"), + ..Default::default() + }); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["publish"]); + } + + #[test] + fn test_yarn_modern_publish_force_falls_back_to_npm() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm + .resolve_publish_command(&PublishCommandOptions { force: true, ..Default::default() }); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["publish", "--force"]); + } + + #[test] + fn test_yarn_modern_publish_with_pass_through_args_falls_back_to_npm() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let pass_through_args = vec!["--loglevel".to_string(), "error".to_string()]; + let result = pm.resolve_publish_command(&PublishCommandOptions { + dry_run: true, + pass_through_args: Some(&pass_through_args), + ..Default::default() + }); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["publish", "--dry-run", "--loglevel", "error"]); + } + + #[test] + fn test_pnpm_publish_keeps_filter_order_and_global_position() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let filters = vec!["pkg-b".to_string(), "pkg-a".to_string()]; + let result = pm.resolve_publish_command(&PublishCommandOptions { + filters: Some(&filters), + dry_run: true, + recursive: true, + ..Default::default() + }); + assert_eq!( + result.args, + vec!["--filter", "pkg-b", "--filter", "pkg-a", "publish", "--dry-run", "--recursive"] + ); + } + + #[test] + fn test_npm_publish_keeps_workspace_filters_after_command() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); + let filters = vec!["pkg-b".to_string(), "pkg-a".to_string()]; + let result = pm.resolve_publish_command(&PublishCommandOptions { + filters: Some(&filters), + recursive: true, + ..Default::default() + }); + assert_eq!( + result.args, + vec!["publish", "--workspaces", "--workspace", "pkg-b", "--workspace", "pkg-a"] + ); + } + + #[test] + fn test_pnpm_publish_combines_target_access_and_branch_flags() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let result = pm.resolve_publish_command(&PublishCommandOptions { + target: Some("./dist/pkg.tgz"), + dry_run: true, + tag: Some("beta"), + access: Some("public"), + no_git_checks: true, + publish_branch: Some("main"), + report_summary: true, + force: true, + json: true, + ..Default::default() + }); + assert_eq!( + result.args, + vec![ + "publish", + "./dist/pkg.tgz", + "--dry-run", + "--tag", + "beta", + "--access", + "public", + "--no-git-checks", + "--publish-branch", + "main", + "--report-summary", + "--force", + "--json", + ] + ); + } + + #[test] + fn test_publish_provenance_env_is_enabled() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let result = pm.resolve_publish_command(&PublishCommandOptions { + provenance: Some(true), + ..Default::default() + }); + assert_eq!(result.envs.get("NPM_CONFIG_PROVENANCE").map(String::as_str), Some("true")); + } + + #[test] + fn test_publish_provenance_env_can_be_disabled() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); + let result = pm.resolve_publish_command(&PublishCommandOptions { + provenance: Some(false), + ..Default::default() + }); + assert_eq!(result.envs.get("NPM_CONFIG_PROVENANCE").map(String::as_str), Some("false")); + } + + #[test] + fn test_publish_provenance_env_is_absent_when_unspecified() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_publish_command(&PublishCommandOptions::default()); + assert!(!result.envs.contains_key("NPM_CONFIG_PROVENANCE")); + } + + #[test] + fn test_bun_publish_keeps_supported_flags_and_ignores_unsupported_ones() { + let pm = create_mock_package_manager(PackageManagerType::Bun, "1.2.0"); + let filters = vec!["pkg-a".to_string()]; + let result = pm.resolve_publish_command(&PublishCommandOptions { + target: Some("./dist/pkg.tgz"), + dry_run: true, + tag: Some("beta"), + access: Some("public"), + otp: Some("123456"), + no_git_checks: true, + publish_branch: Some("main"), + report_summary: true, + force: true, + json: true, + recursive: true, + filters: Some(&filters), + ..Default::default() + }); + assert_eq!( + result.args, + vec![ + "publish", + "./dist/pkg.tgz", + "--dry-run", + "--tag", + "beta", + "--access", + "public", + "--otp", + "123456", + ] + ); + } + + #[test] + fn test_pass_through_args_are_appended_last() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); + let pass_through_args = vec!["--provenance-file".to_string(), "out.json".to_string()]; + let result = pm.resolve_publish_command(&PublishCommandOptions { + dry_run: true, + pass_through_args: Some(&pass_through_args), + ..Default::default() + }); + assert_eq!(result.args, vec!["publish", "--dry-run", "--provenance-file", "out.json"]); + } } diff --git a/crates/vite_install/src/commands/script.rs b/crates/vite_install/src/commands/script.rs new file mode 100644 index 0000000000..2cef82807c --- /dev/null +++ b/crates/vite_install/src/commands/script.rs @@ -0,0 +1,86 @@ +use std::{collections::HashMap, process::ExitStatus}; + +use vite_command::run_command; +use vite_error::Error; +use vite_path::AbsolutePath; + +use crate::package_manager::{PackageManager, ResolveCommandResult, format_path_env}; + +/// Options for running package manager scripts. +#[derive(Debug, Default)] +pub struct ScriptCommandOptions<'a> { + pub scripts: &'a [String], + pub pass_through_args: Option<&'a [String]>, +} + +impl PackageManager { + /// Run one or more scripts using the current package manager. + #[must_use] + pub async fn run_script_command( + &self, + options: &ScriptCommandOptions<'_>, + cwd: impl AsRef, + ) -> Result { + let resolve_command = self.resolve_script_command(options); + run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) + .await + } + + /// Resolve a package-manager script invocation into an executable, args, and env map. + #[must_use] + pub fn resolve_script_command(&self, options: &ScriptCommandOptions) -> ResolveCommandResult { + let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); + let mut args = Vec::with_capacity(4 + options.scripts.len()); + + args.push("run".into()); + args.extend(options.scripts.iter().cloned()); + + if let Some(pass_through_args) = options.pass_through_args { + args.extend_from_slice(pass_through_args); + } + + ResolveCommandResult { bin_path: self.client.to_string(), args, envs } + } +} + +#[cfg(test)] +mod tests { + use std::env; + + use vite_path::AbsolutePathBuf; + + use super::*; + use crate::package_manager::PackageManagerType; + + fn temp_absolute_path() -> AbsolutePathBuf { + AbsolutePathBuf::new(env::temp_dir()).unwrap() + } + + fn make_pm(client: PackageManagerType) -> PackageManager { + PackageManager { + client, + package_name: "pm".into(), + version: "1.0.0".into(), + hash: None, + bin_name: client.to_string().into(), + workspace_root: temp_absolute_path(), + is_monorepo: false, + install_dir: temp_absolute_path(), + } + } + + #[test] + fn resolve_script_command_uses_run_subcommand() { + let pm = make_pm(PackageManagerType::Pnpm); + let options = ScriptCommandOptions { + scripts: &["build".to_string()], + pass_through_args: Some(&["--if-present".to_string()]), + }; + + let result = pm.resolve_script_command(&options); + + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["run", "build", "--if-present"]); + assert!(result.envs.contains_key("PATH")); + } +} diff --git a/crates/vite_shared/Cargo.toml b/crates/vite_shared/Cargo.toml index ff814d571b..b03a450efe 100644 --- a/crates/vite_shared/Cargo.toml +++ b/crates/vite_shared/Cargo.toml @@ -14,6 +14,7 @@ owo-colors = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } supports-color = "3" +thiserror = { workspace = true } tracing-subscriber = { workspace = true } vite_path = { workspace = true } vite_str = { workspace = true } diff --git a/crates/vite_shared/src/conventional_commit.rs b/crates/vite_shared/src/conventional_commit.rs new file mode 100644 index 0000000000..36ae23b81f --- /dev/null +++ b/crates/vite_shared/src/conventional_commit.rs @@ -0,0 +1,249 @@ +//! Conventional Commits parser used for release classification. +//! +//! References: +//! - Conventional Commits 1.0.0: https://www.conventionalcommits.org/en/v1.0.0/#specification +//! - Conventional Commits FAQ: https://www.conventionalcommits.org/en/v1.0.0/#faq + +/// Parsed Conventional Commit header/body information used by release classification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConventionalCommit<'a> { + /// Commit type, for example `feat`, `fix`, or `chore`. + pub kind: &'a str, + /// Optional scope extracted from `type(scope): description`. + pub scope: Option<&'a str>, + /// Header description after the first `:`. + pub description: &'a str, + /// Whether the commit advertises a breaking change via `!` or footer syntax. + pub breaking: bool, +} + +/// Parses a Conventional Commit subject/body pair. +/// +/// The parser intentionally extracts only the pieces that release classification needs: the type, +/// optional scope, human description, and whether the commit is breaking. +/// +/// # Examples +/// +/// ```rust +/// use vite_shared::parse_conventional_commit; +/// +/// let commit = parse_conventional_commit( +/// "feat(cli)!: add release command", +/// "BREAKING CHANGE: old release flow was removed", +/// ) +/// .unwrap(); +/// +/// assert_eq!(commit.kind, "feat"); +/// assert_eq!(commit.scope, Some("cli")); +/// assert_eq!(commit.description, "add release command"); +/// assert!(commit.breaking); +/// ``` +#[must_use] +pub fn parse_conventional_commit<'a>( + subject: &'a str, + body: &'a str, +) -> Option> { + // Header and BREAKING CHANGE footer parsing intentionally follows the Conventional Commits + // 1.0.0 grammar rather than git-conventional-commits dialects. + // https://www.conventionalcommits.org/en/v1.0.0/#specification + let header = subject.trim(); + if header.is_empty() { + return None; + } + + let (prefix, description) = header.split_once(':')?; + let prefix = prefix.trim(); + let description = description.trim(); + if prefix.is_empty() || description.is_empty() { + return None; + } + + let (kind_with_scope, breaking_header) = match prefix.strip_suffix('!') { + Some(prefix) => (prefix, true), + None => (prefix, false), + }; + + let (kind, scope) = match kind_with_scope.split_once('(') { + Some((kind, rest)) => { + let scope = rest.strip_suffix(')')?.trim(); + if scope.is_empty() { + return None; + } + (kind.trim(), Some(scope)) + } + None => (kind_with_scope.trim(), None), + }; + + if kind.is_empty() { + return None; + } + + let breaking = breaking_header + || body.lines().any(|line| { + let line = line.trim(); + line.starts_with("BREAKING CHANGE:") || line.starts_with("BREAKING-CHANGE:") + }); + + Some(ConventionalCommit { kind, scope, description, breaking }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_commit<'a>( + subject: &'a str, + body: &'a str, + kind: &'a str, + scope: Option<&'a str>, + description: &'a str, + breaking: bool, + ) { + let commit = parse_conventional_commit(subject, body).expect("commit should parse"); + assert_eq!(commit.kind, kind); + assert_eq!(commit.scope, scope); + assert_eq!(commit.description, description); + assert_eq!(commit.breaking, breaking); + } + + #[test] + fn parses_scope_and_breaking_marker() { + assert_commit("feat(cli)!: add release", "", "feat", Some("cli"), "add release", true); + } + + #[test] + fn parses_commits_without_scope() { + assert_commit("fix: handle cache miss", "", "fix", None, "handle cache miss", false); + } + + #[test] + fn trims_subject_parts() { + assert_commit( + " feat(parser): add support for colons: here ", + "", + "feat", + Some("parser"), + "add support for colons: here", + false, + ); + } + + #[test] + fn parses_breaking_header_without_scope() { + assert_commit("refactor!: split module", "", "refactor", None, "split module", true); + } + + #[test] + fn parses_scopes_with_symbols_used_in_package_names() { + assert_commit( + "build(pkg-utils/core): ship binary", + "", + "build", + Some("pkg-utils/core"), + "ship binary", + false, + ); + } + + #[test] + fn parses_breaking_change_footer() { + assert_commit( + "chore: cleanup", + "BREAKING CHANGE: changed API", + "chore", + None, + "cleanup", + true, + ); + } + + #[test] + fn parses_breaking_change_hyphenated_footer() { + assert_commit( + "feat: ship release", + "BREAKING-CHANGE: config file layout changed", + "feat", + None, + "ship release", + true, + ); + } + + #[test] + fn detects_breaking_footer_after_blank_line_and_indentation() { + assert_commit( + "feat(ui): refresh", + "\n BREAKING CHANGE: theme tokens moved", + "feat", + Some("ui"), + "refresh", + true, + ); + } + + #[test] + fn does_not_mark_non_breaking_body_text_as_breaking() { + assert_commit( + "docs: explain migration", + "This mentions BREAKING CHANGE but not as a footer.\nAlso BREAKING CHANGE without colon", + "docs", + None, + "explain migration", + false, + ); + } + + #[test] + fn breaking_header_wins_even_without_footer() { + assert_commit("feat!: ship api v2", "some body", "feat", None, "ship api v2", true); + } + + #[test] + fn returns_none_for_empty_scope() { + assert!(parse_conventional_commit("feat(): release", "").is_none()); + assert!(parse_conventional_commit("feat( ): release", "").is_none()); + } + + #[test] + fn returns_none_for_missing_separator_or_description() { + assert!(parse_conventional_commit("release prep", "").is_none()); + assert!(parse_conventional_commit("feat", "").is_none()); + assert!(parse_conventional_commit("feat:", "").is_none()); + assert!(parse_conventional_commit("feat: ", "").is_none()); + } + + #[test] + fn returns_none_for_missing_type() { + assert!(parse_conventional_commit(": description", "").is_none()); + assert!(parse_conventional_commit(" : description", "").is_none()); + } + + #[test] + fn returns_none_for_malformed_scope_syntax() { + assert!(parse_conventional_commit("feat(parser: release", "").is_none()); + assert!(parse_conventional_commit("feat)parser(: release", "").is_none()); + assert!(parse_conventional_commit("feat(parser) extra: release", "").is_none()); + } + + #[test] + fn only_uses_first_colon_as_header_separator() { + assert_commit( + "feat: add support: parser mode", + "", + "feat", + None, + "add support: parser mode", + false, + ); + } + + #[test] + fn preserves_case_of_kind_and_scope() { + assert_commit("Feat(API): allow preview", "", "Feat", Some("API"), "allow preview", false); + } + + #[test] + fn rejects_non_conventional_subjects() { + assert!(parse_conventional_commit("release prep", "").is_none()); + } +} diff --git a/crates/vite_shared/src/git.rs b/crates/vite_shared/src/git.rs new file mode 100644 index 0000000000..f81a57c8ef --- /dev/null +++ b/crates/vite_shared/src/git.rs @@ -0,0 +1,160 @@ +//! Shared git helpers used by release planning and repository introspection. +//! +//! These helpers intentionally use `std::process::Command` rather than an async runtime. The +//! commands here are short-lived process invocations in already-async callsites, so keeping the +//! shared utility crate synchronous avoids forcing a heavier dependency such as `tokio` onto every +//! consumer of `vite_shared`. + +use std::{ + process::{Command, Output}, + string::String, +}; + +use thiserror::Error; +use vite_path::AbsolutePath; + +/// Error raised while invoking git commands or interpreting their result. +#[derive(Debug, Error)] +pub enum GitError { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("{0}")] + Command(String), +} + +/// Runs `git` and returns trimmed stdout on success. +/// +/// The helper collects command arguments once up front so the same owned argument list can be used +/// for both process execution and rich error reporting. +pub fn capture_git(cwd: &AbsolutePath, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let args = collect_args(args); + let output = Command::new("git").args(&args).current_dir(cwd.as_path()).output()?; + + if !output.status.success() { + return Err(command_error(&args, &output)); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Runs `git` and returns only success or a structured command error. +pub fn run_git(cwd: &AbsolutePath, args: I) -> Result<(), GitError> +where + I: IntoIterator, + S: AsRef, +{ + let args = collect_args(args); + let output = Command::new("git").args(&args).current_dir(cwd.as_path()).output()?; + + if output.status.success() { + return Ok(()); + } + + Err(command_error(&args, &output)) +} + +/// Returns whether the current worktree has no staged or unstaged changes. +pub fn is_clean_git_worktree(cwd: &AbsolutePath) -> Result { + Ok(capture_git(cwd, ["status", "--porcelain"])?.trim().is_empty()) +} + +/// Extracts an `owner/repo` slug from common GitHub remote URL formats. +/// +/// # Examples +/// +/// ```rust +/// use vite_shared::parse_github_repo_slug; +/// +/// assert_eq!( +/// parse_github_repo_slug("git@github.com:voidzero-dev/vite-plus.git"), +/// Some("voidzero-dev/vite-plus".into()), +/// ); +/// assert_eq!( +/// parse_github_repo_slug("https://github.com/voidzero-dev/vite-plus"), +/// Some("voidzero-dev/vite-plus".into()), +/// ); +/// ``` +#[must_use] +pub fn parse_github_repo_slug(url: &str) -> Option { + let url = url.trim().trim_end_matches('/'); + let path = if let Some(path) = url.strip_prefix("github:") { + path + } else if let Some((_, path)) = url.split_once("github.com/") { + path + } else if let Some((_, path)) = url.rsplit_once("github.com:") { + path + } else { + return None; + }; + + let path = path.trim_start_matches('/').strip_suffix(".git").unwrap_or(path); + let mut segments = path.split('/').filter(|segment| !segment.is_empty()); + let owner = segments.next()?; + let repo = segments.next()?; + let mut slug = String::with_capacity(owner.len() + repo.len() + 1); + slug.push_str(owner); + slug.push('/'); + slug.push_str(repo); + Some(slug) +} + +fn collect_args(args: I) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + // Preserve one owned argument buffer for both process spawning and later error reporting. + let iter = args.into_iter(); + let (lower, _) = iter.size_hint(); + let mut collected = Vec::with_capacity(lower); + for arg in iter { + collected.push(arg.as_ref().to_owned()); + } + collected +} + +fn command_error(args: &[String], output: &Output) -> GitError { + // Construct the git invocation string directly rather than joining/formatting multiple + // temporaries, since this path is also used by release dry-runs and failure reporting. + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + let args_len: usize = args.iter().map(String::len).sum(); + let mut message = String::with_capacity(args_len + stderr.len() + 16 + args.len()); + message.push_str("`git"); + for arg in args { + message.push(' '); + message.push_str(arg); + } + message.push_str("` failed"); + if !stderr.is_empty() { + message.push_str(": "); + message.push_str(stderr); + } + GitError::Command(message) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_github_repo_slug_supports_common_remote_formats() { + assert_eq!( + parse_github_repo_slug("git@github.com:voidzero-dev/vite-plus.git"), + Some("voidzero-dev/vite-plus".into()) + ); + assert_eq!( + parse_github_repo_slug("https://github.com/voidzero-dev/vite-plus.git"), + Some("voidzero-dev/vite-plus".into()) + ); + assert_eq!( + parse_github_repo_slug("github:voidzero-dev/vite-plus"), + Some("voidzero-dev/vite-plus".into()) + ); + assert_eq!(parse_github_repo_slug("https://example.com/acme/repo.git"), None); + } +} diff --git a/crates/vite_shared/src/lib.rs b/crates/vite_shared/src/lib.rs index 33353b894e..6eea84b325 100644 --- a/crates/vite_shared/src/lib.rs +++ b/crates/vite_shared/src/lib.rs @@ -1,7 +1,9 @@ //! Shared utilities for vite-plus crates +pub mod conventional_commit; mod env_config; pub mod env_vars; +pub mod git; pub mod header; mod home; pub mod output; @@ -10,13 +12,25 @@ mod path_env; pub mod string_similarity; mod tls; mod tracing; +pub mod versioning; +pub use conventional_commit::{ConventionalCommit, parse_conventional_commit}; pub use env_config::{EnvConfig, TestEnvGuard}; -pub use home::get_vp_home; -pub use package_json::{DevEngines, Engines, PackageJson, RuntimeEngine, RuntimeEngineConfig}; +pub use git::{GitError, capture_git, is_clean_git_worktree, parse_github_repo_slug, run_git}; +pub use home::{get_vp_home, get_vp_home as get_vite_plus_home}; +pub use package_json::{ + DependencyProtocolSummary, DevEngines, Engines, PackageJson, PackageJsonError, PackageManifest, + PackageManifestDocument, PublishConfig, ReleaseLifecycle, RuntimeEngine, RuntimeEngineConfig, + VitePlusMetadata, WorkspaceReference, WorkspaceVersionSpec, parse_workspace_reference, + read_package_manifest, replace_dependency_version_ranges, replace_top_level_string_property, +}; pub use path_env::{ PrependOptions, PrependResult, format_path_prepended, format_path_with_prepend, prepend_to_path_env, }; pub use tls::ensure_tls_provider; pub use tracing::init_tracing; +pub use versioning::{ + Version, VersionBump, VersionError, VersionPattern, VersionPrefix, build_prerelease, + bump_version, parse_version_pattern, prerelease_channel, prerelease_number, strip_prerelease, +}; diff --git a/crates/vite_shared/src/package_json.rs b/crates/vite_shared/src/package_json.rs index 1e1cd56647..cf8a3a215f 100644 --- a/crates/vite_shared/src/package_json.rs +++ b/crates/vite_shared/src/package_json.rs @@ -1,11 +1,29 @@ -//! Package.json parsing utilities for Node.js version resolution. +//! `package.json` parsing and editing utilities shared across vite-plus crates. //! -//! This module provides shared types for parsing `devEngines.runtime` and `engines.node` -//! fields from package.json, used across multiple crates for version resolution. +//! The release flow relies on this module for two distinct jobs: +//! +//! - reading just enough manifest structure to plan and validate releases +//! - performing targeted string-preserving edits without reserializing the full JSON document +//! +//! Preserving the original source formatting matters for reviewability: release commits should be +//! narrow and predictable rather than full-document rewrites caused by JSON serialization. + +use std::{collections::BTreeMap, fs}; use serde::Deserialize; +use thiserror::Error; +use vite_path::AbsolutePath; use vite_str::Str; +use crate::versioning::{VersionError, VersionPattern, parse_version_pattern}; + +const JSON_OBJECT_START_BYTE: u8 = b'{'; +const JSON_OBJECT_END_BYTE: u8 = b'}'; +const JSON_STRING_DELIMITER_BYTE: u8 = b'"'; +const JSON_KEY_VALUE_SEPARATOR_BYTE: u8 = b':'; +const JSON_ESCAPE_PREFIX_BYTE: u8 = b'\\'; +const TOP_LEVEL_JSON_OBJECT_DEPTH: usize = 1; + /// A single runtime engine configuration. #[derive(Deserialize, Default, Debug, Clone)] #[serde(rename_all = "camelCase")] @@ -72,6 +90,657 @@ pub struct PackageJson { pub engines: Option, } +#[derive(Debug, Error)] +pub enum PackageJsonError { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error("{0}")] + Message(String), +} + +/// Subset of `publishConfig` used by release/publish flows. +/// +/// npm documents `publishConfig` as the package-level place to pin publish-time behavior such as +/// `access`, `tag`, and provenance preferences, which is why release planning reads it directly +/// from `package.json`. +/// https://docs.npmjs.com/cli/v11/configuring-npm/package-json/ +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PublishConfig { + /// npm access mode, typically `"public"` for first publish of scoped public packages. + #[serde(default)] + pub access: Option, + /// Default dist-tag to publish under when the release flow does not override it explicitly. + #[serde(default)] + pub tag: Option, + /// Explicit provenance preference for publish tools that honor npm-style publish config. + #[serde(default)] + pub provenance: Option, +} + +/// Summary of dependency protocols found in a package manifest. +/// +/// The release flow uses this compact bitset-like structure to decide whether the selected +/// publisher can safely rewrite manifest references before publish. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DependencyProtocolSummary { + pub workspace: bool, + pub catalog: bool, + pub file: bool, + pub link: bool, + pub portal: bool, + pub patch: bool, + pub jsr: bool, +} + +impl DependencyProtocolSummary { + /// Returns `true` when no special protocols were detected. + #[must_use] + pub const fn is_empty(self) -> bool { + !self.workspace + && !self.catalog + && !self.file + && !self.link + && !self.portal + && !self.patch + && !self.jsr + } +} + +/// Release lifecycle metadata stored under `vitePlus.release`. +/// +/// These fields let release planning survive package renames, moves, and retirement without +/// losing the ability to match previous tags or find the right commit history. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReleaseLifecycle { + /// Historical package names that should still match tag selection. + #[serde(default)] + pub previous_names: Vec, + /// Additional active paths whose commits should contribute commit history. + #[serde(default)] + pub tracked_paths: Vec, + /// Historical package paths that should still contribute commit history. + #[serde(default)] + pub previous_paths: Vec, + /// Released names that should no longer resolve to an active workspace package. + #[serde(default)] + pub retired_names: Vec, + /// Extra scripts to surface in the pre-release readiness summary. + #[serde(default)] + pub check_scripts: Vec, +} + +/// Top-level vite-plus metadata read from `package.json`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VitePlusMetadata { + #[serde(default)] + pub release: ReleaseLifecycle, +} + +/// Manifest subset used by vite-plus release/publish logic. +/// +/// The type stays intentionally partial so release planning can read just the fields it needs +/// without depending on a fully modeled `package.json`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PackageManifest { + #[serde(default)] + pub name: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub private: bool, + #[serde(default)] + pub publish_config: PublishConfig, + #[serde(default)] + pub repository: Option, + #[serde(default)] + pub vite_plus: VitePlusMetadata, + #[serde(default)] + pub scripts: BTreeMap, + #[serde(default)] + pub dependencies: BTreeMap, + #[serde(default)] + pub dev_dependencies: BTreeMap, + #[serde(default)] + pub peer_dependencies: BTreeMap, + #[serde(default)] + pub optional_dependencies: BTreeMap, +} + +impl PackageManifest { + /// Returns whether a named npm script exists in the manifest. + #[must_use] + pub fn has_script(&self, name: &str) -> bool { + self.scripts.contains_key(name) + } + + /// Returns the repository URL regardless of whether `repository` is a string or object form. + /// + /// npm accepts both the string shorthand and object form for `repository`, so the release + /// flow normalizes both into one accessor when validating publish metadata. + /// https://docs.npmjs.com/cli/v11/configuring-npm/package-json/ + #[must_use] + pub fn repository_url(&self) -> Option<&str> { + match self.repository.as_ref()? { + serde_json::Value::String(url) => Some(url.as_str()), + serde_json::Value::Object(repository) => repository.get("url")?.as_str(), + _ => None, + } + } + + /// Scans dependency sections and reports which non-trivial protocols are in use. + /// + /// Release safety only needs to know whether protocols such as `workspace:` or `catalog:` are + /// present anywhere in the manifest, so a compact summary is cheaper and easier to reason + /// about than carrying full per-dependency protocol metadata through the planner. + #[must_use] + pub fn dependency_protocol_summary(&self) -> DependencyProtocolSummary { + let mut summary = DependencyProtocolSummary::default(); + scan_dependency_versions(self.dependencies.values(), &mut summary); + scan_dependency_versions(self.dev_dependencies.values(), &mut summary); + scan_dependency_versions(self.peer_dependencies.values(), &mut summary); + scan_dependency_versions(self.optional_dependencies.values(), &mut summary); + summary + } +} + +/// A parsed manifest together with its original source text. +/// +/// Keeping the original text around allows top-level field rewrites without round-tripping the +/// entire JSON document through a serializer. +#[derive(Debug, Clone)] +pub struct PackageManifestDocument { + /// Original `package.json` source text. + pub contents: String, + /// Parsed manifest subset derived from `contents`. + pub manifest: PackageManifest, +} + +impl PackageManifestDocument { + /// Returns manifest contents with only the top-level `version` field updated. + pub fn updated_version_contents( + &self, + current_version: &str, + next_version: &str, + ) -> Result { + replace_top_level_string_property(&self.contents, "version", current_version, next_version) + } +} + +/// Reads and parses a package manifest while preserving the original file contents. +pub fn read_package_manifest( + path: &AbsolutePath, +) -> Result { + let contents = fs::read_to_string(path)?; + let manifest = serde_json::from_str(&contents)?; + Ok(PackageManifestDocument { contents, manifest }) +} + +/// Rewrites a single top-level string property without reserializing the whole JSON document. +/// +/// This is designed for version updates where preserving existing formatting and field order is +/// more important than supporting arbitrary JSON mutations. +/// +/// # Examples +/// +/// ```rust +/// use vite_shared::replace_top_level_string_property; +/// +/// let input = r#"{ +/// "version": "1.0.0", +/// "nested": { "version": "keep-me" } +/// }"#; +/// +/// let updated = +/// replace_top_level_string_property(input, "version", "1.0.0", "1.1.0").unwrap(); +/// +/// assert!(updated.contains(r#""version": "1.1.0""#)); +/// assert!(updated.contains(r#""version": "keep-me""#)); +/// ``` +pub fn replace_top_level_string_property( + contents: &str, + key: &str, + expected_value: &str, + new_value: &str, +) -> Result { + let bytes = contents.as_bytes(); + let mut depth = 0usize; + let mut index = 0usize; + + while index < bytes.len() { + match bytes[index] { + JSON_OBJECT_START_BYTE => { + depth += 1; + index += 1; + } + JSON_OBJECT_END_BYTE => { + depth = depth.saturating_sub(1); + index += 1; + } + JSON_STRING_DELIMITER_BYTE if depth == TOP_LEVEL_JSON_OBJECT_DEPTH => { + let Some((string_end, parsed_key)) = parse_json_string(contents, index) else { + break; + }; + let mut cursor = skip_json_whitespace(bytes, string_end + 1); + if parsed_key != key + || cursor >= bytes.len() + || bytes[cursor] != JSON_KEY_VALUE_SEPARATOR_BYTE + { + index = string_end + 1; + continue; + } + + cursor = skip_json_whitespace(bytes, cursor + 1); + if cursor >= bytes.len() || bytes[cursor] != JSON_STRING_DELIMITER_BYTE { + let mut message = String::from("Expected top-level '"); + message.push_str(key); + message.push_str("' to be a JSON string"); + return Err(PackageJsonError::Message(message)); + } + + let Some((value_end, parsed_value)) = parse_json_string(contents, cursor) else { + break; + }; + if parsed_value != expected_value { + let mut message = String::from("Expected '"); + message.push_str(key); + message.push_str("' to be '"); + message.push_str(expected_value); + message.push_str("' but found '"); + message.push_str(&parsed_value); + message.push('\''); + return Err(PackageJsonError::Message(message)); + } + + let mut updated = String::with_capacity(contents.len() + new_value.len()); + updated.push_str(&contents[..cursor + 1]); + updated.push_str(new_value); + updated.push_str(&contents[value_end..]); + return Ok(updated); + } + JSON_STRING_DELIMITER_BYTE => { + if let Some((string_end, _)) = parse_json_string(contents, index) { + index = string_end + 1; + } else { + break; + } + } + _ => index += 1, + } + } + + let mut message = String::from("Could not find top-level '"); + message.push_str(key); + message.push_str("' field in package.json"); + Err(PackageJsonError::Message(message)) +} + +/// Rewrites dependency version strings across the standard package dependency sections. +/// +/// The rewrite preserves the existing JSON formatting and key order by editing only the targeted +/// string literal values inside `dependencies`, `devDependencies`, `peerDependencies`, and +/// `optionalDependencies`. +pub fn replace_dependency_version_ranges( + contents: &str, + updates: &BTreeMap>, +) -> Result { + if updates.is_empty() { + return Ok(contents.to_owned()); + } + + const DEPENDENCY_SECTION_KEYS: [&str; 4] = + ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]; + + let bytes = contents.as_bytes(); + let mut depth = 0usize; + let mut index = 0usize; + let mut last_copied = 0usize; + let mut rewritten = String::with_capacity(contents.len()); + let mut changed = false; + + while index < bytes.len() { + match bytes[index] { + JSON_OBJECT_START_BYTE => { + depth += 1; + index += 1; + } + JSON_OBJECT_END_BYTE => { + depth = depth.saturating_sub(1); + index += 1; + } + JSON_STRING_DELIMITER_BYTE if depth == TOP_LEVEL_JSON_OBJECT_DEPTH => { + let Some((key_end, parsed_key)) = parse_json_string(contents, index) else { + break; + }; + let Some(section_updates) = updates.get(parsed_key.as_str()) else { + index = key_end + 1; + continue; + }; + let mut cursor = skip_json_whitespace(bytes, key_end + 1); + if !DEPENDENCY_SECTION_KEYS.contains(&parsed_key.as_str()) + || cursor >= bytes.len() + || bytes[cursor] != JSON_KEY_VALUE_SEPARATOR_BYTE + { + index = key_end + 1; + continue; + } + + cursor = skip_json_whitespace(bytes, cursor + 1); + if cursor >= bytes.len() || bytes[cursor] != JSON_OBJECT_START_BYTE { + let mut message = String::from("Expected top-level '"); + message.push_str(&parsed_key); + message.push_str("' to be a JSON object"); + return Err(PackageJsonError::Message(message)); + } + + let Some(object_end) = find_matching_object_end(contents, cursor) else { + let mut message = String::from("Could not parse top-level '"); + message.push_str(&parsed_key); + message.push_str("' object in package.json"); + return Err(PackageJsonError::Message(message)); + }; + + let section_contents = &contents[cursor..=object_end]; + let updated_section = + replace_flat_object_string_properties(section_contents, section_updates)?; + if updated_section != section_contents { + rewritten.push_str(&contents[last_copied..cursor]); + rewritten.push_str(&updated_section); + last_copied = object_end + 1; + changed = true; + } + + index = object_end + 1; + } + JSON_STRING_DELIMITER_BYTE => { + if let Some((string_end, _)) = parse_json_string(contents, index) { + index = string_end + 1; + } else { + break; + } + } + _ => index += 1, + } + } + + if !changed { + return Ok(contents.to_owned()); + } + + rewritten.push_str(&contents[last_copied..]); + Ok(rewritten) +} + +/// Workspace version selector after peeling off the `workspace:` protocol. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceVersionSpec { + Current, + Pattern(VersionPattern), +} + +/// Parsed form of a `workspace:` dependency reference. +/// +/// The parser distinguishes between relative path references, direct version/current references, +/// and aliased package references such as `workspace:@scope/pkg@^`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceReference<'a> { + RelativePath(&'a str), + Version(WorkspaceVersionSpec), + Alias { package: &'a str, spec: WorkspaceVersionSpec }, +} + +/// Parses a `workspace:` dependency spec into a structured representation. +/// +/// The exact publish-time rewrite differs by package manager, which is why this parser only +/// recognizes the syntax shape and leaves policy decisions to the release/publish layer: +/// +/// - npm RFC for `workspace:`: https://github.com/npm/rfcs/issues/765 +/// - pnpm workspaces: https://pnpm.io/workspaces +/// - Yarn `workspace:` protocol: https://yarnpkg.com/protocol/workspace +/// - Bun workspaces: https://bun.sh/docs/pm/workspaces +/// +/// # Examples +/// +/// ```rust +/// use vite_shared::{WorkspaceReference, WorkspaceVersionSpec, parse_workspace_reference}; +/// +/// assert!(matches!( +/// parse_workspace_reference("workspace:^").unwrap(), +/// WorkspaceReference::Version(WorkspaceVersionSpec::Pattern(_)), +/// )); +/// +/// assert!(matches!( +/// parse_workspace_reference("workspace:./packages/shared").unwrap(), +/// WorkspaceReference::RelativePath("./packages/shared"), +/// )); +/// +/// assert!(matches!( +/// parse_workspace_reference("workspace:@scope/pkg@~").unwrap(), +/// WorkspaceReference::Alias { package: "@scope/pkg", .. }, +/// )); +/// ``` +pub fn parse_workspace_reference(input: &str) -> Result, VersionError> { + // `workspace:` references are intentionally parsed without assuming one package manager's + // exact publish-time rewrite semantics. That logic lives higher up in release/publish flows. + let spec = input.strip_prefix("workspace:").ok_or_else(|| { + let mut message = String::from("not a workspace reference: '"); + message.push_str(input); + message.push('\''); + VersionError::Message(message) + })?; + + if spec.is_empty() { + return Ok(WorkspaceReference::Version(WorkspaceVersionSpec::Current)); + } + if spec.starts_with("./") || spec.starts_with("../") { + return Ok(WorkspaceReference::RelativePath(spec)); + } + + if let Some((package, pattern)) = split_workspace_alias(spec) { + return Ok(WorkspaceReference::Alias { + package, + spec: parse_workspace_version_spec(pattern)?, + }); + } + + Ok(WorkspaceReference::Version(parse_workspace_version_spec(spec)?)) +} + +fn parse_workspace_version_spec(input: &str) -> Result { + if input.is_empty() { + return Ok(WorkspaceVersionSpec::Current); + } + Ok(WorkspaceVersionSpec::Pattern(parse_version_pattern(input)?)) +} + +fn split_workspace_alias(input: &str) -> Option<(&str, &str)> { + if input.starts_with("./") || input.starts_with("../") { + return None; + } + let at_index = input.rfind('@')?; + let (package, pattern) = input.split_at(at_index); + if package.is_empty() { + return None; + } + Some((package, &pattern[1..])) +} + +fn scan_dependency_versions<'a, I>(versions: I, summary: &mut DependencyProtocolSummary) +where + I: IntoIterator, +{ + // Keep protocol detection conservative so release code can block unsafe publish paths before a + // raw `package.json` escapes with manager-specific protocols still intact. + for version in versions { + let version = version.as_str(); + if version.contains("workspace:") { + summary.workspace = true; + } + if version.starts_with("catalog:") { + summary.catalog = true; + } + if version.starts_with("file:") { + summary.file = true; + } + if version.starts_with("link:") { + summary.link = true; + } + if version.starts_with("portal:") { + summary.portal = true; + } + if version.starts_with("patch:") { + summary.patch = true; + } + if version.starts_with("jsr:") { + summary.jsr = true; + } + } +} + +fn replace_flat_object_string_properties( + contents: &str, + updates: &BTreeMap, +) -> Result { + let bytes = contents.as_bytes(); + let mut depth = 0usize; + let mut index = 0usize; + let mut last_copied = 0usize; + let mut rewritten = String::with_capacity(contents.len()); + let mut changed = false; + + while index < bytes.len() { + match bytes[index] { + JSON_OBJECT_START_BYTE => { + depth += 1; + index += 1; + } + JSON_OBJECT_END_BYTE => { + depth = depth.saturating_sub(1); + index += 1; + } + JSON_STRING_DELIMITER_BYTE if depth == TOP_LEVEL_JSON_OBJECT_DEPTH => { + let Some((key_end, parsed_key)) = parse_json_string(contents, index) else { + break; + }; + let Some(new_value) = updates.get(parsed_key.as_str()) else { + index = key_end + 1; + continue; + }; + + let mut cursor = skip_json_whitespace(bytes, key_end + 1); + if cursor >= bytes.len() || bytes[cursor] != JSON_KEY_VALUE_SEPARATOR_BYTE { + index = key_end + 1; + continue; + } + + cursor = skip_json_whitespace(bytes, cursor + 1); + if cursor >= bytes.len() || bytes[cursor] != JSON_STRING_DELIMITER_BYTE { + let mut message = String::from("Expected dependency '"); + message.push_str(&parsed_key); + message.push_str("' to have a JSON string version"); + return Err(PackageJsonError::Message(message)); + } + + let Some((value_end, _)) = parse_json_string(contents, cursor) else { + break; + }; + rewritten.push_str(&contents[last_copied..cursor + 1]); + rewritten.push_str(new_value); + last_copied = value_end; + index = value_end + 1; + changed = true; + } + JSON_STRING_DELIMITER_BYTE => { + if let Some((string_end, _)) = parse_json_string(contents, index) { + index = string_end + 1; + } else { + break; + } + } + _ => index += 1, + } + } + + if !changed { + return Ok(contents.to_owned()); + } + + rewritten.push_str(&contents[last_copied..]); + Ok(rewritten) +} + +fn find_matching_object_end(contents: &str, start: usize) -> Option { + let bytes = contents.as_bytes(); + if bytes.get(start) != Some(&JSON_OBJECT_START_BYTE) { + return None; + } + + let mut depth = 0usize; + let mut index = start; + while index < bytes.len() { + match bytes[index] { + JSON_OBJECT_START_BYTE => { + depth += 1; + index += 1; + } + JSON_OBJECT_END_BYTE => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(index); + } + index += 1; + } + JSON_STRING_DELIMITER_BYTE => { + if let Some((string_end, _)) = parse_json_string(contents, index) { + index = string_end + 1; + } else { + return None; + } + } + _ => index += 1, + } + } + + None +} + +fn parse_json_string(contents: &str, start: usize) -> Option<(usize, String)> { + let bytes = contents.as_bytes(); + if bytes.get(start) != Some(&JSON_STRING_DELIMITER_BYTE) { + return None; + } + + let mut escaped = false; + let mut index = start + 1; + while index < bytes.len() { + let byte = bytes[index]; + if escaped { + escaped = false; + } else if byte == JSON_ESCAPE_PREFIX_BYTE { + escaped = true; + } else if byte == JSON_STRING_DELIMITER_BYTE { + let raw = &contents[start..=index]; + let value: String = serde_json::from_str(raw).ok()?; + return Some((index, value)); + } + index += 1; + } + + None +} + +fn skip_json_whitespace(bytes: &[u8], mut index: usize) -> usize { + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + index +} + #[cfg(test)] mod tests { use super::*; @@ -198,4 +867,377 @@ mod tests { let node = runtime.find_by_name("node").unwrap(); assert_eq!(node.version, "^24.4.0"); } + + #[test] + fn detects_publish_protocols_across_dependency_sections() { + let manifest: PackageManifest = serde_json::from_str( + r#"{ + "dependencies": { + "@scope/pkg-a": "workspace:*", + "@scope/pkg-b": "workspace:^", + "@scope/pkg-c": "workspace:~" + }, + "devDependencies": { + "react": "catalog:" + }, + "peerDependencies": { + "@scope/pkg-b": "^1.0.0 || workspace:>" + }, + "optionalDependencies": { + "patched": "patch:patched@npm:patched@1.0.0#./patch.patch" + } + }"#, + ) + .unwrap(); + + let summary = manifest.dependency_protocol_summary(); + assert!(summary.workspace); + assert!(summary.catalog); + assert!(summary.patch); + assert!(!summary.file); + } + + #[test] + fn repository_url_supports_string_and_object_forms() { + let manifest: PackageManifest = serde_json::from_str( + r#"{ + "repository": "https://github.com/voidzero-dev/vite-plus.git" + }"#, + ) + .unwrap(); + assert_eq!( + manifest.repository_url(), + Some("https://github.com/voidzero-dev/vite-plus.git") + ); + + let manifest: PackageManifest = serde_json::from_str( + r#"{ + "repository": { + "type": "git", + "url": "git@github.com:voidzero-dev/vite-plus.git" + } + }"#, + ) + .unwrap(); + assert_eq!(manifest.repository_url(), Some("git@github.com:voidzero-dev/vite-plus.git")); + } + + #[test] + fn repository_url_ignores_unsupported_repository_shapes() { + let manifest: PackageManifest = serde_json::from_str( + r#"{ + "repository": { + "type": "git" + } + }"#, + ) + .unwrap(); + assert_eq!(manifest.repository_url(), None); + + let manifest: PackageManifest = serde_json::from_str( + r#"{ + "repository": 42 + }"#, + ) + .unwrap(); + assert_eq!(manifest.repository_url(), None); + } + + #[test] + fn parses_publish_config_access_and_tag() { + let manifest: PackageManifest = serde_json::from_str( + r#"{ + "publishConfig": { + "access": "public", + "tag": "next", + "provenance": true + } + }"#, + ) + .unwrap(); + + assert_eq!(manifest.publish_config.access.as_deref(), Some("public")); + assert_eq!(manifest.publish_config.tag.as_deref(), Some("next")); + assert_eq!(manifest.publish_config.provenance, Some(true)); + } + + #[test] + fn parses_vite_plus_release_lifecycle_metadata() { + let manifest: PackageManifest = serde_json::from_str( + r#"{ + "vitePlus": { + "release": { + "previousNames": ["@scope/old-name"], + "trackedPaths": ["crates"], + "previousPaths": ["packages/old-name"], + "retiredNames": ["@scope/older-name"], + "checkScripts": ["release:verify", "release:pack"] + } + } + }"#, + ) + .unwrap(); + + assert_eq!(manifest.vite_plus.release.previous_names, vec!["@scope/old-name"]); + assert_eq!(manifest.vite_plus.release.tracked_paths, vec!["crates"]); + assert_eq!(manifest.vite_plus.release.previous_paths, vec!["packages/old-name"]); + assert_eq!(manifest.vite_plus.release.retired_names, vec!["@scope/older-name"]); + assert_eq!( + manifest.vite_plus.release.check_scripts, + vec!["release:verify", "release:pack"] + ); + } + + #[test] + fn parses_scripts_and_detects_script_presence() { + let manifest: PackageManifest = serde_json::from_str( + r#"{ + "scripts": { + "build": "tsdown", + "release:verify": "pnpm test" + } + }"#, + ) + .unwrap(); + + assert!(manifest.has_script("build")); + assert!(manifest.has_script("release:verify")); + assert!(!manifest.has_script("pack")); + } + + #[test] + fn dependency_protocol_summary_is_empty_for_plain_semver_ranges() { + let manifest: PackageManifest = serde_json::from_str( + r#"{ + "dependencies": { + "pkg-a": "^1.0.0" + }, + "devDependencies": { + "pkg-b": "~2.0.0" + } + }"#, + ) + .unwrap(); + + assert!(manifest.dependency_protocol_summary().is_empty()); + } + + #[test] + fn detects_link_portal_file_and_jsr_protocols() { + let manifest: PackageManifest = serde_json::from_str( + r#"{ + "dependencies": { + "pkg-a": "file:../pkg-a", + "pkg-b": "link:../pkg-b", + "pkg-c": "portal:../pkg-c", + "pkg-d": "jsr:@std/assert" + } + }"#, + ) + .unwrap(); + + let summary = manifest.dependency_protocol_summary(); + assert!(summary.file); + assert!(summary.link); + assert!(summary.portal); + assert!(summary.jsr); + } + + #[test] + fn replace_top_level_string_property_only_updates_top_level_field() { + let contents = r#"{ + "version": "1.0.0", + "nested": { + "version": "should-stay" + } +} +"#; + + let updated = + replace_top_level_string_property(contents, "version", "1.0.0", "2.0.0").unwrap(); + assert!(updated.contains(r#""version": "2.0.0""#)); + assert!(updated.contains(r#""version": "should-stay""#)); + } + + #[test] + fn replace_top_level_string_property_handles_escaped_strings() { + let contents = r#"{ + "name": "pkg-a", + "description": "says \"version\" here", + "version": "1.0.0" +} +"#; + + let updated = + replace_top_level_string_property(contents, "version", "1.0.0", "1.0.1").unwrap(); + assert!(updated.contains(r#""description": "says \"version\" here""#)); + assert!(updated.contains(r#""version": "1.0.1""#)); + } + + #[test] + fn replace_top_level_string_property_rejects_non_string_field() { + let contents = r#"{ + "version": 1 +} +"#; + + let error = + replace_top_level_string_property(contents, "version", "1.0.0", "1.0.1").unwrap_err(); + assert!(error.to_string().contains("Expected top-level 'version' to be a JSON string")); + } + + #[test] + fn replace_top_level_string_property_errors_when_field_is_missing() { + let contents = r#"{ + "name": "pkg-a" +} +"#; + + let error = + replace_top_level_string_property(contents, "version", "1.0.0", "1.0.1").unwrap_err(); + assert!(error.to_string().contains("Could not find top-level 'version' field")); + } + + #[test] + fn replace_dependency_version_ranges_updates_known_sections_only() { + let contents = r#"{ + "name": "pkg-a", + "dependencies": { + "pkg-b": "^1.0.0", + "pkg-c": "workspace:*" + }, + "peerDependencies": { + "pkg-b": "~1.0.0" + } +} +"#; + + let updates = BTreeMap::from([ + ( + "dependencies".to_string(), + BTreeMap::from([("pkg-b".to_string(), "^1.1.0".to_string())]), + ), + ( + "peerDependencies".to_string(), + BTreeMap::from([("pkg-b".to_string(), "~1.1.0".to_string())]), + ), + ]); + let updated = replace_dependency_version_ranges(contents, &updates).unwrap(); + + assert!(updated.contains(r#""pkg-b": "^1.1.0""#)); + assert!(updated.contains(r#""pkg-b": "~1.1.0""#)); + assert!(updated.contains(r#""pkg-c": "workspace:*""#)); + } + + #[test] + fn replace_dependency_version_ranges_preserves_unmatched_sections() { + let contents = r#"{ + "name": "pkg-a", + "scripts": { + "build": "vite build" + }, + "dependencies": { + "pkg-b": "1.0.0" + } +} +"#; + + let updates = BTreeMap::from([( + "dependencies".to_string(), + BTreeMap::from([("pkg-c".to_string(), "1.1.0".to_string())]), + )]); + let updated = replace_dependency_version_ranges(contents, &updates).unwrap(); + + assert_eq!(updated, contents); + } + + #[test] + fn replace_dependency_version_ranges_rejects_non_object_sections() { + let contents = r#"{ + "dependencies": [] +} +"#; + + let updates = BTreeMap::from([( + "dependencies".to_string(), + BTreeMap::from([("pkg-a".to_string(), "1.1.0".to_string())]), + )]); + let error = replace_dependency_version_ranges(contents, &updates).unwrap_err(); + + assert!( + error.to_string().contains("Expected top-level 'dependencies' to be a JSON object") + ); + } + + #[test] + fn replace_dependency_version_ranges_rejects_non_string_dependency_versions() { + let contents = r#"{ + "dependencies": { + "pkg-a": 1 + } +} +"#; + + let updates = BTreeMap::from([( + "dependencies".to_string(), + BTreeMap::from([("pkg-a".to_string(), "1.1.0".to_string())]), + )]); + let error = replace_dependency_version_ranges(contents, &updates).unwrap_err(); + + assert!( + error.to_string().contains("Expected dependency 'pkg-a' to have a JSON string version") + ); + } + + #[test] + fn parses_workspace_references_with_current_and_range_tokens() { + assert_eq!( + parse_workspace_reference("workspace:").unwrap(), + WorkspaceReference::Version(WorkspaceVersionSpec::Current) + ); + assert_eq!( + parse_workspace_reference("workspace:^").unwrap(), + WorkspaceReference::Version(WorkspaceVersionSpec::Pattern( + parse_version_pattern("^").unwrap() + )) + ); + assert_eq!( + parse_workspace_reference("workspace:~1.2.3").unwrap(), + WorkspaceReference::Version(WorkspaceVersionSpec::Pattern( + parse_version_pattern("~1.2.3").unwrap() + )) + ); + } + + #[test] + fn parses_workspace_references_with_aliases_and_paths() { + assert_eq!( + parse_workspace_reference("workspace:@scope/pkg@^").unwrap(), + WorkspaceReference::Alias { + package: "@scope/pkg", + spec: WorkspaceVersionSpec::Pattern(parse_version_pattern("^").unwrap()), + } + ); + assert_eq!( + parse_workspace_reference("workspace:../pkg-a").unwrap(), + WorkspaceReference::RelativePath("../pkg-a") + ); + } + + #[test] + fn parse_workspace_reference_rejects_non_workspace_inputs() { + let error = parse_workspace_reference("^1.0.0").unwrap_err(); + assert!(error.to_string().contains("not a workspace reference")); + } + + #[test] + fn parses_workspace_alias_with_current_version_selector() { + assert_eq!( + parse_workspace_reference("workspace:@scope/pkg@").unwrap(), + WorkspaceReference::Alias { + package: "@scope/pkg", + spec: WorkspaceVersionSpec::Current, + } + ); + } } diff --git a/crates/vite_shared/src/versioning.rs b/crates/vite_shared/src/versioning.rs new file mode 100644 index 0000000000..de5e261d62 --- /dev/null +++ b/crates/vite_shared/src/versioning.rs @@ -0,0 +1,827 @@ +//! Scratch SemVer parser and version-line helpers used by release flows. +//! +//! References: +//! - SemVer 2.0.0: https://semver.org/spec/v2.0.0.html +//! - SemVer FAQ for `0.y.z`: https://semver.org/#faq + +use std::{fmt, fmt::Write as _, str::FromStr}; + +use thiserror::Error; + +/// Error produced while parsing or synthesizing versions and version patterns. +/// +/// The parser in this module is intentionally scratch-built for the release flow, so callers only +/// need a human-readable message explaining which SemVer rule was violated. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum VersionError { + #[error("{0}")] + Message(String), +} + +/// A parsed semantic version with optional prerelease and build metadata. +/// +/// The release command uses this type instead of depending on an external SemVer crate so that the +/// accepted syntax, error messages, and bump behavior remain fully under vite-plus control. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Version { + /// Major version number. + pub major: u64, + /// Minor version number. + pub minor: u64, + /// Patch version number. + pub patch: u64, + prerelease: Option, + build: Option, +} + +impl Version { + /// Creates a core version without prerelease or build metadata. + #[must_use] + pub const fn new(major: u64, minor: u64, patch: u64) -> Self { + Self { major, minor, patch, prerelease: None, build: None } + } + + /// Parses a SemVer string into a [`Version`]. + /// + /// # Examples + /// + /// ```rust + /// use vite_shared::Version; + /// + /// let version = Version::parse("1.2.3-beta.1+build.5").unwrap(); + /// assert_eq!(version.major, 1); + /// assert_eq!(version.prerelease(), Some("beta.1")); + /// assert_eq!(version.build(), Some("build.5")); + /// ``` + pub fn parse(input: &str) -> Result { + input.parse() + } + + /// Returns the prerelease portion, if present. + #[must_use] + pub fn prerelease(&self) -> Option<&str> { + self.prerelease.as_deref() + } + + /// Returns the build metadata portion, if present. + #[must_use] + pub fn build(&self) -> Option<&str> { + self.build.as_deref() + } + + /// Returns `true` when this version is a prerelease. + #[must_use] + pub fn has_prerelease(&self) -> bool { + self.prerelease.is_some() + } + + /// Replaces the prerelease component. + pub fn set_prerelease(&mut self, prerelease: Option) { + self.prerelease = prerelease; + } + + /// Removes build metadata in place. + pub fn clear_build(&mut self) { + self.build = None; + } +} + +impl fmt::Display for Version { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?; + if let Some(prerelease) = self.prerelease() { + write!(f, "-{prerelease}")?; + } + if let Some(build) = self.build() { + write!(f, "+{build}")?; + } + Ok(()) + } +} + +impl FromStr for Version { + type Err = VersionError; + + fn from_str(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + return Err(VersionError::Message("version cannot be empty".into())); + } + + let (without_build, build) = split_once_checked(input, '+', "build metadata", true)?; + let (core, prerelease) = split_once_checked(without_build, '-', "prerelease", false)?; + + let mut segments = core.split('.'); + let major = parse_core_number(segments.next(), "major")?; + let minor = parse_core_number(segments.next(), "minor")?; + let patch = parse_core_number(segments.next(), "patch")?; + if segments.next().is_some() { + return Err(VersionError::Message(invalid_core_segment_count_message(input))); + } + + let prerelease = match prerelease { + Some(value) => Some(validate_identifiers(value, true, "prerelease")?), + None => None, + }; + let build = match build { + Some(value) => Some(validate_identifiers(value, false, "build metadata")?), + None => None, + }; + + Ok(Self { major, minor, patch, prerelease, build }) + } +} + +/// Release-level bump classification used by vite-plus release planning. +/// +/// `Alpha`, `Beta`, and `Rc` are prerelease channels rather than base SemVer increments, which is +/// why [`VersionBump::is_version_bump`] returns `false` for them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum VersionBump { + Alpha, + Beta, + Rc, + Patch, + Minor, + Major, +} + +impl VersionBump { + /// Returns the CLI/config spelling of the bump level. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Alpha => "alpha", + Self::Beta => "beta", + Self::Rc => "rc", + Self::Patch => "patch", + Self::Minor => "minor", + Self::Major => "major", + } + } + + /// Returns whether this value changes the SemVer core version line. + #[must_use] + pub const fn is_version_bump(self) -> bool { + matches!(self, Self::Patch | Self::Minor | Self::Major) + } +} + +/// Prefix used when serializing dependency ranges such as `^1.2.3` or `~1.2.3`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VersionPrefix { + Exact, + Caret, + Tilde, +} + +impl VersionPrefix { + /// Returns the string prefix used in package manifest ranges. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Exact => "", + Self::Caret => "^", + Self::Tilde => "~", + } + } +} + +/// A workspace-friendly version selector. +/// +/// This is broader than raw SemVer because monorepo protocols often need to represent `*`, bare +/// `^` / `~`, or a concrete version with one of those prefixes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VersionPattern { + Any, + Token(VersionPrefix), + Version { prefix: VersionPrefix, version: Version }, +} + +impl fmt::Display for VersionPattern { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Any => f.write_str("*"), + Self::Token(prefix) => f.write_str(prefix.as_str()), + Self::Version { prefix, version } => write!(f, "{}{version}", prefix.as_str()), + } + } +} + +/// Parses a dependency version token such as `1.2.3`, `^1.2.3`, `~`, or `*`. +/// +/// # Examples +/// +/// ```rust +/// use vite_shared::{VersionPattern, VersionPrefix, parse_version_pattern}; +/// +/// assert_eq!(parse_version_pattern("*").unwrap(), VersionPattern::Any); +/// assert_eq!( +/// parse_version_pattern("^").unwrap(), +/// VersionPattern::Token(VersionPrefix::Caret), +/// ); +/// assert_eq!(parse_version_pattern("~1.2.3").unwrap().to_string(), "~1.2.3"); +/// ``` +pub fn parse_version_pattern(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + return Err(VersionError::Message("version pattern cannot be empty".into())); + } + + if input == "*" { + return Ok(VersionPattern::Any); + } + + let (prefix, rest) = if let Some(rest) = input.strip_prefix('^') { + (VersionPrefix::Caret, rest) + } else if let Some(rest) = input.strip_prefix('~') { + (VersionPrefix::Tilde, rest) + } else { + (VersionPrefix::Exact, input) + }; + + if rest.is_empty() { + return match prefix { + VersionPrefix::Exact => { + Err(VersionError::Message("bare version token must be '^', '~', or '*'".into())) + } + VersionPrefix::Caret | VersionPrefix::Tilde => Ok(VersionPattern::Token(prefix)), + }; + } + + Ok(VersionPattern::Version { prefix, version: Version::parse(rest)? }) +} + +/// Applies a SemVer core bump and drops prerelease/build metadata from the source version. +/// +/// Prerelease channel bumps are intentionally rejected here because they are handled by the +/// release-layer logic that decides how prerelease channels advance on top of a base version. +/// +/// # Examples +/// +/// ```rust +/// use vite_shared::{Version, VersionBump, bump_version}; +/// +/// let current = Version::parse("1.2.3-alpha.2+build.7").unwrap(); +/// let next = bump_version(¤t, VersionBump::Minor); +/// +/// assert_eq!(next.to_string(), "1.3.0"); +/// ``` +#[must_use] +pub fn bump_version(version: &Version, bump: VersionBump) -> Version { + // Base version increments follow the SemVer core rules for MAJOR/MINOR/PATCH. + // https://semver.org/spec/v2.0.0.html + let mut next = strip_prerelease(version); + match bump { + VersionBump::Alpha | VersionBump::Beta | VersionBump::Rc => { + unreachable!("prerelease channels do not directly bump the base semver") + } + VersionBump::Patch => { + next.patch += 1; + } + VersionBump::Minor => { + next.minor += 1; + next.patch = 0; + } + VersionBump::Major => { + next.major += 1; + next.minor = 0; + next.patch = 0; + } + } + next +} + +/// Returns a copy of the version without prerelease or build metadata. +#[must_use] +pub fn strip_prerelease(version: &Version) -> Version { + let mut stripped = version.clone(); + stripped.set_prerelease(None); + stripped.clear_build(); + stripped +} + +/// Returns the first prerelease identifier, which vite-plus treats as the prerelease channel. +#[must_use] +pub fn prerelease_channel(version: &Version) -> Option<&str> { + version.prerelease()?.split('.').next().filter(|segment| !segment.is_empty()) +} + +/// Returns the trailing numeric prerelease suffix when the prerelease is shaped like `alpha.3`. +#[must_use] +pub fn prerelease_number(version: &Version) -> Option { + let prerelease = version.prerelease()?; + let (_, number) = prerelease.rsplit_once('.')?; + number.parse().ok() +} + +/// Builds a prerelease identifier such as `alpha.0` or `rc.12`. +/// +/// The channel is validated with the same identifier rules as parsed prerelease strings so that +/// generated versions round-trip through the parser. +/// +/// # Examples +/// +/// ```rust +/// use vite_shared::build_prerelease; +/// +/// assert_eq!(build_prerelease("alpha", 0).unwrap(), "alpha.0"); +/// assert_eq!(build_prerelease("rc", 12).unwrap(), "rc.12"); +/// ``` +pub fn build_prerelease(channel: &str, number: u64) -> Result { + let channel = validate_identifiers(channel, false, "prerelease channel")?; + let mut prerelease = String::with_capacity(channel.len() + 24); + prerelease.push_str(&channel); + prerelease.push('.'); + let _ = write!(prerelease, "{number}"); + Ok(prerelease) +} + +fn split_once_checked<'a>( + input: &'a str, + delimiter: char, + label: &str, + reject_repeated_delimiter: bool, +) -> Result<(&'a str, Option<&'a str>), VersionError> { + match input.split_once(delimiter) { + Some((head, tail)) => { + if head.is_empty() + || tail.is_empty() + || (reject_repeated_delimiter && tail.contains(delimiter)) + { + Err(VersionError::Message(invalid_delimited_version_message(label, input))) + } else { + Ok((head, Some(tail))) + } + } + None => Ok((input, None)), + } +} + +fn parse_core_number(value: Option<&str>, label: &str) -> Result { + let value = value.ok_or_else(|| VersionError::Message(missing_core_segment_message(label)))?; + if value.is_empty() { + return Err(VersionError::Message(empty_core_segment_message(label))); + } + if value.len() > 1 && value.starts_with('0') { + return Err(VersionError::Message(leading_zero_core_segment_message(label, value))); + } + value + .parse::() + .map_err(|_| VersionError::Message(non_numeric_core_segment_message(label, value))) +} + +fn validate_identifiers( + input: &str, + disallow_numeric_leading_zero: bool, + label: &str, +) -> Result { + if input.is_empty() { + return Err(VersionError::Message(empty_identifier_message(label))); + } + + for identifier in input.split('.') { + if identifier.is_empty() { + return Err(VersionError::Message(empty_identifier_in_value_message(label, input))); + } + if !identifier.bytes().all(is_valid_identifier_char) { + return Err(VersionError::Message(invalid_identifier_chars_message(label, identifier))); + } + if disallow_numeric_leading_zero + && identifier.len() > 1 + && identifier.starts_with('0') + && identifier.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(VersionError::Message(leading_zero_identifier_message(label, identifier))); + } + } + + Ok(input.to_owned()) +} + +fn invalid_core_segment_count_message(input: &str) -> String { + let mut message = String::from("invalid version '"); + message.push_str(input); + message.push_str("': expected exactly 3 numeric core segments"); + message +} + +fn invalid_delimited_version_message(label: &str, input: &str) -> String { + let mut message = String::from("invalid "); + message.push_str(label); + message.push_str(" in version '"); + message.push_str(input); + message.push('\''); + message +} + +fn missing_core_segment_message(label: &str) -> String { + let mut message = String::from("invalid version: missing "); + message.push_str(label); + message.push_str(" segment"); + message +} + +fn empty_core_segment_message(label: &str) -> String { + let mut message = String::from("invalid version: empty "); + message.push_str(label); + message.push_str(" segment"); + message +} + +fn leading_zero_core_segment_message(label: &str, value: &str) -> String { + let mut message = String::from("invalid version: "); + message.push_str(label); + message.push_str(" segment '"); + message.push_str(value); + message.push_str("' has a leading zero"); + message +} + +fn non_numeric_core_segment_message(label: &str, value: &str) -> String { + let mut message = String::from("invalid version: "); + message.push_str(label); + message.push_str(" segment '"); + message.push_str(value); + message.push_str("' is not numeric"); + message +} + +fn empty_identifier_message(label: &str) -> String { + let mut message = String::new(); + message.push_str(label); + message.push_str(" cannot be empty"); + message +} + +fn empty_identifier_in_value_message(label: &str, input: &str) -> String { + let mut message = String::new(); + message.push_str(label); + message.push_str(" identifier cannot be empty in '"); + message.push_str(input); + message.push('\''); + message +} + +fn invalid_identifier_chars_message(label: &str, identifier: &str) -> String { + let mut message = String::new(); + message.push_str(label); + message.push_str(" identifier '"); + message.push_str(identifier); + message.push_str("' contains invalid characters"); + message +} + +fn leading_zero_identifier_message(label: &str, identifier: &str) -> String { + let mut message = String::new(); + message.push_str(label); + message.push_str(" numeric identifier '"); + message.push_str(identifier); + message.push_str("' has a leading zero"); + message +} + +fn is_valid_identifier_char(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'-' +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_version_error_contains(input: &str, expected: &str) { + let error = Version::parse(input).unwrap_err(); + assert!( + error.to_string().contains(expected), + "expected error for '{input}' to contain '{expected}', got '{error}'" + ); + } + + fn assert_pattern_error_contains(input: &str, expected: &str) { + let error = parse_version_pattern(input).unwrap_err(); + assert!( + error.to_string().contains(expected), + "expected pattern error for '{input}' to contain '{expected}', got '{error}'" + ); + } + + #[test] + fn parses_plain_versions() { + let version = Version::parse("1.2.3").unwrap(); + assert_eq!(version.major, 1); + assert_eq!(version.minor, 2); + assert_eq!(version.patch, 3); + assert_eq!(version.prerelease(), None); + assert_eq!(version.build(), None); + } + + #[test] + fn parses_prerelease_and_build_metadata() { + let version = Version::parse("1.2.3-alpha.1+build.5").unwrap(); + assert_eq!(version.prerelease(), Some("alpha.1")); + assert_eq!(version.build(), Some("build.5")); + assert_eq!(version.to_string(), "1.2.3-alpha.1+build.5"); + } + + #[test] + fn allows_hyphens_inside_prerelease_identifiers() { + let version = Version::parse("1.2.3-alpha-beta.1").unwrap(); + assert_eq!(version.prerelease(), Some("alpha-beta.1")); + } + + #[test] + fn trims_surrounding_whitespace_before_parsing() { + let version = Version::parse(" \n\t1.2.3-rc.1+build.9 \t").unwrap(); + assert_eq!(version.to_string(), "1.2.3-rc.1+build.9"); + } + + #[test] + fn parses_zero_major_versions_and_numeric_zero_prereleases() { + let version = Version::parse("0.0.0-0+001").unwrap(); + assert_eq!(version.major, 0); + assert_eq!(version.minor, 0); + assert_eq!(version.patch, 0); + assert_eq!(version.prerelease(), Some("0")); + assert_eq!(version.build(), Some("001")); + } + + #[test] + fn parses_maximum_u64_core_segments() { + let version = + Version::parse("18446744073709551615.18446744073709551615.18446744073709551615") + .unwrap(); + assert_eq!(version.major, u64::MAX); + assert_eq!(version.minor, u64::MAX); + assert_eq!(version.patch, u64::MAX); + } + + #[test] + fn allows_leading_zero_build_identifiers() { + let version = Version::parse("1.2.3+001.0002").unwrap(); + assert_eq!(version.build(), Some("001.0002")); + } + + #[test] + fn allows_alphanumeric_prerelease_identifiers_with_leading_zero() { + let version = Version::parse("1.2.3-alpha.01a").unwrap(); + assert_eq!(version.prerelease(), Some("alpha.01a")); + } + + #[test] + fn rejects_empty_versions() { + assert_version_error_contains("", "version cannot be empty"); + assert_version_error_contains(" ", "version cannot be empty"); + } + + #[test] + fn rejects_incomplete_core_segments() { + assert_version_error_contains("1.2", "missing patch segment"); + assert_version_error_contains("1", "missing minor segment"); + } + + #[test] + fn rejects_extra_core_segments() { + assert_version_error_contains("1.2.3.4", "expected exactly 3 numeric core segments"); + } + + #[test] + fn rejects_leading_zero_core_segments() { + assert_version_error_contains("01.2.3", "major segment '01' has a leading zero"); + assert_version_error_contains("1.02.3", "minor segment '02' has a leading zero"); + assert_version_error_contains("1.2.03", "patch segment '03' has a leading zero"); + } + + #[test] + fn rejects_invalid_core_characters() { + assert_version_error_contains("1.x.3", "minor segment 'x' is not numeric"); + } + + #[test] + fn rejects_empty_core_segments() { + assert_version_error_contains(".1.2", "empty major segment"); + assert_version_error_contains("1..2", "empty minor segment"); + assert_version_error_contains("1.2.", "empty patch segment"); + assert_version_error_contains("1.2.-3", "empty patch segment"); + } + + #[test] + fn rejects_overflowing_core_segments() { + assert_version_error_contains("18446744073709551616.0.0", "major segment"); + assert_version_error_contains("0.18446744073709551616.0", "minor segment"); + assert_version_error_contains("0.0.18446744073709551616", "patch segment"); + } + + #[test] + fn rejects_empty_prerelease_identifiers() { + assert_version_error_contains("1.2.3-", "invalid prerelease in version"); + assert_version_error_contains( + "1.2.3-alpha..1", + "prerelease identifier cannot be empty in 'alpha..1'", + ); + } + + #[test] + fn rejects_leading_zero_numeric_prerelease_identifiers() { + assert_version_error_contains( + "1.2.3-alpha.01", + "numeric identifier '01' has a leading zero", + ); + assert_version_error_contains("1.2.3-01", "numeric identifier '01' has a leading zero"); + } + + #[test] + fn rejects_invalid_prerelease_and_build_characters() { + assert_version_error_contains( + "1.2.3-alpha_1", + "prerelease identifier 'alpha_1' contains invalid characters", + ); + assert_version_error_contains( + "1.2.3+build_1", + "build metadata identifier 'build_1' contains invalid characters", + ); + } + + #[test] + fn rejects_empty_build_metadata_and_identifiers() { + assert_version_error_contains("1.2.3+", "invalid build metadata in version"); + assert_version_error_contains("1.2.3-alpha+", "invalid build metadata in version"); + assert_version_error_contains( + "1.2.3+build..meta", + "build metadata identifier cannot be empty in 'build..meta'", + ); + } + + #[test] + fn rejects_non_ascii_identifier_characters() { + assert_version_error_contains( + "1.2.3-βeta", + "prerelease identifier 'βeta' contains invalid characters", + ); + assert_version_error_contains( + "1.2.3+ビルド", + "build metadata identifier 'ビルド' contains invalid characters", + ); + } + + #[test] + fn rejects_repeated_build_delimiters() { + assert_version_error_contains("1.2.3+build+meta", "invalid build metadata in version"); + assert_version_error_contains( + "1.2.3-alpha+build+meta", + "invalid build metadata in version", + ); + } + + #[test] + fn bumps_versions() { + let version = Version::parse("1.2.3-alpha.1+build.1").unwrap(); + assert_eq!(bump_version(&version, VersionBump::Patch).to_string(), "1.2.4"); + assert_eq!(bump_version(&version, VersionBump::Minor).to_string(), "1.3.0"); + assert_eq!(bump_version(&version, VersionBump::Major).to_string(), "2.0.0"); + } + + #[test] + fn stripping_prerelease_removes_build_metadata_too() { + let version = Version::parse("1.2.3-alpha.1+build.1").unwrap(); + assert_eq!(strip_prerelease(&version).to_string(), "1.2.3"); + } + + #[test] + fn exposes_prerelease_parts() { + let version = Version::parse("1.2.3-beta.4").unwrap(); + assert_eq!(prerelease_channel(&version), Some("beta")); + assert_eq!(prerelease_number(&version), Some(4)); + } + + #[test] + fn prerelease_helpers_return_none_when_suffix_is_missing_or_not_numeric() { + let stable = Version::parse("1.2.3").unwrap(); + assert_eq!(prerelease_channel(&stable), None); + assert_eq!(prerelease_number(&stable), None); + + let prerelease = Version::parse("1.2.3-beta").unwrap(); + assert_eq!(prerelease_channel(&prerelease), Some("beta")); + assert_eq!(prerelease_number(&prerelease), None); + + let named_suffix = Version::parse("1.2.3-beta.next").unwrap(); + assert_eq!(prerelease_channel(&named_suffix), Some("beta")); + assert_eq!(prerelease_number(&named_suffix), None); + } + + #[test] + fn builds_prerelease_strings() { + assert_eq!(build_prerelease("alpha", 0).unwrap(), "alpha.0"); + assert_eq!(build_prerelease("beta-candidate", 12).unwrap(), "beta-candidate.12"); + } + + #[test] + fn builds_prerelease_strings_for_multi_segment_channels() { + assert_eq!(build_prerelease("alpha.beta", 42).unwrap(), "alpha.beta.42"); + } + + #[test] + fn rejects_invalid_prerelease_channels() { + assert!(build_prerelease("alpha beta", 0).is_err()); + assert!(build_prerelease("", 0).is_err()); + assert!(build_prerelease("alpha..beta", 0).is_err()); + assert!(build_prerelease(".alpha", 0).is_err()); + assert!(build_prerelease("alpha.", 0).is_err()); + } + + #[test] + fn parses_exact_version_patterns() { + assert_eq!( + parse_version_pattern("1.2.3").unwrap(), + VersionPattern::Version { + prefix: VersionPrefix::Exact, + version: Version::parse("1.2.3").unwrap(), + } + ); + } + + #[test] + fn parses_caret_and_tilde_version_patterns() { + assert_eq!( + parse_version_pattern("^1.2.3").unwrap(), + VersionPattern::Version { + prefix: VersionPrefix::Caret, + version: Version::parse("1.2.3").unwrap(), + } + ); + assert_eq!( + parse_version_pattern("~1.2.3-alpha.1").unwrap(), + VersionPattern::Version { + prefix: VersionPrefix::Tilde, + version: Version::parse("1.2.3-alpha.1").unwrap(), + } + ); + } + + #[test] + fn trims_whitespace_in_version_patterns() { + assert_eq!( + parse_version_pattern(" ~1.2.3+build.5 \n").unwrap().to_string(), + "~1.2.3+build.5", + ); + assert_eq!( + parse_version_pattern(" ^ \t").unwrap(), + VersionPattern::Token(VersionPrefix::Caret) + ); + } + + #[test] + fn parses_bare_monorepo_tokens() { + assert_eq!( + parse_version_pattern("^").unwrap(), + VersionPattern::Token(VersionPrefix::Caret) + ); + assert_eq!( + parse_version_pattern("~").unwrap(), + VersionPattern::Token(VersionPrefix::Tilde) + ); + assert_eq!(parse_version_pattern("*").unwrap(), VersionPattern::Any); + } + + #[test] + fn rejects_invalid_version_patterns() { + assert_pattern_error_contains("", "version pattern cannot be empty"); + assert_pattern_error_contains("workspace:^", "major segment 'workspace:^' is not numeric"); + assert_pattern_error_contains("^1.2", "missing patch segment"); + assert_pattern_error_contains("1.2.3.4", "expected exactly 3 numeric core segments"); + } + + #[test] + fn version_pattern_display_roundtrips() { + let patterns = ["*", "^", "~", "1.2.3", "^1.2.3", "~1.2.3-beta.1"]; + for pattern in patterns { + let parsed = parse_version_pattern(pattern).unwrap(); + assert_eq!(parsed.to_string(), pattern); + } + } + + #[test] + fn version_bump_order_matches_release_priority() { + assert!(VersionBump::Alpha < VersionBump::Beta); + assert!(VersionBump::Beta < VersionBump::Rc); + assert!(VersionBump::Rc < VersionBump::Patch); + assert!(VersionBump::Patch < VersionBump::Minor); + assert!(VersionBump::Minor < VersionBump::Major); + } + + #[test] + fn version_bump_helpers_match_release_semantics() { + assert_eq!(VersionBump::Alpha.as_str(), "alpha"); + assert_eq!(VersionBump::Beta.as_str(), "beta"); + assert_eq!(VersionBump::Rc.as_str(), "rc"); + assert_eq!(VersionBump::Patch.as_str(), "patch"); + assert_eq!(VersionBump::Minor.as_str(), "minor"); + assert_eq!(VersionBump::Major.as_str(), "major"); + + assert!(!VersionBump::Alpha.is_version_bump()); + assert!(!VersionBump::Beta.is_version_bump()); + assert!(!VersionBump::Rc.is_version_bump()); + assert!(VersionBump::Patch.is_version_bump()); + assert!(VersionBump::Minor.is_version_bump()); + assert!(VersionBump::Major.is_version_bump()); + } +} diff --git a/packages/cli/README.md b/packages/cli/README.md index e5b23b0441..296779e1cd 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -17,11 +17,16 @@ Vite+ is the unified entry point for local web development. It combines [Vite](h - **`vp build`:** Build applications for production with Vite + Rolldown - **`vp run`:** Execute monorepo tasks with caching and dependency-aware scheduling - **`vp pack`:** Build libraries for npm publishing or standalone app binaries +- **`vp release`:** Version and publish workspace packages with native publish preflight during `--dry-run`, release checks before real publishes by default, retry-friendly exact version overrides via `--version`, optional changelog generation via `--changelog`, prerelease channels like `--preid alpha` / `beta` / `rc`, and `--projects` order respected between independent packages - **`vp create` / `vp migrate`:** Scaffold new projects and migrate existing ones All of this is configured from your project root and works across Vite's framework ecosystem. Vite+ is fully open-source under the MIT license. +`vp release` detects likely checks from `build`, `pack`, `prepack`, `prepublishOnly`, `prepare`, and `vitePlus.release.checkScripts`. Real releases run those checks before publishing unless you pass `--no-run-checks`; dry-runs stay lightweight by default and can opt in with `--run-checks`. `--dry-run` also runs the native publisher in dry-run mode from a temporary release manifest state when the git worktree is clean. Use `--yes` for CI or other non-interactive runs, and `--version ` when retrying a partial publish at an exact version. + +Real releases always create git tags after a successful publish. When every released package shares the same target version, `vp release` also creates a repository-level `v` tag so GitHub Releases and repo-wide release notes can follow the same watermark. Preview-only flags such as `--skip-publish` and `--no-git-tag` are therefore limited to `--dry-run`. + ## Getting Started Install Vite+ globally as `vp`: @@ -118,6 +123,7 @@ Use `vp migrate` to migrate to Vite+. It merges tool-specific config files such - **build** - Build for production - **pack** - Build libraries +- **release** - Version and publish workspace packages, with optional changelog generation - **preview** - Preview production build #### Manage Dependencies diff --git a/packages/cli/snap-tests-global/cli-helper-message/snap.txt b/packages/cli/snap-tests-global/cli-helper-message/snap.txt index 33f1dd12fc..89afbb0e0e 100644 --- a/packages/cli/snap-tests-global/cli-helper-message/snap.txt +++ b/packages/cli/snap-tests-global/cli-helper-message/snap.txt @@ -28,6 +28,7 @@ Build: build Build for production pack Build library preview Preview production build + release Version and publish workspace packages with readiness checks, confirmation, and optional changelog generation Manage Dependencies: add Add packages to dependencies diff --git a/packages/cli/snap-tests-global/command-release-dry-run/package.json b/packages/cli/snap-tests-global/command-release-dry-run/package.json new file mode 100644 index 0000000000..14b9fb0601 --- /dev/null +++ b/packages/cli/snap-tests-global/command-release-dry-run/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-release-dry-run", + "private": true, + "packageManager": "pnpm@10.28.0" +} diff --git a/packages/cli/snap-tests-global/command-release-dry-run/packages/pkg-a/package.json b/packages/cli/snap-tests-global/command-release-dry-run/packages/pkg-a/package.json new file mode 100644 index 0000000000..ee734262cb --- /dev/null +++ b/packages/cli/snap-tests-global/command-release-dry-run/packages/pkg-a/package.json @@ -0,0 +1,9 @@ +{ + "name": "pkg-a", + "version": "1.0.0", + "repository": "https://github.com/example/release-fixture.git", + "type": "module", + "scripts": { + "build": "node -e \"console.log('build')\"" + } +} diff --git a/packages/cli/snap-tests-global/command-release-dry-run/packages/pkg-a/src/index.js b/packages/cli/snap-tests-global/command-release-dry-run/packages/pkg-a/src/index.js new file mode 100644 index 0000000000..efeee5db16 --- /dev/null +++ b/packages/cli/snap-tests-global/command-release-dry-run/packages/pkg-a/src/index.js @@ -0,0 +1 @@ +export const value = 1; diff --git a/packages/cli/snap-tests-global/command-release-dry-run/pnpm-workspace.yaml b/packages/cli/snap-tests-global/command-release-dry-run/pnpm-workspace.yaml new file mode 100644 index 0000000000..924b55f42e --- /dev/null +++ b/packages/cli/snap-tests-global/command-release-dry-run/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/packages/cli/snap-tests-global/command-release-dry-run/snap.txt b/packages/cli/snap-tests-global/command-release-dry-run/snap.txt new file mode 100644 index 0000000000..0100639c9d --- /dev/null +++ b/packages/cli/snap-tests-global/command-release-dry-run/snap.txt @@ -0,0 +1,47 @@ +> git init +> git add -A && git commit -m 'chore: init' +> printf '\nexport const feature = 2;\n' >> packages/pkg-a/src/index.js && git add packages/pkg-a/src/index.js && git commit -m 'feat: add release fixture' +> vp release --dry-run --skip-publish --no-run-checks --yes +info: Planned releases: + pkg-a -> (minor) +note: Dry run enabled; no files will be changed. +note: Changelog generation is disabled for this run. +note: Release checks are disabled for this run. + +info: Pre-release readiness: + package scripts: + - pkg-a: build + trusted publishing: + environment: not detected (local shell or unsupported CI provider) + provenance: will be enabled automatically when the same release runs from trusted-publishing CI + interactive fallback: prefer npm passkey/security-key auth; use `--otp` only for legacy TOTP fallback +note: Release checks can run from these detected scripts; real releases do so by default and dry-runs can opt in with `--run-checks`. +note: Review this summary, then confirm to continue. +note: Local dry-runs validate packaging and publish command shape, but OIDC auth and trusted-publishing provenance are only exercised from CI. +warn: This environment cannot produce the npm provenance attestations required by the hardened release policy. Use `vp release --dry-run` locally and publish from GitHub Actions on a GitHub-hosted runner or from GitLab CI shared runners. +note: Would update 1 release file(s) (1 manifests) +note: Would skip changelog generation because --changelog was not provided. +note: Would create release commit: chore(release): publish pkg-a@ +note: Would create git tag release/pkg-a/v +note: Would create repository git tag v +note: Would skip the final confirmation because --yes was provided. +note: Would skip release checks for this run. +note: Would skip publishing because --skip-publish was provided. +note: Release checks are disabled for this run. + +info: Dry-run summary: + packages planned: 1 + files covered: 1 total (1 manifests) + native publish check: skipped by --skip-publish + trusted publish target: not detected (local shell or unsupported CI provider) + repository tag: v + interactive fallback auth: passkey/security-key preferred; `--otp` remains legacy-only + rollback coverage: + - temporary package.json rewrites are restored after publish checks and publish + - final release files are applied transactionally before the release commit + - partially created local release tags are removed if tag creation fails + - OIDC auth and provenance are not exercised in this local preview; rerun from CI for the full trusted-publish path + +> git status --short +> git tag --list 'v*' +> git tag --list 'release/*' \ No newline at end of file diff --git a/packages/cli/snap-tests-global/command-release-dry-run/steps.json b/packages/cli/snap-tests-global/command-release-dry-run/steps.json new file mode 100644 index 0000000000..799a246298 --- /dev/null +++ b/packages/cli/snap-tests-global/command-release-dry-run/steps.json @@ -0,0 +1,14 @@ +{ + "commands": [ + { "command": "git init", "ignoreOutput": true }, + { "command": "git add -A && git commit -m 'chore: init'", "ignoreOutput": true }, + { + "command": "printf '\\nexport const feature = 2;\\n' >> packages/pkg-a/src/index.js && git add packages/pkg-a/src/index.js && git commit -m 'feat: add release fixture'", + "ignoreOutput": true + }, + "vp release --dry-run --skip-publish --no-run-checks --yes", + "git status --short", + "git tag --list 'v*'", + "git tag --list 'release/*'" + ] +}