mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-14 09:42:37 -04:00
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:
10
.changeset/native-monorepo-versioning-core.md
Normal file
10
.changeset/native-monorepo-versioning-core.md
Normal 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
20
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
52
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
421
pnpm/crates/cli/src/cli_args/change.rs
Normal file
421
pnpm/crates/cli/src/cli_args/change.rs
Normal 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()
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
200
pnpm/crates/cli/src/cli_args/lane.rs
Normal file
200
pnpm/crates/cli/src/cli_args/lane.rs
Normal 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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
185
pnpm/crates/cli/src/cli_args/version.rs
Normal file
185
pnpm/crates/cli/src/cli_args/version.rs
Normal 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())
|
||||
}
|
||||
312
pnpm/crates/cli/tests/change.rs
Normal file
312
pnpm/crates/cli/tests/change.rs
Normal 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);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
31
pnpm/crates/versioning/Cargo.toml
Normal file
31
pnpm/crates/versioning/Cargo.toml
Normal 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
|
||||
122
pnpm/crates/versioning/src/apply.rs
Normal file
122
pnpm/crates/versioning/src/apply.rs
Normal 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, §ion)?;
|
||||
}
|
||||
|
||||
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;
|
||||
219
pnpm/crates/versioning/src/apply/tests.rs
Normal file
219
pnpm/crates/versioning/src/apply/tests.rs
Normal 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}");
|
||||
}
|
||||
130
pnpm/crates/versioning/src/changelog.rs
Normal file
130
pnpm/crates/versioning/src/changelog.rs
Normal 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 })
|
||||
}
|
||||
99
pnpm/crates/versioning/src/error.rs
Normal file
99
pnpm/crates/versioning/src/error.rs
Normal 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),
|
||||
}
|
||||
189
pnpm/crates/versioning/src/human_id.rs
Normal file
189
pnpm/crates/versioning/src/human_id.rs
Normal 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()]
|
||||
}
|
||||
174
pnpm/crates/versioning/src/intents.rs
Normal file
174
pnpm/crates/versioning/src/intents.rs
Normal 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;
|
||||
57
pnpm/crates/versioning/src/intents/tests.rs
Normal file
57
pnpm/crates/versioning/src/intents/tests.rs
Normal 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.");
|
||||
}
|
||||
251
pnpm/crates/versioning/src/ledger.rs
Normal file
251
pnpm/crates/versioning/src/ledger.rs
Normal 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+0000–U+001F and
|
||||
// U+007F–U+009F, so a two-digit `\xNN` always fits.
|
||||
control if control.is_control() => {
|
||||
write!(escaped, r"\x{:02X}", control as u32).expect("write to string");
|
||||
}
|
||||
other => escaped.push(other),
|
||||
}
|
||||
}
|
||||
escaped.push('"');
|
||||
escaped
|
||||
}
|
||||
|
||||
fn needs_quoting(value: &str) -> bool {
|
||||
if value.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// Leading indicator characters, or a value YAML would read as something
|
||||
// other than a plain string.
|
||||
if value.starts_with([
|
||||
'!', '&', '*', '[', ']', '{', '}', ',', '#', '|', '>', '@', '`', '"', '\'', '%', '?', ':',
|
||||
'-', ' ',
|
||||
]) {
|
||||
return true;
|
||||
}
|
||||
if value.ends_with(' ') {
|
||||
return true;
|
||||
}
|
||||
matches!(value, "true" | "false" | "null" | "yes" | "no" | "~")
|
||||
|| value.chars().any(char::is_control)
|
||||
// A colon or hash that YAML would treat as a key/comment boundary.
|
||||
|| value.contains(": ")
|
||||
|| value.ends_with(':')
|
||||
|| value.contains(" #")
|
||||
}
|
||||
|
||||
/// Which intent ids the ledger records for one project.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct PackageConsumption {
|
||||
/// Intent ids recorded against any released version of the project.
|
||||
pub all_ids: HashSet<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;
|
||||
67
pnpm/crates/versioning/src/ledger/tests.rs
Normal file
67
pnpm/crates/versioning/src/ledger/tests.rs
Normal 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""#);
|
||||
}
|
||||
40
pnpm/crates/versioning/src/lib.rs
Normal file
40
pnpm/crates/versioning/src/lib.rs
Normal 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};
|
||||
1023
pnpm/crates/versioning/src/plan.rs
Normal file
1023
pnpm/crates/versioning/src/plan.rs
Normal file
File diff suppressed because it is too large
Load Diff
585
pnpm/crates/versioning/src/plan/tests.rs
Normal file
585
pnpm/crates/versioning/src/plan/tests.rs
Normal 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);
|
||||
}
|
||||
70
pnpm/crates/versioning/src/settings.rs
Normal file
70
pnpm/crates/versioning/src/settings.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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<'_>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './options.js'
|
||||
export * from './package.js'
|
||||
export * from './peerDependencyIssues.js'
|
||||
export * from './project.js'
|
||||
export * from './versioning.js'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
37
pnpm11/core/types/src/versioning.ts
Normal file
37
pnpm11/core/types/src/versioning.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
318
pnpm11/releasing/commands/src/change/index.ts
Normal file
318
pnpm11/releasing/commands/src/change/index.ts
Normal 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,
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
182
pnpm11/releasing/commands/src/lane/index.ts
Normal file
182
pnpm11/releasing/commands/src/lane/index.ts
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
206
pnpm11/releasing/commands/test/change/index.test.ts
Normal file
206
pnpm11/releasing/commands/test/change/index.test.ts
Normal 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.')
|
||||
})
|
||||
})
|
||||
@@ -123,8 +123,14 @@
|
||||
{
|
||||
"path": "../../workspace/projects-sorter"
|
||||
},
|
||||
{
|
||||
"path": "../../workspace/workspace-manifest-writer"
|
||||
},
|
||||
{
|
||||
"path": "../exportable-manifest"
|
||||
},
|
||||
{
|
||||
"path": "../versioning"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
24
pnpm11/releasing/versioning/README.md
Normal file
24
pnpm11/releasing/versioning/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# @pnpm/releasing.versioning
|
||||
|
||||
> Native workspace release management: change intents, release-plan assembly, and changelog writing
|
||||
|
||||
[](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
|
||||
54
pnpm11/releasing/versioning/package.json
Normal file
54
pnpm11/releasing/versioning/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
95
pnpm11/releasing/versioning/src/applyReleasePlan.ts
Normal file
95
pnpm11/releasing/versioning/src/applyReleasePlan.ts
Normal 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".`
|
||||
)
|
||||
}
|
||||
}
|
||||
722
pnpm11/releasing/versioning/src/assembleReleasePlan.ts
Normal file
722
pnpm11/releasing/versioning/src/assembleReleasePlan.ts
Normal 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
|
||||
}
|
||||
80
pnpm11/releasing/versioning/src/changelog.ts
Normal file
80
pnpm11/releasing/versioning/src/changelog.ts
Normal 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')
|
||||
}
|
||||
45
pnpm11/releasing/versioning/src/index.ts
Normal file
45
pnpm11/releasing/versioning/src/index.ts
Normal 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'
|
||||
103
pnpm11/releasing/versioning/src/intents.ts
Normal file
103
pnpm11/releasing/versioning/src/intents.ts
Normal 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
|
||||
}
|
||||
171
pnpm11/releasing/versioning/src/ledger.ts
Normal file
171
pnpm11/releasing/versioning/src/ledger.ts
Normal 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(/\/+$/, '')
|
||||
}
|
||||
417
pnpm11/releasing/versioning/test/assembleReleasePlan.ts
Normal file
417
pnpm11/releasing/versioning/test/assembleReleasePlan.ts
Normal 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()
|
||||
})
|
||||
177
pnpm11/releasing/versioning/test/lifecycle.ts
Normal file
177
pnpm11/releasing/versioning/test/lifecycle.ts
Normal 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)
|
||||
})
|
||||
18
pnpm11/releasing/versioning/test/tsconfig.json
Normal file
18
pnpm11/releasing/versioning/test/tsconfig.json
Normal 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": ".."
|
||||
}
|
||||
]
|
||||
}
|
||||
25
pnpm11/releasing/versioning/tsconfig.json
Normal file
25
pnpm11/releasing/versioning/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
pnpm11/releasing/versioning/tsconfig.lint.json
Normal file
8
pnpm11/releasing/versioning/tsconfig.lint.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { getChangedProjects } from './getChangedProjects.js'
|
||||
import { parseProjectSelector, type ProjectSelector } from './parseProjectSelector.js'
|
||||
|
||||
export { filterProjectsBySelectorObjectsFromDir, parseProjectSelector, type ProjectSelector }
|
||||
export { getChangedProjects } from './getChangedProjects.js'
|
||||
|
||||
export interface WorkspaceFilter {
|
||||
filter: string
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
69
pnpm11/workspace/workspace-manifest-reader/src/versioning.ts
Normal file
69
pnpm11/workspace/workspace-manifest-reader/src/versioning.ts
Normal 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>
|
||||
}
|
||||
Reference in New Issue
Block a user