Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

Publishing

Recent monochange improvements made package publishing guidance and diagnostics much more actionable:

  • a dedicated trusted-publishing guide covers npm, crates.io, jsr, and pub.dev
  • CI examples prefer the official registry-maintained workflows for crates.io and pub.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-check as a dry-run PublishPackages workflow 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.

GoalCommandUse it when
Validate config and changesetsmonochange step validateYou changed monochange.toml or .changeset/*.md files
Inspect package ids and groupsmonochange step discover --format jsonYou need the normalized workspace model
Sync internal dependency rangesmonochange versions --dry-runYou want internal dependency references to match canonical workspace package versions
Check the next versionmonochange nextYou want the next release group and package versions from pending changesets, without writing any release state
Create release intentmonochange run change --package <id> --bump <severity> --reason "..."You need a new .changeset/*.md file
Audit pending release contextmonochange step diagnose-changesets --format jsonYou need git provenance, PR/MR links, or related issues
Preview the release planmonochange run release --dry-run --diff or monochange step prepare-release --dry-runYou want changelog/version patches without mutating the repo
Create a durable release commitmonochange step commit-releaseYou want a monochange-managed release commit with an embedded ReleaseRecord
Open or update a release requestmonochange step open-release-requestYou want a long-lived release PR/MR branch updated from current release state
Inspect a past release commitmonochange step release-record --from <ref>You need the durable release declaration from git history
Check package publish readinessmonochange publish readiness --from HEAD --output <path>You want a non-mutating preflight report before package publication
Dry-run configured publishingmonochange run publish-checkThis repository, or another repo with a similar [cli.publish-check], should exercise publishing in CI without registry mutations
Plan ready package publishingmonochange step plan-publish-rate-limits --readiness <path>You want rate-limit batches that exclude non-ready package work
Publish packages to registriesmonochange publish packages --output <path>You want cargo publish, npm publish, deno publish, or dart pub publish style package publication
Bootstrap release packagesmonochange publish placeholderYou need a release-record-scoped placeholder bootstrap artifact before rerunning readiness
Create post-merge release tagsmonochange step tag-release --from HEADYou merged a monochange release commit and now need to create and push its declared tag set
Repair a recent releasemonochange step retarget-release --from <tag> --target <commit>You need to retarget a just-created release to a later commit
Publish hosted/provider releasesmonochange step publish-releaseYou 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-changesets for 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/cli and 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 fileDiffs previews 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.toml with monochange 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:

  1. monochange discovers packages.
  2. You author explicit changes against package ids.
  3. monochange propagates dependent bumps for you.
  4. 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 init says a config already exists: keep the existing monochange.toml and continue with monochange step validate, or pass --force to regenerate.
  • monochange step validate reports problems: fix the reported config or changeset issue, then rerun monochange step validate.
  • monochange run change rejects your target: rerun monochange step discover --format json and copy a valid package id.
  • You are not sure what to do next: continue with Your first release plan.

Next steps

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 agents
  • REFERENCE.md: broader high-context reference with more examples
  • skills/README.md: index of focused deep dives
  • skills/adoption.md: setup-depth questions, migration guidance, and recommendation patterns
  • skills/change-classification.md: release-aware severity decisions, uncertainty, and ecosystem review
  • skills/changesets.md: changeset authoring and lifecycle guidance
  • skills/commands.md: built-in command catalog and workflow selection
  • skills/configuration.md: monochange.toml setup and editing guidance
  • skills/linting.md: [lints] presets, monochange check, and manifest-focused examples
  • examples/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:

  1. Configures the [source] section - adds provider-specific settings for releases and pull/merge requests
  2. Generates provider CLI commands - includes commit-release and release-pr commands in monochange.toml
  3. Creates workflow files (GitHub only) - writes .github/workflows/release.yml and .github/workflows/changeset-policy.yml
  4. Auto-detects owner/repo - parses git remote get-url origin to 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.yml refreshes the release PR on normal main pushes, then tags and publishes when the merged release commit lands on main
  • Changeset policy enforcement - changeset-policy.yml validates 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 json if some packages still need a bootstrap 0.0.0 release so they exist in their registries first
  • use monochange step publish-packages --dry-run --format json to preview built-in package publication to crates.io, npm, jsr, or pub.dev
  • before real package publication, optionally write a readiness artifact with monochange step publish-readiness --from HEAD --output .monochange/readiness.json for preflight review, then run monochange step publish-packages
  • use monochange step publish-release --dry-run --format json only 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.json packages
  • Deno workspaces and standalone deno.json / deno.jsonc packages
  • Dart and Flutter workspaces plus standalone pubspec.yaml packages
  • Python uv workspaces, Poetry projects, and standalone pyproject.toml packages
  • Go modules discovered from standalone go.mod files

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 in monochange.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

EcosystemPackage typeDiscovery sourcesVersion and dependency updatesLockfile behaviorBuilt-in registry publishing
CargocargoCargo.toml workspaces and standalone cratesCargo.toml package versions and internal dependency requirementsDirect Cargo.lock rewrite by default; configure cargo generate-lockfile, cargo check, or another command when you need package-manager resolutioncrates.io
npm-familynpmnpm workspaces, pnpm workspaces, Bun workspaces, and standalone package.json packagespackage.json versions and dependency rangesDirect package-lock.json, pnpm-lock.yaml, bun.lock, and bun.lockb updates by default; command overrides support package-manager refreshesnpm
DenodenoDeno workspaces and standalone deno.json / deno.jsonc packagesDeno manifest versions, exports/imports metadata, and dependency referencesDirect deno.lock update when possible; no inferred lockfile commandjsr
Dart / Flutterdart, flutterDart and Flutter workspaces plus standalone pubspec.yaml packagespubspec.yaml versions and dependency rangesDirect pubspec.lock update by default; configure dart pub get or flutter pub get when you need full solver refreshespub.dev
Pythonpythonuv workspaces, Poetry projects, and standalone pyproject.toml packagesPEP 621 [project] and Poetry [tool.poetry] package versions plus dependency specifiersDoes not mutate uv.lock or poetry.lock directly; infers uv lock and poetry lock --no-update commands; unknown Python lockfiles are skippedpypi
GogoStandalone go.mod modulesInternal require directives in go.mod; package versions stay in VCS tagsDoes not mutate go.sum directly; infers go mod tidy so the Go toolchain refreshes go.mod and checksum dataGo module proxy via VCS tags
GitHub Actionsgithub_actionsConfigured [package.<id>] declarations with action.yml (or action.yaml) in the package directoryNone: releases are git tags; a sibling package.json version field is synced when presentNot applicable, no lockfilesNone: 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_files entries, 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.lock is 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.json packages
  • internal workspace dependencies that use npm-compatible version ranges

npm-family behavior:

  • package ids come from package.json names
  • internal dependency ranges default to the ^ prefix
  • dependencies, devDependencies, and peerDependencies participate in dependency updates
  • monochange versions sync can repair npm internal dependency ranges outside a release while preserving workspace:* protocol references
  • direct lockfile support covers package-lock.json, pnpm-lock.yaml, bun.lock, and bun.lockb
  • built-in publishing targets the public npm registry
  • GitHub npm trusted-publishing diagnostics are built in; registry-side enrollment stays manual or external, and trusted npm publishes use the npm CLI 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.json or deno.jsonc package manifests
  • Deno workspace members
  • imports entries that connect internal packages
  • packages published to jsr

Deno behavior:

  • internal dependency ranges default to ^
  • imports are the primary dependency field
  • deno.lock can 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 flutter section
  • Dart or Flutter workspace layouts
  • packages published to pub.dev

Dart / Flutter behavior:

  • package type is canonically dart for both pure Dart and Flutter packages; Flutter packages are detected from pubspec.yaml metadata and published with flutter pub publish when appropriate
  • internal dependency ranges default to ^
  • dependencies and dev_dependencies participate in dependency updates
  • monochange versions sync can repair Dart internal dependency ranges outside a release, including converting internal path: references to version constraints when resolution: workspace is enabled
  • pubspec.lock can be rewritten directly by default
  • configure dart pub get or flutter pub get as 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:

  1. If the repository root has a pyproject.toml with uv workspace members, monochange expands the member globs and reads each member manifest.
  2. monochange then scans for standalone pyproject.toml files 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 dependencies are runtime dependencies
  • PEP 621 optional-dependencies are development/optional dependency edges for release-planning purposes
  • Poetry dependencies are runtime dependencies, except the special python constraint is skipped
  • Poetry dependency groups under [tool.poetry.group.<name>.dependencies] are development dependencies
  • release preparation rewrites pyproject.toml package versions and matching dependency specifiers while preserving extras such as httpx[cli]

Python lockfile behavior is command-based by design:

  • uv.lock infers uv lock
  • poetry.lock infers poetry lock --no-update
  • unknown Python lockfile names are ignored rather than guessed
  • configuring [ecosystems.python].lockfile_commands overrides 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 module directive
  • internal dependency ranges default to exact Go module versions with a leading v, matching Go module semantics
  • require directives participate in dependency updates, including grouped require (...) 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 prepare and preview can plan Go releases without a manifest version field
  • go.sum is treated as checksum data, not as a lockfile to patch directly
  • monochange infers go mod tidy when go.mod / go.sum changes need package-manager refreshes
  • built-in publishing creates VCS tags: root modules use v1.2.3, while submodules use path-prefixed tags such as api/v1.2.3
  • readiness and publish checks query the Go module proxy for <module>/@v/<version>.info visibility

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, and initial_version = "0.1.0"; every field can be overridden explicitly
  • discovery reads the package directory and synthesizes the release identity from action.yml (or action.yaml); the manifest itself carries no version
  • a sibling package.json version field is synced to the released version when present; set ignore_ecosystem_versioned_files = true or declare explicit versioned_files to control this
  • release tags follow version_format; multi-action repos should keep the default namespaced format so tags look like my-action/v1.2.3
  • floating tag aliases (floating_tags) move to each release, reproducing the common v1.2 / v1 moving 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 (or docs:update in this repository) after changing any template or consumer block
  • Run mdt check (or docs: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"
  • inherit matches the target’s own release severity: a breaking change in the package means breaking changes for its dependents. bump_propagation_max clamps the inherited severity (only valid with inherit).
  • A fixed severity (none, patch, minor, major) is a floor: dependents release at least that severity whenever the package releases. none disables 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_bump floor. Semantic compatibility evidence can still escalate beyond the declared floor.

Required fields:

  • path
  • type, unless [defaults].package_type is set

Supported type values:

  • cargo
  • npm
  • deno
  • dart
  • flutter
  • python

Optional package fields:

  • type, when [defaults].package_type is set
  • bump_ceiling
  • changelog
  • classification_enforced
  • empty_update_message
  • publish
  • versioned_files
  • tag
  • release
  • version_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_ceiling caps 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 = false makes classification advisory for the package. The proposal still appears in reports and change-classification comments, but monochange changeset validate --api never 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.md
  • false → disable the package changelog
  • "some/path.md" → use that exact path

[defaults].changelog also accepts three forms:

  • true → default every package to {{ path }}/CHANGELOG.md
  • false → 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: use base_version, which is useful for binary/nightly workflows such as 0.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 first beta prerelease does not look like an empty delta after a series of alpha prereleases.
  • 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 publishing
  • mode - builtin or external. When builtin (the default), monochange’s built-in publisher handles release publishing. When external, monochange skips the package during release publishing (PublishPackages): your own CI or scripts handle release publishing instead. The mode setting does not affect placeholder publishing (PlaceholderPublish), which processes all packages with publish.enabled = true.
  • registry - public registry override for the package ecosystem
  • trusted_publishing - true/false or a table with enabled, repository, workflow, and environment
  • attestations.require_registry_provenance - require registry-native package provenance when the selected registry/provider capability supports it
  • rate_limits.enforce - block built-in publish runs when the selected package set exceeds a known single registry window
  • fail_on_duplicate - fail the publish step when a version is already published on the registry instead of skipping it (default: false); the built-in publish-packages step exposes the same policy as the --fail-on-duplicate CLI input for a single run
  • timeout.timeout_seconds - maximum seconds a single package publish command may run before it is killed and retried; set to 0 to 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 content
  • publish_order.dependency_fields - ecosystem-level dependency fields used to topologically order package publishes
  • placeholder.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_proxy via 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.readme or placeholder.readme_file overrides 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, but monochange step publish-packages does not run npm trust automatically
  • trusted npm publishing uses the npm CLI directly; pnpm workspaces still use pnpm for non-trusted npm publishing paths
  • Cargo, jsr, pub.dev, and PyPI also 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, and environment values in config
  • otherwise [source] plus GitHub Actions environment such as GITHUB_WORKFLOW_REF and GITHUB_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_format templates must include {{ version }} and render unique, valid Git tag names; include {{ name }} when sharing a template across release owners
  • group tag, release, and version_format override member package release identity
  • package changelogs and package versioned_files still apply when grouped
  • grouped packages can customize fallback changelog entries with empty_update_message when no direct package notes are present
  • [group.<id>.changelog].include can 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:

  1. the entry’s prefix
  2. [ecosystems.<type>] dependency_version_prefix (see the [ecosystems.*] reference in Ecosystems)
  3. the ecosystem default: ^ for npm, deno, and dart; >= for python; v for 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:

  • format entries cannot set type or regex
  • fields is 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 VERSION and update existing KEY=value or export KEY=value lines
  • 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:

  • regex entries cannot set type, prefix, fields, or name: they operate on raw text
  • the regex must include a (?<version>...) named capture group
  • the path field 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, and bun.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:

  1. [[cli.<command>.inputs]] declares the flags and arguments accepted by monochange run <command>.
  2. inputs on 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 name labels such as prepare release and publish release; keep or replace those labels when you want progress output to stay readable
  • custom command variables become available when variables is present: map your own names to variables such as version, group_version, released_packages, changed_files, and changesets
  • always_run = true on any step causes it to run even when a previous step has failed, which is useful for cleanup, notification, or dry-run preview steps
  • update_release_json = true on a CommitRelease step 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 error
  • dry_run_command on a Command step replaces command only when the CLI command is run with --dry-run
  • dry_run = true on a [cli.<command>] table forces the entire command to run in dry-run mode even when the user does not pass --dry-run
  • shell = true runs 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 formatBuilt-in release titleBuilt-in changelog version title
primaryv{{ 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 a v prefix
  • {{ 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:

  • monochange keeps the current heading-and-bullets layout
  • keep_a_changelog renders 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:

TypeBumpSection
majormajorbreaking
breakingmajorbreaking
minorminorfeat
featminorfeat
changeminorchange
patchpatchfix
fixpatchfix
refactorpatchrefactor
nonenonenone
docsnonedocs
securitynonesecurity
testnonetest

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:

FieldMeaning
streamStream to render; defaults to default
targetsOne or more configured package or group ids
pathDestination template supporting {{ path }}, {{ id }}, and {{ version }}
formatmonochange, keep_a_changelog, json, or text
modeappend for a cumulative Markdown changelog or release for a standalone current-release artifact
initial_headerOptional 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:

VariableMeaningNotes
{{ summary }}rendered release-note summary headingalways available
{{ details }}optional long-form details bodyomitted when the changeset has no details
{{ package }}owning package id for the rendered entryuseful in shared templates
{{ version }}release version for the current targetpackage or group version
{{ target_id }}release target idpackage id or group id
{{ bump }}resolved bump severitynone, patch, minor, or major
{{ type }}changeset note typee.g. feature, fix, security; omitted when absent
{{ context }}compact default metadata blockpreferred rendered block for human-readable notes
{{ changeset_path }}source .changeset/*.md pathtracked in manifests and still available for custom templates, but not shown by default in {{ context }}
{{ change_owner }}plain-text hosted actor labelusually something like @ifiokjr
{{ change_owner_link }}markdown link to the hosted actorfalls back to plain text when no URL is available
{{ review_request }}plain-text PR/MR labele.g. PR #31 or MR !42
{{ review_request_link }}markdown link to the PR/MRfalls back to plain text when no URL is available
{{ introduced_commit }}short SHA for the commit that first introduced the changesetplain text only
{{ introduced_commit_link }}markdown link to the introducing commitpreferred for changelog output
{{ last_updated_commit }}short SHA for the most recent commit that changed the changesetonly populated when different from {{ introduced_commit }}
{{ last_updated_commit_link }}markdown link to the most recent commit that changed the changesetonly populated when different from {{ introduced_commit }}
{{ closed_issues }}plain-text list of issues closed by the linked review requesttypically #12, #18
{{ closed_issue_links }}markdown links to issues closed by the linked review requestpreferred for changelog output
{{ related_issues }}plain-text list of related issues that were referenced but not closedhost support may vary
{{ related_issue_links }}markdown links to related issues that were referenced but not closedhost 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:

GroupVariables
Identitymajor, minor, patch, version, identity, prerelease
Calendaryear, year_short, month, month_padded, quarter, day, date, time
Ordinalsrelease_of_month, release_of_quarter, release_of_year
Declaredevery [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
SourceStampedOrdering guarantee
file + field with on_releaseyesmonotonic within reset
hashnonone — an identifier
envnonone
gitnodepends on the git value
timestampnonone

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_private is parsed and validated, but discovery reports private packages either way. Rely on include_private only for release planning, not for filtering what step discover prints.
  • [ecosystems.*].enabled, .roots, and .exclude are parsed and validated, but discovery still scans every supported ecosystem. A package found by discovery appears regardless of those settings.
  • [defaults].strict_version_conflicts controls conflicting explicit version entries across changesets. The default warns and picks the highest; setting it to true fails 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 octocrab with GITHUB_TOKEN or GH_TOKEN, falling back to the authenticated GitHub CLI credential from gh auth token when neither variable is set. GitLab and Gitea use direct HTTP APIs.
  • Release-request publishing uses local git for 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], a changed_paths command input, and diagnostics formatted for GitHub Actions.
  • Supported [[cli.<command>.steps]] types are Config, Validate, Discover, DisplayVersions, CreateChangeFile, PrepareRelease, CommitRelease, VerifyReleaseBranch, PublishRelease, PlaceholderPublish, PublishPackages, PlanPublishRateLimits, OpenReleaseRequest, CommentReleasedIssues, AffectedPackages, DiagnoseChangesets, RetargetRelease, ReleaseRecord, PublishReadiness, TagRelease, and Command.
  • 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_files structural rules (type/format/regex conflicts, required format fields, capture groups)
  • versioned_files content checks: file existence, version field readability, regex pattern matching
  • .changeset/*.md targets 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_files can also be updated
  • grouped packages can use empty_update_message when 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 patch advisory 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.json captures what monochange is preparing right now during command execution.
  • ReleaseRecord captures 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:

CommandBest for
monochange run release --dry-runHuman review in the terminal
monochange run release --dry-run --diffHuman review plus exact file patches
monochange run release --dry-run --format jsonAutomation, scripts, MCP clients
monochange run release --dry-run --format json --diffAutomation 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 monochange or keep_a_changelog format
  • groups release notes into default Breaking changes, Features, Fixes, and Notes sections, with package/group overrides available through changelog types and sections
  • applies workspace-wide release-note templates from [changelog].templates
  • refreshes the cached .monochange/release-manifest.json artifact during PrepareRelease for 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 AffectedPackages using changed paths and labels supplied by CI
  • applies group-owned release identity for outward tag, release, and version_format
  • deletes consumed change files only after a successful non-dry-run execution
  • leaves the workspace untouched during --dry-run except 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 change defaults --bump to patch; use --bump none when you want a type-only or version-only entry, and pass --version to 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/major or configured change types, plus object syntax for bump, version, type, and caused_by
  • when version is given without bump, 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 type values can route entries into custom changelog sections, and configured section default_bump values let scalar type shorthand imply the desired semver behavior
  • caused_by references package or group ids and suppresses only the matching dependency-propagation records; use object syntax whenever you need it
  • monochange run change accepts repeated --caused-by <id> flags, and --bump none is the right fit when you want to acknowledge an affected package without forcing a user-facing version bump
  • monochange run change can 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_propagation overrides group declarations, which override [defaults].bump_propagation, which falls back to the [defaults].parent_bump floor; inherit matches the source’s own severity (optionally clamped by bump_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, and version_format metadata
  • release-manifest JSON captures release targets, changelog payloads, authored changesets, linked changeset context metadata, changed files, and the synchronized release plan for downstream automation
  • PublishRelease reuses the same structured release data to build provider release requests for grouped and package-owned releases
  • OpenReleaseRequest reuses the same structured release data to render release-request summaries, branch names, and idempotent provider updates
  • CommentReleasedIssues can use linked changeset context metadata to add follow-up comments to closed issues after a release is published
  • AffectedPackages evaluates changed paths, skip labels, and changed .changeset/*.md files 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: if HEAD is 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_proxy via 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.

EcosystemRegistryTrusted-publishing providers modeled by monochangeCurrent CI identity can be detectedPublish-time setup/context can be verified by monochangeRegistry-side setup can be automated by monochangeRegistry-native provenance / attestations
npmnpmGitHub Actions, GitLab CI/CDYesGitHub Actions context onlyNo; run npm trust github ... manually or externallyYes, npm provenance
cargocrates.ioGitHub ActionsYesNoNoNo registry-native package provenance
denojsrGitHub ActionsYesNoNoYes, JSR package provenance
dart / flutterpub.devGitHub Actions, Google Cloud BuildYesNoNoNo registry-native package provenance
pythonPyPIGitHub Actions, GitLab CI/CD, Google Cloud BuildYesNoNoYes, PEP 740 digital attestations
goGo proxyNone; VCS tags are used insteadN/AN/ACreates module tagsSource-control provenance only
custom/privatecustomNone by defaultProvider may be detectedNoNoUnknown

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.repository
  • publish.trusted_publishing.workflow
  • publish.trusted_publishing.environment
  • otherwise [source]
  • otherwise GitHub Actions runtime values such as GITHUB_REPOSITORY, GITHUB_WORKFLOW_REF, and GITHUB_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:

  1. publish.trusted_publishing = true is effective for the package.
  2. The current environment exposes a verifiable CI/OIDC identity.
  3. 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.

Use this sequence when adopting trusted publishing for an existing workspace:

  1. Set publish.trusted_publishing = true for the target ecosystem, then override individual packages only when they differ.
  2. Run monochange step placeholder-publish --dry-run to see which packages do not exist yet.
  3. If needed, run monochange step placeholder-publish so the package exists in the registry first.
  4. Complete the registry-side trusted-publishing setup for each package.
  5. Run monochange step publish-packages --dry-run to confirm monochange sees the expected trust configuration.
  6. Optionally generate a readiness artifact in CI with monochange step publish-readiness --from HEAD --output .monochange/readiness.json for preflight review or publish planning.
  7. 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 trust or npm trust list automatically.
  • 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 npm CLI directly. pnpm workspaces can still use pnpm 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.io trusted-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-publishing
  • https://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-packages
  • https://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.

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-dart is the officially recommended path and is worth preferring unless you need custom pre-publish steps.
  • Keep the Git tag, pubspec.yaml version, 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-publishing
  • https://pub.dev/packages/<package>/admin

Mapping monochange config to registry values

Use this cheat sheet when a registry asks for workflow details.

Registry fieldValue to use
repository owner / organization / namespaceGitHub owner from [source] or publish.trusted_publishing.repository
repository name / projectrepository part of owner/repo
workflow filenamepublish.trusted_publishing.workflow, for example publish.yml
environmentpublish.trusted_publishing.environment, for example publisher
pub.dev tag patternchoose 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: write only on the publish job instead of the entire workflow when possible
  • use a protected GitHub environment such as publisher for 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 publishing
  • jsr: better repository-link diagnostics and package metadata checks before publish, especially when the package already exists but repository-side linking is incomplete
  • pub.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 json refreshes the cached manifest and shows the downstream automation payload
  • monochange step publish-release previews or publishes provider releases from the structured release notes
  • monochange step open-release-request previews or opens an idempotent provider release request; when [source.pull_requests].verified_commits = true and 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 verified
  • monochange step affected-packages evaluates 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:

  1. Complete source configuration - [source], [source.releases], and [source.pull_requests] sections
  2. Automation CLI commands - commit-release and release-pr commands ready to use
  3. GitHub Actions workflows - release.yml and changeset-policy.yml for CI/CD
  4. 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
  • ReleaseRecord describes the durable release declaration stored in the release commit body
  • monochange step tag-release consumes 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 mutation
  • monochange step publish-packages handles package registries such as crates.io, npm, jsr, and pub.dev
  • monochange step publish-release handles 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-packages runs do not execute npm trust or npm trust list; trusted-publishing npm publishes use the npm CLI 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.yml refreshes the dedicated release PR branch on normal main pushes
  • the same workflow detects when HEAD is already a merged monochange release commit, runs monochange step tag-release --from HEAD, runs monochange step publish-readiness --from HEAD --output <path>, and then runs monochange 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-release job 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] in monochange.toml
  • running a real changeset-policy GitHub Actions workflow that shells into monochange step affected-packages
  • publishing the CLI npm packages from .github/workflows/publish.yml with the protected publisher environment and id-token: write, without NODE_AUTH_TOKEN or NPM_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 valueWorkflow generationRelease automationPull/merge requests
GitHubgithubYes: GitHub ActionsYesYes
GitLabgitlabNo: use .gitlab-ci.ymlYesYes
GiteagiteaNo: use Gitea ActionsYesYes

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 harnesses
  • monochange mcp starts 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 agents
  • REFERENCE.md: broader high-context reference with more examples
  • skills/README.md: index of focused deep dives
  • skills/adoption.md: setup-depth questions, migration guidance, and recommendation patterns
  • skills/change-classification.md: release-aware severity decisions, uncertainty, and ecosystem review
  • skills/changesets.md: changeset authoring and lifecycle guidance
  • skills/commands.md: built-in command catalog and workflow selection
  • skills/configuration.md: monochange.toml setup and editing guidance
  • skills/linting.md: [lints] presets, monochange check, and manifest-focused examples
  • examples/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:

  • claude
  • vscode
  • copilot
  • pi
  • codex
  • cursor

Generated subagents are CLI-first. They should prefer:

  1. monochange
  2. npx -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.

Keep instructions like these close to your project guidance:

  • Read monochange.toml before proposing release workflow changes.
  • Run monochange step validate before and after release-affecting edits.
  • Use monochange step discover --format json to inspect package ids, group ownership, and dependency edges.
  • Use monochange step diagnose-changesets --format json or monochange_diagnostics for a structured view of all pending changesets with git and review context.
  • Run monochange change classify --format json --dependency-propagation public before writing release intent. Trace each proposed bump to its finding ids and review partial results.
  • Use monochange_lint_catalog and monochange_lint_explain when you need lint metadata without shelling out.
  • Prefer monochange run change plus .changeset/*.md files over ad hoc release notes.
  • Use monochange step prepare-release --dry-run --format json before 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: validate monochange.toml and .changeset targets
  • monochange_discover: discover packages, dependencies, and groups across the repository
  • monochange_diagnostics: inspect pending changesets with git and review context as structured JSON
  • monochange_change: write a .changeset markdown file for one or more package or group ids
  • monochange_release_preview: prepare a dry-run release preview from discovered .changeset files
  • monochange_release_manifest: generate a dry-run release manifest JSON document for downstream automation
  • monochange_affected_packages: evaluate changeset policy from changed paths and optional labels
  • monochange_lint_catalog: list registered manifest lint rules and presets
  • monochange_lint_explain: explain one manifest lint rule or preset
  • monochange_analyze_changes: analyze git diff state and return ecosystem-specific semantic changes
  • monochange_classify_changes: compare the pull request and latest release, then return evidence-backed package bumps
  • monochange_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 queried
  • changesets: full PreparedChangeset records, each with:
    • path: workspace-relative path to the changeset file
    • summary: the first paragraph of the markdown body
    • details: optional follow-up paragraphs
    • targets: package/group bump entries, each with kind, id, bump, origin, and optional evidence_refs
    • context: 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 committed
  • last_updated: revision where it was most recently changed (omitted when same as introduced)
  • related_issues: issues linked by the changeset or the PR that introduced it

Each revision record includes:

  • commit.sha: full commit SHA
  • commit.short_sha: short SHA for display
  • review_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:

  1. monochange step discover --format json: understand the workspace package graph
  2. monochange step diagnose-changesets --format json: see all pending changesets, linked PRs, and introduced commits
  3. monochange run release --dry-run --format json: preview the computed release plan
  4. monochange run change ...: add, update, or remove changesets as needed
  5. monochange 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:

ArtifactWhat it meansWhen it existsWhat it is for
cached release manifest (.monochange/release-manifest.json)what monochange is preparing right nowduring command execution and cached locallyCI, MCP/server consumers, previews, downstream automation, and AI/agent workflows
ReleaseRecordwhat this release commit historically declaredinside the monochange-managed release commit bodylater 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:

  1. a compact human-readable release summary
  2. 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:

  1. resolve the supplied ref to a commit
  2. walk first-parent ancestry
  3. stop at the first valid monochange ReleaseRecord
  4. 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:

  1. finds the canonical release record from history
  2. derives the full release set from that record
  3. validates descendant-only safety rules by default
  4. previews the retarget plan in dry-run mode
  5. moves the whole tag set together when run for real
  6. 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:

  1. monochange creates a release request commit with an embedded release record.
  2. That release is tagged and published.
  3. You add a follow-up fix commit or two.
  4. You inspect the durable history record:
monochange step release-record --from v1.2.3
  1. You preview the repair:
monochange step retarget-release --from v1.2.3 --target HEAD --dry-run
  1. 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.from
  • retarget.target
  • retarget.record_commit
  • retarget.resolved_from_commit
  • retarget.distance
  • retarget.tags
  • retarget.provider_results
  • retarget.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.

GoalCommandUse it when
Validate config and changesetsmonochange step validateYou changed monochange.toml or .changeset/*.md files
Inspect package ids and groupsmonochange step discover --format jsonYou need the normalized workspace model
Sync internal dependency rangesmonochange versions --dry-runYou want internal dependency references to match canonical workspace package versions
Check the next versionmonochange nextYou want the next release group and package versions from pending changesets, without writing any release state
Create release intentmonochange run change --package <id> --bump <severity> --reason "..."You need a new .changeset/*.md file
Audit pending release contextmonochange step diagnose-changesets --format jsonYou need git provenance, PR/MR links, or related issues
Preview the release planmonochange run release --dry-run --diff or monochange step prepare-release --dry-runYou want changelog/version patches without mutating the repo
Create a durable release commitmonochange step commit-releaseYou want a monochange-managed release commit with an embedded ReleaseRecord
Open or update a release requestmonochange step open-release-requestYou want a long-lived release PR/MR branch updated from current release state
Inspect a past release commitmonochange step release-record --from <ref>You need the durable release declaration from git history
Check package publish readinessmonochange publish readiness --from HEAD --output <path>You want a non-mutating preflight report before package publication
Dry-run configured publishingmonochange run publish-checkThis repository, or another repo with a similar [cli.publish-check], should exercise publishing in CI without registry mutations
Plan ready package publishingmonochange step plan-publish-rate-limits --readiness <path>You want rate-limit batches that exclude non-ready package work
Publish packages to registriesmonochange publish packages --output <path>You want cargo publish, npm publish, deno publish, or dart pub publish style package publication
Bootstrap release packagesmonochange publish placeholderYou need a release-record-scoped placeholder bootstrap artifact before rerunning readiness
Create post-merge release tagsmonochange step tag-release --from HEADYou merged a monochange release commit and now need to create and push its declared tag set
Repair a recent releasemonochange step retarget-release --from <tag> --target <commit>You need to retarget a just-created release to a later commit
Publish hosted/provider releasesmonochange step publish-releaseYou want GitHub/GitLab/Gitea release objects from prepared release state

A practical rule of thumb:

  • use monochange step publish-readiness for registry preflight reports and monochange step publish-packages for registry package publication
  • use monochange step publish-release for hosted releases from prepared release state
  • use monochange step open-release-request when you want a provider-backed release request branch
  • use monochange step commit-release when you want a durable local release commit in git history
  • use monochange step tag-release when 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:

  1. Release planning: monochange run release --dry-run, monochange run release, monochange step diagnose-changesets
  2. Package registries: monochange step publish-readiness, monochange step placeholder-publish, monochange step plan-publish-rate-limits --readiness <path>, monochange step publish-packages, and lower-level monochange step placeholder-publish
  3. 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:

  1. 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.
  2. 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

CapabilityCurrent status
Multi-ecosystem discoveryCargo, npm/pnpm/Bun, Deno, Dart, Flutter, Python, Go
Package release planningBuilt in
Grouped/shared versioningBuilt in
Internal dependency version synchronizationAll supported ecosystems via monochange versions; release planning also updates supported ecosystems during releases
Dry-run release diff previewsBuilt 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 taggingBuilt in via ReleaseRecord, monochange step release-record, monochange step tag-release, and monochange step retarget-release
Hosted provider releasesGitHub, GitLab, Gitea, Forgejo
Hosted release requestsGitHub, GitLab, Gitea, Forgejo
Python release planningBuilt in for discovery, version rewrites, dependency rewrites, lockfile command inference, and PyPI publishing
Go release planningBuilt in for go.mod discovery, dependency rewrites, go mod tidy inference, and Go proxy tag publishing
Built-in registry publishingcrates.io, npm, jsr, pub.dev, pypi, Go proxy tags; use external mode for custom registries
GitHub npm trusted-publishing diagnosticsBuilt in; registry-side enrollment stays manual or external
GitHub trusted-publishing guidance for crates.io, jsr, pub.dev, and PyPIBuilt in, but manual registry enrollment is still required
GitLab trusted-publishing auto-derivationNot built in
Release-retarget sync for hosted releasesGitHub first

CI setup assumption

The workflow sketches below assume the job already has:

  • the monochange CLI available as monochange
  • 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:

  1. a workflow prepares or updates a release PR branch
  2. a release commit lands on main
  3. a post-merge workflow detects the release commit
  4. that workflow creates the declared tags and publishes packages from the durable release commit
  5. 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 npm CLI 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-readiness blocks packages whose current Cargo.toml cannot be published: publish = false, publish = [...] without crates-io, missing description, or missing both license and license-file
  • workspace-inherited Cargo metadata such as description = { workspace = true } and license = { 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.io trust; registry-side enrollment remains manual
  • if you want the most literal crates.io/OIDC workflow, mode = "external" plus rust-lang/crates-io-auth-action@v1 is the clearest path

Recommended setup:

  1. configure trusted_publishing = true
  2. bootstrap missing release packages with monochange step placeholder-publish if needed, then rerun readiness
  3. manually enroll the repository/workflow in crates.io
  4. choose either:
    • mode = "builtin" and let monochange own the publish command, or
    • mode = "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.dev trusted publishing
  • if you want the most copy-pasteable pub.dev flow, mode = "external" plus the reusable dart-lang/setup-dart workflow 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:

  1. merge the release PR so the monochange release commit lands on main
  2. run monochange step release-record --from HEAD --format json in CI
  3. if the command succeeds, run monochange step publish-readiness --from HEAD --output .monochange/readiness.json
  4. run monochange step publish-packages only after readiness succeeds
  5. 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 = false unless 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:

  1. every merge to main updates a dedicated release branch and PR
  2. that branch contains the prepared release commit and release files
  3. the release PR stays open and keeps tracking the latest releasable state
  4. 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-request can open or update a release request branch from current release state
  • monochange step commit-release can create a durable monochange release commit with an embedded ReleaseRecord
  • monochange step release-record --from HEAD can detect whether the latest commit is a monochange release commit
  • monochange step tag-release --from HEAD can create and push the declared tag set from that merged release commit
  • monochange step publish-readiness can write a readiness artifact from that same durable record on HEAD, and monochange step publish-packages can 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 from main
  • 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.

For the long-running release PR model, the recommended shape is:

  1. on every push to main, run monochange step open-release-request to refresh the dedicated release PR branch
  2. do not create tags on the release PR branch
  3. merge the release PR when you are ready
  4. on the post-merge workflow, run monochange step release-record --from HEAD --format json
  5. if the latest commit is a release commit, run monochange step tag-release --from HEAD
  6. after tags exist, run monochange step publish-readiness --from HEAD --output <path> and then monochange step publish-packages for 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 ReleaseRecord on HEAD, run monochange step tag-release --from HEAD, then run monochange step publish-readiness --from HEAD --output <path> and monochange 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 = true with monochange step publish-readiness and monochange step publish-packages
  • Need GitLab CI with custom auth/bootstrap? → keep mode = "external" as the escape hatch

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 on mode = "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:

  1. let monochange prepare one release commit for the workspace
  2. decide which packages use built-in publishing and which use external publishing
  3. 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:

  1. Build the selected publish requests from the prepared release or HEAD release state.
  2. Materialize the workspace dependency graph.
  3. Consider only dependencies where both packages are part of the selected publish set.
  4. Ignore development dependency edges.
  5. 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.io setups
  • 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

RegistryRecommended multi-package patternWhy
npmone post-merge monochange step publish-readiness + monochange step publish-packages job when possiblemonochange can automate npm trusted-publishing setup on GitHub
crates.ioone job per crate when using external OIDC authtrusted publishing is enrolled per crate and workflow context matters
jsrbuilt-in monochange step publish-readiness + monochange step publish-packages is often fine, but keep setup package-specificregistry linking is still manual
pub.devpackage-specific tags and often one workflow per packageautomated 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

  1. decide which packages are public and which stay unpublished
  2. choose builtin or external per ecosystem or package
  3. register trusted publishing for each package at the registry
  4. prefer package-specific tags where a registry is tag-authorized
  5. run monochange step publish-packages --dry-run after registry enrollment changes
  6. optionally run monochange step publish-readiness --from HEAD --output <path> as a preflight before real monochange step publish-packages
  7. 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.dev automated publishing is tag-triggered

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 metadata
  • npm: conservative advisory metadata when exact package publish quotas are not officially documented
  • jsr: official publish-window metadata
  • pub.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 example 0.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

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 text return concise human-readable text;
  • --format markdown always returns raw Markdown and is never terminal-rendered;
  • --format json and --format json-min return 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 config prints a short workspace summary. Use --format json for the complete resolved configuration.

  • --jq now 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>'
    
  • --quiet now changes output only. It no longer silently turns a real operation into a dry run, so add --dry-run explicitly:

    # 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=1 now 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 json emits a newline-delimited event stream for tooling that wants machine-readable progress.
  • --log-level debug stays 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 2 to 3. Regenerate stored readiness artifacts after upgrading.
  • For each package with publish.trusted_publishing = true, make sure repository, workflow, and the optional environment resolve 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.

ArtifactPreviousCurrentAction
Configuration and release-record schema0.50.6None; records migrate automatically
Publish-readiness artifact23Regenerate stored artifacts
Change-classification reportolder3Update 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:

  1. Built-in step commands moved to the nested monochange step <name> path; colon-delimited top-level aliases are no longer supported.
  2. User-defined [cli.<name>] commands moved from monochange <name> to monochange run <name>.
  3. The packaged mc binary alias was removed; use monochange directly.

Quick replacement table

Old commandNew command
mc checkmonochange check
mc versions list --format jsonmonochange versions list --format json
Colon-delimited built-in step tokenmonochange 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.yml
  • devenv.nix and task files
  • package.json scripts
  • Cargo.toml aliases or xtask wrappers
  • README.md and docs examples
  • AGENTS.md, skill files, prompt templates, and other agent instructions
  • Shell scripts under scripts/

Apply these rules in order:

  1. Replace executable mc with monochange.
  2. Replace each colon-delimited built-in step token with the nested monochange step <name> path.
  3. For each command name defined in monochange.toml under [cli.<name>], replace monochange <name> with monochange run <name>.
  4. Do not rewrite built-ins such as monochange check, monochange run change, monochange init, monochange mcp, monochange run release, or monochange versions into monochange run ... unless that exact name is intentionally a configured command in the repository.
  5. Prefer monochange versions list --format json for 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

Featureknopemonochange
Config fileknope.tomlmonochange.toml
CLI binaryknopemonochange / monochange
Changeset directory.changeset/.changeset/
Changeset formatMarkdown frontmatterMarkdown frontmatter
Conventional commitsSupportedNot supported
Single-package config[package][package.<id>]
Multi-package config[packages.<name>][package.<id>]
Version groupsImplicit (single [package])Explicit [group.<id>]
Workflows[[workflows]][cli.<command>]
GitHub config[github][source] (provider-neutral)
Ecosystem supportRust, Go, JSRust, npm, pnpm, Bun, Deno, Dart, Flutter, Python
Dependency propagationNot built-inAutomatic 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 scopes filter 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.toml as 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, and version_format are owned by the group
  • member packages can still have their own changelogs
  • members without direct changes get a configurable empty_update_message fallback

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 stepmonochange stepNotes
PrepareReleasePrepareReleaseSame name, same purpose
CreateChangeFileCreateChangeFileSame name
ReleasePublishReleaseknope’s Release creates GitHub releases; monochange calls this PublishRelease and supports multiple providers
CommandCommandSame name; monochange adds dry_run_command and shell = true
:OpenReleaseRequestNew: open/update a release PR
:PrepareReleaseNew: refresh the cached .monochange/release-manifest.json artifact for downstream CI
:AffectedPackagesNew: PR changeset policy enforcement
:ValidateNew: validate config and changesets
:DiscoverNew: list workspace packages
:CommentReleasedIssuesNew: 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, and git push Command steps. monochange handles git operations internally when using PublishRelease or OpenReleaseRequest, 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.toml with [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 scopes and [changes] sections (no conventional commits)
  • Update .changeset/*.md frontmatter keys to use declared package/group ids
  • Update CI workflows from knope <command> to monochange <command>
  • Run monochange step validate to check config and changesets
  • Run monochange run release --dry-run to 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:

KindBaseHeadPurpose
pullRequestThe remote default branch, or --baseThe synthetic merge result of the base and --headChanges introduced after the pull request merges
sourceDeltaThe merge base of the default branch and the source candidateThe source candidateChanges authored on the pull request branch
workingTreeHEADThe staged, unstaged, deleted, and untracked filesA diagnostic view of local changes
releaseThe package release owner’s latest reachable tag, or --releaseThe synthetic merge resultAccumulated change since the latest release
releaseToDefaultThe same release tagThe default branchChange 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:

FieldMeaning
compatibility_impactbreaking, additive, compatible, or unmodeled between the default branch and the candidate
release_impactThe same verdict between the latest release and the candidate; absent when no release tag matched
pull_request_changesWhether the net candidate or the local working tree contains files that belong to this package
proposed_changeset_bumpThe highest current finding after the release cap: major, minor, patch, or none
enforceable_minimumThe highest bump supported by high-confidence evidence, after the same release cap
release_floorThe highest bump found between the latest release and the candidate
confidenceConfidence of the finding that determines proposed_changeset_bump
completenessWhether the analyzer claims complete, partial, or unsupported coverage
review_requiredWhether the proposal needs human or agent review before it becomes release intent
finding_idsStable 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:

EvidenceImpactProposed bump
Removed entrypoint, export, or non-assignable consumer contractbreakingmajor
Added entrypoint/export, overload, optional member, or input capabilityadditiveminor
Changed source with an equivalent or consumer-compatible declaration APIcompatiblenone
Unresolved config, wildcard export, generic/nominal identity, or failureunmodeledpatch

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 resultClassification behavior
Any checked cell proves a breakPropose major, even if another cell fails
A checked cell reports a minor requirement and none breakPropose at least minor
Every configured cell is checked and requires no bumpReport compatible Rust API evidence; retain syntax-derived additions as minor evidence
A tool, target, build, timeout, or matrix cell is incompleteKeep 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_request job with least-privilege credentials; do not run semantic classification on untrusted changes through pull_request_target or 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_changes and scopes decision.proposed_changeset_bump, decision.enforceable_minimum, and decision.review_required to packages the pull request actually touches.
  • 0.2 adds decision.release_impact and caps decision.proposed_changeset_bump, decision.enforceable_minimum, and decision.release_floor with the release comparison.
  • 0.1 is the first contract published by the monochange_classification crate: snake_case keys, unmodeled instead of unknown, and the top-level skipped, summary, and matched_skip_labels fields.
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:

FieldMeaning
nameThe binary name users invoke. It also names the committed baseline file and must be unique across the workspace.
snapshotA 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 ids monochange/cli-surface/<change-kind>, surface: "cli";
  • removed commands, options, or positionals and value narrowing propose major (breaking); additions and widening propose minor (additive); description-only changes are compatible patches;
  • per-command max_bump caps from the snapshot still apply;
  • findings are high confidence with complete coverage, so they raise enforceableMinimum and 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:

FieldValueWhy
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:

  • high for structured metadata read from a command framework’s own definitions.
  • medium for partial introspection, for example commands discovered but option types guessed.
  • low for snapshots inferred from --help prose, 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:

FieldMeaning
namesEvery accepted spelling, for example ["-f", "--format"].
canonical_nameThe primary spelling used in findings.
hiddenWhether the argument is hidden from help output.
globalWhether the option is accepted by subcommands too.
value.kindOne of flag, string, enum, or counter.
value.requiredWhether the argument must be supplied.
value.repeatableWhether the option may be passed more than once.
value.variadicWhether a positional accepts multiple values.
value.enum_valuesAccepted values when kind is enum.
value.defaultThe 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.kind and value.enum_values. Narrowing (string to enum, or removing an enum value) proposes major; widening proposes minor.
  • 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.

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.toml uses one schema version (currently 0.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.

JSON Schema reference

classification.schema.json

command-snapshot.schema.json

monochange.schema.json

release-record.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_snapshot contract version, derived from that crate’s package version.
  • monochange.toml and release records carry the monochange_schema contract 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.

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:

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:

  1. normal workspace validation, similar to monochange step validate
  2. 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

PresetWhat it is forRules enabled
changesets/recommendedBaseline changeset hygiene.changesets/summary = error with an H1 heading, changesets/summary-description = error, changesets/prefer-inline = error
cargo/recommendedBalanced 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/strictCargo policy with style rules promoted to errors.Same as cargo/recommended, but cargo/dependency-field-order and cargo/sorted-dependencies are error.
npm/recommendedBalanced 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/strictnpm-family policy with dependency sorting promoted to an error.Same as npm/recommended, but npm/sorted-dependencies is error.
dart/recommendedBaseline 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/strictDart 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 idEcosystemCategoryAutofixSummary
changesets/summarychangesetscorrectnessnoRequires a changeset body to start with a summary heading.
changesets/summary-descriptionchangesetsstylenoRequires the first description sentence to add information instead of repeating the summary.
changesets/no_section_headingschangesetscorrectnessnoRejects change-type headings inside changeset bodies.
changesets/prefer-inlinechangesetsstyleyesRewrites object change entries that repeat what the inline form already implies.
changesets/bump/nonechangesetscorrectnessnoApplies scoped body policy to none bump entries.
changesets/bump/patchchangesetscorrectnessnoApplies scoped body policy to patch bump entries.
changesets/bump/minorchangesetscorrectnessnoApplies scoped body policy to minor bump entries.
changesets/bump/majorchangesetscorrectnessnoApplies scoped body policy to major bump entries.
changesets/types/<type>changesetscorrectnessnoApplies scoped body policy to a configured changelog type.
changesets/duplicatechangesetscorrectnessnoRecognized compatibility switch for duplicate target validation; workspace validation rejects duplicate package entries regardless.
cargo/dependency-field-orderCargostyleyesOrders keys inside inline dependency tables.
cargo/internal-dependency-workspaceCargocorrectnessyesRequires internal crate dependencies to use workspace = true.
cargo/publishable-dependenciesCargocorrectnessnoPrevents publishable crates from depending on unpublished workspace crates.
cargo/required-package-fieldsCargocorrectnessnoRequires selected [package] metadata fields.
cargo/sorted-dependenciesCargostyleyesSorts dependency tables alphabetically.
cargo/unlisted-package-privateCargocorrectnessyesRequires unmanaged crates to set publish = false.
cargo/manifest-repositoryCargocorrectnessyesRequires package.repository to point at the root repository or the package subdirectory URL.
npm/workspace-protocolnpm-familycorrectnessyesRequires internal dependencies to use workspace: ranges.
npm/sorted-dependenciesnpm-familystyleyesSorts dependency sections alphabetically.
npm/required-package-fieldsnpm-familycorrectnessnoRequires selected package.json metadata fields.
npm/root-no-prod-depsnpm-familybest practiceyesKeeps production dependencies out of the workspace root package.
npm/no-duplicate-dependenciesnpm-familycorrectnessyesPrevents the same dependency from appearing in multiple dependency sections.
npm/unlisted-package-privatenpm-familycorrectnessyesRequires unmanaged packages to set private: true.
npm/manifest-repositorynpm-familycorrectnessyesRequires repository in package.json to point at the root repository or the package subdirectory URL.
dart/sdk-constraint-presentDartcorrectnessnoRequires environment.sdk in pubspec.yaml.
dart/sdk-constraint-modernDartbest practicenoEnforces a modern SDK lower bound and, by default, an upper bound.
dart/dependency-sortedDartstyleyesSorts dependency sections in pubspec.yaml.
dart/required-package-fieldsDartcorrectnessnoRequires selected pubspec.yaml metadata fields.
dart/no-git-dependencies-in-published-packagesDartcorrectnessnoBlocks git: dependencies in publishable packages unless allowed.
dart/unlisted-package-privateDartcorrectnessyesRequires unmanaged packages to set publish_to: none.
dart/no-unexpected-dependency-overridesDartbest practicenoAllows dependency_overrides only in approved packages.
dart/internal-path-dependency-policyDartbest practicenoEnforces one policy for internal Dart dependency references.
dart/workspace-internal-version-consistencyDartcorrectnessnoRequires internal hosted dependency ranges to match workspace package versions.
dart/flutter-package-metadata-consistentDart / FluttercorrectnessnoRequires Flutter packages to declare the Flutter SDK dependency consistently.
dart/assets-sortedDart / FlutterstyleyesSorts Flutter assets and fonts.
dart/manifest-repositoryDartcorrectnessyesRequires 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 from 1 to 6.
  • min_length / max_length: constrain summary text length.
  • forbid_trailing_period: reject summaries ending in ..
  • forbid_conventional_commit_prefix: reject summaries such as feat: 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:

  • type alone (core: { type: "feat" }),
  • type plus a bump that the type already implies (core: { type: "feat", bump: "minor" }, since feat implies minor), and
  • a bare bump whose keyword is also a configured change type that implies the same bump (core: { bump: "minor" }, since minor is a change type with default bump minor; 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:

  1. workspace or version
  2. default-features / default_features
  3. features
  4. 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 to true; 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 to true; require internal dependencies to use workspace = true.
  • fix: defaults to true; 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:

  • description
  • license
  • repository

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 to true; 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 to true; inserts publish = false when 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 as https://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 to false; also enforce the rule for private packages.
  • fix: defaults to true; rewrites internal dependency ranges to workspace: 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 to true; rewrites dependency sections in sorted order.

npm/required-package-fields

Why: package metadata should stay consistent across publishable npm packages.

Default required fields:

  • description
  • repository
  • license

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 to true; moves root dependencies into devDependencies.

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 to true; 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 to true; inserts private: true when 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 as https://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 to false if 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 to true; 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:

  • description
  • repository
  • license

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 use git: 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 to true; inserts publish_to: none when 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 to true; allow overrides in private packages.
  • allow_packages: list package names that may keep dependency_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 use resolution: 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 to true; 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 as https://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.

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:

  • default keeps each ecosystem’s own constraint style (see the table below).
  • exact writes the bare version with no range prefix, such as 1.2.3.
  • caret writes a caret constraint, such as ^1.2.3, for ecosystems that use caret ranges.
  • compatible writes 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:

StrategyCargonpmDenoDartPythonGo
default1.2.3^1.2.3^1.2.3^1.2.3>=1.2.3v1.2.3
exact1.2.31.2.31.2.31.2.31.2.3v1.2.3
caret1.2.3^1.2.3^1.2.3^1.2.3>=1.2.3v1.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 sync never writes tilde (~) or equality (=) prefixes. To stamp internal dependency references with a custom prefix such as ~ or = at release time, use a typed versioned_files entry with an explicit prefix (see Versioned files).
  • Deno keeps ^ under compatible, and Go’s compatible output keeps the mandatory v module prefix (>=v1.2.3); prefer default, exact, or caret for those two ecosystems.
  • Cargo reads a bare requirement such as 1.2.3 as a caret range, so --strategy exact does 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

CommandReports
monochange nextPlanned group and package versions only
monochange versions listThe current versions recorded in the workspace
monochange step prepare-release --dry-runPlanned 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.

CommandRuns
monochange publish packagesmonochange step publish-packages
monochange publish readinessmonochange step publish-readiness
monochange publish placeholdermonochange 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 json when a consumer must tell the two streams apart
  • completed PrepareRelease and DisplayVersions steps 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_started
  • phase_finished
  • phase_failed
  • command_started
  • step_started
  • command_output
  • step_finished
  • step_failed
  • step_skipped
  • command_finished
  • command_failed
  • lint_planning_started
  • lint_planning_finished
  • lint_suite_started
  • lint_suite_finished
  • lint_fix_started
  • lint_fix_applied
  • lint_fix_finished
  • lint_summary
  • publish_run_started
  • publish_registry_check_started
  • publish_package_started
  • publish_package_skipped
  • publish_package_planned
  • publish_package_published
  • publish_package_failed
  • publish_run_finished

Shared fields:

  • sequence: monotonically increasing event sequence number for the command run
  • command: CLI command name, such as release
  • dry_run: whether the command is running in dry-run mode
  • total_steps: total step count for the command
  • step_index: 1-based step index for step events
  • step_kind: built-in step kind, such as PrepareRelease
  • step_display_name: rendered human label for the step
  • step_name: explicit configured name, or null when omitted

Event-specific fields:

  • command_output adds stream and text
  • phase_finished, step_finished, and command completion events add duration_ms
  • step_finished adds phase_timings
  • step_failed adds duration_ms and error
  • step_skipped may add the backward-compatible condition field for conditional skips and reason for a human-readable explanation
  • command_failed adds duration_ms and error

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_name
  • command_source: configured or generated_step
  • dry_run
  • show_diff
  • progress_format: auto, unicode, ascii, or json
  • step_count
  • duration_ms
  • outcome: success or error
  • error_kind: sanitized error category or null

command_step

Emitted for each CLI step that succeeds, fails, or is skipped.

Attributes:

  • command_name
  • step_index
  • step_kind
  • skipped
  • duration_ms
  • outcome: success, skipped, or error
  • error_kind: sanitized error category or null

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 status to succeed with repo scope
  • 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 release delta between main and the PR binary
  • the prepare release total row 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 Command is exposed directly as monochange step <kebab-step-name>, for example monochange step discover, monochange step prepare-release, and monochange step affected-packages. These commands are generated by the binary, derive their flags from the step schema, and do not require a [cli.*] entry in monochange.toml.
  • config-driven workflow commands: every [cli.<command>] table in monochange.toml becomes monochange run <command>. monochange init does not seed default workflow aliases; add these tables when you want a named workflow that chains steps, adds custom inputs, or runs Command steps.

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:

  1. what state the command needs
  2. which step produces that state
  3. which later step consumes it
  4. 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:

  1. [[cli.<command>.inputs]] declares the flags and arguments accepted by monochange run <command>.
  2. inputs on 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

StepUse it when you want to…Requires previous step?Typical follow-up
Configinspect resolved configuration and workspace metadatanolocal debugging, CI artifacts
Validatefail fast on invalid config, groups, or changesetsnoCI gate or local preflight
Discoverinspect normalized package discovery across ecosystemsnolocal inspection, debug commands
CreateChangeFileauthor a .changeset/*.md file from CLI inputsnorun independently, or before planning
PrepareReleasebuild the release result, update files, and refresh the cached manifestnoCommitRelease, PublishRelease, OpenReleaseRequest, CommentReleasedIssues, Command
DisplayVersionsdisplay planned package and group versions without mutating release filesnoPrepareRelease
CommitReleasecreate a local release commit with an embedded ReleaseRecordPrepareReleaseOpenReleaseRequest, manual review, custom Command
ReleaseRecordinspect an embedded release record from a commit or tagrelease commit or tagtag repair, publish readiness, custom auditing
VerifyReleaseBranchverify a ref is reachable from configured release branches[source.releases]early release CI gates; enforced internally by tag and publish paths
TagReleasecreate and optionally push release tags from an embedded release recordrelease commitsource-provider releases, package publishing, custom announcements
PublishReadinesscheck package-registry readiness without publishing packagesrelease commit or tagPlanPublishRateLimits, human review, package publishing
PublishReleasecreate or update hosted provider releasesPrepareRelease + [source]CommentReleasedIssues, custom notification commands
OpenReleaseRequestcreate or update a hosted release PR/MRPrepareRelease + [source]provider review, follow-up Command steps
PlanPublishRateLimitsplan package-registry publish work against known rate limitsnoPublishPackages, PlaceholderPublish
PlaceholderPublishpublish 0.0.0 placeholder versions for missing registry packagesnonormally before PublishPackages
PublishPackagespublish package versions to registries using built-in ecosystem workflowsprepared or HEAD release statecustom Command steps using publish.*
CommentReleasedIssuespost release follow-up comments to closed issuesPrepareRelease + GitHub sourcenormally after PublishRelease
AffectedPackagesevaluate changeset coverage for changed filesnoCI enforcement, custom failure messaging
DiagnoseChangesetsinspect changeset context, commit provenance, and linked review metadatanolocal debugging, CI inspection
RetargetReleaserepair a recent release by moving its tag setnocustom Command steps using retarget.*
Commandrun arbitrary shell/program commands with monochange contextdepends on your workflowany 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-run flows through the whole command and changes the behavior of steps that support previews
  • --quiet suppresses stdout/stderr without changing execution; use --dry-run separately
  • a plain Command step 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:

  1. validation / inspection
    • Validate
    • Discover
    • DisplayVersions
    • AffectedPackages
    • DiagnoseChangesets
  2. change authoring
    • CreateChangeFile
  3. release preparation and publication
    • PrepareRelease
    • then one or more of CommitRelease, PublishRelease, OpenReleaseRequest, CommentReleasedIssues, Command
  4. 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:

  • auto enables the human renderer only when stderr is a terminal
  • unicode forces the human renderer with Unicode symbols
  • ascii forces the human renderer with ASCII-safe symbols
  • json emits 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.* after PrepareRelease
  • manifest.path after PrepareRelease
  • affected.* after AffectedPackages
  • retarget.* after RetargetRelease
  • release_commit.* after CommitRelease
  • steps.<id>.stdout and steps.<id>.stderr after a Command step with id = "..."

Those namespaces are the main reason to prefer built-in steps over reimplementing the same workflow in shell.

Pages in this section

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.toml before 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_url and [source].host must use https://; insecure http:// 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:

  1. Validate
  2. Command for extra project-specific checks
  3. 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-release checks
  • 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: text or json

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, and details
  • 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 arguments
  • package: list of package or group ids to target
  • bump: none, patch, minor, or major
  • version: explicit version pin for the change
  • reason: summary line
  • type: optional release-note type
  • caused_by: optional list of package or group ids that explain dependency-only follow-up changes
  • details: optional long-form body
  • output: 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 package in non-interactive mode
  • expecting CreateChangeFile to release anything immediately
  • using raw manifest paths when configured package ids are the stable interface
  • forgetting caused_by when 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: text or json
  • changed_paths: explicit changed paths
  • from: revision to diff against; takes priority over changed_paths
  • verify: whether to enforce non-zero failure on uncovered packages
  • label: 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.status and affected.summary to later Command steps
  • 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 from and changed_paths and forgetting from wins
  • 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: text or json
  • changeset: 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 DiagnoseChangesets to modify anything
  • assuming it prepares release state for later publication steps
  • using it when a simpler monochange step validate failure would already answer the question
  • forgetting to switch to json output 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 CommitRelease left the durable release record you now want to reuse safely

Inputs

  • from: tag or commit-ish used to discover the release record
  • target: commit-ish to move the release to; defaults to HEAD
  • force: allow non-descendant retargets
  • sync_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 later Command steps

Commonly useful fields include:

  • retarget.from
  • retarget.target
  • retarget.record_commit
  • retarget.resolved_from_commit
  • retarget.distance
  • retarget.tags
  • retarget.provider_results
  • retarget.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:

  1. run the repair command with --dry-run
  2. inspect retarget.status, retarget.tags, and the proposed target
  3. rerun without --dry-run once 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.

  • PrepareRelease answers: “what should be released now?”
  • RetargetRelease answers: “how should an already-recorded release be repaired?”

Also avoid:

  • skipping --dry-run when the repair is high risk
  • using force without 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.json artifact exposed as manifest.path
  • later steps such as CommitRelease, PublishRelease, OpenReleaseRequest, and CommentReleasedIssues

If your command eventually needs release metadata, start with PrepareRelease rather than trying to reconstruct that state in shell.

Inputs

  • format: markdown, text, or json
  • write_empty_release_record: boolean; write a release record even when no packages were released
  • release_json: boolean; write the release record (.monochange/releases/<hash>/release.json) during dry-run preview. By default monochange preview skips the release record; pass --release-json to 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 later Command steps
  • manifest.path for later Command steps 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 → PublishRelease
  • PrepareRelease → OpenReleaseRequest
  • PrepareRelease → CommitRelease
  • PrepareRelease → 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 (RetargetRelease is separate)

Common mistakes

  • putting PublishRelease or OpenReleaseRequest before PrepareRelease
  • assuming PrepareRelease is just a read-only planner in non-dry-run mode
  • forgetting that later Command steps can consume its structured output directly
  • forgetting that --quiet suppresses 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 ReleaseRecord needed for later repair or inspection flows
  • hand off a prepared release to later custom Command steps without reconstructing commit metadata yourself

Inputs

CommitRelease accepts one optional step-level boolean input:

InputTypeDefaultDescription
update_release_jsonbooleanfalseWhen 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 PrepareRelease step in the same command
  • reuse a saved prepared release artifact from .monochange/local/prepared-release-cache.json or --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.subject
  • release_commit.body
  • release_commit.commit
  • release_commit.tracked_paths
  • release_commit.dry_run
  • release_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:

  1. PrepareRelease
  2. CommitRelease
  3. OpenReleaseRequest

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 CommitRelease as a replacement for PrepareRelease
  • assuming the cached .monochange/release-manifest.json artifact must be committed for CommitRelease to succeed
  • assuming it publishes releases or opens a release request by itself
  • forgetting that --dry-run previews the commit rather than creating it
  • reaching for a custom git commit command and then losing durable release metadata
  • running a formatter (such as dprint fmt) between PrepareRelease and CommitRelease without setting update_release_json = true on the CommitRelease step

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 record
  • format: text or json output, defaulting to text

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 to HEAD.

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-release enforces [source.releases] when enforce_for_tags = true.
  • PublishRelease and PublishPackages enforce [source.releases] during real publish runs when enforce_for_publish = true.
  • CommitRelease enforces [source.releases] only when enforce_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 ref
  • push: boolean, defaulting to true; set --push=false to create tags locally without pushing
  • dry_run: preview without creating or pushing tags
  • format: text or json, defaulting to text

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 PlanPublishRateLimits in 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 record
  • format: text, markdown, or json, defaulting to text
  • package: optional repeated package ids used to restrict the report
  • output: 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-publish first

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, or json
  • mode: publish (default) or placeholder
  • package: optional repeated package ids used to filter the plan
  • readiness: optional path to a JSON artifact from monochange step publish-readiness; only valid when mode = "publish"
  • ci: optional github-actions or gitlab-ci snippet 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 readiness is 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: text or json

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 PrepareRelease step 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: text or json

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 PrepareRelease step 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_chars overrides 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:

  1. It builds the GitHub API client from GITHUB_TOKEN or GH_TOKEN.
  2. It reads the pushed fallback commit through the Git Database API.
  3. It creates a new Git commit object with the same message, tree, and parents.
  4. It accepts the replacement only when GitHub returns verification.verified = true for the new commit.
  5. 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: text or json
  • from-ref: git ref that contains the release record to publish
  • auto-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 PrepareRelease step 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:

  1. PrepareRelease
  2. PublishRelease
  3. CommentReleasedIssues

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 mode
  • when: optional boolean condition controlling whether the step runs
  • dry_run_command: optional replacement command used only when the command runs with --dry-run
  • shell: whether to run through a shell (true, false, or a custom shell binary name)
  • id: optional identifier that exposes steps.<id>.stdout and steps.<id>.stderr to later steps
  • variables: optional custom variable mapping for command substitution
  • inputs: optional step-local input overrides
  • show_progress: optional boolean; set to false when the command itself is interactive and spinner output would get in the way
  • interactive: optional step input; when set to true the 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; unlike show_progress = false, the command can read from stdin and render directly to the terminal. Output is not captured, so steps.<id>.stdout and steps.<id>.stderr are 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 to true to 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 consume release.* and manifest.path
  • after AffectedPackages, it can consume affected.*
  • after RetargetRelease, it can consume retarget.*
  • after CommitRelease, it can consume release_commit.*
  • after another named Command, it can consume steps.<id>.*

Side effects and outputs

  • runs an external command
  • records stdout/stderr when id is 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

A good workflow usually looks like this:

  1. use built-in steps to create stable state
  2. use Command only for the final custom integration points
  3. give important custom steps an id so later steps can consume structured stdout

Common mistakes

  • using Command to reimplement PublishRelease or OpenReleaseRequest
  • forgetting dry_run_command when the real command would mutate external systems
  • omitting id and then having no clean way to reuse the command’s output later
  • relying on shell features without setting shell = true or 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, or json

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, or json
  • does not update manifests, changelogs, or consumed changesets
  • does not write release.json or the prepared-release cache under .monochange/local/
  • does not require a previous PrepareRelease step

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 PrepareRelease in publish or release-request workflows
  • bundling it into long multi-step commands when monochange next is 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 PublishPackages can 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 later Command steps

Use PublishPackages instead when you want to publish the real planned versions from a prepared release.

Inputs

  • format: text, markdown, or json
  • package: 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.0 placeholder 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 json is selected, regardless of --show-all
  • contributes publish.* and publish_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 PlaceholderPublish with PublishPackages: the former publishes 0.0.0 placeholders, the latter publishes the real planned versions
  • forgetting that PlaceholderPublish does not require PrepareRelease, but PublishPackages does
  • 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:

  1. Build the selected publish requests from the prepared release or HEAD release state.
  2. Materialize the workspace dependency graph.
  3. Consider only dependencies where both packages are part of the selected publish set.
  4. Ignore development dependency edges.
  5. 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 later Command steps

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, or json
  • package: optional repeated package ids used to filter the publish set
  • group: optional repeated group ids; all packages in each group are added to the publish set
  • ecosystem: optional repeated ecosystem names (cargo, npm, deno, dart, python, go; flutter is accepted as a legacy alias for dart); only packages targeting the selected ecosystems are published
  • resume: optional path to a JSON result artifact from an earlier real monochange step publish-packages or configured publish workflow run; completed package versions are skipped and failed or pending work is retried
  • output: optional path where monochange writes the package publish result JSON artifact for retry/resume workflows
  • show-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 HEAD that 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: no publish = false, any publish = [...] list includes crates-io, description is set, and either license or license-file is 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 json is selected, regardless of --show-all
  • when output is set, writes the package publish result artifact even if a registry publish command fails, then exits non-zero for failed package outcomes
  • contributes publish.* and publish_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 PublishPackages with PublishRelease: the former publishes to package registries, the latter creates hosted provider releases (such as GitHub releases)
  • assuming monochange step publish-packages consumes the JSON file from monochange step publish-readiness; use readiness for preflight review or monochange step plan-publish-rate-limits --readiness, not as a PublishPackages input
  • omitting output in 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 PublishPackages without rate-limit planning: use PlanPublishRateLimits first when you are unsure about registry windows