Introduction
monochange is a cross-ecosystem release planner for monorepos.
It is easiest to learn with one safe local walkthrough before you touch provider publishing, release PRs, diagnostics, or MCP setup.
Who this guide is for
- maintainers of monorepos that span more than one package ecosystem
- teams replacing ad hoc release scripts with explicit change files
- people who want a predictable release plan before adding automation
Start with one safe walkthrough
Install the prebuilt CLI from npm:
npm install -g @monochange/cli
monochange --help
Then run the core beginner flow:
Generate a starter config from the packages monochange detects:
monochange init
monochange init writes an annotated, minimal monochange.toml without default [cli.*] workflow aliases. The binary exposes immutable monochange step * commands for every built-in step when you need a direct, config-free entry point; add [cli.*] tables only for repository-specific named workflows.
For automated CI setup, include the --provider flag:
monochange init --provider github
This configures the [source] section and creates GitHub Actions workflows for changeset policy and release automation. It intentionally does not add [cli.*] workflow commands.
Validate the workspace:
monochange step validate
Discover the package ids you will use in commands and changesets:
monochange step discover --format json
Create one change file for a package id:
monochange run change --package <id> --bump patch --reason "describe the change"
Most changes should target a package id. Use group ids only when the change is intentionally owned by the whole group.
When a package is only changing because another dependency or version group moved first, author that context explicitly instead of relying on anonymous propagation:
monochange run change --package <dependent-id> --bump none --caused-by <upstream-id> --reason "dependency-only follow-up"
Preview the release plan safely:
monochange run release --dry-run --format json
Add --diff when you want unified file previews for version and changelog updates without mutating the workspace:
monochange run release --dry-run --diff
This first run is safe: nothing is published. Stop here until you are ready to prepare release files locally.
When you are ready to prepare the release locally, run monochange run release.
For human-readable local output, monochange run release --dry-run defaults to concise text. Use --format markdown for a raw Markdown artifact and --format json for automation. Add --quiet only to suppress output; combine it with --dry-run when you also need a non-mutating run. Use monochange step display-versions when you only need planned package and group versions; use monochange versions --dry-run when you want to preview internal dependency constraint updates before writing them.
This book is maintained with mdt so shared content blocks stay synchronized across pages. See the Configuration reference for how template updates work.
If you want a slower, more guided walkthrough, continue with Start here and Your first release plan.
What to read next
- Start here: install,
monochange init, validation, discovery, and--dry-run - Installation: npm, Cargo, optional assistant tooling, and repository development setup
- Your first release plan: generated config first, package ids before groups
- Configuration reference: the full package, group, changelog, and CLI model
- Release planning: changesets, dry runs, diff previews, and planning rules
- Advanced: GitHub automation: provider publishing and release requests
- Advanced: CI, package publishing, and release PR flows: per-provider CI patterns, trusted publishing, and long-running release PR design notes
- Advanced: Assistant setup and MCP: optional AI-assisted workflows
- Change classification: release-aware bump evidence, confidence, and validation
- Reference: Manifest linting with
monochange check:[lints]rules for Cargo and npm-family manifests
Publishing
Recent monochange improvements made package publishing guidance and diagnostics much more actionable:
- a dedicated trusted-publishing guide covers
npm,crates.io,jsr, andpub.dev - CI examples prefer the official registry-maintained workflows for
crates.ioandpub.dev - a dedicated multi-package publishing guide covers monorepo tag, workflow, and package-boundary patterns
- CLI output gives clearer manual next steps for registries that still require registry-side trusted-publishing enrollment
- built-in publish preflight validates and reports the expected GitHub repository, workflow, and environment context for manual registries when it can infer them
- the monochange repository wires
monochange run publish-checkas a dry-runPublishPackagesworkflow so CI can verify package-publishing readiness without publishing
Command and automation matrix
These are common commands for repositories using monochange. With the current CLI model, workflow names such as discover, change, release, and affected come from optional [cli.*] tables in monochange.toml and run as monochange run <name>; binary commands such as check, init, versions, publish, and mcp stay built in, while typed built-in operations such as validation are exposed as immutable monochange step * commands.
| Goal | Command | Use it when |
|---|---|---|
| Validate config and changesets | monochange step validate | You changed monochange.toml or .changeset/*.md files |
| Inspect package ids and groups | monochange step discover --format json | You need the normalized workspace model |
| Sync internal dependency ranges | monochange versions --dry-run | You want internal dependency references to match canonical workspace package versions |
| Check the next version | monochange next | You want the next release group and package versions from pending changesets, without writing any release state |
| Create release intent | monochange run change --package <id> --bump <severity> --reason "..." | You need a new .changeset/*.md file |
| Audit pending release context | monochange step diagnose-changesets --format json | You need git provenance, PR/MR links, or related issues |
| Preview the release plan | monochange run release --dry-run --diff or monochange step prepare-release --dry-run | You want changelog/version patches without mutating the repo |
| Create a durable release commit | monochange step commit-release | You want a monochange-managed release commit with an embedded ReleaseRecord |
| Open or update a release request | monochange step open-release-request | You want a long-lived release PR/MR branch updated from current release state |
| Inspect a past release commit | monochange step release-record --from <ref> | You need the durable release declaration from git history |
| Check package publish readiness | monochange publish readiness --from HEAD --output <path> | You want a non-mutating preflight report before package publication |
| Dry-run configured publishing | monochange run publish-check | This repository, or another repo with a similar [cli.publish-check], should exercise publishing in CI without registry mutations |
| Plan ready package publishing | monochange step plan-publish-rate-limits --readiness <path> | You want rate-limit batches that exclude non-ready package work |
| Publish packages to registries | monochange publish packages --output <path> | You want cargo publish, npm publish, deno publish, or dart pub publish style package publication |
| Bootstrap release packages | monochange publish placeholder | You need a release-record-scoped placeholder bootstrap artifact before rerunning readiness |
| Create post-merge release tags | monochange step tag-release --from HEAD | You merged a monochange release commit and now need to create and push its declared tag set |
| Repair a recent release | monochange step retarget-release --from <tag> --target <commit> | You need to retarget a just-created release to a later commit |
| Publish hosted/provider releases | monochange step publish-release | You want GitHub/GitLab/Gitea release objects from prepared release state |
monochange step publish-readiness performs non-mutating registry checks before monochange step publish-packages. For built-in Cargo publishes to crates.io it also verifies current manifest publishability: publish = false blocks publishing, publish = [...] must include crates-io, description must be set, and either license or license-file must be set. Workspace-inherited Cargo metadata is accepted, and already-published versions remain non-blocking in readiness reports. The artifact fingerprints monochange.toml, package manifests, lockfiles, and registry/tooling files, so rerun monochange step publish-readiness after those inputs change. monochange step plan-publish-rate-limits --readiness <path> validates the artifact for planning and limits rate-limit batches to package ids that are ready in both the artifact and the fresh local readiness check. monochange step publish-packages publishes directly from prepared release or HEAD release state and does not require the readiness artifact. If readiness shows missing first-time registry packages, run monochange step placeholder-publish, then rerun readiness before real publishing.
What monochange can do
- discover Cargo, npm/pnpm/Bun, Deno, Dart, Flutter, Python, and Go packages
- normalize dependency edges across ecosystems
- coordinate shared package groups from
monochange.toml - compute release plans from explicit change input
- expose repository-defined workflow commands as
monochange run <command>from[cli.<command>]definitions - run config-defined release commands from
.changeset/*.md - render changelogs through structured release notes and configurable formats
- emit stable release-manifest JSON for downstream automation
- preview or publish provider releases and release requests from typed command steps and shared release data
- inspect durable release records from tags or descendant commits with
monochange step release-record - create post-merge release tags from a merged release commit with
monochange step tag-release --from HEAD - repair a recent source/provider release by retargeting its release tags with
monochange step retarget-release - inspect changeset context and review metadata with
monochange step diagnose-changesetsfor both human and automation workflows - apply Rust semver evidence when provided
- expose a bundled assistant skill plus a stdio MCP server with
monochange mcp - publish the CLI as
@monochange/cliand the bundled agent skill as@monochange/skill - publish end-user documentation through the mdBook in
docs/
What the JSON output includes
Discovery output includes:
- normalized package records
- dependency edges
- release groups derived from configured groups
- warnings
Release-plan output includes:
- per-package bump decisions
- synchronized group outcomes
- compatibility evidence
- warnings and unresolved items
- optional
fileDiffspreviews when you request--diff
Contributing to monochange itself
If you are working on the monochange repository, run the full local validation suite before opening a PR:
lint:all
test:all
build:all
build:book
Start here
monochange is easiest to learn with one safe local walkthrough.
In about 10 minutes you will:
- install the CLI
- generate a starter
monochange.tomlwithmonochange init - validate the workspace
- discover package ids
- create one change file
- preview a release plan with
--dry-run
This first run is safe: nothing is published.
1. Install the CLI
The fastest path is the prebuilt npm package:
npm install -g @monochange/cli
monochange --help
If you prefer a Rust-native install, use:
cargo install monochange
monochange --help
If you use devenv, add monochange via the ifiokjr/nixpkgs overlay:
# flake.nix
inputs.ifiokjr-nixpkgs.url = "github:ifiokjr/nixpkgs";
# devenv.nix
let extra = inputs.ifiokjr-nixpkgs.packages.${pkgs.stdenv.system};
in {
packages = [ extra.monochange ];
}
Or run directly:
nix run github:ifiokjr/nixpkgs#monochange
2. Generate a starter config
Run monochange init at the repository root:
monochange init
monochange init scans the repository, detects packages, and writes an annotated starter monochange.toml.
Start with the generated file instead of hand-authoring your first config.
3. Validate the workspace
monochange step validate
This checks monochange.toml and your .changeset/*.md files together.
4. Discover package ids
monochange step discover --format json
Look for the package ids you will use in changesets and CLI commands.
If you do not know which id to target later, rerun discovery and copy one directly from the output.
5. Create one change file
monochange run change --package <id> --bump patch --reason "describe the change"
Most first changes should target a package id.
Use group ids only when the change is intentionally owned by the whole group.
A typical generated file looks like this:
---
<id>: patch
---
#### describe the change
If the same package changed for a more specific reason, you can add more context right away:
monochange run change \
--package <id> \
--bump minor \
--reason "add release preview improvements" \
--details "Adds file diff previews during dry runs."
6. Preview the release plan safely
monochange run release --dry-run
By default this renders concise text in the terminal. Use --format markdown when you want a raw Markdown artifact, --format json when you want structured output for tooling, or monochange step display-versions when you only need the planned package and group versions. Use monochange versions --dry-run when you want to preview internal dependency constraint updates without modifying manifests.
When you want to see the exact file patch without mutating the workspace, add --diff:
monochange run release --dry-run --diff
When you want to inspect changeset provenance before releasing, add a diagnostics pass:
monochange step diagnose-changesets --format json
Stop here on your first run. This previews the release plan without publishing anything.
Package ids first, groups later
A good first-time mental model is:
- monochange discovers packages.
- You author explicit changes against package ids.
- monochange propagates dependent bumps for you.
- Groups synchronize packages that intentionally share release identity.
That is why most beginner flows should start with package ids, not groups.
If you need a silent safety check, run monochange run release --dry-run --quiet. Quiet mode only suppresses output; --dry-run is what prevents workspace changes.
If you hit a problem
monochange initsays a config already exists: keep the existingmonochange.tomland continue withmonochange step validate, or pass--forceto regenerate.monochange step validatereports problems: fix the reported config or changeset issue, then rerunmonochange step validate.monochange run changerejects your target: rerunmonochange step discover --format jsonand copy a valid package id.- You are not sure what to do next: continue with Your first release plan.
Next steps
- Installation: install paths, optional assistant tooling, and repository development setup
- Your first release plan: a fuller walkthrough built around
monochange init - Discovery: what discovery finds and how ids are rendered
- Configuration: evolve the generated config once the basics feel familiar
- Release planning: compare preview modes, grouped releases, and planning rules
- Advanced: GitHub automation: provider publishing, release PRs, and automation
- Advanced: Assistant setup and MCP: optional AI-assisted workflows
- Reference: Manifest linting with
monochange check:[lints]rules for Cargo and npm-family manifests
Installation
If you want the fastest path to a first successful run, install the prebuilt CLI from npm.
Fastest path: npm
npm install -g @monochange/cli
monochange --help
Then continue with Start here or Your first release plan.
Alternative: Nix / devenv
If you use devenv or the Nix package manager, monochange is available via the ifiokjr/nixpkgs flake:
# flake.nix
inputs = {
ifiokjr-nixpkgs.url = "github:ifiokjr/nixpkgs";
};
Then add monochange to your devenv packages:
# devenv.nix
let
extra = inputs.ifiokjr-nixpkgs.packages.${pkgs.stdenv.system};
in
{
packages = [ extra.monochange ];
}
Or run directly without adding to your flake:
nix run github:ifiokjr/nixpkgs#monochange
Alternative: Cargo
If you prefer to install from Rust tooling instead:
cargo install monochange
monochange --help
Optional: assistant skill package
You do not need assistant tooling to use monochange.
When you want reusable agent guidance for Pi or other assistants, install the bundled skill into the current project with:
monochange help skill
monochange skill
monochange skill read configuration
monochange skill install --dir ./.claude/skills/monochange
The skill ships inside the binary, so it needs no network access or npm install. monochange skill read serves any bundled topic as raw Markdown, and monochange skill install --dir writes the whole tree into an agent runtime’s skills directory.
After copying the bundled skill, you get a small documentation set that is designed to load in layers:
SKILL.md: concise entrypoint for agentsREFERENCE.md: broader high-context reference with more examplesskills/README.md: index of focused deep divesskills/adoption.md: setup-depth questions, migration guidance, and recommendation patternsskills/change-classification.md: release-aware severity decisions, uncertainty, and ecosystem reviewskills/changesets.md: changeset authoring and lifecycle guidanceskills/commands.md: built-in command catalog and workflow selectionskills/configuration.md:monochange.tomlsetup and editing guidanceskills/linting.md:[lints]presets,monochange check, and manifest-focused examplesexamples/README.md: condensed scenario examples for quick recommendations
This layout keeps the top-level skill small while still making the richer guidance available when an assistant needs more context.
Assistant-specific setup is covered in Advanced: Assistant setup and MCP.
CLI name
The CLI executable is monochange. The old mc binary alias was removed, so repository automation should call monochange directly.
Repository development
If you are working on the monochange repository itself, use the reproducible development shell:
devenv shell
install:all
monochange step validate
monochange step discover --format json
monochange run change --package monochange --bump minor --reason "add release planning"
monochange step diagnose-changesets --format json
monochange run release --dry-run --format json
monochange step publish-release --dry-run --format json
monochange step open-release-request --dry-run --format json
monochange step release-record --from v1.2.3
monochange step tag-release --from HEAD --dry-run --format json
monochange step publish-readiness --from HEAD --output .monochange/readiness.json
monochange step placeholder-publish
monochange step publish-readiness --from HEAD --output .monochange/readiness.json
monochange step plan-publish-rate-limits --readiness .monochange/readiness.json --format json
monochange step publish-packages --output .monochange/publish-result.json
monochange step retarget-release --from v1.2.3 --target HEAD --dry-run
monochange run release
Useful repository-development commands:
monochange --help
docs:check # verify mdt shared-doc synchronization
docs:update # synchronize shared docs via mdt update
schema:check # verify committed JSON schemas are current
schema:update # regenerate schema assets from source
monochange step validate
lint:all
test:all
coverage:all
coverage:patch
build:all
build:book
Your first release plan
Use this guide after installation when you want one local, beginner-safe walkthrough.
You will stop at monochange run release --dry-run --format json, so nothing is published.
1. Generate a starter config with monochange init
Run this at the repository root:
monochange init
monochange init detects packages, writes an annotated monochange.toml, and gives you a better starting point than hand-authoring a first config from scratch.
The generated file is intentionally minimal and does not create default [cli.*] workflow aliases. Every built-in step is available directly as an immutable monochange step * command, for example monochange step discover, monochange step create-change-file, and monochange step prepare-release.
Add [cli.*] tables only when you want repository-specific named workflows that chain steps, expose custom inputs, or run shell Command steps.
Commit .monochange/, gitignore only .monochange/local/
monochange writes committed state under .monochange/. The most important file is the release record at .monochange/releases/<id>/release.json, which CommitRelease writes and later publish-readiness, tag-release, and provider release automation read from git history. Prerelease state in .monochange/prerelease-state.json is committed too.
Only the .monochange/local/ directory holds local artifacts. Never add .monochange/ as a whole to .gitignore: ignoring it hides new release records from git and makes releases unpublishable. monochange keeps local state out of git status by adding .monochange/local/ to .git/info/exclude automatically when it writes a local artifact, so local runs should either use the default .monochange/local/ paths or write artifacts to a temporary directory.
Automated CI setup with --provider
When you know which source provider you will use for release automation, include the --provider flag during initialization:
monochange init --provider github
The --provider flag supports github, gitlab, and gitea. When provided, monochange init:
- Configures the
[source]section - adds provider-specific settings for releases and pull/merge requests - Generates provider CLI commands - includes
commit-releaseandrelease-prcommands inmonochange.toml - Creates workflow files (GitHub only) - writes
.github/workflows/release.ymland.github/workflows/changeset-policy.yml - Auto-detects owner/repo - parses
git remote get-url originto pre-populate[source]
Example generated configuration with --provider github:
[source]
provider = "github"
owner = "ifiokjr" # auto-detected from git remote
repo = "monochange" # auto-detected from git remote
[source.releases]
enabled = true
draft = false
prerelease = false
source = "monochange"
branches = ["main", "release/*"]
enforce_for_tags = true
enforce_for_publish = true
enforce_for_commit = false
changeset_context_timeout_seconds = 120
[source.pull_requests]
enabled = true
branch_prefix = "monochange/release"
base = "main"
title = "chore(release): prepare release"
labels = ["release", "automated"]
auto_merge = false
[cli.commit-release]
help_text = "Prepare a release and create a release commit"
[[cli.commit-release.steps]]
type = "PrepareRelease"
name = "plan release"
[[cli.commit-release.steps]]
type = "CommitRelease"
name = "create release commit"
[cli.release-pr]
help_text = "Prepare a release and open a release pull request"
[[cli.release-pr.steps]]
type = "PrepareRelease"
name = "plan release"
[[cli.release-pr.steps]]
type = "OpenReleaseRequest"
name = "open release PR"
The GitHub Actions workflows enable:
- Release automation -
release.ymlrefreshes the release PR on normalmainpushes, then tags and publishes when the merged release commit lands onmain - Changeset policy enforcement -
changeset-policy.ymlvalidates PRs have required changeset coverage
For GitLab and Gitea, the [source] section is configured but workflows are not generated (use their respective CI configuration files).
2. Validate the generated workspace
monochange step validate
This confirms that the generated config and any existing .changeset/*.md files agree with the workspace.
If validation fails, fix the reported problem first, then rerun monochange step validate.
3. Discover the package ids you will actually use
monochange step discover --format json
The most important thing to find in discovery output is the package id you want to target in your first change file.
If you are unsure what id to use later, rerun discovery and copy one from the output.
4. Create one change file
monochange run change --package <id> --bump patch --reason "describe the change"
Most changes should target a package id.
monochange will propagate bumps to dependents and synchronize configured groups for you, so group ids are best reserved for intentionally shared ownership.
5. Preview the release plan safely
monochange run release --dry-run --format json
This is the right stopping point for a first-time user.
You get a concrete preview of the release plan without publishing anything or opening provider requests.
When you are ready to move beyond planning:
- use
monochange step placeholder-publish --dry-run --format jsonif some packages still need a bootstrap0.0.0release so they exist in their registries first - use
monochange step publish-packages --dry-run --format jsonto preview built-in package publication tocrates.io,npm,jsr, orpub.dev - before real package publication, optionally write a readiness artifact with
monochange step publish-readiness --from HEAD --output .monochange/readiness.jsonfor preflight review, then runmonochange step publish-packages - use
monochange step publish-release --dry-run --format jsononly for hosted/provider releases such as GitHub releases
Package ids vs. group ids
Use this rule of thumb:
- package ids first: most authored changes belong to one package
- group ids later: use a group id only when the change is intentionally owned by the whole group
That keeps your first changes simple while still letting monochange synchronize grouped packages when needed.
First-failure recovery
monochange init says a config already exists
Keep the existing monochange.toml, inspect it, and continue with monochange step validate. If you want to regenerate the config from scratch, pass the --force flag:
monochange init --force
monochange step validate reports config or changeset errors
Fix the reported issue first. monochange step validate is the fastest way to get back to a known-good workspace.
monochange run change says the package id is unknown
Run monochange step discover --format json again and copy an id directly from the output.
You are not ready to hand-edit config yet
That is normal. Stay with the generated monochange.toml until the basic flow feels familiar.
When to edit monochange.toml by hand
Most first-time users should not start by writing a large config manually.
Reach for manual edits when you want to:
- rename or reorganize package ids
- define groups with
[group.<id>] - customize changelog paths or formats
- add provider configuration for release publishing or release PRs
- expand the CLI surface beyond the default generated commands
Reference: expanded configuration example
The example below shows the broader package, group, changelog, source-provider, and CLI-command model.
Use it as reference material after the generated config makes sense.
[defaults]
parent_bump = "patch"
warn_on_group_mismatch = true
package_type = "cargo"
[defaults.changelog]
path = "{{ path }}/changelog.md"
format = "keep_a_changelog"
[changelog]
templates = [
"#### {{ summary }}\n\n{{ details }}\n\n{{ context }}",
"#### {{ summary }}\n\n{{ context }}",
"#### {{ summary }}\n\n{{ details }}",
"- {{ summary }}",
]
[package.sdk-core]
path = "crates/sdk_core"
[package.sdk-core.changelog.types]
security = { bump = "patch", section = "Security" }
[package.web-sdk]
path = "packages/web-sdk"
type = "npm"
[package.mobile-sdk]
path = "packages/mobile-sdk"
type = "dart"
[group.sdk]
packages = ["sdk-core", "web-sdk", "mobile-sdk"]
tag = true
release = true
version_format = "primary"
[group.sdk.changelog]
path = "docs/sdk-changelog.md"
format = "monochange"
[source]
provider = "github"
owner = "ifiokjr"
repo = "monochange"
[source.releases]
source = "monochange"
[source.pull_requests]
branch_prefix = "monochange/release"
base = "main"
title = "chore(release): prepare release"
labels = ["release", "automated"]
auto_merge = false
[changesets.affected]
enabled = true
required = true
skip_labels = ["no-changeset-required"]
comment_on_failure = true
changed_paths = ["crates/**", "packages/**", "npm/**", "skills/**"]
ignored_paths = [
"docs/**",
"specs/**",
"readme.md",
"CONTRIBUTING.md",
"license",
]
[cli.discover]
help_text = "Discover packages across supported ecosystems"
[[cli.discover.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.discover.steps]]
name = "discover packages"
type = "Discover"
inputs = ["format"]
[cli.change]
help_text = "Create a change file for one or more packages"
[[cli.change.inputs]]
name = "interactive"
type = "boolean"
short = "i"
[[cli.change.inputs]]
name = "package"
type = "string_list"
[[cli.change.inputs]]
name = "bump"
type = "choice"
choices = ["none", "patch", "minor", "major"]
default = "patch"
[[cli.change.inputs]]
name = "version"
type = "string"
[[cli.change.inputs]]
name = "reason"
type = "string"
[[cli.change.inputs]]
name = "type"
type = "string"
[[cli.change.inputs]]
name = "details"
type = "string"
[[cli.change.inputs]]
name = "output"
type = "path"
[[cli.change.steps]]
name = "create change file"
type = "CreateChangeFile"
inputs = ["interactive", "package", "bump", "version", "type", "reason", "details", "output"]
[cli.release]
help_text = "Prepare a release from discovered change files"
[[cli.release.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.release.steps]]
name = "prepare release"
type = "PrepareRelease"
inputs = ["format"]
[cli.publish-release]
help_text = "Prepare a release and publish provider releases"
[[cli.publish-release.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.publish-release.steps]]
name = "prepare release"
type = "PrepareRelease"
inputs = ["format"]
[[cli.publish-release.steps]]
name = "publish release"
type = "PublishRelease"
inputs = ["format"]
[cli.release-pr]
help_text = "Prepare a release and open or update a provider release request"
[[cli.release-pr.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.release-pr.steps]]
name = "prepare release"
type = "PrepareRelease"
inputs = ["format"]
[[cli.release-pr.steps]]
name = "open release request"
type = "OpenReleaseRequest"
inputs = ["format"]
[cli.affected]
help_text = "Evaluate pull-request changeset policy"
[[cli.affected.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.affected.inputs]]
name = "changed_paths"
type = "string_list"
required = true
[[cli.affected.inputs]]
name = "label"
type = "string_list"
[[cli.affected.steps]]
name = "evaluate affected packages"
type = "AffectedPackages"
inputs = ["format", "changed_paths", "label"]
This guide shows the preferred package/group configuration model together with an expanded CLI command surface.
Discovery
monochange discovers packages from native manifests and workspace definitions. For a capability-by-capability comparison of each adapter, see Ecosystems.
Supported sources:
- Cargo workspaces and standalone crates
- npm workspaces, pnpm workspaces, Bun workspaces, and standalone
package.jsonpackages - Deno workspaces and standalone
deno.json/deno.jsoncpackages - Dart and Flutter workspaces plus standalone
pubspec.yamlpackages - Python uv workspaces, Poetry projects, and standalone
pyproject.tomlpackages - Go modules discovered from standalone
go.modfiles
Run discovery:
monochange step discover --format json
Key behaviors:
- native workspace globs are expanded by each ecosystem adapter
- dependency names are normalized into one graph
- package ids and manifest paths in CLI output are rendered relative to the repository root for deterministic automation
- gitignored paths and nested git worktrees are skipped during discovery
- version-group assignments are attached after discovery
- unmatched group members (declared in config but not found during discovery) produce warnings
- unresolvable group members (invalid package IDs in
group.packages) produce errors during configuration loading - discovery scans all supported ecosystems regardless of
[ecosystems.*]toggles inmonochange.toml
Ecosystems
monochange uses ecosystem adapters to translate native package-manager files into one release-planning model. Each adapter answers the same questions:
- which package manifests exist in the repository?
- which packages depend on other packages?
- where should a package version and internal dependency references be rewritten during a release?
- should lockfiles be rewritten directly, refreshed with a command, or left to external tooling?
- can monochange publish the package directly, or should publication stay external?
Capability matrix
| Ecosystem | Package type | Discovery sources | Version and dependency updates | Lockfile behavior | Built-in registry publishing |
|---|---|---|---|---|---|
| Cargo | cargo | Cargo.toml workspaces and standalone crates | Cargo.toml package versions and internal dependency requirements | Direct Cargo.lock rewrite by default; configure cargo generate-lockfile, cargo check, or another command when you need package-manager resolution | crates.io |
| npm-family | npm | npm workspaces, pnpm workspaces, Bun workspaces, and standalone package.json packages | package.json versions and dependency ranges | Direct package-lock.json, pnpm-lock.yaml, bun.lock, and bun.lockb updates by default; command overrides support package-manager refreshes | npm |
| Deno | deno | Deno workspaces and standalone deno.json / deno.jsonc packages | Deno manifest versions, exports/imports metadata, and dependency references | Direct deno.lock update when possible; no inferred lockfile command | jsr |
| Dart / Flutter | dart, flutter | Dart and Flutter workspaces plus standalone pubspec.yaml packages | pubspec.yaml versions and dependency ranges | Direct pubspec.lock update by default; configure dart pub get or flutter pub get when you need full solver refreshes | pub.dev |
| Python | python | uv workspaces, Poetry projects, and standalone pyproject.toml packages | PEP 621 [project] and Poetry [tool.poetry] package versions plus dependency specifiers | Does not mutate uv.lock or poetry.lock directly; infers uv lock and poetry lock --no-update commands; unknown Python lockfiles are skipped | pypi |
| Go | go | Standalone go.mod modules | Internal require directives in go.mod; package versions stay in VCS tags | Does not mutate go.sum directly; infers go mod tidy so the Go toolchain refreshes go.mod and checksum data | Go module proxy via VCS tags |
| GitHub Actions | github_actions | Configured [package.<id>] declarations with action.yml (or action.yaml) in the package directory | None: releases are git tags; a sibling package.json version field is synced when present | Not applicable, no lockfiles | None: the tag and GitHub release are the publish |
The built-in publishing column is intentionally narrower than release planning. It lists only the canonical public registry for each supported ecosystem; private registries and custom publication flows should use mode = "external".
Shared behavior across ecosystems
All supported ecosystems feed the same planner. After discovery, monochange can:
- render package ids and manifest paths relative to the repository root
- normalize dependency edges into one graph
- apply
[group.<id>]version synchronization rules - propagate dependent bumps through internal dependency edges
- update native manifests during
monochange run release - update extra
versioned_filesentries, including regex-managed files - render changelogs and release notes from
.changeset/*.md - create durable release records and post-merge tags
[ecosystems.<name>] configuration controls settings such as dependency-version prefixes, extra versioned files, publish defaults, and lockfile commands. Discovery still scans every supported ecosystem regardless of [ecosystems.*].enabled, roots, or exclude toggles.
Syncing internal dependency versions
monochange versions sync updates existing internal dependency references outside a release. (The bare monochange versions alias is deprecated.) It discovers workspace package versions, finds supported manifests that reference another workspace package, and rewrites those references to the canonical package version. Use --dry-run first to print the planned edits without writing files.
monochange versions sync --dry-run
monochange versions sync --strategy exact
The --strategy flag accepts default, exact, caret, or compatible. default uses each supported ecosystem’s normal constraint style; for monochange versions that means each ecosystem’s configured or default constraint style. Dart version sync scans dependencies, dev_dependencies, and dependency_overrides; when a pubspec uses resolution: workspace, path references to internal packages are converted to version constraints. npm version sync scans package dependency sections and leaves workspace:* protocol references alone. Cargo, Deno, Go, and Python manifests are also rewritten when they reference another workspace package.
Internal dependency versions lists the exact constraint each strategy writes per ecosystem. versions sync cannot write custom prefixes such as ~ or =; to stamp internal dependency references with those, use a typed versioned_files entry with an explicit prefix, or set [ecosystems.<name>] dependency_version_prefix to change the prefix typed versioned files write by default (see Versioned files).
Cargo
Cargo support is designed for Rust crates that keep version data in Cargo.toml and dependency resolution in Cargo.lock.
Use Cargo support when your repository has:
- a root Cargo workspace with
members - standalone crates outside a workspace
- internal crate dependencies that should move together when one crate is released
- crates published to
crates.io
Cargo-specific behavior:
- package ids come from each crate manifest
- dependency references use Cargo’s native requirement style; the default dependency version prefix is empty
Cargo.lockis updated directly by default for fast release preparation- incomplete or complex lockfile cases can be delegated to explicit lockfile commands
- built-in publishing targets
crates.io - publish readiness validates common crates.io requirements, including
publish,description, and license metadata
npm, pnpm, and Bun
The npm-family adapter covers JavaScript and TypeScript packages that share package.json as their manifest format.
Use npm-family support when your repository has:
- npm workspaces declared in
package.json - pnpm workspaces declared in
pnpm-workspace.yaml - Bun workspaces and Bun lockfiles
- standalone
package.jsonpackages - internal workspace dependencies that use npm-compatible version ranges
npm-family behavior:
- package ids come from
package.jsonnames - internal dependency ranges default to the
^prefix dependencies,devDependencies, andpeerDependenciesparticipate in dependency updatesmonochange versions synccan repair npm internal dependency ranges outside a release while preservingworkspace:*protocol references- direct lockfile support covers
package-lock.json,pnpm-lock.yaml,bun.lock, andbun.lockb - built-in publishing targets the public
npmregistry - GitHub npm trusted-publishing diagnostics are built in; registry-side enrollment stays manual or external, and trusted npm publishes use the
npmCLI directly
Deno
Deno support is for packages described by deno.json or deno.jsonc, including workspaces that publish to JSR.
Use Deno support when your repository has:
deno.jsonordeno.jsoncpackage manifests- Deno workspace members
importsentries that connect internal packages- packages published to
jsr
Deno behavior:
- internal dependency ranges default to
^ importsare the primary dependency fielddeno.lockcan be updated directly when present- monochange does not infer a default Deno lockfile command; configure one if your release flow needs
deno cache,deno task, or another resolver step - built-in publishing targets
jsr
Dart and Flutter
Dart and Flutter share the canonical dart ecosystem settings because both use pubspec.yaml and Pub’s version constraints. The legacy flutter config and CLI filter spelling is still accepted as an alias, but normalized package records use the Dart ecosystem with Flutter metadata.
Use Dart / Flutter support when your repository has:
- pure Dart packages
- Flutter packages with a
fluttersection - Dart or Flutter workspace layouts
- packages published to
pub.dev
Dart / Flutter behavior:
- package type is canonically
dartfor both pure Dart and Flutter packages; Flutter packages are detected frompubspec.yamlmetadata and published withflutter pub publishwhen appropriate - internal dependency ranges default to
^ dependenciesanddev_dependenciesparticipate in dependency updatesmonochange versions synccan repair Dart internal dependency ranges outside a release, including converting internalpath:references to version constraints whenresolution: workspaceis enabledpubspec.lockcan be rewritten directly by default- configure
dart pub getorflutter pub getas lockfile commands when you need the Pub solver to refresh files instead of the direct updater - built-in publishing targets
pub.dev
Python
Python support is centered on pyproject.toml. It covers modern PEP 621 projects, Poetry projects, uv workspaces, and standalone packages discovered by scanning for manifests.
Use Python support when your repository has:
- a uv workspace declared under
[tool.uv.workspace] - PEP 621 package metadata under
[project] - Poetry package metadata under
[tool.poetry] - standalone Python packages with
pyproject.toml - internal dependencies that should receive version bumps alongside released workspace packages
Python discovery works in two passes:
- If the repository root has a
pyproject.tomlwith uv workspace members, monochange expands the member globs and reads each member manifest. - monochange then scans for standalone
pyproject.tomlfiles that were not already included by the uv workspace pass.
When a manifest has both PEP 621 and Poetry metadata, monochange prefers [project]. If [project].dynamic contains "version", monochange treats the package version as dynamic and does not rewrite the version field.
Python version and dependency behavior:
- package names and dependency names are normalized using Python’s PEP 503 style normalization for dependency graph matching
- PEP 440 versions are parsed into the shared semantic-version model when possible
- internal dependency ranges default to the
>=prefix - PEP 621
dependenciesare runtime dependencies - PEP 621
optional-dependenciesare development/optional dependency edges for release-planning purposes - Poetry
dependenciesare runtime dependencies, except the specialpythonconstraint is skipped - Poetry dependency groups under
[tool.poetry.group.<name>.dependencies]are development dependencies - release preparation rewrites
pyproject.tomlpackage versions and matching dependency specifiers while preserving extras such ashttpx[cli]
Python lockfile behavior is command-based by design:
uv.lockinfersuv lockpoetry.lockinferspoetry lock --no-update- unknown Python lockfile names are ignored rather than guessed
- configuring
[ecosystems.python].lockfile_commandsoverrides the inferred commands
Built-in Python publishing targets PyPI. monochange builds Python artifacts with uv build --out-dir dist and publishes them with uv publish, using --trusted-publishing always when trusted publishing is enabled and --trusted-publishing never otherwise. Placeholder publishing creates a minimal Hatchling project with a normalized module directory under src/.
Example Python package configuration:
[package.api]
path = "services/api"
type = "python"
changelog = true
[package.api.publish]
enabled = true
mode = "builtin"
registry = "pypi"
trusted_publishing = true
[ecosystems.python]
dependency_version_prefix = ">="
# Optional: override inferred uv/Poetry lockfile commands.
lockfile_commands = [{ command = "uv lock", cwd = "." }]
Go
Go support is centered on go.mod files. Go module versions live in VCS tags rather than manifest fields, so monochange updates internal require directives and records enough metadata for tag-based publishing.
Use Go support when your repository has:
- one or more modules declared by
go.mod - multi-module layouts where submodules need path-prefixed tags such as
api/v1.2.3 - internal module dependencies that should receive version bumps alongside released workspace modules
- packages published through normal Go module proxy discovery
Go behavior:
- package ids come from the module path in the
moduledirective - internal dependency ranges default to exact Go module versions with a leading
v, matching Go module semantics requiredirectives participate in dependency updates, including groupedrequire (...)blocks- Go v2+ semantic import versioning remains encoded in module paths, not a separate manifest version field
- release planning resolves each module’s current version from its latest release tag, so
prepareandpreviewcan plan Go releases without a manifest version field go.sumis treated as checksum data, not as a lockfile to patch directly- monochange infers
go mod tidywhengo.mod/go.sumchanges need package-manager refreshes - built-in publishing creates VCS tags: root modules use
v1.2.3, while submodules use path-prefixed tags such asapi/v1.2.3 - readiness and publish checks query the Go module proxy for
<module>/@v/<version>.infovisibility
Example Go package configuration:
[package.api]
path = "services/api"
type = "go"
changelog = true
[package.api.publish]
enabled = true
mode = "builtin"
registry = "go_proxy"
trusted_publishing = false
[ecosystems.go]
# Optional: override inferred tidy commands.
lockfile_commands = [{ command = "go mod tidy", cwd = "services/api" }]
GitHub Actions
type = "github_actions" targets repositories released as a git tag plus a provider release. GitHub Actions can be consumed as npm packages, Docker images, composites, or plain scripts, so there is no registry publish step: the tag and the GitHub release are the publish.
Example configuration:
[package.actions]
path = "."
type = "github_actions"
version_format = "primary"
[source]
provider = "github"
owner = "acme"
repo = "actions"
[source.releases]
enabled = true
source = "monochange"
GitHub Actions behavior:
- the type preset implies
version_source = "tag",tag = true,release = true,publish.enabled = false, andinitial_version = "0.1.0"; every field can be overridden explicitly - discovery reads the package directory and synthesizes the release identity from
action.yml(oraction.yaml); the manifest itself carries no version - a sibling
package.jsonversionfield is synced to the released version when present; setignore_ecosystem_versioned_files = trueor declare explicitversioned_filesto control this - release tags follow
version_format; multi-action repos should keep the defaultnamespacedformat so tags look likemy-action/v1.2.3 - floating tag aliases (
floating_tags) move to each release, reproducing the commonv1.2/v1moving tags
Choosing external publishing
Use mode = "external" when an ecosystem or registry is not handled by monochange’s built-in publisher, or when your organization needs custom signing, provenance, approval, rate-limit, private-registry behavior, a Python publishing toolchain other than the built-in uv build / uv publish flow, or a Go publishing workflow that signs, pushes, or annotates tags outside monochange.
That keeps the package in release planning while leaving upload mechanics to your existing publishing workflow.
Configuration
Repository configuration lives in monochange.toml.
JSON Schema
A JSON Schema for editor support is published with the book at https://monochange.github.io/monochange/schemas/monochange.schema.json. That URL is the moving “current” alias for the latest docs. Stable generated copies use public schema-version suffixes, starting with https://monochange.github.io/monochange/schemas/monochange.v0.1.schema.json.
Schema-aware TOML editors such as Taplo can opt in with a comment directive at the top of monochange.toml:
#:schema https://monochange.github.io/monochange/schemas/monochange.schema.json
The same file is also available from GitHub raw content at https://raw.githubusercontent.com/monochange/monochange/main/docs/src/schemas/monochange.schema.json. Regenerate committed schema assets with schema:update and verify them with schema:check; lint:all runs the check in CI.
Shared documentation
This book is maintained with mdt so shared content blocks stay synchronized across pages.
- Shared blocks live in
.templates/*.t.md - Consumer files include them with
<!-- {=templateName} -->directives - Run
mdt update(ordocs:updatein this repository) after changing any template or consumer block - Run
mdt check(ordocs:check) before opening a PR to verify synchronization
When you edit a template such as .templates/cli-steps.t.md, the changes propagate to every documentation file that references it. This keeps the book, readmes, and inline help consistent without manual copying.
Defaults
[defaults]
# Severity added to a dependent package when this package changes.
# `bump_propagation` on a package or group overrides this floor.
parent_bump = "patch"
# Parsed and validated, but discovery reports private packages either way.
include_private = false
# Warn when group members carry different current versions.
warn_on_group_mismatch = true
# Conflicting explicit `version` entries across changesets: warn and pick the
# highest (false, the default) or fail planning outright (true).
strict_version_conflicts = false
# Ecosystem for [package.*] tables that omit `type`.
package_type = "cargo"
[defaults.changelog]
# `{{ path }}` is replaced with each package path.
path = "{{ path }}/changelog.md"
# `keep_a_changelog` or `monochange`.
format = "keep_a_changelog"
Packages
Declare every release-managed package explicitly.
[defaults]
package_type = "cargo"
[defaults.changelog]
path = "{{ path }}/changelog.md"
format = "keep_a_changelog"
[package.sdk-core]
path = "crates/sdk_core"
versioned_files = [
# A bare string infers the package ecosystem (`cargo` here).
"Cargo.toml",
# An explicit entry names the ecosystem, and can update another package's
# manifest when `name` identifies it.
{ path = "crates/sdk_core/extra.toml", type = "cargo" },
]
# Skip the Git tag for this package.
tag = false
# Skip the provider release for this package.
release = false
# Default tag shape, rendering `sdk-core/v1.2.3`.
version_format = "namespaced"
[package.sdk-core.changelog]
# Override the default changelog path and format for this package only.
path = "crates/sdk_core/CHANGELOG.md"
format = "monochange"
Bump propagation to dependents
Packages, groups, and the defaults section declare what a target’s own changes mean for the packages that depend on them via bump_propagation:
[defaults]
# Workspace-wide fallback for dependents with no package or group declaration.
bump_propagation = "inherit"
bump_propagation_max = "major"
[package.core]
# Dependents match this package's release severity, never exceeding `minor`.
bump_propagation = "inherit"
bump_propagation_max = "minor"
[package.tooling]
# Dependents always receive at least a minor bump from this package's changes.
bump_propagation = "minor"
[package.leaf]
# Dependents never release because of this package.
bump_propagation = "none"
[group.sdk]
# A group declaration applies to members that declare nothing.
packages = ["core"]
bump_propagation = "major"
inheritmatches the target’s own release severity: a breaking change in the package means breaking changes for its dependents.bump_propagation_maxclamps the inherited severity (only valid withinherit).- A fixed severity (
none,patch,minor,major) is a floor: dependents release at least that severity whenever the package releases.nonedisables dependency propagation entirely. - Declarations resolve most-specific-first: a package declaration overrides its group’s declaration, which overrides
[defaults].bump_propagation. Targets matching no declaration fall back to the legacy[defaults].parent_bumpfloor. Semantic compatibility evidence can still escalate beyond the declared floor.
Required fields:
pathtype, unless[defaults].package_typeis set
Supported type values:
cargonpmdenodartflutterpython
Optional package fields:
type, when[defaults].package_typeis setbump_ceilingchangelogclassification_enforcedempty_update_messagepublishversioned_filestagreleaseversion_format
version_format controls the Git tag identity used for package and group releases. It defaults to namespaced when no configuration is set, which produces collision-safe tags like my-package/v1.2.3. Set it to primary for the single top-level release identity that should use tags like v1.2.3. You can also provide a custom tag template with {{ name }}, {{ version }}, and {{ ecosystem }}:
[package.cli]
path = "crates/cli"
type = "cargo"
version_format = "{{ ecosystem }}/{{ name }}/v{{ version }}"
Custom formats must include {{ version }}, render to valid Git tag names without whitespace or other invalid ref characters, and must not collide with another release owner for the same sample version. If several packages share a custom format, include {{ name }} so the generated tags remain unique.
version_source controls where release planning reads the package’s current release version from. The default manifest reads the version field from the package manifest. Set version_source = "tag" to resolve the baseline from the latest reachable release tag matching the owner’s version_format. This is useful when the manifest carries no version, such as GitHub Actions repositories, or when the tag is the release identity:
[package.web]
path = "."
type = "npm"
version_source = "tag"
initial_version = "0.1.0"
initial_version is the baseline used when no matching release tag exists yet; without it, a tag-versioned package with no tag produces a warning and no release target.
floating_tags declares moving tag aliases that tag-release force-moves to every non-prerelease release tag, such as v1.2, v1, or latest:
[package.cli]
path = "crates/cli"
type = "cargo"
version_format = "primary"
floating_tags = ["v{{ major }}.{{ minor }}", "v{{ major }}"]
Alias templates support {{ major }}, {{ minor }}, {{ patch }}, and the version_format variables ({{ version }}, {{ name }}, {{ ecosystem }}). Floating tags are skipped for prereleases, never receive provider releases, and are excluded from baseline and previous-tag resolution.
Classification policy
bump_ceiling and classification_enforced decouple change classification from what a package is allowed to release.
bump_ceilingcaps the severity classification may propose for the package. It clamps the proposed changeset bump, the enforceable minimum, and the release floor to the ceiling, and never raises a smaller bump.classification_enforced = falsemakes classification advisory for the package. The proposal still appears in reports and change-classification comments, butmonochange changeset validate --apinever fails on its behalf.
Both fields resolve most-specific-first: a package declaration overrides its group’s declaration, and a group declaration applies to members that do not declare their own. Set the policy on a group when a whole family of packages shares it.
[group.main]
packages = ["cli", "docs-site"]
[package.docs-site]
path = "packages/docs-site"
type = "npm"
# Prose-only package: keep the advisory proposal at patch and never block a
# release on classification.
bump_ceiling = "patch"
classification_enforced = false
changelog accepts three forms on packages:
true→ use{{ path }}/CHANGELOG.mdfalse→ disable the package changelog"some/path.md"→ use that exact path
[defaults].changelog also accepts three forms:
true→ default every package to{{ path }}/CHANGELOG.mdfalse→ default every package to no changelog"{{ path }}/changelog.md"or another pattern → replace{path}with each package path
A package-level changelog value overrides the default for that package.
The table form also accepts initial_header. monochange renders this Markdown only when a changelog file is created from empty content. Existing changelog preambles are preserved and are not rewritten on later releases. If initial_header is omitted or blank, monochange uses the selected format’s built-in header: keep_a_changelog gets the Keep a Changelog/SemVer preamble, and monochange gets the monochange-managed preamble. Package and group changelog tables can override the default header.
[defaults.changelog]
path = "{{ path }}/changelog.md"
format = "keep_a_changelog"
initial_header = """
# Changelog
All notable changes to this project will be documented in this file.
This changelog is managed by [monochange](https://github.com/monochange/monochange).
"""
initial_header templates can use release context such as {{ monochange_version }}, {{ config_path }}, {{ monochange_config_path }}, {{ workspace_root }}, {{ changelog_path }}, {{ changelog_format }}, {{ package }}, {{ package_name }}, {{ package_id }}, {{ package_path }}, {{ group }}, {{ group_name }}, {{ group_id }}, {{ member_count }}, {{ members }}, {{ release_owner }}, {{ release_owner_kind }}, {{ version }}, {{ new_version }}, and {{ current_version }}.
empty_update_message lets changelog targets render a readable fallback entry when a version update is required but no direct release notes were recorded for that target. This is especially useful for grouped packages that keep their own changelog entries even when only another member of the group changed.
empty_update_message can be set on:
[defaults][package.<id>][group.<id>]
Per-package changelog overrides ([package.<id>.changelog]) can also customize sections:
[defaults][package.<id>][group.<id>]
Defaults are inherited by packages and groups; package/group definitions append target-specific sections on top of the workspace defaults.
Template placeholders may include:
{{ package }}/{{ package_name }}{{ package_id }}{{ group }}/{{ group_name }}{{ group_id }}{{ version }}/{{ new_version }}{{ current_version }}/{{ previous_version }}{{ bump }}{{ trigger }}{{ ecosystem }}{{ release_owner }}/{{ release_owner_kind }}{{ members }}/{{ member_count }}for group changelogs{{ reasons }}
Fallback order:
- package changelog entries: package → group → defaults → built-in message
- group changelog entries: group → defaults → built-in message
The built-in grouped-package fallback reads:
No package-specific changes were recorded;
{{ package }}was updated to {{ version }} as part of group{{ group }}.
Prerelease mode
Enable [prerelease] when you want repeatable alpha/rc/binary prerelease builds before the final stable release. Prerelease mode writes SemVer prerelease versions into manifests by default, keeps changesets for the later stable release, skips changelog file updates by default, and does not publish packages unless explicitly enabled.
[prerelease]
enabled = true
channel = "alpha"
numbering = "increment" # increment | date | datetime
base = "planned" # planned | current-stable | fixed
base_version = "0.0.0" # required when base = "fixed"
branches = [
"next",
"prerelease/*",
] # optional; overrides [source.releases].branches for tag/publish checks while enabled
write_manifests = true
keep_changesets = true
changelog = false
release_notes = true
publish_packages = false
Base strategies:
planned: compute the next stable version from changesets and dependency propagation, then append the prerelease suffix.current-stable: use the current/original stable manifest version as the prerelease base.fixed: usebase_version, which is useful for binary/nightly workflows such as0.0.0-alpha.0.
When no changesets exist, prerelease mode still synthesizes release decisions from discovered packages and version groups. Repeated prerelease runs persist state in .monochange/prerelease-state.json so a series advances from alpha.0 to alpha.1 without repeatedly reapplying the same stable bump. Set branches when prerelease tag/publish workflow steps should be allowed from a different branch set than stable releases. Disable prerelease mode for the final stable release; successful stable preparation removes the state file. If prerelease mode is disabled and .monochange/prerelease-state.json is still present, validation/check fails so stale prerelease state is not ignored.
Prerelease release notes
release_notes = true (the default) renders hosted release notes for each prerelease even though changelog = false skips changelog file updates. Notes come from the same configured [changelog.outputs] artifacts that stable releases use, so [source.releases].changelog_output selects the prerelease body too.
Because keep_changesets = true leaves earlier changeset files in place, each prerelease reports only the changesets that were added since the previous prerelease. A changeset already covered by an earlier prerelease in the same series is omitted from later notes, and editing the body of an already reported changeset does not make it reappear. The already reported changesets are tracked in .monochange/prerelease-state.json under release_note_changesets.
Two situations restart the series and present every pending change again:
- Changing
channel, so the firstbetaprerelease does not look like an empty delta after a series ofalphaprereleases. - Removing
.monochange/prerelease-state.json, which makes the next prerelease the first of a new series.
keep_changesets = false consumes the changesets instead, so the delta is naturally empty after the first prerelease and release_notes has nothing left to report.
Prerelease versions and floating tags
A prerelease version never moves a floating_tags alias. Aliases such as v1 or latest keep pointing at the newest stable release commit while a prerelease series is active, and only a stable release repoints them.
Switching channel also restarts the increment sequence. With numbering = "increment", a series at 1.1.0-alpha.4 becomes 1.1.0-beta.0 after switching to beta rather than continuing at beta.5.
Rust semantic compatibility
Rust packages can opt into cargo-semver-checks when change classify runs at the semantic detection level:
[ecosystems.cargo.semver_checks]
enabled = true
timeout_seconds = 300
[[ecosystems.cargo.semver_checks.matrix]]
name = "default"
feature_mode = "default"
[[ecosystems.cargo.semver_checks.matrix]]
name = "all-features"
feature_mode = "all"
[[ecosystems.cargo.semver_checks.matrix]]
name = "linux-no-defaults"
feature_mode = "none"
features = ["server"]
target = "x86_64-unknown-linux-gnu"
Each matrix cell defines one supported feature and compilation-target contract. Feature mode accepts default, all, none, or heuristic. features applies to both endpoints; baseline_features and current_features support intentional feature renames and migrations. Cell names must be unique. A matrix has 1–16 cells, and timeout_seconds is 1–1800 seconds per cell.
Install cargo-semver-checks and every configured Rust target before classification. The analyzer reports failures as incomplete evidence and retains monochange’s conservative syntax result. It never silently treats a missing tool or failed target build as compatible.
cargo install cargo-semver-checks --locked
rustup target add x86_64-unknown-linux-gnu
monochange change classify --detection-level semantic --format json
cargo-semver-checks executes Cargo builds, including build scripts and procedural macros, from both comparison endpoints. Use least-privilege CI credentials and do not run this analysis for untrusted pull-request code through pull_request_target.
Package publishing
Built-in package publishing is configured through publish on packages and ecosystems.
[ecosystems.npm.publish]
enabled = true
mode = "builtin"
registry = "npm"
trusted_publishing = true
[ecosystems.npm.publish_order]
dependency_fields = ["dependencies", "devDependencies", "peerDependencies", "catalogDependencies"]
[ecosystems.npm.publish.trusted_publishing]
workflow = "publish.yml"
environment = "publisher"
[ecosystems.npm.publish.attestations]
require_registry_provenance = true
[package.web.publish]
mode = "builtin"
[package.web.publish.placeholder]
readme_file = "docs/web-placeholder.md"
[package.legacy.publish]
trusted_publishing = false
Supported fields:
enabled- include this package in managed publishingmode-builtinorexternal. Whenbuiltin(the default), monochange’s built-in publisher handles release publishing. Whenexternal, monochange skips the package during release publishing (PublishPackages): your own CI or scripts handle release publishing instead. Themodesetting does not affect placeholder publishing (PlaceholderPublish), which processes all packages withpublish.enabled = true.registry- public registry override for the package ecosystemtrusted_publishing-true/falseor a table withenabled,repository,workflow, andenvironmentattestations.require_registry_provenance- require registry-native package provenance when the selected registry/provider capability supports itrate_limits.enforce- block built-in publish runs when the selected package set exceeds a known single registry windowfail_on_duplicate- fail the publish step when a version is already published on the registry instead of skipping it (default:false); the built-inpublish-packagesstep exposes the same policy as the--fail-on-duplicateCLI input for a single runtimeout.timeout_seconds- maximum seconds a single package publish command may run before it is killed and retried; set to0to disable the timeout (default:300)timeout.retries- number of times to retry a publish command that times out before reporting the package as failed (default:2)placeholder.readme- inline placeholder README contentpublish_order.dependency_fields- ecosystem-level dependency fields used to topologically order package publishesplaceholder.readme_file- workspace-relative file to use as placeholder README content
Inheritance flows from [ecosystems.<name>.publish] to matching packages, and package-level values override the inherited ecosystem defaults. Configure shared trusted-publishing, attestation, and context policy on the ecosystem, then use package-level publish settings for opt-outs or package-specific workflows.
Built-in publishing targets only the canonical public registry for each supported ecosystem:
- Cargo →
crates.io - npm packages →
npm - Deno packages →
jsr - Dart / Flutter packages →
pub.dev - Python packages →
pypi - Go modules →
go_proxyvia VCS tags
Private registries and custom publication flows are still external. For those packages, set mode = "external" and handle release publication outside monochange. Placeholder publishing (monochange step placeholder-publish) still works for external-mode packages because it is a bootstrap utility, not a release publishing step.
Placeholder publishing
monochange step placeholder-publish exists for the bootstrap case where a package must already exist in the registry before you can finish automation setup such as trusted publishing.
Placeholder publishing works for all packages with publish.enabled = true, including those set to publish.mode = "external". The mode field controls who handles release publishing (monochange’s built-in publisher vs your own CI/scripts); it does not affect placeholder publishing because that is a one-time bootstrap utility, not a release step. To opt out of placeholder publishing entirely, set publish.enabled = false.
For each publishable package, monochange:
- checks whether the package already exists in its configured public registry
- skips packages that already exist
- publishes a placeholder package only for packages that are missing
- uses version
0.0.0 - renders a default placeholder README unless
placeholder.readmeorplaceholder.readme_fileoverrides it
placeholder.readme and placeholder.readme_file are mutually exclusive. If both are set, config validation fails.
Publish order dependency fields
publish_order.dependency_fields controls which manifest dependency fields create publish-order edges for an ecosystem. npm defaults to dependencies and devDependencies, so peer packages do not block publishing unless opted in. Cargo defaults stay dependencies, dev-dependencies, and build-dependencies. Deno defaults to dependencies and imports, Dart/Flutter default to dependencies and dev_dependencies, Python defaults to dependencies, and Go defaults to require. Optional Python extras (optional-dependencies) and Poetry groups (group.dependencies) only affect publish order when you opt in.
[ecosystems.npm.publish_order]
# Add peer and custom package.json fields.
dependency_fields = ["dependencies", "devDependencies", "peerDependencies", "catalogDependencies"]
[ecosystems.npm.publish_order]
# Or remove devDependencies from publish ordering.
dependency_fields = ["dependencies"]
[ecosystems.python.publish_order]
# Include optional dependency groups in Python publish ordering.
dependency_fields = ["dependencies", "optional-dependencies", "group.dependencies"]
[ecosystems.go.publish_order]
# An empty list disables Go require-based publish ordering.
dependency_fields = []
The same resolved policy is used by monochange step plan-publish-rate-limits and monochange step publish-packages.
Trusted publishing
trusted_publishing lets you tell monochange that package publication is expected to come from a verified GitHub Actions context.
[ecosystems.npm.publish]
trusted_publishing = true
[ecosystems.npm.publish.trusted_publishing]
repository = "owner/repo"
workflow = "publish.yml"
environment = "publisher"
[package.cli.publish.trusted_publishing]
workflow = "publish-cli.yml"
[package.legacy.publish]
trusted_publishing = false
When trusted_publishing is enabled:
- npm package publishing must run from a verifiable CI/OIDC identity and must not use long-lived npm token environment variables
- npm trusted-publisher enrollment is manual or external: monochange can render the expected
npm trust github ...repair command and verify the GitHub workflow context, butmonochange step publish-packagesdoes not runnpm trustautomatically - trusted npm publishing uses the
npmCLI directly; pnpm workspaces still use pnpm for non-trusted npm publishing paths - Cargo,
jsr,pub.dev, andPyPIalso require manual trusted-publishing setup; monochange reports the setup URL and blocks built-in release publishing until trust is configured
Attestation policy
publish.attestations.require_registry_provenance is separate from publish.trusted_publishing. Trusted publishing must be enabled first, then the attestation policy tells monochange to require registry-native package provenance where the selected registry and CI provider support it.
[ecosystems.npm.publish]
trusted_publishing = true
[ecosystems.npm.publish.attestations]
require_registry_provenance = true
[package.legacy.publish.attestations]
require_registry_provenance = false
monochange treats npm provenance and JSR package provenance as enforceable built-in registry provenance. PyPI PEP 740 attestations are modeled in the capability matrix, but require_registry_provenance is rejected for PyPI until the built-in Python publisher exposes a publish command that can require uploading those attestations. crates.io, pub.dev, Go proxy publishing, and custom registries are also rejected when this requirement is enabled because monochange cannot verify equivalent registry-native package attestations for those flows.
GitHub release asset attestations are a separate release policy under [source.releases.attestations] and are valid only for the GitHub source provider:
[source.releases.attestations]
require_github_artifact_attestations = true
For a GitHub-focused setup guide with exact registry fields, commands, and workflow requirements, see Trusted publishing and OIDC. For monorepo workflow and tag-shape recommendations, see Multi-package publishing patterns.
monochange resolves the GitHub trust context from:
- explicit
repository,workflow, andenvironmentvalues in config - otherwise
[source]plus GitHub Actions environment such asGITHUB_WORKFLOW_REFandGITHUB_JOB - and, when possible, the workflow job environment declared in
.github/workflows/<file>.yml
If monochange cannot determine the GitHub repository or workflow for an npm package, it cannot render a precise npm trust github ... repair command or verify the expected GitHub context.
Implementation limits
The built-in package publishing flow is intentionally narrow:
- no private or custom registry support in
mode = "builtin" - rate-limit planning can batch work and enforce single-window safety, but monochange still does not sleep across windows or requeue later batches automatically
- registry-side trusted-publisher enrollment is still manual for every registry; npm is special only because monochange can render and verify the GitHub setup context
If your workflow needs any of these, keep the package on mode = "external" and let your own CI or scripts own publication.
For end-to-end GitHub and GitLab examples - including npm trusted publishing on GitHub and token/external-mode patterns on GitLab - see Advanced: CI, package publishing, and release PR flows.
Groups
Groups own outward release identity for their member packages.
[group.sdk]
packages = ["sdk-core", "web-sdk", "mobile-sdk"]
changelog = "changelog.md"
versioned_files = [{ path = "group.toml", type = "cargo" }]
tag = true
release = true
version_format = "primary"
Rules:
- group members must already be declared under
[package.<id>] - package and group ids share one namespace
- a package may belong to only one group
- only one package or group may use
version_format = "primary" - custom
version_formattemplates must include{{ version }}and render unique, valid Git tag names; include{{ name }}when sharing a template across release owners - group
tag,release, andversion_formatoverride member package release identity - package changelogs and package
versioned_filesstill apply when grouped - grouped packages can customize fallback changelog entries with
empty_update_messagewhen no direct package notes are present [group.<id>.changelog].includecan filter which member-targeted changesets appear in the group changelog without changing release planning or package changelogs
For grouped changelog filtering, use the changelog table form:
[group.sdk.changelog]
path = "docs/sdk-changelog.md"
include = ["sdk-cli"]
include accepts:
"all"- include direct group-targeted changesets and all member-targeted changesets (default)"group-only"- include only direct group-targeted changesets[]or["package-id", ...]- include direct group-targeted changesets plus member-targeted changesets only when every target in that group is listed
Versioned files
versioned_files are additional managed files beyond native manifests.
Examples:
# package-scoped shorthand infers the package ecosystem
versioned_files = ["Cargo.toml"]
versioned_files = ["**/crates/*/Cargo.toml"]
# explicit typed entries remain available
versioned_files = [{ path = "group.toml", type = "cargo", name = "sdk-core" }]
versioned_files = [{ path = "docs/version.txt", type = "cargo" }]
versioned_files = [
{ path = "Cargo.toml", type = "cargo", fields = ["workspace.metadata.bin.monochange.version"], prefix = "" }, # bare version, e.g. 1.2.3
]
versioned_files = [
{ path = "package.json", type = "npm", fields = ["metadata.bin.monochange.version"] },
]
# generic format entries update explicit fields in non-ecosystem files
versioned_files = [
{ path = "metadata.json", format = "json", fields = ["release.version"] },
{ path = "tools.toml", format = "toml", fields = ["tool.sdk.version"] },
{ path = "pubspec-overrides.yaml", format = "yaml", fields = ["metadata.sdkVersion"] },
{ path = ".env", format = "env", fields = ["VERSION"] },
]
# ecosystem-level defaults inherited by matching packages
[ecosystems.npm]
versioned_files = ["**/packages/*/package.json"]
Typed manifest entries can update dependency sections and arbitrary string fields inside TOML or JSON manifests. Dependency targets in versioned_files must reference declared package ids. Groups must use explicit typed entries because monochange cannot infer a group ecosystem from a bare string.
Value templates
A versioned file can render a whole value instead of the plain version. This is how a store build number reaches a pubspec.yaml or an Expo app.json:
[[package.app.versioned_files]]
path = "pubspec.yaml"
type = "dart"
value_template = "{{ identity }}+{{ build }}"
The template sees the full variable namespace, including values declared with [package.<id>.values.<id>]. See Release values and version schemes.
A package’s own ecosystem manifest must stay a plain SemVer, so value_template on that path rejects calendar, ordinal, and counter variables with a configuration error. Move store values into a separate versioned file, or set version_source = "tag" when the manifest cannot carry a SemVer version at all.
Dependency prefixes
Typed entries write internal dependency references with a range prefix. Set prefix on an entry to control it exactly. Accepted values are "^", "~", ">=", "=", "v", or "" for a bare version:
versioned_files = [
# write internal npm dependencies as tilde ranges, e.g. "~1.2.3"
{ path = "package.json", type = "npm", fields = ["dependencies"], prefix = "~" },
]
Resolution order for the prefix:
- the entry’s
prefix [ecosystems.<type>] dependency_version_prefix(see the[ecosystems.*]reference in Ecosystems)- the ecosystem default:
^for npm, deno, and dart;>=for python;vfor go; empty for cargo
The prefix applies to internal dependency references only. The package’s own version field is written without it. format entries ignore prefix and always write the bare version, and regex entries cannot set prefix. monochange versions sync --strategy uses its own fixed per-ecosystem prefixes and ignores dependency_version_prefix; see Internal dependency versions for that table.
Format versioned files
Use format when a version lives in a structured or key/value file that should not receive ecosystem-specific dependency handling. Supported values are json, toml, yaml, yml, and env.
[package.core]
path = "crates/core"
versioned_files = [
{ path = "metadata.json", format = "json", fields = ["release.version"] },
{ path = ".env", format = "env", fields = ["VERSION"] },
]
Key rules:
formatentries cannot settypeorregexfieldsis required and must name every value to update; monochange does not infer ecosystem defaults in format mode- JSON, TOML, YAML, and YML fields use dot-separated object/table paths such as
release.version - env fields use exact keys such as
VERSIONand update existingKEY=valueorexport KEY=valuelines - field names can include
{{ name }}and{{ version }}placeholders for simple context-aware paths or keys
Regex versioned files
Regex entries let you version-stamp any plain-text file, such as README badges, download links, or install scripts, without needing an ecosystem-specific parser. The regex must contain a named version capture group; monochange replaces the captured substring with the new version while preserving the surrounding text.
[package.core]
path = "crates/core"
versioned_files = [
# update a download link in the README
{ path = "README.md", regex = 'https://example\.com/download/v(?<version>\d+\.\d+\.\d+)\.tgz' },
# update a version badge
{ path = "README.md", regex = 'img\.shields\.io/badge/version-(?<version>\d+\.\d+\.\d+)-blue' },
]
[group.sdk]
packages = ["core", "cli"]
versioned_files = [
# update the install script across all packages (glob pattern)
{ path = "**/install.sh", regex = 'SDK_VERSION="(?<version>\d+\.\d+\.\d+)"' },
]
[ecosystems.cargo]
versioned_files = [
# update a workspace-wide version constant
{ path = "crates/constants/src/lib.rs", regex = 'pub const VERSION: &str = "(?<version>\d+\.\d+\.\d+)"' },
]
Key rules:
regexentries cannot settype,prefix,fields, orname: they operate on raw text- the regex must include a
(?<version>...)named capture group - the
pathfield supports glob patterns (e.g.**/README.md) - regex entries work on packages, groups, and ecosystem-level
versioned_files
Lockfile commands
By default monochange rewrites supported lockfiles directly from the release plan. That keeps normal monochange run release runs close to --dry-run speed instead of launching package managers just to rewrite workspace version strings.
Built-in direct lockfile updates cover:
- Cargo:
Cargo.lock - npm-family:
package-lock.json,pnpm-lock.yaml,bun.lock, andbun.lockb - Deno:
deno.lock - Dart / Flutter:
pubspec.lock
For Python projects, monochange infers package-manager lockfile commands instead of mutating lockfiles directly: uv.lock uses uv lock, and poetry.lock uses poetry lock --no-update. Unknown Python lockfile names are skipped rather than guessed.
If you configure lockfile_commands for an ecosystem, monochange stops using the built-in direct updater for that ecosystem and those commands fully own lockfile refresh. Use that escape hatch only when your workspace needs package-manager-side regeneration beyond version rewrites.
For Cargo specifically, monochange no longer falls back to cargo generate-lockfile automatically when a lockfile looks incomplete. That keeps monochange run release on the fast path and leaves the final dependency-resolution refresh under your control: either configure [ecosystems.cargo].lockfile_commands explicitly or run cargo generate-lockfile / cargo check yourself afterwards.
If you want to measure that tradeoff before opting into a refresh command, run the prepare_release_apply_cargo_lockfile_refresh Criterion benchmark. It compares the default direct_rewrite path against an explicit full_refresh_command run on the same synthetic Cargo workspace.
[ecosystems.npm]
lockfile_commands = [
{ command = "pnpm install --lockfile-only", cwd = "packages/web" },
{ command = "npm install --package-lock-only", cwd = "packages/legacy", shell = true },
]
cwd is resolved relative to the workspace root. shell = false runs the command directly, shell = true uses sh -c, and shell = "bash" uses a custom shell binary.
CLI commands
CLI workflow commands are user-defined commands that run as monochange run <command>. Each [cli.<command>] table in monochange.toml defines one workflow with its own help text, inputs, and ordered step list.
monochange init writes a minimal starter config and does not seed default [cli.*] workflow aliases. Add [cli.<command>] tables only for repository-specific workflows that need to chain multiple steps, expose custom names, or run shell Command steps.
Built-in steps are also available directly as immutable monochange step <name> commands. The binary generates those commands from the step schemas, so monochange step discover, monochange step prepare-release, monochange step affected-packages, and the other step commands do not require config entries. Use monochange step <name> in CI when you want a stable built-in operation without depending on a repository-defined wrapper.
Some top-level names are reserved for binary commands, including init, mcp, help, version, analyze, check, and step. The step command namespace is reserved for immutable built-in step commands, and run is reserved for executing configured workflows. Do not define [cli.step] or [cli.run] tables.
Explicit step input inheritance
Config-defined workflow commands have two input layers:
[[cli.<command>.inputs]]declares the flags and arguments accepted bymonochange run <command>.inputson each step decides which of those parsed command inputs are visible while that step runs.
Command inputs are not inherited automatically. A step receives a command input only when the step explicitly lists it. This makes wrappers predictable when a command-level flag and a step-specific input share the same name.
Use the array shorthand when a step should inherit command inputs unchanged:
[cli.discover]
inputs = [
{ name = "format", type = "choice", choices = ["text", "json", "json-min"], default = "text" },
]
steps = [
{ type = "Discover", inputs = ["format"] },
]
Use the map form when a step needs fixed values, renamed values, templates, or a mixture of inherited and overridden values:
[cli.release-pr]
inputs = [
{ name = "format", type = "choice", choices = ["text", "json", "json-min", "markdown"], default = "text" },
{ name = "open_as_draft", type = "boolean", default = false },
]
steps = [
{ type = "PrepareRelease", inputs = ["format"] },
{ type = "OpenReleaseRequest", inputs = { format = "markdown", draft = "{{ inputs.open_as_draft }}" } },
]
Step-local when expressions and command templates evaluate against the same explicit step input context. If a when condition references inputs.publish, the step must include publish in its inputs array or map. Use inputs = ["publish"] for unchanged inheritance, or inputs = { publish = "{{ inputs.publish }}" } when you need the map form for other overrides.
Override values in the map form accept native TOML literals: strings, booleans, integers, and floats. Booleans stay booleans in the parsed model and are stringified to "true"/"false" when the step runs; numbers are coerced to their string form at parse time, so writing { jobs = 4, ratio = 2.5 } is exactly the same as writing { jobs = "4", ratio = "2.5" }:
[[cli.release.steps]]
name = "publish"
type = "Command"
command = "npm publish --jobs {{ inputs.jobs }}"
inputs = { jobs = 4, dry_run = true }
Interactive command steps
Add an explicit interactive boolean input to the command, pass it to a Command step, and run the workflow with --interactive when you want the command to own the terminal. The step inherits stdio for that run, so prompts and terminal UIs work, and the progress spinner is suppressed while the command runs.
[cli.publish]
help_text = "Publish packages"
[[cli.publish.inputs]]
name = "interactive"
type = "boolean"
default = false
[[cli.publish.steps]]
name = "publish"
type = "Command"
command = "npm publish"
inputs = ["interactive"]
monochange run publish --interactive
Leave interactive unset (the default) for CI and scripted runs so those commands stay non-interactive. Interactive steps do not capture output: steps.<id>.stdout and steps.<id>.stderr are empty for them, so downstream steps cannot read what an interactive command printed.
Built-in monochange step <name> commands are different: they are generated directly from the step schema, so their CLI flags map to that single step without a [cli.*] wrapper.
[changelog]
templates = [
"#### {{ summary }}\n\n{{ details }}\n\n{{ context }}",
"#### {{ summary }}\n\n{{ context }}",
"#### {{ summary }}\n\n{{ details }}",
"- {{ summary }}",
]
[package.core]
path = "crates/core"
[package.sdk-core.changelog.types]
security = { bump = "patch", section = "Security" }
[cli.discover]
help_text = "Discover packages across supported ecosystems"
[[cli.discover.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.discover.steps]]
name = "discover packages"
type = "Discover"
inputs = ["format"]
[cli.release]
help_text = "Prepare a release from discovered change files"
[[cli.release.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.release.steps]]
name = "prepare release"
type = "PrepareRelease"
inputs = ["format"]
[cli.publish-release]
help_text = "Prepare a release and publish provider releases"
[[cli.publish-release.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.publish-release.steps]]
name = "prepare release"
type = "PrepareRelease"
inputs = ["format"]
[[cli.publish-release.steps]]
name = "publish release"
type = "PublishRelease"
inputs = ["format"]
[[cli.publish-release.steps]]
name = "comment released issues"
type = "CommentReleasedIssues"
[cli.release-pr]
help_text = "Prepare a release and open or update a provider release request"
[[cli.release-pr.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.release-pr.steps]]
name = "prepare release"
type = "PrepareRelease"
inputs = ["format"]
[[cli.release-pr.steps]]
name = "open release request"
type = "OpenReleaseRequest"
inputs = ["format"]
[cli.affected]
help_text = "Evaluate pull-request changeset policy"
[[cli.affected.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.affected.inputs]]
name = "changed_paths"
type = "string_list"
required = true
[[cli.affected.inputs]]
name = "label"
type = "string_list"
[[cli.affected.steps]]
name = "evaluate affected packages"
type = "AffectedPackages"
inputs = ["format", "changed_paths", "label"]
CLI command interpolation variables:
- built-in command variables are available directly as
{{ version }},{{ group_version }},{{ released_packages }},{{ changed_files }}, and{{ changesets }} - command templates can read CLI inputs through
{{ inputs.name }} - every step can override the inputs it receives with
inputs = { ... }; direct references like"{{ inputs.labels }}"preserve list and boolean values when rebinding to built-in steps - built-in commands already attach descriptive step
namelabels such asprepare releaseandpublish release; keep or replace those labels when you want progress output to stay readable - custom command variables become available when
variablesis present: map your own names to variables such asversion,group_version,released_packages,changed_files, andchangesets always_run = trueon any step causes it to run even when a previous step has failed, which is useful for cleanup, notification, or dry-run preview stepsupdate_release_json = trueon aCommitReleasestep allows the step to create or overwrite the release record file when it is missing or differs from the expected content; the default (false) treats a missing or mismatched record as an errordry_run_commandon aCommandstep replacescommandonly when the CLI command is run with--dry-rundry_run = trueon a[cli.<command>]table forces the entire command to run in dry-run mode even when the user does not pass--dry-runshell = trueruns the command through the current shell; the default mode runs the executable directly after shell-style splitting
Performance tip: keep the default monochange run release path focused on built-in steps such as PrepareRelease. Arbitrary Command steps shell out to external tools, so expensive follow-up work like formatting, validation, publishing, or pushes should usually be gated behind an explicit input such as when = "{{ inputs.commit }}" if you want local release preparation to stay sub-second.
RetargetRelease is intentionally different from PrepareRelease-driven steps. It operates from git history plus source/provider information, discovers the durable ReleaseRecord, and then exposes structured retarget.* outputs for later command steps.
See Repairable releases for when to use monochange step retarget-release versus publishing a new patch release.
Release titles
Every release renders two titles from minijinja templates: the release title becomes the provider release name (the GitHub, GitLab, Gitea, or Forgejo release heading), and the changelog version title becomes the ## heading at the top of that release’s entry in each changelog file. They answer different questions — the provider release is attached to its tag and covers one release, while a changelog file spans every version — so they are configured separately.
Each title resolves most-specific-first: release_title (or changelog_version_title) on the package or group, then [defaults].release_title (or [defaults].changelog_version_title), then a built-in default chosen by the owner’s version_format:
| Owner version format | Built-in release title | Built-in changelog version title |
|---|---|---|
primary | v{{ version }} ({{ date }}) | [{{ version }}]({{ tag_url }}) ({{ date }}) when a source is configured, otherwise {{ version }} ({{ date }}) |
namespaced | {{ id }} v{{ version }} ({{ date }}) | {{ id }} [{{ version }}]({{ tag_url }}) ({{ date }}) when a source is configured, otherwise {{ id }} {{ version }} ({{ date }}) |
Both templates render with these variables:
{{ id }}— the release owner: the package or group id{{ version }}— the planned version, without avprefix{{ previous_version }}— the version of the previous release tag, empty for a first release{{ date }},{{ time }},{{ datetime }}— the release date and time{{ changes_count }}— the number of changesets in the release{{ tag_url }}— the URL of the release tag on the provider{{ compare_url }}— the provider comparison URL between the previous tag and this one
[defaults]
# Name every release after its owner with the tag-style version.
release_title = "{{ id }} v{{ version }} ({{ date }})"
[group.sdk]
release_title = "SDK {{ version }} ({{ date }})" # override for one group
Titles are rendered once, when the release is prepared. The rendered release title is persisted in the release record, so publishing the provider release from git history (monochange step publish-release --from-ref HEAD) replays the exact prepare-time title and date; records written before schema v0.9 carry no persisted title and fall back to the built-in default for the target’s version format, dated from the record.
GitHub release settings
Use [source] plus [source.releases] when you want command steps such as PublishRelease to derive repository release payloads from the prepared release. GitHub remains the default provider when provider is omitted. Add [source.releases] to restrict tag and publish operations to commits reachable from allowed release branches; branches accepts multiple names and glob patterns such as release/*.
The [source] section configures provider integration for releases, pull requests, and changeset enforcement. GitHub is the default provider when provider is omitted.
For self-hosted instances, set api_url or host to your server’s URL. These fields must use https://. Insecure http:// schemes are rejected because API tokens would be transmitted in cleartext.
[source]
provider = "github"
owner = "ifiokjr"
repo = "monochange"
# Optional: GitHub Enterprise or a self-hosted instance.
# api_url = "https://github.company.com/api/v3"
[source.releases]
enabled = true
# Create the provider release as a draft.
draft = false
# Mark the provider release as a prerelease.
prerelease = false
# Render the release body from monochange notes.
source = "monochange"
# Restrict tag and publish operations to commits reachable from these branches.
branches = ["main", "release/*"]
# Refuse to tag from a non-matching branch.
enforce_for_tags = true
# Refuse to publish from a non-matching branch.
enforce_for_publish = true
# Allow release commits from any branch.
enforce_for_commit = false
changeset_context_timeout_seconds = 120
[source.pull_requests]
enabled = true
# Head branch for the release PR.
branch_prefix = "monochange/release"
base = "main"
title = "chore(release): prepare release"
# Optional: override the release commit subject while keeping `title` for the
# release PR title. When omitted, the commit subject falls back to `title`.
# commit_subject = "chore(release): prepare release"
labels = ["release", "automated"]
auto_merge = false
# How much of the release notes the release PR body carries.
# "full" inlines every target's notes; "summary" renders only the header,
# the target list, and the changelog paths.
body_style = "full"
# Optional: cap the rendered release PR body in characters. Defaults to the
# provider's own limit (65536 for GitHub). Notes that do not fit are dropped
# from the end and replaced with a pointer to the changelog files.
# max_body_chars = 65536
[changesets.affected]
enabled = true
# Fail the check when coverage is missing.
required = true
skip_labels = ["no-changeset-required"]
# Explain the failure on the pull request.
comment_on_failure = true
changed_paths = ["crates/**", "packages/**", "npm/**", "skills/**"]
ignored_paths = [
"docs/**",
"specs/**",
"readme.md",
"CONTRIBUTING.md",
"license",
]
[changesets.classification]
# Labels that skip `monochange change classify` for the pull request.
skip_labels = ["release"]
Ecosystem settings
These settings are parsed from config and document intended control points for discovery:
Each key below is parsed and validated. enabled, roots, and exclude do not currently filter discovery, so treat them as declared intent rather than an active filter.
[ecosystems.cargo]
enabled = true
roots = ["crates/*"]
exclude = ["crates/experimental/*"]
# Range operator written into internal Cargo dependency references.
dependency_version_prefix = "^"
# Extra files to version-stamp in matching packages.
versioned_files = ["Cargo.toml"]
# Configuring this replaces the built-in direct lockfile rewrite for Cargo.
lockfile_commands = [{ command = "cargo generate-lockfile" }]
[ecosystems.npm]
enabled = true
roots = ["packages/*"]
exclude = ["packages/legacy/*"]
dependency_version_prefix = "^"
versioned_files = ["**/packages/*/package.json"]
lockfile_commands = [
# `cwd` is relative to the workspace root.
{ command = "pnpm install --lockfile-only", cwd = "packages/web" },
]
[ecosystems.deno]
enabled = true
# Deno has no inferred lockfile command.
[ecosystems.dart]
enabled = true
lockfile_commands = [{ command = "flutter pub get", cwd = "packages/mobile" }]
[ecosystems.python]
enabled = true
# Without an explicit command, `uv.lock` uses `uv lock` and `poetry.lock` uses
# `poetry lock --no-update`. Other lockfile names are skipped.
lockfile_commands = [{ command = "uv lock" }]
[ecosystems.go]
enabled = true
# monochange infers `go mod tidy` for go.mod and go.sum refreshes.
lockfile_commands = [{ command = "go mod tidy" }]
Changelog configuration
When [defaults].package_type is set, package entries may omit an explicit type.
Existing package and group changelogs support two appendable Markdown formats:
monochangekeeps the current heading-and-bullets layoutkeep_a_changelogrenders section headings such as### Features,### Fixes, and### Breaking changes
Defaults can set a repository-wide changelog path pattern and format, while package and group changelog tables can override either field.
Release-note streams and outputs
Streams separate the wording intended for different audiences without changing the changeset syntax. The built-in default stream always exists and preserves the current developer-oriented changelog behavior. A custom type opts into another stream with stream; types that omit it continue to use default.
Each changeset file resolves to exactly one stream. If one implementation needs both developer-facing detail and user-facing wording, author two small changesets and choose a type from each stream. This keeps each entry understandable on its own and prevents internal details from leaking into product notes.
[changelog.streams.user]
description = "Product release notes for app users"
[changelog.sections.native]
heading = "Native releases"
# Lower priority renders first and wins when one changeset targets several
# packages with different types.
priority = 5
[changelog.sections.app_features]
heading = "App features"
priority = 10
[changelog.types.native]
bump = "major"
section = "native"
[changelog.types.app_feature]
bump = "minor"
section = "app_features"
# Routes every changeset using this type into the `user` stream.
stream = "user"
[changelog.outputs.user]
stream = "user"
format = "json"
mode = "release"
path = "{{ path }}/release-notes/{{ version }}.json"
targets = ["app"]
[source.releases]
source = "monochange"
# Publish the `user` output as the hosted release body.
changelog_output = "user"
This example makes native a major bump in the default stream and app_feature a minor bump in the user stream. A mobile app can use that distinction to require an app-store release for native changes while letting an app_feature release ship through a patch system such as Shorebird.
Configured sections and types extend the built-in set
[changelog.sections] and [changelog.types] add to the built-in vocabulary. A declared key overrides the built-in entry of the same name; every other built-in key stays available.
The built-in types include the semantic aliases and the stream types:
| Type | Bump | Section |
|---|---|---|
major | major | breaking |
breaking | major | breaking |
minor | minor | feat |
feat | minor | feat |
change | minor | change |
patch | patch | fix |
fix | patch | fix |
refactor | patch | refactor |
none | none | none |
docs | none | docs |
security | none | security |
test | none | test |
Adding one custom type therefore does not remove the aliases:
[changelog.types.app_feature]
bump = "minor"
section = "app_features"
With that table, app_feature is added while minor, patch, fix, and every other built-in type still resolve. To narrow the vocabulary for one target, use excluded_changelog_types on that package or group:
[package.core]
excluded_changelog_types = ["docs"]
Named [changelog.outputs.<id>] tables support:
| Field | Meaning |
|---|---|
stream | Stream to render; defaults to default |
targets | One or more configured package or group ids |
path | Destination template supporting {{ path }}, {{ id }}, and {{ version }} |
format | monochange, keep_a_changelog, json, or text |
mode | append for a cumulative Markdown changelog or release for a standalone current-release artifact |
initial_header | Optional header for append mode; invalid with release mode |
JSON and text outputs must use mode = "release". The existing package/group changelog configuration is the implicit output named default; [source.releases].changelog_output selects which output becomes the hosted release body.
JSON release notes expose structured entry fields such as summary, details_markdown, packages, change_type, bump, stream, style, and provenance. They do not embed a pre-rendered Markdown entry. Text output is rendered from the same data without Markdown emphasis or link syntax, which keeps it readable in logs and shell pipelines.
Preview or export one configured artifact without preparing the release:
# stdout
monochange notes --output user --target app
# explicit file (relative paths resolve from the workspace root)
monochange notes --output user --target app --file artifacts/app-release-notes.json
--output selects the configured stream and format. It is required so automation cannot accidentally publish the wrong audience. Use --target when an output has more than one target. The command is read-only: it does not update versions, consume changesets, or write the output’s configured path. Omitting --file (or passing --file -) writes to stdout, so ordinary shell redirection also works.
Use [changelog.style] to tune rendered release-note shape. metadata_style accepts inline (the default), blockquote, plain, or omit. The inline style renders owner, review request, and issue metadata as one ·-separated paragraph; when a PR/MR link is available, commit links are omitted because the review link already identifies the change.
Routine entries use a compact bullet. Breaking entries and entries with migration guidance, code fences, or multiline details use an expanded heading and body. A package’s own release notes omit the redundant package label; group and workspace notes keep package labels so readers can see what each entry affects.
One changeset can target several packages with different change types. monochange renders that changeset once, in the section with the lowest priority, and lists every package it targeted. Each package label carries a colored symbol for the bump that package received: 🔴 major, 🟠 minor, 🟢 patch, and ⚪ none. Set package_bump_symbols = false to omit the symbols.
Affected packages are metadata about a change, so they render as a _Packages:_ line directly above the _Owner:_ line rather than inside the heading. A package’s own release notes omit the label entirely, because the document already identifies the package.
An expanded entry — a breaking change, or any change whose body contains a code block, a blank line, or migration guidance — puts the package line directly beneath its heading and above the explanation, so a reader learns what the change affects before reading it. A compact entry keeps the line beside the bullet text.
Built-in section headings are plain text, such as Features and Fixes. Configure custom [changelog.sections.<id>].heading values when a project deliberately wants emoji or other decoration.
[changelog.style]
# `inline` (default), `blockquote`, `plain`, or `omit`.
metadata_style = "inline"
# Prefix each package label with a colored bump symbol.
package_bump_symbols = true
# `inline` (default), `badge`, or `omit`.
package_label_style = "inline"
# `blank_line` (default), `thematic_break`, or `none`.
section_separator = "blank_line"
You can also customize release-note rendering with a workspace-wide [changelog] table plus per-package or per-group changelog overrides.
Supported template variables include:
| Variable | Meaning | Notes |
|---|---|---|
{{ summary }} | rendered release-note summary heading | always available |
{{ details }} | optional long-form details body | omitted when the changeset has no details |
{{ package }} | owning package id for the rendered entry | useful in shared templates |
{{ version }} | release version for the current target | package or group version |
{{ target_id }} | release target id | package id or group id |
{{ bump }} | resolved bump severity | none, patch, minor, or major |
{{ type }} | changeset note type | e.g. feature, fix, security; omitted when absent |
{{ context }} | compact default metadata block | preferred rendered block for human-readable notes |
{{ changeset_path }} | source .changeset/*.md path | tracked in manifests and still available for custom templates, but not shown by default in {{ context }} |
{{ change_owner }} | plain-text hosted actor label | usually something like @ifiokjr |
{{ change_owner_link }} | markdown link to the hosted actor | falls back to plain text when no URL is available |
{{ review_request }} | plain-text PR/MR label | e.g. PR #31 or MR !42 |
{{ review_request_link }} | markdown link to the PR/MR | falls back to plain text when no URL is available |
{{ introduced_commit }} | short SHA for the commit that first introduced the changeset | plain text only |
{{ introduced_commit_link }} | markdown link to the introducing commit | preferred for changelog output |
{{ last_updated_commit }} | short SHA for the most recent commit that changed the changeset | only populated when different from {{ introduced_commit }} |
{{ last_updated_commit_link }} | markdown link to the most recent commit that changed the changeset | only populated when different from {{ introduced_commit }} |
{{ closed_issues }} | plain-text list of issues closed by the linked review request | typically #12, #18 |
{{ closed_issue_links }} | markdown links to issues closed by the linked review request | preferred for changelog output |
{{ related_issues }} | plain-text list of related issues that were referenced but not closed | host support may vary |
{{ related_issue_links }} | markdown links to related issues that were referenced but not closed | host support may vary |
The *_link variants render markdown links when the hosting provider exposes URLs. By default {{ context }} renders the highest-value metadata for readers: owner, review request, introduced commit, last updated commit when different, and linked issues. It does not expose the transient .changeset/*.md path unless you explicitly reference {{ changeset_path }} in your template.
Release values and version schemes
Release planning tracks one version axis: a SemVer identity. Two optional surfaces add a second number and a human-facing label.
Version schemes
[version_scheme.<id>] renders a display label from calendar parts, release ordinals, and declared values. Reference one from a package with display_version:
[version_scheme.calver]
template = "{{ year }}.{{ month_padded }}.{{ release_of_month }}"
[package.app]
display_version = "calver"
Template variables:
| Group | Variables |
|---|---|
| Identity | major, minor, patch, version, identity, prerelease |
| Calendar | year, year_short, month, month_padded, quarter, day, date, time |
| Ordinals | release_of_month, release_of_quarter, release_of_year |
| Declared | every [package.<id>.values.<id>] id, plus label |
Ordinals chain from the previous release record and restart at 1 in a new month, quarter, or year. Values and labels are frozen into the release record, so re-rendering a historical release always produces the same string.
Declared values
[package.<id>.values.<id>] declares a value that becomes a template variable. Every declaration names exactly one source.
# A stamped counter in a file you create and commit.
[package.app.values.build]
file = "build.json" # { "build": 0 }
field = "build" # dot-separated paths are supported
on_release = "increment" # or { add = { amount = 10 } } or "none"
reset = "version" # "version" for iOS trains, "never" for Play/macOS
[package.app.values.artifact]
hash = "artifacts/app.aab" # sha256 over a file
encoding = "base36" # hex | base32 | base36 | digits
length = 8
[package.app.values.run]
env = "GITHUB_RUN_NUMBER"
[package.app.values.rev]
git = "commit_count" # or short_hash
[package.app.values.when]
timestamp = "commit" # or now
| Source | Stamped | Ordering guarantee |
|---|---|---|
file + field with on_release | yes | monotonic within reset |
hash | no | none — an identifier |
env | no | none |
git | no | depends on the git value |
timestamp | no | none |
Only stamped counters and ordinals are monotonic. A hash-derived value is valid in a display label, but must not be relied on for ordering; monochange treats a scheme that uses one as non-monotonic rather than pretending otherwise.
Counter files
Counter files are yours to create and commit. monochange reads the declared field and rewrites only that value, preserving the surrounding formatting and comments:
{ "build": 0 }
The first stamped release writes 1. A missing file, a missing field, or a non-integer value is a blocking error naming the path, field, and expected shape:
counter file `build.json` does not exist; create it with its starting value, for example {"build": 0}
Adopting monochange in a repository whose app already has a production build number means creating the file once with the current value.
reset = "version" restarts the counter when the identity version changes, which is Apple’s release-train rule for iOS build numbers. reset = "never" is the Google Play and macOS rule: the value only ever increases.
Re-running prepare-release for a version that already has a release record reuses the frozen values instead of stamping counters again, so a repeated run never double-increments.
Package references
Package references in changesets and CLI commands should use configured ids.
Prefer package ids when a leaf package changed. That keeps the authored change as specific as possible, and monochange will still propagate bumps to dependents and synchronize any configured groups automatically.
Use a group id only when the change is intentionally owned by the whole group and should read that way in release output.
Current status
Implementation notes:
[defaults].include_privateis parsed and validated, but discovery reports private packages either way. Rely oninclude_privateonly for release planning, not for filtering whatstep discoverprints.[ecosystems.*].enabled,.roots, and.excludeare parsed and validated, but discovery still scans every supported ecosystem. A package found by discovery appears regardless of those settings.[defaults].strict_version_conflictscontrols conflicting explicitversionentries across changesets. The default warns and picks the highest; setting it totruefails planning instead.- Source automation reads
[source], provider release settings under[source.releases], pull request settings under[source.pull_requests], and affected-package policy under[changesets.affected]. GitHub is the default provider. - Live GitHub release and release-request publishing uses
octocrabwithGITHUB_TOKENorGH_TOKEN, falling back to the authenticated GitHub CLI credential fromgh auth tokenwhen neither variable is set. GitLab and Gitea use direct HTTP APIs. - Release-request publishing uses local
gitfor branch, commit, and push operations before provider API updates when not in dry-run mode. - Changeset policy commands apply only to the GitHub provider and expect
[changesets.affected], achanged_pathscommand input, and diagnostics formatted for GitHub Actions. - Supported
[[cli.<command>.steps]]types areConfig,Validate,Discover,DisplayVersions,CreateChangeFile,PrepareRelease,CommitRelease,VerifyReleaseBranch,PublishRelease,PlaceholderPublish,PublishPackages,PlanPublishRateLimits,OpenReleaseRequest,CommentReleasedIssues,AffectedPackages,DiagnoseChangesets,RetargetRelease,ReleaseRecord,PublishReadiness,TagRelease, andCommand. - See the CLI step reference for per-step guidance, prerequisites, and composition examples.
Validation
Run:
monochange step validate
monochange step validate validates:
- package and group declarations
- manifest presence for each package type
- group membership rules
versioned_filesstructural rules (type/format/regex conflicts, required format fields, capture groups)versioned_filescontent checks: file existence, version field readability, regex pattern matching.changeset/*.mdtargets and overlap rules- Cargo workspace version-group constraints
[source]url scheme security (https://required)
Groups and shared release identity
A configured group forces multiple packages to share one planned version and one outward release identity.
[package.sdk-core]
path = "cargo/sdk-core"
type = "cargo"
[package.web-sdk]
path = "packages/web-sdk"
type = "npm"
[group.sdk]
packages = ["sdk-core", "web-sdk"]
tag = true
release = true
version_format = "primary"
Groups can also use version_format = "namespaced" or a custom tag template such as version_format = "{{ name }}/v{{ version }}". Custom formats support {{ name }}, {{ version }}, and {{ ecosystem }}, must include {{ version }}, and must render unique valid Git tag names.
When any member releases:
- the highest required bump in the group wins
- every member in the group receives that bump
- one planned group version is calculated from the highest current member version
- the group owns outward release identity
- member package changelogs can still be updated individually
- group changelog and group
versioned_filescan also be updated - grouped packages can use
empty_update_messagewhen their own changelog needs a version-only update with no direct notes - dependents of newly synced members still receive propagated parent bumps
- unmatched members (not found during discovery) produce warnings; unresolvable members (invalid IDs) produce errors
- mismatched current versions produce warnings when
warn_on_group_mismatch = true
A changeset may reference the group id:
---
sdk: minor
---
#### coordinated SDK release
But a changeset may not reference both the group id and one of its members in the same file.
To keep a group changelog focused on public surfaces while leaving package changelogs detailed, configure the grouped changelog table:
[group.sdk.changelog]
path = "changelog.md"
include = ["sdk-cli"]
Direct group-targeted changesets are always included. Member-targeted changesets are filtered only for the group changelog; package changelogs and release planning remain unchanged.
The filter curates the committed changelog, and nothing else. A provider release body still describes every change the release shipped, so a group whose changelog hides internal notes cannot publish a release that appears to contain nothing. Each change is listed once, in its configured section, with the packages it affects:
Grouped release for `sdk`.
## Fixes
- **Fix shared bug.** _Packages:_ 🟠 _core_, 🟢 _cli_ _Owner:_ @ifiokjr · _Review:_ [PR #725](https://github.com/monochange/monochange/pull/725)
Release planning
Create a changeset with the CLI:
monochange run change --package sdk-core --bump minor --reason "public API addition"
monochange run change --package sdk-core --bump patch --type security --reason "rotate signing keys" --details "Roll the signing key before the release window closes."
monochange run change --package sdk-core --bump none --type docs --reason "clarify migration guidance" --output .changeset/sdk-core-docs.md
monochange run change --package sdk-core --bump major --version 2.0.0 --reason "break the public API" --output .changeset/sdk-core-major.md
Or use interactive mode to select packages, bumps, and options from a guided wizard:
monochange run change -i
Interactive mode automatically prevents conflicting selections (a group and one of its members) and lets you pick per-package bumps and optional explicit versions.
Or write one manually with configured package or group ids:
---
sdk-core:
bump: patch
type: security
---
# rotate signing keys
Roll the signing key before the release window closes.
Group-targeted changesets are also valid:
---
sdk: minor
---
# coordinated SDK release
Package ids first, group ids when the release boundary is shared
Use a package id when one package changed directly:
---
sdk-core: minor
---
# add changelog rendering API
Use a group id when the outward release note should be owned by the whole group:
---
sdk: minor
---
# coordinated SDK release
A quick rule of thumb:
- package id: the leaf package changed and monochange can propagate the rest
- group id: the note should read as one coordinated release for all members
Prefer the inline form: write a configured change type as the target value when its default bump is what you want (sdk-core: docs for a documentation-only change, sdk-core: fix for a patch fix). Use scalar bumps (sdk-core: minor) for plain bumps without a custom type. Use the object syntax only when you need to pin an exact version, combine bump, version, and type, or override a type’s default bump (for example docs with a patch bump):
---
sdk-core:
bump: major
version: "2.0.0"
---
# promote to stable
When version is provided without bump, the bump is inferred from the current version. If the package belongs to a version group, the explicit version propagates to the whole group. Overriding a type’s default bump is possible but should be avoided unless the user explicitly asks for that version behavior.
When a dependent package changes only because another package moved first, author that context explicitly with caused_by:
monochange run change --package sdk-config --bump none --caused-by sdk-core --reason "dependency-only follow-up"
---
sdk-config:
bump: patch
caused_by: ["sdk-core"]
---
# update dependency on sdk-core
And when the package is affected but does not deserve a consumer-facing version bump, use bump: none:
---
sdk-config:
bump: none
caused_by: ["sdk-core"]
type: docs
---
# document the coordinated release
If multiple changesets specify conflicting explicit versions for the same package or group, monochange uses the highest semver version and emits a warning by default. Set defaults.strict_version_conflicts = true to fail instead.
monochange keeps its own changeset standard rather than reusing a narrower external parser. Top-level frontmatter keys are package ids or group ids only. Each target can use scalar shorthand or the object syntax with bump, version, and type, while the markdown body is split into a summary plus optional detailed follow-up paragraphs. Authored heading depth is normalized when release notes are rendered, so use natural markdown headings in the changeset body instead of hard-coding output depth.
Validate before planning:
monochange step validate
Semantic SemVer guardrails
Release planning also runs semantic analysis for the detected git change frame when it can. The analyzer output is advisory release evidence, not a replacement for changesets:
- removed or modified public API/export evidence can raise the minimum bump to
major; - added public API/export evidence can raise the minimum bump to
minor; - dependency and metadata evidence is treated as
patchadvisory context; - analyzer failures become release-plan warnings instead of blocking the command.
Dry-run JSON and release manifest payloads include this data under compatibilityEvidence. Human reviewers should compare that evidence with the authored changesets before preparing a release.
Before writing a changeset, use monochange change classify --format json to compare the pull request with both the default branch and the latest release. See Change classification for finding confidence, coverage limits, and changeset validation.
Release manifests vs release records
Release planning and release repair use two different artifacts on purpose.
- the cached release manifest at
.monochange/release-manifest.jsoncaptures what monochange is preparing right now during command execution. ReleaseRecordcaptures what a release commit historically declared inside the monochange-managed release commit body.
Use the manifest when you want execution-time automation such as CI artifacts, MCP/server responses, previews, or downstream machine-readable release data.
Use the release record when you want to rediscover or repair a release later from a tag or descendant commit.
That is why ReleaseRecord does not replace the cached release manifest: one is an execution-time automation artifact, the other is a durable git-history artifact.
When you need to inspect or repair a recent release, see Repairable releases.
Generate a plan directly when you want to inspect the raw planner output:
monochange run release --dry-run --format json
For human-readable local output, monochange run release --dry-run defaults to concise text. Use --format markdown for a raw Markdown artifact or --format json for automation.
Preferred repository command flow:
monochange run release --dry-run --format json
When you want a reviewable patch-style preview of the filesystem changes without mutating the workspace, add --diff:
monochange run release --dry-run --diff
monochange run release --dry-run --format json --diff
Markdown and text output render unified diffs directly in the terminal. JSON output wraps the normal manifest payload under manifest and adds fileDiffs entries for each changed file.
A good planning loop looks like this:
monochange step validate
monochange step discover --format json
monochange step diagnose-changesets --format json
monochange run release --dry-run --diff
Use each command for a different question:
monochange step validate: is the config and changeset set valid?monochange step discover --format json: which package ids, groups, and dependency edges exist?monochange step diagnose-changesets --format json: who introduced these changesets and what review context is attached?monochange run release --dry-run --diff: what exact files would change if I prepared the release now?
Compare preview modes
Use the preview mode that matches the decision you are trying to make:
| Command | Best for |
|---|---|
monochange run release --dry-run | Human review in the terminal |
monochange run release --dry-run --diff | Human review plus exact file patches |
monochange run release --dry-run --format json | Automation, scripts, MCP clients |
monochange run release --dry-run --format json --diff | Automation that also needs file patch details |
When you want command semantics without any command-line noise, add --quiet. Quiet mode does not change execution; add --dry-run separately when the workspace must stay unchanged.
monochange run release
monochange run release is a config-driven workflow command only when your repository defines a [cli.release] table. monochange init writes a minimal starter config and does not seed default workflow aliases, so use the immutable monochange step prepare-release command unless you add your own named workflow.
The binary no longer ships a hidden default workflow set for commands such as discover, change, release, affected, diagnostics, repair-release, publish, or publish-plan. Those names exist under monochange run <name> only when your config defines them. If a repository has not opted into a named workflow, use the immutable step command instead, for example monochange step discover, monochange step create-change-file, monochange step prepare-release, monochange step affected-packages, monochange step diagnose-changesets, monochange step retarget-release, monochange step publish-readiness, or monochange step plan-publish-rate-limits.
monochange step validate is the immutable built-in step command for normal preflight checks. Do not define [cli.validate] or [cli.step] in monochange.toml; those names are reserved for built-in commands.
Configured workflows like monochange run commit-release combine PrepareRelease with later stateful steps such as CommitRelease. Provider request workflows such as monochange run release-pr can add OpenReleaseRequest. Keep both as explicit [cli.*] workflow commands when you want a durable, named release process.
Current PrepareRelease behavior:
- reads
.changeset/*.md - computes one synchronized release plan from discovered change files
- updates native manifests plus configured changelogs and versioned files
- renders changelog files through structured release notes using the configured
monochangeorkeep_a_changelogformat - groups release notes into default
Breaking changes,Features,Fixes, andNotessections, with package/group overrides available through changelogtypesandsections - applies workspace-wide release-note templates from
[changelog].templates - refreshes the cached
.monochange/release-manifest.jsonartifact duringPrepareReleasefor downstream automation - can preview or publish provider releases via
PublishRelease - can preview or open/update release requests via
OpenReleaseRequest - can comment on released issues via
CommentReleasedIssues - can evaluate pull-request changeset policy via
AffectedPackagesusing changed paths and labels supplied by CI - applies group-owned release identity for outward
tag,release, andversion_format - deletes consumed change files only after a successful non-dry-run execution
- leaves the workspace untouched during
--dry-runexcept for explicitly requested outputs such as a rendered release manifest or release preview
A GitHub Actions check can pass changed paths and labels directly into a policy workflow, for example:
name: changeset-policy
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
concurrency:
group: changeset-policy-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check:
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: checkout repository
uses: actions/checkout@v6
- name: setup
uses: ./.github/actions/devenv
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: collect changed files
id: changed
uses: tj-actions/changed-files@v46
- name: run changeset policy
env:
PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }}
CHANGED_FILES: ${{ steps.changed.outputs.all_changed_files }}
shell: bash
run: |
set -euo pipefail
mapfile -t labels < <(jq -r '.[]' <<<"$PR_LABELS_JSON")
args=(step affected-packages --format json --verify)
for path in $CHANGED_FILES; do
args+=(--changed-paths "$path")
done
for label in "${labels[@]}"; do
args+=(--label "$label")
done
devenv shell -- monochange "${args[@]}" | tee policy.raw
awk 'BEGIN { capture = 0 } /^\{/ { capture = 1 } capture { print }' policy.raw > policy.json
jq -e '.status != "failed"' policy.json >/dev/null
Planning rules:
monochange run changedefaults--bumptopatch; use--bump nonewhen you want a type-only or version-only entry, and pass--versionto pin an explicit release version- markdown change files use package/group ids as the only top-level frontmatter keys, with scalar shorthand for
none/patch/minor/majoror configured change types, plus object syntax forbump,version,type, andcaused_by - when
versionis given withoutbump, the bump is inferred by comparing the current and target versions - explicit versions from grouped members propagate to the group version; conflicts take the highest semver or fail when
defaults.strict_version_conflicts = true - prefer package ids over group ids in authored changesets when possible; direct package changes still propagate to dependents and synchronize configured groups
- optional change
typevalues can route entries into custom changelog sections, and configured sectiondefault_bumpvalues let scalar type shorthand imply the desired semver behavior caused_byreferences package or group ids and suppresses only the matching dependency-propagation records; use object syntax whenever you need itmonochange run changeaccepts repeated--caused-by <id>flags, and--bump noneis the right fit when you want to acknowledge an affected package without forcing a user-facing version bumpmonochange run changecan write to a deterministic path with--output ...- change templates support detailed multi-line release-note entries through
{{ details }}, compact metadata blocks through{{ context }}, and fine-grained linked metadata like{{ change_owner_link }},{{ review_request_link }}, and{{ closed_issue_links }} - dependents resolve their propagation policy most-specific-first:
[[package]].bump_propagationoverridesgroupdeclarations, which override[defaults].bump_propagation, which falls back to the[defaults].parent_bumpfloor;inheritmatches the source’s own severity (optionally clamped bybump_propagation_max) and fixed severities act as floors - computed compatibility evidence can still escalate both the changed crate and its dependents when provider analysis produces it
- configured groups synchronize before final output is rendered
- release targets carry effective
tag,release, andversion_formatmetadata - release-manifest JSON captures release targets, changelog payloads, authored changesets, linked changeset context metadata, changed files, and the synchronized release plan for downstream automation
PublishReleasereuses the same structured release data to build provider release requests for grouped and package-owned releasesOpenReleaseRequestreuses the same structured release data to render release-request summaries, branch names, and idempotent provider updatesCommentReleasedIssuescan use linked changeset context metadata to add follow-up comments to closed issues after a release is publishedAffectedPackagesevaluates changed paths, skip labels, and changed.changeset/*.mdfiles into reusable pass/skip/fail diagnostics and optional failure comments- CLI text and JSON output render workspace paths relative to the repository root for stable snapshots and automation
Diagnostics vs. release records
These commands answer different questions:
monochange step diagnose-changesets --format json: what is currently pending in.changeset/*.md, and who introduced it?monochange step release-record --from <ref>: what did a past release commit declare durably in git history?monochange step tag-release --from HEAD: ifHEADis the merged release commit, which release tags should be created now?
Use diagnostics before you release. Use release records after a release exists and you need to inspect it. Use monochange step tag-release in post-merge CI when the release commit has landed on the default branch and you want to create the declared tag set from that durable history record.
Across release-oriented commands, global --quiet suppresses stdout/stderr without changing whether the command mutates the workspace.
Concurrency
monochange run release is designed for sequential execution. Do not run multiple monochange run release commands concurrently on the same workspace. There is no file locking, so concurrent runs could produce duplicate changelog entries, inconsistent version files, or corrupted release records. If you need parallel release preparation across workspaces, use separate working copies.
Trusted publishing and OIDC
monochange supports built-in package publishing for the canonical public registries of the ecosystems it manages:
- Cargo →
crates.io - npm packages →
npm - Deno packages →
jsr - Dart / Flutter packages →
pub.dev - Python packages →
pypi - Go modules →
go_proxyvia VCS tags
For those registries, monochange can also manage or verify trusted publishing when the registry supports publishing directly from a verified GitHub Actions identity. PyPI is supported by the built-in publisher through uv build and uv publish; PyPI trusted-publisher enrollment is still completed manually in the PyPI project settings.
Different registries use different names for the same general pattern:
- trusted publishing
- OIDC publishing
- automated publishing
- trusted publishers
The goal is the same in every case:
- publish from CI instead of local machines
- avoid long-lived registry tokens where possible
- restrict publish rights to a specific repository, workflow, and sometimes environment
Provider and registry capability matrix
Trusted publishing support is not uniform across registries or CI providers. monochange models these dimensions separately so it can be strict where support is verifiable and honest where setup still needs manual review.
| Ecosystem | Registry | Trusted-publishing providers modeled by monochange | Current CI identity can be detected | Publish-time setup/context can be verified by monochange | Registry-side setup can be automated by monochange | Registry-native provenance / attestations |
|---|---|---|---|---|---|---|
| npm | npm | GitHub Actions, GitLab CI/CD | Yes | GitHub Actions context only | No; run npm trust github ... manually or externally | Yes, npm provenance |
| cargo | crates.io | GitHub Actions | Yes | No | No | No registry-native package provenance |
| deno | jsr | GitHub Actions | Yes | No | No | Yes, JSR package provenance |
| dart / flutter | pub.dev | GitHub Actions, Google Cloud Build | Yes | No | No | No registry-native package provenance |
| python | PyPI | GitHub Actions, GitLab CI/CD, Google Cloud Build | Yes | No | No | Yes, PEP 740 digital attestations |
| go | Go proxy | None; VCS tags are used instead | N/A | N/A | Creates module tags | Source-control provenance only |
| custom/private | custom | None by default | Provider may be detected | No | No | Unknown |
monochange also detects CircleCI publish-time identity, but none of the built-in public registry combinations above are treated as CircleCI trusted-publishing support today. Unknown local shells and unsupported providers are never treated as trusted.
npm is the only ecosystem where monochange can render precise GitHub trusted-publisher repair commands. It still does not run npm trust during real package publishing; use mode = "external" for any registry workflow that should stay outside monochange’s built-in publisher.
Go module publishing is included in the built-in package publisher, but it is not an OIDC trusted-publishing flow. Go versions are published by creating VCS tags. monochange uses git tag, choosing v1.2.3 for a root module and path-prefixed tags such as api/v1.2.3 for submodules, then checks availability through the Go module proxy.
For crates.io, jsr, pub.dev, and PyPI, monochange reports the setup URL for each package and blocks the next built-in registry publish until the trust configuration has been completed manually. It also preflights the trusted-publishing context for those registries, surfacing the provider capability message and, for GitHub contexts, the repository, workflow, and environment it expects when that context can be resolved.
monochange configuration
Start by enabling trusted publishing for the relevant ecosystem. Packages inherit the ecosystem publish setting by default and can override it when needed.
[source]
provider = "github"
owner = "ifiokjr"
repo = "monochange"
[source.releases.attestations]
require_github_artifact_attestations = true
[ecosystems.npm.publish]
trusted_publishing = true
[ecosystems.npm.publish.trusted_publishing]
workflow = "publish.yml"
environment = "publisher"
[ecosystems.npm.publish.attestations]
require_registry_provenance = true
[ecosystems.cargo.publish]
trusted_publishing = true
[ecosystems.deno.publish]
trusted_publishing = true
[ecosystems.deno.publish.attestations]
require_registry_provenance = true
[ecosystems.dart.publish]
trusted_publishing = true
[ecosystems.python.publish]
trusted_publishing = true
[package.cli.publish.trusted_publishing]
workflow = "publish-cli.yml"
[package.legacy.publish]
trusted_publishing = false
Use ecosystem publish settings for the shared trust policy and GitHub context. Use package publish settings only for package-specific workflows, environments, or opt-outs.
monochange resolves the GitHub trust context from:
publish.trusted_publishing.repositorypublish.trusted_publishing.workflowpublish.trusted_publishing.environment- otherwise
[source] - otherwise GitHub Actions runtime values such as
GITHUB_REPOSITORY,GITHUB_WORKFLOW_REF, andGITHUB_JOB
If your workflow filename or environment cannot be inferred reliably, set them explicitly in monochange.toml.
Requiring or preferring trusted publishing
publish.trusted_publishing.mode decides what happens when no verifiable CI/OIDC identity is available:
mode = "required"(default). Publishing must run from a verifiable CI identity. Local and manual runs fail before any registry mutation. Use this when trusted publishing is the only publishing path you want to allow.mode = "preferred". Trusted publishing is used whenever a verifiable CI identity is detected, and publishing falls back to local/manual credentials otherwise. Detected CI identities are still verified against the configured repository, workflow, and environment, so a misconfigured CI context fails instead of silently publishing with a token.
[ecosystems.dart.publish.trusted_publishing]
enabled = true
mode = "preferred"
repository = "acme/widgets"
workflow = "publish.yml"
environment = "publisher"
preferred fits repositories that publish from CI with OIDC trusted publishing but also let maintainers run monochange run publish locally with their own registry credentials. The mode only relaxes the identity requirement. It never disables the CI context verification, and enabled = false remains the explicit opt-out from trusted publishing entirely.
Attestation and provenance policy
Trusted publishing and attestations answer different questions:
- trusted publishing decides which CI/OIDC identity may publish a package
- registry-native package provenance records where a published package came from in registries that support it, such as npm provenance, JSR provenance, and PyPI PEP 740 attestations
- GitHub release artifact attestations cover release assets uploaded to GitHub Releases and are separate from package-registry provenance
Trusted publishing does not automatically require registry provenance. Enable provenance explicitly for registries where you want monochange to enforce it:
[ecosystems.npm.publish]
trusted_publishing = true
[ecosystems.npm.publish.attestations]
require_registry_provenance = true
Packages inherit publish.attestations from their ecosystem publish settings. Package-level settings can override or opt out:
[package.legacy.publish.attestations]
require_registry_provenance = false
When publish.attestations.require_registry_provenance = true, built-in release publishing fails before invoking the registry command unless all of these are true:
publish.trusted_publishing = trueis effective for the package.- The current environment exposes a verifiable CI/OIDC identity.
- The provider/registry capability matrix says registry-native provenance is available for that identity.
Today this is enforceable for npm trusted publishing and JSR publishing from supported OIDC contexts. The capability matrix records PyPI PEP 740 attestations as registry-native provenance, but monochange rejects require_registry_provenance for PyPI until the built-in Python publisher exposes a publish command that can require uploading those attestations. It also rejects crates.io, pub.dev, Go proxy publication, and custom registries because those flows do not expose registry-native package provenance that monochange can require without creating false assurance.
For GitHub release assets, keep the policy under [source.releases.attestations]:
[source]
provider = "github"
owner = "ifiokjr"
repo = "monochange"
[source.releases.attestations]
require_github_artifact_attestations = true
This setting is accepted only for the GitHub source provider. It records that release assets are expected to be covered by GitHub Artifact Attestations; the workflow that builds/uploads assets must still grant attestations: write and run the GitHub attestation action for the uploaded subjects.
GitHub Actions baseline
Most registries require the publish job to request an OIDC token.
permissions:
contents: read
id-token: write
If you use a protected deployment environment, keep the workflow and registry settings aligned:
jobs:
publish:
environment: publisher
Use the same environment name in GitHub Actions and in the registry configuration.
When publish.trusted_publishing = true, release publishing is a mandatory CI/OIDC flow. Built-in publish commands reject local/manual execution before invoking registry CLIs, require the configured GitHub repository/workflow/environment to match the current job, require id-token: write, and refuse long-lived npm token environment variables such as NPM_TOKEN or NODE_AUTH_TOKEN. Use publish.trusted_publishing = false only for packages that intentionally opt out.
Recommended rollout
Use this sequence when adopting trusted publishing for an existing workspace:
- Set
publish.trusted_publishing = truefor the target ecosystem, then override individual packages only when they differ. - Run
monochange step placeholder-publish --dry-runto see which packages do not exist yet. - If needed, run
monochange step placeholder-publishso the package exists in the registry first. - Complete the registry-side trusted-publishing setup for each package.
- Run
monochange step publish-packages --dry-runto confirm monochange sees the expected trust configuration. - Optionally generate a readiness artifact in CI with
monochange step publish-readiness --from HEAD --output .monochange/readiness.jsonfor preflight review or publish planning. - Publish from CI with
monochange step publish-packages --output .monochange/publish-result.json.
Placeholder publishing is especially useful when the package name must exist in the registry before trusted publishing can be configured.
npm
Registry-side setup
On npm, trusted publishing can be configured from the package settings page or through the CLI.
UI path
npmjs.com→ package → Settings → Trusted publishing
Fields to enter for GitHub Actions
- Organization or user: GitHub owner
- Repository: GitHub repository name
- Workflow filename: for example
publish.yml - Environment name: optional, for example
publisher
Only the workflow filename is required, not the full .github/workflows/... path.
CLI setup commands
These are the same commands monochange models for npm trusted publishing.
List the current trusted publishers for a package:
npm trust list <package-name> --json
Configure a package for a GitHub workflow:
npm trust github <package-name> \
--repo owner/repo \
--file publish.yml \
--yes
Add an environment restriction:
npm trust github <package-name> \
--repo owner/repo \
--file publish.yml \
--env publisher \
--yes
If the workspace uses pnpm, use the pnpm-wrapped form:
pnpm exec npm trust github <package-name> \
--repo owner/repo \
--file publish.yml \
--env publisher \
--yes
Workflow requirements
At minimum, the publish workflow should have:
permissions:
contents: read
id-token: write
monochange notes
- Real package publishing does not execute
npm trustornpm trust listautomatically. - monochange verifies the configured GitHub Actions OIDC context before trusted npm publishing.
- If trust needs repair, monochange reports the exact
npm trust github ...command to run manually or from separate tooling. - Trusted npm publishing uses the
npmCLI directly. pnpm workspaces can still usepnpm exec npm trust ...for manual setup commands, and non-trusted pnpm publishing paths continue to use pnpm.
crates.io
Registry-side setup
crates.io supports trusted publishing through CI-issued OIDC. monochange models GitHub Actions for built-in trusted-publishing diagnostics and keeps other crates.io provider combinations manual until registry-side verification is available.
Trusted publishing on crates.io exchanges your CI identity for a short-lived publish token, so you do not need a long-lived crates.io API token in CI.
Prerequisites
- the crate must already exist on
crates.io - you must be an owner of the crate on
crates.io - the repository must live on GitHub or GitLab
If the crate does not exist yet, bootstrap it first with a real initial release or monochange step placeholder-publish. The first publish still uses the normal crates.io token flow.
UI path
- crate page → Settings → Trusted Publishing
Fields to enter for GitHub Actions
- Repository owner: GitHub owner
- Repository name: GitHub repository name
- Workflow filename: for example
release.yml - Environment: optional, for example
release
Use the workflow filename only, not the full .github/workflows/... path.
Workflow setup
A typical GitHub Actions release job looks like this:
name: Publish to crates.io
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: rust-lang/crates-io-auth-action@v1
id: auth
- run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
If you configure an environment on crates.io, the GitHub job must use the same environment name.
monochange notes
- monochange does not create the
crates.iotrusted-publisher record for you yet. - monochange preflights the GitHub repository/workflow/environment context it expects for manual registries and reports when one of those values still needs to be set explicitly in config.
- Once the registry-side configuration exists, monochange can publish with the temporary token exposed by
rust-lang/crates-io-auth-action@v1. - crates.io issues a short-lived publish token; the current docs describe these tokens as expiring after 30 minutes.
- Use a specific workflow filename and, when needed, a protected GitHub environment to reduce the publish attack surface.
- The current monochange GitHub publish workflow already uses this pattern.
Useful references:
https://crates.io/docs/trusted-publishinghttps://rust-lang.github.io/rfcs/3691-trusted-publishing-cratesio.html
jsr
Registry-side setup
JSR supports tokenless publishing from GitHub Actions.
Manual setup step
- go to the package on
jsr.io - open Settings
- link the package to the GitHub repository that is allowed to publish it
monochange reports the package URL and expects this repository-linking step to be completed manually.
Workflow setup
A minimal GitHub Actions job looks like this:
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- run: npx jsr publish
You can also publish with:
deno publish
monochange notes
- JSR’s tokenless publishing is GitHub Actions focused.
- Other CI providers still need token-based publishing.
- monochange does not yet link the package to the repository for you.
- If the package does not exist yet, placeholder publishing can bootstrap the registry entry before you finish the repository-link step.
Useful references:
https://jsr.io/docs/publishing-packageshttps://jsr.io/docs/trust
pub.dev
Registry-side setup
pub.dev calls this automated publishing.
Automated publishing on pub.dev authenticates with a temporary GitHub-signed OIDC token instead of a long-lived pub credential.
Prerequisites
- the package must already exist on
pub.dev - you must be an uploader or admin for the package
- the repository must be on GitHub
If the package does not exist yet, publish it once first or use monochange step placeholder-publish.
UI path
https://pub.dev/packages/<package>/admin- find the Automated publishing section
- click Enable publishing from GitHub Actions
Fields to enter
- Repository:
owner/repo - Tag pattern: a string containing
{{version}}
Examples:
- single-package repo:
v{{version}} - monorepo package-specific tag:
my_package-v{{version}}
For a monorepo, give each package its own tag pattern so a tag for one package cannot publish another package by accident. The official pub.dev guidance also recommends a separate workflow file per package when one repository publishes multiple Dart packages. For a broader monorepo strategy across registries, see Multi-package publishing patterns.
Optional hardening
- click Require GitHub Actions environment on the package admin page
- choose an environment name such as
pub.dev - use the same environment name in the GitHub workflow
Workflow requirements
pub.dev only accepts GitHub Actions automated publishing when the workflow was triggered by a tag push. It rejects branch-triggered and manually dispatched workflows for this publishing flow.
That means the GitHub workflow trigger must align exactly with the configured tag pattern.
Recommended reusable workflow
pub.dev strongly encourages the reusable workflow maintained by dart-lang/setup-dart:
name: Publish to pub.dev
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
publish:
permissions:
id-token: write
uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1
# with:
# working-directory: path/to/package/within/repository
If you require a GitHub Actions environment on pub.dev, pass the same environment name to the reusable workflow:
jobs:
publish:
permissions:
id-token: write
uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1
with:
environment: pub.dev
# working-directory: path/to/package/within/repository
Custom workflow
If you need custom code generation or build steps, set up Dart yourself and publish manually after the OIDC-authenticated setup step:
dart pub publish --force
For Flutter packages, the equivalent publish command is:
flutter pub publish --force
monochange notes
- monochange does not configure pub.dev automated publishing for you yet.
- monochange reports the package admin URL so you can finish the setup manually.
- pub.dev is stricter than the others here because the workflow must be tag-triggered, not just manually dispatched or branch-triggered.
- The reusable workflow from
dart-lang/setup-dartis the officially recommended path and is worth preferring unless you need custom pre-publish steps. - Keep the Git tag,
pubspec.yamlversion, and tag pattern aligned. - Protect matching tags, and use GitHub environment protection rules when you need an approval gate before publishing.
Useful references:
https://dart.dev/tools/pub/automated-publishinghttps://pub.dev/packages/<package>/admin
Mapping monochange config to registry values
Use this cheat sheet when a registry asks for workflow details.
| Registry field | Value to use |
|---|---|
| repository owner / organization / namespace | GitHub owner from [source] or publish.trusted_publishing.repository |
| repository name / project | repository part of owner/repo |
| workflow filename | publish.trusted_publishing.workflow, for example publish.yml |
| environment | publish.trusted_publishing.environment, for example publisher |
| pub.dev tag pattern | choose a tag rule that matches your release workflow, for example v{{version}} or my_package-v{{version}} |
If monochange cannot infer the GitHub repository or workflow for a package, set them explicitly in monochange.toml.
Security recommendations
- prefer trusted publishing over long-lived registry tokens whenever the registry supports it
- keep
id-token: writeonly on the publish job instead of the entire workflow when possible - use a protected GitHub environment such as
publisherfor high-value publish jobs - restrict tag creation and release workflows to trusted maintainers
- use package-specific tags in monorepos when a registry supports tag-based publish authorization
When to keep mode = "external"
Keep a package on mode = "external" when:
- the registry is private or custom
- you need retry or delayed requeue behavior that monochange does not manage yet
- your registry requires a CI pattern that differs substantially from monochange’s built-in publish flow
In those cases, you can still use the same registry-side trusted-publishing setup while letting your own workflow own the actual publish command. The same approach is often the cleanest fit for multi-package repositories that need package-specific tags or workflows; see Multi-package publishing patterns.
Possible future automation for manual registries
monochange is intentionally conservative here.
Today, monochange does not perform registry-side trusted-publishing enrollment during real package publishing. npm has the richest repair guidance because monochange can render the expected npm trust github ... command, but the command is still manual or external. For crates.io, jsr, pub.dev, and PyPI, monochange focuses on setup guidance, preflight checks, and actionable diagnostics instead of trying to mutate registry-side trust records automatically.
Areas that may become more automated later, where the registry and CI contracts make it safe enough, include:
crates.io: stronger preflight validation around explicit workflow filenames, environment alignment, and clearer checks for first-publish bootstrap versus post-bootstrap trusted publishingjsr: better repository-link diagnostics and package metadata checks before publish, especially when the package already exists but repository-side linking is incompletepub.dev: stronger validation that tag patterns, workflow triggers, working directories, and optional environments still match the automated-publishing setup expected by pub.dev- PyPI: stronger validation that the project trusted-publisher settings match the workflow name, environment, and package path monochange expects before running
uv publish
Areas that monochange does not promise:
- auto-enrolling registry-side trusted-publisher records for any registry, including npm,
crates.io,jsr,pub.dev, or PyPI - bypassing browser-confirmed or admin-page-only steps that the registry intentionally keeps manual
- inferring enough registry-side state to claim a package is fully enrolled when the registry does not expose that state safely or consistently
Treat this as a direction of travel, not a guarantee of upcoming behavior. If you need a registry-native workflow, keep the package on mode = "external" and let the registry-maintained workflow own the actual publish command.
GitHub automation
monochange keeps source-provider automation layered on top of the same PrepareRelease result used for normal release planning.
That means one set of .changeset/*.md inputs can drive all of these commands and automation flows consistently:
monochange step prepare-release --dry-run --format jsonrefreshes the cached manifest and shows the downstream automation payloadmonochange step publish-releasepreviews or publishes provider releases from the structured release notesmonochange step open-release-requestpreviews or opens an idempotent provider release request; when[source.pull_requests].verified_commits = trueand the step runs on GitHub Actions for the configured repository, the GitHub provider pushes a normal release branch commit as a fallback and then only moves the branch to a Git Database API replacement commit when GitHub reports that replacement as verifiedmonochange step affected-packagesevaluates pull-request changeset policy from CI-supplied changed paths and labels without requiring a config-defined wrapper command
Quick start with monochange init --provider
The fastest way to configure GitHub automation is using the --provider flag during initialization:
# Initialize with GitHub automation pre-configured
monochange init --provider github
# The generated monochange.toml includes:
# - [source] section with GitHub releases and pull request settings
# - CLI commands for commit-release and release-pr
# - GitHub Actions workflows in .github/workflows/
This single command generates:
- Complete source configuration -
[source],[source.releases], and[source.pull_requests]sections - Automation CLI commands -
commit-releaseandrelease-prcommands ready to use - GitHub Actions workflows -
release.ymlandchangeset-policy.ymlfor CI/CD - Auto-detected repository info - parses your git remote to pre-fill owner and repo
CLI commands
monochange step prepare-release --dry-run --format json
monochange step publish-release --dry-run --format json
monochange step open-release-request --dry-run --format json
monochange step affected-packages --format json --verify --changed-paths crates/monochange/src/lib.rs
Inspecting and repairing a recent release
GitHub automation has a repair-oriented history flow in addition to the existing manifest-driven execution flow.
Use these commands when you need to inspect, tag, or repair a just-created release:
monochange step release-record --from v1.2.3
monochange step tag-release --from HEAD --dry-run --format json
monochange step retarget-release --from v1.2.3 --target HEAD --dry-run
monochange step retarget-release --from v1.2.3 --target HEAD
The important distinction is:
- the cached release manifest still describes the execution-time release plan for automation
ReleaseRecorddescribes the durable release declaration stored in the release commit bodymonochange step tag-releaseconsumes that durable record after merge and creates the declared tag set on the default branch
Use --dry-run first for monochange step retarget-release. It is a destructive workflow because it retargets release tags.
If immutable registry artifacts have already been published, prefer cutting a new patch release instead of retargeting the source release.
Tag-release JSON for follow-up workflows
When a post-merge workflow needs to trigger follow-up release work, prefer monochange step tag-release --from HEAD --format json and read the release tag by package or group id from the top-level tags object:
{
"tags": {
"main": "v1.2.3",
"sdk": "sdk/v1.2.3"
}
}
name/version examples such as sdk/v1.2.3 correspond to a tag template like {{ name }}/v{{ version }}.
The tags object is intentionally flat because package ids and group ids share the same monochange namespace. A workspace cannot have both a package and a group with the same id, so workflows do not need separate tags.packages and tags.groups branches or prefixed lookup keys. This makes automation stable and explicit: use .tags.<id> for the package or group whose release should drive the next step.
A package or group might not be released in a particular release commit. Handle that by checking whether tags has an entry for the id you care about. If there is no tag attached to that id, you can assume that release did not include that package or group and skip that follow-up workflow.
For example, a repository with [group.main] can trigger a downstream GitHub release workflow from the main group tag with:
monochange step tag-release --from HEAD --format json >/tmp/tag-report.json
tag="$(jq -r '.tags.main // empty' /tmp/tag-report.json)"
if [ -z "$tag" ]; then
echo "No main group tag found in tag-report.json, skipping release trigger"
exit 0
fi
gh workflow run release.yml --ref "$tag" -f tag="$tag"
Avoid indexing tagResults[0] for workflow control. tagResults remains the audit log of tag operations, while tags is the stable id-addressable map for automation.
Package publishing and trusted publishing
Package publishing is separate from provider release publishing:
monochange step publish-readiness --from HEAD --output <path>checks package registries before mutationmonochange step publish-packageshandles package registries such ascrates.io,npm,jsr, andpub.devmonochange step publish-releasehandles hosted source-provider releases such as GitHub releases
When publish.trusted_publishing is enabled, monochange can derive GitHub trust metadata from the workflow runtime and the configured [source] block. npm packages get the richest built-in diagnostics:
- monochange rejects long-lived npm token environment variables for trusted-publishing runs
- monochange verifies that the current GitHub Actions OIDC context matches the configured trusted-publisher context
- if npm trust needs repair, monochange reports the exact
npm trust github ...command to run manually or in separate tooling - real
monochange step publish-packagesruns do not executenpm trustornpm trust list; trusted-publishing npm publishes use thenpmCLI directly
For crates.io, jsr, and pub.dev, monochange reports the setup URL for the package and requires manual trusted-publishing setup before the next built-in release publish. Placeholder publishing can still proceed so the package exists before that manual step.
For exact registry-side setup steps and field mappings, see Trusted publishing and OIDC.
For full GitHub and GitLab CI examples by ecosystem, including npm, Cargo, Deno/JSR, and Dart/pub.dev, see Advanced: CI, package publishing, and release PR flows.
Release notes, GitHub releases, and release PRs
[defaults.changelog]
path = "{{ path }}/changelog.md"
format = "keep_a_changelog"
[changelog]
templates = [
"#### {{ summary }}\n\n{{ details }}\n\n{{ context }}",
"#### {{ summary }}\n\n{{ context }}",
"#### {{ summary }}\n\n{{ details }}",
"- {{ summary }}",
]
[group.main.changelog]
path = "changelog.md"
format = "monochange"
[source]
provider = "github"
owner = "ifiokjr"
repo = "monochange"
[source.releases]
enabled = true
source = "monochange"
[source.releases]
branches = ["main"]
enforce_for_tags = true
enforce_for_publish = true
enforce_for_commit = false
changeset_context_timeout_seconds = 120
[source.pull_requests]
enabled = true
branch_prefix = "monochange/release"
base = "main"
title = "chore(release): prepare release"
labels = ["release", "automated"]
auto_merge = false
[cli.publish-release]
help_text = "Prepare a release and publish provider releases"
[[cli.publish-release.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.publish-release.steps]]
type = "PrepareRelease"
inputs = ["format"]
[[cli.publish-release.steps]]
type = "PublishRelease"
[[cli.publish-release.steps]]
type = "CommentReleasedIssues"
[cli.release-pr]
help_text = "Prepare a release and open or update a provider release request"
[[cli.release-pr.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.release-pr.steps]]
type = "PrepareRelease"
inputs = ["format"]
[[cli.release-pr.steps]]
type = "OpenReleaseRequest"
inputs = ["format"]
When you want fine-grained changelog formatting instead of the default {{ context }} block, GitHub-backed release notes can reference individual metadata fields such as {{ change_owner_link }}, {{ review_request_link }}, {{ introduced_commit_link }}, {{ closed_issue_links }}, and {{ related_issue_links }}. Those variables render markdown links when host URLs are available, so generated changelogs can point directly at the responsible actor, the PR, and linked issues. The source changeset path stays available through {{ changeset_path }}, but {{ context }} keeps that transient file path out of the default rendered note.
[source]
provider = "github"
owner = "ifiokjr"
repo = "monochange"
[changesets.affected]
enabled = true
required = true
skip_labels = ["no-changeset-required"]
comment_on_failure = true
changed_paths = [
"crates/**",
".github/**",
"Cargo.toml",
"Cargo.lock",
"devenv.nix",
"devenv.yaml",
"devenv.lock",
"monochange.toml",
"codecov.yml",
"deny.toml",
"scripts/**",
"npm/**",
"skills/**",
]
ignored_paths = [
".changeset/**",
"docs/**",
"specs/**",
"readme.md",
"CONTRIBUTING.md",
"license",
]
name = "docs"
trigger = "release_published"
workflow = "docs-release"
environment = "github-pages"
release_targets = ["main"]
requires = ["main"]
metadata = { site = "github-pages" }
name = "format"
type = "choice"
choices = ["text", "json"]
default = "text"
type = "PrepareRelease"
[cli.affected]
help_text = "Evaluate pull-request changeset policy"
[[cli.affected.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
default = "text"
[[cli.affected.inputs]]
name = "changed_paths"
type = "string_list"
required = true
[[cli.affected.inputs]]
name = "label"
type = "string_list"
[[cli.affected.steps]]
type = "AffectedPackages"
Release and npm publish workflows
monochange includes a release workflow modeled around long-running release PR refresh plus post-merge tagging:
.github/workflows/release.ymlrefreshes the dedicated release PR branch on normalmainpushes- the same workflow detects when
HEADis already a merged monochange release commit, runsmonochange step tag-release --from HEAD, runsmonochange step publish-readiness --from HEAD --output <path>, and then runsmonochange step publish-packages - tag-triggered or downstream workflows can then build archives, create hosted releases, publish additional assets from the pushed tags, or run a separate
monochange step publish-releasejob when you still want manifest-driven hosted-release publication
That split keeps tag creation on the default branch side of the merge and lets downstream automation consume the exact durable release metadata that monochange stored in git history.
For release asset workflows, prefer tag or manual dispatch triggers over draft release.created triggers. Draft releases do not reliably emit release.created, and immutable releases need every archive to be uploaded and attested before the release is finalized. A hardened GitHub release asset job should request contents: write, id-token: write, and attestations: write, upload the .tar.gz and .zip archives, then attest the archive files directly instead of treating checksum files as a substitute.
After a release finishes, verify an archive with GitHub’s attestation CLI:
gh attestation verify monochange-x86_64-unknown-linux-gnu-v1.2.3.tar.gz \
--repo monochange/monochange
For release repair, GitHub is also the first provider with hosted-release retarget sync support. monochange uses the durable release record plus tag names from that record to keep the hosted release view aligned with moved tags.
GitHub Actions policy workflow
name: changeset-policy
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
concurrency:
group: changeset-policy-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check:
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: checkout repository
uses: actions/checkout@v6
- name: setup
uses: ./.github/actions/devenv
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: collect changed files
id: changed
uses: tj-actions/changed-files@v46
- name: run changeset policy
env:
PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }}
CHANGED_FILES: ${{ steps.changed.outputs.all_changed_files }}
shell: bash
run: |
set -euo pipefail
mapfile -t labels < <(jq -r '.[]' <<<"$PR_LABELS_JSON")
args=(step affected-packages --format json --verify)
for path in $CHANGED_FILES; do
args+=(--changed-paths "$path")
done
for label in "${labels[@]}"; do
args+=(--label "$label")
done
devenv shell -- monochange "${args[@]}" | tee policy.raw
awk 'BEGIN { capture = 0 } /^\{/ { capture = 1 } capture { print }' policy.raw > policy.json
jq -e '.status != "failed"' policy.json >/dev/null
Dogfooding on the monochange repository
The monochange repository itself can dogfood this model by:
- declaring
[source],[source.releases], and[source.pull_requests]inmonochange.toml - running a real
changeset-policyGitHub Actions workflow that shells intomonochange step affected-packages - publishing the CLI npm packages from
.github/workflows/publish.ymlwith the protectedpublisherenvironment andid-token: write, withoutNODE_AUTH_TOKENorNPM_TOKEN
For monochange’s own npm packages, register every package under the GitHub trusted-publishing context monochange/monochange, workflow file publish.yml, and environment publisher before the first tokenless publish:
@monochange/cli@monochange/cli-darwin-arm64@monochange/cli-darwin-x64@monochange/cli-linux-arm64-gnu@monochange/cli-linux-arm64-musl@monochange/cli-linux-x64-gnu@monochange/cli-linux-x64-musl@monochange/cli-win32-arm64-msvc@monochange/cli-win32-x64-msvc
After publishing, verify npm provenance from the package page or with npm’s provenance metadata for the released version. The expected publisher identity is the publish.yml workflow in monochange/monochange; a run that lacks npm trusted-publishing setup should fail instead of falling back to a long-lived registry token.
Supported providers
The --provider flag supports three source providers:
| Provider | --provider value | Workflow generation | Release automation | Pull/merge requests |
|---|---|---|---|---|
| GitHub | github | Yes: GitHub Actions | Yes | Yes |
| GitLab | gitlab | No: use .gitlab-ci.yml | Yes | Yes |
| Gitea | gitea | No: use Gitea Actions | Yes | Yes |
All providers configure the [source] section in monochange.toml with appropriate settings for releases and pull/merge requests. GitLab and Gitea require manual CI configuration since they don’t support GitHub Actions workflow files.
If you are comparing provider-specific CI layouts or designing a long-running release PR branch, continue with Advanced: CI, package publishing, and release PR flows.
Subagents and MCP
monochange ships two assistant-facing surfaces:
monochange subagents <target...>generates repo-local agent, subagent, or rule files for supported harnessesmonochange mcpstarts a stdio MCP server so assistants can call monochange tools directly
Classify changes before writing changesets
Run the JSON form when an agent needs to decide release intent:
monochange change classify --detection-level semantic --format json --dependency-propagation public
The report compares the pull request candidate with both the default branch and each package’s latest release. It separates the current proposedChangesetBump from the accumulated releaseFloor, and every major or minor proposal links to specific findings. Read Change classification for the full report contract and coverage limits.
After writing or updating the changesets, validate high-confidence evidence:
monochange changeset validate --api --format markdown
Package addition and removal findings use complete, high-confidence endpoint evidence. TypeScript packages can also produce complete, high-confidence declaration evidence in semantic mode when their compiler inputs are available. Other ecosystem source findings and TypeScript fallbacks remain advisory. Add --strict only when the repository wants every proposal to fail CI on a changeset mismatch.
Install the CLI and skill
Install the CLI:
npm install -g @monochange/cli
monochange --help
Install the bundled skill into the current project:
monochange help skill
monochange skill
monochange skill read configuration
monochange skill install --dir ./.claude/skills/monochange
The skill ships inside the binary, so it needs no network access or npm install. monochange skill read serves any bundled topic as raw Markdown, and monochange skill install --dir writes the whole tree into an agent runtime’s skills directory.
After copying the bundled skill, you get a small documentation set that is designed to load in layers:
SKILL.md: concise entrypoint for agentsREFERENCE.md: broader high-context reference with more examplesskills/README.md: index of focused deep divesskills/adoption.md: setup-depth questions, migration guidance, and recommendation patternsskills/change-classification.md: release-aware severity decisions, uncertainty, and ecosystem reviewskills/changesets.md: changeset authoring and lifecycle guidanceskills/commands.md: built-in command catalog and workflow selectionskills/configuration.md:monochange.tomlsetup and editing guidanceskills/linting.md:[lints]presets,monochange check, and manifest-focused examplesexamples/README.md: condensed scenario examples for quick recommendations
This layout keeps the top-level skill small while still making the richer guidance available when an assistant needs more context.
Generate repo-local subagents
Start with:
monochange help subagents
monochange subagents claude
monochange subagents pi codex
monochange subagents --all --dry-run --format json
Supported targets include:
claudevscodecopilotpicodexcursor
Generated subagents are CLI-first. They should prefer:
monochangenpx -y @monochange/cli
MCP config generation is optional and only emitted for targets with a stable repo-local MCP config format.
MCP configuration
Typical client configuration:
{
"mcpServers": {
"monochange": {
"command": "monochange",
"args": ["mcp"]
}
}
}
Start the server manually with:
monochange mcp
monochange subagents keeps MCP secondary. The generated files tell agents to prefer the CLI first and use MCP as an optional structured fallback.
Recommended repo-local guidance
Keep instructions like these close to your project guidance:
- Read
monochange.tomlbefore proposing release workflow changes. - Run
monochange step validatebefore and after release-affecting edits. - Use
monochange step discover --format jsonto inspect package ids, group ownership, and dependency edges. - Use
monochange step diagnose-changesets --format jsonormonochange_diagnosticsfor a structured view of all pending changesets with git and review context. - Run
monochange change classify --format json --dependency-propagation publicbefore writing release intent. Trace each proposed bump to its finding ids and review partial results. - Use
monochange_lint_catalogandmonochange_lint_explainwhen you need lint metadata without shelling out. - Prefer
monochange run changeplus.changeset/*.mdfiles over ad hoc release notes. - Use
monochange step prepare-release --dry-run --format jsonbefore mutating release state. - Gitignore only
.monochange/local/. Never ignore the whole.monochange/directory: release records and prerelease state are committed release state that publish, tag, and readiness steps read from git history, so ignoring them makes releases unpublishable.
Current MCP tools
The MCP server is JSON-first and focuses on reviewable operations:
monochange_validate: validatemonochange.tomland.changesettargetsmonochange_discover: discover packages, dependencies, and groups across the repositorymonochange_diagnostics: inspect pending changesets with git and review context as structured JSONmonochange_change: write a.changesetmarkdown file for one or more package or group idsmonochange_release_preview: prepare a dry-run release preview from discovered.changesetfilesmonochange_release_manifest: generate a dry-run release manifest JSON document for downstream automationmonochange_affected_packages: evaluate changeset policy from changed paths and optional labelsmonochange_lint_catalog: list registered manifest lint rules and presetsmonochange_lint_explain: explain one manifest lint rule or presetmonochange_analyze_changes: analyze git diff state and return ecosystem-specific semantic changesmonochange_classify_changes: compare the pull request and latest release, then return evidence-backed package bumpsmonochange_validate_changeset: validate one changeset against the current semantic diff
These tools are designed to help assistants inspect the workspace, write explicit release intent, and preview release effects before a human or CI system performs mutating follow-up commands.
monochange_analyze_changes and monochange_validate_changeset provide semantic analysis across Cargo, npm, Deno, and Dart/Flutter packages. They surface ecosystem-specific evidence such as Rust public API diffs, JS/TS export changes, package.json and deno.json export metadata, and pubspec.yaml dependency or plugin-platform changes, then validate authored changesets against that semantic model.
When you need full changeset context, including the introduced commit, linked PR, and related issues, use monochange step diagnose-changesets --format json directly. It returns stable workspace-relative paths and structured records that agents can parse without reading raw markdown files.
Diagnostics
monochange step diagnose-changesets gives you a quick snapshot of every pending changeset together with its git and review context.
It is useful for human developers reviewing a PR, for AI agents auditing what has changed, and for CI steps that need to understand the full context of pending work before triggering a release.
Basic usage
Inspect all pending changesets:
monochange step diagnose-changesets
Inspect a specific changeset:
monochange step diagnose-changesets --changeset .changeset/feature.md
You can pass --changeset multiple times. Duplicate paths are deduplicated automatically:
monochange step diagnose-changesets \
--changeset .changeset/api-change.md \
--changeset .changeset/api-change.md \
--changeset .changeset/bug-fix.md
A short name without the directory prefix also works:
monochange step diagnose-changesets --changeset feature.md
And absolute paths resolve correctly too:
monochange step diagnose-changesets --changeset /home/user/project/.changeset/feature.md
JSON output
Machine-readable diagnostics for scripting, CI, or AI consumption:
monochange step diagnose-changesets --format json
The JSON envelope includes:
requested_changesets: the resolved paths that were queriedchangesets: fullPreparedChangesetrecords, each with:path: workspace-relative path to the changeset filesummary: the first paragraph of the markdown bodydetails: optional follow-up paragraphstargets: package/group bump entries, each withkind,id,bump,origin, and optionalevidence_refscontext: git and review context (see below)
Context fields
When a changeset has been committed to a git repository, each context record contains:
introduced: revision where the changeset file was first committedlast_updated: revision where it was most recently changed (omitted when same asintroduced)related_issues: issues linked by the changeset or the PR that introduced it
Each revision record includes:
commit.sha: full commit SHAcommit.short_sha: short SHA for displayreview_request: PR/MR number and URL when the commit is associated with a pull request
Command
Use the generated immutable step command directly:
monochange step diagnose-changesets --format json
You only need a [cli.*] entry if you want a repository-specific alias that wraps diagnostics with additional steps or inputs.
AI agent and MCP usage
monochange step diagnose-changesets --format json and the MCP tool monochange_diagnostics are designed to give AI agents a structured overview of all pending changes before planning a release, reviewing a PR, or proposing follow-up work.
A typical agent workflow looks like this:
monochange step discover --format json: understand the workspace package graphmonochange step diagnose-changesets --format json: see all pending changesets, linked PRs, and introduced commitsmonochange run release --dry-run --format json: preview the computed release planmonochange run change ...: add, update, or remove changesets as neededmonochange run release: execute the release when everything looks correct
Because monochange step diagnose-changesets and monochange_diagnostics return stable, workspace-relative paths and structured JSON, agents can parse the output without needing to read raw markdown files directly. Each changeset record includes enough context, such as who introduced it, which PR it belongs to, and which issues it closes, for an agent to make targeted decisions about whether to proceed with a release or request changes.
Example: check for undocumented packages before a release
monochange step diagnose-changesets --format json | jq '[.changesets[] | select(.targets | length == 0)]'
Example: list all open review requests linked to pending changesets
monochange step diagnose-changesets --format json \
| jq '[.changesets[].context?.introduced?.review_request? | select(. != null) | .id] | unique'
Repairable releases
monochange step retarget-release is for the stressful moment right after a release when you discover that a few follow-up commits still need to be part of that release.
If you have not created the tags yet and only need the initial post-merge tag creation step, use monochange step tag-release --from HEAD instead. monochange step retarget-release is the follow-up tool for moving an already-created release tag set.
Examples:
- a packaging file was missing from the release branch
- generated artifacts were wrong
- a release automation step succeeded, but the tagged commit needs one or two immediate fixes before the release should stand
monochange solves that by storing a durable release declaration in git history and then using that declaration to move the whole release set forward together.
The two artifacts: release manifest vs release record
monochange has two related but different release artifacts:
| Artifact | What it means | When it exists | What it is for |
|---|---|---|---|
cached release manifest (.monochange/release-manifest.json) | what monochange is preparing right now | during command execution and cached locally | CI, MCP/server consumers, previews, downstream automation, and AI/agent workflows |
ReleaseRecord | what this release commit historically declared | inside the monochange-managed release commit body | later inspection and repair from git history |
Plain-language summary:
- manifest = “what monochange is preparing right now”
- release record = “what this release commit historically declared”
If you prefer the emphasized version:
- manifest = “what monochange is preparing right now”
- release record = “what this release commit historically declared”
The important consequence is that ReleaseRecord does not replace the cached release manifest.
Use the manifest when you want execution-time automation. Use the release record when you want history-time inspection, post-merge tagging, or repair.
Where the release record lives
monochange writes the durable ReleaseRecord into the body of the monochange-managed release commit.
That means the repair anchor travels with git history itself instead of living in a mutable receipt file somewhere in the repository tree.
The release commit body contains:
- a compact human-readable release summary
- a reserved monochange release-record block with structured JSON
The ReleaseRecord JSON schema is published with the book at https://monochange.github.io/monochange/schemas/release-record.schema.json. Stable generated copies use public schema-version suffixes, starting with https://monochange.github.io/monochange/schemas/release-record.v0.1.schema.json.
How monochange finds a release later
Use monochange step release-record when you want to inspect the durable release declaration for a tag or a newer commit built on top of that release.
monochange step release-record --from v1.2.3
monochange step release-record --from HEAD --format json
monochange step tag-release --from HEAD --dry-run --format json
monochange will:
- resolve the supplied ref to a commit
- walk first-parent ancestry
- stop at the first valid monochange
ReleaseRecord - report the release commit that declared it plus the distance from the input ref
That lets you inspect a release directly from its tag or from later fix commits.
Repairing a recent release
Use monochange step retarget-release when you want to move a recent release forward to a later commit.
monochange step retarget-release --from v1.2.3 --target HEAD --dry-run
monochange step retarget-release --from v1.2.3 --target HEAD
The command does the heavy lifting for you:
- finds the canonical release record from history
- derives the full release set from that record
- validates descendant-only safety rules by default
- previews the retarget plan in dry-run mode
- moves the whole tag set together when run for real
- syncs hosted release state when the provider supports it
Dry-run first
RetargetRelease is intentionally a dry-run-friendly workflow.
Use dry-run to see:
- the release record monochange found
- the target commit
- which tags will move
- whether the target is a descendant of the original release commit
- whether hosted-release sync will run
monochange step retarget-release --from v1.2.3 --target HEAD --dry-run --format json
Example workflow
A typical repair flow looks like this:
- monochange creates a release request commit with an embedded release record.
- That release is tagged and published.
- You add a follow-up fix commit or two.
- You inspect the durable history record:
monochange step release-record --from v1.2.3
- You preview the repair:
monochange step retarget-release --from v1.2.3 --target HEAD --dry-run
- You execute the repair:
monochange step retarget-release --from v1.2.3 --target HEAD
What RetargetRelease changes
RetargetRelease is focused and narrow. It changes:
- the release-set git tags derived from the durable release record
- hosted source-provider release state when supported by the provider integration
It does not:
- rewrite the original release commit
- rewrite the historical release record block
- regenerate a new release plan from scratch
- automatically republish immutable registry artifacts
When to use this vs publish a new patch release
Use monochange step retarget-release for just-created source/provider releases when the right fix is to move the release tags forward to a later commit.
Use monochange step tag-release when the release commit has merged but the declared tags have not been created yet.
Prefer publishing a new patch release when:
- immutable registry artifacts have already been published and consumers may already be relying on them
- you need a new externally visible version instead of retargeting an existing source release
- the release is no longer an immediate post-release repair situation
If you are under pressure, the rule of thumb is simple:
- if you need to fix the just-created source release itself, use
monochange step retarget-release - if you need a new immutable published artifact, cut a new patch release
Configuration and step model
The user-facing command is:
monochange step retarget-release --from v1.2.3 --target HEAD
The underlying built-in step is RetargetRelease.
That means you can also compose it into custom CLI workflows and then reference its structured outputs through retarget.* in later command steps.
The main fields exposed there are:
retarget.fromretarget.targetretarget.record_commitretarget.resolved_from_commitretarget.distanceretarget.tagsretarget.provider_resultsretarget.status
Provider scope in v1
GitHub is the first provider with release retarget sync support.
When provider sync is unsupported, monochange reports that clearly in dry-run and real execution paths rather than pretending the operation completed.
Keep using release manifests for automation
The new history-oriented repair workflow does not remove the execution-time manifest workflow.
Keep using the cached manifest JSON from PrepareRelease when you want:
- machine-readable release plans in CI
- MCP/server responses for assistants
- deterministic previews for downstream automation
- a stable execution-time snapshot of what monochange is about to do
Use ReleaseRecord and RetargetRelease when you want to inspect or repair a release later from git history.
Advanced: CI, package publishing, and release PR flows
This guide brings together the practical CI patterns around monochange step publish-packages, monochange step placeholder-publish, monochange step open-release-request, monochange step commit-release, and provider release automation.
It also documents the recommended workflow for long-running release PR branches.
Start with the command surface
These commands solve different automation problems:
These are common commands for repositories using monochange. With the current CLI model, workflow names such as discover, change, release, and affected come from optional [cli.*] tables in monochange.toml and run as monochange run <name>; binary commands such as check, init, versions, publish, and mcp stay built in, while typed built-in operations such as validation are exposed as immutable monochange step * commands.
| Goal | Command | Use it when |
|---|---|---|
| Validate config and changesets | monochange step validate | You changed monochange.toml or .changeset/*.md files |
| Inspect package ids and groups | monochange step discover --format json | You need the normalized workspace model |
| Sync internal dependency ranges | monochange versions --dry-run | You want internal dependency references to match canonical workspace package versions |
| Check the next version | monochange next | You want the next release group and package versions from pending changesets, without writing any release state |
| Create release intent | monochange run change --package <id> --bump <severity> --reason "..." | You need a new .changeset/*.md file |
| Audit pending release context | monochange step diagnose-changesets --format json | You need git provenance, PR/MR links, or related issues |
| Preview the release plan | monochange run release --dry-run --diff or monochange step prepare-release --dry-run | You want changelog/version patches without mutating the repo |
| Create a durable release commit | monochange step commit-release | You want a monochange-managed release commit with an embedded ReleaseRecord |
| Open or update a release request | monochange step open-release-request | You want a long-lived release PR/MR branch updated from current release state |
| Inspect a past release commit | monochange step release-record --from <ref> | You need the durable release declaration from git history |
| Check package publish readiness | monochange publish readiness --from HEAD --output <path> | You want a non-mutating preflight report before package publication |
| Dry-run configured publishing | monochange run publish-check | This repository, or another repo with a similar [cli.publish-check], should exercise publishing in CI without registry mutations |
| Plan ready package publishing | monochange step plan-publish-rate-limits --readiness <path> | You want rate-limit batches that exclude non-ready package work |
| Publish packages to registries | monochange publish packages --output <path> | You want cargo publish, npm publish, deno publish, or dart pub publish style package publication |
| Bootstrap release packages | monochange publish placeholder | You need a release-record-scoped placeholder bootstrap artifact before rerunning readiness |
| Create post-merge release tags | monochange step tag-release --from HEAD | You merged a monochange release commit and now need to create and push its declared tag set |
| Repair a recent release | monochange step retarget-release --from <tag> --target <commit> | You need to retarget a just-created release to a later commit |
| Publish hosted/provider releases | monochange step publish-release | You want GitHub/GitLab/Gitea release objects from prepared release state |
A practical rule of thumb:
- use
monochange step publish-readinessfor registry preflight reports andmonochange step publish-packagesfor registry package publication - use
monochange step publish-releasefor hosted releases from prepared release state - use
monochange step open-release-requestwhen you want a provider-backed release request branch - use
monochange step commit-releasewhen you want a durable local release commit in git history - use
monochange step tag-releasewhen that durable release commit has merged and you want to create its tag set on the default branch
The three automation layers
monochange has three related but different automation layers:
- Release planning:
monochange run release --dry-run,monochange run release,monochange step diagnose-changesets - Package registries:
monochange step publish-readiness,monochange step placeholder-publish,monochange step plan-publish-rate-limits --readiness <path>,monochange step publish-packages, and lower-levelmonochange step placeholder-publish - Hosted providers:
monochange step open-release-request,monochange step publish-release,monochange step retarget-release
Keeping those layers separate is important. Package publication and hosted-release publication are not the same job.
Gate CI on a dry-run publish check
Publishing is the only release phase that cannot be rolled back. A broken release commit is fixed with a follow-up commit, and release tags can be deleted and re-created, but registry publications are permanent: when a multi-package publish fails partway, earlier packages are already live, later packages are missing, and cleanup usually means manual unpublishing or burned version numbers.
Run a dry-run publish check before anything mutates:
monochange step publish-packages --dry-run
The dry run resolves the same publish set and dependency-aware batches as a real publish, checks each selected version against its registry, and validates the configured publishing flow without mutating any registry. Repositories commonly expose it through a configured workflow command such as monochange run publish-check or through a lint script.
Use it at two checkpoints:
- A required CI job on every pull request. Changes that would break publication fail CI instead of merging. This protects the default branch, but the pull request tree is not yet the release tree, so problems that only appear after versions change can still slip through.
- A simulated release commit. In the same pull request, create the release commit locally without pushing (
monochange run release --commit), run the dry-run publish check against that tree, then discard the commit. This validates the exact bumped versions, manifests, and changelogs the release will publish, and it is the strongest pre-merge signal.
For compiled ecosystems, also verify packages build from their packaged tarballs (for example cargo package --workspace) so a tarball that cannot compile fails CI instead of surfacing during publication.
Keep a dry-run publish check in the release workflow as the final gate before real publication. A problem that slips past CI then fails the release job before tags and hosted releases are created, so the fix is another commit instead of rolling back half-published registries.
Registry and provider capability snapshot
| Capability | Current status |
|---|---|
| Multi-ecosystem discovery | Cargo, npm/pnpm/Bun, Deno, Dart, Flutter, Python, Go |
| Package release planning | Built in |
| Grouped/shared versioning | Built in |
| Internal dependency version synchronization | All supported ecosystems via monochange versions; release planning also updates supported ecosystems during releases |
| Dry-run release diff previews | Built in via monochange step prepare-release --dry-run --diff; configured workflows may expose monochange run release --dry-run --diff |
| Durable release history and post-merge tagging | Built in via ReleaseRecord, monochange step release-record, monochange step tag-release, and monochange step retarget-release |
| Hosted provider releases | GitHub, GitLab, Gitea, Forgejo |
| Hosted release requests | GitHub, GitLab, Gitea, Forgejo |
| Python release planning | Built in for discovery, version rewrites, dependency rewrites, lockfile command inference, and PyPI publishing |
| Go release planning | Built in for go.mod discovery, dependency rewrites, go mod tidy inference, and Go proxy tag publishing |
| Built-in registry publishing | crates.io, npm, jsr, pub.dev, pypi, Go proxy tags; use external mode for custom registries |
| GitHub npm trusted-publishing diagnostics | Built in; registry-side enrollment stays manual or external |
GitHub trusted-publishing guidance for crates.io, jsr, pub.dev, and PyPI | Built in, but manual registry enrollment is still required |
| GitLab trusted-publishing auto-derivation | Not built in |
| Release-retarget sync for hosted releases | GitHub first |
CI setup assumption
The workflow sketches below assume the job already has:
- the
monochangeCLI available asmonochange - the native ecosystem toolchain it needs (
npm/pnpm,cargo,deno,dart,flutter,uv,poetry, or your external publishing tool) - repository checkout with enough history for release-record inspection
In the monochange repository itself, that usually means entering the devenv shell. In other repositories, it may mean installing @monochange/cli or monochange explicitly before the publish step.
GitHub flows
Common GitHub shape
For GitHub Actions, the most common structure is:
- a workflow prepares or updates a release PR branch
- a release commit lands on
main - a post-merge workflow detects the release commit
- that workflow creates the declared tags and publishes packages from the durable release commit
- hosted release objects or extra assets come either from downstream tag-driven workflows or from a separate workflow that still uses
monochange step publish-release
The important current implementation detail is that monochange step publish-readiness can write a preflight artifact from the ReleaseRecord on HEAD, monochange step placeholder-publish can run release-record-scoped first-time placeholder setup, monochange step publish-packages publishes directly from prepared release or HEAD release state, monochange step tag-release can create the declared release tags from that same durable record, and monochange step publish-release still works from prepared release state when you want a manifest-driven hosted-release job. The readiness artifact also fingerprints publish inputs that affect registry behavior for planning: monochange.toml, package manifests, lockfiles, and registry/tooling files such as .npmrc, .cargo/config.toml, rust-toolchain.toml, workspace Cargo.toml, and ecosystem manifests.
If the same post-merge job is responsible for both tags and package publication, run monochange step tag-release --from HEAD immediately after release-commit detection, then run monochange step publish-readiness --from HEAD --output <path>, use monochange step placeholder-publish only when first-time package setup is required, optionally inspect monochange step plan-publish-rate-limits --readiness <path>, and finally run monochange step publish-packages --output .monochange/publish-result.json. Rerun monochange step publish-readiness if CI setup edits publish inputs after the artifact is written. If a registry command fails after some packages were published, fix the cause and rerun monochange step publish-packages --resume .monochange/publish-result.json --output .monochange/publish-result.json; monochange skips completed package versions from the previous result and retries the remaining release work.
Tag-release JSON for follow-up workflows
When a post-merge workflow needs to trigger follow-up release work, prefer monochange step tag-release --from HEAD --format json and read the release tag by package or group id from the top-level tags object:
{
"tags": {
"main": "v1.2.3",
"sdk": "sdk/v1.2.3"
}
}
name/version examples such as sdk/v1.2.3 correspond to a tag template like {{ name }}/v{{ version }}.
The tags object is intentionally flat because package ids and group ids share the same monochange namespace. A workspace cannot have both a package and a group with the same id, so workflows do not need separate tags.packages and tags.groups branches or prefixed lookup keys. This makes automation stable and explicit: use .tags.<id> for the package or group whose release should drive the next step.
A package or group might not be released in a particular release commit. Handle that by checking whether tags has an entry for the id you care about. If there is no tag attached to that id, you can assume that release did not include that package or group and skip that follow-up workflow.
For example, a repository with [group.main] can trigger a downstream GitHub release workflow from the main group tag with:
monochange step tag-release --from HEAD --format json >/tmp/tag-report.json
tag="$(jq -r '.tags.main // empty' /tmp/tag-report.json)"
if [ -z "$tag" ]; then
echo "No main group tag found in tag-report.json, skipping release trigger"
exit 0
fi
gh workflow run release.yml --ref "$tag" -f tag="$tag"
Avoid indexing tagResults[0] for workflow control. tagResults remains the audit log of tag operations, while tags is the stable id-addressable map for automation.
GitHub + npm trusted publishing
Config:
[source]
provider = "github"
owner = "owner"
repo = "repo"
[ecosystems.npm.publish]
enabled = true
mode = "builtin"
trusted_publishing = true
Workflow sketch:
name: publish-npm
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: setup repo tooling
uses: ./.github/actions/devenv
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: detect monochange release commit
shell: bash
run: |
set -euo pipefail
if ! devenv shell -- monochange step release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
echo "HEAD is not a monochange release commit; skipping publish"
exit 0
fi
- name: publish npm packages
run: |
devenv shell -- monochange step publish-readiness --from HEAD --output .monochange/readiness.json
devenv shell -- monochange run publish
What monochange does here:
- resolves the GitHub workflow context
- rejects long-lived npm token environment variables when trusted publishing is enabled
- verifies that the publish job is running from the configured GitHub Actions OIDC context
- reports the
npm trust github ...repair command when setup needs manual or external repair - publishes trusted npm packages with the
npmCLI directly
Run npm trust github ... separately before this workflow if npm has not been enrolled yet; monochange step publish-packages does not execute npm trust during real publishing.
GitHub + Cargo (crates.io) trusted publishing
Config for monochange-managed release planning:
[source]
provider = "github"
owner = "owner"
repo = "repo"
[ecosystems.cargo.publish]
enabled = true
mode = "builtin"
trusted_publishing = true
monochange-oriented post-merge workflow sketch:
name: publish-cargo
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: ./.github/actions/devenv
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: detect monochange release commit
shell: bash
run: |
set -euo pipefail
if ! devenv shell -- monochange step release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
echo "HEAD is not a monochange release commit; skipping publish"
exit 0
fi
- name: publish Cargo packages
run: |
devenv shell -- monochange step publish-readiness --from HEAD --output .monochange/readiness.json
devenv shell -- monochange run publish
More copy-pasteable registry-native example:
If you want to follow the crates.io documentation more literally, let the official auth action own the token exchange and keep monochange focused on release planning. In that case, prefer mode = "external" for Cargo publication.
[source]
provider = "github"
owner = "owner"
repo = "repo"
[ecosystems.cargo.publish]
enabled = true
mode = "external"
trusted_publishing = true
name: publish-cargo
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: rust-lang/crates-io-auth-action@v1
id: auth
- run: cargo publish --package my_crate
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
For monorepos with multiple Cargo packages, split this into one job per published crate or have an external script decide which crates should publish for the current tag. For a broader decision guide across built-in and external multi-package flows, see Multi-package publishing patterns.
Important current behavior:
- monochange can carry the trust expectation in config
- monochange can report the setup URL and enforce that trust is configured before built-in release publishing continues
- for built-in crates.io publishing,
monochange step publish-readinessblocks packages whose currentCargo.tomlcannot be published:publish = false,publish = [...]withoutcrates-io, missingdescription, or missing bothlicenseandlicense-file - workspace-inherited Cargo metadata such as
description = { workspace = true }andlicense = { workspace = true }is accepted when[workspace.package]supplies the value - already-published Cargo versions remain non-blocking and are skipped when current readiness and the saved readiness artifact agree
- monochange does not auto-configure
crates.iotrust; registry-side enrollment remains manual - if you want the most literal crates.io/OIDC workflow,
mode = "external"plusrust-lang/crates-io-auth-action@v1is the clearest path
Recommended setup:
- configure
trusted_publishing = true - bootstrap missing release packages with
monochange step placeholder-publishif needed, then rerun readiness - manually enroll the repository/workflow in
crates.io - choose either:
mode = "builtin"and let monochange own the publish command, ormode = "external"and use the official crates.io auth action directly
GitHub + Deno / JSR trusted publishing
Config:
[source]
provider = "github"
owner = "owner"
repo = "repo"
[ecosystems.deno.publish]
enabled = true
mode = "builtin"
trusted_publishing = true
registry = "jsr"
Workflow sketch:
name: publish-jsr
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: ./.github/actions/devenv
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: detect monochange release commit
shell: bash
run: |
set -euo pipefail
if ! devenv shell -- monochange step release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
echo "HEAD is not a monochange release commit; skipping publish"
exit 0
fi
- name: publish JSR packages
run: |
devenv shell -- monochange step publish-readiness --from HEAD --output .monochange/readiness.json
devenv shell -- monochange run publish
Current behavior matches Cargo more than npm:
- monochange can validate the trust expectation and report the setup URL
- monochange does not auto-configure JSR trust on GitHub
- manual registry enrollment is still required before the built-in publish can proceed
GitHub + Dart / Flutter (pub.dev) trusted publishing
Config for monochange-managed release planning:
[source]
provider = "github"
owner = "owner"
repo = "repo"
[ecosystems.dart.publish]
enabled = true
mode = "builtin"
trusted_publishing = true
registry = "pub.dev"
monochange-oriented post-merge workflow sketch:
name: publish-pub-dev
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: ./.github/actions/devenv
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: detect monochange release commit
shell: bash
run: |
set -euo pipefail
if ! devenv shell -- monochange step release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
echo "HEAD is not a monochange release commit; skipping publish"
exit 0
fi
- name: publish pub.dev packages
run: |
devenv shell -- monochange step publish-readiness --from HEAD --output .monochange/readiness.json
devenv shell -- monochange run publish
More copy-pasteable registry-native example:
If you want the workflow shape recommended by the Dart team, prefer the reusable workflow from dart-lang/setup-dart and keep monochange focused on release planning. In that case, mode = "external" is usually the clearest fit.
[source]
provider = "github"
owner = "owner"
repo = "repo"
[ecosystems.dart.publish]
enabled = true
mode = "external"
trusted_publishing = true
registry = "pub.dev"
name: publish-pub-dev
on:
push:
tags:
- "my_package-v[0-9]+.[0-9]+.[0-9]+"
jobs:
publish:
permissions:
id-token: write
uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1
with:
working-directory: packages/my_package
# environment: pub.dev
If you need custom generation or build steps before publishing, switch to a custom workflow that runs dart pub publish --force or flutter pub publish --force after the OIDC-authenticated setup. For monorepos that mix package-specific tags, working directories, and external-mode jobs, see Multi-package publishing patterns.
Current behavior:
- monochange can enforce the configured trust expectation
- monochange reports the manual setup URL when trust is not configured
- monochange does not auto-configure
pub.devtrusted publishing - if you want the most copy-pasteable pub.dev flow,
mode = "external"plus the reusabledart-lang/setup-dartworkflow is the clearest path
GitHub post-merge package publish flow
If you want package publication to happen after the release PR merges, the simplest current pattern is:
- merge the release PR so the monochange release commit lands on
main - run
monochange step release-record --from HEAD --format jsonin CI - if the command succeeds, run
monochange step publish-readiness --from HEAD --output .monochange/readiness.json - run
monochange step publish-packagesonly after readiness succeeds - if release-record detection or readiness fails, exit early before registry mutation
That pattern works well because monochange step publish-readiness and monochange step publish-packages consume the durable ReleaseRecord from HEAD; readiness gives you a reviewable preflight report, while monochange step publish-packages derives the publish work directly from release state before publishing.
GitLab flows
Current GitLab reality
GitLab is a supported source provider for hosted releases and release requests.
For package publishing, monochange can still run built-in package publication commands from GitLab CI, but the trust auto-derivation and npm trust github automation are GitHub-specific.
That means the practical GitLab pattern is:
- keep
mode = "builtin"when monochange’s package publish command already matches what you need - keep
trusted_publishing = falseunless the registry workflow is one you manage externally - use CI secrets or external publishing logic when the registry requires a setup monochange does not automate on GitLab
GitLab + npm
Config:
[ecosystems.npm.publish]
enabled = true
mode = "builtin"
trusted_publishing = false
Workflow sketch:
publish_npm:
image: node:22
stage: publish
rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
script:
- corepack enable
- git fetch --force --tags origin
- |
set -euo pipefail
if monochange step release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
monochange step tag-release --from HEAD
monochange step publish-readiness --from HEAD --output .monochange/readiness.json
monochange step publish-packages
else
echo "not a release commit"
fi
If your npm flow needs registry-token setup or a custom .npmrc, do that in CI before running monochange step publish-readiness and monochange step publish-packages.
GitLab + Cargo
Config:
[ecosystems.cargo.publish]
enabled = true
mode = "builtin"
trusted_publishing = false
Workflow sketch:
publish_cargo:
image: rust:1.90
stage: publish
rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
script:
- git fetch --force --tags origin
- |
set -euo pipefail
if monochange step release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
monochange step tag-release --from HEAD
monochange step publish-readiness --from HEAD --output .monochange/readiness.json
monochange step publish-packages
else
echo "not a release commit"
fi
If you need a crates.io token or a more customized release process, inject the credential in GitLab CI or switch the package to mode = "external".
GitLab + Deno / JSR
Config:
[ecosystems.deno.publish]
enabled = true
mode = "builtin"
trusted_publishing = false
registry = "jsr"
Workflow sketch:
publish_jsr:
image: denoland/deno:latest
stage: publish
rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
script:
- git fetch --force --tags origin
- |
set -euo pipefail
if monochange step release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
monochange step tag-release --from HEAD
monochange step publish-readiness --from HEAD --output .monochange/readiness.json
monochange step publish-packages
else
echo "not a release commit"
fi
If your JSR auth bootstrap is more specialized than the built-in path expects, prefer mode = "external" and run the native publish command yourself.
GitLab + Dart / Flutter
Config:
[ecosystems.dart.publish]
enabled = true
mode = "builtin"
trusted_publishing = false
registry = "pub.dev"
Workflow sketch:
publish_pub_dev:
image: dart:stable
stage: publish
rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
script:
- git fetch --force --tags origin
- |
set -euo pipefail
if monochange step release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
monochange step tag-release --from HEAD
monochange step publish-readiness --from HEAD --output .monochange/readiness.json
monochange step publish-packages
else
echo "not a release commit"
fi
As with JSR, use mode = "external" when you need CI-specific auth or publish orchestration outside monochange’s built-in assumptions.
Long-running release PR branch flow
This is the flow you described:
- every merge to
mainupdates a dedicated release branch and PR - that branch contains the prepared release commit and release files
- the release PR stays open and keeps tracking the latest releasable state
- when the PR merges, publication happens from that merged release commit
What monochange supports
monochange supports the core post-merge pieces of this shape directly:
monochange step open-release-requestcan open or update a release request branch from current release statemonochange step commit-releasecan create a durable monochange release commit with an embeddedReleaseRecordmonochange step release-record --from HEADcan detect whether the latest commit is a monochange release commitmonochange step tag-release --from HEADcan create and push the declared tag set from that merged release commitmonochange step publish-readinesscan write a readiness artifact from that same durable record onHEAD, andmonochange step publish-packagescan publish directly from the durable release record
The important tag semantics
Tags are not branch-scoped.
A git tag points at a commit object, not at a branch name.
That means:
- if you create a tag on a release-PR commit, the tag exists immediately even before merge
- if that exact commit is later merged into
main, the tag still points at the same commit and is now reachable frommain - if the release branch is later rebased, force-pushed, or regenerated, the old tag does not move automatically
That is why pre-merge tagging on a long-running release PR is usually the wrong move.
Recommended workflow
For the long-running release PR model, the recommended shape is:
- on every push to
main, runmonochange step open-release-requestto refresh the dedicated release PR branch - do not create tags on the release PR branch
- merge the release PR when you are ready
- on the post-merge workflow, run
monochange step release-record --from HEAD --format json - if the latest commit is a release commit, run
monochange step tag-release --from HEAD - after tags exist, run
monochange step publish-readiness --from HEAD --output <path>and thenmonochange step publish-packagesfor package registries and let tag-triggered workflows create hosted releases or other downstream assets
That keeps tag creation on the default branch side of the merge, which is much safer than tagging the PR branch early.
GitHub Actions reference sketch
name: release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: fetch tags
run: git fetch --force --tags origin
- name: detect merged release commit
id: release_record
shell: bash
run: |
set -euo pipefail
if monochange step release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
echo "is_release_commit=true" >> "$GITHUB_OUTPUT"
else
echo "is_release_commit=false" >> "$GITHUB_OUTPUT"
fi
- name: refresh release PR
if: steps.release_record.outputs.is_release_commit != 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: monochange step open-release-request
- name: create release tags
if: steps.release_record.outputs.is_release_commit == 'true'
run: monochange step tag-release --from HEAD
- name: publish packages
if: steps.release_record.outputs.is_release_commit == 'true'
run: |
monochange step publish-readiness --from HEAD --output .monochange/readiness.json
monochange run publish
GitLab CI reference sketch
release_pr_or_publish:
stage: release
rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
script:
- git fetch --force --tags origin
- |
set -euo pipefail
if monochange step release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
monochange step tag-release --from HEAD
monochange step publish-readiness --from HEAD --output .monochange/readiness.json
monochange step publish-packages
else
monochange step open-release-request
fi
Choosing a CI pattern
Use this decision rule:
- Need human review before release files land? → use
monochange step open-release-request - Need a durable local release commit? → use
monochange step commit-release - Need package registries after merge? → detect
ReleaseRecordonHEAD, runmonochange step tag-release --from HEAD, then runmonochange step publish-readiness --from HEAD --output <path>andmonochange step publish-packages - Need hosted provider releases from prepared release state? → use
monochange step publish-release - Need to bootstrap release packages that do not exist yet? → use
monochange step placeholder-publish; it handles first-time placeholder setup for missing packages - Need GitHub npm trusted publishing with the least custom glue? → use
trusted_publishing = truewithmonochange step publish-readinessandmonochange step publish-packages - Need GitLab CI with custom auth/bootstrap? → keep
mode = "external"as the escape hatch
Related guides
Advanced: Multi-package publishing patterns
This guide covers the practical publishing patterns that work well when one repository releases multiple packages across one or more ecosystems.
Use it when:
- one monochange workspace publishes more than one public package
- different registries need different publish triggers
- some packages stay on
mode = "builtin"while others are clearer onmode = "external" - trusted publishing must be enrolled per package instead of once per repository
Start with the release boundary
For multi-package repositories, keep one idea fixed:
- monochange plans releases at the workspace level
- registries authorize publishing at the package level
That means the release plan can be shared, while publish automation often needs to stay package-specific.
A good default is:
- let monochange prepare one release commit for the workspace
- decide which packages use built-in publishing and which use external publishing
- keep each registry’s trusted-publishing enrollment aligned with the exact package workflow that will publish it
Choose the simplest publish pattern that matches the registry
Pattern 1: One post-merge publish job runs monochange step publish-readiness and monochange step publish-packages
Use this when most packages can stay on monochange’s built-in publishing path.
name: publish-packages
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: ./.github/actions/devenv
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: detect monochange release commit
shell: bash
run: |
set -euo pipefail
if ! devenv shell -- monochange step release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
echo "HEAD is not a monochange release commit; skipping publish"
exit 0
fi
- name: create release tags
run: devenv shell -- monochange step tag-release --from HEAD
- name: publish packages
run: |
devenv shell -- monochange step publish-readiness --from HEAD --output .monochange/readiness.json
devenv shell -- monochange step publish-packages --output .monochange/publish-result.json
This is the best fit when:
- multiple npm packages publish from the same workflow
- multiple packages share the same built-in post-merge flow
- you do not need package-specific tag triggers to satisfy the registry
Built-in publish order
When one monochange step publish-packages invocation contains multiple package publications, monochange publishes packages with no selected dependencies first, then publishes packages that depend on those packages, walking up the dependency tree until packages that depend on the most selected packages are published last.
The order is computed like this:
- Build the selected publish requests from the prepared release or
HEADrelease state. - Materialize the workspace dependency graph.
- Consider only dependencies where both packages are part of the selected publish set.
- Ignore development dependency edges.
- Topologically sort the publish requests so dependencies are emitted before dependents.
For example, with this internal package graph:
core # no dependencies
utils # depends on core
api # depends on utils
app # depends on core, utils, api
monochange publishes in this order:
core
utils
api
app
If multiple packages are independent at the same depth, their order is deterministic by package id, registry, and version.
A package with no selected dependencies is eligible first. A package is not published until all of its selected publish-relevant dependencies have been ordered before it. Dependencies outside the selected publish set do not block ordering. Development-only cycles are ignored. Runtime, build, peer, workspace, and unknown dependency cycles fail before publishing anything, with a cycle diagnostic.
Pattern 2: Package-specific external workflows publish from tags
Use this when the registry expects each package to have its own tag trigger, working directory, or workflow.
This is often the clearest fit for:
pub.dev- some
crates.iosetups - mixed workspaces where one package needs registry-native steps that do not match
monochange step publish-packages
Example tag naming scheme:
web-v{{version}}cli-v{{version}}dart_client-v{{version}}
Example config:
[ecosystems.cargo.publish]
enabled = true
mode = "external"
trusted_publishing = true
[ecosystems.dart.publish]
enabled = true
mode = "external"
trusted_publishing = true
registry = "pub.dev"
Example workflow shape:
name: publish-dart-client
on:
push:
tags:
- "dart_client-v[0-9]+.[0-9]+.[0-9]+"
jobs:
publish:
permissions:
id-token: write
uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1
with:
working-directory: packages/dart_client
# environment: pub.dev
Choose this pattern when a tag for one package must never authorize publishing a different package.
Pattern 3: One workflow, multiple package-specific jobs
Use this when you want one workflow file but separate jobs per package.
That gives you:
- one place to manage permissions and branch or tag triggers
- package-specific working directories
- package-specific environments
- package-specific failure visibility
Example shape:
jobs:
publish-crate-a:
environment: crates-a
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: rust-lang/crates-io-auth-action@v1
id: auth
- run: cargo publish --package crate_a
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
publish-crate-b:
environment: crates-b
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: rust-lang/crates-io-auth-action@v1
id: auth
- run: cargo publish --package crate_b
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
This pattern is especially useful when multiple packages live in the same ecosystem but should not share the same trusted-publishing enrollment.
Registry-specific recommendations
| Registry | Recommended multi-package pattern | Why |
|---|---|---|
| npm | one post-merge monochange step publish-readiness + monochange step publish-packages job when possible | monochange can automate npm trusted-publishing setup on GitHub |
| crates.io | one job per crate when using external OIDC auth | trusted publishing is enrolled per crate and workflow context matters |
| jsr | built-in monochange step publish-readiness + monochange step publish-packages is often fine, but keep setup package-specific | registry linking is still manual |
| pub.dev | package-specific tags and often one workflow per package | automated publishing is tag-driven and package-specific |
Keep config, tags, and workflows aligned
For each published package, keep these values aligned:
- package id in
monochange.toml - registry package name
- trusted-publishing repository/workflow/environment values
- workflow trigger
- tag pattern, when the registry uses tags
- working directory, when the registry workflow publishes from a subdirectory
If those drift apart, trusted-publishing validation will be confusing even when release planning is correct.
When to use package-level overrides
Use package-level publishing config when one package differs from the ecosystem default.
[ecosystems.dart.publish]
enabled = true
mode = "external"
trusted_publishing = true
registry = "pub.dev"
[package.dart_client.publish.trusted_publishing]
workflow = "publish-dart-client.yml"
environment = "pub.dev"
[package.example_app.publish]
enabled = false
This is the right move when:
- one package publishes from a different workflow file
- one package needs a protected environment but others do not
- one package is internal and should not publish publicly
- one ecosystem default is correct for most packages, but not all of them
Practical rollout for an existing monorepo
- decide which packages are public and which stay unpublished
- choose
builtinorexternalper ecosystem or package - register trusted publishing for each package at the registry
- prefer package-specific tags where a registry is tag-authorized
- run
monochange step publish-packages --dry-runafter registry enrollment changes - optionally run
monochange step publish-readiness --from HEAD --output <path>as a preflight before realmonochange step publish-packages - keep the workflow filename and environment stable once a registry record is enrolled
Common mistakes
Avoid these failure modes:
- using one broad tag pattern that lets a tag for package A publish package B
- reusing one trusted-publishing record across packages that actually publish from different workflows
- changing a workflow filename after registry enrollment without updating the registry record
- keeping
mode = "builtin"for packages that really need registry-native external publish steps - forgetting that
pub.devautomated publishing is tag-triggered
Related guides
- for registry-side trusted-publishing setup details, see Trusted publishing and OIDC
- for end-to-end CI examples, see CI, package publishing, and release PR flows
- for publishing config fields and inheritance, see Configuration reference
Publish rate-limit planning
monochange step plan-publish-rate-limits previews package-registry publish work against monochange’s built-in ecosystem rate-limit metadata.
monochange step publish-readiness --from HEAD --output .monochange/readiness.json
monochange step plan-publish-rate-limits --readiness .monochange/readiness.json --format json
monochange step plan-publish-rate-limits --mode placeholder --format json
monochange step plan-publish-rate-limits --ci github-actions
The report includes:
- registry windows grouped by publish operation
- the number of pending package publishes per registry
- whether the work fits in a single rate-limit window
- how many batches are required when it does not fit
- a provider-agnostic batch schedule with package ids per batch
- evidence links and confidence levels for the built-in limits
monochange step plan-publish-rate-limits only counts package versions that are still missing from their registries. If you rerun a release after some packages were already published, the remaining batches shrink automatically. When you pass --readiness <path>, the plan first validates that the readiness artifact covers the current release record, selected package set, and publish input fingerprint, then excludes package ids that are not ready in both the artifact and the fresh local readiness check.
Built-in coverage
crates.io: source-backed publish window metadatanpm: conservative advisory metadata when exact package publish quotas are not officially documentedjsr: official publish-window metadatapub.dev: conservative daily publish planning metadata for CI batching
Use monochange step publish-readiness --from HEAD --output <path>, then monochange step plan-publish-rate-limits --readiness <path>, then monochange step publish-packages when you want CI to fail early instead of discovering registry throttling mid-release. Rerun monochange step publish-readiness if workspace config, package manifests, lockfiles, or registry/tooling files changed since the artifact was written. The --readiness input is only valid for normal publish planning; placeholder planning still uses monochange step plan-publish-rate-limits --mode placeholder without a readiness artifact.
Filtering and enforcement
Both monochange step publish-packages and monochange step placeholder-publish accept repeated --package <id> filters so you can execute one planned batch at a time. For planning, generate the readiness artifact with the same --package <id> selection, or pass a broader readiness artifact to monochange step plan-publish-rate-limits --readiness <path> --package <id> so the plan can validate that the artifact covers the selected package subset. The later monochange step publish-packages --package <id> run derives work directly from release state and does not consume the readiness artifact.
If you want monochange to block risky built-in publishes instead of only warning, enable:
[ecosystems.dart.publish.rate_limits]
enforce = true
That setting is inherited by matching packages and causes monochange to stop before publishing when the selected package set needs more than one known registry window.
CI snippets
monochange step plan-publish-rate-limits --ci github-actions renders a GitHub Actions job matrix snippet.
monochange step plan-publish-rate-limits --ci gitlab-ci renders a GitLab CI matrix snippet.
Both snippets use explicit monochange step publish-packages --package ... invocations for each planned batch so you can wire the batches into manual, scheduled, or follow-up pipelines without relying on long sleeps inside CI. Pair each planned batch with monochange step publish-readiness --from HEAD --package ... --output <path> when you want a preflight report for that subset; publish the batch with monochange step publish-packages --package ....
Migration guides
Migration guides exist for the versions whose changes require you to update code, configuration, or automation. Each guide covers one version jump and stays focused on the actions you must take. The complete change list for every version lives in changelog.md.
When a version gets a guide
A version gets a guide when its release contains at least one change that breaks how people use, configure, or automate monochange. Patch releases and feature releases without breaking changes do not get a guide. Rust-only breaking changes are a section inside the same version guide rather than a separate document.
How the guides are organized
- One file per version at
docs/src/guide/migrations/<version>.md, for example0.11.md. - Listed newest first in the
SUMMARY.md“Migration guides” part. - Entries are grouped by audience: CLI behaviour first, then configuration and machine-readable schemas, then library APIs.
- Every entry states who is affected, what changed, and the exact update step with before and after examples.
- Breaking changesets reference the version guide so release notes link readers to the upgrade steps.
Guides
- Upgrading to 0.11
- Upgrading to 0.9: the nested command API
- Migrating from knope: for repositories coming from the knope release tool rather than upgrading monochange.
Versions older than 0.9 predate the migration-guide convention; their breaking changes are documented in the changelog.
Upgrading to 0.11
Version 0.11 makes the CLI output human-first, tightens publish readiness before any registry is touched, and updates several library APIs. Most repositories only need the CLI and changeset-authoring sections; Rust consumers should also review the library API section.
Each section states who is affected, what changed, and the exact update step. The complete change list for this release lives in changelog.md.
CLI results are human-first by default
Affects: scripts, CI jobs, and agents that parse default output.
Commands now print concise human-readable text by default. The previous default was Markdown, which a terminal rendered and a pipe received as Markdown text. One format now has one meaning:
- the default and
--format textreturn concise human-readable text; --format markdownalways returns raw Markdown and is never terminal-rendered;--format jsonand--format json-minreturn structured, ANSI-free data.
Update steps:
-
Replace parsers of the old default output with
--format json(complete report) or--format json-min(one line). -
monochange step configprints a short workspace summary. Use--format jsonfor the complete resolved configuration. -
--jqnow requires JSON output, so it can never run a command and only then discover that the result was not JSON:# before monochange <command> --jq '<query>' # after monochange <command> --format json --jq '<query>' -
--quietnow changes output only. It no longer silently turns a real operation into a dry run, so add--dry-runexplicitly:# before: this performed a dry run monochange run release --quiet # after monochange run release --dry-run --quiet
Workflows that set an explicit format default in monochange.toml keep that choice.
check failures exit non-zero in every format
Affects: CI jobs that run monochange check with JSON output.
A lint failure previously returned status 0 under --format json and --format json-min. Every format now exits 1 when lint errors exist, and the complete lint report still arrives on stdout:
monochange check --format json
echo $? # 1 when lint errors exist
CI can stop on the exit status without parsing error_count first.
Progress and diagnostics are safe for automation
Affects: CI log scrapers and automation that watched progress output.
- Captured and CI output never contains cursor-control sequences such as
ESC[2K. Progress is emitted as complete, deterministic lines. MONOCHANGE_NO_PROGRESS=1now also suppresses lint progress, as was already documented.- Failures print a stable diagnostic code, the relevant command or file context, and a suggested next action.
--progress-format jsonemits a newline-delimited event stream for tooling that wants machine-readable progress.--log-level debugstays a maintainer trace and now disables animation.
If a script detected progress by scraping spinner frames or control sequences, switch it to --progress-format json.
Publishing defaults and readiness
Longer default publish timeout (schema v0.6)
Affects: releases that publish to slow registries.
The default publish.timeout.timeout_seconds rises from 60 to 300 because crates.io server-side verification can take minutes. The timeout is a ceiling, not a delay: publishes that finish quickly are unaffected. The configuration and release-record schemas advance to v0.6; existing records migrate automatically and need no action.
Override the default per ecosystem or per package, and set timeout_seconds = 0 to disable the ceiling entirely:
[ecosystems.cargo.publish.timeout]
timeout_seconds = 300
retries = 2
Readiness blocks packages that cannot publish (artifact v3)
Affects: workflows that run monochange step publish-readiness or monochange step publish-packages.
Publish readiness now verifies trusted publishing for every selected package and validates the publication order against the workspace dependency graph before any registry state changes. A real publish run executes every readiness check before the first publish command, so a package that cannot publish aborts the run instead of failing midway.
Update steps:
- The readiness artifact schema advances from
2to3. Regenerate stored readiness artifacts after upgrading. - For each package with
publish.trusted_publishing = true, make surerepository,workflow, and the optionalenvironmentresolve from configuration, source settings, or the CI environment, and that the workflow file exists under.github/workflows/. - npm, crates.io, and pub.dev only accept trusted publishing for packages that already exist. Bootstrap first-time packages with
monochange step placeholder-publish, otherwise readiness blocks them with guidance.
Changeset authoring rules tightened
Affects: changeset authors and repositories using the changesets/recommended lint preset.
The preset now requires an H1 source summary (# Outcome) and rejects a first description sentence that merely repeats the summary. Repositories that do not use the preset can enable the standalone rule:
[lint.rules]
"changesets/summary-description" = "error"
Existing changeset files keep working, but new ones must lead with the H1 summary. The rendered changelog keeps choosing its own heading depth.
Library API updates
Affects: Rust consumers of the published crates.
PackagePublishSummary reports domain statuses
Replace expected, succeeded, and skipped with the explicit counters:
#![allow(unused)]
fn main() {
let summary = report.summary();
assert_eq!(summary.total(), report.packages.len());
assert_eq!(summary.published, 3);
assert_eq!(summary.already_exists, 2);
}
planned rows no longer collapse into skipped: use published, already_exists, blocked, and not_attempted for the states you care about.
Release notes stay structured until rendered
ReleaseNotesDocument is generic over its entry type. The previous Vec<String> of rendered Markdown entries still works, while typed entries expose summary, details, packages, change type, bump, stream, style, and provenance as fields:
#![allow(unused)]
fn main() {
let notes: ReleaseNotesDocument<ReleaseNotesEntry> = ReleaseNotesDocument {
title: "1.2.3".into(),
summary: vec![],
sections: vec![ReleaseNotesSection {
title: "Fixes".into(),
collapsed: false,
entries: vec![ReleaseNotesEntry {
summary: "Keep JSON failures non-zero".into(),
details_markdown: Some("CI can trust the exit status.".into()),
packages: vec![],
change_type: Some("fix".into()),
bump: BumpSeverity::Patch,
stream: "default".into(),
style: ReleaseNoteEntryStyle::Compact,
provenance: ReleaseNoteProvenance::default(),
}],
}],
};
let legacy = notes.to_legacy_markdown(&ChangelogStyle::default());
}
Built-in section names no longer contain emoji. Repositories that want emoji put them in their configured section headings.
SemanticChange is non-exhaustive
Custom semantic analyzers construct findings with SemanticChange::new and attach compiler evidence with with_assessment instead of using a struct literal:
#![allow(unused)]
fn main() {
let change = SemanticChange::new(
SemanticChangeCategory::PublicApi,
SemanticChangeKind::Modified,
"function",
"parse",
"function `parse` changed",
"src/lib.rs",
)
.with_assessment(SemanticChangeAssessment::new(
SemanticAnalysisOutcome::Breaking,
BumpSeverity::Major,
ApiConfidence::High,
SemanticAnalyzerEvidence::new(
"example/analyzer",
"example-engine",
SemanticAnalysisCompleteness::Complete,
"all public entrypoints checked",
),
));
}
CargoSemanticAnalyzer uses constructors
EcosystemSettings gains a semver_checks field, so struct literals must initialize it. CargoSemanticAnalyzer is no longer a unit struct; construct it explicitly:
#![allow(unused)]
fn main() {
let disabled = monochange_cargo::semantic_analyzer();
let with_settings = monochange_cargo::semantic_analyzer_with_settings(settings);
}
Exact ranges replace merge-base semantics for custom ranges
ChangeFrame::CustomRange now compares exactly base..head, while pull request frames keep merge-base semantics. Tools that compare several git frames should build one AnalysisSession and reuse it, and shared package path policy moved into monochange_core::PackagePathMatcher:
#![allow(unused)]
fn main() {
use monochange_analysis::{AnalysisConfig, AnalysisSession, ChangeFrame};
let session = AnalysisSession::new(root, AnalysisConfig::default())?;
let pull_request = session.analyze(&ChangeFrame::CustomRange {
base: "origin/main".into(),
head: "HEAD".into(),
})?;
}
#![allow(unused)]
fn main() {
use monochange_core::{PackagePathMatch, PackagePathMatcher};
let matcher = PackagePathMatcher::new(
"web",
"packages/web".as_ref(),
&["shared/schema/**".into()],
&["fixtures/**".into()],
);
assert_eq!(
matcher.classify("shared/schema/api.json".as_ref()),
PackagePathMatch::Touched,
);
}
Machine-readable schema versions
Affects: tools that pin monochange’s JSON schemas.
| Artifact | Previous | Current | Action |
|---|---|---|---|
| Configuration and release-record schema | 0.5 | 0.6 | None; records migrate automatically |
| Publish-readiness artifact | 2 | 3 | Regenerate stored artifacts |
| Change-classification report | older | 3 | Update parsers; coverage may carry a checks array |
Upgrading to 0.9: the nested command API
This guide is for maintainers and agents updating repositories from the older monochange CLI command layout to the nested command API shipped in monochange 0.9.
The migration has three breaking command-path changes:
- Built-in step commands moved to the nested
monochange step <name>path; colon-delimited top-level aliases are no longer supported. - User-defined
[cli.<name>]commands moved frommonochange <name>tomonochange run <name>. - The packaged
mcbinary alias was removed; usemonochangedirectly.
Quick replacement table
| Old command | New command |
|---|---|
mc check | monochange check |
mc versions list --format json | monochange versions list --format json |
| Colon-delimited built-in step token | monochange step <name> |
monochange <configured-command> | monochange run <configured-command> |
1. Replace the mc binary alias
The release now ships only the monochange executable. Replace every mc invocation in scripts, CI workflows, documentation, and agent instructions.
Before:
mc check
mc versions list --format json
After:
monochange check
monochange versions list --format json
monochange step validate
If a local developer wants shorthand, they can define their own shell alias, but repository automation should not depend on it:
alias mc = monochange
2. Move built-in step commands under step
Built-in workflow steps no longer use colon-delimited top-level command names. Invoke them through the nested command path:
monochange step config --format json
monochange step validate
monochange step publish-readiness --format json
monochange step publish-packages --dry-run
The step flags and output formats stay attached to the step itself. Split the command path into step and <name> arguments; argument arrays should likewise use two entries.
3. Move configured commands under run
Commands defined in monochange.toml stay in [cli.<name>], but they are invoked through monochange run <name>.
Given this config:
[cli.release-pr]
description = "Prepare a release pull request"
steps = [
{ type = "PrepareRelease", dry_run = true },
{ type = "OpenReleaseRequest", dry_run = true },
]
Before:
monochange run release-pr --dry-run
After:
monochange run release-pr --dry-run
Only add run for commands that come from [cli.<name>]. Built-in commands remain top-level or nested built-ins:
monochange check
monochange run change
monochange versions list --format json
monochange step validate
Agent checklist
When updating a repository, scan these files first:
.github/workflows/*.yml.gitlab-ci.ymldevenv.nixand task filespackage.jsonscriptsCargo.tomlaliases or xtask wrappersREADME.mdand docs examplesAGENTS.md, skill files, prompt templates, and other agent instructions- Shell scripts under
scripts/
Apply these rules in order:
- Replace executable
mcwithmonochange. - Replace each colon-delimited built-in step token with the nested
monochange step <name>path. - For each command name defined in
monochange.tomlunder[cli.<name>], replacemonochange <name>withmonochange run <name>. - Do not rewrite built-ins such as
monochange check,monochange run change,monochange init,monochange mcp,monochange run release, ormonochange versionsintomonochange run ...unless that exact name is intentionally a configured command in the repository. - Prefer
monochange versions list --format jsonfor machine-readable version output.
Validation
After updating automation, run the commands that the repository expects agents or CI to use. A typical monochange repository can validate with:
monochange check
monochange step validate
monochange versions list --format json
Migrating from knope
This guide walks through converting a knope.toml configuration to monochange.toml.
monochange was originally inspired by knope and shares many of the same ideas: changeset-driven releases, configurable workflows, and GitHub integration. It uses a different configuration surface and adds cross-ecosystem support.
Quick comparison
| Feature | knope | monochange |
|---|---|---|
| Config file | knope.toml | monochange.toml |
| CLI binary | knope | monochange / monochange |
| Changeset directory | .changeset/ | .changeset/ |
| Changeset format | Markdown frontmatter | Markdown frontmatter |
| Conventional commits | Supported | Not supported |
| Single-package config | [package] | [package.<id>] |
| Multi-package config | [packages.<name>] | [package.<id>] |
| Version groups | Implicit (single [package]) | Explicit [group.<id>] |
| Workflows | [[workflows]] | [cli.<command>] |
| GitHub config | [github] | [source] (provider-neutral) |
| Ecosystem support | Rust, Go, JS | Rust, npm, pnpm, Bun, Deno, Dart, Flutter, Python |
| Dependency propagation | Not built-in | Automatic parent bumps |
Step 1: Replace the config file
Delete knope.toml and create monochange.toml at the repository root.
Step 2: Migrate package declarations
Single-package knope repository
knope uses a bare [package] table for single-package repos:
# knope.toml
[package]
versioned_files = [
{ path = "Cargo.toml", type = "cargo" },
{ path = "Cargo.lock", type = "cargo" },
]
changelog = "changelog.md"
scopes = ["core", "cli"]
[package.my-crate.changelog.types]
note = { bump = "none", section = "Notes" }
docs = { bump = "none", section = "Documentation" }
In monochange, every package gets a named [package.<id>] entry. Use [defaults] to reduce boilerplate and [group.<id>] when all packages should share one version:
# monochange.toml
[defaults]
package_type = "cargo"
[defaults.changelog]
path = "{{ path }}/changelog.md"
format = "keep_a_changelog"
[package.my-crate]
path = "."
versioned_files = [{ path = "Cargo.lock", type = "cargo" }]
[package.my-crate.changelog.types]
note = { bump = "none", section = "Notes" }
docs = { bump = "none", section = "Documentation" }
Note: knope’s
scopesfilter conventional commits to specific packages. monochange does not use conventional commits. Use changeset frontmatter keys instead.
Multi-package knope repository
knope uses [packages.<name>] for multi-package repos:
# knope.toml
[packages.sdk_core]
versioned_files = [
"crates/sdk_core/Cargo.toml",
]
changelog = "crates/sdk_core/changelog.md"
[packages.sdk_cli]
versioned_files = [
"crates/sdk_cli/Cargo.toml",
]
changelog = "crates/sdk_cli/changelog.md"
In monochange, use [package.<id>] entries with a path field. monochange updates native manifests automatically for supported ecosystems, so versioned_files only needs to cover extra managed files:
# monochange.toml
[defaults]
package_type = "cargo"
[defaults.changelog]
path = "{{ path }}/changelog.md"
[package.sdk_core]
path = "crates/sdk_core"
[package.sdk_cli]
path = "crates/sdk_cli"
versioned_files = [
{ path = "Cargo.lock", type = "cargo" },
]
Tip: you do not need to list the package’s own
Cargo.tomlas a versioned file. monochange discovers and updates native manifests automatically.
Step 3: Migrate version groups
knope’s single [package] table implicitly groups all crates under one version. When migrating a repo that uses [package] with multiple versioned_files dependency entries, create an explicit [group.<id>]:
# monochange.toml
[group.main]
packages = ["sdk_core", "sdk_cli"]
tag = true
release = true
version_format = "primary"
[group.main.changelog]
path = "changelog.md"
Group behavior:
- all members share one synchronized version
tag,release, andversion_formatare owned by the group- member packages can still have their own changelogs
- members without direct changes get a configurable
empty_update_messagefallback
Step 4: Migrate workflows to CLI commands
knope uses [[workflows]] arrays. monochange uses [cli.<command>] map entries that become top-level CLI subcommands.
knope workflow
# knope.toml
[[workflows]]
name = "release"
[[workflows.steps]]
type = "PrepareRelease"
[[workflows.steps]]
type = "Command"
command = "dprint fmt"
[[workflows.steps]]
type = "Command"
command = "git add --all"
[[workflows.steps]]
type = "Command"
command = 'git commit -m "chore: prepare releases {{ version }}"'
[[workflows.steps]]
type = "Command"
command = "git push"
[[workflows.steps]]
type = "Release"
monochange equivalent
# monochange.toml
[cli.release]
help_text = "Prepare a release from discovered change files"
[[cli.release.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
default = "text"
[[cli.release.steps]]
type = "PrepareRelease"
[cli.publish-release]
help_text = "Prepare a release and publish provider releases"
[[cli.publish-release.steps]]
type = "PrepareRelease"
[[cli.publish-release.steps]]
type = "PublishRelease"
Workflow step mapping
| knope step | monochange step | Notes |
|---|---|---|
PrepareRelease | PrepareRelease | Same name, same purpose |
CreateChangeFile | CreateChangeFile | Same name |
Release | PublishRelease | knope’s Release creates GitHub releases; monochange calls this PublishRelease and supports multiple providers |
Command | Command | Same name; monochange adds dry_run_command and shell = true |
| : | OpenReleaseRequest | New: open/update a release PR |
| : | PrepareRelease | New: refresh the cached .monochange/release-manifest.json artifact for downstream CI |
| : | AffectedPackages | New: PR changeset policy enforcement |
| : | Validate | New: validate config and changesets |
| : | Discover | New: list workspace packages |
| : | CommentReleasedIssues | New: comment on closed issues referenced in changesets |
Common knope workflow → monochange command recipes
Create a changeset (knope document-change):
# monochange.toml
[cli.change]
help_text = "Create a change file"
[[cli.change.inputs]]
name = "package"
type = "string_list"
required = true
[[cli.change.inputs]]
name = "bump"
type = "choice"
choices = ["patch", "minor", "major"]
default = "patch"
[[cli.change.inputs]]
name = "reason"
type = "string"
required = true
[[cli.change.steps]]
type = "CreateChangeFile"
Open a release PR (no knope equivalent):
# monochange.toml
[cli.release-pr]
help_text = "Open or update a release pull request"
[[cli.release-pr.steps]]
type = "PrepareRelease"
[[cli.release-pr.steps]]
type = "OpenReleaseRequest"
Key difference: knope workflows often include manual
git add,git commit, andgit pushCommand steps. monochange handles git operations internally when usingPublishReleaseorOpenReleaseRequest, so you can drop those manual steps.
Step 5: Migrate GitHub configuration
knope
# knope.toml
[github]
owner = "my-org"
repo = "my-repo"
monochange
monochange uses a provider-neutral [source] table. GitHub is the default provider:
# monochange.toml
[source]
provider = "github" # default, can be omitted
owner = "my-org"
repo = "my-repo"
[source.releases]
enabled = true
source = "monochange"
[source.releases]
branches = ["main"]
enforce_for_tags = true
enforce_for_publish = true
enforce_for_commit = false
changeset_context_timeout_seconds = 120
[source.pull_requests]
enabled = true
branch_prefix = "monochange/release"
base = "main"
title = "chore(release): prepare release"
labels = ["release", "automated"]
monochange also supports GitLab and Gitea providers:
[source]
provider = "gitlab"
owner = "my-group"
repo = "my-project"
host = "gitlab.example.com"
Step 6: Migrate changeset files
monochange and knope both use markdown-frontmatter changesets under .changeset/. The format is compatible, but there are differences in how packages are referenced.
knope changeset
---
my_crate: minor
---
# add new feature
Details about the feature.
monochange changeset
Same format, but use declared package ids or group ids as keys:
---
my_crate: minor
---
# add new feature
Details about the feature.
If you have a group, you can target the group directly:
---
main: minor
---
# coordinated release across all packages
Note: a changeset may not reference both a group id and one of its member package ids in the same file. Use either the group id or individual package ids.
Step 7: Handle knope-specific features
Conventional commits
knope can derive version bumps from conventional commit messages. monochange does not support conventional commits. All version changes must come from changeset files.
If your knope config uses conventional commits alongside changesets:
# knope.toml — remove this
[changes]
ignore_conventional_commits = false # or absent
Switch to changeset-only workflows. Use monochange run change to create changesets:
monochange run change --package my_crate --bump minor --reason "add new feature"
knope scopes
knope uses scopes to filter conventional commits to specific packages. Since monochange doesn’t use conventional commits, there is no equivalent. Remove scopes entries from your config.
knope [bot.releases]
# knope.toml
[bot.releases]
enabled = true
In monochange, release automation is configured through [changesets.affected]:
# monochange.toml
[changesets.affected]
enabled = true
required = true
skip_labels = ["no-changeset-required"]
comment_on_failure = true
changed_paths = ["crates/**", "packages/**"]
ignored_paths = ["docs/**", "readme.md"]
knope forced-release workflow
knope’s forced-release workflow runs Release without PrepareRelease. In monochange, use a configured PublishRelease workflow, which always requires a PrepareRelease step first. For publishing without changesets, create a changeset manually or adjust the release flow.
Regex-based versioned files
knope supports regex patterns in versioned files:
# knope.toml
versioned_files = [
{ path = "readme.md", regex = "my_crate = \"(?<version>\\d+\\.\\d+\\.\\d+)\"" },
]
monochange supports the same regex versioned-file entries with an identical shape. The regex must include a (?<version>...) named capture group, and monochange replaces only the captured substring:
[package.core]
path = "crates/core"
versioned_files = [
{ path = "readme.md", regex = 'my_crate = "(?<version>\d+\.\d+\.\d+)"' },
]
Regex entries accept glob path patterns and work on packages, groups, and ecosystem-level versioned_files. See Regex versioned files for the full rule set.
Step 8: Migrate GitHub Actions workflows
knope GitHub Actions
A typical knope CI workflow runs knope release or knope document-change:
# Before
- run: knope release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
monochange GitHub Actions
Replace with the equivalent monochange command:
# After
- run: monochange run release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
For PR-based release flows with monochange, add a changeset policy workflow:
- name: run changeset policy
run: |
monochange step affected-packages --format json --verify \
--changed-paths file1.rs \
--changed-paths file2.rs
See GitHub automation for a complete workflow example.
Complete migration example
Before: knope.toml
[package]
versioned_files = [
"Cargo.toml",
{ dependency = "my_core", path = "Cargo.lock" },
{ dependency = "my_core", path = "Cargo.toml" },
{ dependency = "my_cli", path = "Cargo.lock" },
{ dependency = "my_cli", path = "Cargo.toml" },
]
changelog = "changelog.md"
scopes = ["core", "cli"]
[package.my-crate.changelog.types]
note = { bump = "none", section = "Notes" }
docs = { bump = "none", section = "Documentation" }
[changes]
ignore_conventional_commits = true
[[workflows]]
name = "release"
[[workflows.steps]]
type = "PrepareRelease"
[[workflows.steps]]
type = "Command"
command = "dprint fmt"
[[workflows.steps]]
type = "Command"
command = "git add --all"
[[workflows.steps]]
type = "Command"
command = 'git commit -m "chore: prepare releases {{ version }}"'
[[workflows.steps]]
type = "Command"
command = "git push"
[[workflows.steps]]
type = "Release"
[[workflows]]
name = "document-change"
[[workflows.steps]]
type = "CreateChangeFile"
[[workflows.steps]]
type = "Command"
command = "dprint fmt .changeset/* --allow-no-files"
[github]
owner = "my-org"
repo = "my-repo"
After: monochange.toml
[defaults]
package_type = "cargo"
[defaults.changelog]
path = "{{ path }}/changelog.md"
format = "keep_a_changelog"
[package.my_core]
path = "crates/my_core"
[package.my-crate.changelog.types]
note = { bump = "none", section = "Notes" }
docs = { bump = "none", section = "Documentation" }
[package.my_cli]
path = "crates/my_cli"
[package.my-crate.changelog.types]
note = { bump = "none", section = "Notes" }
docs = { bump = "none", section = "Documentation" }
[group.main]
packages = ["my_core", "my_cli"]
tag = true
release = true
version_format = "primary"
[group.main.changelog]
path = "changelog.md"
[source]
provider = "github"
owner = "my-org"
repo = "my-repo"
[source.releases]
enabled = true
source = "monochange"
[cli.release]
help_text = "Prepare a release from discovered change files"
[[cli.release.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
default = "text"
[[cli.release.steps]]
type = "PrepareRelease"
[cli.publish-release]
help_text = "Prepare a release and publish provider releases"
[[cli.publish-release.steps]]
type = "PrepareRelease"
[[cli.publish-release.steps]]
type = "PublishRelease"
[cli.change]
help_text = "Create a change file"
[[cli.change.inputs]]
name = "package"
type = "string_list"
required = true
[[cli.change.inputs]]
name = "bump"
type = "choice"
choices = ["patch", "minor", "major"]
default = "patch"
[[cli.change.inputs]]
name = "reason"
type = "string"
required = true
[[cli.change.steps]]
type = "CreateChangeFile"
# `validate` is a built-in step command; run `monochange step validate` directly instead of defining [cli.validate].
Migration checklist
- Delete
knope.toml - Create
monochange.tomlwith[defaults]and[package.<id>]entries - Add
[group.<id>]if packages should share a version - Replace
[[workflows]]with[cli.<command>]entries - Replace
[github]with[source] - Remove
scopesand[changes]sections (no conventional commits) - Update
.changeset/*.mdfrontmatter keys to use declared package/group ids - Update CI workflows from
knope <command>tomonochange <command> - Run
monochange step validateto check config and changesets - Run
monochange run release --dry-runto verify the release plan - Remove knope from your dependencies and install monochange
Change classification
monochange change classify produces a release-aware severity report for packages affected by a pull request or local worktree.
Comparisons
The classifier resolves these comparisons:
| Kind | Base | Head | Purpose |
|---|---|---|---|
pullRequest | The remote default branch, or --base | The synthetic merge result of the base and --head | Changes introduced after the pull request merges |
sourceDelta | The merge base of the default branch and the source candidate | The source candidate | Changes authored on the pull request branch |
workingTree | HEAD | The staged, unstaged, deleted, and untracked files | A diagnostic view of local changes |
release | The package release owner’s latest reachable tag, or --release | The synthetic merge result | Accumulated change since the latest release |
releaseToDefault | The same release tag | The default branch | Change that has already accumulated before the pull request |
pullRequest and release are exact two-endpoint comparisons. sourceDelta uses the merge base only to identify work authored on the branch. When --head is HEAD, the source candidate materializes committed and local changes into one temporary Git commit. The bump comes from the net candidate comparison, while workingTree remains a diagnostic view. A local edit that reverses a committed breaking change therefore removes that break from the proposal instead of adding a second, contradictory signal.
The candidate is a tree object created by git merge-tree --write-tree. monochange uses a temporary index for local changes and does not change the real index or worktree. If Git cannot create the merge tree, the report marks the comparison as conflicted, falls back to the source candidate, and requires human review.
Package decision
Each package has a decision object with these fields:
| Field | Meaning |
|---|---|
compatibility_impact | breaking, additive, compatible, or unmodeled between the default branch and the candidate |
release_impact | The same verdict between the latest release and the candidate; absent when no release tag matched |
pull_request_changes | Whether the net candidate or the local working tree contains files that belong to this package |
proposed_changeset_bump | The highest current finding after the release cap: major, minor, patch, or none |
enforceable_minimum | The highest bump supported by high-confidence evidence, after the same release cap |
release_floor | The highest bump found between the latest release and the candidate |
confidence | Confidence of the finding that determines proposed_changeset_bump |
completeness | Whether the analyzer claims complete, partial, or unsupported coverage |
review_required | Whether the proposal needs human or agent review before it becomes release intent |
finding_ids | Stable identifiers for the findings that determine the proposal |
compatibility_impact describes the default-branch comparison, so it also sees breaks against an API the default branch has not released yet. release_impact describes the same candidate against the package’s latest release. When a release comparison is analyzed, a modeled finding cannot propose a bump higher than the release-relative bump for the package, because nobody holding the latest release can observe a break in an item that release never shipped. A pull request that refines an unreleased API therefore reports compatibility_impact: breaking with release_impact: additive and a minor proposal instead of an inflated major. Unmodeled findings stay uncapped: they are the review floor for surfaces the analyzers cannot model, and the release comparison cannot refute them. release_floor still reports the complete unreleased interval, so a pull request can propose patch while the release floor is major because an earlier merged pull request introduced the breaking change.
pull_request_changes decides what the contribution itself may propose. Findings from the release and releaseToDefault comparisons describe what the default branch accumulated since the latest release, so a package the pull request does not modify reports proposed_changeset_bump: none, enforceable_minimum: none, completeness: complete, and review_required: false while release_floor and release_impact keep the accumulated change. A pending changeset for such a package still reports action: review, because a changeset may intentionally describe a consumer-facing effect implemented in another package, but the report no longer escalates the bump or the review verdict for work someone else already merged. A package becomes pull-request-scoped through the net candidate or through the local working tree, so a branch change that a local edit reverts stays in scope and keeps requiring review.
none is conclusive only when completeness is complete and review_required is false. A changed package without modeled semantic evidence receives a low-confidence patch proposal instead of a false none result. A decision is also complete when every current finding is complete. A high-confidence major finding makes the bump decision complete even when another analyzer is partial because no unmodeled finding can require a higher bump.
The package-level action is create, update, keep, review, or no_changeset. The report includes packages targeted only by a pending changeset and marks them review; a changeset can intentionally describe a consumer-facing effect implemented in another package, so monochange does not assume that unmatched intent is stale. Public dependency propagation retains the dependent package’s release owner, comparisons, and existing changesets.
Skipping a pull request
Some pull requests have no pending work to classify. The release pull request monochange opens only bumps versions and deletes consumed changesets, so every package would report the unmodeled fallback and bury the real signal.
Configure [changesets.classification].skip_labels and pass the pull request labels with --label:
[changesets.classification]
skip_labels = ["release"]
monochange change classify --format json --label release
When any configured label is present, the report sets skipped: true, analyzes no packages, names the matched labels in matched_skip_labels, and exits successfully. The recommended bump is none. Set skip_labels = [] to classify every pull request.
Findings
Each finding records its rule_id, API surface, change kind, compatibility impact, bump, confidence, analyzer id, engine and version, coverage note, optional fallback reason, source location, before and after signatures, and comparison membership. Markdown and text reports print the evidence directly below each finding so pull request comments retain the same provenance as JSON.
unmodeled means the change is real but sits outside the public surface the analyzer models, so no compatibility verdict applies. The package is supported; only that file change is outside the model. A changed package with no modeled finding receives the low-confidence monochange/unclassified-source fallback, which proposes patch and requires review because no analyzer can rule out a break.
Identical evidence found in several comparisons shares one finding and lists every comparison. If the same item has different before or after signatures across the pull-request and release intervals, monochange emits distinct comparison-qualified finding ids so that an agent never applies one interval’s signature evidence to another interval.
monochange compares package manifests at both endpoints. Adding or removing a package produces a monochange/package-lifecycle finding with complete coverage and high confidence. Removing a package proposes major, even when the package has no modeled public symbols. Adding one proposes minor.
The built-in Cargo, JavaScript, Deno, and Dart source analyzers inspect syntax and package metadata. Their findings are partial and medium-confidence because they do not prove every language compatibility rule. Cargo packages can opt into a cargo-semver-checks matrix for stronger Rust evidence.
One Rust case carries a stronger verdict than the syntax default. A public const or static whose declared type is a slice (&[T]), a fixed-size array ([T; N]), or Vec<T> and whose initializer is a literal is compared element by element. Adding elements to the end is additive with a minor proposal and high confidence, matching cargo semver-checks, because the element type and every existing element stay the same. Removing an element, reordering elements, editing an existing element, or changing the declared element type stays breaking, and a non-literal initializer such as a path or a vec![value; count] repeat stays conservative because its elements cannot be enumerated.
CLI command-surface findings
Packages that register a CLI under [package.<id>].cli get their command surface classified automatically. monochange diffs the committed baseline in .monochange/cli-snapshots/<name>.json against a fresh capture from the configured snapshot command and appends findings with surface: "cli". Removed commands, options, or positionals and value narrowing are breaking and propose major; additions are additive and propose minor; description-only changes are compatible. See package CLI registration.
Skip the comparison with --skip-cli-snapshots or MONOCHANGE_SKIP_CLI_SNAPSHOTS=1.
TypeScript declaration compatibility
Use semantic detection when an npm package publishes TypeScript types:
monochange change classify \
--detection-level semantic \
--format json \
--dependency-propagation public
The npm adapter resolves the package’s explicit exports, types, or typings entrypoints, emits declarations for the before and after package snapshots, and asks the workspace’s TypeScript compiler to compare the resulting consumer contracts. It keeps import and require conditions separate. Declaration-only packages do not need a tsconfig.json.
The analyzer classifies evidence as follows:
| Evidence | Impact | Proposed bump |
|---|---|---|
| Removed entrypoint, export, or non-assignable consumer contract | breaking | major |
| Added entrypoint/export, overload, optional member, or input capability | additive | minor |
| Changed source with an equivalent or consumer-compatible declaration API | compatible | none |
| Unresolved config, wildcard export, generic/nominal identity, or failure | unmodeled | patch |
Complete TypeScript evidence requires node and a locally resolvable typescript package. Install TypeScript and the package’s dependencies in the workspace before classification. monochange reports the exact compiler version in finding.analyzer.version.
pnpm add --save-dev --workspace-root typescript
pnpm install --frozen-lockfile
Snapshots contain package files from each Git endpoint. monochange never substitutes a candidate package file into the baseline. An inherited config or dependency declaration that only exists in the current checkout is allowed so the compiler can proceed, but the finding records partial coverage and a fallback reason. Wildcard exports are also partial because an export pattern cannot be enumerated conclusively from package metadata alone.
Changed generic declarations and classes with private or protected identity are reported as inconclusive when TypeScript cannot compare the two snapshot identities safely. Runtime behavior, side effects, JavaScript-only packages, and unlisted dynamic entrypoints remain outside declaration compatibility. Review those changes even when the declaration result is none.
When the repository defines [package.*] entries, classification is limited to those configured packages. Package additional_paths and ignored_paths, plus [changesets.affected].ignored_paths, use the same path policy as changeset coverage. This keeps fixtures, tests, generated output, and other explicitly ignored paths from producing release recommendations.
Package discovery reads both comparison endpoints and joins packages by ecosystem and repository-relative manifest path. A package that exists only in the base remains in the report with its baseline package id, path policy, release owner, and tag format. Its before snapshot contains the package files, and its after snapshot is empty. This behavior also applies when the pull request removes the package’s [package.*] entry from monochange.toml.
Rust compatibility matrix
Install cargo-semver-checks, then opt into it for semantic classification:
cargo install cargo-semver-checks --locked
[ecosystems.cargo.semver_checks]
enabled = true
timeout_seconds = 300
[[ecosystems.cargo.semver_checks.matrix]]
name = "default"
feature_mode = "default"
[[ecosystems.cargo.semver_checks.matrix]]
name = "all-features"
feature_mode = "all"
[[ecosystems.cargo.semver_checks.matrix]]
name = "wasm"
feature_mode = "none"
features = ["wasm"]
target = "wasm32-unknown-unknown"
Install every configured target before classification, for example rustup target add wasm32-unknown-unknown. A matrix may contain at most 16 uniquely named cells. timeout_seconds applies to each cell and must be between 1 and 1800. Feature mode is default, all, none, or heuristic; the features, baseline_features, and current_features lists add common or endpoint-specific features.
The integration runs only with --detection-level semantic. monochange materializes each Git endpoint as a complete, isolated repository tree so workspace inheritance and path dependencies remain available. Each cell receives an isolated CARGO_TARGET_DIR. The JSON finding exposes the exact cell inputs, checked, failed, or skipped status, minimum bump, lint ids, authoritative lint references, and cargo-semver-checks version under coverage.checks.
Results merge conservatively:
| Matrix result | Classification behavior |
|---|---|
| Any checked cell proves a break | Propose major, even if another cell fails |
| A checked cell reports a minor requirement and none break | Propose at least minor |
| Every configured cell is checked and requires no bump | Report compatible Rust API evidence; retain syntax-derived additions as minor evidence |
| A tool, target, build, timeout, or matrix cell is incomplete | Keep conservative syntax findings and require review |
cargo-semver-checks’ active lint set is authoritative for the rules it runs, but compatible additions are not exhaustively enabled by default. monochange therefore retains syntax-derived additions even after a clean matrix. Runtime behavior, undocumented supported configurations, and downstream build behavior remain outside this proof.
Security: cargo-semver-checks asks Cargo and rustdoc to build both endpoints. Build scripts and procedural macros can execute repository code. Enable this only for code you are prepared to execute. In CI, use a normal
pull_requestjob with least-privilege credentials; do not run semantic classification on untrusted changes throughpull_request_targetor with publishing secrets.
Output and validation
Markdown output is intended for terminal output, pull request comments, and job summaries:
monochange change classify --format markdown --dependency-propagation public
JSON is the stable agent and automation interface. The top-level schema_version changes when the JSON contract changes:
- 0.3 adds
decision.pull_request_changesand scopesdecision.proposed_changeset_bump,decision.enforceable_minimum, anddecision.review_requiredto packages the pull request actually touches. - 0.2 adds
decision.release_impactand capsdecision.proposed_changeset_bump,decision.enforceable_minimum, anddecision.release_floorwith the release comparison. - 0.1 is the first contract published by the
monochange_classificationcrate: snake_case keys,unmodeledinstead ofunknown, and the top-levelskipped,summary, andmatched_skip_labelsfields.
monochange change classify --format json --dependency-propagation public
monochange changeset validate --api fails only when a pending changeset is lower than enforceable_minimum. --strict compares pending changesets with proposed_changeset_bump, including partial and medium-confidence evidence.
monochange changeset validate --api --format markdown
monochange changeset validate --api --strict --format markdown
The monochange_classify_changes MCP tool returns the same report under its report field. It accepts base, head, release, packages, detection_level, include_unchanged, and dependency_propagation inputs. Agents should use dependency_propagation: "public" for the same package coverage as the canonical CLI workflow.
CI requirements
Release comparison needs the relevant tags and history. A shallow checkout can make the latest release unavailable or produce an incomplete merge base. CI jobs that publish classification reports fetch the default branch and tags before running the command.
Creating or updating the pull request comment needs the pull-requests: write scope. With only issues: write the API returns 403 Resource not accessible by integration, and the action warns instead of publishing the comment; fork pull requests receive a read-only token, which produces the same warning. A classification job can always write the Markdown report to the job summary and expose it as an output even when the provider refuses the comment.
The change-classification GitHub Action runs the canonical JSON command, writes a job summary, and creates or updates one marker comment:
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Install Rust semantic analyzer
if: ${{ hashFiles('**/Cargo.toml') != '' }}
run: cargo install cargo-semver-checks --locked
- id: classify
uses: monochange/actions/change-classification@v0
with:
detection-level: semantic
dependency-propagation: public
Checking out the pull request head SHA keeps GitHub’s synthetic test-merge commit out of the source candidate. The action exposes json, markdown, recommendation, review-required, and summary outputs. Use recommendation for routing, but inspect the package decisions in json before writing changesets whenever review-required is true. The action accepts every report with classification schema_version 0.1 or newer. The evidence fields the action reads (packages, decisions, findings) are stable across those schema versions.
For complete TypeScript evidence, install the repository dependencies before this step. For configured Rust target cells, install those targets before the action. Keep the workflow on pull_request; the analyzer may execute changed build scripts and procedural macros. Comment creation is best-effort. Fork pull requests with read-only tokens still receive the action outputs and job summary.
The changeset-policy action can turn the same classification into an enforced gate. Pass from instead of changed-paths so monochange step affected-packages --verify --from <ref> derives changed packages from git history and compares each attached changeset bump with the classified change type:
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: monochange/actions/changeset-policy@v0
with:
from: origin/main
comment-on-failure: true
A changeset that understates the classified change type fails the policy and names the package with the requested and recommended bumps. A higher bump only warns, and changesets with bump: none or an explicit target version skip the alignment check. from requires a full-history checkout and takes priority over changed-paths.
Package CLI registration
A package can ship a CLI binary in addition to its library surface. Register that CLI under [package.<id>].cli so monochange knows the binary name, can capture a normalized snapshot of its command surface, and can classify command-surface breaks during monochange change classify instead of reporting those changes as unclassified package changes.
Register a CLI
[package.monochange]
path = "crates/monochange"
cli = { name = "monochange", snapshot = "monochange snapshot --view index" }
Both fields are required:
| Field | Meaning |
|---|---|
name | The binary name users invoke. It also names the committed baseline file and must be unique across the workspace. |
snapshot | A command that prints a normalized command-surface snapshot JSON document on stdout. |
snapshot accepts either a bare command string or a detailed definition with the same fields as [ecosystems.*].lockfile_commands entries:
[package.some_cli]
cli = { name = "somecli", snapshot = { command = "node scripts/emit-cli-snapshot.mjs", cwd = "packages/somecli", shell = false } }
cwd is workspace-relative when set; without it the command runs from the workspace root. shell = true runs the command through sh -c. CLI registration is additive to package_type: the package keeps its ecosystem surface and public-API analysis and gains a command-surface identity on top. One CLI per package; register the package that owns the binary source, not platform wrappers that repackage it.
Snapshot contract
The snapshot command must print one CommandSnapshot JSON document (see the monochange_snapshot crate). For CLIs built with clap in this repository, monochange snapshot --view index already emits it. For other ecosystems such as TypeScript, Python, Go, and Dart, see CLI snapshot emitters, which documents the field-level contract and verified per-language examples. monochange validates schema_version against its supported snapshot schema version and rejects stale or unparsable documents.
Baselines
Captured snapshots are committed release state at .monochange/cli-snapshots/<name>.json. Refresh them as part of the release workflow so the baseline always describes the latest release:
[cli.release]
steps = [
{ type = "Command", name = "capture cli snapshot", when = "{{ number_of_changesets > 0 }}", command = "monochange snapshot --package <id> --save" },
]
The diff compares command surface only and ignores tool.version, so baselines do not need to be rebuilt at the exact released commit.
Useful commands:
monochange snapshot: print monochange’s own surface (unchanged behavior).monochange snapshot --package <id>: run that package’s configured snapshot command and print the document.monochange snapshot --package <id> --save: write or update the committed baseline.monochange snapshot --list: list registered CLIs and baseline status.
Classification integration
For every classified package with a registered CLI whose files changed, monochange change classify captures a fresh snapshot, diffs it against the committed baseline, and appends first-class findings:
- analyzer id
monochange/cli-surface, rule idsmonochange/cli-surface/<change-kind>,surface: "cli"; - removed commands, options, or positionals and value narrowing propose
major(breaking); additions and widening proposeminor(additive); description-only changes are compatible patches; - per-command
max_bumpcaps from the snapshot still apply; - findings are high confidence with complete coverage, so they raise
enforceableMinimumand clear the “unknown impact” review requirement that unclassified changes otherwise trigger.
Each classified package may also carry an additive cli block in the report: { name, status, recommendation?, findingCount?, baseline? } with status diffed, missing_baseline, stale_baseline, failed, or skipped. Capture problems degrade to warnings so the pull request comment still posts.
Skip the comparison with --skip-cli-snapshots or MONOCHANGE_SKIP_CLI_SNAPSHOTS=1 (useful when the snapshot command needs a build that is unavailable in a given environment).
CLI snapshot emitters
A CLI snapshot is a normalized JSON document describing a tool’s command surface: command paths, options, positionals, and parser behavior. monochange diffs that document against a committed baseline to classify command-surface breaks during monochange change classify.
Package CLI registration covers registering a binary. This page covers the other half: making a CLI actually print a snapshot document. The published schema at https://monochange.github.io/monochange/schemas/command-snapshot.schema.json is the contract, and the schema reference lists every hosted URL.
The contract in one example
Every snapshot is one JSON document on stdout:
{
"schema_version": "0.1",
"kind": "cli-surface",
"tool": { "name": "demo", "version": "1.0.0" },
"provenance": { "extractor": "commander", "confidence": "high" },
"standard_entrypoints": {
"help": { "flags": ["--help", "-h"] },
"version": { "flags": ["--version", "-V"] },
"snapshot": {}
},
"commands": [
{
"path": ["check"],
"hidden": false,
"max_bump": "major",
"summary": "Check things",
"parser": {
"flags_are_posix_noncompliant": false,
"options_must_precede_arguments": false,
"option_arg_separators": [" ", "="]
},
"options": [
{
"names": ["--format"],
"canonical_name": "--format",
"hidden": false,
"global": false,
"summary": "Output format",
"value": {
"kind": "string",
"required": false,
"repeatable": false,
"variadic": false
}
}
]
}
]
}
Five fields are required: schema_version, kind, tool, provenance, and standard_entrypoints. Everything else may be omitted when empty, and the validator rejects unknown fields so typos fail loudly instead of being ignored.
Two values are fixed by the contract rather than chosen by you:
| Field | Value | Why |
|---|---|---|
kind | "cli-surface" | Discriminator for snapshot documents. |
schema_version | "0.1" | Must match the schema version monochange supports, or the capture fails. |
Read the current schema_version from the schema asset you pin against rather than hardcoding it forever; see Version policy.
What each field means
tool identifies the binary. version may be null, but supplying it makes snapshots easier to audit.
provenance records how the snapshot was produced. extractor is a free-form label such as clap, commander, argparse, or help-text. confidence is one of high, medium, or low and tells reviewers how much to trust the extraction:
highfor structured metadata read from a command framework’s own definitions.mediumfor partial introspection, for example commands discovered but option types guessed.lowfor snapshots inferred from--helpprose, where types, defaults, and parser behavior are not reliably recoverable.
Confidence is advisory metadata today; it does not currently change classification severity. Set it honestly so the record shows how the snapshot was derived.
standard_entrypoints normalizes help, version, and snapshot discovery across spelling variants, so --help and a help subcommand compare as the same capability. Each entrypoint takes commands (a list of command paths, each itself a list of segments) and flags. Omit what the tool does not support. It is recorded for documentation and agent discovery; the current diff does not compare it.
commands is a flat list of command nodes. Nested subcommands use the full path: a get command under config is one node with "path": ["config", "get"], optionally also mirrored as a nested child. The diff keys on the path, so nested children are flattened and a node in commands and the same path nested under its parent are the same command. Always emit the complete path.
Note that global_options is recorded but not diffed, so put options that participate in a command’s contract on that command’s options list.
max_bump caps the release impact of changes at or below a command path. It defaults to major, which is the safe assumption for a public command. Lower it only for commands you deliberately treat as unstable.
options and positionals describe accepted arguments:
| Field | Meaning |
|---|---|
names | Every accepted spelling, for example ["-f", "--format"]. |
canonical_name | The primary spelling used in findings. |
hidden | Whether the argument is hidden from help output. |
global | Whether the option is accepted by subcommands too. |
value.kind | One of flag, string, enum, or counter. |
value.required | Whether the argument must be supplied. |
value.repeatable | Whether the option may be passed more than once. |
value.variadic | Whether a positional accepts multiple values. |
value.enum_values | Accepted values when kind is enum. |
value.default | The default value as a string, when one exists. |
parser captures invocation rules that affect compatibility. Use [" ", "="] for option_arg_separators when both --flag value and --flag=value work. Set options_must_precede_arguments when the parser stops recognizing options after the first positional, and flags_are_posix_noncompliant for parsers that accept combined short flags differently from POSIX.
Which fields actually drive classification
Getting these right matters more than completeness, because they are what produce findings:
- Command paths. A missing path reads as a removed command (
major). options[].names. Dropping a spelling reads as a removed option; adding one reads as additive.value.kindandvalue.enum_values. Narrowing (stringtoenum, or removing an enum value) proposesmajor; widening proposesminor.value.required. An optional argument becoming required is breaking.max_bump. Caps the severity proposed for that command.
Descriptions (summary, description) only ever produce compatible patches, so you can refine wording freely. Conversely, omitting a real option is not harmless: the baseline records it, so the next capture looks like a removal.
Rust: emit from clap
The monochange_snapshot crate ships a clap extractor, so no hand-written mapping is needed. Add the crate and print the snapshot in a subcommand:
#![allow(unused)]
fn main() {
use clap::Command;
use monochange_snapshot::snapshot_from_clap;
let command = build_cli();
let snapshot = snapshot_from_clap(&command);
println!("{}", snapshot.to_json()?);
}
to_json renders pretty-printed JSON with a trailing newline. For a fuller extractor with control over provenance:
#![allow(unused)]
fn main() {
use monochange_snapshot::ClapCommandSurfaceExtractor;
use monochange_snapshot::CommandSurfaceExtractor;
let extractor = ClapCommandSurfaceExtractor::new(&build_cli());
let snapshot = extractor.extract();
}
This produces a high-confidence snapshot labelled with the clap extractor. If your binary already uses monochange, monochange snapshot --view index prints an equivalent document and you do not need a subcommand of your own.
TypeScript and JavaScript
Node CLIs built on commander expose their definitions at runtime, so an emitter can read them directly. This works because commander keeps parsed option metadata on each command.
import { Command } from "commander";
function optionEntry(option) {
const names = option.flags
.split(/[ ,|]+/)
.filter((token) => token.startsWith("-"));
const takesValue = option.required || option.optional;
const canonical =
option.long ?? names.find((name) => name.startsWith("--")) ?? names[0];
return {
names,
canonical_name: canonical,
hidden: Boolean(option.hidden),
global: false,
...(option.description ? { summary: option.description } : {}),
value: {
kind: takesValue ? "string" : "flag",
required: false,
repeatable: Boolean(option.variadic),
variadic: Boolean(option.variadic),
...(option.defaultValue === undefined
? {}
: { default: String(option.defaultValue) }),
},
};
}
function commandNode(command, parentPath) {
const path = [...parentPath, command.name()];
return {
path,
hidden: false,
max_bump: "major",
...(command.summary() ? { summary: command.summary() } : {}),
parser: {
flags_are_posix_noncompliant: false,
options_must_precede_arguments: false,
option_arg_separators: [" ", "="],
},
options: command.options.map(optionEntry),
positionals: [],
commands: command.commands.map((child) => commandNode(child, path)),
};
}
export function emitSnapshot(
program,
{ extractor = "commander", confidence = "high" } = {},
) {
return {
schema_version: "0.1",
kind: "cli-surface",
tool: { name: program.name(), version: program.version() ?? null },
provenance: { extractor, confidence },
standard_entrypoints: {
help: { flags: ["--help", "-h"] },
version: { flags: ["--version", "-V"] },
snapshot: {},
},
commands: program.commands.map((child) => commandNode(child, [])),
};
}
Wire it into the program and write it to stdout:
import { Command } from "commander";
import { emitSnapshot } from "./emit-snapshot.mjs";
const program = new Command("demo").version("1.0.0");
program
.command("check")
.description("Check things")
.option("-f, --format <value>", "Output format", "text");
if (process.argv[2] === "snapshot") {
process.stdout.write(`${JSON.stringify(emitSnapshot(program), null, 2)}\n`);
process.exit(0);
}
program.parse();
Register it in monochange.toml with the CLI snapshot subcommand:
[package.demo]
path = "packages/demo"
cli = { name = "demo", snapshot = { command = "node dist/index.js snapshot", cwd = "packages/demo" } }
Point snapshot at the built entry point, since emitters that read a live program need the CLI to load. Add shell = true only when the command needs shell features such as pipes or environment expansion.
Other Node frameworks (yargs, oclif, clipanion) expose comparable command metadata. Map their structures into the same shape, or fall back to the help-text approach below. yargs in particular keeps .getOptions() per command, and oclif exposes a manifest via oclif manifest.
Python
For argparse, read each parser’s _actions. Private attributes are acceptable here because the emitter is a development tool pinned to a known CLI, but assert on the fields you depend on so a Python upgrade fails loudly rather than silently emitting a wrong snapshot.
"""Emit a normalized command surface snapshot for an argparse CLI."""
import argparse
def option_entry(action):
names = list(action.option_strings)
if not names:
return None
takes_value = action.nargs != 0
canonical = next((n for n in names if n.startswith("--")), names[0])
value = {
"kind": "string" if takes_value else "flag",
"required": bool(action.required),
"repeatable": action.nargs in ("*", "+"),
"variadic": action.nargs in ("*", "+"),
}
if action.default is not None and takes_value:
value["default"] = str(action.default)
if action.choices is not None:
value["kind"] = "enum"
value["enum_values"] = [str(c) for c in action.choices]
entry = {
"names": names,
"canonical_name": canonical,
"hidden": False,
"global": False,
"value": value,
}
if action.help:
entry["summary"] = action.help
return entry
def command_node(parser, parent_path):
path = parent_path + [parser.prog.split()[-1]]
options = [e for e in (option_entry(a) for a in parser._actions) if e]
options = [o for o in options if o["canonical_name"] not in ("--help", "-h")]
positionals = [
{
"name": a.dest,
"hidden": False,
"value": {
"kind": "string",
"required": a.required,
"repeatable": a.nargs in ("*", "+"),
"variadic": a.nargs in ("*", "+"),
},
}
for a in parser._actions
if not a.option_strings and a.dest != "help"
]
for positional in positionals:
action = next(a for a in parser._actions if a.dest == positional["name"])
if action.help:
positional["summary"] = action.help
return {
"path": path,
"hidden": False,
"max_bump": "major",
"parser": {
"flags_are_posix_noncompliant": False,
"options_must_precede_arguments": False,
"option_arg_separators": [" ", "="],
},
"options": options,
"positionals": positionals,
"commands": [],
}
def emit_snapshot(name, version, commands, extractor="argparse", confidence="high"):
return {
"schema_version": "0.1",
"kind": "cli-surface",
"tool": {"name": name, "version": version},
"provenance": {"extractor": extractor, "confidence": confidence},
"standard_entrypoints": {
"help": {"flags": ["--help", "-h"]},
"version": {"flags": ["--version"]},
"snapshot": {},
},
"commands": commands,
}
Walk the subparser tree to build complete paths, then print the document:
import argparse
import json
from emit_snapshot import command_node, emit_snapshot
parser = argparse.ArgumentParser(prog="demo", description="Demo tool")
sub = parser.add_subparsers(dest="command")
check = sub.add_parser("check", help="Check things")
check.add_argument("-f", "--format", default="text", help="Output format")
commands = []
for sub_parser in parser._subparsers._group_actions[0].choices.values():
node = command_node(sub_parser, [])
if sub_parser._subparsers:
children = sub_parser._subparsers._group_actions[0].choices
node["commands"] = [command_node(child, node["path"]) for child in children.values()]
commands.append(node)
print(json.dumps(emit_snapshot("demo", "1.0.0", commands), indent=2))
Both Click and Typer expose structured command trees: Click commands carry .params with .opts, .is_flag, and .multiple, and Typer builds on Click. Fire and docopt are weaker fits because they derive interfaces from signatures or docstrings, so either map their introspection output or mark the result medium/low confidence.
Go
Go CLIs commonly build commands imperatively, so the most reliable emitter mirrors the command definitions you already declare. With cobra, read cmd.Commands(), cmd.Flags(), and cmd.NonInheritedFlags(); with the standard library flag package or pflag, iterate the flag set.
Define the snapshot as plain structs so the JSON shape is checked at compile time:
package main
import (
"encoding/json"
"os"
)
type Value struct {
Kind string `json:"kind"`
Required bool `json:"required"`
Repeatable bool `json:"repeatable"`
Variadic bool `json:"variadic"`
EnumValues []string `json:"enum_values,omitempty"`
Default *string `json:"default,omitempty"`
}
type Option struct {
Names []string `json:"names"`
CanonicalName string `json:"canonical_name"`
Hidden bool `json:"hidden"`
Global bool `json:"global"`
Summary string `json:"summary,omitempty"`
Value Value `json:"value"`
}
type Positional struct {
Name string `json:"name"`
Hidden bool `json:"hidden"`
Summary string `json:"summary,omitempty"`
Value Value `json:"value"`
}
type Parser struct {
FlagsArePosixNoncompliant bool `json:"flags_are_posix_noncompliant"`
OptionsMustPrecedeArguments bool `json:"options_must_precede_arguments"`
OptionArgSeparators []string `json:"option_arg_separators"`
}
type Command struct {
Path []string `json:"path"`
Hidden bool `json:"hidden"`
MaxBump string `json:"max_bump"`
Summary string `json:"summary,omitempty"`
Parser Parser `json:"parser"`
Options []Option `json:"options,omitempty"`
Positionals []Positional `json:"positionals,omitempty"`
Commands []Command `json:"commands,omitempty"`
}
type Entrypoint struct {
Commands [][]string `json:"commands,omitempty"`
Flags []string `json:"flags,omitempty"`
}
type Tool struct {
Name string `json:"name"`
Version *string `json:"version"`
}
type Provenance struct {
Extractor string `json:"extractor"`
Confidence string `json:"confidence"`
}
type StandardEntrypoints struct {
Help Entrypoint `json:"help"`
Version Entrypoint `json:"version"`
Snapshot Entrypoint `json:"snapshot"`
}
type Snapshot struct {
SchemaVersion string `json:"schema_version"`
Kind string `json:"kind"`
Tool Tool `json:"tool"`
Provenance Provenance `json:"provenance"`
StandardEntrypoints StandardEntrypoints `json:"standard_entrypoints"`
Commands []Command `json:"commands,omitempty"`
}
Build the tree and encode it, setting version := "1.0.0" and &version:
func emit(name string, version *string, commands []Command) error {
snapshot := Snapshot{
SchemaVersion: "0.1",
Kind: "cli-surface",
Tool: Tool{Name: name, Version: version},
Provenance: Provenance{Extractor: "cobra", Confidence: "high"},
StandardEntrypoints: StandardEntrypoints{
Help: Entrypoint{Flags: []string{"--help", "-h"}},
Version: Entrypoint{Flags: []string{"--version"}},
Snapshot: Entrypoint{},
},
Commands: commands,
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(snapshot)
}
Note that version must be a pointer (or omitted) so the field serializes as either a string or null. A plain string always emits "", which the schema accepts but misrepresents an unknown version.
Go has no reflection-based option introspection comparable to clap’s, because flags live in a user-defined struct. Reading the flag.FlagSet or cobra command values you construct is the reliable path.
Dart and Flutter
The args package exposes an ArgParser whose subcommands and options are enumerable at runtime. Map ArgParser.commands recursively, using the same path convention. Build the document with dart:convert:
import 'dart:convert';
Map<String, Object?> emitSnapshot({
required String name,
String? version,
required List<Map<String, Object?>> commands,
}) {
return {
'schema_version': '0.1',
'kind': 'cli-surface',
'tool': {'name': name, 'version': version},
'provenance': {'extractor': 'dart/args', 'confidence': 'high'},
'standard_entrypoints': {
'help': {'flags': ['--help', '-h']},
'version': {'flags': ['--version']},
'snapshot': <String>[],
},
'commands': commands,
};
}
void main() {
final snapshot = emitSnapshot(name: 'demo', version: '1.0.0', commands: const []);
print(const JsonEncoder.withIndent(' ').convert(snapshot));
}
Read each ArgParser’s options for flag metadata (isFlag, abbr, defaultsTo) and commands for nested parsers. For Flutter tools, the host CLI is usually a Dart entry point, so the same approach applies.
Help-text inference
When no structured metadata exists, parse --help output. Treat this as a last resort: it recovers command paths and option names reasonably well but cannot reliably determine value kinds, defaults, or parser behavior.
mycli --help > help.txt
Mark these snapshots "confidence": "low" so the record shows how the document was derived. Be aware that provenance.confidence is currently recorded metadata only: monochange change classify reports command-surface findings at high confidence regardless of it, so a help-text snapshot carries the same enforcement weight as a clap-extracted one. That is the main reason to prefer structured extraction, because a wrong guess about a value kind becomes a real major finding rather than a hedged one.
Recommended pattern: a hidden or explicit snapshot subcommand
Give the CLI its own snapshot entry point rather than a separate script, because the subcommand:
- keeps the emitter next to the definitions it reads, so it cannot drift;
- runs in the same environment as the CLI, including built artifacts;
- avoids duplicating command definitions in a second language.
The name snapshot is a good choice because it reads clearly and monochange already reserves it for this purpose. Reserving it is only strictly required inside monochange.toml: [cli.*] workflow names cannot shadow monochange’s built-in commands. A hidden snapshot subcommand in your own CLI is fine; discoverability is not required.
if (process.argv[2] === "snapshot") {
process.stdout.write(`${JSON.stringify(emitSnapshot(program), null, 2)}\n`);
process.exit(0);
}
Validate before you register
Validate the emitted document against the published schema before wiring it into monochange.toml, so schema drift is a local error rather than a confusing classification warning. Any draft 2020-12 validator works:
# Node
node -e "
const Ajv = require('ajv/dist/2020');
const schema = require('./command-snapshot.schema.json');
const doc = require('./snapshot.json');
const validate = new Ajv({ strict: false }).compile(schema);
if (!validate(doc)) { console.error(validate.errors); process.exit(1); }
console.log('valid');
"
# Python
python3 -c "
import json, jsonschema
schema = json.load(open('command-snapshot.schema.json'))
doc = json.load(open('snapshot.json'))
jsonschema.validate(doc, schema)
print('valid')
"
Because the schema sets additionalProperties: false, an unexpected field is a validation failure. That is intentional: it catches misspelled field names that would otherwise silently drop data from the classification.
Should you share a helper package?
Reusing one emitter across repositories is tempting, but the tradeoff is different from a typical utility library, because the emitter has to run inside the CLI process to read the live command tree.
That makes any adapter a runtime dependency of the CLI, shipped in the published artifact even though it only matters at release time. Weigh that against what an adapter saves: the mapping code above is roughly 60 lines, and each command framework needs its own version because commander, yargs, and oclif expose different introspection APIs. An adapter package therefore costs a runtime dependency plus a per-framework maintenance surface, and repays it only if you maintain several Node CLIs that share one framework.
A narrower helper avoids the runtime cost: ship a schema-driven validator rather than an emitter. Validation runs in CI on an already-emitted file, so it can be a dev dependency, covers every framework at once, and catches the failure that actually matters, which is a snapshot that does not match the contract. Combined with the per-language examples on this page, that covers most of the value without imposing on the CLI’s runtime dependencies.
If a shared emitter is still worth it for your organization, keep it a thin wrapper that emits the document shape and let callers pass in already-extracted command data. That keeps framework-specific introspection out of the shared package and lets it stay a dev dependency when the CLI structure is generated at build time rather than read at runtime.
Version policy
Snapshot documents carry their own schema_version, derived from the monochange_snapshot crate version rather than the configuration schema version. The two are independent:
monochange.tomluses one schema version (currently0.6).- Command snapshots use another (currently
0.1).
A snapshot captured with a version monochange does not support is rejected and reported as a capture warning, not a silent skip. Pin your emitter to the version that matches the monochange release you run in CI, and expect a version bump to require regenerating baselines.
Troubleshooting
cli snapshot for <name> was not compared: no committed baseline means no baseline file exists yet. Capture one with monochange snapshot --package <id> --save.
stale_baseline means the committed baseline uses a different schema_version than this monochange build supports. Regenerate the baseline.
failed means the snapshot command returned a non-zero exit, printed unparsable JSON, or emitted a document that does not match the schema. Run the configured command by hand with monochange snapshot --package <id>, which prints the output, and validate it against the schema.
A capture that needs a build unavailable in a given environment can be skipped with --skip-cli-snapshots or MONOCHANGE_SKIP_CLI_SNAPSHOTS=1. Prefer fixing the environment: skipped comparisons mean command-surface breaks are reported as unclassified changes again.
Related pages
- Package CLI registration: registering a binary and committing baselines.
- Schema reference: hosted schema URLs and versioning.
- Change classification: how findings reach the pull request.
JSON Schema reference
classification.schema.json
- Current: https://monochange.github.io/monochange/schemas/classification.schema.json
- v0.1: https://monochange.github.io/monochange/schemas/classification.v0.1.schema.json
- v0.2: https://monochange.github.io/monochange/schemas/classification.v0.2.schema.json
- v0.3: https://monochange.github.io/monochange/schemas/classification.v0.3.schema.json
command-snapshot.schema.json
- Current: https://monochange.github.io/monochange/schemas/command-snapshot.schema.json
- v0.1: https://monochange.github.io/monochange/schemas/command-snapshot.v0.1.schema.json
monochange.schema.json
- Current: https://monochange.github.io/monochange/schemas/monochange.schema.json
- v0.0: https://monochange.github.io/monochange/schemas/monochange.v0.0.schema.json
- v0.1: https://monochange.github.io/monochange/schemas/monochange.v0.1.schema.json
- v0.2: https://monochange.github.io/monochange/schemas/monochange.v0.2.schema.json
- v0.3: https://monochange.github.io/monochange/schemas/monochange.v0.3.schema.json
- v0.4: https://monochange.github.io/monochange/schemas/monochange.v0.4.schema.json
- v0.5: https://monochange.github.io/monochange/schemas/monochange.v0.5.schema.json
- v0.6: https://monochange.github.io/monochange/schemas/monochange.v0.6.schema.json
- v0.7: https://monochange.github.io/monochange/schemas/monochange.v0.7.schema.json
- v0.8: https://monochange.github.io/monochange/schemas/monochange.v0.8.schema.json
- v0.9: https://monochange.github.io/monochange/schemas/monochange.v0.9.schema.json
release-record.schema.json
- Current: https://monochange.github.io/monochange/schemas/release-record.schema.json
- v0.0: https://monochange.github.io/monochange/schemas/release-record.v0.0.schema.json
- v0.1: https://monochange.github.io/monochange/schemas/release-record.v0.1.schema.json
- v0.2: https://monochange.github.io/monochange/schemas/release-record.v0.2.schema.json
- v0.3: https://monochange.github.io/monochange/schemas/release-record.v0.3.schema.json
- v0.4: https://monochange.github.io/monochange/schemas/release-record.v0.4.schema.json
- v0.5: https://monochange.github.io/monochange/schemas/release-record.v0.5.schema.json
- v0.6: https://monochange.github.io/monochange/schemas/release-record.v0.6.schema.json
- v0.7: https://monochange.github.io/monochange/schemas/release-record.v0.7.schema.json
- v0.8: https://monochange.github.io/monochange/schemas/release-record.v0.8.schema.json
- v0.9: https://monochange.github.io/monochange/schemas/release-record.v0.9.schema.json
Snapshot documents set additionalProperties: false, so unknown fields fail validation. That is deliberate: it turns a misspelled field in a foreign emitter into a local error instead of silently dropping data.
Schema-aware TOML editors such as Taplo can opt in to the configuration schema with a directive at the top of monochange.toml:
#:schema https://monochange.github.io/monochange/schemas/monochange.schema.json
Version namespaces
Contract versions are independent per artifact family:
- Snapshot documents carry the
monochange_snapshotcontract version, derived from that crate’s package version. monochange.tomland release records carry themonochange_schemacontract version.
A version bump only affects the artifact family it belongs to, so a new snapshot contract does not invalidate configuration or release-record assets. Versioned URLs are generated during release preparation; the moving aliases always describe the latest release.
Only versions on the breaking axis are listed above. While a family’s major version is 0, every minor bump may break consumers, so each 0.N is published. From 1.0 onward only a major bump may break consumers, so only N.0 is published. Intermediate releases keep their moving alias and remain reachable by their exact URL.
Related pages
- Command snapshots: CLI snapshot emitters, Package CLI registration.
- Configuration: Configuration.
- Release records: Repairable releases.
Regenerating assets
Committed schema assets are generated from the Rust wire types, so never hand-edit them.
schema:update # regenerate current aliases and fixtures
schema:check # verify committed assets match generated output
schema:release:update # regenerate release assets, including versioned copies
schema:release:check # verify release assets
schema:check runs as part of lint:all and in CI, so committed assets cannot drift from the types they describe. The version list above is generated from the committed versioned assets by scripts/schema-versions.ts, so it stays current without hand edits. To validate a document against an asset locally, use any draft 2020-12 validator; the CLI snapshot emitters page has copyable examples.
Raw GitHub URLs
The same files are available from GitHub raw content, which is useful when you want to diff a pinned commit:
- https://raw.githubusercontent.com/monochange/monochange/main/docs/src/schemas/command-snapshot.schema.json
- https://raw.githubusercontent.com/monochange/monochange/main/docs/src/schemas/monochange.schema.json
- https://raw.githubusercontent.com/monochange/monochange/main/docs/src/schemas/release-record.schema.json
Manifest linting with monochange check
monochange can lint monorepo package manifests through monochange check, using rules configured under [lints] in monochange.toml.
Use this guide when the task is to configure or explain monochange’s lint rules.
These are the rules that run through monochange check and are configured in monochange.toml under the top-level [lints] section. They are separate from Rust compiler or Clippy lints used to develop monochange itself.
This page is the human-readable companion to the live lint catalog. For machine-readable output or to verify the exact catalog in the installed binary, run:
monochange lint list --format json
monochange lint explain <rule-or-preset-id>
What monochange check does
monochange check runs two phases:
- normal workspace validation, similar to
monochange step validate - changeset and manifest lint rules for configured package ecosystems
Common commands:
monochange check
monochange check --fix
monochange check --format json
monochange lint list
monochange lint explain cargo/recommended
Use --fix when you want monochange to apply auto-fixes where a rule supports them. Rules that are not autofixable still report diagnostics and suggested remediation.
Autofixes never destroy surrounding content: rules that rewrite a whole manifest do so by serializing a mutated copy of the parsed document, and every whole-file rewrite is validated against the target ecosystem’s manifest parser before it is written. If a rewrite would produce an unparseable manifest, the fix is skipped and the original file is kept.
Where lint rules live
Configure presets, global rules, and scoped overrides in the top-level [lints] section of monochange.toml:
[lints]
use = [
"changesets/recommended",
"cargo/recommended",
"npm/recommended",
"dart/recommended",
]
exclude = ["fixtures/**"]
[lints.rules]
"cargo/internal-dependency-workspace" = "error"
"npm/workspace-protocol" = "error"
"dart/sdk-constraint-modern" = { level = "warning", minimum = "3.6.0", require_upper_bound = false }
"dart/no-unexpected-dependency-overrides" = { level = "warning", allow_for_private = true, allow_packages = ["app_shell"] }
[[lints.scopes]]
name = "published cargo packages"
match = { ecosystems = ["cargo"], managed = true, publishable = true }
rules = { "cargo/required-package-fields" = "error" }
Rule configuration supports two forms:
- simple severity:
"rule-id" = "error","warning", or"off" - detailed config:
{ level = "error", ...rule_specific_options }
Preset rules provide the baseline. Explicit entries in [lints.rules] override that baseline. Scoped rules let a subset of packages be stricter or looser than the workspace default.
Presets
| Preset | What it is for | Rules enabled |
|---|---|---|
changesets/recommended | Baseline changeset hygiene. | changesets/summary = error with an H1 heading, changesets/summary-description = error, changesets/prefer-inline = error |
cargo/recommended | Balanced Cargo manifest policy for most workspaces. | cargo/internal-dependency-workspace = error, cargo/publishable-dependencies = error, cargo/required-package-fields = error, cargo/dependency-field-order = warning, cargo/sorted-dependencies = warning, cargo/unlisted-package-private = warning |
cargo/strict | Cargo policy with style rules promoted to errors. | Same as cargo/recommended, but cargo/dependency-field-order and cargo/sorted-dependencies are error. |
npm/recommended | Balanced npm-family manifest policy. | npm/workspace-protocol = error, npm/no-duplicate-dependencies = error, npm/required-package-fields = error, npm/root-no-prod-deps = error, npm/sorted-dependencies = warning, npm/unlisted-package-private = warning |
npm/strict | npm-family policy with dependency sorting promoted to an error. | Same as npm/recommended, but npm/sorted-dependencies is error. |
dart/recommended | Baseline Dart metadata, publishability, and SDK hygiene. | dart/sdk-constraint-present = error, dart/required-package-fields = error, dart/no-git-dependencies-in-published-packages = error, dart/unlisted-package-private = error, dart/dependency-sorted = warning |
dart/strict | Dart policy with workspace and Flutter policy rules enforced. | Everything in dart/recommended, plus dart/sdk-constraint-modern, dart/no-unexpected-dependency-overrides, dart/internal-path-dependency-policy, dart/workspace-internal-version-consistency, dart/flutter-package-metadata-consistent, and dart/assets-sorted as errors; dart/dependency-sorted is promoted to error. |
Available rules at a glance
| Rule id | Ecosystem | Category | Autofix | Summary |
|---|---|---|---|---|
changesets/summary | changesets | correctness | no | Requires a changeset body to start with a summary heading. |
changesets/summary-description | changesets | style | no | Requires the first description sentence to add information instead of repeating the summary. |
changesets/no_section_headings | changesets | correctness | no | Rejects change-type headings inside changeset bodies. |
changesets/prefer-inline | changesets | style | yes | Rewrites object change entries that repeat what the inline form already implies. |
changesets/bump/none | changesets | correctness | no | Applies scoped body policy to none bump entries. |
changesets/bump/patch | changesets | correctness | no | Applies scoped body policy to patch bump entries. |
changesets/bump/minor | changesets | correctness | no | Applies scoped body policy to minor bump entries. |
changesets/bump/major | changesets | correctness | no | Applies scoped body policy to major bump entries. |
changesets/types/<type> | changesets | correctness | no | Applies scoped body policy to a configured changelog type. |
changesets/duplicate | changesets | correctness | no | Recognized compatibility switch for duplicate target validation; workspace validation rejects duplicate package entries regardless. |
cargo/dependency-field-order | Cargo | style | yes | Orders keys inside inline dependency tables. |
cargo/internal-dependency-workspace | Cargo | correctness | yes | Requires internal crate dependencies to use workspace = true. |
cargo/publishable-dependencies | Cargo | correctness | no | Prevents publishable crates from depending on unpublished workspace crates. |
cargo/required-package-fields | Cargo | correctness | no | Requires selected [package] metadata fields. |
cargo/sorted-dependencies | Cargo | style | yes | Sorts dependency tables alphabetically. |
cargo/unlisted-package-private | Cargo | correctness | yes | Requires unmanaged crates to set publish = false. |
cargo/manifest-repository | Cargo | correctness | yes | Requires package.repository to point at the root repository or the package subdirectory URL. |
npm/workspace-protocol | npm-family | correctness | yes | Requires internal dependencies to use workspace: ranges. |
npm/sorted-dependencies | npm-family | style | yes | Sorts dependency sections alphabetically. |
npm/required-package-fields | npm-family | correctness | no | Requires selected package.json metadata fields. |
npm/root-no-prod-deps | npm-family | best practice | yes | Keeps production dependencies out of the workspace root package. |
npm/no-duplicate-dependencies | npm-family | correctness | yes | Prevents the same dependency from appearing in multiple dependency sections. |
npm/unlisted-package-private | npm-family | correctness | yes | Requires unmanaged packages to set private: true. |
npm/manifest-repository | npm-family | correctness | yes | Requires repository in package.json to point at the root repository or the package subdirectory URL. |
dart/sdk-constraint-present | Dart | correctness | no | Requires environment.sdk in pubspec.yaml. |
dart/sdk-constraint-modern | Dart | best practice | no | Enforces a modern SDK lower bound and, by default, an upper bound. |
dart/dependency-sorted | Dart | style | yes | Sorts dependency sections in pubspec.yaml. |
dart/required-package-fields | Dart | correctness | no | Requires selected pubspec.yaml metadata fields. |
dart/no-git-dependencies-in-published-packages | Dart | correctness | no | Blocks git: dependencies in publishable packages unless allowed. |
dart/unlisted-package-private | Dart | correctness | yes | Requires unmanaged packages to set publish_to: none. |
dart/no-unexpected-dependency-overrides | Dart | best practice | no | Allows dependency_overrides only in approved packages. |
dart/internal-path-dependency-policy | Dart | best practice | no | Enforces one policy for internal Dart dependency references. |
dart/workspace-internal-version-consistency | Dart | correctness | no | Requires internal hosted dependency ranges to match workspace package versions. |
dart/flutter-package-metadata-consistent | Dart / Flutter | correctness | no | Requires Flutter packages to declare the Flutter SDK dependency consistently. |
dart/assets-sorted | Dart / Flutter | style | yes | Sorts Flutter assets and fonts. |
dart/manifest-repository | Dart | correctness | yes | Requires repository in pubspec.yaml to point at the root repository or the package subdirectory URL. |
Changeset lint rules
Changeset lint rules use the same [lints.rules] table as manifest rules. They are evaluated while markdown changesets are loaded by validation and release workflows.
[lints]
use = ["changesets/recommended"]
[lints.rules]
"changesets/no_section_headings" = "error"
"changesets/summary" = { level = "error", required = true, heading_level = 1, min_length = 12, max_length = 80, forbid_trailing_period = true, forbid_conventional_commit_prefix = true, require_description = true }
"changesets/summary-description" = "error"
"changesets/bump/major" = { level = "error", required_sections = ["Impact", "Migration"], min_body_chars = 120, require_code_block = true }
"changesets/types/breaking" = { level = "error", forbidden_headings = ["Breaking", "Breaking changes"], required_sections = ["Impact", "Migration"], required_bump = "major" }
changesets/summary
Why: every changeset should be understandable from a compact, release-note-ready heading.
What it checks: the first heading in a changeset body. It can require a heading, constrain its level and length, ban trailing periods, ban conventional-commit prefixes, and require descriptive body text after the heading.
The changesets/recommended preset requires an H1 summary. The release-note renderer chooses the final heading depth, so authors do not need to anticipate whether an entry will be rendered compactly or expanded.
Useful options:
required: require the summary heading.heading_level: require a Markdown heading level from1to6.min_length/max_length: constrain summary text length.forbid_trailing_period: reject summaries ending in..forbid_conventional_commit_prefix: reject summaries such asfeat: add parser.require_description: require a non-empty paragraph after the heading.
changesets/summary-description
Why: repeating the headline as the first sentence makes a changeset look longer without telling the reader anything new.
What it checks: after normalizing case, punctuation, and whitespace, the first description sentence must differ from the summary. Use the description to explain impact, behavior, or required action.
Without the rule:
# Show publish results clearly
Show publish results clearly.
With the rule:
# Show publish results clearly
Publish now ends with package counts and one outcome row per package, so CI logs expose the result without requiring debug output.
changesets/no_section_headings
Why: change types already come from the changeset entries. Repeating them as body headings creates noisy generated changelogs.
With the rule: headings that duplicate configured changelog types, such as ## Breaking or ## Fix, are rejected.
changesets/prefer-inline
Why: change entries read best in the inline target: type form. Writing an object that only repeats what the inline form already implies adds noise for agents and humans authoring changesets.
What it checks: object (table) change entries whose fields are exactly equivalent to the inline form:
typealone (core: { type: "feat" }),typeplus abumpthat the type already implies (core: { type: "feat", bump: "minor" }, sincefeatimpliesminor), and- a bare
bumpwhose keyword is also a configured change type that implies the same bump (core: { bump: "minor" }, sinceminoris a change type with default bumpminor; the inline entry keeps the bump and gains the type).
Entries with a version, a caused_by, an unknown field, an unknown change type, or a bump that disagrees with the type default are left untouched, because the inline form cannot express them without changing meaning. A bare bump: none is also left alone: changeset validation rejects it outright.
Before:
---
"@monochange/cli":
bump: minor
type: feat
---
After (monochange check --fix):
---
"@monochange/cli": feat
---
The rule is on by default for every project and included in changesets/recommended.
changesets/bump/<severity>
Why: different bump severities can require different explanation standards. A major bump often needs impact and migration notes, while a patch bump may only need a concise description.
Supported severities: none, patch, minor, and major.
Useful options:
required_sections: headings that must appear in the body.forbidden_headings: headings that must not appear in the body.min_body_chars/max_body_chars: body length bounds.require_code_block: require a fenced code block.required_bump: require entries governed by this rule to use a specific bump severity.
changesets/types/<type>
Why: changelog types can carry their own policy. For example, a breaking type can require migration notes even if a repository has multiple bump severities.
The <type> segment must match a configured changelog type. It accepts the same scoped options as changesets/bump/<severity>.
changesets/duplicate
Why: a changeset should not target the same effective package more than once.
Duplicate package entries are rejected by workspace validation. The rule id remains recognized in [lints.rules] for compatibility with existing configurations that explicitly turn it on or off.
Cargo manifest lint rules
Cargo rules apply to discovered Cargo.toml package manifests and, where needed, the workspace package graph.
cargo/dependency-field-order
Why: keeps inline dependency tables visually consistent.
What it checks: preferred key order inside dependency tables:
workspaceorversiondefault-features/default_featuresfeatures- other keys like
optional,path,registry,package,git,branch,tag,rev
Without the rule:
serde = { features = ["derive"], workspace = true }
With the rule:
serde = { workspace = true, features = ["derive"] }
Options:
fix: defaults totrue; rewrites the dependency entry when safe.
cargo/internal-dependency-workspace
Why: internal workspace dependencies should usually be declared through the workspace rather than carrying their own explicit version strings.
Without the rule:
[dependencies]
monochange_core = { path = "../monochange_core", version = "0.1.0" }
With the rule:
[dependencies]
monochange_core = { workspace = true }
When to use it: when the repository wants one workspace-owned version source for internal crates.
Options:
require_workspace: defaults totrue; require internal dependencies to useworkspace = true.fix: defaults totrue; rewrites safe internal dependency entries.
cargo/publishable-dependencies
Why: a crate that can be published should not depend on an internal workspace crate that cannot be published. That leaves registry consumers unable to resolve the dependency.
What it checks: publishable Cargo packages and their internal Cargo dependencies. If the dependent package is publishable, any internal dependency it relies on must also be publishable.
Without the rule:
# crates/app/Cargo.toml
[package]
name = "app"
version = "0.1.0"
[dependencies]
internal_helper = { workspace = true }
# crates/internal_helper/Cargo.toml
[package]
name = "internal_helper"
version = "0.1.0"
publish = false
With the rule: either make internal_helper publishable, remove the dependency from the publishable crate, or mark the depending crate private too.
Autofix: no. This is a release policy decision, so monochange reports the dependency chain instead of changing publishability for you.
cargo/required-package-fields
Why: published crates should consistently carry the metadata your repository expects.
Default required fields:
descriptionlicenserepository
Without the rule:
[package]
name = "example"
version = "0.1.0"
With the rule: monochange reports the missing fields so package metadata stays consistent.
Options:
fields: replace the default required-field list.
Example:
[lints.rules]
"cargo/required-package-fields" = { level = "error", fields = ["description", "license"] }
cargo/sorted-dependencies
Why: alphabetized dependency tables are easier to review and reduce noisy diffs.
Without the rule:
[dependencies]
zzzz = "1.0"
aaaa = "1.0"
mmmm = "1.0"
With the rule:
[dependencies]
aaaa = "1.0"
mmmm = "1.0"
zzzz = "1.0"
Options:
fix: defaults totrue; rewrites dependency sections in sorted order.
cargo/unlisted-package-private
Why: a Cargo package that is not listed in monochange.toml should not be accidentally publishable.
With the rule: monochange requires either:
- adding the package to
monochange.toml, or - marking it private with
publish = false.
Without the rule:
[package]
name = "experimental-crate"
version = "0.1.0"
With the rule:
[package]
name = "experimental-crate"
version = "0.1.0"
publish = false
Options:
fix: defaults totrue; insertspublish = falsewhen safe.
cargo/manifest-repository
Why: package registry pages should send readers to the exact source directory for the package they are using. In monorepos, a root repository URL is correct for root-level packages, but packages under subdirectories should link to that subdirectory on the configured default branch.
What it checks: the rule compares [package].repository with the repository URL derived from [source] in monochange.toml:
- root-level packages must use the base repository URL, such as
https://github.com/acme/widgets - subdirectory packages must use
{repo_url}/tree/{default_branch}/{relative_package_dir}, such ashttps://github.com/acme/widgets/tree/main/crates/widget_core - if
[source]is missing, the rule skips because monochange cannot derive the canonical repository URL
Cargo manifests may also use repository = { workspace = true }. By default, this rule resolves that inheritance from the root Cargo.toml’s [workspace.package].repository, falling back to root [package].repository. If the inherited root value does not point at the package subdirectory, the rule reports the package manifest and can replace the inherited inline table with an explicit repository URL.
Without the rule:
[package]
name = "widget_core"
version = "0.1.0"
repository = "https://github.com/acme/widgets"
With the rule:
[package]
name = "widget_core"
version = "0.1.0"
repository = "https://github.com/acme/widgets/tree/main/crates/widget_core"
Configuration:
[lints.rules]
"cargo/manifest-repository" = "error"
Set allow_workspace_inheritance = true only when you intentionally want to permit repository = { workspace = true } without resolving it against the package path:
[lints.rules]
"cargo/manifest-repository" = { level = "error", allow_workspace_inheritance = true }
Autofix: run monochange check --fix to insert a missing repository, replace an incorrect value, or convert repository = { workspace = true } into the explicit URL required for the package directory. There is no per-rule fix option; applying fixes is controlled by the CLI flag.
npm-family manifest lint rules
npm-family rules apply to package.json manifests discovered through npm, pnpm, yarn, Bun, and Deno/npm-style package graphs.
npm/workspace-protocol
Why: internal workspace dependencies should use the workspace: protocol so local workspace intent is explicit.
Without the rule:
{
"dependencies": {
"@acme/shared": "^1.2.0"
}
}
With the rule:
{
"dependencies": {
"@acme/shared": "workspace:*"
}
}
When to use it: npm, pnpm, yarn, and Bun workspaces where internal packages should not drift to plain registry ranges.
Options:
require_for_private: defaults tofalse; also enforce the rule for private packages.fix: defaults totrue; rewrites internal dependency ranges toworkspace:ranges.
npm/sorted-dependencies
Why: alphabetized dependency sections reduce review noise and make package diffs easier to scan.
Without the rule:
{
"dependencies": {
"zod": "^4.0.0",
"chalk": "^5.0.0"
}
}
With the rule:
{
"dependencies": {
"chalk": "^5.0.0",
"zod": "^4.0.0"
}
}
Options:
fix: defaults totrue; rewrites dependency sections in sorted order.
npm/required-package-fields
Why: package metadata should stay consistent across publishable npm packages.
Default required fields:
descriptionrepositorylicense
Without the rule:
{
"name": "@acme/app",
"version": "1.0.0"
}
With the rule: monochange reports the missing metadata fields.
Options:
fields: replace the default required-field list.
npm/root-no-prod-deps
Why: the workspace root package.json is usually orchestration-only and should keep runtime dependencies out of the root package.
Without the rule:
{
"dependencies": {
"react": "^19.0.0"
}
}
With the rule: move those to devDependencies when the root package is only a workspace manager.
Options:
fix: defaults totrue; moves rootdependenciesintodevDependencies.
npm/no-duplicate-dependencies
Why: the same dependency should not appear in multiple dependency sections unless the repository has a very deliberate reason.
Without the rule:
{
"dependencies": {
"typescript": "^5.0.0"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
With the rule: monochange reports the duplicate and can remove redundant entries from later sections when safe.
Options:
fix: defaults totrue; removes duplicate entries from later sections.
npm/unlisted-package-private
Why: a package not declared in monochange.toml should not remain publishable by accident.
With the rule: monochange requires either:
- adding the package to
monochange.toml, or - marking it private in
package.json.
Without the rule:
{
"name": "@acme/experimental",
"version": "0.1.0"
}
With the rule:
{
"name": "@acme/experimental",
"private": true,
"version": "0.1.0"
}
Options:
fix: defaults totrue; insertsprivate: truewhen safe.
npm/manifest-repository
Why: npm package metadata should link users to the exact source folder for that package. In monorepos, a root repository URL is correct only for root-level packages; packages in subdirectories should link directly to their package directory on the configured default branch.
What it checks: the rule compares repository in package.json with the repository URL derived from [source] in monochange.toml:
- root-level packages must use the base repository URL, such as
https://github.com/acme/widgets - subdirectory packages must use
{repo_url}/tree/{default_branch}/{relative_package_dir}, such ashttps://github.com/acme/widgets/tree/main/packages/widget-core - if
[source]is missing, the rule skips because monochange cannot derive the canonical repository URL
Without the rule:
{
"name": "@acme/widget-core",
"version": "0.1.0",
"repository": "https://github.com/acme/widgets"
}
With the rule:
{
"name": "@acme/widget-core",
"version": "0.1.0",
"repository": "https://github.com/acme/widgets/tree/main/packages/widget-core"
}
Configuration:
[lints.rules]
"npm/manifest-repository" = "error"
Autofix: run monochange check --fix to insert a missing repository or replace an incorrect value. There is no per-rule fix option; applying fixes is controlled by the CLI flag.
Dart manifest lint rules
Dart rules apply to pubspec.yaml manifests, including Flutter packages when a pubspec has Flutter-specific metadata.
dart/sdk-constraint-present
Why: every managed Dart package should declare the SDK range it expects rather than inheriting whatever the developer machine happens to provide.
With the rule: monochange reports any pubspec.yaml that omits environment.sdk.
Without the rule:
name: app
version: 1.0.0
With the rule:
name: app
version: 1.0.0
environment:
sdk: ">=3.6.0 <4.0.0"
dart/sdk-constraint-modern
Why: old or overly broad SDK ranges quietly expand your support policy and make releases harder to reason about.
Default policy:
- minimum lower bound:
3.0.0 - upper bound required by default
Options:
minimum: override the minimum lower bound for your workspace.require_upper_bound: set tofalseif your policy intentionally omits an upper bound.
Example:
[lints.rules]
"dart/sdk-constraint-modern" = { level = "warning", minimum = "3.6.0", require_upper_bound = false }
dart/dependency-sorted
Why: alphabetized dependencies, dev_dependencies, and dependency_overrides blocks reduce review noise and make Dart manifest diffs easier to scan.
Without the rule:
dependencies:
zeta: ^1.0.0
alpha: ^1.0.0
With the rule:
dependencies:
alpha: ^1.0.0
zeta: ^1.0.0
Options:
fix: defaults totrue; rewrites dependency sections in sorted order.
dart/required-package-fields
Why: managed publishable Dart packages should carry the metadata your repository expects before release.
Default required fields:
descriptionrepositorylicense
Without the rule:
name: app
version: 1.0.0
With the rule: monochange reports missing metadata fields for publishable packages.
Options:
fields: replace the default required-field list.
Example:
[lints.rules]
"dart/required-package-fields" = { level = "error", fields = ["description", "repository"] }
dart/no-git-dependencies-in-published-packages
Why: published Dart packages should resolve from hosted dependencies, not source-control dependencies, unless the repository explicitly allows an exception.
Without the rule:
dependencies:
shared:
git:
url: https://github.com/acme/shared.git
With the rule: monochange reports git: dependencies in publishable packages unless the dependency name appears in the allow list.
Options:
allow: list dependency names that may usegit:sources.
Example:
[lints.rules]
"dart/no-git-dependencies-in-published-packages" = { level = "error", allow = ["shared"] }
dart/unlisted-package-private
Why: a Dart package that is not listed in monochange.toml should not be accidentally publishable.
With the rule: monochange requires either:
- adding the package to
monochange.toml, or - marking it private with
publish_to: none.
Without the rule:
name: experimental
version: 0.1.0
With the rule:
name: experimental
version: 0.1.0
publish_to: none
Options:
fix: defaults totrue; insertspublish_to: nonewhen safe.
dart/no-unexpected-dependency-overrides
Why: dependency_overrides are sometimes necessary, but they should usually be limited to private packages or a small allow list of explicitly approved packages.
With the rule: monochange reports dependency_overrides unless they are allowed by privacy or package name.
Options:
allow_for_private: defaults totrue; allow overrides in private packages.allow_packages: list package names that may keepdependency_overrides.
Example:
[lints.rules]
"dart/no-unexpected-dependency-overrides" = { level = "warning", allow_for_private = true, allow_packages = ["app_shell"] }
dart/internal-path-dependency-policy
Why: monorepos usually want one consistent policy for how internal Dart packages reference each other.
Default policy: strict mode expects internal packages to use path: references unless the pubspec declares resolution: workspace.
With Dart workspace resolution, Dart resolves versioned internal dependencies to local workspace packages automatically. In that mode, monochange requires version constraints and reports path: references with the message “use version constraints (not path:) when resolution is workspace”.
Options:
mode: choose"path"or"hosted"for packages that do not useresolution: workspace.
Example:
[lints.rules]
"dart/internal-path-dependency-policy" = { level = "error", mode = "hosted" }
dart/workspace-internal-version-consistency
Why: when workspace packages reference each other with hosted version ranges, those ranges should not drift away from the current workspace version.
With the rule: monochange compares internal dependency version references against the discovered workspace package version and reports mismatches. Use monochange versions --dry-run to preview automatic repairs for supported manifests, then rerun without --dry-run to update supported internal dependency references.
dart/flutter-package-metadata-consistent
Why: packages with a flutter section should declare the Flutter SDK dependency consistently so they are unmistakably Flutter packages.
With the rule: monochange requires dependencies.flutter = { sdk = flutter } in pubspec.yaml terms, expressed as the YAML mapping form.
Without the rule:
name: widgets
flutter:
assets:
- assets/logo.png
With the rule:
name: widgets
dependencies:
flutter:
sdk: flutter
flutter:
assets:
- assets/logo.png
dart/assets-sorted
Why: stable ordering for flutter.assets and flutter.fonts reduces noisy diffs in Flutter packages.
Without the rule:
flutter:
assets:
- assets/zeta.png
- assets/alpha.png
With the rule:
flutter:
assets:
- assets/alpha.png
- assets/zeta.png
Options:
fix: defaults totrue; rewrites Flutter assets and fonts in sorted order.
dart/manifest-repository
Why: Dart and Flutter package metadata should link users to the exact source folder for that package. In monorepos, a root repository URL is correct only for root-level packages; packages in subdirectories should link directly to their package directory on the configured default branch.
What it checks: the rule compares repository in pubspec.yaml with the repository URL derived from [source] in monochange.toml:
- root-level packages must use the base repository URL, such as
https://github.com/acme/widgets - subdirectory packages must use
{repo_url}/tree/{default_branch}/{relative_package_dir}, such ashttps://github.com/acme/widgets/tree/main/packages/widget_core - if
[source]is missing, the rule skips because monochange cannot derive the canonical repository URL
Without the rule:
name: widget_core
version: 0.1.0
repository: https://github.com/acme/widgets
With the rule:
name: widget_core
version: 0.1.0
repository: https://github.com/acme/widgets/tree/main/packages/widget_core
Configuration:
[lints.rules]
"dart/manifest-repository" = "error"
Autofix: run monochange check --fix to insert a missing repository or replace an incorrect value. There is no per-rule fix option; applying fixes is controlled by the CLI flag.
What monochange check looks like in practice
Use plain text for local review:
monochange check
Apply safe auto-fixes where possible:
monochange check --fix
Use JSON for CI or MCP-style tooling:
monochange check --format json
monochange check fails when lint errors are present, so it is appropriate for CI gates.
Recommended workflow
For repository work:
monochange step validate
monochange check
monochange step prepare-release --dry-run --diff
If you changed shared docs too:
devenv shell docs:check
Internal dependency versions
Use monochange versions sync to keep internal workspace dependency constraints aligned with each package’s canonical version. It is useful when migrating an existing monorepo to monochange and when package versions change during normal release work.
monochange versions sync --dry-run
monochange versions sync
The monochange versions list subcommand prints the discovered package and group version inventory without touching files:
monochange versions list
monochange versions list --format json-min
The command scans discovered workspace packages, builds a package-name to version map, and updates supported manifest files where one workspace package depends on another. It only syncs internal workspace dependencies; it does not change external dependency constraints.
Output
By default, monochange versions sync --dry-run prints the file and dependency updates it would make:
would update ^1.1.0 → ^1.2.3 in core (packages/app/pubspec.yaml)
Strategy: default (package config → ecosystem config → ecosystem default; --strategy overrides)
(dry run — no files were modified)
Use JSON output for scripts and CI checks:
monochange versions sync --dry-run --format json
The JSON result includes whether changes were applied, the selected strategy, changed files, dependency updates, and any packages skipped during planning.
Constraint styles and prefixes
--strategy controls the constraint prefix written for updated dependencies:
defaultkeeps each ecosystem’s own constraint style (see the table below).exactwrites the bare version with no range prefix, such as1.2.3.caretwrites a caret constraint, such as^1.2.3, for ecosystems that use caret ranges.compatiblewrites a compatible-range constraint, such as>=1.2.3.
Passing --strategy overrides the built-in style for the whole command. monochange.toml cannot currently change the style versions sync uses, and --strategy is the only override. The command always writes the same prefix for a given strategy and ecosystem. The dependency_version_prefix ecosystem setting affects versioned-file writes (see Versioned files), not versions sync.
What each ecosystem receives for an internal dependency on a package at 1.2.3:
| Strategy | Cargo | npm | Deno | Dart | Python | Go |
|---|---|---|---|---|---|---|
default | 1.2.3 | ^1.2.3 | ^1.2.3 | ^1.2.3 | >=1.2.3 | v1.2.3 |
exact | 1.2.3 | 1.2.3 | 1.2.3 | 1.2.3 | 1.2.3 | v1.2.3 |
caret | 1.2.3 | ^1.2.3 | ^1.2.3 | ^1.2.3 | >=1.2.3 | v1.2.3 |
compatible | >=1.2.3 | >=1.2.3 | ^1.2.3 | >=1.2.3 | >=1.2.3 | >=v1.2.3 |
Keep these limits in mind:
versions syncnever writes tilde (~) or equality (=) prefixes. To stamp internal dependency references with a custom prefix such as~or=at release time, use a typedversioned_filesentry with an explicitprefix(see Versioned files).- Deno keeps
^undercompatible, and Go’scompatibleoutput keeps the mandatoryvmodule prefix (>=v1.2.3); preferdefault,exact, orcaretfor those two ecosystems. - Cargo reads a bare requirement such as
1.2.3as a caret range, so--strategy exactdoes not produce a Cargo exact pin (=1.2.3).
Supported ecosystems
monochange versions updates internal dependency constraints for all ecosystems monochange discovers: Cargo Cargo.toml, Dart pubspec.yaml / pubspec.yml, Deno deno.json, Go go.mod, npm package.json, and Python pyproject.toml manifests.
For Dart workspaces that use resolution: workspace, internal dependencies should use versioned constraints instead of path: references. monochange versions converts eligible internal path: references to the configured version constraint.
Version preview and monochange publish
Two built-in commands shorten the most common release questions: what version comes next, and how do I run a publish step.
Check the next version
monochange next prints the planned next version for every release group and every package that releases independently. It reads pending changesets and nothing else.
monochange next
group versions:
- sdk: 1.1.0
package versions:
- cargo:crates/sdk-a/Cargo.toml: 1.1.0
- cargo:crates/sdk-b/Cargo.toml: 1.1.0
- cargo:crates/tool/Cargo.toml: 1.0.1
Group members report the group version because they share one release identity. tool releases on its own, so it reports its own version.
Use --format for structured output:
monochange next --format json
monochange next --format json-min
monochange next --format md
{
"packages": {
"cargo:crates/sdk-a/Cargo.toml": "1.1.0",
"cargo:crates/sdk-b/Cargo.toml": "1.1.0",
"cargo:crates/tool/Cargo.toml": "1.0.1"
},
"groups": {
"sdk": "1.1.0"
}
}
It writes nothing
monochange next is read-only. It does not create release.json, does not write the prepared-release cache under .monochange/local/, and does not modify manifests, changelogs, or changesets. Running it always leaves a clean working tree, so it is safe in a pre-commit check, a shell prompt, or a CI job that only reports.
The one exception is reuse: if a valid prepared-release artifact already exists and still matches the workspace, the command reports that artifact instead of recomputing. A stale artifact is ignored and the plan is recomputed from changesets.
When there are no changesets
An empty .changeset directory is a normal state, not an error. The command reports it and exits successfully:
no package or group versions were planned
Relationship to other commands
| Command | Reports |
|---|---|
monochange next | Planned group and package versions only |
monochange versions list | The current versions recorded in the workspace |
monochange step prepare-release --dry-run | Planned versions plus changelog and release-artifact previews |
monochange next is the read-only alias for monochange step display-versions; monochange next-versions also resolves there.
Use monochange versions list when you want the versions that exist today, and monochange next when you want the versions that will exist after the next release.
Publish subcommands
monochange publish groups the built-in publishing steps behind short subcommands. Each runs the same step as its monochange step * equivalent, with identical inputs and output formats.
| Command | Runs |
|---|---|
monochange publish packages | monochange step publish-packages |
monochange publish readiness | monochange step publish-readiness |
monochange publish placeholder | monochange step placeholder-publish |
monochange publish readiness --from HEAD --output readiness.json
monochange publish packages --output publish.json
monochange publish placeholder --format json
The monochange step * forms remain supported and behave identically; the grouped commands exist for readability and tab completion. Any [cli.*] workflow you define in monochange.toml is unaffected, because config-defined commands run under monochange run <name>.
Typical first-time registry bootstrap:
monochange publish readiness --from HEAD --output readiness.json
monochange publish placeholder
monochange publish readiness --from HEAD --output readiness.json
monochange publish packages --output publish.json
Progress output
monochange writes progress information to stderr so stdout can remain stable for text, markdown, and JSON command results.
Selecting a renderer
Use the global --progress-format <FORMAT> flag or set MONOCHANGE_PROGRESS_FORMAT.
Supported values:
auto: default behavior. Deterministic human progress output is enabled for terminals, CI logs, editor tasks, and captured processes; animation is terminal-only.unicode: force the human renderer with Unicode symbols and spinners.ascii: force the human renderer with ASCII-safe symbols.json: emit newline-delimited JSON progress events on stderr.
--quiet suppresses progress output. MONOCHANGE_NO_PROGRESS=1 also disables the automatic human renderer.
Human progress output
The human renderer is designed for interactive terminal runs:
- configuration loading and validation report their active phase before work begins
- step labels use each step’s
name = "..."value when present, then fall back to the built-in step kind - long-running steps show a delayed spinner so short steps do not flicker
- command stdout and stderr stream under the active step: the step name is written once as a block header, then every captured line is indented beneath it
- stdout and stderr interleave in arrival order without stream tags; use
--progress-format jsonwhen a consumer must tell the two streams apart - completed
PrepareReleaseandDisplayVersionssteps print per-phase timings so slow phases are visible without a separate trace
Captured command output looks like this, with the step named once instead of on every line:
▶ [2/7] format release files (Command)
▶ [2/7] format release files (Command) — running command `devenv tasks run format`
│ format release files
│ • Validating lock
│ • Validating lock in 2.35ms
✔ [2/7] format release files (Command) 398ms
The same events become complete, newline-terminated records when stderr is captured or monochange runs in CI. Lint and publish operations share the workflow reporter, so a nested operation cannot create a second spinner or append text to an active line. Publish progress uses the same symbols, colors, and ASCII fallback as workflow progress.
Built-in commands already attach descriptive step names such as prepare release, publish release, and open release request. Custom commands can override those names per step.
JSON event stream
--progress-format json is intended for machines, not humans. It writes one JSON object per line to stderr.
Common lifecycle events:
phase_startedphase_finishedphase_failedcommand_startedstep_startedcommand_outputstep_finishedstep_failedstep_skippedcommand_finishedcommand_failedlint_planning_startedlint_planning_finishedlint_suite_startedlint_suite_finishedlint_fix_startedlint_fix_appliedlint_fix_finishedlint_summarypublish_run_startedpublish_registry_check_startedpublish_package_startedpublish_package_skippedpublish_package_plannedpublish_package_publishedpublish_package_failedpublish_run_finished
Shared fields:
sequence: monotonically increasing event sequence number for the command runcommand: CLI command name, such asreleasedry_run: whether the command is running in dry-run modetotal_steps: total step count for the commandstep_index: 1-based step index for step eventsstep_kind: built-in step kind, such asPrepareReleasestep_display_name: rendered human label for the stepstep_name: explicit configuredname, ornullwhen omitted
Event-specific fields:
command_outputaddsstreamandtextphase_finished,step_finished, and command completion events addduration_msstep_finishedaddsphase_timingsstep_failedaddsduration_msanderrorstep_skippedmay add the backward-compatibleconditionfield for conditional skips andreasonfor a human-readable explanationcommand_failedaddsduration_msanderror
Example:
{"sequence":0,"event":"phase_started","phase":"Loading workspace configuration"}
{"sequence":1,"event":"phase_finished","phase":"Loaded workspace configuration","duration_ms":12}
{"sequence":2,"event":"command_started","command":"release","dry_run":true,"total_steps":2}
{"sequence":3,"event":"step_started","command":"release","dry_run":true,"step_index":1,"total_steps":2,"step_kind":"PrepareRelease","step_display_name":"plan release","step_name":"plan release"}
{"sequence":4,"event":"step_finished","command":"release","dry_run":true,"step_index":1,"total_steps":2,"step_kind":"PrepareRelease","step_display_name":"plan release","step_name":"plan release","duration_ms":243,"phase_timings":[{"label":"discover release workspace","duration_ms":97}]}
Failure diagnostics and maintainer tracing
Normal failures use a stable diagnostic code and put the useful recovery information first:
error[cli.json_required]: --jq requires explicit JSON output
command: monochange step config
help: Add `--format json` or `--format json-min` before using `--jq`.
Use the code when searching CI logs or reporting a recurring failure. The diagnostic includes command or path context when monochange knows it and a next action when it can recommend one.
--log-level <FILTER> enables local maintainer tracing, for example --log-level debug or --log-level monochange=trace. Tracing is opt-in, may include internal spans and implementation detail, and is not the normal user-facing explanation for a failure. Animation is disabled while tracing is active so trace records and progress lines remain readable. This flag does not enable remote telemetry.
Benchmark integration
The binary benchmark workflow uses --progress-format json to extract PrepareRelease phase timings for both monochange run release --dry-run and monochange run release.
Those timings are summarized and compared against scripts/benchmark-phase-budgets.json, which lets pull requests fail when real release-path regressions exceed the configured budget.
For hosted-provider analysis outside CI, pnpm node scripts/benchmark-cli.ts run-fixture can benchmark an existing repository checkout and render the same markdown summary against a real hosted fixture. See Hosted release benchmarks.
Telemetry
monochange can write local-only telemetry events for CLI command and step execution. The first implementation does not send data over the network and does not require a telemetry backend.
Current scope
This release only supports a local JSON Lines sink with OpenTelemetry-style event envelopes. It is intended for debugging, support bundles, and validating the event schema before any hosted or remote telemetry work is considered.
Enabling local telemetry
Telemetry is disabled by default. Enable the local sink with environment variables:
MC_TELEMETRY=local monochange step validate
By default, events are appended to:
$XDG_STATE_HOME/monochange/telemetry.jsonl
When XDG_STATE_HOME is not set, monochange falls back to:
$HOME/.local/state/monochange/telemetry.jsonl
For a one-off file path, set MC_TELEMETRY_FILE:
MC_TELEMETRY=local MC_TELEMETRY_FILE=/tmp/mc-telemetry.jsonl monochange step validate
Setting only MC_TELEMETRY_FILE also enables the local sink for that command:
MC_TELEMETRY_FILE=/tmp/mc-telemetry.jsonl monochange step discover
Disable telemetry explicitly with any of:
MC_TELEMETRY=0
MC_TELEMETRY=false
MC_TELEMETRY=off
MC_TELEMETRY=disabled
Events
command_run
Emitted when a CLI command completes or fails after command execution starts.
Attributes:
command_namecommand_source:configuredorgenerated_stepdry_runshow_diffprogress_format:auto,unicode,ascii, orjsonstep_countduration_msoutcome:successorerrorerror_kind: sanitized error category ornull
command_step
Emitted for each CLI step that succeeds, fails, or is skipped.
Attributes:
command_namestep_indexstep_kindskippedduration_msoutcome:success,skipped, orerrorerror_kind: sanitized error category ornull
Local event shape
Each line is one JSON object:
{
"resource": {
"service.name": "monochange",
"service.version": "0.2.0"
},
"scope": {
"name": "monochange.telemetry",
"version": "0.1.0"
},
"time_unix_nano": 1777338000000000000,
"severity_text": "INFO",
"body": {
"string_value": "command_run"
},
"attributes": {
"command_name": "validate",
"command_source": "configured",
"dry_run": false,
"show_diff": false,
"progress_format": "auto",
"step_count": 1,
"duration_ms": 42,
"outcome": "success",
"error_kind": null
}
}
Privacy boundaries
The local sink intentionally records only low-cardinality command metadata and sanitized error categories. It does not record package names, paths, repository URLs, branch names, tag names, commit hashes, issue numbers, pull request numbers, shell command strings, environment values, changeset content, changelog content, release notes, or raw error messages.
Future work
Remote export, a user-facing telemetry command group, persistent opt-in configuration, hosted dashboards, and richer workspace-shape events are tracked as follow-up issues. Until those are implemented, telemetry remains local-only and best-effort.
Hosted release benchmarks
The default binary benchmark workflow uses synthetic local fixtures so every pull request can run quickly in CI.
When you need to measure hosted-provider overhead for the real monochange run release path, use a dedicated hosted fixture repository instead. That lets the benchmark include GitHub request cost, realistic history shape, and changesets that actually arrived through pull requests.
Create the fixture repository
Use the helper script in this repository to create a repeatable fixture with:
- multiple Cargo packages
- more than 200 commits by default
- release changesets introduced from PR-shaped branches
Local dry run:
scripts/setup_hosted_benchmark_fixture.sh \
--local-only \
--output-dir /tmp/monochange-release-benchmark-fixture \
--owner ifiokjr \
--repo monochange-release-benchmark-fixture
Hosted GitHub repo:
scripts/setup_hosted_benchmark_fixture.sh \
--output-dir /tmp/monochange-release-benchmark-fixture \
--owner ifiokjr \
--repo monochange-release-benchmark-fixture
The hosted mode requires:
gh auth statusto succeed withreposcope- permission to create repositories under the chosen owner
The generator stores an authenticated HTTPS remote in the disposable fixture clone so it can push the seeded PR branches without depending on SSH agent state. Use a temporary output directory for hosted runs.
Benchmark the hosted fixture
Build the main and PR binaries first, then run the benchmark script against a clone of the hosted fixture repository:
gh repo clone ifiokjr/monochange-release-benchmark-fixture /tmp/monochange-release-benchmark-fixture
pnpm node scripts/benchmark-cli.ts run-fixture \
--main-bin /tmp/monochange-main \
--pr-bin /tmp/monochange-pr \
--fixture-dir /tmp/monochange-release-benchmark-fixture \
--scenario-id hosted_github \
--scenario-name "Hosted GitHub fixture" \
--scenario-description "8 packages, >200 commits, PR-originated changesets" \
--output /tmp/hosted-benchmark.md \
--violations-output /tmp/hosted-benchmark-violations.txt
This produces the same markdown summary format as the CI benchmark comment, but it benchmarks a real hosted repository checkout instead of a synthetic local fixture.
Reading the result
Focus on:
- the overall
monochange run releasedelta betweenmainand the PR binary - the
prepare release totalrow in the phase table - hosted-specific phases such as
enrich changeset context via github
If the hosted run still shows a regression or an unexpectedly large absolute cost, capture a trace against the same fixture checkout and attach both the benchmark markdown and trace notes to the relevant issue or pull request.
CLI step reference
monochange CLI commands are built in two layers:
- immutable built-in step commands: every built-in step except
Commandis exposed directly asmonochange step <kebab-step-name>, for examplemonochange step discover,monochange step prepare-release, andmonochange step affected-packages. These commands are generated by the binary, derive their flags from the step schema, and do not require a[cli.*]entry inmonochange.toml. - config-driven workflow commands: every
[cli.<command>]table inmonochange.tomlbecomesmonochange run <command>.monochange initdoes not seed default workflow aliases; add these tables when you want a named workflow that chains steps, adds custom inputs, or runsCommandsteps.
A step is the smallest execution unit in a monochange workflow. Some steps are standalone (Config, Validate, Discover, AffectedPackages, DiagnoseChangesets, RetargetRelease, VerifyReleaseBranch, ReleaseRecord, PublishReadiness, and TagRelease). Others are stateful and build on the result of an earlier PrepareRelease step (CommitRelease, PublishRelease, OpenReleaseRequest, and CommentReleasedIssues). PrepareRelease also refreshes the cached .monochange/release-manifest.json artifact exposed to later steps as manifest.path.
When you design a command, think in terms of:
- what state the command needs
- which step produces that state
- which later step consumes it
- what side effects are acceptable in normal mode vs
--dry-run
The reference pages in this section document each built-in step with:
- what the step does
- why you would choose it over a shell-only
Command - which inputs it accepts
- what prerequisite state it needs
- what it contributes to later steps
- examples of how it composes into full workflows
Explicit step input inheritance
Config-defined workflow commands have two input layers:
[[cli.<command>.inputs]]declares the flags and arguments accepted bymonochange run <command>.inputson each step decides which of those parsed command inputs are visible while that step runs.
Command inputs are not inherited automatically. A step receives a command input only when the step explicitly lists it. This makes wrappers predictable when a command-level flag and a step-specific input share the same name.
Use the array shorthand when a step should inherit command inputs unchanged:
[cli.discover]
inputs = [
{ name = "format", type = "choice", choices = ["text", "json", "json-min"], default = "text" },
]
steps = [
{ type = "Discover", inputs = ["format"] },
]
Use the map form when a step needs fixed values, renamed values, templates, or a mixture of inherited and overridden values:
[cli.release-pr]
inputs = [
{ name = "format", type = "choice", choices = ["text", "json", "json-min", "markdown"], default = "text" },
{ name = "open_as_draft", type = "boolean", default = false },
]
steps = [
{ type = "PrepareRelease", inputs = ["format"] },
{ type = "OpenReleaseRequest", inputs = { format = "markdown", draft = "{{ inputs.open_as_draft }}" } },
]
Step-local when expressions and command templates evaluate against the same explicit step input context. If a when condition references inputs.publish, the step must include publish in its inputs array or map. Use inputs = ["publish"] for unchanged inheritance, or inputs = { publish = "{{ inputs.publish }}" } when you need the map form for other overrides.
Override values in the map form accept native TOML literals: strings, booleans, integers, and floats. Booleans stay booleans in the parsed model and are stringified to "true"/"false" when the step runs; numbers are coerced to their string form at parse time, so writing { jobs = 4, ratio = 2.5 } is exactly the same as writing { jobs = "4", ratio = "2.5" }:
[[cli.release.steps]]
name = "publish"
type = "Command"
command = "npm publish --jobs {{ inputs.jobs }}"
inputs = { jobs = 4, dry_run = true }
Interactive command steps
Add an explicit interactive boolean input to the command, pass it to a Command step, and run the workflow with --interactive when you want the command to own the terminal. The step inherits stdio for that run, so prompts and terminal UIs work, and the progress spinner is suppressed while the command runs.
[cli.publish]
help_text = "Publish packages"
[[cli.publish.inputs]]
name = "interactive"
type = "boolean"
default = false
[[cli.publish.steps]]
name = "publish"
type = "Command"
command = "npm publish"
inputs = ["interactive"]
monochange run publish --interactive
Leave interactive unset (the default) for CI and scripted runs so those commands stay non-interactive. Interactive steps do not capture output: steps.<id>.stdout and steps.<id>.stderr are empty for them, so downstream steps cannot read what an interactive command printed.
Built-in monochange step <name> commands are different: they are generated directly from the step schema, so their CLI flags map to that single step without a [cli.*] wrapper.
Choosing the right step
| Step | Use it when you want to… | Requires previous step? | Typical follow-up |
|---|---|---|---|
Config | inspect resolved configuration and workspace metadata | no | local debugging, CI artifacts |
Validate | fail fast on invalid config, groups, or changesets | no | CI gate or local preflight |
Discover | inspect normalized package discovery across ecosystems | no | local inspection, debug commands |
CreateChangeFile | author a .changeset/*.md file from CLI inputs | no | run independently, or before planning |
PrepareRelease | build the release result, update files, and refresh the cached manifest | no | CommitRelease, PublishRelease, OpenReleaseRequest, CommentReleasedIssues, Command |
DisplayVersions | display planned package and group versions without mutating release files | no | PrepareRelease |
CommitRelease | create a local release commit with an embedded ReleaseRecord | PrepareRelease | OpenReleaseRequest, manual review, custom Command |
ReleaseRecord | inspect an embedded release record from a commit or tag | release commit or tag | tag repair, publish readiness, custom auditing |
VerifyReleaseBranch | verify a ref is reachable from configured release branches | [source.releases] | early release CI gates; enforced internally by tag and publish paths |
TagRelease | create and optionally push release tags from an embedded release record | release commit | source-provider releases, package publishing, custom announcements |
PublishReadiness | check package-registry readiness without publishing packages | release commit or tag | PlanPublishRateLimits, human review, package publishing |
PublishRelease | create or update hosted provider releases | PrepareRelease + [source] | CommentReleasedIssues, custom notification commands |
OpenReleaseRequest | create or update a hosted release PR/MR | PrepareRelease + [source] | provider review, follow-up Command steps |
PlanPublishRateLimits | plan package-registry publish work against known rate limits | no | PublishPackages, PlaceholderPublish |
PlaceholderPublish | publish 0.0.0 placeholder versions for missing registry packages | no | normally before PublishPackages |
PublishPackages | publish package versions to registries using built-in ecosystem workflows | prepared or HEAD release state | custom Command steps using publish.* |
CommentReleasedIssues | post release follow-up comments to closed issues | PrepareRelease + GitHub source | normally after PublishRelease |
AffectedPackages | evaluate changeset coverage for changed files | no | CI enforcement, custom failure messaging |
DiagnoseChangesets | inspect changeset context, commit provenance, and linked review metadata | no | local debugging, CI inspection |
RetargetRelease | repair a recent release by moving its tag set | no | custom Command steps using retarget.* |
Command | run arbitrary shell/program commands with monochange context | depends on your workflow | any external tool |
A note on composition
monochange executes steps in order.
That means composition is explicit:
- a step can only consume state created by an earlier step in the same command
- a later step never runs “in parallel” with an earlier one
--dry-runflows through the whole command and changes the behavior of steps that support previews--quietsuppresses stdout/stderr without changing execution; use--dry-runseparately- a plain
Commandstep can bridge monochange and external tools, but built-in steps are preferable when you want stable semantics, structured JSON, or provider-aware behavior
In practice, most workflows fit one of four patterns:
- validation / inspection
ValidateDiscoverDisplayVersionsAffectedPackagesDiagnoseChangesets
- change authoring
CreateChangeFile
- release preparation and publication
PrepareRelease- then one or more of
CommitRelease,PublishRelease,OpenReleaseRequest,CommentReleasedIssues,Command
- post-release repair
RetargetRelease- optionally followed by
Command
Shared concepts
Step-local name
Every step can declare a name = "..." label.
Use that when you want human-friendly progress output such as plan release, publish tags, or announce release instead of the raw step kind.
Progress rendering
monochange can stream step progress on stderr while keeping command output on stdout.
Use --progress-format auto|unicode|ascii|json or MONOCHANGE_PROGRESS_FORMAT to choose the renderer:
autoenables the human renderer only when stderr is a terminalunicodeforces the human renderer with Unicode symbolsasciiforces the human renderer with ASCII-safe symbolsjsonemits newline-delimited progress events for automation and benchmarks
PrepareRelease and DisplayVersions steps also report per-phase timings when they compute release state. Those timings power the benchmark phase-budget checks for release workflows such as monochange step prepare-release --dry-run and configured monochange run release wrappers.
See Progress output for the full renderer behavior and JSON event shape.
Step-local when
Every step can declare a when = "..." expression.
It uses minijinja-style expression evaluation with template context and supports logical combinations like and, or, and not (for example: "{{ inputs.publish && !inputs.dry_run }}").
If the expression resolves to false, monochange skips that step and continues with the next step. Falsy values include false, 0, and the empty string.
Step-local always_run
Every step can declare always_run = true.
When a previous step in the same command fails, monochange normally aborts and returns the error. Steps marked with always_run = true are the exception: they still execute even after an earlier step has failed.
Use this for cleanup, notification, or dry-run preview steps that should run regardless of whether earlier work succeeded.
Step-local inputs
Every step can define an inputs = { ... } override inside the step table.
Use that when:
- a command-level input should be rebound to a built-in step input
- you want to hardcode a value for one step but not the entire command
- you want to pass list or boolean values through direct template references such as
"{{ inputs.changed_paths }}"
Step-local show_progress
Every step reports its start and terminal status by default, including interactive steps. Animation remains terminal-only, and step kinds that support it can set show_progress = false to suppress their progress output explicitly.
Structured template namespaces
When you compose Command steps after built-in steps, monochange exposes structured context values such as:
release.*afterPrepareReleasemanifest.pathafterPrepareReleaseaffected.*afterAffectedPackagesretarget.*afterRetargetReleaserelease_commit.*afterCommitReleasesteps.<id>.stdoutandsteps.<id>.stderrafter aCommandstep withid = "..."
Those namespaces are the main reason to prefer built-in steps over reimplementing the same workflow in shell.
Pages in this section
- Validate
- Discover
- CreateChangeFile
- AffectedPackages
- DiagnoseChangesets
- RetargetRelease
- PrepareRelease
- CommitRelease
- VerifyReleaseBranch
- PlanPublishRateLimits
- PublishRelease
- OpenReleaseRequest
- CommentReleasedIssues
- Command
- DisplayVersions
- PlaceholderPublish
- PublishPackages
Config
What it does
Config renders the resolved monochange configuration and workspace metadata.
Use it when you need to inspect the configuration after defaults, package discovery, source settings, lint settings, and workflow definitions have been loaded into monochange’s execution model.
Why use it
Config is a read-only inspection step. It is useful for:
- debugging why a workflow input, package selector, or source setting resolved the way it did
- capturing configuration state in CI artifacts
- checking generated or hand-written
monochange.tomlbefore running release workflows
Inputs
The direct command is exposed as:
monochange step config
It does not require step-specific inputs.
Prerequisites
A readable monochange workspace configuration.
Side effects and outputs
Config does not mutate files, create release state, or contact package registries. It renders the resolved configuration and workspace metadata for review.
Example
monochange step config
In a workflow, use it before mutating steps when you want a durable diagnostic snapshot:
[cli.inspect]
help_text = "Render resolved monochange configuration"
[[cli.inspect.steps]]
type = "Config"
Validate
What it does
Validate runs monochange’s repository validation without preparing a release.
It checks the current workspace configuration, package and group rules, and authored changesets. The goal is to fail early when the repository is in a state that would make later commands unreliable.
Why use it
Use Validate when you want a cheap, deterministic gate before any workflow that depends on a healthy monochange model.
It is especially useful for:
- local preflight checks before authoring or releasing
- CI jobs that should fail before spending time on planning or publication
- custom commands that should refuse to continue when config or changesets are invalid
Compared with a shell-only Command step that runs monochange step validate, the built-in Validate step is preferable when you want the command definition to stay provider-neutral and semantically typed.
Inputs
Validate does not accept any built-in step inputs.
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. Validate is standalone.
Side effects and outputs
Structural checks (always run):
- validates workspace config syntax and required fields
- validates package and group declarations, membership rules, and namespace collisions
- validates CLI command definitions, step types, and input schemas
- validates changeset files reference declared packages and groups
- validates Cargo workspace version-group constraints
Content checks (verify files on disk):
- versioned file paths that are not globs must resolve to an existing file
- ecosystem-typed versioned files (Cargo.toml, package.json, deno.json, pubspec.yaml) must contain a readable version field
- regex versioned file patterns must match at least once in the target file
- glob patterns that match zero files produce a non-fatal warning printed to stderr
Security checks:
[source].api_urland[source].hostmust usehttps://; insecurehttp://schemes are rejected to prevent cleartext token transmission
Validate returns a normal success/failure result for the command and does not prepare release state for later steps.
When validation fails, monochange renders the offending file path and line/column first, then shows a focused source snippet plus a fix hint when one is available. That makes malformed changesets and config entries much faster to correct from CI logs or local terminal output.
That last point matters: Validate is a gate, not a state-producing step.
When to place it in a workflow
Put Validate first when a later Command step would otherwise run expensive tooling or provider calls.
Typical pattern:
ValidateCommandfor extra project-specific checks- maybe another standalone step such as
AffectedPackages
Example
monochange step validate
monochange step validate
validate is a built-in step command, so do not define [cli.validate] in monochange.toml. Use monochange step validate for the normal workspace preflight, or compose the step under a non-reserved workflow name:
[cli.preflight]
help_text = "Run the workspace validation preflight"
[[cli.preflight.steps]]
type = "Validate"
Composition ideas
Validate before custom project checks
[cli.preflight]
help_text = "Validate monochange state and then run project checks"
[[cli.preflight.steps]]
type = "Validate"
[[cli.preflight.steps]]
type = "Command"
command = "cargo test --workspace --all-features"
shell = true
Validate before authoring workflows
If your team uses a custom change wrapper command, put Validate before any custom Command step that derives package lists or reads repo metadata. That keeps the repository model stable before you generate new artifacts.
Good fit / bad fit
Good fit:
- fast CI gates
- local
pre-releasechecks - repo health checks before other steps
Bad fit:
- anything that needs release outputs such as
release.* - anything that should mutate files or provider state
Common mistake
Do not expect Validate to make PrepareRelease unnecessary. It only checks whether the repository is valid; it does not compute the release state that publication-oriented steps need.
Discover
What it does
Discover runs monochange package discovery and renders the result in text or json form.
It is the step to use when you want to inspect how monochange sees the repository before you involve changesets or release logic.
Why use it
Use Discover when you need visibility into:
- which packages monochange found
- which ids were assigned
- which manifest paths were normalized
- whether a repository layout is discoverable the way you expect
This is particularly valuable in mixed-ecosystem monorepos where discovery rules are part of the product contract.
Inputs
format:textorjson
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. Discover is standalone.
Side effects and outputs
- discovers packages across supported ecosystems
- emits a report for the overall CLI command output
- does not prepare release state for later steps
Example
[cli.discover]
help_text = "Discover packages across supported ecosystems"
[[cli.discover.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.discover.steps]]
type = "Discover"
inputs = ["format"]
Composition ideas
Discovery-focused debug command
[cli.discover-debug]
help_text = "Show package discovery and then print a custom notice"
[[cli.discover-debug.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
default = "json"
[[cli.discover-debug.steps]]
type = "Discover"
Discover is usually best as the only step in a command, because its value is the rendered report itself.
Use it during repository setup
During initial adoption, teams often expose a discover command next to validate so contributors can see the exact package ids they should use in .changeset/*.md files and command inputs.
Why not just shell out?
A Command step that runs monochange step discover works, but the built-in step is easier to validate and easier to understand when reading monochange.toml. It makes the intent obvious: the command exists to inspect discovery, not to run an arbitrary shell pipeline.
Common mistake
Do not treat Discover as release planning. It does not read changesets into a release decision. For that, use PrepareRelease.
CreateChangeFile
What it does
CreateChangeFile writes a .changeset/*.md file from typed CLI inputs.
It supports both:
- explicit non-interactive authoring from inputs such as
package,bump,caused_by,reason, anddetails - interactive authoring when
interactive = true
Why use it
Use CreateChangeFile when you want monochange itself to remain the source of truth for authored change files.
That gives you a few advantages over rolling your own shell template generator:
- package and group references resolve through the same config model used for release planning
- default bump/type behavior stays aligned with monochange parsing rules
- interactive mode can guide authors instead of forcing them to remember frontmatter details
- the generated file shape stays compatible with
monochange step validate,PrepareRelease, and diagnostics tooling
Inputs
interactive: boolean; use interactive prompting instead of explicit package argumentspackage: list of package or group ids to targetbump:none,patch,minor, ormajorversion: explicit version pin for the changereason: summary linetype: optional release-note typecaused_by: optional list of package or group ids that explain dependency-only follow-up changesdetails: optional long-form bodyoutput: optional explicit file path
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. CreateChangeFile is standalone.
Side effects and outputs
- writes a new changeset file
- reports the written path
- does not prepare release state for later steps
- automatically hides the progress spinner during interactive prompting so the selector UI stays readable, and restarts it while the change file is written
- automatically wraps package/group ids in quotes when the authored frontmatter key contains YAML-sensitive characters such as
@or/
Example
[cli.change]
help_text = "Create a change file for one or more packages"
[[cli.change.inputs]]
name = "interactive"
type = "boolean"
short = "i"
[[cli.change.inputs]]
name = "package"
type = "string_list"
[[cli.change.inputs]]
name = "bump"
type = "choice"
choices = ["none", "patch", "minor", "major"]
default = "patch"
[[cli.change.inputs]]
name = "version"
type = "string"
[[cli.change.inputs]]
name = "type"
type = "string"
[[cli.change.inputs]]
name = "caused_by"
type = "string_list"
[[cli.change.inputs]]
name = "reason"
type = "string"
[[cli.change.inputs]]
name = "details"
type = "string"
[[cli.change.inputs]]
name = "output"
type = "string"
[[cli.change.steps]]
type = "CreateChangeFile"
inputs = [
"interactive",
"package",
"bump",
"version",
"type",
"caused_by",
"reason",
"details",
"output",
]
Composition ideas
Non-interactive wrapper for contributors
[cli.change-fix]
help_text = "Create a patch changeset for one package"
[[cli.change-fix.inputs]]
name = "package"
type = "string_list"
required = true
[[cli.change-fix.inputs]]
name = "reason"
type = "string"
required = true
[[cli.change-fix.steps]]
type = "CreateChangeFile"
inputs = { bump = "patch", package = "{{ inputs.package }}", reason = "{{ inputs.reason }}" }
This is a good example of why built-in step inputs matter: the wrapper command is still using CreateChangeFile semantics rather than generating markdown manually.
Interactive authoring command
You can also create a dedicated interactive authoring command that always opts in to prompts.
[cli.change-interactive]
help_text = "Create a change file interactively"
[[cli.change-interactive.steps]]
type = "CreateChangeFile"
show_progress = false
inputs = { interactive = true }
Good fit / bad fit
Good fit:
- contributor-facing commands
- wrappers that standardize bump policies
- interactive authoring helpers
Bad fit:
- release execution
- commands that need
release.*context
Common mistakes
- omitting
packagein non-interactive mode - expecting
CreateChangeFileto release anything immediately - using raw manifest paths when configured package ids are the stable interface
- forgetting
caused_bywhen a dependent package only changed because another package or group moved first
AffectedPackages
What it does
AffectedPackages evaluates changed files into affected package coverage and changeset policy results.
It can answer questions such as:
- which packages are affected by this change set?
- are those changes covered by changesets?
- should verification be skipped because of labels?
Why use it
Use AffectedPackages when you want a CI-oriented policy step instead of a release step.
It is the best fit for:
- pull request checks
- pre-merge policy enforcement
- reusable GitHub Actions or other CI jobs
- custom failure messaging based on affected-package status
Inputs
format:textorjsonchanged_paths: explicit changed pathsfrom: revision to diff against; takes priority overchanged_pathsverify: whether to enforce non-zero failure on uncovered packageslabel: skip labels supplied from CI
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. AffectedPackages is standalone.
Side effects and outputs
- computes the changeset policy evaluation
- exposes
affected.statusandaffected.summaryto laterCommandsteps - can be used as a pure reporting step or an enforcing gate depending on
verify
Example
[cli.affected]
help_text = "Evaluate pull-request changeset policy"
[[cli.affected.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.affected.inputs]]
name = "changed_paths"
type = "string_list"
required = true
[[cli.affected.inputs]]
name = "label"
type = "string_list"
[[cli.affected.inputs]]
name = "verify"
type = "boolean"
[[cli.affected.steps]]
type = "AffectedPackages"
inputs = ["format", "changed_paths", "label", "verify"]
Composition ideas
Evaluate and then print a custom summary
[cli.affected-report]
help_text = "Evaluate affected packages and print a custom summary"
[[cli.affected-report.inputs]]
name = "changed_paths"
type = "string_list"
required = true
[[cli.affected-report.steps]]
type = "AffectedPackages"
[[cli.affected-report.steps]]
type = "Command"
command = "echo affected status {{ affected.status }}: {{ affected.summary }}"
shell = true
Use it as a PR-only command
This step is often best kept in a dedicated CI command rather than bundled into normal release preparation. It answers a different question: “is the pull request policy-complete?” not “what should be released?”
Why choose it over a plain git diff script?
Because it reuses monochange’s own understanding of package paths, groups, ignored paths, additional paths, skip labels, and changeset coverage.
Common mistakes
- providing both
fromandchanged_pathsand forgettingfromwins - assuming this step prepares release state
- treating verification results as equivalent to a release plan
DiagnoseChangesets
What it does
DiagnoseChangesets inspects discovered changesets and reports how monochange interpreted them.
That includes parsed targets, notes, bump or version intent, provenance, and linked review metadata.
It is the inspection step you reach for when a changeset exists but you want to understand why monochange is treating it a certain way.
Why use it
Use DiagnoseChangesets when you need visibility into:
- which package or group targets a changeset resolved to
- what bump or explicit version monochange inferred
- which commit introduced or last updated the changeset
- which review request or linked issues were attached to it
- why a release note, policy decision, or provider comment included that changeset
This makes it especially useful for debugging rich release-note context and CI policy behavior.
Inputs
format:textorjsonchangeset: one or more explicit changeset paths; omit to inspect all discovered changesets
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. DiagnoseChangesets is standalone.
It does not require PrepareRelease, and it does not modify workspace state.
Side effects and outputs
DiagnoseChangesets is read-only.
It:
- produces a diagnostics report
- does not prepare release state
- does not edit files
- is useful both for human debugging and for machine-readable CI inspection
In practice:
- use
format = "text"for local debugging - use
format = "json"when another tool should consume the results
Example
[cli.diagnostics]
help_text = "Inspect changeset context and provenance"
[[cli.diagnostics.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.diagnostics.inputs]]
name = "changeset"
type = "string_list"
[[cli.diagnostics.steps]]
type = "DiagnoseChangesets"
inputs = ["format", "changeset"]
Composition ideas
Diagnose a targeted changeset set in CI
[cli.diagnostics-json]
help_text = "Inspect selected changesets as JSON"
[[cli.diagnostics-json.inputs]]
name = "changeset"
type = "string_list"
[[cli.diagnostics-json.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
default = "json"
[[cli.diagnostics-json.steps]]
type = "DiagnoseChangesets"
Use it as a maintainer support command
Many teams expose DiagnoseChangesets for maintainers only, because it shortens the time needed to explain:
- why a changeset rendered a certain note
- why it resolved to a package or group id
- why it linked to a certain review request or issue
Pair it with external tooling through Command
If you need custom summarization or uploads, run DiagnoseChangesets as JSON and keep that command separate from release planning. It is usually clearer to treat diagnosis as its own workflow instead of trying to hide it inside a release command.
Good fit / bad fit
Good fit:
- maintainer debugging commands
- CI jobs that inspect authored changesets without publishing anything
- support workflows where you need interpreted changeset context, not just raw markdown
Bad fit:
- commands that should mutate manifests, changelogs, or releases
- workflows that need
release.*state - cases where simply reading the markdown file is enough
Why choose it over opening the markdown file directly?
Because the raw file is only part of the picture.
DiagnoseChangesets shows the interpreted result after monochange resolves package ids, provenance, linked review metadata, and related issue context. That is usually the information you actually need when debugging release behavior.
Common mistakes
- expecting
DiagnoseChangesetsto modify anything - assuming it prepares release state for later publication steps
- using it when a simpler
monochange step validatefailure would already answer the question - forgetting to switch to
jsonoutput when another tool should consume the results
RetargetRelease
What it does
RetargetRelease repairs an already-recorded release.
It finds the release’s durable ReleaseRecord, plans a retarget operation, and then moves the release tag set to a later commit.
This is intentionally separate from PrepareRelease-driven steps. It works from git history and durable release metadata, not from newly prepared release state.
Why use it
Use RetargetRelease when the release already happened but the tags or hosted release state need to move.
It is a repair step, not a planning step.
Typical use cases include:
- a release commit landed, but tags must move to a later fix commit
- the hosted release should stay aligned with the corrected tag position
- a recent release needs to be repaired without generating a brand-new release plan
- a previous
CommitReleaseleft the durable release record you now want to reuse safely
Inputs
from: tag or commit-ish used to discover the release recordtarget: commit-ish to move the release to; defaults toHEADforce: allow non-descendant retargetssync_provider: whether hosted provider state should be synchronized
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None.
Unlike publication-oriented steps, RetargetRelease does not require PrepareRelease first.
Side effects and outputs
RetargetRelease is a stateful maintenance step.
It can:
- discover the release record from history
- plan and optionally execute tag movement
- optionally synchronize provider release state
- expose a rich
retarget.*namespace to laterCommandsteps
Commonly useful fields include:
retarget.fromretarget.targetretarget.record_commitretarget.resolved_from_commitretarget.distanceretarget.tagsretarget.provider_resultsretarget.status
In --dry-run mode, it reports the planned repair without mutating tags or provider state.
Safety model
RetargetRelease is designed to make repair explicit.
A few rules matter in practice:
- you identify the release to repair with
from - by default, the target is
HEAD - non-descendant repairs require
force = true - provider synchronization is optional and controlled with
sync_provider
That means you can start with a safe preview, confirm the proposed movement, and only then run the real repair.
Example
[cli.repair-release]
help_text = "Repair a recent release by retargeting its tags"
[[cli.repair-release.inputs]]
name = "from"
type = "string"
required = true
[[cli.repair-release.inputs]]
name = "target"
type = "string"
default = "HEAD"
[[cli.repair-release.inputs]]
name = "force"
type = "boolean"
default = "false"
[[cli.repair-release.inputs]]
name = "sync_provider"
type = "boolean"
default = "true"
[[cli.repair-release.steps]]
type = "RetargetRelease"
inputs = ["from", "target", "force", "sync_provider"]
Composition ideas
Repair and print a custom notification
[cli.repair-and-notify]
help_text = "Repair a release and print the retarget result"
[[cli.repair-and-notify.inputs]]
name = "from"
type = "string"
required = true
[[cli.repair-and-notify.inputs]]
name = "target"
type = "string"
default = "HEAD"
[[cli.repair-and-notify.steps]]
type = "RetargetRelease"
inputs = ["from", "target"]
[[cli.repair-and-notify.steps]]
type = "Command"
command = "echo moved {{ retarget.tags }} to {{ retarget.target }} with status {{ retarget.status }}"
shell = true
Use it in a dedicated maintenance command
RetargetRelease usually belongs in a maintenance-oriented command rather than a day-to-day release command.
It represents a different lifecycle phase: post-release repair.
Preview first, then perform the repair
A good operational pattern is:
- run the repair command with
--dry-run - inspect
retarget.status,retarget.tags, and the proposed target - rerun without
--dry-runonce the plan is correct
Good fit / bad fit
Good fit:
- release repair workflows
- operational commands owned by maintainers or release engineers
- commands that need structured
retarget.*output for notifications or audits
Bad fit:
- normal release publishing flows
- commands that should create a brand-new release plan
- situations where a simple patch release is the safer response
Why choose it over manually moving tags?
Because the built-in step repairs the release as a coherent unit based on the stored ReleaseRecord.
That means it can:
- find the release record from history
- reason about the release as monochange recorded it
- coordinate provider synchronization at the same time
- expose structured repair results to later steps
A manual tag move can change refs, but it does not preserve that workflow-level structure.
Common mistakes
Do not mix up RetargetRelease and PrepareRelease.
PrepareReleaseanswers: “what should be released now?”RetargetReleaseanswers: “how should an already-recorded release be repaired?”
Also avoid:
- skipping
--dry-runwhen the repair is high risk - using
forcewithout first understanding why the target is not a descendant - treating retargeting as a substitute for publishing a new patch release when a real follow-up release is more appropriate
PrepareRelease
What it does
PrepareRelease is the core release execution step.
It discovers packages, loads authored changesets, computes the release plan, updates manifests and changelogs, and prepares the structured release result that later steps can consume.
In other words: most release-oriented commands are really PrepareRelease plus something else.
Why use it
Use PrepareRelease whenever the command needs real release state.
It is the step that unlocks:
- release file updates
- changelog rendering
- release target calculation
- structured
release.*template context - the cached
.monochange/release-manifest.jsonartifact exposed asmanifest.path - later steps such as
CommitRelease,PublishRelease,OpenReleaseRequest, andCommentReleasedIssues
If your command eventually needs release metadata, start with PrepareRelease rather than trying to reconstruct that state in shell.
Inputs
format:markdown,text, orjsonwrite_empty_release_record: boolean; write a release record even when no packages were releasedrelease_json: boolean; write the release record (.monochange/releases/<hash>/release.json) during dry-run preview. By defaultmonochange previewskips the release record; pass--release-jsonto write it.
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. PrepareRelease is the producer step for the rest of the release workflow.
Side effects and outputs
PrepareRelease is stateful.
It can produce:
- updated manifests
- updated changelogs
- deleted or consumed changeset files
- release target information
- a cached release manifest at
.monochange/release-manifest.json - final command output in markdown, text, or JSON form
- structured
release.*template values for laterCommandsteps manifest.pathfor laterCommandsteps that need the on-disk JSON artifact
Built-in release-oriented commands default their human-readable format input to text. Use markdown for a raw Markdown artifact or json for automation.
When you only need the resolved package and group versions, use the dedicated DisplayVersions step or the built-in monochange versions command instead of overloading PrepareRelease.
It also fills the shorthand template values commonly used by Command steps:
{{ version }}{{ group_version }}{{ released_packages }}{{ changed_files }}{{ changesets }}
Example
[cli.release]
help_text = "Prepare a release from discovered change files"
[[cli.release.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.release.steps]]
type = "PrepareRelease"
inputs = ["format"]
Composition ideas
Prepare and run a custom follow-up command
[cli.release-with-notes]
help_text = "Prepare a release and print a custom summary"
[[cli.release-with-notes.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.release-with-notes.steps]]
type = "PrepareRelease"
inputs = ["format"]
[[cli.release-with-notes.steps]]
type = "Command"
command = "echo Releasing {{ release.version }} for {{ released_packages }}"
shell = true
Prepare and then branch into provider automation
Typical production commands look like:
PrepareRelease→PublishReleasePrepareRelease→OpenReleaseRequestPrepareRelease→CommitReleasePrepareRelease→Command
Prepare once, consume several outputs
Because the later steps all depend on the same prepared state, you should generally do one PrepareRelease and then fan out from it with several typed steps rather than trying to run several independent release commands.
Reuse prepared state across separate commands
When you do need to split the workflow across separate commands, monochange can reuse a prepared release artifact instead of recomputing the release plan from scratch.
When a repository defines workflow wrappers, the default cache path is automatic. For example, a repo with release and release-pr wrappers might run:
monochange step prepare-release
monochange step open-release-request --dry-run
The first PrepareRelease step stores prepared state in .monochange/local/prepared-release-cache.json, and later commands with a PrepareRelease step reuse it only while every input it was computed from still matches: the pending changeset bytes and set, package manifests and their ancestor workspace manifests, monochange.toml, prerelease state, release records, the git HEAD, and the workspace status. Because the changeset bytes are fingerprinted, editing a changeset’s severity or body in place — even when it is untracked or already dirty — replans instead of reusing the stale plan.
.monochange/local/ is the only directory under .monochange/ that should be gitignored. monochange adds it to .git/info/exclude automatically, so reusable prepared state, the cached release manifest, and other local release metadata do not pollute reviewable commits.
Never add .monochange/ as a whole to .gitignore. Release records under .monochange/releases/<id>/release.json and .monochange/prerelease-state.json are committed state that CommitRelease, publish-readiness, tag-release, and provider release automation read from git history. Ignoring them makes releases unpublishable.
If your configured workflow exposes a prepared_release input and you need to pass the artifact between explicit jobs or custom commands, wire that input to PrepareRelease and pass the artifact path:
monochange run release --prepared-release /tmp/release-plan.json
monochange step open-release-request --prepared-release /tmp/release-plan.json --format json
Here release and release-request are example workflow names; use the names shown by monochange help for your repository.
If the artifact is stale, monochange falls back to a fresh PrepareRelease run instead of trusting outdated release data.
Good fit / bad fit
Good fit:
- any release workflow
- commands that need release metadata
- commands that need changelog or version updates
Bad fit:
- simple validation-only CI gates
- discovery-only inspection commands
- post-release repair flows (
RetargetReleaseis separate)
Common mistakes
- putting
PublishReleaseorOpenReleaseRequestbeforePrepareRelease - assuming
PrepareReleaseis just a read-only planner in non-dry-run mode - forgetting that later
Commandsteps can consume its structured output directly - forgetting that
--quietsuppresses output but does not replace--dry-run
CommitRelease
What it does
CommitRelease turns an already prepared release into a local git commit.
The step uses monochange’s release-commit format and embeds a durable ReleaseRecord in the commit body. That record is what later powers release inspection and repair workflows such as monochange step release-record and monochange step retarget-release.
Think of it as the step that makes a prepared release durable in git history.
Why use it
Use CommitRelease when you want release planning and file updates to end in a reviewable, local commit before any provider-specific automation happens.
This is especially useful when you want to:
- create a durable release commit locally
- keep release history explicit in git rather than only in provider APIs
- open a release request from a known monochange-generated commit
- preserve the
ReleaseRecordneeded for later repair or inspection flows - hand off a prepared release to later custom
Commandsteps without reconstructing commit metadata yourself
Inputs
CommitRelease accepts one optional step-level boolean input:
| Input | Type | Default | Description |
|---|---|---|---|
update_release_json | boolean | false | When true, allows CommitRelease to create or overwrite the .monochange/releases/<id>/release.json record if it is missing or does not match the expected content. When false (the default), a missing or mismatched record is treated as an error. |
This input is useful when a previous step (such as PrepareRelease or a Command step that runs dprint fmt) may have modified the release record file, and you want CommitRelease to accept the regenerated content rather than fail with a mismatch error.
CommitRelease compares release records semantically (parsed JSON values), so formatting-only differences such as indentation or key ordering are ignored and never trigger a mismatch.
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
CommitRelease needs prepared release state.
You can provide that state in either of two ways:
- run a previous
PrepareReleasestep in the same command - reuse a saved prepared release artifact from
.monochange/local/prepared-release-cache.jsonor--prepared-release
CommitRelease is a consumer step. It does not plan a release on its own.
Side effects and outputs
In normal mode, CommitRelease creates a local commit.
In --dry-run mode, it previews the commit payload without creating the commit.
Before committing, CommitRelease validates the .monochange/releases/<id>/release.json record on disk. If the file exists, the step compares it against the expected content semantically (parsed JSON values), so formatting-only differences such as indentation or key ordering do not trigger a mismatch. If the file is missing or semantically different, the step either errors (default) or overwrites the file, depending on the update_release_json input.
The release record is committed state. Only .monochange/local/ may be gitignored, and an ignore rule that also matches .monochange/releases/ keeps the record out of git history, which breaks publish-readiness, tag-release, and provider release automation.
It exposes a structured release_commit.* namespace to later Command steps. Commonly useful fields include:
release_commit.subjectrelease_commit.bodyrelease_commit.commitrelease_commit.tracked_pathsrelease_commit.dry_runrelease_commit.status
Use those values when you want later steps to:
- print the release commit sha
- generate custom notifications
- attach commit metadata to CI artifacts
- feed the created commit into external tooling
Example
[cli.commit-release]
help_text = "Prepare a release and create a local release commit"
[[cli.commit-release.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.commit-release.steps]]
type = "PrepareRelease"
inputs = ["format"]
[[cli.commit-release.steps]]
type = "CommitRelease"
Composition ideas
Prepare, commit, then print commit metadata
[cli.commit-and-show]
help_text = "Prepare a release, create a commit, and print the commit sha"
[[cli.commit-and-show.steps]]
type = "PrepareRelease"
[[cli.commit-and-show.steps]]
type = "CommitRelease"
[[cli.commit-and-show.steps]]
type = "Command"
command = "echo release commit {{ release_commit.commit }}"
shell = true
Prepare, format, then commit with record overwrite
If a formatting tool such as dprint fmt runs between PrepareRelease and CommitRelease, it may change the whitespace or key ordering of the generated release.json. By default, CommitRelease would treat this as a mismatch and error. Set update_release_json = true to allow CommitRelease to overwrite the formatted file with the semantically-equivalent regenerated content:
[[cli.release-pr.steps]]
type = "PrepareRelease"
name = "prepare release"
[[cli.release-pr.steps]]
type = "Command"
name = "format changed files"
command = "dprint fmt --allow-no-files {{ changed_files }} .monochange/releases/"
[[cli.release-pr.steps]]
type = "CommitRelease"
name = "create release commit"
update_release_json = true
This pattern is the recommended way to combine automated formatting with release record durability.
Prepare, commit, then open a release request
A strong provider-facing pattern is:
PrepareReleaseCommitReleaseOpenReleaseRequest
That sequence keeps the release branch and provider request aligned with the durable release commit that monochange created.
Prepare once, then let custom tooling consume commit metadata
If your team has custom chat notifications, CI uploads, or deployment hooks, CommitRelease is a better producer than a hand-written git commit command because later steps can read structured release_commit.* values directly.
Good fit / bad fit
Good fit:
- release workflows that should leave behind a durable git record
- teams that want provider automation to begin from a known release commit
- workflows that may later need
RetargetRelease
Bad fit:
- validation or inspection-only commands
- workflows that do not prepare a release first
- commands where a plain custom shell commit is acceptable and no monochange release record is needed
Why choose it over a plain git commit command?
Because CommitRelease understands prepared release state and writes monochange’s ReleaseRecord contract for you.
A raw shell commit can create a commit, but it cannot automatically preserve the release metadata that later monochange repair and inspection features rely on unless you reimplement that format yourself.
Common mistakes
- treating
CommitReleaseas a replacement forPrepareRelease - assuming the cached
.monochange/release-manifest.jsonartifact must be committed forCommitReleaseto succeed - assuming it publishes releases or opens a release request by itself
- forgetting that
--dry-runpreviews the commit rather than creating it - reaching for a custom
git commitcommand and then losing durable release metadata - running a formatter (such as
dprint fmt) betweenPrepareReleaseandCommitReleasewithout settingupdate_release_json = trueon theCommitReleasestep
ReleaseRecord
What it does
ReleaseRecord inspects the monochange release record embedded in a release commit.
It resolves the supplied ref to a commit, walks first-parent ancestry until it finds a release-record block, and renders the recorded targets, package versions, changed files, changelogs, and release metadata.
Why use it
Use ReleaseRecord when you need to answer “what did monochange release from this commit or tag?” without re-planning from current workspace files.
It is especially useful for:
- debugging publication or tag automation after a release commit exists
- checking release metadata before tag repair or provider publication
- exporting the embedded record as JSON for external tooling
Inputs
from: required tag or commit-ish used to locate the release recordformat:textorjsonoutput, defaulting totext
Prerequisites
The selected ref, or one of its first-parent ancestors, must contain a valid monochange release record embedded by CommitRelease.
The record lives at .monochange/releases/<id>/release.json and must be committed. Only .monochange/local/ may be gitignored; ignoring the whole .monochange/ directory removes the record from git history and breaks ReleaseRecord, publish-readiness, and tag-release.
Side effects and outputs
ReleaseRecord is read-only. It fails loudly when a malformed release record block is found, because later tag and publish workflows depend on that record being trustworthy.
Example
monochange step release-record --from v1.2.3
monochange step release-record --from HEAD --format json
Use it before tag repair or package-publish planning when you need to confirm the exact release state that downstream commands will consume.
VerifyReleaseBranch
What it does
VerifyReleaseBranch checks that a git ref resolves to a commit reachable from one of the configured release branches.
The policy lives under [source.releases]:
[source.releases]
branches = ["main", "release/*"]
enforce_for_tags = true
enforce_for_publish = true
enforce_for_commit = false
branches accepts multiple branch names and glob patterns. The check uses commit reachability, so it also works in detached CI checkouts when the tag or HEAD commit is present in the repository history.
Inputs
from: git ref to verify. Defaults toHEAD.
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Example
[cli.verify-release-branch]
help_text = "Verify this checkout is on an allowed release branch"
[[cli.verify-release-branch.inputs]]
name = "from"
kind = "string"
default = "HEAD"
[[cli.verify-release-branch.steps]]
type = "VerifyReleaseBranch"
[cli.verify-release-branch.steps.inputs]
from = "{{ inputs.from }}"
Built-in enforcement
You usually do not need to add this step manually for protected release operations:
monochange step tag-releaseenforces[source.releases]whenenforce_for_tags = true.PublishReleaseandPublishPackagesenforce[source.releases]during real publish runs whenenforce_for_publish = true.CommitReleaseenforces[source.releases]only whenenforce_for_commit = true.
Use the explicit step when you want an early, standalone CI gate before other workflow work runs.
TagRelease
What it does
TagRelease creates the release tags declared by a monochange release record.
It reads the record embedded in the selected release commit, creates the full tag set from that record, and pushes tags to origin by default. Re-running it on the same commit is treated as already up to date.
Why use it
Use TagRelease when tag creation is separated from release-commit creation or when CI should make the tag side effect explicit.
It is especially useful for:
- publishing all package and group tags from one authoritative release record
- previewing tag names before creating or pushing them
- enforcing
[source.releases]branch policy before tags are created - keeping tag creation idempotent for retryable CI jobs
Inputs
from: required release commit refpush: boolean, defaulting totrue; set--push=falseto create tags locally without pushingdry_run: preview without creating or pushing tagsformat:textorjson, defaulting totext
Prerequisites
The resolved from ref must be the monochange release commit itself, not just a descendant that can find a release record by ancestry.
If [source.releases] sets enforce_for_tags = true, the release commit must satisfy the configured release-branch policy before tags are created.
Side effects and outputs
In normal mode, TagRelease creates local git tags and pushes them to origin unless push = false. In dry-run mode, it only previews the tags that would be created.
Do not use this step to repair an already-published tag set. Use RetargetRelease for explicit repair workflows.
Example
monochange step tag-release --from HEAD
monochange step tag-release --from HEAD --dry-run
monochange step tag-release --from HEAD --push=false
monochange step tag-release --from HEAD --dry-run --format json
In a release workflow, run it after CommitRelease has produced the release commit and after any branch-policy validation you want to perform explicitly.
PublishReadiness
What it does
PublishReadiness checks package-registry publishing readiness without publishing packages.
It reads package publications from a release commit, compares them with the current workspace configuration and target registries, and reports which packages are ready, already published, or unsupported by built-in publishing.
Why use it
Use PublishReadiness as a reviewable preflight before mutating registry state with package publishing.
It is especially useful for:
- CI jobs that should prove a release can publish before credentials are available
- human review of a package-publish plan
- generating a JSON readiness artifact for
PlanPublishRateLimitsin publish mode - resuming after partial registry publication, because already-published versions are reported as resumable instead of blocking
Inputs
from: required tag or commit-ish used to locate the release recordformat:text,markdown, orjson, defaulting totextpackage: optional repeated package ids used to restrict the reportoutput: optional path for a JSON readiness artifact
Prerequisites
PublishReadiness needs a release record from CommitRelease and any package-registry credentials or local tooling required to perform dry-run existence checks for the selected ecosystems.
Side effects and outputs
The step is read-only. It may contact registries for existence checks, but it does not publish package artifacts.
When output is set, monochange writes a JSON readiness artifact that includes the release record commit, selected packages, package-set fingerprint, publish input fingerprint, the dependency-corrected publish_order, per-package trusted-publishing findings, and order findings. Re-run readiness if workspace configuration, manifests, lockfiles, or registry/tooling files change after the artifact was written.
Trusted publishing checks
For every package with publish.trusted_publishing = true, the step verifies the configuration before anything is published:
- the GitHub trust context (repository, workflow, optional environment) resolves from
monochange.toml, the source configuration, or the CI environment - the referenced workflow file exists under
.github/workflows/ - the current environment can verify the CI/OIDC identity when a supported CI provider is detected
- the package exists on npm, crates.io, or pub.dev, because those registries only accept trusted publishing for existing packages; unpublished packages are blocked with guidance to run
monochange step placeholder-publishfirst
Findings are recorded per package as disabled, verified, manual_verification_required, or blocked. Registry-side trusted publisher entries cannot be read back without registry credentials, so existing packages surface as manual_verification_required with the registry setup URL instead of a hard block. Network lookup failures also stay non-blocking, keeping the step usable offline.
Publication order checks
The step validates the planned publish order against the workspace dependency graph, including dev-dependencies, and records it as publish_order in the artifact. A package scheduled before one of its workspace dependencies is a blocking order finding. A release record whose recorded publication order differs from the corrected plan is a non-blocking note, because package publishing follows the dependency-corrected order.
Example
monochange step publish-readiness --from HEAD
monochange step publish-readiness --from HEAD --output .monochange/local/readiness.json
monochange step publish-readiness --from v1.2.3 --package core --format json
A readiness-backed rate-limit plan can then consume the artifact:
monochange step plan-publish-rate-limits --mode publish --readiness .monochange/local/readiness.json
PlanPublishRateLimits
PlanPublishRateLimits inspects monochange’s built-in ecosystem rate-limit catalog and renders a publish schedule before any registry mutation happens.
Use it when you want to answer questions like:
- how many package publishes fit in one registry window
- which packages should be split into later batches
- whether a filtered package set is safe to publish now
- what GitHub Actions or GitLab CI batch snippet should drive the publish run
Inputs
format:text,markdown, orjsonmode:publish(default) orplaceholderpackage: optional repeated package ids used to filter the planreadiness: optional path to a JSON artifact frommonochange step publish-readiness; only valid whenmode = "publish"ci: optionalgithub-actionsorgitlab-cisnippet renderer
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Produces
A structured publish-rate-limit report containing:
- registry windows
- batch counts
- explicit package ids per batch
- evidence and confidence metadata for each built-in policy
- only versions that are still missing from their registries, so reruns reflect the remaining work
- when
readinessis provided, only package ids ready in both the artifact and the fresh local readiness check
Examples
Plan a normal publish
[cli.publish-plan]
help_text = "Plan package-registry publish work against known ecosystem rate limits"
[[cli.publish-plan.inputs]]
name = "format"
type = "choice"
default = "json"
choices = ["text", "markdown", "json"]
[[cli.publish-plan.inputs]]
name = "readiness"
type = "path"
help_text = "JSON artifact from monochange step publish-readiness; limits publish plans to ready package work"
[[cli.publish-plan.steps]]
name = "plan publish rate limits"
type = "PlanPublishRateLimits"
A readiness-backed plan validates the artifact header, release record commit, selected package coverage, package-set fingerprint, and publish input fingerprint before planning. The artifact may contain non-ready packages, but those package ids are excluded from the plan. Rerun monochange step publish-readiness if workspace config, package manifests, lockfiles, or registry/tooling files changed after the artifact was written. Placeholder plans reject readiness; use mode = "placeholder" without an artifact for first-time bootstrap planning.
Plan placeholder bootstrap publishing
[cli.placeholder-plan]
help_text = "Plan placeholder publishing batches"
[[cli.placeholder-plan.inputs]]
name = "mode"
type = "choice"
default = "placeholder"
choices = ["publish", "placeholder"]
[[cli.placeholder-plan.steps]]
name = "plan placeholder publish rate limits"
type = "PlanPublishRateLimits"
inputs = { mode = "placeholder" }
Render a GitHub Actions snippet
[cli.publish-plan-github]
help_text = "Render a GitHub Actions batch snippet from the publish plan"
[[cli.publish-plan-github.steps]]
name = "plan publish rate limits"
type = "PlanPublishRateLimits"
inputs = { ci = "github-actions" }
Notes
PlanPublishRateLimits is advisory by default. Built-in publish commands only become blocking when matching packages enable publish.rate_limits.enforce = true.
The step checks the target registries before counting pending work, so already-published versions and placeholder packages that already exist do not inflate the batch plan.
PublishRelease
What it does
PublishRelease converts a prepared release into hosted provider release operations.
For example, with a configured source provider it can create or update the outward release objects that correspond to monochange’s prepared release targets.
It does not publish package artifacts to registries. Package publishing lives in monochange step publish-packages, monochange step publish-readiness, and monochange step placeholder-publish.
Why use it
Use PublishRelease when you want monochange to handle provider-aware publication rather than stitching together release API calls manually.
That gives you:
- one publication step for grouped and package-owned releases
- dry-run previews that stay aligned with the prepared release state
- a typed boundary between planning and provider mutation
- source-provider integration driven by the same manifest and release target model as the rest of monochange
Use monochange step publish-packages instead when you want monochange to run cargo publish, pnpm publish, dart pub publish, flutter pub publish, or deno publish style package-registry commands. Run monochange step publish-readiness --from HEAD --output <path> first only when you want a reviewable preflight report.
Inputs
format:textorjson
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
- a previous
PrepareReleasestep in the same command [source]configuration
Side effects and outputs
- in dry-run mode, builds preview release requests
- in normal mode, creates or updates provider releases
- contributes release request/result data to the command’s final output
Example
[cli.publish-release]
help_text = "Prepare a release and publish hosted releases"
[[cli.publish-release.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.publish-release.steps]]
type = "PrepareRelease"
inputs = ["format"]
[[cli.publish-release.steps]]
type = "PublishRelease"
inputs = ["format"]
Composition ideas
Publish and then comment on linked issues
[cli.publish-and-comment]
help_text = "Publish a release and comment on linked issues"
[[cli.publish-and-comment.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.publish-and-comment.steps]]
type = "PrepareRelease"
inputs = ["format"]
[[cli.publish-and-comment.steps]]
type = "PublishRelease"
inputs = ["format"]
[[cli.publish-and-comment.steps]]
type = "CommentReleasedIssues"
This is one of the clearest examples of composition: PublishRelease performs outward release publication, and CommentReleasedIssues performs the follow-up communication step.
Prepare, publish, then notify external systems
[cli.publish-and-notify]
help_text = "Prepare, publish, and notify another system"
[[cli.publish-and-notify.steps]]
type = "PrepareRelease"
[[cli.publish-and-notify.steps]]
type = "PublishRelease"
[[cli.publish-and-notify.steps]]
type = "Command"
command = "echo published {{ release.version }}"
shell = true
Why choose it over a raw Command step?
Because PublishRelease understands monochange release targets, provider settings, and dry-run behavior. A hand-written shell command would need to rebuild all of that context.
Common mistake
Do not treat PublishRelease as either a planning step or a package-registry publish step. It is the hosted/provider mutation step after planning is already complete.
OpenReleaseRequest
What it does
OpenReleaseRequest turns a prepared release into a hosted release request, such as a release pull request.
It uses the prepared release state to build branch names, commit descriptions, and request bodies that correspond to the exact release content monochange prepared.
Why use it
Use OpenReleaseRequest when you want a reviewable, provider-hosted release flow before publication.
This is a strong fit when your release process includes:
- opening or updating a release PR for human review
- staging release artifacts on a branch before merge
- reusing monochange’s structured release data in the request body
Inputs
format:textorjson
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
- a previous
PrepareReleasestep in the same command [source]configuration
Side effects and outputs
- in dry-run mode, previews the request payload
- in normal mode, performs git and provider operations needed to open or update the request
- contributes release-request data to the command result
Release request body bounding
The release request body is the rendered release notes for every outward release target, and every provider bounds it so a long-lived release pull request cannot grow past its own limit.
[source.pull_requests].body_style = "full"(the default) inlines the notes for every target, capped at the provider limit.[source.pull_requests].body_style = "summary"renders only the prepared-release header, the outward target list, and the changelog paths, which keeps the body small when a release pull request stays open for a long time.[source.pull_requests].max_body_charsoverrides the provider limit. Without it, GitHub is capped at 65536 characters, because GitHub rejects a larger body when it creates a pull request. GitLab, Gitea, and Forgejo document no comparable limit and stay unbounded unless you set one.
When the notes do not fit, entries are dropped from the end of the body, a pointer to the changelog files replaces them, and the step reports how many entries were dropped. The complete notes are always written to changelog.md, the per-package changelogs, the hosted release body, and every configured changelog output, so a shortened body never loses notes from anywhere a reader looks for them.
GitHub Actions verified commit behavior
When OpenReleaseRequest publishes a GitHub release pull request in normal mode, monochange first uses local git as the durable fallback path: it checks out the release branch, stages the tracked release files, creates the release commit, and pushes that branch before opening or updating the pull request.
When [source.pull_requests].verified_commits = true and the command is running inside GitHub Actions for the same repository as [source], the GitHub provider then tries to replace that pushed fallback commit with a GitHub-verified commit:
- It builds the GitHub API client from
GITHUB_TOKENorGH_TOKEN. - It reads the pushed fallback commit through the Git Database API.
- It creates a new Git commit object with the same message, tree, and parents.
- It accepts the replacement only when GitHub returns
verification.verified = truefor the new commit. - It confirms the release branch still points at the fallback commit, then moves the branch ref to the verified commit.
Verified commit replacement is opt-in and defaults to off. Any failure keeps the original pushed git commit in place. That includes missing tokens, non-GitHub Actions environments, repository mismatches, GitHub returning an unverified commit, API errors, or the release branch moving between the fallback push and the ref update. The fallback is intentional: release PR automation should keep working even when verified commit replacement is unavailable.
Dry runs never create commits, push branches, or call the provider APIs. Non-GitHub providers continue to use their normal release-request behavior.
Example
[cli.release-pr]
help_text = "Prepare a release and open or update a release request"
[[cli.release-pr.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.release-pr.steps]]
type = "PrepareRelease"
inputs = ["format"]
[[cli.release-pr.steps]]
type = "OpenReleaseRequest"
inputs = ["format"]
Composition ideas
Prepare, commit, then open a release request
[cli.release-pr-from-commit]
help_text = "Prepare a release, create the release commit, and open a release PR"
[[cli.release-pr-from-commit.steps]]
type = "PrepareRelease"
[[cli.release-pr-from-commit.steps]]
type = "CommitRelease"
[[cli.release-pr-from-commit.steps]]
type = "OpenReleaseRequest"
Open a request and run an extra notification step
[cli.release-pr-notify]
help_text = "Open a release request and notify another system"
[[cli.release-pr-notify.steps]]
type = "PrepareRelease"
[[cli.release-pr-notify.steps]]
type = "OpenReleaseRequest"
[[cli.release-pr-notify.steps]]
type = "Command"
command = "echo opened release request for {{ release.version }}"
shell = true
Why choose it over a custom git + provider script?
Because OpenReleaseRequest already knows:
- which release targets were prepared
- which files changed
- how monochange wants release requests described
- how dry-run should behave
Common mistake
Do not assume OpenReleaseRequest can infer a release on its own. It is not a replacement for PrepareRelease.
CommentReleasedIssues
What it does
CommentReleasedIssues uses prepared release context to comment on issues linked from the release’s changeset and review metadata.
It is a post-publication communication step, not a planning step.
Why use it
Use CommentReleasedIssues when you want monochange to close the loop after publication by posting structured release follow-up comments.
This is especially valuable when:
- issues are part of the public release workflow
- you want issue comments to stay tied to the exact prepared release data
- you want a dry-run preview before touching hosted issue state
Inputs
format:textorjsonfrom-ref: git ref that contains the release record to publishauto-close-issues: close issues that the release review requests claim via closing keywords after adding the release comment
Issue closure
With auto-close-issues enabled, only issues referenced through closing keywords (Closes #7, Fixes #8, …) in the release review request bodies are closed, and closure is attempted even when the issue is already closed, because hosted forges only auto-close the first issue of a comma-separated Closes #7, #8 list. Issues that are merely mentioned without a closing keyword are never closed; add a closing keyword to the release pull request body when a mention should close with the release.
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
- a previous
PrepareReleasestep in the same command [source].provider = "github"
Side effects and outputs
- builds issue comment plans from prepared release context
- in dry-run mode, previews which issues would be touched
- in normal mode, creates or skips comments based on provider state
Example
[cli.publish-and-comment]
help_text = "Publish a release and comment on linked issues"
[[cli.publish-and-comment.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.publish-and-comment.steps]]
type = "PrepareRelease"
inputs = ["format"]
[[cli.publish-and-comment.steps]]
type = "PublishRelease"
inputs = ["format"]
[[cli.publish-and-comment.steps]]
type = "CommentReleasedIssues"
Composition ideas
Publish first, then comment
The most common and most sensible sequence is:
PrepareReleasePublishReleaseCommentReleasedIssues
That ordering reflects the real-world intent: only comment after the release event exists.
Comment and then run a reporting step
[cli.publish-comment-report]
help_text = "Publish a release, comment on issues, and print a short report"
[[cli.publish-comment-report.steps]]
type = "PrepareRelease"
[[cli.publish-comment-report.steps]]
type = "PublishRelease"
[[cli.publish-comment-report.steps]]
type = "CommentReleasedIssues"
[[cli.publish-comment-report.steps]]
type = "Command"
command = "echo issue comments processed for {{ release.version }}"
shell = true
Why choose it over a custom GitHub API script?
Because the built-in step already consumes monochange’s linked issue and review metadata model. A shell script would need to rediscover which issues matter for the release.
Common mistake
Using CommentReleasedIssues without a GitHub source configuration. This step is intentionally provider-specific.
Command
What it does
Command runs an arbitrary program or shell command from a monochange workflow.
This is the escape hatch step that lets you combine monochange’s structured state with the rest of your toolchain.
Why use it
Use Command when you need to:
- run project-specific tooling that monochange does not own
- upload artifacts
- call deployment, chat, or notification tools
- bridge monochange release context into custom scripts
- compose outputs from earlier steps into external automation
The important design rule is this:
prefer a built-in step whenever monochange already has a first-class semantic for the work.
Use Command for what is truly custom.
Core fields
command: the command to run in normal modewhen: optional boolean condition controlling whether the step runsdry_run_command: optional replacement command used only when the command runs with--dry-runshell: whether to run through a shell (true,false, or a custom shell binary name)id: optional identifier that exposessteps.<id>.stdoutandsteps.<id>.stderrto later stepsvariables: optional custom variable mapping for command substitutioninputs: optional step-local input overridesshow_progress: optional boolean; set tofalsewhen the command itself is interactive and spinner output would get in the wayinteractive: optional step input; when set totruethe step runs with inherited stdio so the command can own the terminal, and the progress spinner is suppressed for the step. This is the recommended way to run interactive tools such as wizards or TUIs; unlikeshow_progress = false, the command can read from stdin and render directly to the terminal. Output is not captured, sosteps.<id>.stdoutandsteps.<id>.stderrare empty for interactive steps
Example: publish through a configured workflow while keeping an interactive terminal available for prompts. Declare the boolean input, pass it to the step, and set it with --interactive when you run locally; CI stays non-interactive by leaving it unset.
[cli.publish]
help_text = "Publish packages"
[[cli.publish.inputs]]
name = "interactive"
type = "boolean"
default = false
[[cli.publish.steps]]
name = "publish"
type = "Command"
command = "npm publish"
inputs = ["interactive"]
# local run with the terminal handed to the command
monochange run publish --interactive
# CI run without a terminal
monochange run publish
always_run: optional boolean; set totrueto run this step even when a previous step has failed
Prerequisites
Command itself has no built-in prerequisite.
What it can see depends on where you place it:
- after
PrepareRelease, it can consumerelease.*andmanifest.path - after
AffectedPackages, it can consumeaffected.* - after
RetargetRelease, it can consumeretarget.* - after
CommitRelease, it can consumerelease_commit.* - after another named
Command, it can consumesteps.<id>.*
Side effects and outputs
- runs an external command
- records stdout/stderr when
idis present - can act as a consumer or producer step in a workflow chain
Example
[cli.test]
help_text = "Run project tests"
[[cli.test.steps]]
type = "Command"
command = "cargo test --workspace --all-features"
dry_run_command = "cargo test --workspace --all-features --no-run"
shell = true
Composition ideas
Consume prepared release context
[cli.release-with-notes]
help_text = "Prepare a release and print a custom summary"
[[cli.release-with-notes.inputs]]
name = "format"
type = "choice"
choices = ["text", "json", "json-min"]
default = "text"
[[cli.release-with-notes.steps]]
type = "PrepareRelease"
inputs = ["format"]
[[cli.release-with-notes.steps]]
type = "Command"
command = "echo Releasing {{ release.version }} for {{ released_packages }}"
shell = true
Reuse earlier command output
[cli.release-with-generated-notes]
help_text = "Prepare a release, generate notes, and upload them"
[[cli.release-with-generated-notes.steps]]
type = "PrepareRelease"
[[cli.release-with-generated-notes.steps]]
type = "Command"
id = "notes"
command = "printf 'version=%s\n' '{{ release.version }}'"
shell = true
[[cli.release-with-generated-notes.steps]]
type = "Command"
command = "printf '%s\n' '{{ steps.notes.stdout }}'"
shell = true
Consume repair state
[cli.repair-and-notify]
help_text = "Repair a release and print the retarget result"
[[cli.repair-and-notify.inputs]]
name = "from"
type = "string"
required = true
[[cli.repair-and-notify.inputs]]
name = "target"
type = "string"
default = "HEAD"
[[cli.repair-and-notify.steps]]
type = "RetargetRelease"
inputs = ["from", "target"]
[[cli.repair-and-notify.steps]]
type = "Command"
command = "echo moved {{ retarget.tags }} to {{ retarget.target }} with status {{ retarget.status }}"
shell = true
Why choose Command carefully?
Because it is powerful enough to bypass monochange’s typed guarantees.
That is useful, but it also means:
- validation cannot reason deeply about your command string
- provider-aware dry-run semantics are now partly your responsibility
- shell quoting and portability become part of the workflow design
Recommended usage pattern
A good workflow usually looks like this:
- use built-in steps to create stable state
- use
Commandonly for the final custom integration points - give important custom steps an
idso later steps can consume structured stdout
Common mistakes
- using
Commandto reimplementPublishReleaseorOpenReleaseRequest - forgetting
dry_run_commandwhen the real command would mutate external systems - omitting
idand then having no clean way to reuse the command’s output later - relying on shell features without setting
shell = trueor a custom shell name
DisplayVersions
What it does
DisplayVersions computes monochange’s planned package and group versions and renders only that summary.
Use it when you want the release-version answer without the rest of the release preview.
Why use it
Use DisplayVersions when you want a dedicated read-only command such as monochange next.
The built-in monochange next command (and its monochange next-versions alias) runs this step, prints the planned group and package versions, and writes nothing.
It is the best fit for:
- CI or local scripts that only need the planned version map
- release dashboards or follow-up tooling that want compact JSON
- human-readable summaries without release targets, changed files, or changelog previews
- answering “what version comes next” without preparing a release
Use monochange versions list instead when you want the versions recorded in the workspace today rather than the planned next versions.
Inputs
format:text,markdown, orjson
Empty changesets
An empty .changeset directory reports no package or group versions were planned and exits successfully instead of failing, so the step is safe to run between releases.
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. DisplayVersions is standalone.
Side effects and outputs
DisplayVersions is read-only.
It:
- computes the same planned package and group versions used by monochange release workflows
- renders a compact summary in
text,markdown, orjson - does not update manifests, changelogs, or consumed changesets
- does not write
release.jsonor the prepared-release cache under.monochange/local/ - does not require a previous
PrepareReleasestep
Example
[cli.versions]
help_text = "Display planned package and group versions"
[[cli.versions.inputs]]
name = "format"
type = "choice"
choices = ["text", "markdown", "json"]
default = "text"
[[cli.versions.steps]]
name = "display versions"
type = "DisplayVersions"
Composition ideas
Run the display step directly
monochange next
monochange next --format json
monochange step display-versions
monochange step display-versions --format markdown
monochange step display-versions --format json
Keep release preparation and version display separate
Use DisplayVersions when you only need the version summary. Use PrepareRelease when you also need release file updates, release targets, manifest artifacts, or later release-oriented steps.
Common mistakes
- expecting it to update release files
- treating it as a replacement for
PrepareReleasein publish or release-request workflows - bundling it into long multi-step commands when
monochange nextis clearer - confusing it with
monochange versions list, which reports current versions rather than planned next versions
PlaceholderPublish
What it does
PlaceholderPublish publishes minimal 0.0.0 placeholder versions for packages that do not yet exist in their target registries.
This is useful when you need to:
- reserve a package name before the first real release
- enable registry automation (such as trusted publishing or downstream dependency resolution) that requires the package to already be present
- bootstrap a new package into a registry so that later
PublishPackagescan update it with a real version
The step inspects each package’s publish configuration, checks the registry to see if the package already exists, and only attempts to publish when the package is missing.
Why use it
Use PlaceholderPublish when you want monochange to handle the initial registry bootstrap rather than running manual publish commands.
That gives you:
- automatic registry detection (the step skips packages that already exist)
- ecosystem-aware publish commands (
cargo publish,npm publish,dart pub publish,deno publish, and so on) - rate-limit planning before any mutation happens
- dry-run previews that show what would be published without touching registries
- structured
publish.*template context for laterCommandsteps
Use PublishPackages instead when you want to publish the real planned versions from a prepared release.
Inputs
format:text,markdown, orjsonpackage: optional repeated package ids used to filter the publish set
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. PlaceholderPublish does not require a previous PrepareRelease step.
Interaction with publish.mode
Placeholder publishing works for all packages with publish.enabled = true, regardless of publish.mode.
Setting publish.mode = "external" tells monochange not to run built-in release publishing for a package. Your own CI or scripts handle real version publishes. This is the right choice for packages published with melos publish, pnpm publish, or other external tools.
But placeholder publishing is a separate concern: it exists to bootstrap packages into their registries before any release, including before trusted publishing setup. That bootstrap step is useful regardless of who handles the real release publishing. So PlaceholderPublish always processes packages with publish.enabled = true, even when publish.mode = "external".
The only way to opt out of placeholder publishing is publish.enabled = false.
Side effects and outputs
- in dry-run mode, plans and previews placeholder publish operations without touching registries
- in normal mode, publishes
0.0.0placeholder versions for missing packages - starts with the outcome and the placeholder packages published or planned, followed by explicit per-status counts
- keeps already-existing package rows, trusted-publishing metadata, commands, and captured output behind
--show-all - returns every package row when
--format jsonis selected, regardless of--show-all - contributes
publish.*andpublish_rate_limits.*template context to the command result
Example
[cli.placeholder-publish]
help_text = "Publish placeholder package versions for missing registry packages"
[[cli.placeholder-publish.inputs]]
name = "format"
type = "choice"
choices = ["text", "markdown", "json"]
default = "text"
[[cli.placeholder-publish.inputs]]
name = "package"
type = "string_list"
[[cli.placeholder-publish.inputs]]
name = "show-all"
type = "boolean"
help_text = "Include unchanged and skipped package details"
[[cli.placeholder-publish.steps]]
name = "publish placeholder packages"
type = "PlaceholderPublish"
inputs = ["format", "package", "show-all"]
Composition ideas
Plan placeholder publishing before running it
[cli.placeholder-plan]
help_text = "Plan and preview placeholder publishing"
[[cli.placeholder-plan.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
default = "text"
[[cli.placeholder-plan.steps]]
name = "plan publish rate limits"
type = "PlanPublishRateLimits"
inputs = { mode = "placeholder" }
[[cli.placeholder-plan.steps]]
name = "publish placeholder packages"
type = "PlaceholderPublish"
Placeholder publish as part of a CI bootstrapping command
[ci.bootstrap]
help_text = "Reserve package names for new packages"
[[ci.bootstrap.steps]]
type = "PlaceholderPublish"
[[ci.bootstrap.steps]]
type = "Command"
command = "echo placeholder publish completed for {{ publish.packages }}"
shell = true
Why choose it over a raw Command step?
Because PlaceholderPublish understands:
- which packages are configured for publish
- which registries each ecosystem targets
- whether a package already exists (and should be skipped)
- ecosystem-specific publish commands and flags
- rate-limit planning across registries
- dry-run behavior for safe CI previews
Common mistakes
- confusing
PlaceholderPublishwithPublishPackages: the former publishes0.0.0placeholders, the latter publishes the real planned versions - forgetting that
PlaceholderPublishdoes not requirePrepareRelease, butPublishPackagesdoes - expecting placeholder versions to be updated automatically: placeholder publish is a one-time bootstrap step
PublishPackages
What it does
PublishPackages publishes package versions to their target registries using monochange’s built-in ecosystem workflows.
The step derives publish work from durable monochange release state: a prepared release artifact when the command has one, or the release record discoverable from HEAD otherwise. It does not require a readiness artifact. Before publishing, it orders selected package publications by internal publish-relevant dependencies so dependencies are attempted before dependents. Runtime, build, peer, workspace, and unknown dependency kinds participate in ordering and cycle validation; development-only dependency cycles are ignored.
For each package with a planned release version, the step:
- resolves the registry from the package’s publish configuration
- validates publish-relevant dependency cycles before registry mutation
- publishes dependencies before dependents within the selected publish set
- checks whether the version already exists (skipping if it does)
- plans against registry rate limits before attempting any mutation
- runs the ecosystem-specific publish command (
cargo publish,npm publish,dart pub publish,flutter pub publish,deno publish, and so on) - produces a structured report of what was published, skipped, or planned
You can filter the publish set with the package input, or use an empty set to publish everything from the selected release state.
Publication order
Package publication order is dependency-aware. monochange publishes packages with no selected dependencies first, then publishes packages that depend on those packages, walking up the dependency tree until packages that depend on the most selected packages are published last.
The order is computed like this:
- Build the selected publish requests from the prepared release or
HEADrelease state. - Materialize the workspace dependency graph.
- Consider only dependencies where both packages are part of the selected publish set.
- Ignore development dependency edges.
- Topologically sort the publish requests so dependencies are emitted before dependents.
For example, with this internal package graph:
core # no dependencies
utils # depends on core
api # depends on utils
app # depends on core, utils, api
monochange publishes in this order:
core
utils
api
app
If multiple packages are independent at the same depth, their order is deterministic by package id, registry, and version.
A package with no selected dependencies is eligible first. A package is not published until all of its selected publish-relevant dependencies have been ordered before it. Dependencies outside the selected publish set do not block ordering. Development-only cycles are ignored. Runtime, build, peer, workspace, and unknown dependency cycles fail before publishing anything, with a cycle diagnostic.
Why use it
Use PublishPackages when you want monochange to handle the full package-registry publication workflow rather than scripting individual publish commands.
That gives you:
- one publish step for all supported ecosystems
- automatic dependency ordering across internal package publications
- publish-relevant cycle detection before registry mutation
- automatic rate-limit planning and enforcement
- version-existence checks that prevent duplicate publish attempts
- dry-run previews that show the full publish plan without touching registries
- structured
publish.*template context for laterCommandsteps
Use PlaceholderPublish instead when you need to bootstrap a package that does not yet exist in its registry with a minimal 0.0.0 placeholder.
Inputs
format:text,markdown, orjsonpackage: optional repeated package ids used to filter the publish setgroup: optional repeated group ids; all packages in each group are added to the publish setecosystem: optional repeated ecosystem names (cargo,npm,deno,dart,python,go;flutteris accepted as a legacy alias fordart); only packages targeting the selected ecosystems are publishedresume: optional path to a JSON result artifact from an earlier realmonochange step publish-packagesor configured publish workflow run; completed package versions are skipped and failed or pending work is retriedoutput: optional path where monochange writes the package publish result JSON artifact for retry/resume workflowsshow-all: include every package’s status, trusted-publishing metadata, command, and captured output in human-readable results
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
- a prepared release artifact or a release record discoverable from
HEADthat contains the package publication targets - no cycles among selected publish-relevant internal dependencies; development-only cycles are allowed
- for built-in Cargo publishes to crates.io, a publishable current
Cargo.toml: nopublish = false, anypublish = [...]list includescrates-io,descriptionis set, and eitherlicenseorlicense-fileis set; workspace-inherited values are accepted
Side effects and outputs
- in dry-run mode, plans and previews publish operations without touching registries
- in normal mode, validates release-branch policy and publish-relevant dependency cycles, then publishes package versions to their configured registries
- starts human-readable output with the outcome and the package versions published or planned, followed by explicit per-status counts
- keeps already-existing and external package rows, trusted-publishing metadata, commands, and captured output behind
--show-all - returns every package row when
--format jsonis selected, regardless of--show-all - when
outputis set, writes the package publish result artifact even if a registry publish command fails, then exits non-zero for failed package outcomes - contributes
publish.*andpublish_rate_limits.*template context to the command result
Example
[cli.publish]
help_text = "Publish package versions from monochange release state using built-in workflows"
[[cli.publish.inputs]]
name = "format"
type = "choice"
choices = ["text", "markdown", "json"]
default = "text"
[[cli.publish.inputs]]
name = "package"
type = "string_list"
[[cli.publish.inputs]]
name = "group"
type = "string_list"
help_text = "Group ids whose member packages should be published"
[[cli.publish.inputs]]
name = "ecosystem"
type = "string_list"
help_text = "Ecosystems to publish (cargo, npm, deno, dart/flutter, python, go)"
[[cli.publish.inputs]]
name = "resume"
type = "path"
help_text = "JSON result artifact from an earlier package publish run; completed package versions are skipped"
[[cli.publish.inputs]]
name = "output"
type = "path"
help_text = "Write the package publish result JSON artifact for retry/resume"
[[cli.publish.inputs]]
name = "show-all"
type = "boolean"
help_text = "Include unchanged and skipped package details"
[[cli.publish.steps]]
name = "publish packages"
type = "PublishPackages"
inputs = ["format", "package", "group", "ecosystem", "resume", "output", "show-all"]
Composition ideas
Preview readiness before publishing
Use monochange step publish-readiness when you want a reviewable preflight report, then publish directly from the same release state:
monochange step publish-readiness --from HEAD --output .monochange/readiness.json
monochange step publish-packages --output .monochange/publish-result.json
The readiness artifact is informational for PublishPackages; it is not required by monochange step publish-packages. If a real publish fails after writing .monochange/publish-result.json, fix the registry/auth issue and rerun with monochange step publish-packages --resume .monochange/publish-result.json --output .monochange/publish-result.json.
Publish only a specific package
[cli.publish-core]
help_text = "Publish a specific package"
[[cli.publish-core.inputs]]
name = "package"
type = "string_list"
required = true
[[cli.publish-core.steps]]
name = "publish packages"
type = "PublishPackages"
Publish with rate-limit planning
[cli.publish-planned]
help_text = "Plan and publish with rate-limit awareness"
[[cli.publish-planned.steps]]
name = "plan publish rate limits"
type = "PlanPublishRateLimits"
[[cli.publish-planned.steps]]
name = "publish packages"
type = "PublishPackages"
Why choose it over a raw Command step?
Because PublishPackages understands:
- which packages were planned for release
- which ecosystem and registry each package targets
- which selected internal packages must publish before others
- whether publish-relevant dependency cycles would make a safe order impossible
- whether a version already exists (and should be skipped)
- ecosystem-specific publish commands, flags, and auth patterns
- rate-limit planning across registries
- dry-run behavior for safe CI previews
- trusted publishing setup and configuration
Interaction with publish.mode
Packages with publish.mode = "external" are skipped by PublishPackages. If your CI or scripts handle publishing for a package, set mode = "external" to tell monochange not to publish that package during release publishing.
Placeholder publishing (PlaceholderPublish) is not affected by publish.mode. It processes all packages with publish.enabled = true regardless of mode. This is because placeholder publishing is a one-time bootstrap step, not a release publishing step.
Common mistakes
- confusing
PublishPackageswithPublishRelease: the former publishes to package registries, the latter creates hosted provider releases (such as GitHub releases) - assuming
monochange step publish-packagesconsumes the JSON file frommonochange step publish-readiness; use readiness for preflight review ormonochange step plan-publish-rate-limits --readiness, not as aPublishPackagesinput - omitting
outputin CI, which makes partial registry failures harder to resume safely - expecting development-only dependency cycles to block publishing; only publish-relevant dependency kinds participate in cycle validation
- running
PublishPackageswithout rate-limit planning: usePlanPublishRateLimitsfirst when you are unsure about registry windows