diff --git a/.changeset/native-monorepo-versioning-core.md b/.changeset/native-monorepo-versioning-core.md new file mode 100644 index 0000000000..a6995fc9d5 --- /dev/null +++ b/.changeset/native-monorepo-versioning-core.md @@ -0,0 +1,10 @@ +--- +"@pnpm/types": minor +"@pnpm/config.reader": minor +"@pnpm/workspace.workspace-manifest-reader": minor +"@pnpm/releasing.versioning": minor +"@pnpm/releasing.commands": minor +"pnpm": minor +--- + +Added native workspace release management [#12952](https://github.com/pnpm/pnpm/issues/12952): the new `pnpm change` command records change intents as changesets-compatible `.changeset/*.md` files (`pnpm change status` shows the pending release plan), and the bare `pnpm version -r` consumes them — bumping versions across the workspace with dependent propagation through `workspace:` ranges, fixed groups, a `maxBump` cap, `--filter` narrowing, and `--dry-run` — writing changelogs, and recording consumed intents in a committed ledger that keeps cherry-picks and merge-backs between release branches safe. Packages can be moved onto per-package release lanes with the new `pnpm lane --filter ` command and back with `pnpm lane main --filter ` (`pnpm lane` shows the membership), releasing `X.Y.Z-lane.N` prereleases from the same runs that release stable versions of the packages on the main lane. Configuration lives under the new `versioning` key of `pnpm-workspace.yaml` (`fixed`, `ignore`, `maxBump`, `lanes`, `changelog`). When two workspace projects publish the same name, intent files, `versioning.lanes`, and `versioning.fixed`/`ignore` may reference a project by its workspace-relative directory path (e.g. `"./pnpm/npm/pnpm"`) — the one additive extension to the changesets format, applied automatically by `pnpm change`. diff --git a/Cargo.lock b/Cargo.lock index ce31073887..1b76f3abb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3959,6 +3959,7 @@ dependencies = [ "pacquet-store-dir", "pacquet-tarball", "pacquet-testing-utils", + "pacquet-versioning", "pacquet-workspace", "pacquet-workspace-manifest-writer", "pacquet-workspace-projects-filter", @@ -4021,6 +4022,7 @@ dependencies = [ "pacquet-patching", "pacquet-store-dir", "pacquet-testing-utils", + "pacquet-versioning", "pacquet-workspace-state", "pipe-trait", "pretty_assertions", @@ -5052,6 +5054,24 @@ dependencies = [ "walkdir", ] +[[package]] +name = "pacquet-versioning" +version = "0.0.1" +dependencies = [ + "derive_more", + "getrandom 0.4.3", + "indexmap 2.14.0", + "miette 7.6.0", + "node-semver", + "pacquet-package-manifest", + "pathdiff", + "pretty_assertions", + "serde", + "serde-saphyr", + "serde_json", + "tempfile", +] + [[package]] name = "pacquet-workspace" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 23817b59d4..631fced8b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ pacquet-resolving-npm-resolver = { path = "pnpm/crates/resolving-npm- pacquet-resolving-parse-wanted-dependency = { path = "pnpm/crates/resolving-parse-wanted-dependency" } pacquet-resolving-resolver-base = { path = "pnpm/crates/resolving-resolver-base" } pacquet-resolving-tarball-resolver = { path = "pnpm/crates/resolving-tarball-resolver" } +pacquet-versioning = { path = "pnpm/crates/versioning" } pacquet-workspace = { path = "pnpm/crates/workspace" } pacquet-workspace-manifest-writer = { path = "pnpm/crates/workspace-manifest-writer" } pacquet-workspace-projects-filter = { path = "pnpm/crates/workspace-projects-filter" } diff --git a/cspell.json b/cspell.json index d14345bc18..7cd11a0104 100644 --- a/cspell.json +++ b/cspell.json @@ -410,8 +410,10 @@ "undollar", "unextractable", "uninstallation", + "unmigrated", "unnest", "unparseable", + "unreleasable", "unreviewed", "unskip", "unstar", @@ -421,6 +423,7 @@ "ustar", "uuidv", "valign", + "versionless", "vuln", "webauth", "webcontainer", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02e027370b..92b3f08cf6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -525,6 +525,9 @@ catalogs: https-proxy-server-express: specifier: 0.1.2 version: 0.1.2 + human-id: + specifier: ^4.2.0 + version: 4.2.0 husky: specifier: ^9.1.7 version: 9.1.7 @@ -8196,15 +8199,24 @@ importers: '@pnpm/releasing.exportable-manifest': specifier: workspace:* version: link:../exportable-manifest + '@pnpm/releasing.versioning': + specifier: workspace:* + version: link:../versioning '@pnpm/resolving.resolver-base': specifier: workspace:* version: link:../../resolving/resolver-base '@pnpm/types': specifier: workspace:* version: link:../../core/types + '@pnpm/workspace.projects-filter': + specifier: workspace:* + version: link:../../workspace/projects-filter '@pnpm/workspace.projects-sorter': specifier: workspace:* version: link:../../workspace/projects-sorter + '@pnpm/workspace.workspace-manifest-writer': + specifier: workspace:* + version: link:../../workspace/workspace-manifest-writer '@types/normalize-path': specifier: 'catalog:' version: 3.0.2 @@ -8308,9 +8320,6 @@ importers: '@pnpm/testing.registry-mock': specifier: workspace:* version: link:../../testing/registry-mock - '@pnpm/workspace.projects-filter': - specifier: workspace:* - version: link:../../workspace/projects-filter '@types/cross-spawn': specifier: 'catalog:' version: 6.0.6 @@ -8415,6 +8424,43 @@ importers: specifier: 'catalog:' version: 6.0.0 + pnpm11/releasing/versioning: + dependencies: + '@pnpm/error': + specifier: workspace:* + version: link:../../core/error + '@pnpm/types': + specifier: workspace:* + version: link:../../core/types + '@pnpm/workspace.project-manifest-reader': + specifier: workspace:* + version: link:../../workspace/project-manifest-reader + '@pnpm/workspace.spec-parser': + specifier: workspace:* + version: link:../../workspace/spec-parser + human-id: + specifier: 'catalog:' + version: 4.2.0 + semver: + specifier: 'catalog:' + version: 7.8.5 + yaml: + specifier: 'catalog:' + version: 2.9.0 + devDependencies: + '@jest/globals': + specifier: 'catalog:' + version: 30.4.1 + '@pnpm/releasing.versioning': + specifier: workspace:* + version: 'link:' + '@types/semver': + specifier: 'catalog:' + version: 7.7.1 + tempy: + specifier: 'catalog:' + version: 3.0.0 + pnpm11/resolving/default-resolver: dependencies: '@pnpm/engine.runtime.bun-resolver': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c707dbf48c..6d58a76209 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -228,6 +228,7 @@ catalog: isexe: 4.0.0 jest: ^30.4.2 jest-diff: ^30.4.1 + human-id: ^4.2.0 js-yaml: npm:@zkochan/js-yaml@0.0.11 json5: ^2.2.3 keyv: 5.6.0 diff --git a/pnpm/crates/cli/Cargo.toml b/pnpm/crates/cli/Cargo.toml index 6303eddd42..d976a2da29 100644 --- a/pnpm/crates/cli/Cargo.toml +++ b/pnpm/crates/cli/Cargo.toml @@ -52,6 +52,7 @@ pacquet-store-dir = { workspace = true } pacquet-tarball = { workspace = true } pacquet-diagnostics = { workspace = true } pacquet-directory-fetcher = { workspace = true } +pacquet-versioning = { workspace = true } pacquet-workspace = { workspace = true } pacquet-workspace-manifest-writer = { workspace = true } pacquet-workspace-projects-graph = { workspace = true } diff --git a/pnpm/crates/cli/src/cli_args.rs b/pnpm/crates/cli/src/cli_args.rs index ed10727c41..506a9088dd 100644 --- a/pnpm/crates/cli/src/cli_args.rs +++ b/pnpm/crates/cli/src/cli_args.rs @@ -7,6 +7,7 @@ pub mod bugs; pub mod cache; pub mod cat_file; pub mod cat_index; +pub mod change; pub mod completion; pub mod config; pub mod create; @@ -22,6 +23,7 @@ pub mod global; pub mod ignored_builds; pub mod import; pub mod install; +pub mod lane; pub mod link; pub mod list; pub mod login; @@ -63,6 +65,7 @@ pub mod team; pub mod unlink; pub mod update; pub mod update_interactive; +pub mod version; pub mod whoami; pub mod why; pub mod with; diff --git a/pnpm/crates/cli/src/cli_args/change.rs b/pnpm/crates/cli/src/cli_args/change.rs new file mode 100644 index 0000000000..d9fa6d4d14 --- /dev/null +++ b/pnpm/crates/cli/src/cli_args/change.rs @@ -0,0 +1,421 @@ +use clap::Args; +use derive_more::{Display, Error}; +use dialoguer::{Input, MultiSelect}; +use indexmap::IndexMap; +use miette::{Diagnostic, IntoDiagnostic}; +use node_semver::Version; +use pacquet_config::Config; +use pacquet_package_manifest::DependencyGroup; +use pacquet_versioning::{ + AssembleReleasePlanOptions, IntentBumpType, ManifestDependency, ReleasePlan, + VersioningSettings, WorkspaceProject, assemble_release_plan, index_project_refs, + read_change_intents, read_ledger, to_project_dir, write_change_intent, +}; +use pacquet_workspace::Project; +use pacquet_workspace_projects_filter::{GetChangedProjectsOptions, get_changed_projects}; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + process::Command, +}; + +use crate::cli_args::recursive::discover_workspace_projects; + +/// `pnpm change` — record a change intent: which packages a change affects, +/// the bump type for each, and a summary that becomes the changelog entry. +/// The intent file is written to `.changeset/` in the changesets format. +#[derive(Debug, Args)] +pub struct ChangeArgs { + /// `status` to print the pending intents and the release plan they + /// produce; otherwise the packages the change affects. + pub params: Vec, + + /// Bump type for the named packages: none, patch, minor, major. "none" + /// records an explicit decline — the change needs no release. + #[clap(long)] + pub bump: Option, + + /// The summary for the changelog entry. Runs non-interactively when + /// given together with package names. + #[clap(long)] + pub summary: Option, +} + +/// Errors of `pnpm change`. Codes and messages match the TypeScript CLI. +#[derive(Debug, Display, Error, Diagnostic)] +enum ChangeError { + #[display("pnpm change is only supported in a workspace")] + #[diagnostic(code(ERR_PNPM_WORKSPACE_ONLY))] + WorkspaceOnly, + + #[display("No releasable packages found in this workspace")] + #[diagnostic(code(ERR_PNPM_VERSIONING_NO_PACKAGES))] + NoPackages, + + #[display("{pkg_name} is not a releasable package of this workspace")] + #[diagnostic(code(ERR_PNPM_VERSIONING_UNKNOWN_PACKAGE))] + UnknownPackage { pkg_name: String }, + + #[display( + "{reference} matches multiple workspace projects: {}. Reference the project by directory instead.", + dirs.join(", ") + )] + #[diagnostic(code(ERR_PNPM_VERSIONING_AMBIGUOUS_PACKAGE))] + AmbiguousPackage { reference: String, dirs: Vec }, + + #[display("Invalid bump type: {bump}. Expected one of none, patch, minor, major")] + #[diagnostic(code(ERR_PNPM_VERSIONING_INVALID_BUMP))] + InvalidBump { bump: String }, +} + +impl ChangeArgs { + pub fn run(self, config: &Config) -> miette::Result<()> { + let Some(workspace_dir) = config.workspace_dir.clone() else { + return Err(ChangeError::WorkspaceOnly.into()); + }; + let (projects, _) = discover_workspace_projects(&workspace_dir)?; + let engine_projects = to_engine_projects(&projects); + + // Only the exact no-option invocation is the status form, so a + // package that happens to be named "status" stays recordable. + if self.params.len() == 1 + && self.params[0] == "status" + && self.bump.is_none() + && self.summary.is_none() + { + let output = render_status(&workspace_dir, &engine_projects, config)?; + println!("{output}"); + return Ok(()); + } + + let releasable = releasable_projects(&engine_projects, &workspace_dir, &config.versioning); + if releasable.is_empty() { + return Err(ChangeError::NoPackages.into()); + } + let releasable_dirs: HashSet<&str> = + releasable.iter().map(|project| project.dir.as_str()).collect(); + let refs = index_project_refs(&engine_projects, &workspace_dir); + for reference in &self.params { + let dirs = refs.ref_to_dirs(reference); + if dirs.len() > 1 { + return Err(ChangeError::AmbiguousPackage { + reference: reference.clone(), + dirs: dirs.into_iter().map(|dir| format!("./{dir}")).collect(), + } + .into()); + } + if dirs.first().is_none_or(|dir| !releasable_dirs.contains(dir.as_str())) { + return Err(ChangeError::UnknownPackage { pkg_name: reference.clone() }.into()); + } + } + let bump = match &self.bump { + None => None, + Some(bump) => match parse_bump(bump) { + Some(parsed) => Some(parsed), + None => return Err(ChangeError::InvalidBump { bump: bump.clone() }.into()), + }, + }; + + // For a name shared by several projects the interactive picker offers + // each project under its directory reference, so the written intent + // stays unambiguous without the contributor knowing the rule exists. + let pkg_refs = if self.params.is_empty() { + let changed_dirs = + detect_changed_dirs(&releasable, &engine_projects, &workspace_dir, config); + prompt_for_packages(&releasable, &changed_dirs)? + } else { + self.params.clone() + }; + + let releases: IndexMap = match bump { + Some(bump) => pkg_refs.into_iter().map(|reference| (reference, bump)).collect(), + None => prompt_bump_types(&pkg_refs)?, + }; + + let summary = match &self.summary { + Some(summary) => summary.clone(), + None => Input::new() + .with_prompt("Summary of the change (becomes the changelog entry)") + .interact_text() + .into_diagnostic()?, + }; + + let id = write_change_intent(&workspace_dir, &releases, &summary)?; + println!("Recorded change intent .changeset/{id}.md"); + Ok(()) + } +} + +fn parse_bump(bump: &str) -> Option { + match bump { + "none" => Some(IntentBumpType::None), + "patch" => Some(IntentBumpType::Patch), + "minor" => Some(IntentBumpType::Minor), + "major" => Some(IntentBumpType::Major), + _ => None, + } +} + +/// The affected-packages picker. The packages whose directories the branch +/// touched come first and are preselected; the rest follow. (dialoguer has no +/// section headings, so the grouping is conveyed by order and preselection +/// rather than the "changed packages" / "unchanged packages" separators the +/// TypeScript CLI's inquirer prompt renders.) A name shared by several +/// projects is offered under its directory reference so the written intent +/// stays unambiguous. +fn prompt_for_packages( + releasable: &[ReleasableProject], + changed_dirs: &HashSet, +) -> miette::Result> { + let mut ordered: Vec<&ReleasableProject> = + releasable.iter().filter(|project| changed_dirs.contains(&project.dir)).collect(); + ordered.extend(releasable.iter().filter(|project| !changed_dirs.contains(&project.dir))); + + let items: Vec<(String, bool)> = ordered + .iter() + .map(|project| { + let label = if project.reference == project.name { + project.name.clone() + } else { + format!("{} (./{})", project.name, project.dir) + }; + (label, changed_dirs.contains(&project.dir)) + }) + .collect(); + + loop { + let indices = MultiSelect::new() + .with_prompt( + "Which packages does this change affect? ( to select, to confirm)", + ) + .items_checked(items.iter().cloned()) + .interact() + .into_diagnostic()?; + if !indices.is_empty() { + return Ok(indices.into_iter().map(|index| ordered[index].reference.clone()).collect()); + } + println!("Select at least one package."); + } +} + +/// The workspace-relative directories the current branch changed, relative to +/// the base branch, using the same detection behind `--filter="[]"`. +/// Returns an empty set on any failure so the picker degrades to a flat list. +fn detect_changed_dirs( + releasable: &[ReleasableProject], + engine_projects: &[WorkspaceProject], + workspace_dir: &Path, + config: &Config, +) -> HashSet { + let Some(base_commit) = detect_base_commit(workspace_dir) else { + return HashSet::new(); + }; + let project_dirs: Vec = + engine_projects.iter().map(|project| project.root_dir.clone()).collect(); + let opts = GetChangedProjectsOptions { + workspace_dir, + test_pattern: &config.test_pattern, + changed_files_ignore_pattern: &config.changed_files_ignore_pattern, + }; + let Ok(changed) = get_changed_projects(project_dirs, &base_commit, &opts) else { + return HashSet::new(); + }; + let releasable_dirs: HashSet<&str> = + releasable.iter().map(|project| project.dir.as_str()).collect(); + changed + .changed_projects + .iter() + .map(|root_dir| to_project_dir(workspace_dir, root_dir)) + .filter(|dir| releasable_dirs.contains(dir.as_str())) + .collect() +} + +/// The merge-base of HEAD with the default branch, or `None`. +fn detect_base_commit(cwd: &Path) -> Option { + for branch in ["main", "master"] { + let Ok(output) = + Command::new("git").args(["merge-base", "HEAD", branch]).current_dir(cwd).output() + else { + continue; + }; + if output.status.success() { + let commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !commit.is_empty() { + return Some(commit); + } + } + } + None +} + +/// The changesets-style bump picker: ask which packages get a major bump, +/// then which of the rest get a minor, and default whatever remains to patch. +/// One multiselect per level reads far better than a per-package prompt when +/// many packages are affected. Bumps are returned in the original selection +/// order rather than grouped by level. +fn prompt_bump_types(pkg_refs: &[String]) -> miette::Result> { + let mut bump_by_ref: IndexMap = IndexMap::new(); + let mut remaining: Vec = pkg_refs.to_vec(); + for (label, bump_type) in [("major", IntentBumpType::Major), ("minor", IntentBumpType::Minor)] { + if remaining.is_empty() { + break; + } + let chosen: HashSet = MultiSelect::new() + .with_prompt(format!( + "Which packages should have a {label} bump? ( to select, to confirm)", + )) + .items(&remaining) + .interact() + .into_diagnostic()? + .into_iter() + .collect(); + let mut next_remaining = Vec::new(); + for (index, reference) in remaining.into_iter().enumerate() { + if chosen.contains(&index) { + bump_by_ref.insert(reference, bump_type); + } else { + next_remaining.push(reference); + } + } + remaining = next_remaining; + } + for reference in remaining { + bump_by_ref.insert(reference, IntentBumpType::Patch); + } + Ok(pkg_refs.iter().map(|reference| (reference.clone(), bump_by_ref[reference])).collect()) +} + +fn render_status( + workspace_dir: &Path, + projects: &[WorkspaceProject], + config: &Config, +) -> miette::Result { + let intents = read_change_intents(workspace_dir)?; + let ledger = read_ledger(workspace_dir)?; + let plan = assemble_release_plan( + projects, + workspace_dir, + &intents, + &ledger, + Some(&config.versioning), + &AssembleReleasePlanOptions::default(), + )?; + if plan.releases.is_empty() { + return Ok("No pending changes.".to_string()); + } + let consumed_ids: std::collections::HashSet<&str> = plan + .releases + .iter() + .flat_map(|release| release.intents.iter().map(|intent| intent.id.as_str())) + .collect(); + use std::fmt::Write as _; + let mut output = String::from("Pending change intents:\n"); + for intent in intents.iter().filter(|intent| consumed_ids.contains(intent.id.as_str())) { + writeln!(output, " .changeset/{}.md", intent.id).expect("write to string"); + } + output.push('\n'); + output.push_str(&render_release_plan(&plan)); + Ok(output) +} + +/// Renders the plan the way the TypeScript CLI prints it, one line per +/// release. +pub fn render_release_plan(plan: &ReleasePlan) -> String { + use std::fmt::Write as _; + let mut output = String::from("Release plan:\n"); + for release in &plan.releases { + let causes: Vec = release.causes.iter().map(ToString::to_string).collect(); + writeln!( + output, + " {}: {} → {} ({}, via {})", + release.name, + release.current_version, + release.new_version, + release.bump_type, + causes.join("+"), + ) + .expect("write to string"); + } + output +} + +/// One project `pnpm change` may record an intent for, and how an intent +/// file or versioning config should reference it: the bare name, or the +/// `./`-prefixed directory when the name is shared by several workspace +/// projects. +pub struct ReleasableProject { + pub name: String, + /// Workspace-relative project directory. + pub dir: String, + pub reference: String, +} + +/// The projects a change intent may demand a release from: named, carrying a +/// valid semver version, and not frozen by `versioning.ignore`. Matches the +/// participant set of the release-plan assembler. +pub fn releasable_projects( + projects: &[WorkspaceProject], + workspace_dir: &Path, + versioning: &VersioningSettings, +) -> Vec { + let refs = index_project_refs(projects, workspace_dir); + let ignored_dirs: HashSet = + versioning.ignore.iter().flat_map(|reference| refs.ref_to_dirs(reference)).collect(); + let mut releasable: Vec = projects + .iter() + .filter_map(|project| { + let (Some(name), Some(version)) = (&project.name, &project.version) else { + return None; + }; + if Version::parse(version).is_err() { + return None; + } + let dir = to_project_dir(workspace_dir, &project.root_dir); + if ignored_dirs.contains(&dir) { + return None; + } + let reference = + if refs.name_to_dirs(name).len() > 1 { format!("./{dir}") } else { name.clone() }; + Some(ReleasableProject { name: name.clone(), dir, reference }) + }) + .collect(); + releasable.sort_by(|left, right| left.reference.cmp(&right.reference)); + releasable +} + +/// Extracts the manifest fields the release-plan assembler consumes from the +/// discovered workspace projects. +pub fn to_engine_projects(projects: &[Project]) -> Vec { + projects + .iter() + .map(|project| { + let manifest = project.manifest.value(); + let mut prod_dependencies = Vec::new(); + for (group, field) in [ + (DependencyGroup::Prod, pacquet_versioning::DependencyField::Dependencies), + ( + DependencyGroup::Optional, + pacquet_versioning::DependencyField::OptionalDependencies, + ), + (DependencyGroup::Peer, pacquet_versioning::DependencyField::PeerDependencies), + ] { + for (alias, spec) in project.manifest.dependencies([group]) { + prod_dependencies.push(ManifestDependency { + field, + alias: alias.to_string(), + spec: spec.to_string(), + }); + } + } + WorkspaceProject { + root_dir: project.root_dir.clone(), + name: manifest.get("name").and_then(|name| name.as_str()).map(ToString::to_string), + version: manifest + .get("version") + .and_then(|version| version.as_str()) + .map(ToString::to_string), + prod_dependencies, + } + }) + .collect() +} diff --git a/pnpm/crates/cli/src/cli_args/cli_command.rs b/pnpm/crates/cli/src/cli_args/cli_command.rs index 3474919498..06d766627e 100644 --- a/pnpm/crates/cli/src/cli_args/cli_command.rs +++ b/pnpm/crates/cli/src/cli_args/cli_command.rs @@ -8,6 +8,7 @@ use super::{ cache::CacheCommand, cat_file::CatFileArgs, cat_index::CatIndexArgs, + change::ChangeArgs, completion::{CompletionArgs, CompletionServerArgs}, config::ConfigArgs, create::CreateArgs, @@ -22,6 +23,7 @@ use super::{ ignored_builds::IgnoredBuildsArgs, import::ImportArgs, install::InstallArgs, + lane::LaneArgs, link::LinkArgs, list::ListArgs, login::LoginArgs, @@ -57,6 +59,7 @@ use super::{ team::TeamArgs, unlink::UnlinkArgs, update::UpdateArgs, + version::VersionArgs, why::WhyArgs, with::WithArgs, }; @@ -289,6 +292,13 @@ pub enum CliCommand { Outdated(OutdatedArgs), /// Checks for known security issues with the installed packages. Audit(AuditArgs), + /// Record a change intent: which packages a change affects, the bump + /// type for each, and a summary that becomes the changelog entry. + Change(ChangeArgs), + /// Apply the pending change intents (`pnpm version -r`). + Version(VersionArgs), + /// Manage per-package release lanes. + Lane(LaneArgs), /// Opens the bug tracker URL of a package in the default browser. #[clap(visible_alias = "issues")] Bugs(BugsArgs), diff --git a/pnpm/crates/cli/src/cli_args/deploy.rs b/pnpm/crates/cli/src/cli_args/deploy.rs index 169ed69445..1a94c65355 100644 --- a/pnpm/crates/cli/src/cli_args/deploy.rs +++ b/pnpm/crates/cli/src/cli_args/deploy.rs @@ -251,12 +251,14 @@ impl DeployArgs { } apply_deploy_hook(&deploy_dir.join("package.json"))?; - self.run_install_in_deploy_dir::( + // Boxed: the install future exceeds clippy's large-future threshold + // (the captured `Config` is large). + Box::pin(self.run_install_in_deploy_dir::( config, &deploy_dir, DeployInstallMode::Legacy, false, - ) + )) .await } @@ -290,12 +292,13 @@ impl DeployArgs { config, )?; write_deploy_files(deploy_dir, &deploy_files)?; - self.run_install_in_deploy_dir::( + // Boxed for the same large-future reason as the legacy path above. + Box::pin(self.run_install_in_deploy_dir::( config, deploy_dir, DeployInstallMode::Shared { workspace_config: deploy_files.workspace_config }, true, - ) + )) .await?; Ok(SharedDeployOutcome::Deployed) } diff --git a/pnpm/crates/cli/src/cli_args/dispatch.rs b/pnpm/crates/cli/src/cli_args/dispatch.rs index d53ec33ce2..767027d5aa 100644 --- a/pnpm/crates/cli/src/cli_args/dispatch.rs +++ b/pnpm/crates/cli/src/cli_args/dispatch.rs @@ -301,6 +301,9 @@ fn route<'a>(command: CliCommand, ctx: &RunCtx<'a>) -> miette::Result dispatch_install::update(ctx, args), CliCommand::Outdated(args) => dispatch_query::outdated(ctx, args), CliCommand::Audit(args) => dispatch_query::audit(ctx, args), + CliCommand::Change(args) => dispatch_query::change(ctx, args), + CliCommand::Version(args) => dispatch_query::version(ctx, args), + CliCommand::Lane(args) => dispatch_query::lane(ctx, args), CliCommand::Bugs(args) => dispatch_query::bugs(ctx, args), CliCommand::List(args) => dispatch_query::list(ctx, args), CliCommand::Ll(args) => dispatch_query::ll(ctx, args), diff --git a/pnpm/crates/cli/src/cli_args/dispatch_query.rs b/pnpm/crates/cli/src/cli_args/dispatch_query.rs index a96bfcb62a..6cf6f54d91 100644 --- a/pnpm/crates/cli/src/cli_args/dispatch_query.rs +++ b/pnpm/crates/cli/src/cli_args/dispatch_query.rs @@ -6,12 +6,14 @@ use super::{ cache::CacheCommand, cat_file::CatFileArgs, cat_index::CatIndexArgs, + change::ChangeArgs, config::ConfigArgs, dispatch::{CommandFuture, RunCtx}, dist_tag::DistTagArgs, docs::DocsArgs, find_hash::FindHashArgs, ignored_builds::IgnoredBuildsArgs, + lane::LaneArgs, list::ListArgs, login::LoginArgs, logout::LogoutArgs, @@ -32,6 +34,7 @@ use super::{ stage::StageArgs, store::StoreCommand, team::TeamArgs, + version::VersionArgs, why::WhyArgs, with::WithArgs, }; @@ -175,6 +178,29 @@ pub(super) fn dist_tag<'a>( })) } +/// `change` and `version` are synchronous file-and-prompt commands; the +/// returned future only carries their already-computed result. +pub(super) fn change<'a>(ctx: &RunCtx<'a>, args: ChangeArgs) -> miette::Result> { + let cfg: &Config = (ctx.config)()?; + let result = args.run(cfg); + Ok(Box::pin(std::future::ready(result))) +} + +pub(super) fn lane<'a>(ctx: &RunCtx<'a>, args: LaneArgs) -> miette::Result> { + let cfg: &Config = (ctx.config)()?; + let result = args.run(cfg); + Ok(Box::pin(std::future::ready(result))) +} + +pub(super) fn version<'a>( + ctx: &RunCtx<'a>, + args: VersionArgs, +) -> miette::Result> { + let cfg: &Config = (ctx.config)()?; + let result = args.run(cfg, ctx.recursive); + Ok(Box::pin(std::future::ready(result))) +} + pub(super) fn team<'a>(ctx: &RunCtx<'a>, args: TeamArgs) -> miette::Result> { let cfg: &Config = (ctx.config)()?; Ok(Box::pin(async move { diff --git a/pnpm/crates/cli/src/cli_args/lane.rs b/pnpm/crates/cli/src/cli_args/lane.rs new file mode 100644 index 0000000000..f912db1877 --- /dev/null +++ b/pnpm/crates/cli/src/cli_args/lane.rs @@ -0,0 +1,200 @@ +use clap::Args; +use derive_more::{Display, Error}; +use miette::Diagnostic; +use pacquet_config::Config; +use pacquet_versioning::VersioningSettings; +use pacquet_workspace_manifest_writer::update_manifest_field; +use std::collections::{BTreeMap, HashMap, HashSet}; + +use crate::cli_args::{ + change::{releasable_projects, to_engine_projects}, + recursive::discover_workspace_projects, + version::selected_projects, +}; + +/// The reserved name of the default lane: every package is on it unless +/// assigned elsewhere, packages on it release stable versions, and no +/// prerelease lane can take the name. +pub const MAIN_LANE: &str = "main"; + +/// `pnpm lane` — manage per-package release lanes: parallel release tracks +/// that emit `X.Y.Z-.N` prereleases while the main lane keeps releasing +/// stable versions. Membership lives under the `versioning.lanes` key of +/// pnpm-workspace.yaml; this command is a convenience editor for that key. +#[derive(Debug, Args)] +pub struct LaneArgs { + /// The lane to move the `--filter`-selected packages onto (`main` moves + /// them back to the default lane). With no name, shows lane membership. + pub name: Option, +} + +/// Errors of `pnpm lane`. Codes and messages match the TypeScript CLI. +#[derive(Debug, Display, Error, Diagnostic)] +enum LaneError { + #[display("pnpm lane is only supported in a workspace")] + #[diagnostic(code(ERR_PNPM_WORKSPACE_ONLY))] + WorkspaceOnly, + + #[display( + r#"Select the packages to move with --filter, e.g. "pnpm lane alpha --filter ...""# + )] + #[diagnostic(code(ERR_PNPM_VERSIONING_LANE_FILTER_REQUIRED))] + FilterRequired, + + #[display("The filter selected no releasable packages")] + #[diagnostic(code(ERR_PNPM_VERSIONING_NO_PACKAGES))] + NoPackagesSelected, + + #[display( + "Invalid lane name: {name}. Lane names may contain only alphanumerics and hyphens, and cannot be purely numeric." + )] + #[diagnostic(code(ERR_PNPM_VERSIONING_INVALID_LANE_NAME))] + InvalidLaneName { name: String }, + + #[display( + "Invalid lane name: {name}. \"main\" is the reserved default lane; spell it in lowercase to move packages back onto it." + )] + #[diagnostic(code(ERR_PNPM_VERSIONING_INVALID_LANE_NAME))] + ReservedLaneName { name: String }, + + #[display( + r#"{pkg_name} is already on the "{lane}" lane. Move it back with "pnpm lane main" first."# + )] + #[diagnostic(code(ERR_PNPM_VERSIONING_ALREADY_ON_LANE))] + AlreadyOnLane { pkg_name: String, lane: String }, +} + +impl LaneArgs { + pub fn run(self, config: &Config) -> miette::Result<()> { + let Some(workspace_dir) = config.workspace_dir.clone() else { + return Err(LaneError::WorkspaceOnly.into()); + }; + + let Some(lane_name) = self.name else { + println!("{}", render_lanes(&config.versioning.lanes)); + return Ok(()); + }; + + if config.filter.is_empty() { + return Err(LaneError::FilterRequired.into()); + } + let (projects, _) = discover_workspace_projects(&workspace_dir)?; + let engine_projects = to_engine_projects(&projects); + let refs = pacquet_versioning::index_project_refs(&engine_projects, &workspace_dir); + let releasable_dirs: HashSet = + releasable_projects(&engine_projects, &workspace_dir, &config.versioning) + .into_iter() + .map(|project| project.dir) + .collect(); + // (name, dir) of every selected releasable project, with the + // reference an intent or lanes entry should use for it. + let selected: Vec<(String, String, String)> = + selected_projects(&projects, config, &workspace_dir)? + .into_iter() + .filter_map(|(name, dir)| { + let name = name?; + if !releasable_dirs.contains(&dir) { + return None; + } + let reference = if refs.name_to_dirs(&name).len() > 1 { + format!("./{dir}") + } else { + name.clone() + }; + Some((name, dir, reference)) + }) + .collect(); + if selected.is_empty() { + return Err(LaneError::NoPackagesSelected.into()); + } + + // Existing entries may reference projects by name or by directory; + // resolve them so assignments and removals key on the project, not + // the spelling. + let mut lane_by_dir: HashMap = HashMap::new(); + for (key, lane) in &config.versioning.lanes { + for dir in refs.ref_to_dirs(key) { + lane_by_dir.insert(dir, (key.clone(), lane.clone())); + } + } + + let mut settings: VersioningSettings = config.versioning.clone(); + let selected_lines: String = + selected.iter().fold(String::new(), |mut lines, (_, _, reference)| { + use std::fmt::Write as _; + writeln!(lines, " {reference}").expect("write to string"); + lines + }); + let output = if lane_name == MAIN_LANE { + for (_, dir, _) in &selected { + if let Some((key, _)) = lane_by_dir.get(dir) { + settings.lanes.shift_remove(key); + } + } + format!( + r#"Moved to the main lane: +{selected_lines}The accumulated stable versions release on the next "pnpm version -r" run."#, + ) + } else { + if lane_name.eq_ignore_ascii_case(MAIN_LANE) { + return Err(LaneError::ReservedLaneName { name: lane_name }.into()); + } + // A purely numeric lane name is rejected because semver parses an + // all-digit prerelease identifier as a number, which changes + // sorting semantics. + let valid_name = lane_name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + && !lane_name.chars().all(|character| character.is_ascii_digit()); + if !valid_name { + return Err(LaneError::InvalidLaneName { name: lane_name }.into()); + } + for (_, dir, reference) in &selected { + match lane_by_dir.get(dir) { + Some((_, existing)) if existing != &lane_name => { + return Err(LaneError::AlreadyOnLane { + pkg_name: reference.clone(), + lane: existing.clone(), + } + .into()); + } + Some(_) => {} + None => { + settings.lanes.insert(reference.clone(), lane_name.clone()); + } + } + } + format!("Moved to the \"{lane_name}\" lane:\n{selected_lines}") + }; + + let value = if settings.is_empty() { + serde_json::Value::Null + } else { + serde_json::to_value(&settings).expect("versioning settings serialize to JSON") + }; + update_manifest_field(&workspace_dir.join("pnpm-workspace.yaml"), "versioning", &value) + .map_err(miette::Report::new)?; + println!("{output}"); + Ok(()) + } +} + +fn render_lanes(lanes: &indexmap::IndexMap) -> String { + if lanes.is_empty() { + return "All packages are on the main lane.".to_string(); + } + let mut by_lane: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for (pkg_name, lane_name) in lanes { + by_lane.entry(lane_name).or_default().push(pkg_name); + } + use std::fmt::Write as _; + let mut output = String::from("Lanes:\n"); + for (lane_name, mut members) in by_lane { + writeln!(output, " {lane_name}:").expect("write to string"); + members.sort_unstable(); + for pkg_name in members { + writeln!(output, " {pkg_name}").expect("write to string"); + } + } + output +} diff --git a/pnpm/crates/cli/src/cli_args/switch_cli_version.rs b/pnpm/crates/cli/src/cli_args/switch_cli_version.rs index 25a83d4d88..c3623a1f5b 100644 --- a/pnpm/crates/cli/src/cli_args/switch_cli_version.rs +++ b/pnpm/crates/cli/src/cli_args/switch_cli_version.rs @@ -458,6 +458,9 @@ fn command_name(command: &CliCommand) -> &'static str { CliCommand::Update(_) => "update", CliCommand::Outdated(_) => "outdated", CliCommand::Audit(_) => "audit", + CliCommand::Change(_) => "change", + CliCommand::Version(_) => "version", + CliCommand::Lane(_) => "lane", CliCommand::Bugs(_) => "bugs", CliCommand::List(_) => "list", CliCommand::Ll(_) => "ll", diff --git a/pnpm/crates/cli/src/cli_args/version.rs b/pnpm/crates/cli/src/cli_args/version.rs new file mode 100644 index 0000000000..287e3cf363 --- /dev/null +++ b/pnpm/crates/cli/src/cli_args/version.rs @@ -0,0 +1,185 @@ +use clap::Args; +use derive_more::{Display, Error}; +use miette::Diagnostic; +use pacquet_config::Config; +use pacquet_publish::{Host, is_git_repo, is_working_tree_clean}; +use pacquet_versioning::{ + AssembleReleasePlanOptions, apply_release_plan, assemble_release_plan, read_change_intents, + read_ledger, +}; +use std::{collections::HashSet, path::Path}; + +use crate::cli_args::{ + change::{render_release_plan, to_engine_projects}, + recursive::{AutoExcludeRoot, discover_workspace_projects, select_recursive_projects}, +}; + +/// `pnpm version` — apply the pending change intents (`-r` with no version +/// argument). The npm-style explicit-bump forms are not ported yet; +/// per-package release lanes are managed by `pnpm lane`. +#[derive(Debug, Args)] +pub struct VersionArgs { + /// An npm-style version argument (not ported yet); the bare recursive + /// form consumes the pending change intents instead. + pub params: Vec, + + /// Print the release plan the pending change intents produce without + /// applying it. + #[clap(long = "dry-run")] + pub dry_run: bool, + + /// Don't check if the working tree is clean. + #[clap(long = "no-git-checks")] + pub no_git_checks: bool, +} + +/// Errors of `pnpm version`. Codes and messages match the TypeScript CLI. +#[derive(Debug, Display, Error, Diagnostic)] +enum VersionError { + #[display( + "A version argument is required. Must be a valid semver version (e.g. 1.2.3) or one of: major, minor, patch, premajor, preminor, prepatch, prerelease" + )] + #[diagnostic(code(ERR_PNPM_INVALID_VERSION_BUMP))] + MissingBump, + + #[display( + "The npm-style \"pnpm version {bump}\" form is not implemented in the Rust CLI yet. The bare \"pnpm version -r\" form that consumes change intents is available." + )] + #[diagnostic(code(ERR_PNPM_NOT_IMPLEMENTED))] + NpmStyleNotPorted { bump: String }, + + #[display( + r#"The bare "pnpm version -r" form consumes change intents and is only supported in a workspace"# + )] + #[diagnostic(code(ERR_PNPM_WORKSPACE_ONLY))] + ReleaseOutsideWorkspace, + + #[display("Working tree is not clean. Commit or stash your changes.")] + #[diagnostic(code(ERR_PNPM_UNCLEAN_WORKING_TREE))] + UncleanWorkingTree, +} + +impl VersionArgs { + pub fn run(self, config: &Config, recursive: bool) -> miette::Result<()> { + match self.params.first().map(String::as_str) { + None if recursive => self.release_from_intents(config), + None => Err(VersionError::MissingBump.into()), + Some(bump) => Err(VersionError::NpmStyleNotPorted { bump: bump.to_string() }.into()), + } + } + + fn release_from_intents(&self, config: &Config) -> miette::Result<()> { + let Some(workspace_dir) = config.workspace_dir.clone() else { + return Err(VersionError::ReleaseOutsideWorkspace.into()); + }; + + if !self.dry_run + && config.git_checks + && !self.no_git_checks + && is_git_repo::(&workspace_dir) + && !is_working_tree_clean::(&workspace_dir) + { + return Err(VersionError::UncleanWorkingTree.into()); + } + + let intents = read_change_intents(&workspace_dir)?; + let ledger = read_ledger(&workspace_dir)?; + let (projects, _) = discover_workspace_projects(&workspace_dir)?; + let engine_projects = to_engine_projects(&projects); + + let filter = if config.filter.is_empty() { + None + } else { + Some( + selected_projects(&projects, config, &workspace_dir)? + .into_iter() + .map(|(_, dir)| dir) + .collect::>(), + ) + }; + let is_filtered = filter.is_some(); + let plan = assemble_release_plan( + &engine_projects, + &workspace_dir, + &intents, + &ledger, + Some(&config.versioning), + &AssembleReleasePlanOptions { + filter, + snapshot_suffix: None, + enforce_workspace_protocol: true, + }, + )?; + + if plan.releases.is_empty() { + // A full (unfiltered) run garbage-collects the intent files an + // empty plan leaves behind: declined ("none"-only) intents and + // files a merge resurrected after every named package had already + // consumed them. A filtered run must not — "nothing pending in + // this scope" is no reason to delete prose belonging to packages + // outside the filter. + if !self.dry_run && !is_filtered { + apply_release_plan( + &plan, + &workspace_dir, + &engine_projects, + &intents, + Some(&config.versioning), + )?; + } + println!(r#"No pending changes. Record one with "pnpm change"."#); + return Ok(()); + } + if self.dry_run { + println!("{}", render_release_plan(&plan)); + return Ok(()); + } + + let applied = apply_release_plan( + &plan, + &workspace_dir, + &engine_projects, + &intents, + Some(&config.versioning), + )?; + + use std::fmt::Write as _; + let mut output = String::from("Versions applied:\n"); + for release in &applied { + writeln!( + output, + "{}: {} → {}", + release.name, release.current_version, release.new_version, + ) + .expect("write to string"); + } + println!("{output}"); + Ok(()) + } +} + +/// The projects the active `--filter` selectors pick, in graph order, as +/// `(name, workspace-relative dir)` pairs. +pub(crate) fn selected_projects( + projects: &[pacquet_workspace::Project], + config: &Config, + workspace_dir: &Path, +) -> miette::Result, String)>> { + let selection = + select_recursive_projects(projects, config, workspace_dir, AutoExcludeRoot::Disabled)?; + Ok(selection + .selected + .iter() + .map(|(root_dir, node)| { + let name = node + .package + .project + .manifest + .value() + .get("name") + .and_then(|name| name.as_str()) + .map(ToString::to_string); + (name, pacquet_versioning::to_project_dir(workspace_dir, root_dir)) + }) + .collect()) +} diff --git a/pnpm/crates/cli/tests/change.rs b/pnpm/crates/cli/tests/change.rs new file mode 100644 index 0000000000..3718dd6c4a --- /dev/null +++ b/pnpm/crates/cli/tests/change.rs @@ -0,0 +1,312 @@ +//! Integration tests for `pnpm change` and the intent-consuming +//! `pnpm version`: recording an intent, printing the pending release plan, +//! applying it (manifest bumps, changelogs, the consumed-intents ledger, +//! intent-file cleanup) and release-lane management via +//! `pnpm lane`. Mirrors the +//! TypeScript CLI's `pnpm11/releasing/commands/test/change/index.test.ts`. + +use assert_cmd::prelude::*; +use command_extra::CommandExtra; +use pacquet_testing_utils::bin::CommandTempCwd; +use std::{fs, path::Path, process::Command}; + +fn write_workspace(workspace: &Path) { + fs::write(workspace.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n") + .expect("write pnpm-workspace.yaml"); + fs::write(workspace.join("package.json"), "{\"name\": \"e2e-root\", \"private\": true}\n") + .expect("write root package.json"); +} + +fn add_pkg(workspace: &Path, name: &str, version: &str, deps: &str) { + let dir = workspace.join("packages").join(name); + fs::create_dir_all(&dir).expect("create package dir"); + fs::write( + dir.join("package.json"), + format!("{{\"name\": \"{name}\", \"version\": \"{version}\", \"dependencies\": {deps}}}\n"), + ) + .expect("write package.json"); +} + +fn pnpm(workspace: &Path) -> Command { + Command::cargo_bin("pnpm").expect("find the pnpm binary").with_current_dir(workspace) +} + +fn stdout_of(mut command: Command) -> String { + let output = command.output().expect("run pnpm"); + assert!( + output.status.success(), + "command failed.\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn manifest_version(workspace: &Path, name: &str) -> String { + let manifest: serde_json::Value = serde_json::from_str( + &fs::read_to_string(workspace.join("packages").join(name).join("package.json")) + .expect("read package.json"), + ) + .expect("parse package.json"); + manifest["version"].as_str().expect("version is a string").to_string() +} + +#[test] +fn change_records_an_intent_and_version_applies_the_release_plan() { + let CommandTempCwd { workspace, root, .. } = CommandTempCwd::init(); + write_workspace(&workspace); + add_pkg(&workspace, "lib", "1.2.0", "{}"); + add_pkg(&workspace, "cli", "3.0.0", r#"{"lib": "workspace:^"}"#); + + let output = stdout_of(pnpm(&workspace).with_args([ + "change", + "--bump", + "major", + "--summary", + "Rewrote the widget API.", + "lib", + ])); + assert!(output.contains("Recorded change intent .changeset/"), "unexpected: {output}"); + + let status = stdout_of(pnpm(&workspace).with_args(["change", "status"])); + assert!(status.contains("lib: 1.2.0 → 2.0.0 (major, via intent)"), "unexpected: {status}"); + assert!( + status.contains("cli: 3.0.0 → 3.0.1 (patch, via dependencies)"), + "unexpected: {status}", + ); + + let dry_run = stdout_of(pnpm(&workspace).with_args(["version", "-r", "--dry-run"])); + assert!(dry_run.contains("lib: 1.2.0 → 2.0.0"), "unexpected: {dry_run}"); + + let applied = stdout_of(pnpm(&workspace).with_args(["version", "-r"])); + assert!(applied.contains("lib: 1.2.0 → 2.0.0"), "unexpected: {applied}"); + assert!(applied.contains("cli: 3.0.0 → 3.0.1"), "unexpected: {applied}"); + + assert_eq!(manifest_version(&workspace, "lib"), "2.0.0"); + assert_eq!(manifest_version(&workspace, "cli"), "3.0.1"); + + let lib_changelog = + fs::read_to_string(workspace.join("packages/lib/CHANGELOG.md")).expect("read changelog"); + assert!(lib_changelog.contains("## 2.0.0"), "unexpected: {lib_changelog}"); + assert!(lib_changelog.contains("- Rewrote the widget API."), "unexpected: {lib_changelog}"); + let cli_changelog = + fs::read_to_string(workspace.join("packages/cli/CHANGELOG.md")).expect("read changelog"); + assert!(cli_changelog.contains(" - lib@2.0.0"), "unexpected: {cli_changelog}"); + + let ledger = fs::read_to_string(workspace.join(".changeset/ledger.yaml")).expect("read ledger"); + assert!(ledger.contains("lib@2.0.0:"), "unexpected: {ledger}"); + + let leftover_intents: Vec<_> = fs::read_dir(workspace.join(".changeset")) + .expect("read .changeset") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".md")) + .collect(); + assert!(leftover_intents.is_empty(), "intent files were not cleaned up"); + + let no_pending = stdout_of(pnpm(&workspace).with_args(["version", "-r"])); + assert!(no_pending.contains("No pending changes"), "unexpected: {no_pending}"); + + drop(root); +} + +#[test] +fn lanes_are_entered_released_and_graduated() { + let CommandTempCwd { workspace, root, .. } = CommandTempCwd::init(); + write_workspace(&workspace); + add_pkg(&workspace, "cli", "2.0.0", "{}"); + + let bare = stdout_of(pnpm(&workspace).with_args(["lane"])); + assert!(bare.contains("All packages are on the main lane."), "unexpected: {bare}"); + + let entered = stdout_of(pnpm(&workspace).with_args(["lane", "alpha", "--filter", "cli"])); + assert!(entered.contains(r#"Moved to the "alpha" lane:"#), "unexpected: {entered}"); + + let membership = stdout_of(pnpm(&workspace).with_args(["lane"])); + assert!(membership.contains("alpha:"), "unexpected: {membership}"); + assert!(membership.contains(" cli"), "unexpected: {membership}"); + let manifest = fs::read_to_string(workspace.join("pnpm-workspace.yaml")).expect("read yaml"); + assert!(manifest.contains("cli: alpha"), "unexpected: {manifest}"); + + stdout_of(pnpm(&workspace).with_args([ + "change", + "--bump", + "minor", + "--summary", + "Added a flag.", + "cli", + ])); + let applied = stdout_of(pnpm(&workspace).with_args(["version", "-r"])); + assert!(applied.contains("cli: 2.0.0 → 2.1.0-alpha.0"), "unexpected: {applied}"); + + // The intent survives the prerelease: its prose is needed at graduation. + let intents: Vec<_> = fs::read_dir(workspace.join(".changeset")) + .expect("read .changeset") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".md")) + .collect(); + assert_eq!(intents.len(), 1, "the intent must survive until graduation"); + + let exited = stdout_of(pnpm(&workspace).with_args(["lane", "main", "--filter", "cli"])); + assert!(exited.contains("Moved to the main lane:"), "unexpected: {exited}"); + + let graduated = stdout_of(pnpm(&workspace).with_args(["version", "-r"])); + assert!(graduated.contains("cli: 2.1.0-alpha.0 → 2.1.0"), "unexpected: {graduated}"); + + let changelog = + fs::read_to_string(workspace.join("packages/cli/CHANGELOG.md")).expect("read changelog"); + assert!(changelog.contains("## 2.1.0-alpha.0"), "unexpected: {changelog}"); + assert!(changelog.contains("## 2.1.0"), "unexpected: {changelog}"); + + let manifest = fs::read_to_string(workspace.join("pnpm-workspace.yaml")).expect("read yaml"); + assert!(!manifest.contains("alpha"), "the versioning key must be cleaned up: {manifest}"); + + drop(root); +} + +#[test] +fn version_without_arguments_outside_recursive_mode_requires_a_bump() { + let CommandTempCwd { workspace, root, .. } = CommandTempCwd::init(); + write_workspace(&workspace); + add_pkg(&workspace, "lib", "1.0.0", "{}"); + + let output = pnpm(&workspace).with_arg("version").output().expect("run pnpm"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("A version argument is required"), "unexpected: {stderr}"); + + drop(root); +} + +#[test] +fn lane_assignment_requires_a_filter() { + let CommandTempCwd { workspace, root, .. } = CommandTempCwd::init(); + write_workspace(&workspace); + add_pkg(&workspace, "cli", "2.0.0", "{}"); + + let output = pnpm(&workspace).with_args(["lane", "alpha"]).output().expect("run pnpm"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--filter"), "unexpected: {stderr}"); + + drop(root); +} + +/// A filtered `pnpm version -r` with nothing pending in scope must not +/// garbage-collect intents belonging to packages outside the filter. +#[test] +fn a_filtered_version_run_leaves_out_of_scope_intents_untouched() { + let CommandTempCwd { workspace, root, .. } = CommandTempCwd::init(); + write_workspace(&workspace); + add_pkg(&workspace, "lib", "1.0.0", "{}"); + add_pkg(&workspace, "cli", "2.0.0", "{}"); + + stdout_of(pnpm(&workspace).with_args([ + "change", + "--bump", + "none", + "--summary", + "refactor, no release needed", + "lib", + ])); + + let output = stdout_of(pnpm(&workspace).with_args(["version", "-r", "--filter", "cli"])); + assert!(output.contains("No pending changes"), "unexpected: {output}"); + let intents: Vec<_> = fs::read_dir(workspace.join(".changeset")) + .expect("read .changeset") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".md")) + .collect(); + assert_eq!(intents.len(), 1, "the out-of-scope none-only intent must survive"); + + drop(root); +} + +/// `pnpm change status` is a read-only diagnostic: it must not fail with the +/// release-time workspace-protocol error, but `pnpm version -r` does enforce it. +#[test] +fn change_status_is_read_only_about_unmigrated_internal_deps() { + let CommandTempCwd { workspace, root, .. } = CommandTempCwd::init(); + write_workspace(&workspace); + add_pkg(&workspace, "lib", "1.0.0", "{}"); + add_pkg(&workspace, "cli", "2.0.0", r#"{"lib": "^1.0.0"}"#); + + let status = pnpm(&workspace).with_args(["change", "status"]).output().expect("run pnpm"); + assert!( + status.status.success(), + "change status must not fail: {}", + String::from_utf8_lossy(&status.stderr), + ); + + stdout_of(pnpm(&workspace).with_args([ + "change", + "--bump", + "patch", + "--summary", + "A fix.", + "lib", + ])); + let release = pnpm(&workspace).with_args(["version", "-r"]).output().expect("run pnpm"); + assert!(!release.status.success()); + assert!( + String::from_utf8_lossy(&release.stderr).contains("workspace: protocol"), + "unexpected: {}", + String::from_utf8_lossy(&release.stderr), + ); + + drop(root); +} + +/// Two workspace projects publishing the same npm name (pnpm's own TS and Rust +/// CLIs) must be referenced by directory; the ledger attributes the release to +/// the right one. +#[test] +fn a_name_shared_by_two_projects_must_be_referenced_by_directory() { + let CommandTempCwd { workspace, root, .. } = CommandTempCwd::init(); + fs::write(workspace.join("pnpm-workspace.yaml"), "packages:\n - ts/pnpm\n - rust/pnpm\n") + .expect("write pnpm-workspace.yaml"); + fs::write(workspace.join("package.json"), "{\"name\": \"e2e-root\", \"private\": true}\n") + .expect("write root package.json"); + for (dir, version) in [("ts/pnpm", "11.0.0"), ("rust/pnpm", "12.0.0")] { + let pkg_dir = workspace.join(dir); + fs::create_dir_all(&pkg_dir).expect("create package dir"); + fs::write( + pkg_dir.join("package.json"), + format!("{{\"name\": \"pnpm\", \"version\": \"{version}\"}}\n"), + ) + .expect("write package.json"); + } + + let output = pnpm(&workspace) + .with_args(["change", "--bump", "patch", "--summary", "x", "pnpm"]) + .output() + .expect("run pnpm"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("matches multiple workspace projects"), "unexpected: {stderr}"); + + let recorded = stdout_of(pnpm(&workspace).with_args([ + "change", + "--bump", + "patch", + "--summary", + "Rust-line fix.", + "./rust/pnpm", + ])); + assert!(recorded.contains("Recorded change intent"), "unexpected: {recorded}"); + + let applied = stdout_of(pnpm(&workspace).with_args(["version", "-r"])); + assert!(applied.contains("pnpm: 12.0.0 → 12.0.1"), "unexpected: {applied}"); + assert!(!applied.contains("11.0.0"), "the TS line must not release: {applied}"); + + let rust_manifest: serde_json::Value = serde_json::from_str( + &fs::read_to_string(workspace.join("rust/pnpm/package.json")).expect("read"), + ) + .expect("parse"); + assert_eq!(rust_manifest["version"].as_str(), Some("12.0.1")); + + let ledger = fs::read_to_string(workspace.join(".changeset/ledger.yaml")).expect("read ledger"); + assert!(ledger.contains("pnpm@12.0.1:"), "unexpected ledger: {ledger}"); + assert!(ledger.contains("dir: rust/pnpm"), "ledger must attribute by dir: {ledger}"); + + drop(root); +} diff --git a/pnpm/crates/config/Cargo.toml b/pnpm/crates/config/Cargo.toml index 521c54115b..cd24b15278 100644 --- a/pnpm/crates/config/Cargo.toml +++ b/pnpm/crates/config/Cargo.toml @@ -19,6 +19,7 @@ pacquet-network = { workspace = true } pacquet-package-is-installable = { workspace = true } pacquet-patching = { workspace = true } pacquet-store-dir = { workspace = true } +pacquet-versioning = { workspace = true } pacquet-workspace-state = { workspace = true } derive_more = { workspace = true } diff --git a/pnpm/crates/config/src/lib.rs b/pnpm/crates/config/src/lib.rs index 03275d27fa..33df197029 100644 --- a/pnpm/crates/config/src/lib.rs +++ b/pnpm/crates/config/src/lib.rs @@ -1483,6 +1483,10 @@ pub struct Config { /// `auditConfig` config for `pnpm audit`. pub audit_config: AuditConfig, + /// `versioning` from `pnpm-workspace.yaml`: native workspace release + /// management, consumed by `pnpm change` and the bare `pnpm version -r`. + pub versioning: pacquet_versioning::VersioningSettings, + /// Glob-style `name[@version]` patterns that opt specific packages /// out of the [`trust_policy`] check. The `trustPolicyExclude` /// setting. diff --git a/pnpm/crates/config/src/workspace_yaml.rs b/pnpm/crates/config/src/workspace_yaml.rs index 92c7ea991e..d0745566cc 100644 --- a/pnpm/crates/config/src/workspace_yaml.rs +++ b/pnpm/crates/config/src/workspace_yaml.rs @@ -405,6 +405,11 @@ pub struct WorkspaceSettings { /// `auditConfig` from `pnpm-workspace.yaml`. pub audit_config: Option, + /// `versioning` from `pnpm-workspace.yaml`: native workspace release + /// management (fixed groups, ignore list, maxBump cap, per-package + /// prerelease lines, changelog settings). + pub versioning: Option, + /// `trustPolicyExclude` from `pnpm-workspace.yaml`. pub trust_policy_exclude: Option>, @@ -581,6 +586,7 @@ impl WorkspaceSettings { /// hoisted` in `~/.config/pnpm/config.yaml` and pacquet would /// honor it while pnpm wouldn't — anti-parity. pub fn clear_workspace_only_fields(&mut self) { + self.versioning = None; self.hoist = None; self.hoist_pattern = None; self.public_hoist_pattern = None; @@ -906,6 +912,9 @@ impl WorkspaceSettings { if let Some(v) = self.audit_config { config.audit_config = v; } + if let Some(v) = self.versioning { + config.versioning = v; + } if let Some(v) = self.trust_policy_exclude { config.trust_policy_exclude = Some(v); } diff --git a/pnpm/crates/config/src/workspace_yaml/tests.rs b/pnpm/crates/config/src/workspace_yaml/tests.rs index 5e67084b58..46e760a1c1 100644 --- a/pnpm/crates/config/src/workspace_yaml/tests.rs +++ b/pnpm/crates/config/src/workspace_yaml/tests.rs @@ -578,6 +578,21 @@ changedFilesIgnorePattern: assert!(settings.changed_files_ignore_pattern.is_none()); } +/// `versioning` is workspace-only: release plans must not be shaped by a +/// global `config.yaml`. +#[test] +fn versioning_cleared_as_workspace_only_field() { + let yaml = r#" +versioning: + lanes: + "@example/cli": alpha +"#; + let mut settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap(); + assert!(settings.versioning.is_some()); + settings.clear_workspace_only_fields(); + assert!(settings.versioning.is_none()); +} + /// `configDependencies` is workspace-only: it must not be honored from /// the global `config.yaml`, matching pnpm's `isConfigFileKey` filter. #[test] diff --git a/pnpm/crates/package-manager/src/install_package_from_registry/tests.rs b/pnpm/crates/package-manager/src/install_package_from_registry/tests.rs index 631567ae61..074991bff4 100644 --- a/pnpm/crates/package-manager/src/install_package_from_registry/tests.rs +++ b/pnpm/crates/package-manager/src/install_package_from_registry/tests.rs @@ -26,6 +26,7 @@ use tempfile::tempdir; fn create_config(store_dir: &Path, modules_dir: &Path, virtual_store_dir: &Path) -> Config { Config { + versioning: Default::default(), hoist: false, hoist_pattern: None, public_hoist_pattern: None, diff --git a/pnpm/crates/versioning/Cargo.toml b/pnpm/crates/versioning/Cargo.toml new file mode 100644 index 0000000000..7415cf12b4 --- /dev/null +++ b/pnpm/crates/versioning/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "pacquet-versioning" +version = "0.0.1" +publish = false +authors.workspace = true +description.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +pacquet-package-manifest = { workspace = true } + +derive_more = { workspace = true } +getrandom = { workspace = true } +indexmap = { workspace = true } +miette = { workspace = true } +node-semver = { workspace = true } +pathdiff = { workspace = true } +serde = { workspace = true } +serde-saphyr = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/pnpm/crates/versioning/src/apply.rs b/pnpm/crates/versioning/src/apply.rs new file mode 100644 index 0000000000..b9ed9c06e9 --- /dev/null +++ b/pnpm/crates/versioning/src/apply.rs @@ -0,0 +1,122 @@ +use std::{ + collections::{BTreeMap, HashSet}, + fs, + path::Path, +}; + +use crate::{ + changelog::{compose_changelog_section, prepend_changelog_section}, + error::VersioningError, + intents::{ChangeIntent, IntentBumpType}, + ledger::{PackageConsumption, append_to_ledger, build_consumption_index}, + plan::{ReleasePlan, WorkspaceProject, index_project_refs}, + settings::{ChangelogStorage, VersioningSettings}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppliedRelease { + pub name: String, + pub current_version: String, + pub new_version: String, +} + +/// Applies an assembled release plan: manifest version updates, changelog +/// sections, the consumed-intents ledger, and intent-file cleanup. +/// `all_intents` is every intent file currently in the workspace, used to +/// decide which files are fully consumed after this run and can be deleted. +pub fn apply_release_plan( + plan: &ReleasePlan, + workspace_dir: &Path, + projects: &[WorkspaceProject], + all_intents: &[ChangeIntent], + versioning: Option<&VersioningSettings>, +) -> Result, VersioningError> { + assert_supported_changelog_storage(versioning)?; + + let mut applied = Vec::with_capacity(plan.releases.len()); + for release in &plan.releases { + let manifest_path = release.root_dir.join("package.json"); + let mut manifest = pacquet_package_manifest::PackageManifest::from_path(manifest_path) + .map_err(VersioningError::Manifest)?; + manifest.value_mut()["version"] = serde_json::Value::String(release.new_version.clone()); + manifest.save().map_err(VersioningError::Manifest)?; + applied.push(AppliedRelease { + name: release.name.clone(), + current_version: release.current_version.clone(), + new_version: release.new_version.clone(), + }); + } + + for release in &plan.releases { + let section = compose_changelog_section(release); + prepend_changelog_section(&release.root_dir, &release.name, §ion)?; + } + + let mut new_entries = BTreeMap::new(); + for release in &plan.releases { + if release.intents.is_empty() { + continue; + } + let mut ids: Vec = release.intents.iter().map(|intent| intent.id.clone()).collect(); + ids.sort(); + new_entries.insert( + format!("{}@{}", release.name, release.new_version), + (release.dir.clone(), ids), + ); + } + let ledger = append_to_ledger(workspace_dir, &new_entries)?; + + // An intent file is deletable once every project it names has a ledger + // entry for it, with one exemption: while a project is still on a lane, + // entries against prerelease versions alone keep the file alive — its + // prose is still needed to compose the stable changelog section at + // graduation. Declined (`none`) entries demand no release and never + // block deletion. References here were already validated by the plan + // assembly, so an unresolvable one just keeps its file around. + let refs = index_project_refs(projects, workspace_dir); + let consumption = build_consumption_index(&ledger, |name| refs.name_to_dirs(name))?; + let empty = PackageConsumption::default(); + let mut lane_dirs: HashSet = HashSet::new(); + for reference in versioning.map(|settings| settings.lanes.keys()).into_iter().flatten() { + lane_dirs.extend(refs.ref_to_dirs(reference)); + } + + for intent in all_intents { + let deletable = intent.releases.iter().all(|(reference, bump_type)| { + if *bump_type == IntentBumpType::None { + return true; + } + let dirs = refs.ref_to_dirs(reference); + let [dir] = dirs.as_slice() else { + return false; + }; + let consumed = consumption.get(dir).unwrap_or(&empty); + consumed.all_ids.contains(&intent.id) + && !(lane_dirs.contains(dir) && consumed.prerelease_only_ids.contains(&intent.id)) + }); + if deletable { + fs::remove_file(&intent.file_path).map_err(|source| VersioningError::Write { + path: intent.file_path.clone(), + source, + })?; + } + } + Ok(applied) +} + +fn assert_supported_changelog_storage( + versioning: Option<&VersioningSettings>, +) -> Result<(), VersioningError> { + let storage = versioning + .and_then(|settings| settings.changelog.as_ref()) + .and_then(|changelog| changelog.storage); + match storage { + None | Some(ChangelogStorage::Repository) => Ok(()), + Some(storage) => { + Err(VersioningError::UnsupportedChangelogStorage { storage: storage.to_string() }) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/pnpm/crates/versioning/src/apply/tests.rs b/pnpm/crates/versioning/src/apply/tests.rs new file mode 100644 index 0000000000..febe85fa2d --- /dev/null +++ b/pnpm/crates/versioning/src/apply/tests.rs @@ -0,0 +1,219 @@ +use std::{fs, path::Path}; + +use indexmap::IndexMap; +use pretty_assertions::assert_eq; + +use super::apply_release_plan; +use crate::{ + changelog::prepend_changelog_section, + intents::{IntentBumpType, read_change_intents, write_change_intent}, + ledger::read_ledger, + plan::{ + AssembleReleasePlanOptions, DependencyField, ManifestDependency, WorkspaceProject, + assemble_release_plan, + }, + settings::VersioningSettings, +}; + +struct Workspace { + dir: tempfile::TempDir, + projects: Vec, +} + +type FixturePkg<'a> = (&'a str, &'a str, &'a [(&'a str, &'a str)]); + +fn make_workspace(pkgs: &[FixturePkg<'_>]) -> Workspace { + let dir = tempfile::tempdir().expect("create temp workspace"); + let projects = pkgs + .iter() + .map(|(name, version, deps)| { + let root_dir = dir.path().join(name.replace(['@', '/'], "_")); + fs::create_dir_all(&root_dir).expect("create package dir"); + let dependencies: serde_json::Map = deps + .iter() + .map(|(alias, spec)| ((*alias).to_string(), serde_json::json!(spec))) + .collect(); + let manifest = serde_json::json!({ + "name": name, + "version": version, + "dependencies": dependencies, + }); + fs::write( + root_dir.join("package.json"), + format!( + "{}\n", + serde_json::to_string_pretty(&manifest).expect("serialize manifest"), + ), + ) + .expect("write package.json"); + WorkspaceProject { + root_dir, + name: Some((*name).to_string()), + version: Some((*version).to_string()), + prod_dependencies: deps + .iter() + .map(|(alias, spec)| ManifestDependency { + field: DependencyField::Dependencies, + alias: (*alias).to_string(), + spec: (*spec).to_string(), + }) + .collect(), + } + }) + .collect(); + Workspace { dir, projects } +} + +fn manifest_version(root_dir: &Path) -> String { + let manifest: serde_json::Value = + serde_json::from_str(&fs::read_to_string(root_dir.join("package.json")).expect("read")) + .expect("parse"); + manifest["version"].as_str().expect("version is a string").to_string() +} + +#[test] +fn apply_bumps_manifests_writes_changelogs_records_the_ledger_and_deletes_consumed_intents() { + let workspace = + make_workspace(&[("lib", "1.0.0", &[]), ("cli", "2.0.0", &[("lib", "workspace:*")])]); + let releases = IndexMap::from([("lib".to_string(), IntentBumpType::Minor)]); + write_change_intent(workspace.dir.path(), &releases, "Added a feature.") + .expect("intent writes"); + let intents = read_change_intents(workspace.dir.path()).expect("intents read"); + let ledger = read_ledger(workspace.dir.path()).expect("ledger reads"); + let plan = assemble_release_plan( + &workspace.projects, + workspace.dir.path(), + &intents, + &ledger, + None, + &AssembleReleasePlanOptions::default(), + ) + .expect("plan assembles"); + + let applied = + apply_release_plan(&plan, workspace.dir.path(), &workspace.projects, &intents, None) + .expect("plan applies"); + let mut applied_names: Vec = + applied.iter().map(|release| format!("{}@{}", release.name, release.new_version)).collect(); + applied_names.sort(); + assert_eq!(applied_names, ["cli@2.0.1", "lib@1.1.0"]); + + let lib_dir = &workspace.projects[0].root_dir; + let cli_dir = &workspace.projects[1].root_dir; + assert_eq!(manifest_version(lib_dir), "1.1.0"); + assert_eq!(manifest_version(cli_dir), "2.0.1"); + + let lib_changelog = fs::read_to_string(lib_dir.join("CHANGELOG.md")).expect("read changelog"); + assert!(lib_changelog.contains("# lib")); + assert!(lib_changelog.contains("## 1.1.0")); + assert!(lib_changelog.contains("### Minor Changes")); + assert!(lib_changelog.contains("- Added a feature.")); + + let cli_changelog = fs::read_to_string(cli_dir.join("CHANGELOG.md")).expect("read changelog"); + assert!(cli_changelog.contains("## 2.0.1")); + assert!(cli_changelog.contains("- Updated dependencies:")); + assert!(cli_changelog.contains(" - lib@1.1.0")); + + let ledger = read_ledger(workspace.dir.path()).expect("ledger reads"); + let keys: Vec<&str> = ledger.keys().map(String::as_str).collect(); + assert_eq!(keys, ["lib@1.1.0"]); + + assert_eq!(read_change_intents(workspace.dir.path()).expect("intents read").len(), 0); +} + +#[test] +fn intent_files_consumed_only_by_lane_prereleases_survive_until_graduation() { + let workspace = make_workspace(&[("cli", "2.0.0", &[])]); + let releases = IndexMap::from([("cli".to_string(), IntentBumpType::Minor)]); + write_change_intent(workspace.dir.path(), &releases, "Added a feature.") + .expect("intent writes"); + let versioning = VersioningSettings { + lanes: IndexMap::from([("cli".to_string(), "alpha".to_string())]), + ..VersioningSettings::default() + }; + + let intents = read_change_intents(workspace.dir.path()).expect("intents read"); + let ledger = read_ledger(workspace.dir.path()).expect("ledger reads"); + let prerelease_plan = assemble_release_plan( + &workspace.projects, + workspace.dir.path(), + &intents, + &ledger, + Some(&versioning), + &AssembleReleasePlanOptions::default(), + ) + .expect("plan assembles"); + assert_eq!(prerelease_plan.releases[0].new_version, "2.1.0-alpha.0"); + apply_release_plan( + &prerelease_plan, + workspace.dir.path(), + &workspace.projects, + &intents, + Some(&versioning), + ) + .expect("plan applies"); + + // The prose is still needed for the stable changelog at graduation. + let intents = read_change_intents(workspace.dir.path()).expect("intents read"); + assert_eq!(intents.len(), 1); + + // Return to the main lane: the accumulated stable version releases and + // the intent is garbage-collected. + let graduated_projects = [WorkspaceProject { + root_dir: workspace.projects[0].root_dir.clone(), + name: Some("cli".to_string()), + version: Some("2.1.0-alpha.0".to_string()), + prod_dependencies: Vec::new(), + }]; + let ledger = read_ledger(workspace.dir.path()).expect("ledger reads"); + let graduation_plan = assemble_release_plan( + &graduated_projects, + workspace.dir.path(), + &intents, + &ledger, + None, + &AssembleReleasePlanOptions::default(), + ) + .expect("plan assembles"); + assert_eq!(graduation_plan.releases[0].new_version, "2.1.0"); + apply_release_plan(&graduation_plan, workspace.dir.path(), &graduated_projects, &intents, None) + .expect("plan applies"); + + let changelog = + fs::read_to_string(workspace.projects[0].root_dir.join("CHANGELOG.md")).expect("read"); + assert!(changelog.contains("## 2.1.0-alpha.0")); + assert!(changelog.contains("## 2.1.0")); + assert_eq!(read_change_intents(workspace.dir.path()).expect("intents read").len(), 0); +} + +#[test] +fn a_none_only_intent_is_garbage_collected_by_a_run_with_an_empty_plan() { + let workspace = make_workspace(&[("lib", "1.0.0", &[])]); + let releases = IndexMap::from([("lib".to_string(), IntentBumpType::None)]); + write_change_intent(workspace.dir.path(), &releases, "refactor, no release needed") + .expect("intent writes"); + let intents = read_change_intents(workspace.dir.path()).expect("intents read"); + let plan = assemble_release_plan( + &workspace.projects, + workspace.dir.path(), + &intents, + &read_ledger(workspace.dir.path()).expect("ledger reads"), + None, + &AssembleReleasePlanOptions::default(), + ) + .expect("plan assembles"); + assert!(plan.releases.is_empty()); + apply_release_plan(&plan, workspace.dir.path(), &workspace.projects, &intents, None) + .expect("plan applies"); + assert_eq!(read_change_intents(workspace.dir.path()).expect("intents read").len(), 0); +} + +#[test] +fn prepend_keeps_the_title_above_the_new_section_even_without_a_trailing_newline() { + let dir = tempfile::tempdir().expect("create temp dir"); + fs::write(dir.path().join("CHANGELOG.md"), "# lib").expect("write changelog"); + prepend_changelog_section(dir.path(), "lib", "## 1.0.1\n\n### Patch Changes\n\n- A fix.\n") + .expect("changelog updates"); + let changelog = fs::read_to_string(dir.path().join("CHANGELOG.md")).expect("read changelog"); + assert!(changelog.starts_with("# lib\n\n## 1.0.1"), "unexpected changelog: {changelog}"); +} diff --git a/pnpm/crates/versioning/src/changelog.rs b/pnpm/crates/versioning/src/changelog.rs new file mode 100644 index 0000000000..cb058958c5 --- /dev/null +++ b/pnpm/crates/versioning/src/changelog.rs @@ -0,0 +1,130 @@ +use std::{fs, io::ErrorKind, path::Path}; + +use crate::{ + error::VersioningError, + intents::IntentBumpType, + ledger::normalize_project_dir, + plan::{PlannedRelease, is_dir_ref}, + settings::ReleaseBumpType, +}; + +fn section_title(bump_type: ReleaseBumpType) -> &'static str { + match bump_type { + ReleaseBumpType::Major => "Major Changes", + ReleaseBumpType::Minor => "Minor Changes", + ReleaseBumpType::Patch => "Patch Changes", + } +} + +/// Renders one release's changelog section. Byte-identical to the TypeScript +/// `composeChangelogSection`, since both stacks write the same CHANGELOG.md. +#[must_use] +pub fn compose_changelog_section(release: &PlannedRelease) -> String { + let mut entries_by_bump: [(ReleaseBumpType, Vec); 3] = [ + (ReleaseBumpType::Major, Vec::new()), + (ReleaseBumpType::Minor, Vec::new()), + (ReleaseBumpType::Patch, Vec::new()), + ]; + for intent in &release.intents { + let Some(bump_type) = release_bump_for(&intent.releases, release) else { + continue; + }; + if intent.summary.is_empty() { + continue; + } + let entries = &mut entries_by_bump + .iter_mut() + .find(|(entry_bump, _)| *entry_bump == bump_type) + .expect("all bump classes are present") + .1; + entries.push(format_list_item(&intent.summary)); + } + if !release.dependency_updates.is_empty() { + let dep_lines: Vec = release + .dependency_updates + .iter() + .map(|dep| format!(" - {}@{}", dep.name, dep.new_version)) + .collect(); + entries_by_bump[2].1.push(format!("- Updated dependencies:\n{}", dep_lines.join("\n"))); + } + + let mut parts = vec![format!("## {}", release.new_version)]; + for (bump_type, entries) in &entries_by_bump { + if entries.is_empty() { + continue; + } + parts.push(format!("### {}", section_title(*bump_type))); + parts.push(entries.join("\n\n")); + } + format!("{}\n", parts.join("\n\n")) +} + +/// The bump an intent declares for this release, whichever way the intent +/// references the project — by name (sound only when unambiguous, which plan +/// assembly guarantees) or by directory. +fn release_bump_for( + releases: &indexmap::IndexMap, + release: &PlannedRelease, +) -> Option { + for (reference, bump_type) in releases { + if reference == &release.name + || (is_dir_ref(reference) && normalize_project_dir(reference) == release.dir) + { + return bump_type.release(); + } + } + None +} + +fn format_list_item(summary: &str) -> String { + let mut lines = summary.split('\n'); + let first_line = lines.next().unwrap_or_default(); + let mut item = format!("- {first_line}"); + for line in lines { + item.push('\n'); + if !line.is_empty() { + item.push_str(" "); + item.push_str(line); + } + } + item +} + +/// Inserts `section` at the top of the package's CHANGELOG.md, under the +/// `# ` title (which is created for a new file). +pub fn prepend_changelog_section( + pkg_dir: &Path, + pkg_name: &str, + section: &str, +) -> Result<(), VersioningError> { + let changelog_path = pkg_dir.join("CHANGELOG.md"); + let existing = match fs::read_to_string(&changelog_path) { + Ok(existing) => Some(existing), + Err(err) if err.kind() == ErrorKind::NotFound => None, + Err(source) => { + return Err(VersioningError::Read { path: changelog_path, source }); + } + }; + + let content = match existing { + None => format!("# {pkg_name}\n\n{section}"), + Some(existing) => { + let (first_line, rest) = match existing.find('\n') { + Some(newline_index) => (&existing[..newline_index], &existing[newline_index + 1..]), + None => (existing.as_str(), ""), + }; + if first_line.starts_with("# ") { + let body = rest.trim_start_matches(['\r', '\n']); + if body.is_empty() { + format!("{first_line}\n\n{section}") + } else { + format!("{first_line}\n\n{section}\n{body}") + } + } else { + format!("{section}\n{existing}") + } + } + }; + fs::write(&changelog_path, content) + .map_err(|source| VersioningError::Write { path: changelog_path, source }) +} diff --git a/pnpm/crates/versioning/src/error.rs b/pnpm/crates/versioning/src/error.rs new file mode 100644 index 0000000000..c341bdef2f --- /dev/null +++ b/pnpm/crates/versioning/src/error.rs @@ -0,0 +1,99 @@ +use std::path::PathBuf; + +use derive_more::{Display, Error}; +use miette::Diagnostic; + +/// Errors of the versioning engine. Codes and messages match the TypeScript +/// implementation's `PnpmError`s — they are part of the shared CLI contract. +#[derive(Debug, Display, Error, Diagnostic)] +pub enum VersioningError { + #[display("Change intent file {} has no YAML frontmatter", file_path.display())] + #[diagnostic(code(ERR_PNPM_INVALID_CHANGE_INTENT))] + NoFrontmatter { file_path: PathBuf }, + + #[display("Change intent file {} has invalid YAML frontmatter: {message}", file_path.display())] + #[diagnostic(code(ERR_PNPM_INVALID_CHANGE_INTENT))] + InvalidFrontmatter { file_path: PathBuf, message: String }, + + #[display( + "Change intent file {} declares an invalid bump type for {pkg_name}: {bump_type}. Expected one of none, patch, minor, major", + file_path.display() + )] + #[diagnostic(code(ERR_PNPM_INVALID_CHANGE_INTENT))] + InvalidBumpType { file_path: PathBuf, pkg_name: String, bump_type: String }, + + #[display("Expected {} to be a mapping of package@version keys to intent id lists", ledger_path.display())] + #[diagnostic(code(ERR_PNPM_INVALID_VERSIONING_LEDGER))] + InvalidLedger { ledger_path: PathBuf }, + + #[display( + "The ledger entry {key} names {pkg_name}, which matches multiple workspace projects ({}). Rewrite the entry with an explicit \"dir\".", + dirs.join(", ") + )] + #[diagnostic(code(ERR_PNPM_INVALID_VERSIONING_LEDGER))] + AmbiguousLedgerEntry { key: String, pkg_name: String, dirs: Vec }, + + #[display("{context} names {reference}, which matches multiple workspace projects: {}. Reference the project by directory instead.", dirs.join(", "))] + #[diagnostic(code(ERR_PNPM_VERSIONING_AMBIGUOUS_PACKAGE))] + AmbiguousPackage { context: String, reference: String, dirs: Vec }, + + #[display("Change intent file {} names {pkg_name}, which is not a package in this workspace", file_path.display())] + #[diagnostic(code(ERR_PNPM_VERSIONING_UNKNOWN_PACKAGE))] + UnknownPackage { file_path: PathBuf, pkg_name: String }, + + #[display( + "Change intent file {} requests a {bump_type} release of {pkg_name}, which cannot release (it is listed in versioning.ignore, has no version field, or has a non-semver version). Remove the entry or change it to \"none\".", + file_path.display() + )] + #[diagnostic(code(ERR_PNPM_VERSIONING_UNRELEASABLE_PACKAGE))] + UnreleasablePackage { file_path: PathBuf, pkg_name: String, bump_type: String }, + + #[display( + "Package {pkg_name} declares the internal dependency {alias} in {field} as \"{spec}\". Internal dependencies must use the workspace: protocol so that dependency ranges never need rewriting at release time." + )] + #[diagnostic(code(ERR_PNPM_VERSIONING_INTERNAL_RANGE))] + InternalRange { pkg_name: String, alias: String, field: String, spec: String }, + + #[display( + "versioning.lanes assigns {pkg_name} to the \"{lane}\" lane, but \"main\" is the reserved default lane. Remove the entry instead." + )] + #[diagnostic(code(ERR_PNPM_VERSIONING_INVALID_LANE_NAME))] + InvalidLaneName { pkg_name: String, lane: String }, + + #[display( + "The fixed group [{}] mixes packages on different lanes. A fixed group must move between lanes together.", + group.join(", ") + )] + #[diagnostic(code(ERR_PNPM_VERSIONING_CONFLICTING_CONFIG))] + ConflictingConfig { group: Vec }, + + #[display( + "Two projects both release {identity}: ./{first_dir} and ./{second_dir}. A package name and version identify one published artifact, so same-named projects must release on different version lines (e.g. different lanes or majors)." + )] + #[diagnostic(code(ERR_PNPM_VERSIONING_DUPLICATE_RELEASE))] + DuplicateRelease { identity: String, first_dir: String, second_dir: String }, + + #[display( + "The release plan bumps {pkg_name} by {bump_type}, but versioning.maxBump caps releases from this branch at {max_bump}. Raised by {raised_by}." + )] + #[diagnostic(code(ERR_PNPM_VERSIONING_MAX_BUMP_EXCEEDED))] + MaxBumpExceeded { pkg_name: String, bump_type: String, max_bump: String, raised_by: String }, + + #[display( + "versioning.changelog.storage \"{storage}\" is not implemented yet. Use \"repository\"." + )] + #[diagnostic(code(ERR_PNPM_VERSIONING_UNSUPPORTED_CHANGELOG_STORAGE))] + UnsupportedChangelogStorage { storage: String }, + + #[display("Failed to read {}: {source}", path.display())] + #[diagnostic(code(pacquet_versioning::read_error))] + Read { path: PathBuf, source: std::io::Error }, + + #[display("Failed to write {}: {source}", path.display())] + #[diagnostic(code(pacquet_versioning::write_error))] + Write { path: PathBuf, source: std::io::Error }, + + #[display("{_0}")] + #[diagnostic(transparent)] + Manifest(pacquet_package_manifest::PackageManifestError), +} diff --git a/pnpm/crates/versioning/src/human_id.rs b/pnpm/crates/versioning/src/human_id.rs new file mode 100644 index 0000000000..8cc00c5d7d --- /dev/null +++ b/pnpm/crates/versioning/src/human_id.rs @@ -0,0 +1,189 @@ +//! Human-readable random ids for intent files, in the adjective-noun-verb +//! shape the changesets ecosystem uses (`wild-wombats-do`). The exact word +//! pool is not part of the cross-stack contract — any collision-free file +//! name works — so a compact embedded list stands in for the TypeScript +//! side's `human-id` dependency. + +const ADJECTIVES: &[&str] = &[ + "afraid", "ancient", "angry", "big", "brave", "breezy", "bright", "calm", "chilly", "clean", + "clever", "cool", "curly", "curvy", "dry", "eager", "early", "empty", "fair", "famous", "fast", + "fluffy", "fresh", "friendly", "funny", "gentle", "giant", "good", "great", "happy", "heavy", + "honest", "hungry", "itchy", "khaki", "kind", "large", "lazy", "light", "little", "loud", + "lucky", "mean", "mighty", "modern", "neat", "new", "nice", "old", "olive", "orange", "polite", + "pretty", "proud", "purple", "quick", "quiet", "rare", "real", "shaggy", "sharp", "shiny", + "short", "silent", "silly", "slow", "small", "smart", "smooth", "soft", "sour", "spicy", + "strong", "sweet", "tall", "tame", "thick", "thin", "tidy", "tiny", "tough", "vast", "warm", + "weak", "wet", "wicked", "wide", "wild", "wise", "young", +]; + +const NOUNS: &[&str] = &[ + "ants", + "apes", + "bats", + "beans", + "bears", + "beds", + "bees", + "birds", + "boats", + "books", + "bottles", + "boxes", + "buses", + "camels", + "carrots", + "cats", + "chairs", + "cherries", + "clocks", + "clouds", + "cobras", + "cooks", + "cows", + "crabs", + "cups", + "deers", + "dodos", + "dogs", + "dolphins", + "donkeys", + "doors", + "dragons", + "ducks", + "eagles", + "eels", + "eggs", + "elephants", + "emus", + "falcons", + "fireants", + "fishes", + "flies", + "flowers", + "forks", + "foxes", + "frogs", + "geckos", + "geese", + "goats", + "grapes", + "hairs", + "hats", + "horses", + "hounds", + "houses", + "icons", + "insects", + "jars", + "jeans", + "jobs", + "jokes", + "keys", + "kids", + "kings", + "kiwis", + "knives", + "lamps", + "lemons", + "lions", + "lizards", + "mails", + "mangos", + "maps", + "melons", + "mice", + "moles", + "monkeys", + "moons", + "moose", + "mugs", + "olives", + "onions", + "owls", + "pandas", + "peaches", + "pears", + "pens", + "pigs", + "planes", + "plums", + "poets", + "pots", + "pumas", + "queens", + "rabbits", + "rats", + "ravens", + "rice", + "rings", + "rivers", + "rocks", + "roses", + "seals", + "seas", + "sheep", + "shirts", + "shoes", + "snails", + "snakes", + "socks", + "spiders", + "spoons", + "squids", + "stars", + "suns", + "swans", + "tables", + "taxis", + "teeth", + "tigers", + "tips", + "tools", + "toys", + "trains", + "trees", + "turkeys", + "turtles", + "walls", + "wasps", + "waves", + "wolves", + "wombats", + "worms", + "zebras", +]; + +const VERBS: &[&str] = &[ + "act", "add", "agree", "argue", "arrive", "ask", "attack", "bake", "bathe", "beam", "beg", + "begin", "behave", "bet", "boil", "bow", "brake", "brush", "build", "burn", "buy", "call", + "camp", "care", "carry", "change", "cheat", "cheer", "chew", "clap", "clean", "collect", + "compare", "compete", "complain", "cough", "count", "cover", "crash", "cross", "cry", "dance", + "decide", "deliver", "deny", "design", "destroy", "dig", "divide", "do", "double", "doubt", + "draw", "dream", "dress", "drive", "drop", "drum", "eat", "end", "enjoy", "exist", "explain", + "explode", "fail", "fetch", "film", "fix", "flash", "float", "flow", "fly", "fold", "fry", + "give", "glow", "greet", "grin", "grow", "guess", "hammer", "hang", "happen", "heal", "hear", + "help", "hide", "hope", "hug", "hunt", "invent", "invite", "itch", "jam", "jog", "join", + "joke", "judge", "juggle", "jump", "kick", "kiss", "kneel", "knock", "know", "learn", "leave", + "lick", "lie", "listen", "live", "look", "love", "march", "marry", "matter", "melt", "mix", + "move", "nail", "notice", "obey", "occur", "open", "own", "pay", "peel", "perform", "play", + "poke", "press", "pretend", "promise", "pull", "pump", "punch", "push", "raise", "reflect", + "refuse", "relate", "relax", "remain", "remember", "repair", "repeat", "reply", "report", + "rescue", "rest", "retire", "return", "rhyme", "ring", "roll", "rule", "run", "rush", "scream", + "search", "sell", "serve", "shake", "share", "shave", "shine", "shop", "shout", "sin", "sing", + "sip", "sit", "sleep", "smash", "smell", "smile", "smoke", "sneeze", "sniff", "sort", "speak", + "stand", "stare", "start", "stay", "study", "swim", "switch", "tan", "tap", "taste", "teach", + "tease", "tell", "thank", "think", "throw", "tickle", "tie", "trade", "train", "travel", "try", + "turn", "type", "unite", "vanish", "visit", "wait", "walk", "warn", "wash", "watch", "wave", + "whisper", "win", "wink", "wonder", "work", "worry", "yawn", "yell", +]; + +/// A random `adjective-nouns-verb` id, e.g. `wild-wombats-do`. +pub fn random_human_id() -> String { + format!("{}-{}-{}", pick(ADJECTIVES), pick(NOUNS), pick(VERBS)) +} + +fn pick(words: &[&'static str]) -> &'static str { + let mut buf = [0u8; 4]; + getrandom::fill(&mut buf).expect("read entropy from the operating system"); + words[u32::from_le_bytes(buf) as usize % words.len()] +} diff --git a/pnpm/crates/versioning/src/intents.rs b/pnpm/crates/versioning/src/intents.rs new file mode 100644 index 0000000000..20d47771df --- /dev/null +++ b/pnpm/crates/versioning/src/intents.rs @@ -0,0 +1,174 @@ +use std::{ + fs, + io::ErrorKind, + path::{Path, PathBuf}, +}; + +use derive_more::Display; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +use crate::{error::VersioningError, human_id::random_human_id, settings::ReleaseBumpType}; + +/// The directory holding change-intent files, shared with changesets. +pub const CHANGES_DIR: &str = ".changeset"; + +/// A bump type as recorded in an intent file: a release bump or the additive +/// `none` decline ("this change needs no release"). +#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum IntentBumpType { + #[display("none")] + None, + #[display("patch")] + Patch, + #[display("minor")] + Minor, + #[display("major")] + Major, +} + +impl IntentBumpType { + /// The release bump this entry demands; `None` for a decline. + #[must_use] + pub fn release(self) -> Option { + match self { + IntentBumpType::None => None, + IntentBumpType::Patch => Some(ReleaseBumpType::Patch), + IntentBumpType::Minor => Some(ReleaseBumpType::Minor), + IntentBumpType::Major => Some(ReleaseBumpType::Major), + } + } + + fn parse(value: &str) -> Option { + match value { + "none" => Some(IntentBumpType::None), + "patch" => Some(IntentBumpType::Patch), + "minor" => Some(IntentBumpType::Minor), + "major" => Some(IntentBumpType::Major), + _ => None, + } + } +} + +/// One `.changeset/*.md` file: which packages a change affects, the bump type +/// for each, and the summary that becomes the changelog entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangeIntent { + pub id: String, + pub file_path: PathBuf, + pub releases: IndexMap, + pub summary: String, +} + +pub fn parse_change_intent( + content: &str, + id: &str, + file_path: &Path, +) -> Result { + let lines: Vec<&str> = content + .trim_start_matches('\u{FEFF}') + .split('\n') + .map(|line| line.strip_suffix('\r').unwrap_or(line)) + .collect(); + let closing_index = if lines.first().is_some_and(|line| line.trim() == "---") { + lines.iter().skip(1).position(|line| line.trim() == "---").map(|index| index + 1) + } else { + None + }; + let Some(closing_index) = closing_index else { + return Err(VersioningError::NoFrontmatter { file_path: file_path.to_path_buf() }); + }; + + let frontmatter_text = lines[1..closing_index].join("\n"); + let frontmatter: IndexMap = if frontmatter_text.trim().is_empty() { + IndexMap::new() + } else { + serde_saphyr::from_str(&frontmatter_text).map_err(|err| { + VersioningError::InvalidFrontmatter { + file_path: file_path.to_path_buf(), + message: err.to_string(), + } + })? + }; + + let mut releases = IndexMap::new(); + for (pkg_name, bump_type) in frontmatter { + let Some(parsed) = IntentBumpType::parse(&bump_type) else { + return Err(VersioningError::InvalidBumpType { + file_path: file_path.to_path_buf(), + pkg_name, + bump_type, + }); + }; + releases.insert(pkg_name, parsed); + } + + Ok(ChangeIntent { + id: id.to_string(), + file_path: file_path.to_path_buf(), + releases, + summary: lines[closing_index + 1..].join("\n").trim().to_string(), + }) +} + +pub fn read_change_intents(workspace_dir: &Path) -> Result, VersioningError> { + let changes_dir = workspace_dir.join(CHANGES_DIR); + let entries = match fs::read_dir(&changes_dir) { + Ok(entries) => entries, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()), + Err(source) => return Err(VersioningError::Read { path: changes_dir, source }), + }; + + let mut file_names = Vec::new(); + for entry in entries { + let entry = + entry.map_err(|source| VersioningError::Read { path: changes_dir.clone(), source })?; + let name = entry.file_name().to_string_lossy().into_owned(); + if name.ends_with(".md") && !name.eq_ignore_ascii_case("readme.md") { + file_names.push(name); + } + } + file_names.sort(); + + file_names + .into_iter() + .map(|file_name| { + let file_path = changes_dir.join(&file_name); + let content = fs::read_to_string(&file_path) + .map_err(|source| VersioningError::Read { path: file_path.clone(), source })?; + let id = file_name.trim_end_matches(".md"); + parse_change_intent(&content, id, &file_path) + }) + .collect() +} + +pub fn write_change_intent( + workspace_dir: &Path, + releases: &IndexMap, + summary: &str, +) -> Result { + let changes_dir = workspace_dir.join(CHANGES_DIR); + fs::create_dir_all(&changes_dir) + .map_err(|source| VersioningError::Write { path: changes_dir.clone(), source })?; + + let mut id = random_human_id(); + while changes_dir.join(format!("{id}.md")).exists() { + id = random_human_id(); + } + + let frontmatter_lines: Vec = releases + .iter() + .map(|(pkg_name, bump_type)| { + format!("{}: {bump_type}", serde_json::to_string(pkg_name).expect("serialize string")) + }) + .collect(); + let content = format!("---\n{}\n---\n\n{}\n", frontmatter_lines.join("\n"), summary.trim()); + let file_path = changes_dir.join(format!("{id}.md")); + fs::write(&file_path, content) + .map_err(|source| VersioningError::Write { path: file_path, source })?; + Ok(id) +} + +#[cfg(test)] +mod tests; diff --git a/pnpm/crates/versioning/src/intents/tests.rs b/pnpm/crates/versioning/src/intents/tests.rs new file mode 100644 index 0000000000..b0fea22085 --- /dev/null +++ b/pnpm/crates/versioning/src/intents/tests.rs @@ -0,0 +1,57 @@ +use std::path::Path; + +use indexmap::IndexMap; +use pretty_assertions::assert_eq; + +use super::{IntentBumpType, parse_change_intent, read_change_intents, write_change_intent}; + +#[test] +fn parses_the_changesets_file_format() { + let content = "---\n\"@example/ui\": minor\n\"@example/core\": patch\n---\n\nAdded a `variant` prop to `Button`.\n"; + let intent = + parse_change_intent(content, "brave-pandas-smile", Path::new("/x/brave-pandas-smile.md")) + .expect("intent parses"); + assert_eq!( + intent.releases, + IndexMap::from([ + ("@example/ui".to_string(), IntentBumpType::Minor), + ("@example/core".to_string(), IntentBumpType::Patch), + ]), + ); + assert_eq!(intent.summary, "Added a `variant` prop to `Button`."); +} + +#[test] +fn tolerates_a_utf8_bom_and_crlf_line_endings() { + let content = "\u{FEFF}---\r\nfoo: patch\r\n---\r\n\r\nA fix.\r\n"; + let intent = parse_change_intent(content, "id", Path::new("/x/id.md")).expect("intent parses"); + assert_eq!(intent.releases, IndexMap::from([("foo".to_string(), IntentBumpType::Patch)])); + assert_eq!(intent.summary, "A fix."); +} + +#[test] +fn rejects_an_invalid_bump_type() { + let err = parse_change_intent("---\nfoo: gigantic\n---\nx", "id", Path::new("/x/id.md")) + .expect_err("parse must fail"); + assert!(err.to_string().contains("invalid bump type"), "unexpected error: {err}"); +} + +#[test] +fn rejects_a_file_without_frontmatter() { + let err = parse_change_intent("Just some text.", "id", Path::new("/x/id.md")) + .expect_err("parse must fail"); + assert!(err.to_string().contains("no YAML frontmatter"), "unexpected error: {err}"); +} + +#[test] +fn write_change_intent_output_round_trips_through_read_change_intents() { + let workspace = tempfile::tempdir().expect("create temp workspace"); + let releases = IndexMap::from([("@example/ui".to_string(), IntentBumpType::Minor)]); + let id = + write_change_intent(workspace.path(), &releases, "Added a thing.").expect("intent writes"); + let intents = read_change_intents(workspace.path()).expect("intents read"); + assert_eq!(intents.len(), 1); + assert_eq!(intents[0].id, id); + assert_eq!(intents[0].releases, releases); + assert_eq!(intents[0].summary, "Added a thing."); +} diff --git a/pnpm/crates/versioning/src/ledger.rs b/pnpm/crates/versioning/src/ledger.rs new file mode 100644 index 0000000000..688c5c5c51 --- /dev/null +++ b/pnpm/crates/versioning/src/ledger.rs @@ -0,0 +1,251 @@ +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + fs, + io::ErrorKind, + path::Path, +}; + +use serde::Deserialize; + +use crate::{error::VersioningError, intents::CHANGES_DIR}; + +pub const LEDGER_FILENAME: &str = "ledger.yaml"; + +/// One consumed release: the workspace-relative directory of the project +/// that released (the engine's unit of identity — package names may collide +/// across workspace projects) and the ids of the intent files the release +/// consumed. The bare id-list shape is accepted when read, for hand-written +/// entries; its project is then resolved by the package name in the entry +/// key, which must be unambiguous. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub enum LedgerEntry { + Ids(Vec), + Attributed { dir: String, intents: Vec }, +} + +impl LedgerEntry { + #[must_use] + pub fn intent_ids(&self) -> &[String] { + match self { + LedgerEntry::Ids(ids) => ids, + LedgerEntry::Attributed { intents, .. } => intents, + } + } +} + +/// The committed, append-only record of consumed change intents: maps +/// `@` to the released project and the ids +/// of the intent files consumed by that release. Consumption is scoped per +/// project — an intent file is fully consumed only once every project it +/// names has an entry — which is what makes cherry-picked releases on +/// maintenance branches and merge-backs safe, and what lets one intent be +/// half-consumed by a package on a release lane. +pub type Ledger = BTreeMap; + +pub fn read_ledger(workspace_dir: &Path) -> Result { + let ledger_path = workspace_dir.join(CHANGES_DIR).join(LEDGER_FILENAME); + let content = match fs::read_to_string(&ledger_path) { + Ok(content) => content, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Ledger::new()), + Err(source) => return Err(VersioningError::Read { path: ledger_path, source }), + }; + if content.trim().is_empty() { + return Ok(Ledger::new()); + } + serde_saphyr::from_str(&content).map_err(|_| VersioningError::InvalidLedger { ledger_path }) +} + +pub fn append_to_ledger( + workspace_dir: &Path, + new_entries: &BTreeMap)>, +) -> Result { + let mut ledger = read_ledger(workspace_dir)?; + if new_entries.is_empty() { + return Ok(ledger); + } + for (key, (dir, ids)) in new_entries { + let mut merged: Vec = + ledger.get(key).map(|entry| entry.intent_ids().to_vec()).unwrap_or_default(); + for id in ids { + if !merged.contains(id) { + merged.push(id.clone()); + } + } + merged.sort(); + ledger.insert(key.clone(), LedgerEntry::Attributed { dir: dir.clone(), intents: merged }); + } + + let changes_dir = workspace_dir.join(CHANGES_DIR); + fs::create_dir_all(&changes_dir) + .map_err(|source| VersioningError::Write { path: changes_dir.clone(), source })?; + let ledger_path = changes_dir.join(LEDGER_FILENAME); + fs::write(&ledger_path, render_ledger(&ledger)) + .map_err(|source| VersioningError::Write { path: ledger_path, source })?; + Ok(ledger) +} + +/// Renders the ledger in the same YAML shape the TypeScript side's +/// `yaml.stringify` produces: sorted `package@version` keys (quoted when the +/// name is scoped, since a leading `@` is a YAML indicator), each with the +/// released project directory and a two-space-indented id list. +fn render_ledger(ledger: &Ledger) -> String { + use std::fmt::Write as _; + let mut output = String::new(); + for (key, entry) in ledger { + writeln!(output, "{}:", yaml_scalar(key)).expect("write to string"); + if let LedgerEntry::Attributed { dir, .. } = entry { + writeln!(output, " dir: {}", yaml_scalar(dir)).expect("write to string"); + writeln!(output, " intents:").expect("write to string"); + for id in entry.intent_ids() { + writeln!(output, " - {}", yaml_scalar(id)).expect("write to string"); + } + } else { + for id in entry.intent_ids() { + writeln!(output, " - {}", yaml_scalar(id)).expect("write to string"); + } + } + } + output +} + +/// Renders a string as a YAML scalar the way the TypeScript side's +/// `yaml.stringify` does: plain when unambiguous, otherwise double-quoted +/// with escapes. Directory paths and `human-id` intent ids are always plain, +/// so the two stacks produce byte-identical ledgers for real content; the +/// quoting only guards odd hand-written values (a `#`, `: `, leading `@`, ...) +/// from round-tripping wrong. +fn yaml_scalar(value: &str) -> String { + if !needs_quoting(value) { + return value.to_string(); + } + use std::fmt::Write as _; + let mut escaped = String::with_capacity(value.len() + 2); + escaped.push('"'); + for character in value.chars() { + match character { + '\\' => escaped.push_str(r"\\"), + '"' => escaped.push_str(r#"\""#), + '\n' => escaped.push_str(r"\n"), + '\r' => escaped.push_str(r"\r"), + '\t' => escaped.push_str(r"\t"), + '\0' => escaped.push_str(r"\0"), + // Every other control character must be escaped too, or the + // written scalar would be invalid YAML that `read_ledger` can no + // longer parse. `char::is_control` covers only U+0000–U+001F and + // U+007F–U+009F, so a two-digit `\xNN` always fits. + control if control.is_control() => { + write!(escaped, r"\x{:02X}", control as u32).expect("write to string"); + } + other => escaped.push(other), + } + } + escaped.push('"'); + escaped +} + +fn needs_quoting(value: &str) -> bool { + if value.is_empty() { + return true; + } + // Leading indicator characters, or a value YAML would read as something + // other than a plain string. + if value.starts_with([ + '!', '&', '*', '[', ']', '{', '}', ',', '#', '|', '>', '@', '`', '"', '\'', '%', '?', ':', + '-', ' ', + ]) { + return true; + } + if value.ends_with(' ') { + return true; + } + matches!(value, "true" | "false" | "null" | "yes" | "no" | "~") + || value.chars().any(char::is_control) + // A colon or hash that YAML would treat as a key/comment boundary. + || value.contains(": ") + || value.ends_with(':') + || value.contains(" #") +} + +/// Which intent ids the ledger records for one project. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct PackageConsumption { + /// Intent ids recorded against any released version of the project. + pub all_ids: HashSet, + /// Intent ids recorded only against prerelease versions of the project. + pub prerelease_only_ids: HashSet, +} + +/// Indexes the ledger by workspace-relative project directory in a single +/// pass. Bare id-list entries carry no directory, so their project is +/// resolved from the entry key's package name via `resolve_name_dirs`; a +/// name matching several projects cannot be attributed and is an error — +/// write such entries in the `dir`/`intents` shape instead. Entries whose +/// name no longer exists in the workspace are inert. Projects without +/// entries map to an empty consumption, so lookups never miss. +pub fn build_consumption_index( + ledger: &Ledger, + resolve_name_dirs: impl Fn(&str) -> Vec, +) -> Result, VersioningError> { + let mut stable_ids_by_dir: HashMap> = HashMap::new(); + let mut prerelease_ids_by_dir: HashMap> = HashMap::new(); + for (key, entry) in ledger { + let Some(at_index) = key.rfind('@').filter(|&index| index > 0) else { + continue; + }; + let version = &key[at_index + 1..]; + let dir = match entry { + LedgerEntry::Attributed { dir, .. } => normalize_project_dir(dir), + LedgerEntry::Ids(_) => { + let pkg_name = &key[..at_index]; + let dirs = resolve_name_dirs(pkg_name); + match dirs.len() { + 0 => continue, + 1 => dirs.into_iter().next().expect("one element"), + _ => { + return Err(VersioningError::AmbiguousLedgerEntry { + key: key.clone(), + pkg_name: pkg_name.to_string(), + dirs, + }); + } + } + } + }; + // Build metadata (after "+") may itself contain hyphens and never + // makes a version a prerelease. + let is_prerelease = version.split('+').next().is_some_and(|core| core.contains('-')); + let by_dir = + if is_prerelease { &mut prerelease_ids_by_dir } else { &mut stable_ids_by_dir }; + by_dir.entry(dir).or_default().extend(entry.intent_ids().iter().cloned()); + } + + let names: HashSet = + stable_ids_by_dir.keys().chain(prerelease_ids_by_dir.keys()).cloned().collect(); + Ok(names + .into_iter() + .map(|dir| { + let stable = stable_ids_by_dir.remove(&dir).unwrap_or_default(); + let prerelease = prerelease_ids_by_dir.remove(&dir).unwrap_or_default(); + let consumption = PackageConsumption { + prerelease_only_ids: prerelease.difference(&stable).cloned().collect(), + all_ids: stable.into_iter().chain(prerelease).collect(), + }; + (dir, consumption) + }) + .collect()) +} + +/// The canonical spelling of a workspace-relative project directory: +/// forward slashes, no leading `./`, no trailing slash. +#[must_use] +pub fn normalize_project_dir(dir: &str) -> String { + let mut normalized = dir.replace('\\', "/"); + while let Some(rest) = normalized.strip_prefix("./") { + normalized = rest.to_string(); + } + normalized.trim_end_matches('/').to_string() +} + +#[cfg(test)] +mod tests; diff --git a/pnpm/crates/versioning/src/ledger/tests.rs b/pnpm/crates/versioning/src/ledger/tests.rs new file mode 100644 index 0000000000..d7aa29ec2a --- /dev/null +++ b/pnpm/crates/versioning/src/ledger/tests.rs @@ -0,0 +1,67 @@ +use pretty_assertions::assert_eq; + +use super::{Ledger, LedgerEntry, render_ledger, yaml_scalar}; + +#[test] +fn real_content_renders_as_plain_scalars() { + let mut ledger = Ledger::new(); + ledger.insert( + "@scope/util@1.2.3".to_string(), + LedgerEntry::Attributed { + dir: "packages/util".to_string(), + intents: vec!["wild-wombats-do".to_string()], + }, + ); + let rendered = render_ledger(&ledger); + assert_eq!( + rendered, + "\"@scope/util@1.2.3\":\n dir: packages/util\n intents:\n - wild-wombats-do\n", + ); +} + +#[test] +fn special_characters_round_trip_through_the_serializer() { + let mut ledger = Ledger::new(); + ledger.insert( + "pkg@1.0.0".to_string(), + LedgerEntry::Attributed { + dir: "weird: dir #1".to_string(), + intents: vec!["id: with colon".to_string(), "plain-id".to_string()], + }, + ); + let rendered = render_ledger(&ledger); + let parsed: Ledger = serde_saphyr::from_str(&rendered).expect("render output parses back"); + assert_eq!(parsed, ledger); +} + +#[test] +fn control_characters_are_escaped_and_round_trip() { + // A crafted intent id / directory with control characters (from an odd + // .changeset filename) must not produce unparsable YAML. + let mut ledger = Ledger::new(); + ledger.insert( + "pkg@1.0.0".to_string(), + LedgerEntry::Attributed { + dir: "tab\ttab".to_string(), + intents: vec![ + "carriage\rreturn".to_string(), + "nul\0byte".to_string(), + "esc\u{1b}here".to_string(), + ], + }, + ); + let rendered = render_ledger(&ledger); + assert!(!rendered.contains('\r'), "carriage return must be escaped, not literal"); + assert!(!rendered.contains('\0'), "NUL must be escaped, not literal"); + let parsed: Ledger = serde_saphyr::from_str(&rendered).expect("render output parses back"); + assert_eq!(parsed, ledger); +} + +#[test] +fn yaml_scalar_quotes_only_when_needed() { + assert_eq!(yaml_scalar("packages/util"), "packages/util"); + assert_eq!(yaml_scalar("pkg@1.0.0"), "pkg@1.0.0"); + assert_eq!(yaml_scalar("@scope/pkg"), r#""@scope/pkg""#); + assert_eq!(yaml_scalar("a: b"), r#""a: b""#); + assert_eq!(yaml_scalar("has #hash"), r#""has #hash""#); +} diff --git a/pnpm/crates/versioning/src/lib.rs b/pnpm/crates/versioning/src/lib.rs new file mode 100644 index 0000000000..f926260d96 --- /dev/null +++ b/pnpm/crates/versioning/src/lib.rs @@ -0,0 +1,40 @@ +//! Native workspace release management: the engine behind `pnpm change` and +//! the bare `pnpm version -r`. Reads and writes changesets-compatible change +//! intents from `.changeset/*.md`, assembles a release plan (direct bumps, +//! dependent propagation through materialized `workspace:` ranges, fixed +//! groups, per-package release lanes), and applies it (manifest version +//! updates, changelog composition, the consumed-intents ledger, and +//! intent-file cleanup). +//! +//! The TypeScript counterpart is `@pnpm/releasing.versioning` +//! (`pnpm11/releasing/versioning`); the two must stay behaviorally identical. +//! See the native monorepo versioning RFC: +//! . + +mod apply; +mod changelog; +mod error; +mod human_id; +mod intents; +mod ledger; +mod plan; +mod settings; + +pub use apply::{AppliedRelease, apply_release_plan}; +pub use changelog::{compose_changelog_section, prepend_changelog_section}; +pub use error::VersioningError; +pub use intents::{ + CHANGES_DIR, ChangeIntent, IntentBumpType, parse_change_intent, read_change_intents, + write_change_intent, +}; +pub use ledger::{ + LEDGER_FILENAME, Ledger, LedgerEntry, PackageConsumption, append_to_ledger, + build_consumption_index, normalize_project_dir, read_ledger, +}; +pub use plan::{ + AssembleReleasePlanOptions, DependencyField, DependencyUpdate, ManifestDependency, + PlannedRelease, ProjectRefIndex, ReleaseCause, ReleasePlan, WorkspaceProject, + assemble_release_plan, index_project_refs, is_dir_ref, materialize_workspace_range, + to_project_dir, +}; +pub use settings::{ChangelogSettings, ChangelogStorage, ReleaseBumpType, VersioningSettings}; diff --git a/pnpm/crates/versioning/src/plan.rs b/pnpm/crates/versioning/src/plan.rs new file mode 100644 index 0000000000..2ef4c4bf19 --- /dev/null +++ b/pnpm/crates/versioning/src/plan.rs @@ -0,0 +1,1023 @@ +use std::{ + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + path::{Path, PathBuf}, +}; + +use derive_more::Display; +use node_semver::{Identifier, Range, Version}; + +use crate::{ + error::VersioningError, + intents::{ChangeIntent, IntentBumpType}, + ledger::{Ledger, PackageConsumption, build_consumption_index, normalize_project_dir}, + settings::{ReleaseBumpType, VersioningSettings}, +}; + +/// One workspace project as the release-plan assembler sees it: the manifest +/// fields the plan depends on, extracted by the CLI layer. +#[derive(Debug, Clone)] +pub struct WorkspaceProject { + pub root_dir: PathBuf, + pub name: Option, + pub version: Option, + /// The entries of the manifest's production dependency fields + /// (`dependencies`, `optionalDependencies`, `peerDependencies`). + /// devDependencies never propagate — they are not part of the published + /// artifact. + pub prod_dependencies: Vec, +} + +#[derive(Debug, Clone)] +pub struct ManifestDependency { + pub field: DependencyField, + pub alias: String, + pub spec: String, +} + +#[derive(Debug, Display, Clone, Copy, PartialEq, Eq)] +pub enum DependencyField { + #[display("dependencies")] + Dependencies, + #[display("optionalDependencies")] + OptionalDependencies, + #[display("peerDependencies")] + PeerDependencies, +} + +/// Causes are reported sorted by name, matching the TypeScript plan output: +/// `dependencies < fixed < intent`. +#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ReleaseCause { + #[display("dependencies")] + Dependencies, + #[display("fixed")] + Fixed, + #[display("intent")] + Intent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DependencyUpdate { + pub name: String, + pub new_version: String, +} + +#[derive(Debug, Clone)] +pub struct PlannedRelease { + pub name: String, + /// Workspace-relative project directory — the engine's unit of identity. + pub dir: String, + pub root_dir: PathBuf, + pub current_version: String, + pub new_version: String, + pub bump_type: ReleaseBumpType, + /// The intent files this release consumes for this package: the pending + /// ones, plus — when the release graduates the package off a lane — the + /// ones the ledger recorded against the lane's prerelease versions. + pub intents: Vec, + pub dependency_updates: Vec, + pub causes: Vec, +} + +#[derive(Debug, Default, Clone)] +pub struct ReleasePlan { + pub releases: Vec, +} + +#[derive(Debug, Default, Clone)] +pub struct AssembleReleasePlanOptions { + /// Workspace-relative directories of the projects selected with + /// --filter. The plan is narrowed to the selected packages' portion of + /// the pending work, expanded with their fixed-group companions and + /// range-invalidated dependents. + pub filter: Option>, + /// When set, every planned release gets the version `0.0.0-` + /// instead of the computed one, matching snapshot releases. + pub snapshot_suffix: Option, + /// Enforce that every internal production dependency uses the + /// `workspace:` protocol — a prerequisite for actually releasing. The + /// release path (`pnpm version -r`) sets this; read-only callers + /// (`pnpm change status`) leave it off so a diagnostic never fails on an + /// unmigrated dependency. + pub enforce_workspace_protocol: bool, +} + +/// Whether a package reference is a workspace-relative directory path rather +/// than a package name — the additive extension to the changesets format, +/// needed only when workspace projects share a published name. +#[must_use] +pub fn is_dir_ref(reference: &str) -> bool { + reference.starts_with("./") +} + +/// The workspace-relative directory of a project, in canonical spelling. +#[must_use] +pub fn to_project_dir(workspace_dir: &Path, root_dir: &Path) -> String { + let relative = + pathdiff::diff_paths(root_dir, workspace_dir).unwrap_or_else(|| root_dir.to_path_buf()); + normalize_project_dir(&relative.to_string_lossy()) +} + +/// Resolves package references — bare names, or `./`-prefixed +/// workspace-relative directories — against the workspace. Names are +/// aliases: one that matches several projects cannot identify any of them +/// and callers must treat it as an error, never a silent pick. +pub struct ProjectRefIndex { + dirs: HashSet, + dirs_by_name: HashMap>, +} + +impl ProjectRefIndex { + /// The directories a reference resolves to: empty when unknown, two or + /// more when the name is ambiguous. + #[must_use] + pub fn ref_to_dirs(&self, reference: &str) -> Vec { + if is_dir_ref(reference) { + let dir = normalize_project_dir(reference); + return if self.dirs.contains(&dir) { vec![dir] } else { Vec::new() }; + } + self.dirs_by_name.get(reference).cloned().unwrap_or_default() + } + + #[must_use] + pub fn name_to_dirs(&self, name: &str) -> Vec { + self.dirs_by_name.get(name).cloned().unwrap_or_default() + } +} + +#[must_use] +pub fn index_project_refs(projects: &[WorkspaceProject], workspace_dir: &Path) -> ProjectRefIndex { + let mut dirs = HashSet::new(); + let mut dirs_by_name: HashMap> = HashMap::new(); + for project in projects { + let dir = to_project_dir(workspace_dir, &project.root_dir); + dirs.insert(dir.clone()); + if let Some(name) = &project.name { + dirs_by_name.entry(name.clone()).or_default().push(dir); + } + } + ProjectRefIndex { dirs, dirs_by_name } +} + +pub fn assemble_release_plan( + projects: &[WorkspaceProject], + workspace_dir: &Path, + intents: &[ChangeIntent], + ledger: &Ledger, + versioning: Option<&VersioningSettings>, + opts: &AssembleReleasePlanOptions, +) -> Result { + let refs = index_project_refs(projects, workspace_dir); + let participants = collect_participants(projects, workspace_dir, &refs, versioning)?; + let lanes_by_dir = resolve_lanes(&refs, versioning)?; + let fixed_groups = resolve_fixed_groups(&refs, &participants, versioning)?; + validate_fixed_group_lanes(&fixed_groups, &lanes_by_dir, versioning)?; + let intent_bumps = resolve_intents(intents, &refs, &participants)?; + if opts.enforce_workspace_protocol { + assert_internal_deps_use_workspace_protocol(&participants)?; + } + let consumption = build_consumption_index(ledger, |name| refs.name_to_dirs(name))?; + + let ctx = AssembleContext { + participants: &participants, + lanes_by_dir: &lanes_by_dir, + fixed_groups: &fixed_groups, + intent_bumps: &intent_bumps, + consumption: &consumption, + intents, + versioning, + opts, + }; + let mut selection = opts.filter.clone(); + loop { + let plan = assemble(&ctx, selection.as_ref())?; + let Some(selected) = &mut selection else { + return Ok(plan); + }; + let before = selected.len(); + selected.extend(plan.releases.iter().map(|release| release.dir.clone())); + if selected.len() == before { + return Ok(plan); + } + } +} + +struct Participant<'a> { + name: &'a str, + dir: String, + root_dir: &'a Path, + current_version: &'a str, + internal_deps: Vec>, +} + +struct InternalDep<'a> { + target_dir: String, + target_name: String, + field: DependencyField, + alias: &'a str, + spec: &'a str, +} + +struct BumpState { + bump_type: ReleaseBumpType, + causes: BTreeSet, + dependency_updates: BTreeMap, +} + +struct AssembleContext<'a> { + participants: &'a BTreeMap>, + lanes_by_dir: &'a BTreeMap, + fixed_groups: &'a [Vec], + /// Per intent id: the participant dirs it releases and their bump types. + intent_bumps: &'a HashMap>, + consumption: &'a HashMap, + intents: &'a [ChangeIntent], + versioning: Option<&'a VersioningSettings>, + opts: &'a AssembleReleasePlanOptions, +} + +impl AssembleContext<'_> { + fn intent_bump_for(&self, intent: &ChangeIntent, dir: &str) -> Option { + self.intent_bumps.get(&intent.id).and_then(|by_dir| by_dir.get(dir)).copied() + } +} + +fn assemble( + ctx: &AssembleContext<'_>, + selection: Option<&HashSet>, +) -> Result { + let participants = ctx.participants; + let pending_by_dir = collect_pending_intents(ctx); + let lane_consumed_by_dir = collect_lane_consumed_intents(ctx); + + let mut state: BTreeMap = BTreeMap::new(); + + for (dir, pending) in &pending_by_dir { + if selection.is_some_and(|selected| !selected.contains(dir)) { + continue; + } + if let Some(direct) = + max_bump_type(pending.iter().filter_map(|intent| ctx.intent_bump_for(intent, dir))) + { + bump_at_least(&mut state, dir, direct, ReleaseCause::Intent); + } + } + + // A package that left its lane releases the accumulated stable version + // even when no new intents are pending. + for (dir, lane_consumed) in &lane_consumed_by_dir { + if selection.is_some_and(|selected| !selected.contains(dir)) { + continue; + } + if ctx.lanes_by_dir.contains_key(dir) { + continue; + } + if let Some(graduated) = max_bump_type( + lane_consumed.iter().filter_map(|intent| ctx.intent_bump_for(intent, dir)), + ) { + bump_at_least(&mut state, dir, graduated, ReleaseCause::Intent); + } + } + + let cumulative_bump = |dir: &str, planned: ReleaseBumpType| -> ReleaseBumpType { + lane_consumed_by_dir + .get(dir) + .into_iter() + .flatten() + .filter_map(|intent| ctx.intent_bump_for(intent, dir)) + .filter_map(IntentBumpType::release) + .chain([planned]) + .max() + .unwrap_or(planned) + }; + + let mut new_versions: BTreeMap = BTreeMap::new(); + let compute_versions = + |state: &BTreeMap, new_versions: &mut BTreeMap| { + new_versions.clear(); + for (dir, pkg_state) in state { + let participant = &participants[dir.as_str()]; + new_versions.insert( + dir.clone(), + compute_new_version( + participant.current_version, + pkg_state.bump_type, + ctx.lanes_by_dir.get(dir).map(String::as_str), + cumulative_bump(dir, pkg_state.bump_type), + ), + ); + } + apply_fixed_group_versions( + participants, + state, + new_versions, + &cumulative_bump, + ctx.fixed_groups, + ctx.lanes_by_dir, + ); + }; + + let mut changed = true; + while changed { + changed = false; + compute_versions(&state, &mut new_versions); + + let mut forced: Vec<(&str, String, String)> = Vec::new(); + for dependent in participants.values() { + for dep in &dependent.internal_deps { + let Some(target) = participants.get(dep.target_dir.as_str()) else { + continue; + }; + let Some(target_new_version) = new_versions.get(&dep.target_dir) else { + continue; + }; + let Some(materialized) = + materialize_workspace_range(dep.spec, target.current_version) + else { + continue; + }; + if range_accepts(&materialized, target_new_version) { + continue; + } + forced.push(( + dependent.dir.as_str(), + dep.target_name.clone(), + target_new_version.clone(), + )); + } + } + for (dependent_dir, target_name, target_new_version) in forced { + if bump_at_least( + &mut state, + dependent_dir, + ReleaseBumpType::Patch, + ReleaseCause::Dependencies, + ) { + changed = true; + } + state + .get_mut(dependent_dir) + .expect("bump_at_least inserted the state") + .dependency_updates + .insert(target_name, target_new_version); + } + + for group in ctx.fixed_groups { + let Some(group_bump) = max_bump_type_of( + group.iter().filter_map(|dir| state.get(dir).map(|entry| entry.bump_type)), + ) else { + continue; + }; + for dir in group { + if bump_at_least(&mut state, dir, group_bump, ReleaseCause::Fixed) { + changed = true; + } + } + } + } + compute_versions(&state, &mut new_versions); + + let mut releases: Vec = state + .iter() + .map(|(dir, pkg_state)| { + let participant = &participants[dir.as_str()]; + let mut consumed_for_changelog: Vec = pending_by_dir + .get(dir) + .map(|intents| intents.iter().map(|&intent| intent.clone()).collect()) + .unwrap_or_default(); + if !ctx.lanes_by_dir.contains_key(dir) + && let Some(lane_consumed) = lane_consumed_by_dir.get(dir) + { + consumed_for_changelog.extend(lane_consumed.iter().map(|&intent| intent.clone())); + } + PlannedRelease { + name: participant.name.to_string(), + dir: dir.clone(), + root_dir: participant.root_dir.to_path_buf(), + current_version: participant.current_version.to_string(), + new_version: match &ctx.opts.snapshot_suffix { + Some(suffix) => format!("0.0.0-{suffix}"), + None => new_versions[dir].clone(), + }, + bump_type: pkg_state.bump_type, + intents: consumed_for_changelog, + dependency_updates: pkg_state + .dependency_updates + .iter() + .map(|(dep_name, new_version)| DependencyUpdate { + name: dep_name.clone(), + new_version: new_version.clone(), + }) + .collect(), + causes: pkg_state.causes.iter().copied().collect(), + } + }) + .collect(); + releases + .sort_by(|left, right| left.name.cmp(&right.name).then_with(|| left.dir.cmp(&right.dir))); + + assert_no_duplicate_release_identity(&releases)?; + if ctx.opts.snapshot_suffix.is_none() { + enforce_max_bump(&releases, ctx.versioning)?; + } + + Ok(ReleasePlan { releases }) +} + +/// A published `package@version` identifies exactly one artifact, so two +/// projects that share a name cannot both release the same version — the +/// registry would reject the second publish, and the name-keyed ledger entry +/// would collide. Caught here, before any manifest is written, naming both +/// directories. +fn assert_no_duplicate_release_identity( + releases: &[PlannedRelease], +) -> Result<(), VersioningError> { + let mut by_identity: HashMap = HashMap::new(); + for release in releases { + let identity = format!("{}@{}", release.name, release.new_version); + if let Some(other) = by_identity.insert(identity.clone(), release.dir.clone()) { + return Err(VersioningError::DuplicateRelease { + identity, + first_dir: other, + second_dir: release.dir.clone(), + }); + } + } + Ok(()) +} + +fn bump_at_least( + state: &mut BTreeMap, + dir: &str, + bump_type: ReleaseBumpType, + cause: ReleaseCause, +) -> bool { + let Some(existing) = state.get_mut(dir) else { + state.insert( + dir.to_string(), + BumpState { + bump_type, + causes: BTreeSet::from([cause]), + dependency_updates: BTreeMap::new(), + }, + ); + return true; + }; + existing.causes.insert(cause); + if bump_type > existing.bump_type { + existing.bump_type = bump_type; + return true; + } + false +} + +fn collect_participants<'a>( + projects: &'a [WorkspaceProject], + workspace_dir: &Path, + refs: &ProjectRefIndex, + versioning: Option<&VersioningSettings>, +) -> Result>, VersioningError> { + let mut ignored_dirs: HashSet = HashSet::new(); + for reference in versioning.map(|settings| settings.ignore.as_slice()).unwrap_or_default() { + ignored_dirs.extend(resolve_config_ref(refs, reference, "versioning.ignore")?); + } + + let mut participants = BTreeMap::new(); + for project in projects { + let (Some(name), Some(version)) = (project.name.as_deref(), project.version.as_deref()) + else { + continue; + }; + let dir = to_project_dir(workspace_dir, &project.root_dir); + // What cannot release is excluded automatically: unnamed and + // versionless (private) packages, packages with non-semver + // placeholder versions, and the explicitly frozen ones. + if Version::parse(version).is_err() || ignored_dirs.contains(&dir) { + continue; + } + participants.insert( + dir.clone(), + Participant { + name, + dir, + root_dir: &project.root_dir, + current_version: version, + internal_deps: Vec::new(), + }, + ); + } + + let participant_dirs: HashSet = participants.keys().cloned().collect(); + for (project, dir) in + projects.iter().map(|project| (project, to_project_dir(workspace_dir, &project.root_dir))) + { + if !participant_dirs.contains(&dir) { + continue; + } + let mut internal_deps = Vec::new(); + for dep in &project.prod_dependencies { + let Some(target_name) = internal_dep_target_name(&dep.alias, &dep.spec, refs) else { + continue; + }; + let target_dirs: Vec = refs + .name_to_dirs(&target_name) + .into_iter() + .filter(|target_dir| participant_dirs.contains(target_dir)) + .collect(); + if target_dirs.is_empty() { + continue; + } + // A workspace: range naming an ambiguous package cannot be + // linked at install time, so the release engine never + // legitimately sees one. + if target_dirs.len() > 1 { + let participant = &participants[dir.as_str()]; + return Err(VersioningError::AmbiguousPackage { + context: format!("Package {} (./{})", participant.name, participant.dir), + reference: target_name, + dirs: target_dirs, + }); + } + internal_deps.push(InternalDep { + target_dir: target_dirs.into_iter().next().expect("one element"), + target_name, + field: dep.field, + alias: &dep.alias, + spec: &dep.spec, + }); + } + participants.get_mut(dir.as_str()).expect("participant exists").internal_deps = + internal_deps; + } + Ok(participants) +} + +/// Decides whether a dependency entry points at a workspace package. Aliased +/// specs targeting somewhere else (`npm:`, `file:`, git URLs, ...) are +/// external even when the alias collides with a workspace package name; a +/// plain semver range or `catalog:` entry on a workspace name is internal — +/// it is exactly the declaration the workspace-protocol check must reject. +fn internal_dep_target_name(alias: &str, spec: &str, refs: &ProjectRefIndex) -> Option { + if let Some(rest) = spec.strip_prefix("workspace:") { + let target_name = parse_workspace_spec_alias(rest).unwrap_or(alias); + return (!refs.name_to_dirs(target_name).is_empty()).then(|| target_name.to_string()); + } + if refs.name_to_dirs(alias).is_empty() { + return None; + } + (spec.starts_with("catalog:") || Range::parse(spec).is_ok()).then(|| alias.to_string()) +} + +/// The alias of an aliased `workspace:@` spec body, mirroring +/// `WorkspaceSpec.parse` from `@pnpm/workspace.spec-parser`: the alias must +/// not start with `.`, `_`, or `/` and ends at the last `@`. +fn parse_workspace_spec_alias(rest: &str) -> Option<&str> { + let at_index = rest.rfind('@').filter(|&index| index > 0)?; + let alias = &rest[..at_index]; + if alias.starts_with(['.', '_', '/']) || alias.chars().skip(1).any(|character| character == '@') + { + return None; + } + Some(alias) +} + +/// Resolves a package reference from `versioning` configuration. An unknown +/// reference is skipped — configuration may outlive a removed project — but +/// an ambiguous name is an error: it cannot be attributed, and silence here +/// is exactly the name-keying flaw this engine exists to fix. +fn resolve_config_ref( + refs: &ProjectRefIndex, + reference: &str, + setting_name: &str, +) -> Result, VersioningError> { + let dirs = refs.ref_to_dirs(reference); + if dirs.len() > 1 { + return Err(VersioningError::AmbiguousPackage { + context: setting_name.to_string(), + reference: reference.to_string(), + dirs, + }); + } + Ok(dirs) +} + +fn resolve_lanes( + refs: &ProjectRefIndex, + versioning: Option<&VersioningSettings>, +) -> Result, VersioningError> { + let mut lanes_by_dir = BTreeMap::new(); + let Some(settings) = versioning else { + return Ok(lanes_by_dir); + }; + for (reference, lane) in &settings.lanes { + if lane.eq_ignore_ascii_case("main") { + return Err(VersioningError::InvalidLaneName { + pkg_name: reference.clone(), + lane: lane.clone(), + }); + } + for dir in resolve_config_ref(refs, reference, "versioning.lanes")? { + lanes_by_dir.insert(dir, lane.clone()); + } + } + Ok(lanes_by_dir) +} + +fn resolve_fixed_groups( + refs: &ProjectRefIndex, + participants: &BTreeMap>, + versioning: Option<&VersioningSettings>, +) -> Result>, VersioningError> { + let mut groups = Vec::new(); + for group in versioning.map(|settings| settings.fixed.as_slice()).unwrap_or_default() { + let mut dirs = Vec::new(); + for reference in group { + for dir in resolve_config_ref(refs, reference, "versioning.fixed")? { + if participants.contains_key(&dir) { + dirs.push(dir); + } + } + } + groups.push(dirs); + } + Ok(groups) +} + +fn validate_fixed_group_lanes( + fixed_groups: &[Vec], + lanes_by_dir: &BTreeMap, + versioning: Option<&VersioningSettings>, +) -> Result<(), VersioningError> { + for (index, group) in fixed_groups.iter().enumerate() { + let tags: HashSet> = + group.iter().map(|dir| lanes_by_dir.get(dir)).collect(); + if tags.len() > 1 { + let declared = + versioning.map(|settings| settings.fixed[index].clone()).unwrap_or_default(); + return Err(VersioningError::ConflictingConfig { group: declared }); + } + } + Ok(()) +} + +/// Resolves every intent's package references to participant directories, +/// validating along the way: unknown references and names matching several +/// projects are hard errors, and a release can only be demanded from a +/// participant — otherwise the intent could never be consumed and the file +/// would linger forever. A `none` decline is fine for any workspace package. +fn resolve_intents( + intents: &[ChangeIntent], + refs: &ProjectRefIndex, + participants: &BTreeMap>, +) -> Result>, VersioningError> { + let mut intent_bumps = HashMap::new(); + for intent in intents { + let mut by_dir: BTreeMap = BTreeMap::new(); + for (reference, bump_type) in &intent.releases { + let dirs = refs.ref_to_dirs(reference); + if dirs.is_empty() { + return Err(VersioningError::UnknownPackage { + file_path: intent.file_path.clone(), + pkg_name: reference.clone(), + }); + } + if dirs.len() > 1 { + return Err(VersioningError::AmbiguousPackage { + context: format!("Change intent file {}", intent.file_path.display()), + reference: reference.clone(), + dirs, + }); + } + let dir = dirs.into_iter().next().expect("one element"); + if *bump_type != IntentBumpType::None && !participants.contains_key(&dir) { + return Err(VersioningError::UnreleasablePackage { + file_path: intent.file_path.clone(), + pkg_name: reference.clone(), + bump_type: bump_type.to_string(), + }); + } + let entry = by_dir.entry(dir).or_insert(*bump_type); + if bump_release_order(*bump_type) > bump_release_order(*entry) { + *entry = *bump_type; + } + } + intent_bumps.insert(intent.id.clone(), by_dir); + } + Ok(intent_bumps) +} + +fn bump_release_order(bump_type: IntentBumpType) -> u8 { + match bump_type.release() { + None => 0, + Some(ReleaseBumpType::Patch) => 1, + Some(ReleaseBumpType::Minor) => 2, + Some(ReleaseBumpType::Major) => 3, + } +} + +fn assert_internal_deps_use_workspace_protocol( + participants: &BTreeMap>, +) -> Result<(), VersioningError> { + for participant in participants.values() { + for dep in &participant.internal_deps { + if !dep.spec.starts_with("workspace:") { + return Err(VersioningError::InternalRange { + pkg_name: participant.name.to_string(), + alias: dep.alias.to_string(), + field: dep.field.to_string(), + spec: dep.spec.to_string(), + }); + } + } + } + Ok(()) +} + +fn collect_pending_intents<'i>( + ctx: &AssembleContext<'i>, +) -> BTreeMap> { + let mut pending = BTreeMap::new(); + let empty = PackageConsumption::default(); + for dir in ctx.participants.keys() { + let consumed = ctx.consumption.get(dir).unwrap_or(&empty); + let pkg_intents: Vec<&ChangeIntent> = ctx + .intents + .iter() + .filter(|intent| { + ctx.intent_bump_for(intent, dir).is_some_and(|bump| bump != IntentBumpType::None) + && !consumed.all_ids.contains(&intent.id) + }) + .collect(); + if !pkg_intents.is_empty() { + pending.insert(dir.clone(), pkg_intents); + } + } + pending +} + +/// Intents already consumed by prereleases of a package that has not +/// graduated to a stable version yet. They participate in the cumulative +/// bump computation of the package's lane and compose the stable changelog +/// section at graduation. +fn collect_lane_consumed_intents<'i>( + ctx: &AssembleContext<'i>, +) -> BTreeMap> { + let mut lane_consumed = BTreeMap::new(); + for dir in ctx.participants.keys() { + let Some(consumed) = ctx.consumption.get(dir) else { + continue; + }; + if consumed.prerelease_only_ids.is_empty() { + continue; + } + let pkg_intents: Vec<&ChangeIntent> = ctx + .intents + .iter() + .filter(|intent| { + ctx.intent_bump_for(intent, dir).is_some_and(|bump| bump != IntentBumpType::None) + && consumed.prerelease_only_ids.contains(&intent.id) + }) + .collect(); + if !pkg_intents.is_empty() { + lane_consumed.insert(dir.clone(), pkg_intents); + } + } + lane_consumed +} + +fn max_bump_type(types: impl Iterator) -> Option { + max_bump_type_of(types.filter_map(IntentBumpType::release)) +} + +fn max_bump_type_of(types: impl Iterator) -> Option { + types.max() +} + +fn compute_new_version( + current: &str, + bump_type: ReleaseBumpType, + lane_tag: Option<&str>, + cumulative_bump: ReleaseBumpType, +) -> String { + let current_version = Version::parse(current).expect("participants have valid versions"); + let Some(lane_tag) = lane_tag else { + if current_version.pre_release.is_empty() { + return inc_stable(¤t_version, bump_type); + } + // Graduation: the accumulated stable version the lane was building + // toward. + return escalate_stable_target(&stable_part(¤t_version), cumulative_bump); + }; + let target = if current_version.pre_release.is_empty() { + inc_stable(¤t_version, cumulative_bump) + } else { + escalate_stable_target(&stable_part(¤t_version), cumulative_bump) + }; + let next_n = next_prerelease_number(¤t_version, &target, lane_tag); + format!("{target}-{lane_tag}.{next_n}") +} + +fn inc_stable(version: &Version, bump_type: ReleaseBumpType) -> String { + match bump_type { + ReleaseBumpType::Major => format!("{}.0.0", version.major + 1), + ReleaseBumpType::Minor => format!("{}.{}.0", version.major, version.minor + 1), + ReleaseBumpType::Patch => { + format!("{}.{}.{}", version.major, version.minor, version.patch + 1) + } + } +} + +/// Re-derives the stable version a lane is building toward when the +/// cumulative bump escalates. The invariant: the stable part of the current +/// prerelease already reflects the previous cumulative bump applied to the +/// version the lane started from, so only an escalation changes it. +fn escalate_stable_target(target: &str, cumulative_bump: ReleaseBumpType) -> String { + let target_version = Version::parse(target).expect("stable parts are valid versions"); + match cumulative_bump { + ReleaseBumpType::Major => { + if target_version.minor == 0 && target_version.patch == 0 { + target.to_string() + } else { + format!("{}.0.0", target_version.major + 1) + } + } + ReleaseBumpType::Minor => { + if target_version.patch == 0 { + target.to_string() + } else { + format!("{}.{}.0", target_version.major, target_version.minor + 1) + } + } + ReleaseBumpType::Patch => target.to_string(), + } +} + +fn stable_part(version: &Version) -> String { + format!("{}.{}.{}", version.major, version.minor, version.patch) +} + +fn next_prerelease_number(current: &Version, target: &str, lane_tag: &str) -> u64 { + if current.pre_release.is_empty() || stable_part(current) != target { + return 0; + } + // semver parses an all-digit prerelease identifier as a number, so the + // tag comparison must not be strict about the identifier kind. + let current_tag = match current.pre_release.first() { + Some(Identifier::AlphaNumeric(tag)) => tag.clone(), + Some(Identifier::Numeric(tag)) => tag.to_string(), + None => return 0, + }; + if current_tag != lane_tag { + return 0; + } + match current.pre_release.get(1) { + Some(Identifier::Numeric(current_n)) => current_n + 1, + _ => 0, + } +} + +fn apply_fixed_group_versions( + participants: &BTreeMap>, + state: &BTreeMap, + new_versions: &mut BTreeMap, + cumulative_bump: &dyn Fn(&str, ReleaseBumpType) -> ReleaseBumpType, + fixed_groups: &[Vec], + lanes_by_dir: &BTreeMap, +) { + for group in fixed_groups { + let Some(group_bump) = + max_bump_type_of(group.iter().filter_map(|dir| { + state.get(dir).map(|entry| cumulative_bump(dir, entry.bump_type)) + })) + else { + continue; + }; + + let highest_current = group + .iter() + .map(|dir| { + Version::parse(participants[dir.as_str()].current_version) + .expect("participants have valid versions") + }) + .max(); + let Some(highest_current) = highest_current else { + continue; + }; + let target = if highest_current.pre_release.is_empty() { + inc_stable(&highest_current, group_bump) + } else { + escalate_stable_target(&stable_part(&highest_current), group_bump) + }; + + let lane_tag = group.first().and_then(|dir| lanes_by_dir.get(dir)); + let shared_version = match lane_tag { + Some(lane_tag) => { + let next_n = group + .iter() + .map(|dir| { + let current = Version::parse(participants[dir.as_str()].current_version) + .expect("participants have valid versions"); + next_prerelease_number(¤t, &target, lane_tag) + }) + .max() + .unwrap_or(0); + format!("{target}-{lane_tag}.{next_n}") + } + None => target, + }; + for dir in group { + if state.contains_key(dir) { + new_versions.insert(dir.clone(), shared_version.clone()); + } + } + } +} + +/// The range that pnpm materializes for a workspace: spec at pack time, +/// given the dependency's version at the dependent's previous release. +/// Dependent propagation republishes the dependent whenever the dependency's +/// new version falls outside this range. +#[must_use] +pub fn materialize_workspace_range(spec: &str, dep_current_version: &str) -> Option { + let rest = spec.strip_prefix("workspace:")?; + let range = match parse_workspace_spec_alias(rest) { + Some(alias) => &rest[alias.len() + 1..], + None => rest, + }; + Some(match range { + "^" => format!("^{dep_current_version}"), + "~" => format!("~{dep_current_version}"), + "*" | "" => dep_current_version.to_string(), + explicit => explicit.to_string(), + }) +} + +fn range_accepts(range: &str, version: &str) -> bool { + let (Ok(range), Ok(version)) = (Range::parse(range), Version::parse(version)) else { + return false; + }; + version.satisfies(&range) +} + +fn enforce_max_bump( + releases: &[PlannedRelease], + versioning: Option<&VersioningSettings>, +) -> Result<(), VersioningError> { + let Some(max_bump) = versioning.and_then(|settings| settings.max_bump) else { + return Ok(()); + }; + for release in releases { + let effective_bump = effective_bump_class(release); + if effective_bump <= max_bump { + continue; + } + let intent_files: Vec = release + .intents + .iter() + .filter(|intent| { + intent.releases.values().any(|bump| bump.release() == Some(effective_bump)) + }) + .map(|intent| intent.file_path.display().to_string()) + .collect(); + let raised_by = if intent_files.is_empty() { + format!( + "constraint chain: {}", + release.causes.iter().map(ToString::to_string).collect::>().join(", "), + ) + } else { + format!("intent file(s) {}", intent_files.join(", ")) + }; + return Err(VersioningError::MaxBumpExceeded { + pkg_name: release.name.clone(), + bump_type: effective_bump.to_string(), + max_bump: max_bump.to_string(), + raised_by, + }); + } + Ok(()) +} + +/// The bump class a release actually applies. Fixed-group version sharing +/// and lane escalation can move a version further than the package's own +/// declared or propagated bump, so the cap compares against the real +/// distance between the current and the new version as well. +fn effective_bump_class(release: &PlannedRelease) -> ReleaseBumpType { + let (Ok(current), Ok(new_version)) = + (Version::parse(&release.current_version), Version::parse(&release.new_version)) + else { + return release.bump_type; + }; + let diff_class = if new_version.major != current.major { + Some(ReleaseBumpType::Major) + } else if new_version.minor != current.minor { + Some(ReleaseBumpType::Minor) + } else if new_version.patch != current.patch { + Some(ReleaseBumpType::Patch) + } else { + None + }; + diff_class.into_iter().chain([release.bump_type]).max().unwrap_or(release.bump_type) +} + +#[cfg(test)] +mod tests; diff --git a/pnpm/crates/versioning/src/plan/tests.rs b/pnpm/crates/versioning/src/plan/tests.rs new file mode 100644 index 0000000000..7ba2177070 --- /dev/null +++ b/pnpm/crates/versioning/src/plan/tests.rs @@ -0,0 +1,585 @@ +use std::{collections::HashSet, path::PathBuf}; + +use indexmap::IndexMap; +use pretty_assertions::assert_eq; + +use super::{ + AssembleReleasePlanOptions, DependencyField, DependencyUpdate, ManifestDependency, + PlannedRelease, ReleasePlan, WorkspaceProject, assemble_release_plan, + materialize_workspace_range, +}; +use crate::{ + intents::{ChangeIntent, IntentBumpType}, + ledger::{Ledger, LedgerEntry}, + settings::{ReleaseBumpType, VersioningSettings}, +}; + +fn make_project(name: &str, version: &str, deps: &[(&str, &str)]) -> WorkspaceProject { + WorkspaceProject { + root_dir: PathBuf::from(format!("/ws/{name}")), + name: Some(name.to_string()), + version: Some(version.to_string()), + prod_dependencies: deps + .iter() + .map(|(alias, spec)| ManifestDependency { + field: DependencyField::Dependencies, + alias: (*alias).to_string(), + spec: (*spec).to_string(), + }) + .collect(), + } +} + +fn bump(value: &str) -> IntentBumpType { + match value { + "none" => IntentBumpType::None, + "patch" => IntentBumpType::Patch, + "minor" => IntentBumpType::Minor, + "major" => IntentBumpType::Major, + _ => panic!("unknown bump type in test fixture: {value}"), + } +} + +fn make_intent(id: &str, releases: &[(&str, &str)]) -> ChangeIntent { + ChangeIntent { + id: id.to_string(), + file_path: PathBuf::from(format!("/ws/.changeset/{id}.md")), + releases: releases + .iter() + .map(|(name, bump_type)| ((*name).to_string(), bump(bump_type))) + .collect::>(), + summary: format!("summary of {id}"), + } +} + +fn ledger(entries: &[(&str, &[&str])]) -> Ledger { + entries + .iter() + .map(|(key, ids)| { + ((*key).to_string(), LedgerEntry::Ids(ids.iter().map(|id| (*id).to_string()).collect())) + }) + .collect() +} + +fn assemble( + projects: &[WorkspaceProject], + intents: &[ChangeIntent], + consumed: &Ledger, + versioning: Option<&VersioningSettings>, +) -> ReleasePlan { + assemble_release_plan( + projects, + std::path::Path::new("/ws"), + intents, + consumed, + versioning, + &AssembleReleasePlanOptions::default(), + ) + .expect("plan assembles") +} + +fn release<'a>(plan: &'a ReleasePlan, name: &str) -> &'a PlannedRelease { + plan.releases.iter().find(|release| release.name == name).expect("release is planned") +} + +fn release_names(plan: &ReleasePlan) -> Vec<&str> { + plan.releases.iter().map(|release| release.name.as_str()).collect() +} + +#[test] +fn direct_bumps_highest_pending_bump_type_wins_per_package() { + let projects = [make_project("a", "1.0.0", &[]), make_project("b", "2.3.4", &[])]; + let intents = [ + make_intent("one", &[("a", "patch"), ("b", "minor")]), + make_intent("two", &[("a", "minor")]), + ]; + let plan = assemble(&projects, &intents, &Ledger::new(), None); + assert_eq!(plan.releases.len(), 2); + assert_eq!(release(&plan, "a").new_version, "1.1.0"); + assert_eq!(release(&plan, "a").bump_type, ReleaseBumpType::Minor); + assert_eq!(release(&plan, "b").new_version, "2.4.0"); + assert_eq!(release(&plan, "b").bump_type, ReleaseBumpType::Minor); +} + +#[test] +fn intents_already_recorded_in_the_ledger_are_not_consumed_again() { + let projects = [make_project("a", "1.0.1", &[])]; + let intents = [make_intent("one", &[("a", "patch")])]; + let consumed = ledger(&[("a@1.0.1", &["one"])]); + let plan = assemble(&projects, &intents, &consumed, None); + assert_eq!(plan.releases.len(), 0); +} + +#[test] +fn a_none_bump_type_releases_nothing() { + let projects = [make_project("a", "1.0.0", &[])]; + let intents = [make_intent("one", &[("a", "none")])]; + let plan = assemble(&projects, &intents, &Ledger::new(), None); + assert_eq!(plan.releases.len(), 0); +} + +#[test] +fn dependent_propagation_follows_the_materialized_workspace_range() { + let projects = [ + make_project("lib", "1.2.0", &[]), + make_project("cli", "3.0.0", &[("lib", "workspace:^")]), + ]; + let intents = [make_intent("one", &[("lib", "major")])]; + let plan = assemble(&projects, &intents, &Ledger::new(), None); + let cli = release(&plan, "cli"); + assert_eq!(cli.new_version, "3.0.1"); + assert_eq!(cli.bump_type, ReleaseBumpType::Patch); + assert_eq!( + cli.dependency_updates, + vec![DependencyUpdate { name: "lib".to_string(), new_version: "2.0.0".to_string() }], + ); +} + +#[test] +fn a_minor_bump_does_not_propagate_through_workspace_caret_on_a_1x_dependency() { + let projects = [ + make_project("lib", "1.2.0", &[]), + make_project("cli", "3.0.0", &[("lib", "workspace:^")]), + ]; + let intents = [make_intent("one", &[("lib", "minor")])]; + let plan = assemble(&projects, &intents, &Ledger::new(), None); + assert_eq!(release_names(&plan), ["lib"]); +} + +#[test] +fn a_minor_bump_propagates_through_workspace_caret_on_a_0x_dependency() { + let projects = [ + make_project("lib", "0.2.0", &[]), + make_project("cli", "3.0.0", &[("lib", "workspace:^")]), + ]; + let intents = [make_intent("one", &[("lib", "minor")])]; + let plan = assemble(&projects, &intents, &Ledger::new(), None); + assert_eq!(release_names(&plan), ["cli", "lib"]); +} + +#[test] +fn propagation_cascades_through_chains_of_dependents() { + let projects = [ + make_project("core", "1.0.0", &[]), + make_project("mid", "1.0.0", &[("core", "workspace:*")]), + make_project("top", "1.0.0", &[("mid", "workspace:*")]), + ]; + let intents = [make_intent("one", &[("core", "patch")])]; + let plan = assemble(&projects, &intents, &Ledger::new(), None); + assert_eq!(release_names(&plan), ["core", "mid", "top"]); +} + +#[test] +fn fixed_groups_release_together_at_one_shared_version() { + let projects = [make_project("a", "1.2.0", &[]), make_project("b", "1.0.5", &[])]; + let intents = [make_intent("one", &[("a", "minor")])]; + let versioning = VersioningSettings { + fixed: vec![vec!["a".to_string(), "b".to_string()]], + ..VersioningSettings::default() + }; + let plan = assemble(&projects, &intents, &Ledger::new(), Some(&versioning)); + assert_eq!(release(&plan, "a").new_version, "1.3.0"); + assert_eq!(release(&plan, "b").new_version, "1.3.0"); +} + +#[test] +fn ignored_packages_neither_release_nor_propagate() { + let projects = [ + make_project("lib", "1.0.0", &[]), + make_project("frozen", "1.0.0", &[("lib", "workspace:*")]), + ]; + let intents = [make_intent("one", &[("lib", "major")])]; + let versioning = + VersioningSettings { ignore: vec!["frozen".to_string()], ..VersioningSettings::default() }; + let plan = assemble(&projects, &intents, &Ledger::new(), Some(&versioning)); + assert_eq!(release_names(&plan), ["lib"]); +} + +#[test] +fn an_internal_dependency_without_the_workspace_protocol_fails_a_release_but_not_a_read_only_assemble() + { + let projects = + [make_project("lib", "1.0.0", &[]), make_project("cli", "1.0.0", &[("lib", "^1.0.0")])]; + // enforce_workspace_protocol off (the default, used by `pnpm change + // status`): a read-only assemble never fails on an unmigrated dependency. + let plan = assemble_release_plan( + &projects, + std::path::Path::new("/ws"), + &[], + &Ledger::new(), + None, + &AssembleReleasePlanOptions::default(), + ) + .expect("read-only assemble succeeds"); + assert!(plan.releases.is_empty()); + // The release path enforces the prerequisite. + let err = assemble_release_plan( + &projects, + std::path::Path::new("/ws"), + &[], + &Ledger::new(), + None, + &AssembleReleasePlanOptions { + enforce_workspace_protocol: true, + ..AssembleReleasePlanOptions::default() + }, + ) + .expect_err("plan must fail"); + assert!(err.to_string().contains("workspace: protocol"), "unexpected error: {err}"); +} + +#[test] +fn an_npm_alias_colliding_with_a_workspace_package_name_is_not_an_internal_dependency() { + let projects = [ + make_project("lib", "1.0.0", &[]), + make_project("cli", "1.0.0", &[("lib", "npm:some-fork@^1.0.0")]), + ]; + let intents = [make_intent("one", &[("lib", "major")])]; + let plan = assemble(&projects, &intents, &Ledger::new(), None); + assert_eq!(release_names(&plan), ["lib"]); +} + +#[test] +fn an_intent_demanding_a_release_of_an_unreleasable_package_fails_the_plan() { + let projects = [make_project("lib", "1.0.0", &[]), make_project("frozen", "1.0.0", &[])]; + let intents = [make_intent("one", &[("frozen", "patch"), ("lib", "patch")])]; + let versioning = + VersioningSettings { ignore: vec!["frozen".to_string()], ..VersioningSettings::default() }; + let err = assemble_release_plan( + &projects, + std::path::Path::new("/ws"), + &intents, + &Ledger::new(), + Some(&versioning), + &AssembleReleasePlanOptions::default(), + ) + .expect_err("plan must fail"); + assert!(err.to_string().contains("cannot release"), "unexpected error: {err}"); +} + +#[test] +fn a_none_decline_for_an_unreleasable_package_is_accepted() { + let projects = [make_project("lib", "1.0.0", &[]), make_project("frozen", "1.0.0", &[])]; + let intents = [make_intent("one", &[("frozen", "none"), ("lib", "patch")])]; + let versioning = + VersioningSettings { ignore: vec!["frozen".to_string()], ..VersioningSettings::default() }; + let plan = assemble(&projects, &intents, &Ledger::new(), Some(&versioning)); + assert_eq!(release_names(&plan), ["lib"]); +} + +#[test] +fn an_intent_naming_an_unknown_package_fails_the_plan() { + let projects = [make_project("lib", "1.0.0", &[])]; + let intents = [make_intent("one", &[("ghost", "patch")])]; + let err = assemble_release_plan( + &projects, + std::path::Path::new("/ws"), + &intents, + &Ledger::new(), + None, + &AssembleReleasePlanOptions::default(), + ) + .expect_err("plan must fail"); + assert!(err.to_string().contains("not a package in this workspace"), "unexpected error: {err}"); +} + +#[test] +fn max_bump_rejects_a_plan_whose_effective_bump_exceeds_the_cap() { + let projects = [make_project("lib", "1.0.0", &[])]; + let intents = [make_intent("one", &[("lib", "minor")])]; + let versioning = VersioningSettings { + max_bump: Some(ReleaseBumpType::Patch), + ..VersioningSettings::default() + }; + let err = assemble_release_plan( + &projects, + std::path::Path::new("/ws"), + &intents, + &Ledger::new(), + Some(&versioning), + &AssembleReleasePlanOptions::default(), + ) + .expect_err("plan must fail"); + assert!(err.to_string().contains("maxBump"), "unexpected error: {err}"); +} + +#[test] +fn max_bump_measures_the_real_version_distance_including_fixed_group_jumps() { + let projects = [make_project("a", "1.0.5", &[]), make_project("b", "2.0.0", &[])]; + let intents = [make_intent("one", &[("a", "minor")])]; + let versioning = VersioningSettings { + fixed: vec![vec!["a".to_string(), "b".to_string()]], + max_bump: Some(ReleaseBumpType::Minor), + ..VersioningSettings::default() + }; + let err = assemble_release_plan( + &projects, + std::path::Path::new("/ws"), + &intents, + &Ledger::new(), + Some(&versioning), + &AssembleReleasePlanOptions::default(), + ) + .expect_err("plan must fail"); + assert!(err.to_string().contains("maxBump"), "unexpected error: {err}"); +} + +fn on_lane(pkg_name: &str, tag: &str) -> VersioningSettings { + VersioningSettings { + lanes: IndexMap::from([(pkg_name.to_string(), tag.to_string())]), + ..VersioningSettings::default() + } +} + +#[test] +fn a_package_on_a_lane_emits_tagged_versions_with_an_incrementing_counter() { + let versioning = on_lane("cli", "alpha"); + + let projects = [make_project("cli", "2.0.0", &[])]; + let intents = [make_intent("one", &[("cli", "minor")])]; + let enter_plan = assemble(&projects, &intents, &Ledger::new(), Some(&versioning)); + assert_eq!(enter_plan.releases[0].new_version, "2.1.0-alpha.0"); + + let projects = [make_project("cli", "2.1.0-alpha.0", &[])]; + let intents = + [make_intent("one", &[("cli", "minor")]), make_intent("two", &[("cli", "patch")])]; + let consumed = ledger(&[("cli@2.1.0-alpha.0", &["one"])]); + let next_plan = assemble(&projects, &intents, &consumed, Some(&versioning)); + assert_eq!(next_plan.releases[0].new_version, "2.1.0-alpha.1"); +} + +#[test] +fn a_bigger_bump_landing_later_escalates_the_stable_target_of_the_lane() { + let projects = [make_project("cli", "2.1.0-alpha.1", &[])]; + let intents = + [make_intent("one", &[("cli", "minor")]), make_intent("two", &[("cli", "major")])]; + let consumed = ledger(&[("cli@2.1.0-alpha.0", &["one"]), ("cli@2.1.0-alpha.1", &[])]); + let plan = assemble(&projects, &intents, &consumed, Some(&on_lane("cli", "alpha"))); + assert_eq!(plan.releases[0].new_version, "3.0.0-alpha.0"); +} + +#[test] +fn packages_on_the_main_lane_release_stable_versions_from_the_same_run() { + let projects = [make_project("cli", "2.0.0", &[]), make_project("lib", "1.0.0", &[])]; + let intents = [make_intent("one", &[("cli", "minor"), ("lib", "minor")])]; + let plan = assemble(&projects, &intents, &Ledger::new(), Some(&on_lane("cli", "alpha"))); + assert_eq!(release(&plan, "cli").new_version, "2.1.0-alpha.0"); + assert_eq!(release(&plan, "lib").new_version, "1.1.0"); +} + +#[test] +fn returning_to_the_main_lane_releases_the_accumulated_stable_version_even_without_pending_intents() +{ + let projects = [make_project("cli", "2.1.0-alpha.2", &[])]; + let intents = + [make_intent("one", &[("cli", "minor")]), make_intent("two", &[("cli", "patch")])]; + let consumed = ledger(&[("cli@2.1.0-alpha.0", &["one"]), ("cli@2.1.0-alpha.2", &["two"])]); + let plan = assemble(&projects, &intents, &consumed, None); + assert_eq!(plan.releases.len(), 1); + assert_eq!(plan.releases[0].new_version, "2.1.0"); + let mut consumed_ids: Vec<&str> = + plan.releases[0].intents.iter().map(|intent| intent.id.as_str()).collect(); + consumed_ids.sort_unstable(); + assert_eq!(consumed_ids, ["one", "two"]); +} + +#[test] +fn an_intent_naming_a_main_lane_and_a_lane_package_is_consumed_half_by_half() { + let projects = [make_project("cli", "2.0.0", &[]), make_project("lib", "1.0.1", &[])]; + let intents = [make_intent("one", &[("cli", "minor"), ("lib", "patch")])]; + let consumed = ledger(&[("lib@1.0.1", &["one"])]); + let plan = assemble(&projects, &intents, &consumed, Some(&on_lane("cli", "alpha"))); + assert_eq!(release_names(&plan), ["cli"]); + assert_eq!(plan.releases[0].new_version, "2.1.0-alpha.0"); +} + +#[test] +fn snapshot_plans_release_the_same_set_under_snapshot_versions() { + let projects = [ + make_project("lib", "1.0.0", &[]), + make_project("cli", "1.0.0", &[("lib", "workspace:*")]), + ]; + let intents = [make_intent("one", &[("lib", "patch")])]; + let opts = AssembleReleasePlanOptions { + snapshot_suffix: Some("preview-20260712000000".to_string()), + ..AssembleReleasePlanOptions::default() + }; + let plan = assemble_release_plan( + &projects, + std::path::Path::new("/ws"), + &intents, + &Ledger::new(), + None, + &opts, + ) + .expect("plan assembles"); + let versions: Vec<&str> = + plan.releases.iter().map(|release| release.new_version.as_str()).collect(); + assert_eq!(versions, ["0.0.0-preview-20260712000000", "0.0.0-preview-20260712000000"]); +} + +#[test] +fn filter_narrows_the_plan_to_the_selection_plus_companions_and_invalidated_dependents() { + let projects = [ + make_project("lib", "1.0.0", &[]), + make_project("cli", "1.0.0", &[("lib", "workspace:*")]), + make_project("unrelated", "1.0.0", &[]), + ]; + let intents = + [make_intent("one", &[("lib", "patch")]), make_intent("two", &[("unrelated", "major")])]; + let opts = AssembleReleasePlanOptions { + filter: Some(HashSet::from(["lib".to_string()])), + ..AssembleReleasePlanOptions::default() + }; + let plan = assemble_release_plan( + &projects, + std::path::Path::new("/ws"), + &intents, + &Ledger::new(), + None, + &opts, + ) + .expect("plan assembles"); + assert_eq!(release_names(&plan), ["cli", "lib"]); +} + +fn twins() -> [WorkspaceProject; 2] { + [ + WorkspaceProject { + root_dir: PathBuf::from("/ws/pnpm11/pnpm"), + name: Some("pnpm".to_string()), + version: Some("11.0.0".to_string()), + prod_dependencies: Vec::new(), + }, + WorkspaceProject { + root_dir: PathBuf::from("/ws/pnpm/npm/pnpm"), + name: Some("pnpm".to_string()), + version: Some("12.0.0".to_string()), + prod_dependencies: Vec::new(), + }, + ] +} + +#[test] +fn two_same_named_projects_releasing_to_the_same_version_is_a_hard_error() { + let same_version = [ + WorkspaceProject { + root_dir: PathBuf::from("/ws/a/util"), + name: Some("@scope/util".to_string()), + version: Some("1.0.0".to_string()), + prod_dependencies: Vec::new(), + }, + WorkspaceProject { + root_dir: PathBuf::from("/ws/b/util"), + name: Some("@scope/util".to_string()), + version: Some("1.0.0".to_string()), + prod_dependencies: Vec::new(), + }, + ]; + let intents = [make_intent("one", &[("./a/util", "patch"), ("./b/util", "patch")])]; + let err = assemble_release_plan( + &same_version, + std::path::Path::new("/ws"), + &intents, + &Ledger::new(), + None, + &AssembleReleasePlanOptions::default(), + ) + .expect_err("plan must fail"); + assert!( + err.to_string().contains("Two projects both release @scope/util@1.0.1"), + "unexpected error: {err}", + ); +} + +#[test] +fn a_name_shared_by_two_projects_is_ambiguous_and_must_be_referenced_by_directory() { + let intents = [make_intent("one", &[("pnpm", "patch")])]; + let err = assemble_release_plan( + &twins(), + std::path::Path::new("/ws"), + &intents, + &Ledger::new(), + None, + &AssembleReleasePlanOptions::default(), + ) + .expect_err("plan must fail"); + assert!(err.to_string().contains("matches multiple workspace projects"), "unexpected: {err}"); + + let intents = [make_intent("one", &[("./pnpm/npm/pnpm", "patch")])]; + let plan = assemble(&twins(), &intents, &Ledger::new(), None); + assert_eq!(plan.releases.len(), 1); + assert_eq!(plan.releases[0].dir, "pnpm/npm/pnpm"); + assert_eq!(plan.releases[0].new_version, "12.0.1"); +} + +#[test] +fn ledger_consumption_attributes_by_directory_when_names_collide() { + let intents = [make_intent("one", &[("./pnpm11/pnpm", "patch"), ("./pnpm/npm/pnpm", "patch")])]; + let mut consumed = Ledger::new(); + consumed.insert( + "pnpm@12.0.1".to_string(), + LedgerEntry::Attributed { + dir: "pnpm/npm/pnpm".to_string(), + intents: vec!["one".to_string()], + }, + ); + let plan = assemble(&twins(), &intents, &consumed, None); + // The Rust line already consumed the intent; only the TS line still + // releases. + assert_eq!(release_names(&plan), ["pnpm"]); + assert_eq!(plan.releases[0].dir, "pnpm11/pnpm"); +} + +#[test] +fn lanes_keyed_by_directory_path_apply_to_the_right_twin() { + let intents = [make_intent("one", &[("./pnpm11/pnpm", "patch"), ("./pnpm/npm/pnpm", "minor")])]; + let versioning = VersioningSettings { + lanes: IndexMap::from([("./pnpm/npm/pnpm".to_string(), "alpha".to_string())]), + ..VersioningSettings::default() + }; + let plan = assemble(&twins(), &intents, &Ledger::new(), Some(&versioning)); + let ts_line = plan.releases.iter().find(|release| release.dir == "pnpm11/pnpm").expect("ts"); + let rust_line = + plan.releases.iter().find(|release| release.dir == "pnpm/npm/pnpm").expect("rust"); + assert_eq!(ts_line.new_version, "11.0.1"); + assert_eq!(rust_line.new_version, "12.1.0-alpha.0"); +} + +#[test] +fn a_lane_named_main_is_rejected_as_the_reserved_default_lane() { + let projects = [make_project("cli", "2.0.0", &[])]; + let versioning = on_lane("cli", "Main"); + let err = assemble_release_plan( + &projects, + std::path::Path::new("/ws"), + &[], + &Ledger::new(), + Some(&versioning), + &AssembleReleasePlanOptions::default(), + ) + .expect_err("plan must fail"); + assert!(err.to_string().contains("reserved default lane"), "unexpected error: {err}"); +} + +#[test] +fn non_ascii_workspace_aliases_do_not_panic() { + assert_eq!( + materialize_workspace_range("workspace:\u{e9}dition@^", "1.2.3").as_deref(), + Some("^1.2.3"), + ); + assert_eq!( + materialize_workspace_range("workspace:\u{e9}@1.0.0", "1.2.3").as_deref(), + Some("1.0.0"), + ); +} + +#[test] +fn materialize_workspace_range_mirrors_pack_time_materialization() { + assert_eq!(materialize_workspace_range("workspace:*", "1.2.3").as_deref(), Some("1.2.3")); + assert_eq!(materialize_workspace_range("workspace:^", "1.2.3").as_deref(), Some("^1.2.3")); + assert_eq!(materialize_workspace_range("workspace:~", "1.2.3").as_deref(), Some("~1.2.3")); + assert_eq!(materialize_workspace_range("workspace:^1.0.0", "1.2.3").as_deref(), Some("^1.0.0")); + assert_eq!(materialize_workspace_range("workspace:lib@^", "1.2.3").as_deref(), Some("^1.2.3")); + assert_eq!(materialize_workspace_range("^1.0.0", "1.2.3"), None); +} diff --git a/pnpm/crates/versioning/src/settings.rs b/pnpm/crates/versioning/src/settings.rs new file mode 100644 index 0000000000..fdd486326e --- /dev/null +++ b/pnpm/crates/versioning/src/settings.rs @@ -0,0 +1,70 @@ +use derive_more::Display; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +/// A bump a release can apply. Ordered: `patch < minor < major`. +#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ReleaseBumpType { + #[display("patch")] + Patch, + #[display("minor")] + Minor, + #[display("major")] + Major, +} + +#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ChangelogStorage { + #[display("registry")] + Registry, + #[display("repository")] + Repository, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ChangelogSettings { + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub storage: Option, +} + +/// Settings for native workspace release management, declared under the +/// `versioning` key of pnpm-workspace.yaml. Mirrors the TypeScript type of +/// the same name field for field. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct VersioningSettings { + /// Groups of packages that always release together at one shared version. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub fixed: Vec>, + /// Packages permanently excluded from versioning and dependent + /// propagation. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub ignore: Vec, + /// Caps the bump a release from the current checkout may apply. Enforced + /// on the final assembled release plan, after dependent propagation and + /// fixed-group resolution. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_bump: Option, + /// Per-package release lanes: maps a package name to the lane it is on + /// (e.g. `"@example/cli": "alpha"`). A lane is a parallel release track + /// that emits `X.Y.Z-tag.N` prereleases; every unlisted package is on the + /// reserved default lane, `main`, and releases stable versions. + #[serde(skip_serializing_if = "IndexMap::is_empty")] + pub lanes: IndexMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub changelog: Option, +} + +impl VersioningSettings { + /// Whether nothing is configured — the state in which the `versioning` + /// key is removed from pnpm-workspace.yaml instead of written empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self == &VersioningSettings::default() + } +} diff --git a/pnpm/crates/workspace-projects-filter/src/get_changed_projects.rs b/pnpm/crates/workspace-projects-filter/src/get_changed_projects.rs index 08c9951ac0..8e468f7121 100644 --- a/pnpm/crates/workspace-projects-filter/src/get_changed_projects.rs +++ b/pnpm/crates/workspace-projects-filter/src/get_changed_projects.rs @@ -10,7 +10,7 @@ use std::{ use wax::{Glob, Program}; /// Options for [`get_changed_projects`]. -pub(crate) struct GetChangedProjectsOptions<'a> { +pub struct GetChangedProjectsOptions<'a> { /// Directory the `git diff` runs in and is path-restricted to: the /// selector's `{dir}` part when present, else the workspace root. pub workspace_dir: &'a Path, @@ -19,7 +19,7 @@ pub(crate) struct GetChangedProjectsOptions<'a> { } /// The two selection groups a `[]` diff produces. -pub(crate) struct ChangedProjects { +pub struct ChangedProjects { /// Projects with at least one changed file outside `test_pattern`; /// selected with the selector's full dependency/dependent walk. pub changed_projects: Vec, @@ -33,7 +33,7 @@ pub(crate) struct ChangedProjects { /// `project_dirs` order. A changed file belongs to the nearest /// enclosing project directory; a project's change type is `source` as /// soon as any of its changed files is a source change. -pub(crate) fn get_changed_projects( +pub fn get_changed_projects( project_dirs: Vec, commit: &str, opts: &GetChangedProjectsOptions<'_>, diff --git a/pnpm/crates/workspace-projects-filter/src/lib.rs b/pnpm/crates/workspace-projects-filter/src/lib.rs index cd7eb34177..c7fe252794 100644 --- a/pnpm/crates/workspace-projects-filter/src/lib.rs +++ b/pnpm/crates/workspace-projects-filter/src/lib.rs @@ -16,6 +16,7 @@ mod filter; mod get_changed_projects; +pub use get_changed_projects::{ChangedProjects, GetChangedProjectsOptions, get_changed_projects}; mod glob; mod parse_project_selector; mod path_util; diff --git a/pnpm11/config/reader/src/Config.ts b/pnpm11/config/reader/src/Config.ts index ef2d6d0d3f..ae1893cfa3 100644 --- a/pnpm11/config/reader/src/Config.ts +++ b/pnpm11/config/reader/src/Config.ts @@ -10,6 +10,7 @@ import type { Registries, RegistryConfig, TrustPolicy, + VersioningSettings, } from '@pnpm/types' import type { OptionsFromRootManifest } from './getOptionsFromRootManifest.js' @@ -259,6 +260,7 @@ export interface Config extends OptionsFromRootManifest { testPattern?: string[] changedFilesIgnorePattern?: string[] + versioning?: VersioningSettings userConfig: Record hoist: boolean diff --git a/pnpm11/core/types/src/index.ts b/pnpm11/core/types/src/index.ts index 86f5e46146..af9977f3e9 100644 --- a/pnpm11/core/types/src/index.ts +++ b/pnpm11/core/types/src/index.ts @@ -4,3 +4,4 @@ export * from './options.js' export * from './package.js' export * from './peerDependencyIssues.js' export * from './project.js' +export * from './versioning.js' diff --git a/pnpm11/core/types/src/package.ts b/pnpm11/core/types/src/package.ts index 8c5b31eb76..da9a08a754 100644 --- a/pnpm11/core/types/src/package.ts +++ b/pnpm11/core/types/src/package.ts @@ -1,4 +1,5 @@ import type { Registries } from './misc.js' +import type { VersioningSettings } from './versioning.js' export type Dependencies = Record @@ -201,6 +202,7 @@ export interface PnpmSettings { httpsProxy?: string noProxy?: string | boolean pnprServer?: string + versioning?: VersioningSettings } export interface ProjectManifest extends BaseManifest { diff --git a/pnpm11/core/types/src/versioning.ts b/pnpm11/core/types/src/versioning.ts new file mode 100644 index 0000000000..c9efc4c397 --- /dev/null +++ b/pnpm11/core/types/src/versioning.ts @@ -0,0 +1,37 @@ +export type VersioningBumpType = 'patch' | 'minor' | 'major' + +export type VersioningChangelogStorage = 'registry' | 'repository' + +export interface VersioningChangelogSettings { + format?: string + storage?: VersioningChangelogStorage +} + +/** + * Settings for native workspace release management, declared under the + * `versioning` key of pnpm-workspace.yaml. + */ +export interface VersioningSettings { + /** + * Groups of packages that always release together at one shared version. + */ + fixed?: string[][] + /** + * Packages permanently excluded from versioning and dependent propagation. + */ + ignore?: string[] + /** + * Caps the bump a release from the current checkout may apply. Enforced on + * the final assembled release plan, after dependent propagation and + * fixed-group resolution. + */ + maxBump?: VersioningBumpType + /** + * Per-package release lanes: maps a package name to the lane it is on + * (e.g. `"@example/cli": "alpha"`). A lane is a parallel release track that + * emits `X.Y.Z-tag.N` prereleases; every unlisted package is on the + * reserved default lane, `main`, and releases stable versions. + */ + lanes?: Record + changelog?: VersioningChangelogSettings +} diff --git a/pnpm11/pnpm/src/cmd/index.ts b/pnpm11/pnpm/src/cmd/index.ts index 78e14de4de..eeff90f359 100644 --- a/pnpm11/pnpm/src/cmd/index.ts +++ b/pnpm11/pnpm/src/cmd/index.ts @@ -20,7 +20,7 @@ import { add, dedupe, fetch, importCommand, install, link, prune, remove, unlink import { patch, patchCommit, patchRemove } from '@pnpm/patching.commands' import { pkg, setScript } from '@pnpm/pkg-manifest.commands' import { access, deprecate, distTag, owner, ping, search, star, stars, team, undeprecate, unpublish, unstar, whoami } from '@pnpm/registry-access.commands' -import { deploy, pack, packApp, publish, stage, version } from '@pnpm/releasing.commands' +import { change, deploy, lane, pack, packApp, publish, stage, version } from '@pnpm/releasing.commands' import { catFile, catIndex, findHash, store } from '@pnpm/store.commands' import { init } from '@pnpm/workspace.commands' import { pick } from 'ramda' @@ -130,6 +130,7 @@ const commands: CommandDefinition[] = [ bin, bugs, cache, + change, ci, clean, config, @@ -155,6 +156,7 @@ const commands: CommandDefinition[] = [ install, pkg, installTest, + lane, link, list, login, diff --git a/pnpm11/pnpm/src/main.ts b/pnpm11/pnpm/src/main.ts index 4ecfc3e452..eb76c5e175 100644 --- a/pnpm11/pnpm/src/main.ts +++ b/pnpm11/pnpm/src/main.ts @@ -313,7 +313,10 @@ export async function main (inputArgv: string[]): Promise { process.exitCode = 1 return } - if (cmd !== 'list') { + // "change" operates on the whole workspace through allProjects, so an + // empty selection (e.g. run from a directory without packages beneath + // it) must not skip the command. + if (cmd !== 'list' && cmd !== 'change') { process.exitCode = 0 return } diff --git a/pnpm11/releasing/commands/package.json b/pnpm11/releasing/commands/package.json index 52840e7384..cdfdacc75c 100644 --- a/pnpm11/releasing/commands/package.json +++ b/pnpm11/releasing/commands/package.json @@ -60,9 +60,12 @@ "@pnpm/network.web-auth": "workspace:*", "@pnpm/npm-package-arg": "catalog:", "@pnpm/releasing.exportable-manifest": "workspace:*", + "@pnpm/releasing.versioning": "workspace:*", "@pnpm/resolving.resolver-base": "workspace:*", "@pnpm/types": "workspace:*", + "@pnpm/workspace.projects-filter": "workspace:*", "@pnpm/workspace.projects-sorter": "workspace:*", + "@pnpm/workspace.workspace-manifest-writer": "workspace:*", "@types/normalize-path": "catalog:", "@zkochan/rimraf": "catalog:", "chalk": "catalog:", @@ -102,7 +105,6 @@ "@pnpm/test-ipc-server": "workspace:*", "@pnpm/testing.command-defaults": "workspace:*", "@pnpm/testing.registry-mock": "workspace:*", - "@pnpm/workspace.projects-filter": "workspace:*", "@types/cross-spawn": "catalog:", "@types/is-windows": "catalog:", "@types/libnpmpublish": "catalog:", diff --git a/pnpm11/releasing/commands/src/change/index.ts b/pnpm11/releasing/commands/src/change/index.ts new file mode 100644 index 0000000000..16408e73f5 --- /dev/null +++ b/pnpm11/releasing/commands/src/change/index.ts @@ -0,0 +1,318 @@ +import { checkbox, input, Separator } from '@inquirer/prompts' +import type { Config } from '@pnpm/config.reader' +import { PnpmError } from '@pnpm/error' +import { + assembleReleasePlan, + BUMP_TYPES, + type ChangeIntent, + indexProjectRefs, + type IntentBumpType, + readChangeIntents, + readLedger, + type ReleasePlan, + toProjectDir, + type WorkspaceProject, + writeChangeIntent, +} from '@pnpm/releasing.versioning' +import type { Project, VersioningSettings } from '@pnpm/types' +import { getChangedProjects } from '@pnpm/workspace.projects-filter' +import { safeExeca as execa } from 'execa' +import { renderHelp } from 'render-help' +import { valid } from 'semver' + +export function rcOptionsTypes (): Record { + return {} +} + +export function cliOptionsTypes (): Record { + return { + bump: String, + summary: String, + recursive: Boolean, + } +} + +export const commandNames = ['change'] + +export function help (): string { + return renderHelp({ + description: 'Records a change intent: which packages a change affects, the bump type for each, and a summary that becomes the changelog entry. The intent file is written to .changeset/ in the changesets format.', + usages: [ + 'pnpm change [--bump ] [--summary ] [...]', + 'pnpm change status', + ], + descriptionLists: [ + { + title: 'Options', + list: [ + { + description: `Bump type for the named packages: ${BUMP_TYPES.join(', ')}. "none" records an explicit decline — the change needs no release`, + name: '--bump ', + }, + { + description: 'The summary for the changelog entry. Runs non-interactively when given together with package names', + name: '--summary ', + }, + ], + }, + ], + }) +} + +export type ChangeCommandOptions = Pick & { + allProjects?: Project[] + bump?: string + summary?: string +} + +export async function handler (opts: ChangeCommandOptions, params: string[]): Promise { + const workspaceDir = opts.workspaceDir + if (!workspaceDir) { + throw new PnpmError('WORKSPACE_ONLY', 'pnpm change is only supported in a workspace') + } + // Only the exact no-option invocation is the status form, so a package + // that happens to be named "status" stays recordable. + if (params.length === 1 && params[0] === 'status' && opts.bump == null && opts.summary == null) { + return renderStatus(workspaceDir, opts) + } + return recordChange(workspaceDir, opts, params) +} + +async function recordChange (workspaceDir: string, opts: ChangeCommandOptions, params: string[]): Promise { + const releasable = getReleasableProjects(opts.allProjects ?? [], workspaceDir, opts.versioning) + if (releasable.length === 0) { + throw new PnpmError('VERSIONING_NO_PACKAGES', 'No releasable packages found in this workspace') + } + const releasableDirs = new Set(releasable.map((project) => project.dir)) + const refs = indexProjectRefs(opts.allProjects ?? [], workspaceDir) + + for (const ref of params) { + const dirs = refs.refToDirs(ref) + if (dirs.length > 1) { + throw new PnpmError( + 'VERSIONING_AMBIGUOUS_PACKAGE', + `${ref} matches multiple workspace projects: ${dirs.map((dir) => `./${dir}`).join(', ')}. Reference the project by directory instead.` + ) + } + if (dirs.length === 0 || !releasableDirs.has(dirs[0])) { + throw new PnpmError('VERSIONING_UNKNOWN_PACKAGE', `${ref} is not a releasable package of this workspace`) + } + } + + if (opts.bump != null && !(BUMP_TYPES as readonly string[]).includes(opts.bump)) { + throw new PnpmError('VERSIONING_INVALID_BUMP', `Invalid bump type: ${opts.bump}. Expected one of ${BUMP_TYPES.join(', ')}`) + } + + const pkgRefs = params.length > 0 + ? params + : await promptForPackages(releasable, workspaceDir, opts) + + const releases = opts.bump != null + ? Object.fromEntries(pkgRefs.map((ref) => [ref, opts.bump as IntentBumpType])) + : await promptBumpTypes(pkgRefs) + + const summary = opts.summary ?? + await input({ message: 'Summary of the change (becomes the changelog entry):', required: true }) + + const id = await writeChangeIntent(workspaceDir, { releases, summary }) + return `Recorded change intent .changeset/${id}.md` +} + +/** + * The affected-packages picker, changesets-style: the packages whose + * directories the branch touched are grouped first (under a "changed + * packages" heading) and preselected, the rest listed below under "unchanged + * packages". A name shared by several projects is offered under its directory + * reference so the written intent stays unambiguous. Change detection is + * best-effort — outside a git repo, or when no base branch can be found, the + * list falls back to a flat, unselected picker. + */ +async function promptForPackages ( + releasable: ReleasableProject[], + workspaceDir: string, + opts: ChangeCommandOptions +): Promise { + const changedDirs = await detectChangedDirs(releasable, workspaceDir, opts) + const label = (project: ReleasableProject): string => + project.ref === project.name ? project.name : `${project.name} (./${project.dir})` + + let choices: Array + if (changedDirs.size > 0) { + const changed = releasable.filter((project) => changedDirs.has(project.dir)) + const unchanged = releasable.filter((project) => !changedDirs.has(project.dir)) + choices = [ + new Separator('changed packages'), + ...changed.map((project) => ({ value: project.ref, name: label(project), checked: true })), + ...(unchanged.length > 0 ? [new Separator('unchanged packages')] : []), + ...unchanged.map((project) => ({ value: project.ref, name: label(project) })), + ] + } else { + choices = releasable.map((project) => ({ value: project.ref, name: label(project) })) + } + + return checkbox({ + message: 'Which packages does this change affect?', + choices, + required: true, + }) +} + +/** + * The workspace-relative directories the current branch changed, relative to + * the base branch, using the same detection behind `--filter="[]"`. + * Returns an empty set on any failure so the picker degrades to a flat list. + */ +async function detectChangedDirs ( + releasable: ReleasableProject[], + workspaceDir: string, + opts: ChangeCommandOptions +): Promise> { + const baseCommit = await detectBaseCommit(workspaceDir) + if (baseCommit == null) return new Set() + try { + const projectDirs = (opts.allProjects ?? []).map((project) => project.rootDir) + const [changedRootDirs] = await getChangedProjects(projectDirs, baseCommit, { + workspaceDir, + testPattern: opts.testPattern, + changedFilesIgnorePattern: opts.changedFilesIgnorePattern, + }) + const releasableDirs = new Set(releasable.map((project) => project.dir)) + return new Set( + changedRootDirs + .map((rootDir) => toProjectDir(workspaceDir, rootDir)) + .filter((dir) => releasableDirs.has(dir)) + ) + } catch { + return new Set() + } +} + +/** The merge-base of HEAD with the default branch, or `undefined`. */ +async function detectBaseCommit (cwd: string): Promise { + for (const branch of ['main', 'master']) { + try { + // eslint-disable-next-line no-await-in-loop + const { stdout } = await execa('git', ['merge-base', 'HEAD', branch], { cwd }) + const commit = String(stdout).trim() + if (commit !== '') return commit + } catch { + // Try the next candidate branch. + } + } + return undefined +} + +/** + * The changesets-style bump picker: ask which packages get a major bump, then + * which of the rest get a minor, and default whatever remains to patch. One + * multiselect per level reads far better than a per-package prompt when many + * packages are affected. + */ +async function promptBumpTypes (pkgRefs: string[]): Promise> { + const bumpByRef = new Map() + let remaining = [...pkgRefs] + for (const bumpType of ['major', 'minor'] as const) { + if (remaining.length === 0) break + // eslint-disable-next-line no-await-in-loop + const chosen = new Set(await checkbox({ + message: `Which packages should have a ${bumpType} bump?`, + choices: remaining.map((ref) => ({ value: ref })), + })) + for (const ref of chosen) bumpByRef.set(ref, bumpType) + remaining = remaining.filter((ref) => !chosen.has(ref)) + } + for (const ref of remaining) bumpByRef.set(ref, 'patch') + // Emit in the original selection order rather than grouped by bump level. + return Object.fromEntries(pkgRefs.map((ref) => [ref, bumpByRef.get(ref)!])) +} + +async function renderStatus (workspaceDir: string, opts: ChangeCommandOptions): Promise { + const intents = await readChangeIntents(workspaceDir) + const ledger = await readLedger(workspaceDir) + const plan = assembleReleasePlan({ + workspaceDir, + projects: toWorkspaceProjects(opts.allProjects ?? []), + intents, + ledger, + versioning: opts.versioning, + }) + if (plan.releases.length === 0) { + return 'No pending changes.' + } + const consumedIds = new Set(plan.releases.flatMap((release) => release.intents.map((intent) => intent.id))) + let output = 'Pending change intents:\n' + for (const intent of intents.filter(({ id }) => consumedIds.has(id))) { + output += ` .changeset/${intent.id}.md\n` + } + output += '\n' + output += renderReleasePlan(plan) + return output +} + +export function renderReleasePlan (plan: ReleasePlan): string { + let output = 'Release plan:\n' + for (const release of plan.releases) { + output += ` ${release.name}: ${release.currentVersion} → ${release.newVersion} (${release.bumpType}, via ${release.causes.join('+')})\n` + } + return output +} + +export interface ReleasableProject { + name: string + /** Workspace-relative project directory. */ + dir: string + /** + * How an intent file or versioning config should reference this project: + * the bare name, or the `./`-prefixed directory when the name is shared by + * several workspace projects. + */ + ref: string +} + +/** + * The projects a change intent may demand a release from: named, carrying a + * valid semver version, and not frozen by `versioning.ignore`. Matches the + * participant set of the release-plan assembler. + */ +export function getReleasableProjects ( + allProjects: Array>, + workspaceDir: string, + versioning?: VersioningSettings +): ReleasableProject[] { + const refs = indexProjectRefs(allProjects, workspaceDir) + const ignoredDirs = new Set((versioning?.ignore ?? []).flatMap((ref) => refs.refToDirs(ref))) + return allProjects + .filter(({ manifest }) => + manifest.name != null && + manifest.version != null && + valid(manifest.version) != null) + .map((project) => ({ name: project.manifest.name!, dir: toProjectDir(workspaceDir, project.rootDir) })) + .filter(({ dir }) => !ignoredDirs.has(dir)) + .map(({ name, dir }) => ({ + name, + dir, + ref: refs.nameToDirs(name).length > 1 ? `./${dir}` : name, + })) + .sort((left, right) => left.ref.localeCompare(right.ref)) +} + +export function toWorkspaceProjects (allProjects: Array>): WorkspaceProject[] { + return allProjects.map((project) => ({ rootDir: project.rootDir, manifest: project.manifest })) +} + +export type { ChangeIntent } + +export const change = { + handler, + help, + commandNames, + cliOptionsTypes, + rcOptionsTypes, + recursiveByDefault: true, +} diff --git a/pnpm11/releasing/commands/src/index.ts b/pnpm11/releasing/commands/src/index.ts index 4bb111b9a0..60c9b65351 100644 --- a/pnpm11/releasing/commands/src/index.ts +++ b/pnpm11/releasing/commands/src/index.ts @@ -1,4 +1,6 @@ +export { change } from './change/index.js' export { deploy } from './deploy/index.js' +export { lane } from './lane/index.js' export { packApp } from './pack-app/index.js' export { pack, publish } from './publish/index.js' export * as stage from './stage/index.js' diff --git a/pnpm11/releasing/commands/src/lane/index.ts b/pnpm11/releasing/commands/src/lane/index.ts new file mode 100644 index 0000000000..c74000dce5 --- /dev/null +++ b/pnpm11/releasing/commands/src/lane/index.ts @@ -0,0 +1,182 @@ +import type { Config } from '@pnpm/config.reader' +import { PnpmError } from '@pnpm/error' +import { indexProjectRefs, toProjectDir } from '@pnpm/releasing.versioning' +import type { Project, ProjectsGraph, VersioningSettings } from '@pnpm/types' +import { updateWorkspaceManifest } from '@pnpm/workspace.workspace-manifest-writer' +import { renderHelp } from 'render-help' + +import { getReleasableProjects } from '../change/index.js' + +export function rcOptionsTypes (): Record { + return {} +} + +export function cliOptionsTypes (): Record { + return { + recursive: Boolean, + } +} + +export const commandNames = ['lane'] + +/** + * The reserved name of the default lane: every package is on it unless + * assigned elsewhere, packages on it release stable versions, and no + * prerelease lane can take the name. + */ +export const MAIN_LANE = 'main' + +export function help (): string { + return renderHelp({ + description: 'Manages per-package release lanes. A lane is a parallel release track: while a package is on one, the bare "pnpm version -r" releases it as X.Y.Z-.N prereleases while the rest of the workspace keeps releasing stable versions. Moving a package back to the main lane releases its accumulated stable version on the next run. Membership lives under the versioning.lanes key of pnpm-workspace.yaml; this command is a convenience editor for that key.', + usages: [ + 'pnpm lane', + 'pnpm lane --filter ', + 'pnpm lane main --filter ', + ], + descriptionLists: [ + { + title: 'Options', + list: [ + { + description: 'Select the packages to move between lanes', + name: '--filter ', + }, + ], + }, + ], + }) +} + +export type LaneCommandOptions = Pick & { + allProjects?: Project[] + selectedProjectsGraph?: ProjectsGraph +} + +export async function handler (opts: LaneCommandOptions, params: string[]): Promise { + const workspaceDir = opts.workspaceDir + if (!workspaceDir) { + throw new PnpmError('WORKSPACE_ONLY', 'pnpm lane is only supported in a workspace') + } + + if (params.length === 0) { + return renderLanes(opts.versioning?.lanes ?? {}) + } + const laneName = params[0] + + if ((opts.filter ?? []).length === 0) { + throw new PnpmError('VERSIONING_LANE_FILTER_REQUIRED', 'Select the packages to move with --filter, e.g. "pnpm lane alpha --filter ..."') + } + const refs = indexProjectRefs(opts.allProjects ?? [], workspaceDir) + const releasableDirs = new Set(getReleasableProjects(opts.allProjects ?? [], workspaceDir, opts.versioning).map((project) => project.dir)) + const selected = Object.values(opts.selectedProjectsGraph ?? {}) + .map((node) => ({ + name: node.package.manifest.name, + dir: toProjectDir(workspaceDir, node.package.rootDir), + })) + .filter((project): project is { name: string, dir: string } => + project.name != null && releasableDirs.has(project.dir)) + if (selected.length === 0) { + throw new PnpmError('VERSIONING_NO_PACKAGES', 'The filter selected no releasable packages') + } + + // Existing entries may reference projects by name or by directory; resolve + // them so assignments and removals key on the project, not the spelling. + const lanes = { ...opts.versioning?.lanes } + const laneByDir = new Map() + for (const [key, lane] of Object.entries(lanes)) { + for (const dir of refs.refToDirs(key)) { + laneByDir.set(dir, { key, lane }) + } + } + + let output: string + if (laneName === MAIN_LANE) { + for (const project of selected) { + const existing = laneByDir.get(project.dir) + if (existing != null) { + delete lanes[existing.key] + } + } + output = `Moved to the main lane:\n${selected.map((project) => ` ${refFor(project, refs)}\n`).join('')}` + + 'The accumulated stable versions release on the next "pnpm version -r" run.' + } else { + if (laneName.toLowerCase() === MAIN_LANE) { + throw new PnpmError('VERSIONING_INVALID_LANE_NAME', `Invalid lane name: ${laneName}. "main" is the reserved default lane; spell it in lowercase to move packages back onto it.`) + } + // A purely numeric lane name is rejected because semver parses an + // all-digit prerelease identifier as a number, which changes sorting + // semantics. + if (!/^[0-9A-Z-]+$/i.test(laneName) || /^\d+$/.test(laneName)) { + throw new PnpmError('VERSIONING_INVALID_LANE_NAME', `Invalid lane name: ${laneName}. Lane names may contain only alphanumerics and hyphens, and cannot be purely numeric.`) + } + for (const project of selected) { + const existing = laneByDir.get(project.dir) + if (existing != null && existing.lane !== laneName) { + throw new PnpmError('VERSIONING_ALREADY_ON_LANE', `${refFor(project, refs)} is already on the "${existing.lane}" lane. Move it back with "pnpm lane main" first.`) + } + if (existing == null) { + lanes[refFor(project, refs)] = laneName + } + } + output = `Moved to the "${laneName}" lane:\n${selected.map((project) => ` ${refFor(project, refs)}\n`).join('')}` + } + + const versioning: VersioningSettings = { ...opts.versioning } + if (Object.keys(lanes).length > 0) { + versioning.lanes = lanes + } else { + delete versioning.lanes + } + await updateWorkspaceManifest(workspaceDir, { + updatedFields: { + versioning: Object.keys(versioning).length > 0 ? versioning : undefined, + }, + }) + return output +} + +/** + * How the project is referenced in versioning.lanes and in output: the bare + * name, or the directory path when the name is shared by several projects. + */ +function refFor (project: { name: string, dir: string }, refs: { nameToDirs: (name: string) => string[] }): string { + return refs.nameToDirs(project.name).length > 1 ? `./${project.dir}` : project.name +} + +function renderLanes (lanes: Record): string { + const laneEntries = Object.entries(lanes) + if (laneEntries.length === 0) { + return 'All packages are on the main lane.' + } + const byLane = new Map() + for (const [ref, laneName] of laneEntries) { + let members = byLane.get(laneName) + if (members == null) { + members = [] + byLane.set(laneName, members) + } + members.push(ref) + } + let output = 'Lanes:\n' + for (const [laneName, members] of Array.from(byLane.entries()).sort(([left], [right]) => left.localeCompare(right))) { + output += ` ${laneName}:\n` + for (const ref of members.sort()) { + output += ` ${ref}\n` + } + } + return output +} + +export const lane = { + handler, + help, + commandNames, + cliOptionsTypes, + rcOptionsTypes, +} diff --git a/pnpm11/releasing/commands/src/version/index.ts b/pnpm11/releasing/commands/src/version/index.ts index c7197dd9db..c3fc4a9c43 100644 --- a/pnpm11/releasing/commands/src/version/index.ts +++ b/pnpm11/releasing/commands/src/version/index.ts @@ -5,12 +5,21 @@ import { type Config, types as allTypes } from '@pnpm/config.reader' import { PnpmError } from '@pnpm/error' import { runLifecycleHook, type RunLifecycleHookOptions } from '@pnpm/exec.lifecycle' import { isGitRepo, isWorkingTreeClean } from '@pnpm/network.git-utils' -import type { ProjectsGraph } from '@pnpm/types' +import { + applyReleasePlan, + assembleReleasePlan, + readChangeIntents, + readLedger, + toProjectDir, +} from '@pnpm/releasing.versioning' +import type { Project, ProjectsGraph } from '@pnpm/types' import { safeExeca as execa } from 'execa' import { pick } from 'ramda' import { renderHelp } from 'render-help' import { inc, valid } from 'semver' +import { renderReleasePlan, toWorkspaceProjects } from '../change/index.js' + export function rcOptionsTypes (): Record { return pick([ 'allow-same-version', @@ -26,6 +35,7 @@ export function rcOptionsTypes (): Record { export function cliOptionsTypes (): Record { return { ...rcOptionsTypes(), + 'dry-run': Boolean, json: Boolean, preid: String, recursive: Boolean, @@ -47,6 +57,7 @@ export function help (): string { usages: [ 'pnpm version ', 'pnpm version ', + 'pnpm version -r [--dry-run]', ], descriptionLists: [ { @@ -93,9 +104,13 @@ export function help (): string { name: '--json', }, { - description: 'Apply command to all packages in workspace', + description: 'Apply command to all packages in workspace. Without a version argument, consumes the pending change intents from .changeset/ and applies the resulting release plan', name: '--recursive', }, + { + description: 'Print the release plan the pending change intents produce without applying it', + name: '--dry-run', + }, ], }, ], @@ -111,8 +126,10 @@ interface VersionChange { } interface VersionHandlerOptions extends Config { + allProjects?: Project[] allowSameVersion?: boolean commitHooks?: boolean + dryRun?: boolean gitChecks?: boolean gitTagVersion?: boolean json?: boolean @@ -131,6 +148,9 @@ export async function handler ( const rawBump = params[0] if (!rawBump) { + if (opts.recursive) { + return releaseFromIntents(opts) + } throw new PnpmError('INVALID_VERSION_BUMP', 'A version argument is required. Must be a valid semver version (e.g. 1.2.3) or one of: major, minor, patch, premajor, preminor, prepatch, prerelease') } @@ -191,6 +211,68 @@ export async function handler ( return output } +async function releaseFromIntents (opts: VersionHandlerOptions): Promise { + const workspaceDir = opts.workspaceDir + if (!workspaceDir) { + throw new PnpmError('WORKSPACE_ONLY', 'The bare "pnpm version -r" form consumes change intents and is only supported in a workspace') + } + + if (!opts.dryRun && opts.gitChecks !== false && await isGitRepo({ cwd: workspaceDir })) { + if (!await isWorkingTreeClean({ cwd: workspaceDir })) { + throw new PnpmError('UNCLEAN_WORKING_TREE', 'Working tree is not clean. Commit or stash your changes.') + } + } + + const intents = await readChangeIntents(workspaceDir) + const ledger = await readLedger(workspaceDir) + const projects = toWorkspaceProjects(opts.allProjects ?? []) + const filter = (opts.filter ?? []).length > 0 + ? new Set(Object.keys(opts.selectedProjectsGraph ?? {}).map((rootDir) => toProjectDir(workspaceDir, rootDir))) + : undefined + + const plan = assembleReleasePlan({ + workspaceDir, + projects, + intents, + ledger, + versioning: opts.versioning, + filter, + enforceWorkspaceProtocol: true, + }) + + if (plan.releases.length === 0) { + // A full (unfiltered) run garbage-collects the intent files an empty plan + // leaves behind: declined ("none"-only) intents and files a merge + // resurrected after every named package had already consumed them. A + // filtered run must not — "nothing pending in this scope" is no reason to + // delete prose belonging to packages outside the filter. + if (!opts.dryRun && filter == null) { + await applyReleasePlan(plan, { workspaceDir, projects, allIntents: intents, versioning: opts.versioning }) + } + return 'No pending changes. Record one with "pnpm change".' + } + + if (opts.dryRun) { + return renderReleasePlan(plan) + } + + const applied = await applyReleasePlan(plan, { + workspaceDir, + projects, + allIntents: intents, + versioning: opts.versioning, + }) + + if (opts.json) { + return JSON.stringify(applied, null, 2) + } + let output = 'Versions applied:\n' + for (const release of applied) { + output += `${release.name}: ${release.currentVersion} → ${release.newVersion}\n` + } + return output +} + async function bumpPackageVersion ( pkgDir: string, rawBump: string, diff --git a/pnpm11/releasing/commands/test/change/index.test.ts b/pnpm11/releasing/commands/test/change/index.test.ts new file mode 100644 index 0000000000..265225cef3 --- /dev/null +++ b/pnpm11/releasing/commands/test/change/index.test.ts @@ -0,0 +1,206 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals' + +import { change, lane, version } from '../../src/index.js' + +interface FixturePkg { + name: string + version: string + dependencies?: Record +} + +describe('change command and intent-consuming version -r', () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-change-test-')) + fs.writeFileSync(path.join(tempDir, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n') + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + function addPkg (pkg: FixturePkg): { rootDir: string, manifest: FixturePkg } { + const rootDir = path.join(tempDir, 'packages', pkg.name) + fs.mkdirSync(rootDir, { recursive: true }) + fs.writeFileSync(path.join(rootDir, 'package.json'), JSON.stringify(pkg, null, 2)) + return { rootDir, manifest: pkg } + } + + function baseOpts (projects: Array<{ rootDir: string, manifest: FixturePkg }>): object { + return { + dir: tempDir, + workspaceDir: tempDir, + allProjects: projects, + gitChecks: false, + recursive: true, + } + } + + it('exports the change command', () => { + expect(change.commandNames).toEqual(['change']) + expect(change.help()).toContain('change intent') + }) + + it('records an intent non-interactively and shows it in status', async () => { + const lib = addPkg({ name: 'lib', version: '1.0.0' }) + const cli = addPkg({ name: 'cli', version: '2.0.0', dependencies: { lib: 'workspace:*' } }) + const opts = baseOpts([lib, cli]) + + const output = await change.handler({ ...opts, bump: 'minor', summary: 'Added a feature.' } as any, ['lib']) // eslint-disable-line @typescript-eslint/no-explicit-any + expect(output).toMatch(/Recorded change intent \.changeset\/.+\.md/) + + const status = await change.handler(opts as any, ['status']) // eslint-disable-line @typescript-eslint/no-explicit-any + expect(status).toContain('lib: 1.0.0 → 1.1.0') + expect(status).toContain('cli: 2.0.0 → 2.0.1') + }) + + it('rejects an unknown package name', async () => { + const lib = addPkg({ name: 'lib', version: '1.0.0' }) + await expect( + change.handler({ ...baseOpts([lib]), bump: 'patch', summary: 'x' } as any, ['ghost']) // eslint-disable-line @typescript-eslint/no-explicit-any + ).rejects.toMatchObject({ code: 'ERR_PNPM_VERSIONING_UNKNOWN_PACKAGE' }) + }) + + it('bare version -r applies the release plan and cleans up the intent', async () => { + const lib = addPkg({ name: 'lib', version: '1.0.0' }) + const cli = addPkg({ name: 'cli', version: '2.0.0', dependencies: { lib: 'workspace:*' } }) + const opts = baseOpts([lib, cli]) + + await change.handler({ ...opts, bump: 'major', summary: 'Breaking change.' } as any, ['lib']) // eslint-disable-line @typescript-eslint/no-explicit-any + + const dryRun = await version.handler({ ...opts, dryRun: true } as any, []) // eslint-disable-line @typescript-eslint/no-explicit-any + expect(dryRun).toContain('lib: 1.0.0 → 2.0.0') + + const output = await version.handler(opts as any, []) // eslint-disable-line @typescript-eslint/no-explicit-any + expect(output).toContain('lib: 1.0.0 → 2.0.0') + expect(output).toContain('cli: 2.0.0 → 2.0.1') + + expect(JSON.parse(fs.readFileSync(path.join(lib.rootDir, 'package.json'), 'utf8')).version).toBe('2.0.0') + const changelog = fs.readFileSync(path.join(lib.rootDir, 'CHANGELOG.md'), 'utf8') + expect(changelog).toContain('- Breaking change.') + const remaining = fs.readdirSync(path.join(tempDir, '.changeset')).filter((fileName) => fileName.endsWith('.md')) + expect(remaining).toHaveLength(0) + expect(fs.readFileSync(path.join(tempDir, '.changeset', 'ledger.yaml'), 'utf8')).toContain('lib@2.0.0') + }) + + it('bare version -r without pending intents reports nothing to do', async () => { + const lib = addPkg({ name: 'lib', version: '1.0.0' }) + const output = await version.handler(baseOpts([lib]) as any, []) // eslint-disable-line @typescript-eslint/no-explicit-any + expect(output).toContain('No pending changes') + }) + + it('lane assignments update versioning.lanes in pnpm-workspace.yaml', async () => { + const cli = addPkg({ name: 'cli', version: '2.0.0' }) + const opts = { + ...baseOpts([cli]), + filter: ['cli'], + selectedProjectsGraph: { + [cli.rootDir]: { dependencies: [], package: { rootDir: cli.rootDir, manifest: cli.manifest } }, + }, + } + + await lane.handler(opts as any, ['alpha']) // eslint-disable-line @typescript-eslint/no-explicit-any + expect(fs.readFileSync(path.join(tempDir, 'pnpm-workspace.yaml'), 'utf8')).toContain('cli: alpha') + + const status = await lane.handler({ ...opts, versioning: { lanes: { cli: 'alpha' } } } as any, []) // eslint-disable-line @typescript-eslint/no-explicit-any + expect(status).toContain('alpha:') + expect(status).toContain(' cli') + + await lane.handler({ ...opts, versioning: { lanes: { cli: 'alpha' } } } as any, ['main']) // eslint-disable-line @typescript-eslint/no-explicit-any + expect(fs.readFileSync(path.join(tempDir, 'pnpm-workspace.yaml'), 'utf8')).not.toContain('alpha') + }) + + it('a none-only intent is consumed by a version -r run with nothing to release', async () => { + const lib = addPkg({ name: 'lib', version: '1.0.0' }) + const opts = baseOpts([lib]) + await change.handler({ ...opts, bump: 'none', summary: 'refactor, no release needed' } as any, ['lib']) // eslint-disable-line @typescript-eslint/no-explicit-any + const output = await version.handler(opts as any, []) // eslint-disable-line @typescript-eslint/no-explicit-any + expect(output).toContain('No pending changes') + const remaining = fs.readdirSync(path.join(tempDir, '.changeset')).filter((fileName) => fileName.endsWith('.md')) + expect(remaining).toHaveLength(0) + }) + + it('a filtered version -r with an empty plan leaves out-of-scope intents untouched', async () => { + const lib = addPkg({ name: 'lib', version: '1.0.0' }) + const cli = addPkg({ name: 'cli', version: '2.0.0' }) + const opts = baseOpts([lib, cli]) + await change.handler({ ...opts, bump: 'none', summary: 'refactor, no release needed' } as any, ['lib']) // eslint-disable-line @typescript-eslint/no-explicit-any + + // Filter to cli (nothing pending there); lib's none-only intent must survive. + const filtered = { + ...opts, + filter: ['cli'], + selectedProjectsGraph: { + [cli.rootDir]: { dependencies: [], package: { rootDir: cli.rootDir, manifest: cli.manifest } }, + }, + } + const output = await version.handler(filtered as any, []) // eslint-disable-line @typescript-eslint/no-explicit-any + expect(output).toContain('No pending changes') + expect(fs.readdirSync(path.join(tempDir, '.changeset')).filter((fileName) => fileName.endsWith('.md'))).toHaveLength(1) + }) + + it('change status stays read-only when an internal dependency is not on the workspace protocol', async () => { + const lib = addPkg({ name: 'lib', version: '1.0.0' }) + const cli = addPkg({ name: 'cli', version: '2.0.0', dependencies: { lib: '^1.0.0' } }) + const opts = baseOpts([lib, cli]) + // A read-only diagnostic must not throw the release-time prerequisite error. + await expect(change.handler(opts as any, ['status'])).resolves.toBeDefined() // eslint-disable-line @typescript-eslint/no-explicit-any + // The release path does enforce it. + await change.handler({ ...opts, bump: 'patch', summary: 'A fix.' } as any, ['lib']) // eslint-disable-line @typescript-eslint/no-explicit-any + await expect(version.handler(opts as any, [])).rejects.toMatchObject({ code: 'ERR_PNPM_VERSIONING_INTERNAL_RANGE' }) // eslint-disable-line @typescript-eslint/no-explicit-any + }) + + it('rejects differently-cased spellings of the reserved main lane', async () => { + const cli = addPkg({ name: 'cli', version: '2.0.0' }) + const opts = { + ...baseOpts([cli]), + filter: ['cli'], + selectedProjectsGraph: { + [cli.rootDir]: { dependencies: [], package: { rootDir: cli.rootDir, manifest: cli.manifest } }, + }, + } + await expect( + lane.handler(opts as any, ['MAIN']) // eslint-disable-line @typescript-eslint/no-explicit-any + ).rejects.toMatchObject({ code: 'ERR_PNPM_VERSIONING_INVALID_LANE_NAME' }) + }) + + it('lane assignment requires a filter', async () => { + const cli = addPkg({ name: 'cli', version: '2.0.0' }) + await expect( + lane.handler(baseOpts([cli]) as any, ['alpha']) // eslint-disable-line @typescript-eslint/no-explicit-any + ).rejects.toMatchObject({ code: 'ERR_PNPM_VERSIONING_LANE_FILTER_REQUIRED' }) + }) + + it('a name shared by two projects must be referenced by directory', async () => { + const twinA = addPkg({ name: 'pnpm', version: '11.0.0' }) + const twinB = { rootDir: path.join(tempDir, 'rust', 'pnpm'), manifest: { name: 'pnpm', version: '12.0.0' } } + fs.mkdirSync(twinB.rootDir, { recursive: true }) + fs.writeFileSync(path.join(twinB.rootDir, 'package.json'), JSON.stringify(twinB.manifest, null, 2)) + const opts = baseOpts([twinA, twinB]) + + await expect( + change.handler({ ...opts, bump: 'patch', summary: 'x' } as any, ['pnpm']) // eslint-disable-line @typescript-eslint/no-explicit-any + ).rejects.toMatchObject({ code: 'ERR_PNPM_VERSIONING_AMBIGUOUS_PACKAGE' }) + + const output = await change.handler({ ...opts, bump: 'patch', summary: 'Rust-line fix.' } as any, ['./rust/pnpm']) // eslint-disable-line @typescript-eslint/no-explicit-any + expect(output).toContain('Recorded change intent') + + const applied = await version.handler(opts as any, []) // eslint-disable-line @typescript-eslint/no-explicit-any + expect(applied).toContain('pnpm: 12.0.0 → 12.0.1') + expect(applied).not.toContain('11.0.0') + const ledger = fs.readFileSync(path.join(tempDir, '.changeset', 'ledger.yaml'), 'utf8') + expect(ledger).toContain('pnpm@12.0.1:') + expect(ledger).toContain('dir: rust/pnpm') + }) + + it('bare lane reports when everything is on the main lane', async () => { + const cli = addPkg({ name: 'cli', version: '2.0.0' }) + const output = await lane.handler(baseOpts([cli]) as any, []) // eslint-disable-line @typescript-eslint/no-explicit-any + expect(output).toBe('All packages are on the main lane.') + }) +}) diff --git a/pnpm11/releasing/commands/tsconfig.json b/pnpm11/releasing/commands/tsconfig.json index 9b5873357d..6e9429ba6c 100644 --- a/pnpm11/releasing/commands/tsconfig.json +++ b/pnpm11/releasing/commands/tsconfig.json @@ -123,8 +123,14 @@ { "path": "../../workspace/projects-sorter" }, + { + "path": "../../workspace/workspace-manifest-writer" + }, { "path": "../exportable-manifest" + }, + { + "path": "../versioning" } ] } diff --git a/pnpm11/releasing/versioning/README.md b/pnpm11/releasing/versioning/README.md new file mode 100644 index 0000000000..e71d1840be --- /dev/null +++ b/pnpm11/releasing/versioning/README.md @@ -0,0 +1,24 @@ +# @pnpm/releasing.versioning + +> Native workspace release management: change intents, release-plan assembly, and changelog writing + +[![npm version](https://img.shields.io/npm/v/@pnpm/releasing.versioning.svg)](https://npmx.dev/package/@pnpm/releasing.versioning) + +Implements the engine behind `pnpm change` and the bare `pnpm version -r`: +reading and writing changesets-compatible change-intent files from +`.changeset/*.md`, assembling a release plan (direct bumps, dependent +propagation through materialized `workspace:` ranges, fixed groups, +per-package release lanes), and applying it (manifest version updates, +changelog composition, the consumed-intents ledger, and intent-file cleanup). + +See the [native monorepo versioning RFC](https://github.com/pnpm/rfcs/pull/18). + +## Installation + +```sh +pnpm add @pnpm/releasing.versioning +``` + +## License + +MIT diff --git a/pnpm11/releasing/versioning/package.json b/pnpm11/releasing/versioning/package.json new file mode 100644 index 0000000000..7a75f920be --- /dev/null +++ b/pnpm11/releasing/versioning/package.json @@ -0,0 +1,54 @@ +{ + "name": "@pnpm/releasing.versioning", + "version": "1100.0.0", + "description": "Native workspace release management: change intents, release-plan assembly, and changelog writing", + "keywords": [ + "pnpm", + "pnpm11" + ], + "license": "MIT", + "funding": "https://opencollective.com/pnpm", + "repository": "https://github.com/pnpm/pnpm/tree/main/pnpm11/releasing/versioning", + "homepage": "https://github.com/pnpm/pnpm/tree/main/pnpm11/releasing/versioning#readme", + "bugs": { + "url": "https://github.com/pnpm/pnpm/issues" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": "./lib/index.js" + }, + "files": [ + "lib", + "!*.map" + ], + "scripts": { + "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", + "test": "pn compile && pn .test", + "prepublishOnly": "tsgo --build", + "compile": "tsgo --build && pn lint --fix", + ".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest" + }, + "dependencies": { + "@pnpm/error": "workspace:*", + "@pnpm/types": "workspace:*", + "@pnpm/workspace.project-manifest-reader": "workspace:*", + "@pnpm/workspace.spec-parser": "workspace:*", + "human-id": "catalog:", + "semver": "catalog:", + "yaml": "catalog:" + }, + "devDependencies": { + "@jest/globals": "catalog:", + "@pnpm/releasing.versioning": "workspace:*", + "@types/semver": "catalog:", + "tempy": "catalog:" + }, + "engines": { + "node": ">=22.13" + }, + "jest": { + "preset": "@pnpm/jest-config" + } +} diff --git a/pnpm11/releasing/versioning/src/applyReleasePlan.ts b/pnpm11/releasing/versioning/src/applyReleasePlan.ts new file mode 100644 index 0000000000..b5b7a47e70 --- /dev/null +++ b/pnpm11/releasing/versioning/src/applyReleasePlan.ts @@ -0,0 +1,95 @@ +import fs from 'node:fs/promises' + +import { PnpmError } from '@pnpm/error' +import type { VersioningSettings } from '@pnpm/types' +import { readProjectManifest } from '@pnpm/workspace.project-manifest-reader' + +import { indexProjectRefs, type ReleasePlan, type WorkspaceProject } from './assembleReleasePlan.js' +import { composeChangelogSection, prependChangelogSection } from './changelog.js' +import type { ChangeIntent } from './intents.js' +import { appendToLedger, buildConsumptionIndex } from './ledger.js' + +export interface ApplyReleasePlanOptions { + workspaceDir: string + projects: WorkspaceProject[] + /** + * All intent files currently in the workspace, used to decide which files + * are fully consumed after this run and can be deleted. + */ + allIntents: ChangeIntent[] + versioning?: VersioningSettings +} + +export interface AppliedRelease { + name: string + currentVersion: string + newVersion: string +} + +export async function applyReleasePlan (plan: ReleasePlan, opts: ApplyReleasePlanOptions): Promise { + assertSupportedChangelogStorage(opts.versioning) + + const applied = await Promise.all(plan.releases.map(async (release) => { + const { manifest, writeProjectManifest } = await readProjectManifest(release.rootDir) + manifest.version = release.newVersion + await writeProjectManifest(manifest) + return { + name: release.name, + currentVersion: release.currentVersion, + newVersion: release.newVersion, + } + })) + + await Promise.all(plan.releases.map(async (release) => { + const section = composeChangelogSection(release) + await prependChangelogSection(release.rootDir, release.name, section) + })) + + const newEntries: Record = {} + for (const release of plan.releases) { + if (release.intents.length === 0) continue + newEntries[`${release.name}@${release.newVersion}`] = { + dir: release.dir, + intents: release.intents.map((intent) => intent.id).sort(), + } + } + const ledger = await appendToLedger(opts.workspaceDir, newEntries) + + // An intent file is deletable once every project it names has a ledger + // entry for it, with one exemption: while a project is still on a lane, + // entries against prerelease versions alone keep the file alive — its + // prose is still needed to compose the stable changelog section at + // graduation. Declined (`none`) entries demand no release and never block + // deletion. References here were already validated by the plan assembly, + // so an unresolvable one just keeps its file around. + const refs = indexProjectRefs(opts.projects, opts.workspaceDir) + const consumptionOf = buildConsumptionIndex(ledger, refs.nameToDirs) + const laneDirs = new Set() + for (const ref of Object.keys(opts.versioning?.lanes ?? {})) { + for (const dir of refs.refToDirs(ref)) { + laneDirs.add(dir) + } + } + const deletable = opts.allIntents.filter((intent) => + Object.entries(intent.releases).every(([ref, bumpType]) => { + if (bumpType === 'none') return true + const dirs = refs.refToDirs(ref) + if (dirs.length !== 1) return false + const consumption = consumptionOf(dirs[0]) + return consumption.allIds.has(intent.id) && + !(laneDirs.has(dirs[0]) && consumption.prereleaseOnlyIds.has(intent.id)) + })) + await Promise.all(deletable.map(async (intent) => fs.rm(intent.filePath))) + + return applied +} + +function assertSupportedChangelogStorage (versioning?: VersioningSettings): void { + const storage = versioning?.changelog?.storage + if (storage != null && storage !== 'repository') { + throw new PnpmError( + 'VERSIONING_UNSUPPORTED_CHANGELOG_STORAGE', + `versioning.changelog.storage "${storage}" is not implemented yet. Use "repository".` + ) + } +} diff --git a/pnpm11/releasing/versioning/src/assembleReleasePlan.ts b/pnpm11/releasing/versioning/src/assembleReleasePlan.ts new file mode 100644 index 0000000000..b0d79a8a76 --- /dev/null +++ b/pnpm11/releasing/versioning/src/assembleReleasePlan.ts @@ -0,0 +1,722 @@ +import path from 'node:path' + +import { PnpmError } from '@pnpm/error' +import type { ProjectManifest, VersioningSettings } from '@pnpm/types' +import { WorkspaceSpec } from '@pnpm/workspace.spec-parser' +import { compare, diff, inc, prerelease as parsePrerelease, satisfies, valid, validRange } from 'semver' + +import type { ChangeIntent, IntentBumpType, ReleaseBumpType } from './intents.js' +import { buildConsumptionIndex, type Ledger, normalizeProjectDir, type PackageConsumption } from './ledger.js' + +export interface WorkspaceProject { + rootDir: string + manifest: ProjectManifest +} + +export type ReleaseCause = 'intent' | 'dependencies' | 'fixed' + +export interface DependencyUpdate { + name: string + newVersion: string +} + +export interface PlannedRelease { + name: string + /** Workspace-relative project directory — the engine's unit of identity. */ + dir: string + rootDir: string + currentVersion: string + newVersion: string + bumpType: ReleaseBumpType + /** + * The intent files this release consumes for this package: the pending ones, + * plus — when the release graduates the package off a lane — the + * ones the ledger recorded against the lane's prerelease versions. + */ + intents: ChangeIntent[] + dependencyUpdates: DependencyUpdate[] + causes: ReleaseCause[] +} + +export interface ReleasePlan { + releases: PlannedRelease[] +} + +export interface AssembleReleasePlanOptions { + workspaceDir: string + projects: WorkspaceProject[] + intents: ChangeIntent[] + ledger: Ledger + versioning?: VersioningSettings + /** + * Workspace-relative directories of the projects selected with --filter. + * The plan is narrowed to the selected packages' portion of the pending + * work, expanded with their fixed-group companions and range-invalidated + * dependents. + */ + filter?: Set + /** + * When set, every planned release gets the version `0.0.0-` instead + * of the computed one, matching snapshot releases. + */ + snapshotSuffix?: string + /** + * Enforce that every internal production dependency uses the `workspace:` + * protocol — a prerequisite for actually releasing. The release path + * (`pnpm version -r`) sets this; read-only callers (`pnpm change status`) + * leave it off so a diagnostic never fails on an unmigrated dependency. + */ + enforceWorkspaceProtocol?: boolean +} + +const BUMP_ORDER: Record = { patch: 1, minor: 2, major: 3 } + +const PROPAGATED_DEP_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const + +/** + * Whether a package reference is a workspace-relative directory path rather + * than a package name — the additive extension to the changesets format, + * needed only when workspace projects share a published name. + */ +export function isDirRef (ref: string): boolean { + return ref.startsWith('./') +} + +/** + * Resolves package references — bare names, or `./`-prefixed + * workspace-relative directories — against the workspace. Names are aliases: + * one that matches several projects cannot identify any of them and callers + * must treat it as an error, never a silent pick. + */ +export interface ProjectRefIndex { + /** The directories a reference resolves to: `[]` unknown, 2+ ambiguous. */ + refToDirs: (ref: string) => string[] + nameToDirs: (name: string) => string[] +} + +export function indexProjectRefs ( + projects: ReadonlyArray<{ rootDir: string, manifest: { name?: string } }>, + workspaceDir: string +): ProjectRefIndex { + const dirs = new Set() + const dirsByName = new Map() + for (const project of projects) { + const dir = toProjectDir(workspaceDir, project.rootDir) + dirs.add(dir) + const name = project.manifest.name + if (name == null) continue + let named = dirsByName.get(name) + if (named == null) { + named = [] + dirsByName.set(name, named) + } + named.push(dir) + } + return { + refToDirs: (ref) => { + if (isDirRef(ref)) { + const dir = normalizeProjectDir(ref) + return dirs.has(dir) ? [dir] : [] + } + return dirsByName.get(ref) ?? [] + }, + nameToDirs: (name) => dirsByName.get(name) ?? [], + } +} + +/** The workspace-relative directory of a project, in canonical spelling. */ +export function toProjectDir (workspaceDir: string, rootDir: string): string { + return normalizeProjectDir(path.relative(workspaceDir, rootDir)) +} + +export function assembleReleasePlan (opts: AssembleReleasePlanOptions): ReleasePlan { + const refs = indexProjectRefs(opts.projects, opts.workspaceDir) + const participants = collectParticipants(opts.projects, refs, opts) + const lanesByDir = resolveLanes(refs, participants, opts.versioning) + const fixedGroups = resolveFixedGroups(refs, participants, opts.versioning) + validateFixedGroupLanes(fixedGroups, lanesByDir, opts.versioning) + const intentBumps = resolveIntents(opts.intents, refs, participants) + if (opts.enforceWorkspaceProtocol) { + assertInternalDepsUseWorkspaceProtocol(participants) + } + const consumptionOf = buildConsumptionIndex(opts.ledger, refs.nameToDirs) + + const ctx: AssembleContext = { participants, lanesByDir, fixedGroups, intentBumps, consumptionOf, opts } + let selection = opts.filter + for (;;) { + const plan = assemble(ctx, selection) + if (selection == null) return plan + const expanded = new Set(selection) + for (const release of plan.releases) { + expanded.add(release.dir) + } + if (expanded.size === selection.size) return plan + selection = expanded + } +} + +interface Participant { + name: string + dir: string + rootDir: string + currentVersion: string + manifest: ProjectManifest + /** Workspace-internal production dependencies, by target project dir. */ + internalDeps: InternalDep[] +} + +interface InternalDep { + targetDir: string + targetName: string + fieldName: typeof PROPAGATED_DEP_FIELDS[number] + alias: string + spec: string +} + +interface BumpState { + bumpType: ReleaseBumpType + causes: Set + dependencyUpdates: Map +} + +interface AssembleContext { + participants: Map + lanesByDir: Map + fixedGroups: string[][] + /** Per intent id: the participant dirs it releases and their bump types. */ + intentBumps: Map> + consumptionOf: (dir: string) => PackageConsumption + opts: AssembleReleasePlanOptions +} + +function assemble (ctx: AssembleContext, selection: Set | undefined): ReleasePlan { + const { participants, lanesByDir, fixedGroups, opts } = ctx + const pendingByDir = collectPendingIntents(ctx) + const laneConsumedByDir = collectLaneConsumedIntents(ctx) + + const state = new Map() + const bumpAtLeast = (dir: string, bumpType: ReleaseBumpType, cause: ReleaseCause): boolean => { + const existing = state.get(dir) + if (existing == null) { + state.set(dir, { bumpType, causes: new Set([cause]), dependencyUpdates: new Map() }) + return true + } + existing.causes.add(cause) + if (BUMP_ORDER[bumpType] > BUMP_ORDER[existing.bumpType]) { + existing.bumpType = bumpType + return true + } + return false + } + const intentBumpFor = (intent: ChangeIntent, dir: string): IntentBumpType | undefined => + ctx.intentBumps.get(intent.id)?.get(dir) + + for (const [dir, pending] of pendingByDir.entries()) { + if (selection != null && !selection.has(dir)) continue + const direct = maxBumpType(pending.map((intent) => intentBumpFor(intent, dir))) + if (direct != null) { + bumpAtLeast(dir, direct, 'intent') + } + } + + // A package that left its lane releases the accumulated stable + // version even when no new intents are pending. + for (const [dir, laneConsumed] of laneConsumedByDir.entries()) { + if (selection != null && !selection.has(dir)) continue + if (lanesByDir.has(dir) || laneConsumed.length === 0) continue + const graduated = maxBumpType(laneConsumed.map((intent) => intentBumpFor(intent, dir))) + if (graduated != null) { + bumpAtLeast(dir, graduated, 'intent') + } + } + + const cumulativeBump = (dir: string, planned: ReleaseBumpType): ReleaseBumpType => { + const laneConsumed = laneConsumedByDir.get(dir) ?? [] + return maxBumpType([planned, ...laneConsumed.map((intent) => intentBumpFor(intent, dir))]) ?? planned + } + + const newVersions = new Map() + const computeVersions = (): void => { + newVersions.clear() + for (const [dir, pkgState] of state.entries()) { + const participant = participants.get(dir)! + newVersions.set(dir, computeNewVersion(participant, pkgState.bumpType, { + laneTag: lanesByDir.get(dir), + cumulativeBump: cumulativeBump(dir, pkgState.bumpType), + })) + } + applyFixedGroupVersions({ participants, state, newVersions, cumulativeBump, fixedGroups, lanesByDir }) + } + + for (let changed = true; changed;) { + changed = false + computeVersions() + + for (const dependent of participants.values()) { + for (const dep of dependent.internalDeps) { + const target = participants.get(dep.targetDir) + const targetNewVersion = newVersions.get(dep.targetDir) + if (target == null || targetNewVersion == null) continue + const materializedRange = materializeWorkspaceRange(dep.spec, target.currentVersion) + if (materializedRange == null || satisfies(targetNewVersion, materializedRange)) continue + if (bumpAtLeast(dependent.dir, 'patch', 'dependencies')) { + changed = true + } + state.get(dependent.dir)!.dependencyUpdates.set(dep.targetName, targetNewVersion) + } + } + + for (const group of fixedGroups) { + const groupBump = maxBumpType(group.map((dir) => state.get(dir)?.bumpType)) + if (groupBump == null) continue + for (const dir of group) { + if (bumpAtLeast(dir, groupBump, 'fixed')) { + changed = true + } + } + } + } + computeVersions() + + const releases: PlannedRelease[] = [] + for (const [dir, pkgState] of state.entries()) { + const participant = participants.get(dir)! + const consumedForChangelog = [ + ...(pendingByDir.get(dir) ?? []), + ...(lanesByDir.has(dir) ? [] : laneConsumedByDir.get(dir) ?? []), + ] + releases.push({ + name: participant.name, + dir, + rootDir: participant.rootDir, + currentVersion: participant.currentVersion, + newVersion: opts.snapshotSuffix != null ? `0.0.0-${opts.snapshotSuffix}` : newVersions.get(dir)!, + bumpType: pkgState.bumpType, + intents: consumedForChangelog, + dependencyUpdates: Array.from(pkgState.dependencyUpdates.entries()) + .map(([depName, newVersion]) => ({ name: depName, newVersion })) + .sort((left, right) => left.name.localeCompare(right.name)), + causes: Array.from(pkgState.causes).sort(), + }) + } + releases.sort((left, right) => left.name.localeCompare(right.name) || left.dir.localeCompare(right.dir)) + + assertNoDuplicateReleaseIdentity(releases) + if (opts.snapshotSuffix == null) { + enforceMaxBump(releases, opts.versioning) + } + + return { releases } +} + +/** + * A published `package@version` identifies exactly one artifact, so two + * projects that share a name cannot both release the same version — the + * registry would reject the second publish, and the name-keyed ledger entry + * would collide. Caught here, before any manifest is written, naming both + * directories. + */ +function assertNoDuplicateReleaseIdentity (releases: PlannedRelease[]): void { + const byIdentity = new Map() + for (const release of releases) { + const identity = `${release.name}@${release.newVersion}` + const other = byIdentity.get(identity) + if (other != null) { + throw new PnpmError( + 'VERSIONING_DUPLICATE_RELEASE', + `Two projects both release ${identity}: ./${other} and ./${release.dir}. ` + + 'A package name and version identify one published artifact, so same-named projects must release on different version lines (e.g. different lanes or majors).' + ) + } + byIdentity.set(identity, release.dir) + } +} + +function collectParticipants ( + projects: WorkspaceProject[], + refs: ProjectRefIndex, + opts: AssembleReleasePlanOptions +): Map { + const ignoredDirs = new Set() + for (const ref of opts.versioning?.ignore ?? []) { + for (const dir of resolveConfigRef(refs, ref, 'versioning.ignore')) { + ignoredDirs.add(dir) + } + } + + const participants = new Map() + for (const project of projects) { + const { name, version } = project.manifest + const dir = toProjectDir(opts.workspaceDir, project.rootDir) + // What cannot release is excluded automatically: unnamed and versionless + // (private) packages, packages with non-semver placeholder versions, and + // the explicitly frozen ones. + if (name == null || version == null || valid(version) == null || ignoredDirs.has(dir)) continue + participants.set(dir, { + name, + dir, + rootDir: project.rootDir, + currentVersion: version, + manifest: project.manifest, + internalDeps: [], + }) + } + + for (const participant of participants.values()) { + for (const fieldName of PROPAGATED_DEP_FIELDS) { + for (const [alias, spec] of Object.entries(participant.manifest[fieldName] ?? {})) { + const targetName = internalDepTargetName(alias, spec, refs) + if (targetName == null) continue + const targetDirs = refs.nameToDirs(targetName).filter((dir) => participants.has(dir)) + if (targetDirs.length === 0) continue + // A workspace: range naming an ambiguous package cannot be linked at + // install time, so the release engine never legitimately sees one. + if (targetDirs.length > 1) { + throw new PnpmError( + 'VERSIONING_AMBIGUOUS_PACKAGE', + `Package ${participant.name} (./${participant.dir}) depends on ${targetName}, which matches multiple workspace projects: ${targetDirs.map((dir) => `./${dir}`).join(', ')}` + ) + } + participant.internalDeps.push({ targetDir: targetDirs[0], targetName, fieldName, alias, spec }) + } + } + } + return participants +} + +/** + * Decides whether a dependency entry points at a workspace package. Aliased + * specs targeting somewhere else (`npm:`, `file:`, git URLs, …) are external + * even when the alias collides with a workspace package name; a plain semver + * range or `catalog:` entry on a workspace name is internal — it is exactly + * the declaration the workspace-protocol check must reject. + */ +function internalDepTargetName (alias: string, spec: string, refs: ProjectRefIndex): string | null { + if (spec.startsWith('workspace:')) { + const targetName = WorkspaceSpec.parse(spec)?.alias ?? alias + return refs.nameToDirs(targetName).length > 0 ? targetName : null + } + if (refs.nameToDirs(alias).length === 0) return null + if (spec.startsWith('catalog:') || validRange(spec) != null) return alias + return null +} + +/** + * Resolves a package reference from `versioning` configuration. An unknown + * reference is skipped — configuration may outlive a removed project — but an + * ambiguous name is an error: it cannot be attributed, and silence here is + * exactly the name-keying flaw this engine exists to fix. + */ +function resolveConfigRef (refs: ProjectRefIndex, ref: string, settingName: string): string[] { + const dirs = refs.refToDirs(ref) + if (dirs.length > 1) { + throw new PnpmError( + 'VERSIONING_AMBIGUOUS_PACKAGE', + `${settingName} references ${ref}, which matches multiple workspace projects: ${dirs.map((dir) => `./${dir}`).join(', ')}. Reference the project by directory instead.` + ) + } + return dirs +} + +function resolveLanes ( + refs: ProjectRefIndex, + participants: Map, + versioning?: VersioningSettings +): Map { + const lanesByDir = new Map() + for (const [ref, lane] of Object.entries(versioning?.lanes ?? {})) { + if (lane.toLowerCase() === 'main') { + throw new PnpmError( + 'VERSIONING_INVALID_LANE_NAME', + `versioning.lanes assigns ${ref} to the "${lane}" lane, but "main" is the reserved default lane. Remove the entry instead.` + ) + } + for (const dir of resolveConfigRef(refs, ref, 'versioning.lanes')) { + if (participants.has(dir)) { + lanesByDir.set(dir, lane) + } + } + } + return lanesByDir +} + +function resolveFixedGroups ( + refs: ProjectRefIndex, + participants: Map, + versioning?: VersioningSettings +): string[][] { + return (versioning?.fixed ?? []).map((group) => + group + .flatMap((ref) => resolveConfigRef(refs, ref, 'versioning.fixed')) + .filter((dir) => participants.has(dir))) +} + +function validateFixedGroupLanes ( + fixedGroups: string[][], + lanesByDir: Map, + versioning?: VersioningSettings +): void { + for (const [index, group] of fixedGroups.entries()) { + const tags = new Set(group.map((dir) => lanesByDir.get(dir))) + if (tags.size > 1) { + throw new PnpmError( + 'VERSIONING_CONFLICTING_CONFIG', + `The fixed group [${(versioning?.fixed ?? [])[index].join(', ')}] mixes packages on different lanes. A fixed group must move between lanes together.` + ) + } + } +} + +/** + * Resolves every intent's package references to participant directories, + * validating along the way: unknown references and names matching several + * projects are hard errors, and a release can only be demanded from a + * participant — otherwise the intent could never be consumed and the file + * would linger forever. A `none` decline is fine for any workspace package. + */ +function resolveIntents ( + intents: ChangeIntent[], + refs: ProjectRefIndex, + participants: Map +): Map> { + const intentBumps = new Map>() + for (const intent of intents) { + const byDir = new Map() + for (const [ref, bumpType] of Object.entries(intent.releases)) { + const dirs = refs.refToDirs(ref) + if (dirs.length === 0) { + throw new PnpmError('VERSIONING_UNKNOWN_PACKAGE', `Change intent file ${intent.filePath} names ${ref}, which is not a package in this workspace`) + } + if (dirs.length > 1) { + throw new PnpmError( + 'VERSIONING_AMBIGUOUS_PACKAGE', + `Change intent file ${intent.filePath} names ${ref}, which matches multiple workspace projects: ${dirs.map((dir) => `./${dir}`).join(', ')}. ` + + 'Reference the project by directory instead, e.g. "./' + dirs[0] + '": ' + bumpType + ) + } + const dir = dirs[0] + if (bumpType !== 'none' && !participants.has(dir)) { + throw new PnpmError( + 'VERSIONING_UNRELEASABLE_PACKAGE', + `Change intent file ${intent.filePath} requests a ${bumpType} release of ${ref}, which cannot release ` + + '(it is listed in versioning.ignore, has no version field, or has a non-semver version). ' + + 'Remove the entry or change it to "none".' + ) + } + const existing = byDir.get(dir) + if (existing == null || (bumpType !== 'none' && BUMP_ORDER[bumpType] > (existing === 'none' ? 0 : BUMP_ORDER[existing]))) { + byDir.set(dir, bumpType) + } + } + intentBumps.set(intent.id, byDir) + } + return intentBumps +} + +function assertInternalDepsUseWorkspaceProtocol (participants: Map): void { + for (const participant of participants.values()) { + for (const dep of participant.internalDeps) { + if (!dep.spec.startsWith('workspace:')) { + throw new PnpmError( + 'VERSIONING_INTERNAL_RANGE', + `Package ${participant.name} declares the internal dependency ${dep.alias} in ${dep.fieldName} as "${dep.spec}". ` + + 'Internal dependencies must use the workspace: protocol so that dependency ranges never need rewriting at release time.' + ) + } + } + } +} + +function collectPendingIntents (ctx: AssembleContext): Map { + const pending = new Map() + for (const dir of ctx.participants.keys()) { + const consumed = ctx.consumptionOf(dir) + const pkgIntents = ctx.opts.intents.filter((intent) => { + const bump = ctx.intentBumps.get(intent.id)?.get(dir) + return bump != null && bump !== 'none' && !consumed.allIds.has(intent.id) + }) + if (pkgIntents.length > 0) { + pending.set(dir, pkgIntents) + } + } + return pending +} + +/** + * Intents already consumed by prereleases of a package that has not graduated + * to a stable version yet. They participate in the cumulative bump computation + * of the package's lane and compose the stable changelog section at + * graduation. + */ +function collectLaneConsumedIntents (ctx: AssembleContext): Map { + const laneConsumed = new Map() + for (const dir of ctx.participants.keys()) { + const consumed = ctx.consumptionOf(dir) + if (consumed.prereleaseOnlyIds.size === 0) continue + const pkgIntents = ctx.opts.intents.filter((intent) => { + const bump = ctx.intentBumps.get(intent.id)?.get(dir) + return bump != null && bump !== 'none' && consumed.prereleaseOnlyIds.has(intent.id) + }) + if (pkgIntents.length > 0) { + laneConsumed.set(dir, pkgIntents) + } + } + return laneConsumed +} + +function maxBumpType (types: Array): ReleaseBumpType | null { + let result: ReleaseBumpType | null = null + for (const type of types) { + if (type !== 'patch' && type !== 'minor' && type !== 'major') continue + if (result == null || BUMP_ORDER[type] > BUMP_ORDER[result]) { + result = type + } + } + return result +} + +interface NewVersionOptions { + laneTag?: string + /** + * The highest bump accumulated across the package's lane — the + * planned bump joined with the bumps of intents consumed by earlier + * prereleases — which keeps the stable target stable across `-tag.N` runs. + */ + cumulativeBump: ReleaseBumpType +} + +function computeNewVersion (participant: Participant, bumpType: ReleaseBumpType, opts: NewVersionOptions): string { + const current = participant.currentVersion + if (opts.laneTag == null) { + if (parsePrerelease(current) == null) { + return inc(current, bumpType)! + } + // Graduation: the accumulated stable version the lane was + // building toward. + return escalateStableTarget(stablePart(current), opts.cumulativeBump) + } + const target = parsePrerelease(current) == null + ? inc(current, opts.cumulativeBump)! + : escalateStableTarget(stablePart(current), opts.cumulativeBump) + return `${target}-${opts.laneTag}.${nextPrereleaseNumber(current, target, opts.laneTag)}` +} + +/** + * Re-derives the stable version a lane is building toward when the + * cumulative bump escalates. The invariant: the stable part of the current + * prerelease already reflects the previous cumulative bump applied to the + * version the line started from, so only an escalation changes it. + */ +function escalateStableTarget (target: string, cumulativeBump: ReleaseBumpType): string { + const [major, minor, patch] = target.split('.').map(Number) + switch (cumulativeBump) { + case 'major': + return minor === 0 && patch === 0 ? target : `${major + 1}.0.0` + case 'minor': + return patch === 0 ? target : `${major}.${minor + 1}.0` + case 'patch': + return target + } +} + +function stablePart (version: string): string { + return version.split('-')[0] +} + +function nextPrereleaseNumber (current: string, target: string, laneTag: string): number { + const currentPrerelease = parsePrerelease(current) + if (currentPrerelease == null) return 0 + const [currentTag, currentN] = currentPrerelease + // semver parses an all-digit prerelease identifier as a number, so the tag + // comparison must not be strict about the type. + if (stablePart(current) !== target || String(currentTag) !== laneTag || typeof currentN !== 'number') return 0 + return currentN + 1 +} + +interface ApplyFixedGroupVersionsOptions { + participants: Map + state: Map + newVersions: Map + cumulativeBump: (dir: string, planned: ReleaseBumpType) => ReleaseBumpType + fixedGroups: string[][] + lanesByDir: Map +} + +function applyFixedGroupVersions ({ participants, state, newVersions, cumulativeBump, fixedGroups, lanesByDir }: ApplyFixedGroupVersionsOptions): void { + for (const group of fixedGroups) { + const bumpedMembers = group.filter((dir) => state.has(dir)) + if (bumpedMembers.length === 0) continue + + const groupBump = maxBumpType(bumpedMembers.map((dir) => cumulativeBump(dir, state.get(dir)!.bumpType)))! + const highestCurrent = group + .map((dir) => participants.get(dir)!.currentVersion) + .sort(compare) + .at(-1)! + const target = parsePrerelease(highestCurrent) == null + ? inc(highestCurrent, groupBump)! + : escalateStableTarget(stablePart(highestCurrent), groupBump) + + const laneTag = lanesByDir.get(group[0]) + let sharedVersion = target + if (laneTag != null) { + const nextN = Math.max(...group.map((dir) => nextPrereleaseNumber(participants.get(dir)!.currentVersion, target, laneTag))) + sharedVersion = `${target}-${laneTag}.${nextN}` + } + for (const dir of group) { + if (state.has(dir)) { + newVersions.set(dir, sharedVersion) + } + } + } +} + +/** + * The range that pnpm materializes for a workspace: spec at pack time, given + * the dependency's version at the dependent's previous release. Dependent + * propagation republishes the dependent whenever the dependency's new version + * falls outside this range. + */ +export function materializeWorkspaceRange (spec: string, depCurrentVersion: string): string | null { + const parsed = WorkspaceSpec.parse(spec) + if (parsed == null) return null + switch (parsed.version) { + case '^': + return `^${depCurrentVersion}` + case '~': + return `~${depCurrentVersion}` + case '*': + case '': + return depCurrentVersion + default: + return parsed.version + } +} + +function enforceMaxBump (releases: PlannedRelease[], versioning?: VersioningSettings): void { + const maxBump = versioning?.maxBump + if (maxBump == null) return + for (const release of releases) { + const effectiveBump = effectiveBumpClass(release) + if (BUMP_ORDER[effectiveBump] <= BUMP_ORDER[maxBump]) continue + const intentFiles = release.intents + .filter((intent) => Object.values(intent.releases).includes(effectiveBump)) + .map((intent) => intent.filePath) + const raisedBy = intentFiles.length > 0 ? `intent file(s) ${intentFiles.join(', ')}` : `constraint chain: ${release.causes.join(', ')}` + throw new PnpmError( + 'VERSIONING_MAX_BUMP_EXCEEDED', + `The release plan bumps ${release.name} by ${effectiveBump}, but versioning.maxBump caps releases from this branch at ${maxBump}. Raised by ${raisedBy}.` + ) + } +} + +/** + * The bump class a release actually applies. Fixed-group version sharing and + * lane escalation can move a version further than the package's own + * declared or propagated bump, so the cap compares against the real distance + * between the current and the new version as well. + */ +function effectiveBumpClass (release: PlannedRelease): ReleaseBumpType { + const diffClass = diff(release.currentVersion, release.newVersion) + const normalized = diffClass?.replace(/^pre(?!release)/, '') + return maxBumpType([release.bumpType, normalized ?? undefined]) ?? release.bumpType +} diff --git a/pnpm11/releasing/versioning/src/changelog.ts b/pnpm11/releasing/versioning/src/changelog.ts new file mode 100644 index 0000000000..54df71c22d --- /dev/null +++ b/pnpm11/releasing/versioning/src/changelog.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import util from 'node:util' + +import { isDirRef, type PlannedRelease } from './assembleReleasePlan.js' +import type { IntentBumpType, ReleaseBumpType } from './intents.js' +import { normalizeProjectDir } from './ledger.js' + +const SECTION_TITLES: Record = { + major: 'Major Changes', + minor: 'Minor Changes', + patch: 'Patch Changes', +} + +export function composeChangelogSection (release: PlannedRelease): string { + const entriesByBump: Record = { major: [], minor: [], patch: [] } + for (const intent of release.intents) { + const bumpType = releaseBumpFor(intent.releases, release) + if (bumpType == null || bumpType === 'none' || intent.summary === '') continue + entriesByBump[bumpType].push(formatListItem(intent.summary)) + } + if (release.dependencyUpdates.length > 0) { + const depLines = release.dependencyUpdates.map((dep) => ` - ${dep.name}@${dep.newVersion}`) + entriesByBump.patch.push(`- Updated dependencies:\n${depLines.join('\n')}`) + } + + const parts: string[] = [`## ${release.newVersion}`] + for (const bumpType of ['major', 'minor', 'patch'] as const) { + if (entriesByBump[bumpType].length === 0) continue + parts.push(`### ${SECTION_TITLES[bumpType]}`) + parts.push(entriesByBump[bumpType].join('\n\n')) + } + return `${parts.join('\n\n')}\n` +} + +/** + * The bump an intent declares for this release, whichever way the intent + * references the project — by name (sound only when unambiguous, which plan + * assembly guarantees) or by directory. + */ +function releaseBumpFor (releases: Record, release: PlannedRelease): IntentBumpType | undefined { + for (const [ref, bumpType] of Object.entries(releases)) { + if (ref === release.name) return bumpType + if (isDirRef(ref) && normalizeProjectDir(ref) === release.dir) return bumpType + } + return undefined +} + +function formatListItem (summary: string): string { + const [firstLine, ...restLines] = summary.split('\n') + const rest = restLines.map((line) => (line === '' ? '' : ` ${line}`)) + return ['- ' + firstLine, ...rest].join('\n') +} + +export async function prependChangelogSection (pkgDir: string, pkgName: string, section: string): Promise { + const changelogPath = path.join(pkgDir, 'CHANGELOG.md') + let existing: string | null = null + try { + existing = await fs.readFile(changelogPath, 'utf8') + } catch (err: unknown) { + if (!(util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT')) { + throw err + } + } + + let content: string + if (existing == null) { + content = `# ${pkgName}\n\n${section}` + } else { + const newlineIndex = existing.indexOf('\n') + const firstLine = newlineIndex === -1 ? existing : existing.slice(0, newlineIndex) + if (firstLine.startsWith('# ')) { + const body = (newlineIndex === -1 ? '' : existing.slice(newlineIndex + 1)).replace(/^[\r\n]+/, '') + content = `${firstLine}\n\n${section}${body === '' ? '' : `\n${body}`}` + } else { + content = `${section}\n${existing}` + } + } + await fs.writeFile(changelogPath, content, 'utf8') +} diff --git a/pnpm11/releasing/versioning/src/index.ts b/pnpm11/releasing/versioning/src/index.ts new file mode 100644 index 0000000000..e0090c91ca --- /dev/null +++ b/pnpm11/releasing/versioning/src/index.ts @@ -0,0 +1,45 @@ +export { + type AppliedRelease, + applyReleasePlan, + type ApplyReleasePlanOptions, +} from './applyReleasePlan.js' +export { + assembleReleasePlan, + type AssembleReleasePlanOptions, + type DependencyUpdate, + indexProjectRefs, + isDirRef, + materializeWorkspaceRange, + type PlannedRelease, + type ProjectRefIndex, + type ReleaseCause, + type ReleasePlan, + toProjectDir, + type WorkspaceProject, +} from './assembleReleasePlan.js' +export { + composeChangelogSection, + prependChangelogSection, +} from './changelog.js' +export { + BUMP_TYPES, + type ChangeIntent, + CHANGES_DIR, + type IntentBumpType, + parseChangeIntent, + readChangeIntents, + type ReleaseBumpType, + writeChangeIntent, + type WriteChangeIntentOptions, +} from './intents.js' +export { + appendToLedger, + buildConsumptionIndex, + type Ledger, + LEDGER_FILENAME, + type LedgerEntry, + ledgerEntryIds, + normalizeProjectDir, + type PackageConsumption, + readLedger, +} from './ledger.js' diff --git a/pnpm11/releasing/versioning/src/intents.ts b/pnpm11/releasing/versioning/src/intents.ts new file mode 100644 index 0000000000..afcc25e70e --- /dev/null +++ b/pnpm11/releasing/versioning/src/intents.ts @@ -0,0 +1,103 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import util from 'node:util' + +import { PnpmError } from '@pnpm/error' +import { humanId } from 'human-id' +import * as yaml from 'yaml' + +export const CHANGES_DIR = '.changeset' + +export const BUMP_TYPES = ['none', 'patch', 'minor', 'major'] as const + +export type IntentBumpType = typeof BUMP_TYPES[number] + +export type ReleaseBumpType = Exclude + +export interface ChangeIntent { + id: string + filePath: string + releases: Record + summary: string +} + +export function parseChangeIntent (content: string, id: string, filePath: string): ChangeIntent { + const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/) + const closingIndex = lines[0]?.trim() === '---' + ? lines.findIndex((line, index) => index > 0 && line.trim() === '---') + : -1 + if (closingIndex === -1) { + throw new PnpmError('INVALID_CHANGE_INTENT', `Change intent file ${filePath} has no YAML frontmatter`) + } + + let frontmatter: unknown + try { + frontmatter = yaml.parse(lines.slice(1, closingIndex).join('\n')) ?? {} + } catch (err: unknown) { + throw new PnpmError('INVALID_CHANGE_INTENT', `Change intent file ${filePath} has invalid YAML frontmatter: ${util.types.isNativeError(err) ? err.message : String(err)}`) + } + + if (typeof frontmatter !== 'object' || frontmatter === null || Array.isArray(frontmatter)) { + throw new PnpmError('INVALID_CHANGE_INTENT', `Change intent file ${filePath} frontmatter must be a mapping of package names to bump types`) + } + + const releases: Record = {} + for (const [pkgName, bumpType] of Object.entries(frontmatter)) { + if (typeof bumpType !== 'string' || !(BUMP_TYPES as readonly string[]).includes(bumpType)) { + throw new PnpmError('INVALID_CHANGE_INTENT', `Change intent file ${filePath} declares an invalid bump type for ${pkgName}: ${String(bumpType)}. Expected one of ${BUMP_TYPES.join(', ')}`) + } + releases[pkgName] = bumpType as IntentBumpType + } + + return { + id, + filePath, + releases, + summary: lines.slice(closingIndex + 1).join('\n').trim(), + } +} + +export async function readChangeIntents (workspaceDir: string): Promise { + const changesDir = path.join(workspaceDir, CHANGES_DIR) + let fileNames: string[] + try { + fileNames = await fs.readdir(changesDir) + } catch (err: unknown) { + if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') { + return [] + } + throw err + } + + const intentFileNames = fileNames + .filter((fileName) => fileName.endsWith('.md') && fileName.toLowerCase() !== 'readme.md') + .sort() + + return Promise.all(intentFileNames.map(async (fileName) => { + const filePath = path.join(changesDir, fileName) + const content = await fs.readFile(filePath, 'utf8') + return parseChangeIntent(content, fileName.slice(0, -'.md'.length), filePath) + })) +} + +export interface WriteChangeIntentOptions { + releases: Record + summary: string +} + +export async function writeChangeIntent (workspaceDir: string, opts: WriteChangeIntentOptions): Promise { + const changesDir = path.join(workspaceDir, CHANGES_DIR) + await fs.mkdir(changesDir, { recursive: true }) + + const existing = new Set(await fs.readdir(changesDir)) + let id = humanId({ separator: '-', capitalize: false }) + while (existing.has(`${id}.md`)) { + id = humanId({ separator: '-', capitalize: false }) + } + + const frontmatterLines = Object.entries(opts.releases) + .map(([pkgName, bumpType]) => `${JSON.stringify(pkgName)}: ${bumpType}`) + const content = `---\n${frontmatterLines.join('\n')}\n---\n\n${opts.summary.trim()}\n` + await fs.writeFile(path.join(changesDir, `${id}.md`), content, 'utf8') + return id +} diff --git a/pnpm11/releasing/versioning/src/ledger.ts b/pnpm11/releasing/versioning/src/ledger.ts new file mode 100644 index 0000000000..665c37df48 --- /dev/null +++ b/pnpm11/releasing/versioning/src/ledger.ts @@ -0,0 +1,171 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import util from 'node:util' + +import { PnpmError } from '@pnpm/error' +import * as yaml from 'yaml' + +import { CHANGES_DIR } from './intents.js' + +export const LEDGER_FILENAME = 'ledger.yaml' + +/** + * One consumed release: the workspace-relative directory of the project that + * released (the engine's unit of identity — package names may collide across + * workspace projects) and the ids of the intent files the release consumed. + * The bare id-list shape is accepted when read, for hand-written entries; + * its project is then resolved by the package name in the entry key, which + * must be unambiguous. + */ +export type LedgerEntry = string[] | { dir: string, intents: string[] } + +/** + * The committed, append-only record of consumed change intents: maps + * `@` to the released project and the ids of + * the intent files consumed by that release. Consumption is scoped per + * project — an intent file is fully consumed only once every project it + * names has an entry — which is what makes cherry-picked releases on + * maintenance branches and merge-backs safe, and what lets one intent be + * half-consumed by a package on a release lane. + */ +export type Ledger = Record + +export function ledgerEntryIds (entry: LedgerEntry): string[] { + return Array.isArray(entry) ? entry : entry.intents +} + +export async function readLedger (workspaceDir: string): Promise { + const ledgerPath = path.join(workspaceDir, CHANGES_DIR, LEDGER_FILENAME) + let content: string + try { + content = await fs.readFile(ledgerPath, 'utf8') + } catch (err: unknown) { + if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') { + return {} + } + throw err + } + + const parsed: unknown = yaml.parse(content) ?? {} + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new PnpmError('INVALID_VERSIONING_LEDGER', `Expected ${ledgerPath} to be a mapping of package@version keys to consumed-intent entries`) + } + // A null prototype so a key like "__proto__" lands as an own property + // instead of mutating the prototype. + const ledger: Ledger = Object.create(null) as Ledger + for (const [key, entry] of Object.entries(parsed)) { + if (!isValidLedgerEntry(entry)) { + throw new PnpmError('INVALID_VERSIONING_LEDGER', `Invalid entry for ${key} in ${ledgerPath}. Expected a list of intent ids, or a mapping with "dir" and "intents"`) + } + ledger[key] = entry as LedgerEntry + } + return ledger +} + +function isValidLedgerEntry (entry: unknown): boolean { + if (Array.isArray(entry)) { + return entry.every((id) => typeof id === 'string') + } + if (typeof entry !== 'object' || entry === null) return false + const { dir, intents } = entry as { dir?: unknown, intents?: unknown } + return typeof dir === 'string' && Array.isArray(intents) && intents.every((id) => typeof id === 'string') +} + +export async function appendToLedger ( + workspaceDir: string, + newEntries: Record +): Promise { + const ledger = await readLedger(workspaceDir) + if (Object.keys(newEntries).length === 0) return ledger + for (const [key, entry] of Object.entries(newEntries)) { + const existingIds = ledger[key] != null ? ledgerEntryIds(ledger[key]) : [] + ledger[key] = { + dir: entry.dir, + intents: Array.from(new Set([...existingIds, ...entry.intents])).sort(), + } + } + const sorted = Object.fromEntries(Object.entries(ledger).sort(([left], [right]) => left.localeCompare(right))) + const changesDir = path.join(workspaceDir, CHANGES_DIR) + await fs.mkdir(changesDir, { recursive: true }) + await fs.writeFile(path.join(changesDir, LEDGER_FILENAME), yaml.stringify(sorted), 'utf8') + return sorted +} + +export interface PackageConsumption { + /** Intent ids the ledger records against any released version of the project. */ + allIds: Set + /** Intent ids recorded only against prerelease versions of the project. */ + prereleaseOnlyIds: Set +} + +/** + * Indexes the ledger by workspace-relative project directory in a single + * pass. Bare id-list entries carry no directory, so their project is + * resolved from the entry key's package name via `resolveNameDirs`; a name + * matching several projects cannot be attributed and is an error — write + * such entries in the `dir`/`intents` shape instead. Entries whose name no + * longer exists in the workspace are inert. Projects without entries map to + * an empty consumption, so lookups never miss. + */ +export function buildConsumptionIndex ( + ledger: Ledger, + resolveNameDirs: (pkgName: string) => string[] +): (projectDir: string) => PackageConsumption { + const stableIdsByDir = new Map>() + const prereleaseIdsByDir = new Map>() + for (const [key, entry] of Object.entries(ledger)) { + const atIndex = key.lastIndexOf('@') + if (atIndex <= 0) continue + const version = key.slice(atIndex + 1) + let dir: string + if (Array.isArray(entry)) { + const pkgName = key.slice(0, atIndex) + const dirs = resolveNameDirs(pkgName) + if (dirs.length === 0) continue + if (dirs.length > 1) { + throw new PnpmError( + 'INVALID_VERSIONING_LEDGER', + `The ledger entry ${key} names ${pkgName}, which matches multiple workspace projects (${dirs.join(', ')}). Rewrite the entry with an explicit "dir".` + ) + } + dir = dirs[0] + } else { + dir = normalizeProjectDir(entry.dir) + } + // Build metadata (after "+") may itself contain hyphens and never makes a + // version a prerelease. + const isPrerelease = version.split('+')[0].includes('-') + const byDir = isPrerelease ? prereleaseIdsByDir : stableIdsByDir + let idSet = byDir.get(dir) + if (idSet == null) { + idSet = new Set() + byDir.set(dir, idSet) + } + for (const id of ledgerEntryIds(entry)) { + idSet.add(id) + } + } + + const consumptionByDir = new Map() + for (const dir of new Set([...stableIdsByDir.keys(), ...prereleaseIdsByDir.keys()])) { + const stableIds = stableIdsByDir.get(dir) ?? new Set() + const prereleaseIds = prereleaseIdsByDir.get(dir) ?? new Set() + consumptionByDir.set(dir, { + allIds: new Set([...stableIds, ...prereleaseIds]), + prereleaseOnlyIds: new Set([...prereleaseIds].filter((id) => !stableIds.has(id))), + }) + } + return (projectDir) => consumptionByDir.get(projectDir) ?? { allIds: new Set(), prereleaseOnlyIds: new Set() } +} + +/** + * The canonical spelling of a workspace-relative project directory: forward + * slashes, no leading `./`, no trailing slash. + */ +export function normalizeProjectDir (dir: string): string { + let normalized = dir.replaceAll('\\', '/') + while (normalized.startsWith('./')) { + normalized = normalized.slice(2) + } + return normalized.replace(/\/+$/, '') +} diff --git a/pnpm11/releasing/versioning/test/assembleReleasePlan.ts b/pnpm11/releasing/versioning/test/assembleReleasePlan.ts new file mode 100644 index 0000000000..17b365a632 --- /dev/null +++ b/pnpm11/releasing/versioning/test/assembleReleasePlan.ts @@ -0,0 +1,417 @@ +import { expect, test } from '@jest/globals' +import { + assembleReleasePlan, + type ChangeIntent, + type Ledger, + materializeWorkspaceRange, + type WorkspaceProject, +} from '@pnpm/releasing.versioning' + +function makeProject (name: string, version: string, deps?: Record): WorkspaceProject { + return { + rootDir: `/ws/${name}`, + manifest: { name, version, dependencies: deps }, + } +} + +function makeIntent (id: string, releases: ChangeIntent['releases'], summary = `summary of ${id}`): ChangeIntent { + return { id, filePath: `/ws/.changeset/${id}.md`, releases, summary } +} + +const NO_LEDGER: Ledger = {} + +test('direct bumps: the highest pending bump type wins per package', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('a', '1.0.0'), makeProject('b', '2.3.4')], + intents: [ + makeIntent('one', { a: 'patch', b: 'minor' }), + makeIntent('two', { a: 'minor' }), + ], + ledger: NO_LEDGER, + }) + expect(plan.releases).toHaveLength(2) + expect(plan.releases.find((release) => release.name === 'a')).toMatchObject({ newVersion: '1.1.0', bumpType: 'minor' }) + expect(plan.releases.find((release) => release.name === 'b')).toMatchObject({ newVersion: '2.4.0', bumpType: 'minor' }) +}) + +test('intents already recorded in the ledger are not consumed again', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('a', '1.0.1')], + intents: [makeIntent('one', { a: 'patch' })], + ledger: { 'a@1.0.1': ['one'] }, + }) + expect(plan.releases).toHaveLength(0) +}) + +test('a none bump type releases nothing', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('a', '1.0.0')], + intents: [makeIntent('one', { a: 'none' }, 'refactor, no release needed')], + ledger: NO_LEDGER, + }) + expect(plan.releases).toHaveLength(0) +}) + +test('dependent propagation follows the materialized workspace range', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [ + makeProject('lib', '1.2.0'), + makeProject('cli', '3.0.0', { lib: 'workspace:^' }), + ], + intents: [makeIntent('one', { lib: 'major' })], + ledger: NO_LEDGER, + }) + const cli = plan.releases.find((release) => release.name === 'cli') + expect(cli).toMatchObject({ newVersion: '3.0.1', bumpType: 'patch' }) + expect(cli!.dependencyUpdates).toStrictEqual([{ name: 'lib', newVersion: '2.0.0' }]) +}) + +test('a minor bump does not propagate through workspace:^ on a 1.x dependency', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [ + makeProject('lib', '1.2.0'), + makeProject('cli', '3.0.0', { lib: 'workspace:^' }), + ], + intents: [makeIntent('one', { lib: 'minor' })], + ledger: NO_LEDGER, + }) + expect(plan.releases.map((release) => release.name)).toStrictEqual(['lib']) +}) + +test('a minor bump propagates through workspace:^ on a 0.x dependency', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [ + makeProject('lib', '0.2.0'), + makeProject('cli', '3.0.0', { lib: 'workspace:^' }), + ], + intents: [makeIntent('one', { lib: 'minor' })], + ledger: NO_LEDGER, + }) + expect(plan.releases.map((release) => release.name).sort()).toStrictEqual(['cli', 'lib']) +}) + +test('propagation cascades through chains of dependents', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [ + makeProject('core', '1.0.0'), + makeProject('mid', '1.0.0', { core: 'workspace:*' }), + makeProject('top', '1.0.0', { mid: 'workspace:*' }), + ], + intents: [makeIntent('one', { core: 'patch' })], + ledger: NO_LEDGER, + }) + expect(plan.releases.map((release) => release.name).sort()).toStrictEqual(['core', 'mid', 'top']) +}) + +test('fixed groups release together at one shared version', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('a', '1.2.0'), makeProject('b', '1.0.5')], + intents: [makeIntent('one', { a: 'minor' })], + ledger: NO_LEDGER, + versioning: { fixed: [['a', 'b']] }, + }) + expect(plan.releases.find((release) => release.name === 'a')!.newVersion).toBe('1.3.0') + expect(plan.releases.find((release) => release.name === 'b')!.newVersion).toBe('1.3.0') +}) + +test('ignored packages neither release nor propagate', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [ + makeProject('lib', '1.0.0'), + makeProject('frozen', '1.0.0', { lib: 'workspace:*' }), + ], + intents: [makeIntent('one', { lib: 'major' })], + ledger: NO_LEDGER, + versioning: { ignore: ['frozen'] }, + }) + expect(plan.releases.map((release) => release.name)).toStrictEqual(['lib']) +}) + +test('an internal dependency without the workspace protocol fails a release, but not a read-only assemble', () => { + const projects = [ + makeProject('lib', '1.0.0'), + makeProject('cli', '1.0.0', { lib: '^1.0.0' }), + ] + // enforceWorkspaceProtocol off (the default, used by `pnpm change status`): + // a read-only assemble never fails on an unmigrated dependency. + expect(assembleReleasePlan({ workspaceDir: '/ws', projects, intents: [], ledger: NO_LEDGER }).releases).toHaveLength(0) + // The release path enforces the prerequisite. + expect(() => assembleReleasePlan({ + workspaceDir: '/ws', + projects, + intents: [], + ledger: NO_LEDGER, + enforceWorkspaceProtocol: true, + })).toThrow(/workspace: protocol/) +}) + +test('an npm: alias colliding with a workspace package name is not an internal dependency', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [ + makeProject('lib', '1.0.0'), + makeProject('cli', '1.0.0', { lib: 'npm:some-fork@^1.0.0' }), + ], + intents: [makeIntent('one', { lib: 'major' })], + ledger: NO_LEDGER, + }) + expect(plan.releases.map((release) => release.name)).toStrictEqual(['lib']) +}) + +test('an intent demanding a release of an unreleasable package fails the plan', () => { + expect(() => assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('lib', '1.0.0'), makeProject('frozen', '1.0.0')], + intents: [makeIntent('one', { frozen: 'patch', lib: 'patch' })], + ledger: NO_LEDGER, + versioning: { ignore: ['frozen'] }, + })).toThrow(/cannot release/) +}) + +test('a none decline for an unreleasable package is accepted', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('lib', '1.0.0'), makeProject('frozen', '1.0.0')], + intents: [makeIntent('one', { frozen: 'none', lib: 'patch' })], + ledger: NO_LEDGER, + versioning: { ignore: ['frozen'] }, + }) + expect(plan.releases.map((release) => release.name)).toStrictEqual(['lib']) +}) + +test('maxBump measures the real version distance, including fixed-group jumps', () => { + expect(() => assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('a', '1.0.5'), makeProject('b', '2.0.0')], + intents: [makeIntent('one', { a: 'minor' })], + ledger: NO_LEDGER, + versioning: { fixed: [['a', 'b']], maxBump: 'minor' }, + })).toThrow(/maxBump/) +}) + +test('an intent naming an unknown package fails the plan', () => { + expect(() => assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('lib', '1.0.0')], + intents: [makeIntent('one', { ghost: 'patch' })], + ledger: NO_LEDGER, + })).toThrow(/not a package in this workspace/) +}) + +test('maxBump rejects a plan whose effective bump exceeds the cap', () => { + expect(() => assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('lib', '1.0.0')], + intents: [makeIntent('one', { lib: 'minor' })], + ledger: NO_LEDGER, + versioning: { maxBump: 'patch' }, + })).toThrow(/maxBump/) +}) + +test('two same-named projects releasing to the same version is a hard error', () => { + const twins = [ + { rootDir: '/ws/a/util', manifest: { name: '@scope/util', version: '1.0.0' } }, + { rootDir: '/ws/b/util', manifest: { name: '@scope/util', version: '1.0.0' } }, + ] + expect(() => assembleReleasePlan({ + workspaceDir: '/ws', + projects: twins, + intents: [makeIntent('one', { './a/util': 'patch', './b/util': 'patch' })], + ledger: NO_LEDGER, + })).toThrow(/Two projects both release @scope\/util@1\.0\.1/) +}) + +test('a package on a lane emits tagged versions with an incrementing counter', () => { + const enterPlan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('cli', '2.0.0')], + intents: [makeIntent('one', { cli: 'minor' })], + ledger: NO_LEDGER, + versioning: { lanes: { cli: 'alpha' } }, + }) + expect(enterPlan.releases[0].newVersion).toBe('2.1.0-alpha.0') + + const nextPlan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('cli', '2.1.0-alpha.0')], + intents: [ + makeIntent('one', { cli: 'minor' }), + makeIntent('two', { cli: 'patch' }), + ], + ledger: { 'cli@2.1.0-alpha.0': ['one'] }, + versioning: { lanes: { cli: 'alpha' } }, + }) + expect(nextPlan.releases[0].newVersion).toBe('2.1.0-alpha.1') +}) + +test('a bigger bump landing later escalates the stable target of the lane', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('cli', '2.1.0-alpha.1')], + intents: [ + makeIntent('one', { cli: 'minor' }), + makeIntent('two', { cli: 'major' }), + ], + ledger: { 'cli@2.1.0-alpha.0': ['one'], 'cli@2.1.0-alpha.1': [] }, + versioning: { lanes: { cli: 'alpha' } }, + }) + expect(plan.releases[0].newVersion).toBe('3.0.0-alpha.0') +}) + +test('packages on the main lane release stable versions from the same run', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('cli', '2.0.0'), makeProject('lib', '1.0.0')], + intents: [makeIntent('one', { cli: 'minor', lib: 'minor' })], + ledger: NO_LEDGER, + versioning: { lanes: { cli: 'alpha' } }, + }) + expect(plan.releases.find((release) => release.name === 'cli')!.newVersion).toBe('2.1.0-alpha.0') + expect(plan.releases.find((release) => release.name === 'lib')!.newVersion).toBe('1.1.0') +}) + +test('returning to the main lane releases the accumulated stable version even without pending intents', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('cli', '2.1.0-alpha.2')], + intents: [ + makeIntent('one', { cli: 'minor' }), + makeIntent('two', { cli: 'patch' }), + ], + ledger: { 'cli@2.1.0-alpha.0': ['one'], 'cli@2.1.0-alpha.2': ['two'] }, + versioning: {}, + }) + expect(plan.releases).toHaveLength(1) + const release = plan.releases[0] + expect(release.newVersion).toBe('2.1.0') + expect(release.intents.map((intent) => intent.id).sort()).toStrictEqual(['one', 'two']) +}) + +test('an intent naming a main-lane and a lane package is consumed half by half', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('cli', '2.0.0'), makeProject('lib', '1.0.1')], + intents: [makeIntent('one', { cli: 'minor', lib: 'patch' })], + ledger: { 'lib@1.0.1': ['one'] }, + versioning: { lanes: { cli: 'alpha' } }, + }) + expect(plan.releases.map((release) => release.name)).toStrictEqual(['cli']) + expect(plan.releases[0].newVersion).toBe('2.1.0-alpha.0') +}) + +test('snapshot plans release the same set under snapshot versions', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [ + makeProject('lib', '1.0.0'), + makeProject('cli', '1.0.0', { lib: 'workspace:*' }), + ], + intents: [makeIntent('one', { lib: 'patch' })], + ledger: NO_LEDGER, + snapshotSuffix: 'preview-20260712000000', + }) + expect(plan.releases.map((release) => release.newVersion)).toStrictEqual([ + '0.0.0-preview-20260712000000', + '0.0.0-preview-20260712000000', + ]) +}) + +test('filter narrows the plan to the selection plus its fixed companions and invalidated dependents', () => { + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: [ + makeProject('lib', '1.0.0'), + makeProject('cli', '1.0.0', { lib: 'workspace:*' }), + makeProject('unrelated', '1.0.0'), + ], + intents: [ + makeIntent('one', { lib: 'patch' }), + makeIntent('two', { unrelated: 'major' }), + ], + ledger: NO_LEDGER, + filter: new Set(['lib']), + }) + expect(plan.releases.map((release) => release.name).sort()).toStrictEqual(['cli', 'lib']) +}) + +test('a name shared by two projects is ambiguous and must be referenced by directory', () => { + const twins = [ + { rootDir: '/ws/pnpm11/pnpm', manifest: { name: 'pnpm', version: '11.0.0' } }, + { rootDir: '/ws/pnpm/npm/pnpm', manifest: { name: 'pnpm', version: '12.0.0' } }, + ] + expect(() => assembleReleasePlan({ + workspaceDir: '/ws', + projects: twins, + intents: [makeIntent('one', { pnpm: 'patch' })], + ledger: NO_LEDGER, + })).toThrow(/matches multiple workspace projects/) + + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: twins, + intents: [makeIntent('one', { './pnpm/npm/pnpm': 'patch' })], + ledger: NO_LEDGER, + }) + expect(plan.releases).toHaveLength(1) + expect(plan.releases[0].dir).toBe('pnpm/npm/pnpm') + expect(plan.releases[0].newVersion).toBe('12.0.1') +}) + +test('ledger consumption attributes by directory when names collide', () => { + const twins = [ + { rootDir: '/ws/pnpm11/pnpm', manifest: { name: 'pnpm', version: '11.0.0' } }, + { rootDir: '/ws/pnpm/npm/pnpm', manifest: { name: 'pnpm', version: '12.0.0' } }, + ] + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: twins, + intents: [makeIntent('one', { './pnpm11/pnpm': 'patch', './pnpm/npm/pnpm': 'patch' })], + ledger: { 'pnpm@12.0.1': { dir: 'pnpm/npm/pnpm', intents: ['one'] } }, + }) + // The Rust line already consumed the intent; only the TS line still releases. + expect(plan.releases.map((release) => release.dir)).toStrictEqual(['pnpm11/pnpm']) +}) + +test('lanes keyed by directory path apply to the right twin', () => { + const twins = [ + { rootDir: '/ws/pnpm11/pnpm', manifest: { name: 'pnpm', version: '11.0.0' } }, + { rootDir: '/ws/pnpm/npm/pnpm', manifest: { name: 'pnpm', version: '12.0.0' } }, + ] + const plan = assembleReleasePlan({ + workspaceDir: '/ws', + projects: twins, + intents: [makeIntent('one', { './pnpm11/pnpm': 'patch', './pnpm/npm/pnpm': 'minor' })], + ledger: NO_LEDGER, + versioning: { lanes: { './pnpm/npm/pnpm': 'alpha' } }, + }) + expect(plan.releases.find((release) => release.dir === 'pnpm11/pnpm')!.newVersion).toBe('11.0.1') + expect(plan.releases.find((release) => release.dir === 'pnpm/npm/pnpm')!.newVersion).toBe('12.1.0-alpha.0') +}) + +test('a lane named main is rejected: it is the reserved default lane', () => { + expect(() => assembleReleasePlan({ + workspaceDir: '/ws', + projects: [makeProject('cli', '2.0.0')], + intents: [], + ledger: NO_LEDGER, + versioning: { lanes: { cli: 'Main' } }, + })).toThrow(/reserved default lane/) +}) + +test('materializeWorkspaceRange mirrors pack-time materialization', () => { + expect(materializeWorkspaceRange('workspace:*', '1.2.3')).toBe('1.2.3') + expect(materializeWorkspaceRange('workspace:^', '1.2.3')).toBe('^1.2.3') + expect(materializeWorkspaceRange('workspace:~', '1.2.3')).toBe('~1.2.3') + expect(materializeWorkspaceRange('workspace:^1.0.0', '1.2.3')).toBe('^1.0.0') + expect(materializeWorkspaceRange('workspace:lib@^', '1.2.3')).toBe('^1.2.3') + expect(materializeWorkspaceRange('^1.0.0', '1.2.3')).toBeNull() +}) diff --git a/pnpm11/releasing/versioning/test/lifecycle.ts b/pnpm11/releasing/versioning/test/lifecycle.ts new file mode 100644 index 0000000000..fd59521f56 --- /dev/null +++ b/pnpm11/releasing/versioning/test/lifecycle.ts @@ -0,0 +1,177 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import { expect, test } from '@jest/globals' +import { + applyReleasePlan, + assembleReleasePlan, + parseChangeIntent, + prependChangelogSection, + readChangeIntents, + readLedger, + type WorkspaceProject, + writeChangeIntent, +} from '@pnpm/releasing.versioning' +import { temporaryDirectory } from 'tempy' + +async function makeWorkspace (pkgs: Array<{ name: string, version: string, deps?: Record }>): Promise<{ workspaceDir: string, projects: WorkspaceProject[] }> { + const workspaceDir = temporaryDirectory() + const projects = await Promise.all(pkgs.map(async (pkg) => { + const rootDir = path.join(workspaceDir, pkg.name.replace(/[@/]/g, '_')) + await fs.mkdir(rootDir, { recursive: true }) + const manifest = { name: pkg.name, version: pkg.version, dependencies: pkg.deps } + await fs.writeFile(path.join(rootDir, 'package.json'), JSON.stringify(manifest, null, 2) + '\n') + return { rootDir, manifest } + })) + return { workspaceDir, projects } +} + +test('parseChangeIntent reads the changesets file format', () => { + const intent = parseChangeIntent([ + '---', + '"@example/ui": minor', + '"@example/core": patch', + '---', + '', + 'Added a `variant` prop to `Button`.', + '', + ].join('\n'), 'brave-pandas-smile', '/x/brave-pandas-smile.md') + expect(intent.releases).toStrictEqual({ '@example/ui': 'minor', '@example/core': 'patch' }) + expect(intent.summary).toBe('Added a `variant` prop to `Button`.') +}) + +test('parseChangeIntent tolerates a UTF-8 BOM and CRLF line endings', () => { + const intent = parseChangeIntent('\uFEFF---\r\nfoo: patch\r\n---\r\n\r\nA fix.\r\n', 'id', '/x/id.md') + expect(intent.releases).toStrictEqual({ foo: 'patch' }) + expect(intent.summary).toBe('A fix.') +}) + +test('prependChangelogSection keeps the title above the new section even without a trailing newline', async () => { + const dir = temporaryDirectory() + await fs.writeFile(path.join(dir, 'CHANGELOG.md'), '# lib') + await prependChangelogSection(dir, 'lib', '## 1.0.1\n\n### Patch Changes\n\n- A fix.\n') + const changelog = await fs.readFile(path.join(dir, 'CHANGELOG.md'), 'utf8') + expect(changelog.startsWith('# lib\n\n## 1.0.1')).toBe(true) +}) + +test('parseChangeIntent rejects an invalid bump type', () => { + expect(() => parseChangeIntent('---\nfoo: gigantic\n---\nx', 'id', '/x/id.md')).toThrow(/invalid bump type/) +}) + +test('writeChangeIntent output round-trips through readChangeIntents', async () => { + const workspaceDir = temporaryDirectory() + const id = await writeChangeIntent(workspaceDir, { + releases: { '@example/ui': 'minor' }, + summary: 'Added a thing.', + }) + const intents = await readChangeIntents(workspaceDir) + expect(intents).toHaveLength(1) + expect(intents[0].id).toBe(id) + expect(intents[0].releases).toStrictEqual({ '@example/ui': 'minor' }) + expect(intents[0].summary).toBe('Added a thing.') +}) + +test('applyReleasePlan bumps manifests, writes changelogs, records the ledger, and deletes consumed intents', async () => { + const { workspaceDir, projects } = await makeWorkspace([ + { name: 'lib', version: '1.0.0' }, + { name: 'cli', version: '2.0.0', deps: { lib: 'workspace:*' } }, + ]) + await writeChangeIntent(workspaceDir, { + releases: { lib: 'minor' }, + summary: 'Added a feature.', + }) + const intents = await readChangeIntents(workspaceDir) + const plan = assembleReleasePlan({ workspaceDir, projects, intents, ledger: await readLedger(workspaceDir) }) + + const applied = await applyReleasePlan(plan, { workspaceDir, projects, allIntents: intents }) + expect(applied.map((release) => `${release.name}@${release.newVersion}`).sort()).toStrictEqual(['cli@2.0.1', 'lib@1.1.0']) + + const libDir = projects.find((project) => project.manifest.name === 'lib')!.rootDir + const cliDir = projects.find((project) => project.manifest.name === 'cli')!.rootDir + expect(JSON.parse(await fs.readFile(path.join(libDir, 'package.json'), 'utf8')).version).toBe('1.1.0') + expect(JSON.parse(await fs.readFile(path.join(cliDir, 'package.json'), 'utf8')).version).toBe('2.0.1') + + const libChangelog = await fs.readFile(path.join(libDir, 'CHANGELOG.md'), 'utf8') + expect(libChangelog).toContain('# lib') + expect(libChangelog).toContain('## 1.1.0') + expect(libChangelog).toContain('### Minor Changes') + expect(libChangelog).toContain('- Added a feature.') + + const cliChangelog = await fs.readFile(path.join(cliDir, 'CHANGELOG.md'), 'utf8') + expect(cliChangelog).toContain('## 2.0.1') + expect(cliChangelog).toContain('- Updated dependencies:') + expect(cliChangelog).toContain(' - lib@1.1.0') + + const ledger = await readLedger(workspaceDir) + expect(Object.keys(ledger)).toStrictEqual(['lib@1.1.0']) + + expect(await readChangeIntents(workspaceDir)).toHaveLength(0) +}) + +test('intent files consumed only by lane prereleases survive until graduation', async () => { + const { workspaceDir, projects } = await makeWorkspace([ + { name: 'cli', version: '2.0.0' }, + ]) + await writeChangeIntent(workspaceDir, { + releases: { cli: 'minor' }, + summary: 'Added a feature.', + }) + const versioning = { lanes: { cli: 'alpha' } } + + let intents = await readChangeIntents(workspaceDir) + const prereleasePlan = assembleReleasePlan({ workspaceDir, projects, intents, ledger: await readLedger(workspaceDir), versioning }) + expect(prereleasePlan.releases[0].newVersion).toBe('2.1.0-alpha.0') + await applyReleasePlan(prereleasePlan, { workspaceDir, projects, allIntents: intents, versioning }) + + // The prose is still needed for the stable changelog at graduation. + intents = await readChangeIntents(workspaceDir) + expect(intents).toHaveLength(1) + + // Return to the main lane: the accumulated stable version releases and the + // intent is garbage-collected. + const graduatedProjects: WorkspaceProject[] = [{ + rootDir: projects[0].rootDir, + manifest: { name: 'cli', version: '2.1.0-alpha.0' }, + }] + const graduationPlan = assembleReleasePlan({ workspaceDir, projects: graduatedProjects, intents, ledger: await readLedger(workspaceDir), versioning: {} }) + expect(graduationPlan.releases[0].newVersion).toBe('2.1.0') + await applyReleasePlan(graduationPlan, { workspaceDir, projects: graduatedProjects, allIntents: intents, versioning: {} }) + + const changelog = await fs.readFile(path.join(projects[0].rootDir, 'CHANGELOG.md'), 'utf8') + expect(changelog).toContain('## 2.1.0-alpha.0') + expect(changelog).toContain('## 2.1.0') + expect(await readChangeIntents(workspaceDir)).toHaveLength(0) +}) + +test('a ledger entry named __proto__ stays an own key and cannot pollute the prototype', async () => { + const workspaceDir = temporaryDirectory() + await fs.mkdir(path.join(workspaceDir, '.changeset')) + await fs.writeFile(path.join(workspaceDir, '.changeset', 'ledger.yaml'), '__proto__:\n - sneaky\nlib@1.0.1:\n - one\n') + const ledger = await readLedger(workspaceDir) + expect(({} as Record).sneaky).toBeUndefined() + expect(Object.keys(ledger).sort()).toStrictEqual(['__proto__', 'lib@1.0.1']) +}) + +test('a none-only intent is garbage-collected by a run with an empty plan', async () => { + const { workspaceDir, projects } = await makeWorkspace([ + { name: 'lib', version: '1.0.0' }, + ]) + await writeChangeIntent(workspaceDir, { releases: { lib: 'none' }, summary: 'refactor, no release needed' }) + const intents = await readChangeIntents(workspaceDir) + const plan = assembleReleasePlan({ workspaceDir, projects, intents, ledger: await readLedger(workspaceDir) }) + expect(plan.releases).toHaveLength(0) + await applyReleasePlan(plan, { workspaceDir, projects, allIntents: intents }) + expect(await readChangeIntents(workspaceDir)).toHaveLength(0) +}) + +test('a merge-resurrected intent whose id is already in the ledger stays inert and is garbage-collected', async () => { + const { workspaceDir, projects } = await makeWorkspace([ + { name: 'lib', version: '1.0.1' }, + ]) + await writeChangeIntent(workspaceDir, { releases: { lib: 'patch' }, summary: 'A fix.' }) + const intents = await readChangeIntents(workspaceDir) + // Simulate the entry arriving from another line's release via a merge. + const ledger = { ['lib@1.0.1']: [intents[0].id] } + const plan = assembleReleasePlan({ workspaceDir, projects, intents, ledger }) + expect(plan.releases).toHaveLength(0) +}) diff --git a/pnpm11/releasing/versioning/test/tsconfig.json b/pnpm11/releasing/versioning/test/tsconfig.json new file mode 100644 index 0000000000..67ce5e1d0e --- /dev/null +++ b/pnpm11/releasing/versioning/test/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "../node_modules/.test.lib", + "rootDir": "..", + "isolatedModules": true + }, + "include": [ + "**/*.ts", + "../../../__typings__/**/*.d.ts" + ], + "references": [ + { + "path": ".." + } + ] +} diff --git a/pnpm11/releasing/versioning/tsconfig.json b/pnpm11/releasing/versioning/tsconfig.json new file mode 100644 index 0000000000..bdea996d5c --- /dev/null +++ b/pnpm11/releasing/versioning/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "@pnpm/tsconfig", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts", + "../../__typings__/**/*.d.ts" + ], + "references": [ + { + "path": "../../core/error" + }, + { + "path": "../../core/types" + }, + { + "path": "../../workspace/project-manifest-reader" + }, + { + "path": "../../workspace/spec-parser" + } + ] +} diff --git a/pnpm11/releasing/versioning/tsconfig.lint.json b/pnpm11/releasing/versioning/tsconfig.lint.json new file mode 100644 index 0000000000..1bbe711971 --- /dev/null +++ b/pnpm11/releasing/versioning/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../../__typings__/**/*.d.ts" + ] +} diff --git a/pnpm11/workspace/projects-filter/src/index.ts b/pnpm11/workspace/projects-filter/src/index.ts index 0bf24faa5a..e648e1c5ef 100644 --- a/pnpm11/workspace/projects-filter/src/index.ts +++ b/pnpm11/workspace/projects-filter/src/index.ts @@ -11,6 +11,7 @@ import { getChangedProjects } from './getChangedProjects.js' import { parseProjectSelector, type ProjectSelector } from './parseProjectSelector.js' export { filterProjectsBySelectorObjectsFromDir, parseProjectSelector, type ProjectSelector } +export { getChangedProjects } from './getChangedProjects.js' export interface WorkspaceFilter { filter: string diff --git a/pnpm11/workspace/workspace-manifest-reader/src/index.ts b/pnpm11/workspace/workspace-manifest-reader/src/index.ts index 4760b01637..7adbb010bb 100644 --- a/pnpm11/workspace/workspace-manifest-reader/src/index.ts +++ b/pnpm11/workspace/workspace-manifest-reader/src/index.ts @@ -12,6 +12,7 @@ import { type WorkspaceNamedCatalogs, } from './catalogs.js' import { InvalidWorkspaceManifestError } from './errors/InvalidWorkspaceManifestError.js' +import { assertValidWorkspaceManifestVersioning } from './versioning.js' export type ConfigFileName = | typeof GLOBAL_CONFIG_YAML_FILENAME @@ -76,6 +77,7 @@ export function validateWorkspaceManifest (manifest: unknown): asserts manifest assertValidWorkspaceManifestPackages(manifest) assertValidWorkspaceManifestCatalog(manifest) assertValidWorkspaceManifestCatalogs(manifest) + assertValidWorkspaceManifestVersioning(manifest) checkWorkspaceManifestAssignability(manifest) } diff --git a/pnpm11/workspace/workspace-manifest-reader/src/versioning.ts b/pnpm11/workspace/workspace-manifest-reader/src/versioning.ts new file mode 100644 index 0000000000..1fc210b9cb --- /dev/null +++ b/pnpm11/workspace/workspace-manifest-reader/src/versioning.ts @@ -0,0 +1,69 @@ +import type { VersioningSettings } from '@pnpm/types' + +import { InvalidWorkspaceManifestError } from './errors/InvalidWorkspaceManifestError.js' + +const BUMP_TYPES = ['patch', 'minor', 'major'] as const +const CHANGELOG_STORAGE_MODES = ['registry', 'repository'] as const + +export function assertValidWorkspaceManifestVersioning (manifest: { packages?: readonly string[], versioning?: unknown }): asserts manifest is { versioning?: VersioningSettings } { + if (manifest.versioning == null) { + return + } + + const versioning = assertPlainObject(manifest.versioning, 'versioning') + + if (versioning.fixed != null) { + if (!Array.isArray(versioning.fixed)) { + throw new InvalidWorkspaceManifestError(`Expected versioning.fixed to be an array of arrays, but found - ${typeof versioning.fixed}`) + } + for (const group of versioning.fixed) { + if (!Array.isArray(group) || group.some((name) => typeof name !== 'string' || name === '')) { + throw new InvalidWorkspaceManifestError('Expected every versioning.fixed group to be an array of package names') + } + } + } + + if (versioning.ignore != null) { + if (!Array.isArray(versioning.ignore) || versioning.ignore.some((name) => typeof name !== 'string' || name === '')) { + throw new InvalidWorkspaceManifestError('Expected versioning.ignore to be an array of package names') + } + } + + if (versioning.maxBump != null) { + if (!(BUMP_TYPES as readonly unknown[]).includes(versioning.maxBump)) { + throw new InvalidWorkspaceManifestError(`Expected versioning.maxBump to be one of ${BUMP_TYPES.join(', ')}, but found - ${String(versioning.maxBump)}`) + } + } + + if (versioning.lanes != null) { + const lanes = assertPlainObject(versioning.lanes, 'versioning.lanes') + for (const [pkgName, lane] of Object.entries(lanes)) { + if (typeof lane !== 'string' || lane === '') { + throw new InvalidWorkspaceManifestError(`Expected versioning.lanes entry for ${pkgName} to be a non-empty lane name`) + } + if (lane.toLowerCase() === 'main') { + throw new InvalidWorkspaceManifestError(`Invalid versioning.lanes entry for ${pkgName}: "main" is the reserved default lane. Remove the entry instead.`) + } + } + } + + if (versioning.changelog != null) { + const changelog = assertPlainObject(versioning.changelog, 'versioning.changelog') + if (changelog.format != null && typeof changelog.format !== 'string') { + throw new InvalidWorkspaceManifestError(`Expected versioning.changelog.format to be a string, but found - ${typeof changelog.format}`) + } + if (changelog.storage != null && !(CHANGELOG_STORAGE_MODES as readonly unknown[]).includes(changelog.storage)) { + throw new InvalidWorkspaceManifestError(`Expected versioning.changelog.storage to be one of ${CHANGELOG_STORAGE_MODES.join(', ')}, but found - ${String(changelog.storage)}`) + } + } +} + +function assertPlainObject (value: unknown, fieldName: string): Record { + if (Array.isArray(value)) { + throw new InvalidWorkspaceManifestError(`Expected ${fieldName} field to be an object, but found - array`) + } + if (typeof value !== 'object' || value === null) { + throw new InvalidWorkspaceManifestError(`Expected ${fieldName} field to be an object, but found - ${value === null ? 'null' : typeof value}`) + } + return value as Record +}