feat(releasing): native workspace release management in both stacks — change intents, release plans, per-package release lanes (#12953)

Implements phases 1-2 of the native monorepo versioning plan
(https://github.com/pnpm/pnpm/issues/12952, RFC https://github.com/pnpm/rfcs/pull/18)
in both stacks.

TypeScript: new @pnpm/releasing.versioning package (intent reader/writer for
changesets-compatible .changeset/*.md files with the additive 'none' decline;
the committed per-package consumed-intents ledger .changeset/ledger.yaml;
the release-plan assembler with direct bumps, dependent propagation via
materialized workspace: ranges under real semver semantics, fixed groups,
ignore, maxBump enforced against the real version distance, --filter
narrowing, per-package release lanes with cumulative stable-target
escalation and graduation; the applier with format-preserving
version-only manifest updates, changelog composition in repository storage
mode, ledger append, and intent GC with the prerelease retention exemption).
CLI: pnpm change / change status; bare pnpm version -r (--dry-run,
the new root pnpm lane command manages
versioning.lanes through the format-preserving workspace manifest writer. The versioning key is typed on PnpmSettings/Config and validated by
the workspace manifest reader.

Rust: new pacquet-versioning crate mirroring the engine (same file formats,
error codes, messages, and output), versioning settings on
WorkspaceSettings/Config, and new change/version/lane commands in pacquet-cli.
The npm-style pnpm version <bump> forms remain unported in pacquet (they
did not exist there before) and error with a clear message. The two deploy
install futures are boxed because Config grew past clippy's large-future
threshold.

Both stacks' engine test suites mirror each other one for one; integration
tests drive the real binaries through record -> plan -> release ->
lane -> graduation.
This commit is contained in:
Zoltan Kochan
2026-07-12 21:55:49 +02:00
committed by GitHub
parent 48641d9750
commit 826366b7bc
67 changed files with 7207 additions and 15 deletions

View File

@@ -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 <name> --filter <pkg>` command and back with `pnpm lane main --filter <pkg>` (`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`.

20
Cargo.lock generated
View File

@@ -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"

View File

@@ -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" }

View File

@@ -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",

52
pnpm-lock.yaml generated
View File

@@ -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':

View File

@@ -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

View File

@@ -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 }

View File

@@ -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;

View File

@@ -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<String>,
/// 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<String>,
/// The summary for the changelog entry. Runs non-interactively when
/// given together with package names.
#[clap(long)]
pub summary: Option<String>,
}
/// 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<String> },
#[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<String, IntentBumpType> = 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<IntentBumpType> {
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<String>,
) -> miette::Result<Vec<String>> {
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? (<space> to select, <enter> 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="[<ref>]"`.
/// 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<String> {
let Some(base_commit) = detect_base_commit(workspace_dir) else {
return HashSet::new();
};
let project_dirs: Vec<PathBuf> =
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<String> {
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<IndexMap<String, IntentBumpType>> {
let mut bump_by_ref: IndexMap<String, IntentBumpType> = IndexMap::new();
let mut remaining: Vec<String> = pkg_refs.to_vec();
for (label, bump_type) in [("major", IntentBumpType::Major), ("minor", IntentBumpType::Minor)] {
if remaining.is_empty() {
break;
}
let chosen: HashSet<usize> = MultiSelect::new()
.with_prompt(format!(
"Which packages should have a {label} bump? (<space> to select, <enter> 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<String> {
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<String> = 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<ReleasableProject> {
let refs = index_project_refs(projects, workspace_dir);
let ignored_dirs: HashSet<String> =
versioning.ignore.iter().flat_map(|reference| refs.ref_to_dirs(reference)).collect();
let mut releasable: Vec<ReleasableProject> = 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<WorkspaceProject> {
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()
}

View File

@@ -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),

View File

@@ -251,12 +251,14 @@ impl DeployArgs {
}
apply_deploy_hook(&deploy_dir.join("package.json"))?;
self.run_install_in_deploy_dir::<ReporterT>(
// Boxed: the install future exceeds clippy's large-future threshold
// (the captured `Config` is large).
Box::pin(self.run_install_in_deploy_dir::<ReporterT>(
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::<ReporterT>(
// Boxed for the same large-future reason as the legacy path above.
Box::pin(self.run_install_in_deploy_dir::<ReporterT>(
config,
deploy_dir,
DeployInstallMode::Shared { workspace_config: deploy_files.workspace_config },
true,
)
))
.await?;
Ok(SharedDeployOutcome::Deployed)
}

View File

@@ -301,6 +301,9 @@ fn route<'a>(command: CliCommand, ctx: &RunCtx<'a>) -> miette::Result<CommandFut
CliCommand::Update(args) => 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),

View File

@@ -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<CommandFuture<'a>> {
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<CommandFuture<'a>> {
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<CommandFuture<'a>> {
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<CommandFuture<'a>> {
let cfg: &Config = (ctx.config)()?;
Ok(Box::pin(async move {

View File

@@ -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-<lane>.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<String>,
}
/// 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 <pkg>...""#
)]
#[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<String> =
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<String, (String, String)> = 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, String>) -> 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
}

View File

@@ -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",

View File

@@ -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<String>,
/// 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::<Host>(&workspace_dir)
&& !is_working_tree_clean::<Host>(&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::<HashSet<String>>(),
)
};
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<Vec<(Option<String>, 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())
}

View File

@@ -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);
}

View File

@@ -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 }

View File

@@ -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.

View File

@@ -405,6 +405,11 @@ pub struct WorkspaceSettings {
/// `auditConfig` from `pnpm-workspace.yaml`.
pub audit_config: Option<AuditConfig>,
/// `versioning` from `pnpm-workspace.yaml`: native workspace release
/// management (fixed groups, ignore list, maxBump cap, per-package
/// prerelease lines, changelog settings).
pub versioning: Option<pacquet_versioning::VersioningSettings>,
/// `trustPolicyExclude` from `pnpm-workspace.yaml`.
pub trust_policy_exclude: Option<Vec<String>>,
@@ -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);
}

View File

@@ -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]

View File

@@ -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,

View File

@@ -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

View File

@@ -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<Vec<AppliedRelease>, 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, &section)?;
}
let mut new_entries = BTreeMap::new();
for release in &plan.releases {
if release.intents.is_empty() {
continue;
}
let mut ids: Vec<String> = 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<String> = 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;

View File

@@ -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<WorkspaceProject>,
}
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<String, serde_json::Value> = 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<String> =
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}");
}

View File

@@ -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<String>); 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<String> = 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<String, IntentBumpType>,
release: &PlannedRelease,
) -> Option<ReleaseBumpType> {
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
/// `# <name>` 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 })
}

View File

@@ -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<String> },
#[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<String> },
#[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<String> },
#[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),
}

View File

@@ -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()]
}

View File

@@ -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<ReleaseBumpType> {
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<IntentBumpType> {
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<String, IntentBumpType>,
pub summary: String,
}
pub fn parse_change_intent(
content: &str,
id: &str,
file_path: &Path,
) -> Result<ChangeIntent, VersioningError> {
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<String, String> = 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<Vec<ChangeIntent>, 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<String, IntentBumpType>,
summary: &str,
) -> Result<String, VersioningError> {
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<String> = 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;

View File

@@ -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.");
}

View File

@@ -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<String>),
Attributed { dir: String, intents: Vec<String> },
}
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
/// `<package name>@<released version>` 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<String, LedgerEntry>;
pub fn read_ledger(workspace_dir: &Path) -> Result<Ledger, VersioningError> {
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<String, (String, Vec<String>)>,
) -> Result<Ledger, VersioningError> {
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<String> =
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+0000U+001F and
// U+007FU+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<String>,
/// Intent ids recorded only against prerelease versions of the project.
pub prerelease_only_ids: HashSet<String>,
}
/// 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<String>,
) -> Result<HashMap<String, PackageConsumption>, VersioningError> {
let mut stable_ids_by_dir: HashMap<String, HashSet<String>> = HashMap::new();
let mut prerelease_ids_by_dir: HashMap<String, HashSet<String>> = 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<String> =
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;

View File

@@ -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""#);
}

View File

@@ -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:
//! <https://github.com/pnpm/rfcs/pull/18>.
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};

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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::<IndexMap<String, IntentBumpType>>(),
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);
}

View File

@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub storage: Option<ChangelogStorage>,
}
/// 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<Vec<String>>,
/// Packages permanently excluded from versioning and dependent
/// propagation.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub ignore: Vec<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.
#[serde(skip_serializing_if = "Option::is_none")]
pub max_bump: Option<ReleaseBumpType>,
/// 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<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub changelog: Option<ChangelogSettings>,
}
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()
}
}

View File

@@ -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 `[<since>]` 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<PathBuf>,
@@ -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<PathBuf>,
commit: &str,
opts: &GetChangedProjectsOptions<'_>,

View File

@@ -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;

View File

@@ -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<string, string>
hoist: boolean

View File

@@ -4,3 +4,4 @@ export * from './options.js'
export * from './package.js'
export * from './peerDependencyIssues.js'
export * from './project.js'
export * from './versioning.js'

View File

@@ -1,4 +1,5 @@
import type { Registries } from './misc.js'
import type { VersioningSettings } from './versioning.js'
export type Dependencies = Record<string, string>
@@ -201,6 +202,7 @@ export interface PnpmSettings {
httpsProxy?: string
noProxy?: string | boolean
pnprServer?: string
versioning?: VersioningSettings
}
export interface ProjectManifest extends BaseManifest {

View File

@@ -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<string, string>
changelog?: VersioningChangelogSettings
}

View File

@@ -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,

View File

@@ -313,7 +313,10 @@ export async function main (inputArgv: string[]): Promise<void> {
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
}

View File

@@ -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:",

View File

@@ -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<string, unknown> {
return {}
}
export function cliOptionsTypes (): Record<string, unknown> {
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 <type>] [--summary <text>] [<pkg>...]',
'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 <type>',
},
{
description: 'The summary for the changelog entry. Runs non-interactively when given together with package names',
name: '--summary <text>',
},
],
},
],
})
}
export type ChangeCommandOptions = Pick<Config,
| 'changedFilesIgnorePattern'
| 'dir'
| 'testPattern'
| 'versioning'
| 'workspaceDir'
> & {
allProjects?: Project[]
bump?: string
summary?: string
}
export async function handler (opts: ChangeCommandOptions, params: string[]): Promise<string> {
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<string> {
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<string[]> {
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<Separator | { value: string, name: string, checked?: boolean }>
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<string>({
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="[<ref>]"`.
* 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<Set<string>> {
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<string | undefined> {
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<Record<string, IntentBumpType>> {
const bumpByRef = new Map<string, IntentBumpType>()
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<string>({
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<string> {
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<Pick<Project, 'manifest' | 'rootDir'>>,
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<Pick<Project, 'manifest' | 'rootDir'>>): WorkspaceProject[] {
return allProjects.map((project) => ({ rootDir: project.rootDir, manifest: project.manifest }))
}
export type { ChangeIntent }
export const change = {
handler,
help,
commandNames,
cliOptionsTypes,
rcOptionsTypes,
recursiveByDefault: true,
}

View File

@@ -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'

View File

@@ -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<string, unknown> {
return {}
}
export function cliOptionsTypes (): Record<string, unknown> {
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-<lane>.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 <name> --filter <pattern>',
'pnpm lane main --filter <pattern>',
],
descriptionLists: [
{
title: 'Options',
list: [
{
description: 'Select the packages to move between lanes',
name: '--filter <pattern>',
},
],
},
],
})
}
export type LaneCommandOptions = Pick<Config,
| 'dir'
| 'filter'
| 'versioning'
| 'workspaceDir'
> & {
allProjects?: Project[]
selectedProjectsGraph?: ProjectsGraph
}
export async function handler (opts: LaneCommandOptions, params: string[]): Promise<string> {
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 <pkg>..."')
}
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<string, { key: string, lane: string }>()
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, string>): string {
const laneEntries = Object.entries(lanes)
if (laneEntries.length === 0) {
return 'All packages are on the main lane.'
}
const byLane = new Map<string, string[]>()
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,
}

View File

@@ -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<string, unknown> {
return pick([
'allow-same-version',
@@ -26,6 +35,7 @@ export function rcOptionsTypes (): Record<string, unknown> {
export function cliOptionsTypes (): Record<string, unknown> {
return {
...rcOptionsTypes(),
'dry-run': Boolean,
json: Boolean,
preid: String,
recursive: Boolean,
@@ -47,6 +57,7 @@ export function help (): string {
usages: [
'pnpm version <newversion>',
'pnpm version <major|minor|patch|premajor|preminor|prepatch|prerelease>',
'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<string> {
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,

View File

@@ -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<string, string>
}
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.')
})
})

View File

@@ -123,8 +123,14 @@
{
"path": "../../workspace/projects-sorter"
},
{
"path": "../../workspace/workspace-manifest-writer"
},
{
"path": "../exportable-manifest"
},
{
"path": "../versioning"
}
]
}

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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<AppliedRelease[]> {
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<string, { dir: string, intents: string[] }> = {}
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<string>()
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".`
)
}
}

View File

@@ -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<string>
/**
* When set, every planned release gets the version `0.0.0-<suffix>` 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<ReleaseBumpType, number> = { 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<string>()
const dirsByName = new Map<string, string[]>()
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<ReleaseCause>
dependencyUpdates: Map<string, string>
}
interface AssembleContext {
participants: Map<string, Participant>
lanesByDir: Map<string, string>
fixedGroups: string[][]
/** Per intent id: the participant dirs it releases and their bump types. */
intentBumps: Map<string, Map<string, IntentBumpType>>
consumptionOf: (dir: string) => PackageConsumption
opts: AssembleReleasePlanOptions
}
function assemble (ctx: AssembleContext, selection: Set<string> | undefined): ReleasePlan {
const { participants, lanesByDir, fixedGroups, opts } = ctx
const pendingByDir = collectPendingIntents(ctx)
const laneConsumedByDir = collectLaneConsumedIntents(ctx)
const state = new Map<string, BumpState>()
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<string, string>()
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<string, string>()
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<string, Participant> {
const ignoredDirs = new Set<string>()
for (const ref of opts.versioning?.ignore ?? []) {
for (const dir of resolveConfigRef(refs, ref, 'versioning.ignore')) {
ignoredDirs.add(dir)
}
}
const participants = new Map<string, Participant>()
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<string, Participant>,
versioning?: VersioningSettings
): Map<string, string> {
const lanesByDir = new Map<string, string>()
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<string, Participant>,
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<string, string>,
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<string, Participant>
): Map<string, Map<string, IntentBumpType>> {
const intentBumps = new Map<string, Map<string, IntentBumpType>>()
for (const intent of intents) {
const byDir = new Map<string, IntentBumpType>()
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<string, Participant>): 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<string, ChangeIntent[]> {
const pending = new Map<string, ChangeIntent[]>()
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<string, ChangeIntent[]> {
const laneConsumed = new Map<string, ChangeIntent[]>()
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<string | undefined>): 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<string, Participant>
state: Map<string, BumpState>
newVersions: Map<string, string>
cumulativeBump: (dir: string, planned: ReleaseBumpType) => ReleaseBumpType
fixedGroups: string[][]
lanesByDir: Map<string, string>
}
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
}

View File

@@ -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<ReleaseBumpType, string> = {
major: 'Major Changes',
minor: 'Minor Changes',
patch: 'Patch Changes',
}
export function composeChangelogSection (release: PlannedRelease): string {
const entriesByBump: Record<ReleaseBumpType, string[]> = { 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<string, IntentBumpType>, 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<void> {
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')
}

View File

@@ -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'

View File

@@ -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<IntentBumpType, 'none'>
export interface ChangeIntent {
id: string
filePath: string
releases: Record<string, IntentBumpType>
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<string, IntentBumpType> = {}
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<ChangeIntent[]> {
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<string, IntentBumpType>
summary: string
}
export async function writeChangeIntent (workspaceDir: string, opts: WriteChangeIntentOptions): Promise<string> {
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
}

View File

@@ -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
* `<package name>@<released version>` 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<string, LedgerEntry>
export function ledgerEntryIds (entry: LedgerEntry): string[] {
return Array.isArray(entry) ? entry : entry.intents
}
export async function readLedger (workspaceDir: string): Promise<Ledger> {
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<string, { dir: string, intents: string[] }>
): Promise<Ledger> {
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<string>
/** Intent ids recorded only against prerelease versions of the project. */
prereleaseOnlyIds: Set<string>
}
/**
* 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<string, Set<string>>()
const prereleaseIdsByDir = new Map<string, Set<string>>()
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<string, PackageConsumption>()
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(/\/+$/, '')
}

View File

@@ -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<string, string>): 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()
})

View File

@@ -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<string, string> }>): 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<string, unknown>).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)
})

View File

@@ -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": ".."
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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<string, unknown> {
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<string, unknown>
}