Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/automatically_release_dependents_in_monorepos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
knope: minor
---

# Automatically release dependents when internal dependencies change

In a monorepo, setting `update_internal_dependencies = "patch"` (or `"minor"`) on a package now releases that package whenever one of its in-repo dependencies releases. The new release gets a changesets-style `Dependencies` section listing what triggered it, and propagation is transitive—releases are applied in dependency order.

Knope finds the relationships automatically: from each opted-in package's manifest (`Cargo.toml` or `package.json` dependencies), and from `versioned_files` entries that point at another package's files. For anything neither can see (for example, versions tracked only in a workspace-root manifest), declare the relationship explicitly with `internal_dependencies`:

```toml
[packages.consumer]
versioned_files = ["consumer/Cargo.toml"]
update_internal_dependencies = "patch"
internal_dependencies = ["my-lib"] # usually unnecessary
```

The default is `"none"`—nothing changes unless a package opts in. See the [`update_internal_dependencies` documentation](https://knope.tech/reference/config-file/packages#update_internal_dependencies) for details.
11 changes: 11 additions & 0 deletions .changeset/internal_dependency_api_changes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
versioning: major
config: major
---

# New APIs for internal dependency propagation

To support automatic monorepo dependency updates:

- `knope-versioning`: `ChangeSource` has a new `DependencyUpdate` variant (breaking for exhaustive matches). `Package::versioned_files()`, `Config::is_lock_file()`, `VersionedFile::package_name()`, and `VersionedFile::declares_dependency()` were added.
- `knope-config`: `Package` has new `update_internal_dependencies` and `internal_dependencies` fields (breaking for struct-literal construction), and the `InternalDependencyUpdate` enum was added.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
knope: major
versioning: major
---

# Prerelease changelogs only contain changes since the last prerelease

When running `PrepareRelease` with a prerelease label, Knope now collects conventional commits since the most recent tag of _any_ kind—including prereleases—instead of the last stable release. Iterative prerelease workflows (`alpha.1` → `alpha.2`) get changelogs containing only the commits added since the previous prerelease, and packages with no new commits are no longer re-released on every run.

If the commits since the last prerelease imply a lower version than an existing prerelease line (for example, a fix following a feature that already shipped in `1.1.0-rc.0`), the new version continues the existing line (`1.1.0-rc.1`) rather than regressing below a version that has already been pre-released. This also applies to `knope-versioning`'s version calculation: a rule-derived version with no prerelease line of its own continues the nearest existing line above it.

Stable releases are unaffected: they still summarize everything since the last stable release.
16 changes: 16 additions & 0 deletions .changeset/route_commits_to_packages_by_paths_touched.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
knope: minor
---

# Route commits to packages by the paths they touch with `track_paths`

Setting `track_paths = true` on a package routes conventional commits to it based on the files each commit changed, instead of relying on commit scopes. By default, a package's territory is the parent directories of its own `versioned_files`; set `paths` to override it:

```toml
[packages.my-package]
versioned_files = ["my-package/Cargo.toml"]
track_paths = true
paths = ["my-package", "docs/my-package"] # optional override
```

Commits that touch multiple packages' territories apply to each of them, and commits touching no tracked territory are ignored. See the [`track_paths` documentation](https://knope.tech/reference/config-file/packages#track_paths) for details.
11 changes: 11 additions & 0 deletions .changeset/track_paths_api_changes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
versioning: major
config: major
---

# New APIs for path-based commit routing

To support `track_paths`:

- `knope-versioning`: the conventional commit `Commit` struct has a new public `files` field listing the paths each commit changed (breaking for struct-literal construction).
- `knope-config`: `Package` has new `track_paths` and `paths` fields (breaking for struct-literal construction).
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ on:
push:
branches:
- main
- feat/*
pull_request: {}
merge_group:

Expand Down
2 changes: 1 addition & 1 deletion crates/knope-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};

pub use self::{
changelog_section::ChangelogSection,
package::{Asset, AssetNameError, Assets, Package, VersionedFile},
package::{Asset, AssetNameError, Assets, InternalDependencyUpdate, Package, VersionedFile},
template::{Template, Variable},
};
pub use crate::release_notes::ReleaseNotes;
Expand Down
39 changes: 39 additions & 0 deletions crates/knope-config/src/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,45 @@ pub struct Package {
pub assets: Option<Assets>,
#[serde(default, skip_serializing_if = "<&bool>::not")]
pub ignore_go_major_versioning: bool,
/// What kind of bump this package gets when one of its internal monorepo dependencies bumps.
#[serde(default, skip_serializing_if = "InternalDependencyUpdate::is_default")]
pub update_internal_dependencies: InternalDependencyUpdate,
/// Names of other packages in this config that this package depends on. Usually Knope
/// derives these relationships from `versioned_files` `dependency` entries; use this to
/// declare ones it can't see (for example, versions tracked only in a shared workspace
/// manifest).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub internal_dependencies: Vec<String>,
/// If `true`, conventional commits are routed to this package by the files they changed
/// rather than (or in addition to) the commit's scope.
#[serde(default, skip_serializing_if = "<&bool>::not")]
pub track_paths: bool,
/// Explicit list of directories/files that belong to this package (used when
/// `track_paths` is `true`). If empty, falls back to the parent directories of the
/// package's own (non-dependency) `versioned_files`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub paths: Vec<RelativePathBuf>,
}

/// Policy for how this package is versioned when one of its internal monorepo dependencies
/// receives a release.
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum InternalDependencyUpdate {
/// Don't bump this package when an internal dependency bumps (the default).
#[default]
None,
/// Bump this package as a patch release when an internal dependency bumps.
Patch,
/// Bump this package as a minor release when an internal dependency bumps.
Minor,
}

impl InternalDependencyUpdate {
#[must_use]
pub fn is_default(&self) -> bool {
matches!(self, Self::None)
}
}

/// A type that deserializes from either a single regex string or an array of regex strings.
Expand Down
4 changes: 4 additions & 0 deletions crates/knope-versioning/src/changes/conventional_commit.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use git_conventional::{Commit as ConventionalCommit, Footer, Type};
use relative_path::RelativePathBuf;
use tracing::debug;

use super::{Change, ChangeSource, ChangeType, GitInfo};
Expand All @@ -8,6 +9,9 @@ use crate::release_notes::Sections;
pub struct Commit {
pub message: String,
pub info: Option<GitInfo>,
/// Files changed by this commit relative to the repo root. Empty when unknown (e.g.,
/// in tests that construct commits manually).
pub files: Vec<RelativePathBuf>,
}

/// Try to parse each commit message as a [conventional commit](https://www.conventionalcommits.org/).
Expand Down
11 changes: 9 additions & 2 deletions crates/knope-versioning/src/changes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,14 @@ impl From<FooterToken<'_>> for ChangeType {

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ChangeSource {
ConventionalCommit { description: String },
ChangeFile { id: Arc<changesets::UniqueId> },
ConventionalCommit {
description: String,
},
ChangeFile {
id: Arc<changesets::UniqueId>,
},
/// Synthesized by Knope when propagating a release through internal monorepo dependencies.
DependencyUpdate,
}

impl Display for ChangeSource {
Expand All @@ -184,6 +190,7 @@ impl Display for ChangeSource {
..
} => write!(f, "commit {message}"),
Self::ChangeFile { id, .. } => write!(f, "changeset {}", id.to_file_name()),
Self::DependencyUpdate => write!(f, "dependency update"),
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions crates/knope-versioning/src/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ impl Package {
self.versions.latest()
}

/// The versioned-file configs associated with this package.
#[must_use]
pub fn versioned_files(&self) -> &[Config] {
&self.versioned_files
}

/// Apply a Rule to a [`PackageVersion`], incrementing & resetting the correct components.
///
/// # Versions 0.x
Expand Down
35 changes: 33 additions & 2 deletions crates/knope-versioning/src/release_notes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ pub use release::Release;
use serde::Deserialize;
use time::{OffsetDateTime, macros::format_description};

use crate::{Action, changes::Change, package, semver::Version};
use crate::{
Action,
changes::{Change, ChangeSource},
package,
semver::Version,
};

mod changelog;
mod config;
Expand Down Expand Up @@ -46,7 +51,10 @@ impl ReleaseNotes {
for (section_name, sources) in self.sections.iter() {
let mut changes = changes
.iter()
.filter(|change| sources.contains(&change.change_type))
.filter(|change| {
sources.contains(&change.change_type)
&& change.original_source != ChangeSource::DependencyUpdate
})
.sorted()
.peekable();
if changes.peek().is_some() {
Expand All @@ -59,6 +67,7 @@ impl ReleaseNotes {
write_body(&mut notes, changes, &self.change_templates);
}
}
write_dependency_updates(&mut notes, changes);

let release = Release {
title: release_title(&version)?,
Expand Down Expand Up @@ -95,6 +104,28 @@ impl ReleaseNotes {
)]
pub struct TimeError(#[from] time::error::Format);

/// Append a "Dependencies" section listing internal monorepo dependency bumps in changesets
/// style: a single bullet "Updated dependencies" with each `name@version` nested under it.
fn write_dependency_updates(out: &mut String, changes: &[Change]) {
let dep_changes: Vec<&Change> = changes
.iter()
.filter(|c| c.original_source == ChangeSource::DependencyUpdate)
.collect();
if dep_changes.is_empty() {
return;
}
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str("## Dependencies\n\n- Updated dependencies");
for change in dep_changes {
if let Some(details) = change.details.as_ref() {
out.push('\n');
out.push_str(details);
}
}
}

fn write_body<'change>(
out: &mut String,
changes: Peekable<impl Iterator<Item = &'change Change>>,
Expand Down
15 changes: 15 additions & 0 deletions crates/knope-versioning/src/semver/package_versions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,21 @@ impl PackageVersions {
.map(|(stable, _)| *stable)
.unwrap_or_default()
};
// An existing prerelease may already target a higher stable version (for example, a
// feature shipped in an earlier prerelease and the changes since then are only
// fixes). If the rule-derived version has no prerelease line of its own, continue
// the nearest existing line above it instead of regressing below a version that has
// already been pre-released. A rule-derived version that matches an existing line
// keeps it, so deliberately-newer lines (e.g. a hand-tagged 2.0.0-rc) don't take
// over (see `pre_after_same_pre`).
let stable_component = if self.prereleases.contains_key(&stable_component) {
stable_component
} else {
self.prereleases
.range(stable_component..)
.next()
.map_or(stable_component, |(existing, _)| *existing)
};
let pre_version = self
.prereleases
.get(&stable_component)
Expand Down
34 changes: 34 additions & 0 deletions crates/knope-versioning/src/versioned_file/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,30 @@ impl VersionedFile {
}
}

/// The package name this file declares, if it's a manifest that declares one.
/// Currently supported for `Cargo.toml` and `package.json`.
#[must_use]
pub fn package_name(&self) -> Option<String> {
match self {
VersionedFile::Cargo(cargo) => {
cargo::name_from_document(&cargo.document).map(String::from)
}
VersionedFile::PackageJson(package_json) => package_json.package_name(),
_ => None,
}
}

/// Whether this file is a manifest declaring a dependency on `name`.
/// Currently supported for `Cargo.toml` and `package.json`.
#[must_use]
pub fn declares_dependency(&self, name: &str) -> bool {
match self {
VersionedFile::Cargo(cargo) => cargo::contains_dependency(&cargo.document, name),
VersionedFile::PackageJson(package_json) => package_json.declares_dependency(name),
_ => false,
}
}

#[must_use]
pub fn path(&self) -> &RelativePathBuf {
match self {
Expand Down Expand Up @@ -495,6 +519,16 @@ impl Config {
self.path.clone()
}

/// Whether this file is a lock file (recording resolved versions) rather than a manifest
/// (declaring a package or its dependencies).
#[must_use]
pub fn is_lock_file(&self) -> bool {
matches!(
self.format,
Format::CargoLock | Format::PackageLockJson | Format::DenoLock
)
}

#[must_use]
pub fn to_pathbuf(&self) -> PathBuf {
self.as_path().to_path("")
Expand Down
16 changes: 16 additions & 0 deletions crates/knope-versioning/src/versioned_file/package_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,22 @@ impl PackageJson {
&self.path
}

/// The `name` field of the manifest, if present.
pub(crate) fn package_name(&self) -> Option<String> {
let json = serde_json::from_str::<Map<String, Value>>(&self.raw).ok()?;
json.get("name")?.as_str().map(String::from)
}

/// Whether the manifest declares a dependency (or dev dependency) on `name`.
pub(crate) fn declares_dependency(&self, name: &str) -> bool {
let Ok(json) = serde_json::from_str::<Map<String, Value>>(&self.raw) else {
return false;
};
["dependencies", "devDependencies"]
.iter()
.any(|key| json.get(*key).and_then(|deps| deps.get(name)).is_some())
}

pub(crate) fn set_version(
mut self,
new_version: &Version,
Expand Down
Loading
Loading