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
mc --help

Then run the core beginner flow:

Generate a starter config from the packages monochange detects:

mc init

mc init writes an annotated, minimal monochange.toml without default [cli.*] workflow aliases. The binary exposes immutable mc 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:

mc init --provider github

This configures the [source] section, generates CLI commands for commit-release and release-pr, and creates GitHub Actions workflows.

Validate the workspace:

mc step:validate

Discover the package ids you will use in commands and changesets:

mc discover --format json

Create one change file for a package id:

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

mc change --package <dependent-id> --bump none --caused-by <upstream-id> --reason "dependency-only follow-up"

Preview the release plan safely:

mc release --dry-run --format json

Add --diff when you want unified file previews for version and changelog updates without mutating the workspace:

mc 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 mc release.

For human-readable local output, mc release --dry-run now defaults to terminal-friendly markdown. Use --format json for automation, --format text when you explicitly want the older plain-text rendering, mc versions when you only need planned package and group versions, and --quiet when you want dry-run behavior without stdout/stderr output. mc versions is a dedicated non-mutating summary command and also supports --format markdown and --format json.

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.

Recent package publishing improvements

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

  • a dedicated trusted-publishing guide now covers npm, crates.io, jsr, and pub.dev
  • CI examples now prefer the official registry-maintained workflows for crates.io and pub.dev
  • a dedicated multi-package publishing guide now covers monorepo tag, workflow, and package-boundary patterns
  • CLI output now gives clearer manual next steps for registries that still require registry-side trusted-publishing enrollment
  • built-in publish preflight now validates and reports the expected GitHub repository, workflow, and environment context for manual registries when it can infer them

Command and automation matrix

These are common commands for repositories using monochange. With the current CLI model, workflow names such as discover, change, release, publish, and affected come from optional [cli.*] tables in monochange.toml; binary commands such as check, init, and mcp stay built in, while typed built-in operations such as validation are exposed as immutable mc step:* commands.

GoalCommandUse it when
Validate config and changesetsmc step:validateYou changed monochange.toml or .changeset/*.md files
Inspect package ids and groupsmc discover --format jsonYou need the normalized workspace model
Create release intentmc change --package <id> --bump <severity> --reason "..."You need a new .changeset/*.md file
Audit pending release contextmc step:diagnose-changesets --format jsonYou need git provenance, PR/MR links, or related issues
Preview the release planmc release --dry-run --diffYou want changelog/version patches without mutating the repo
Create a durable release commitmc commit-releaseYou want a monochange-managed release commit with an embedded ReleaseRecord
Open or update a release requestmc release-prYou want a long-lived release PR/MR branch updated from current release state
Inspect a past release commitmc step:release-record --from <ref>You need the durable release declaration from git history
Check package publish readinessmc step:publish-readiness --from HEAD --output <path>You want a non-mutating preflight report before package publication
Plan ready package publishingmc publish-plan --readiness <path>You want rate-limit batches that exclude non-ready package work
Publish packages to registriesmc publish --output <path>You want cargo publish, npm publish, deno publish, or dart pub publish style package publication
Bootstrap release packagesmc step:placeholder-publish --from HEAD --output <path>You need a release-record-scoped placeholder bootstrap artifact before rerunning readiness
Create post-merge release tagsmc step:tag-release --from HEADYou merged a monochange release commit and now need to create and push its declared tag set
Repair a recent releasemc repair-release --from <tag> --target <commit>You need to retarget a just-created release to a later commit
Publish hosted/provider releasesmc publish-releaseYou want GitHub/GitLab/Gitea release objects from prepared release state

mc step:publish-readiness performs non-mutating registry checks before mc publish. 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 mc step:publish-readiness after those inputs change. mc publish-plan --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. mc publish 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 mc step:placeholder-publish --from HEAD --output .monochange/bootstrap-result.json, then rerun readiness before real publishing.

What monochange can do today

  • 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 top-level CLI commands 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 mc step:release-record
  • create post-merge release tags from a merged release commit with mc step:tag-release --from HEAD
  • repair a recent source/provider release by retargeting its release tags with mc repair-release
  • inspect changeset context and review metadata with mc 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 mc 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 mc 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
mc --help

If you prefer a Rust-native install, use:

cargo install monochange
monochange --help
mc --help

2. Generate a starter config

Run mc init at the repository root:

mc init

mc 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

mc step:validate

This checks monochange.toml and your .changeset/*.md files together.

4. Discover package ids

mc 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

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

mc change \
  --package <id> \
  --bump minor \
  --reason "add release preview improvements" \
  --details "Adds file diff previews during dry runs."

6. Preview the release plan safely

mc release --dry-run

By default this now renders a human-friendly markdown preview in the terminal. Use --format json when you want structured output for tooling, --format text when you explicitly want the older plain-text rendering, or mc versions when you only need the planned package and group versions. mc versions is read-only, so it will not update release files.

When you want to see the exact file patch without mutating the workspace, add --diff:

mc release --dry-run --diff

When you want to inspect changeset provenance before releasing, add a diagnostics pass:

mc 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 mc release --quiet. Quiet mode suppresses stdout/stderr and keeps release-oriented commands in dry-run behavior.

If you hit a problem

  • mc init says a config already exists: keep the existing monochange.toml and continue with mc step:validate, or pass --force to regenerate.
  • mc step:validate reports problems: fix the reported config or changeset issue, then rerun mc step:validate.
  • mc change rejects your target: rerun mc 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
mc --help

Then continue with Start here or Your first release plan.

Alternative: Cargo

If you prefer to install from Rust tooling instead:

cargo install monochange
monochange --help
mc --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:

mc help skill
mc skill
mc skill --list
mc skill -a pi -y

mc skill forwards the remaining arguments to the upstream skills add flow, so you can keep the interactive prompts or pass the native --agent, --skill, --copy, --all, --global, and --yes flags directly.

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/changesets.md — changeset authoring and lifecycle guidance
  • skills/commands.md — built-in command catalog and workflow selection
  • skills/configuration.mdmonochange.toml setup and editing guidance
  • skills/linting.md[lints] presets, mc 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 names

The main CLI is monochange and the short alias is mc.

Repository development

If you are working on the monochange repository itself, use the reproducible development shell:

devenv shell
install:all
mc step:validate
mc discover --format json
mc change --package monochange --bump minor --reason "add release planning"
mc step:diagnose-changesets --format json
mc release --dry-run --format json
mc publish-release --dry-run --format json
mc release-pr --dry-run --format json
mc step:release-record --from v1.2.3
mc step:tag-release --from HEAD --dry-run --format json
mc step:publish-readiness --from HEAD --output .monochange/readiness.json
mc step:placeholder-publish --from HEAD --output .monochange/bootstrap-result.json
mc step:publish-readiness --from HEAD --output .monochange/readiness.json
mc publish-plan --readiness .monochange/readiness.json --format json
mc publish --output .monochange/publish-result.json
mc repair-release --from v1.2.3 --target HEAD --dry-run
mc release

Useful repository-development commands:

monochange --help
mc --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
mc 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 mc release --dry-run --format json, so nothing is published.

1. Generate a starter config with mc init

Run this at the repository root:

mc init

mc 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 mc step:* command, for example mc step:discover, mc step:create-change-file, and mc 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.

Automated CI setup with --provider

When you know which source provider you will use for release automation, include the --provider flag during initialization:

mc init --provider github

The --provider flag supports github, gitlab, and gitea. When provided, mc 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"

[source.releases]
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 automationrelease.yml refreshes the release PR on normal main pushes, then tags and publishes when the merged release commit lands on main
  • Changeset policy enforcementchangeset-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

mc 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 mc step:validate.

3. Discover the package ids you will actually use

mc step:validate
mc 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

mc 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

mc 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 mc 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 mc publish --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 mc step:publish-readiness --from HEAD --output .monochange/readiness.json for preflight review, then run mc publish
  • use mc 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

mc init says a config already exists

Keep the existing monochange.toml, inspect it, and continue with mc step:validate. If you want to regenerate the config from scratch, pass the --force flag:

mc init --force

mc step:validate reports config or changeset errors

Fix the reported issue first. mc step:validate is the fastest way to get back to a known-good workspace.

mc change says the package id is unknown

Run mc 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"

[release_notes]
change_templates = [
	"#### {{ summary }}\n\n{{ details }}\n\n{{ context }}",
	"#### {{ summary }}\n\n{{ context }}",
	"#### {{ summary }}\n\n{{ details }}",
	"- {{ summary }}",
]

[package.sdk-core]
path = "crates/sdk_core"
extra_changelog_sections = [
	{ name = "Security", types = ["security"], default_bump = "patch" },
]

[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",
]

name = "production"
trigger = "release_pr_merge"
release_targets = ["sdk"]
requires = ["main"]

[cli.discover]
help_text = "Discover packages across supported ecosystems"

[[cli.discover.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
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"]
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"]
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"]
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"]
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 today:

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

mc step:validate
mc 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 currently 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

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 mc 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 currently 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.

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
  • 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 automation is built in; pnpm workspaces use pnpm-compatible trust and publish commands

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 Dart ecosystem settings because both use pubspec.yaml and Pub’s version constraints.

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 dart for pure Dart packages and flutter for Flutter packages
  • internal dependency ranges default to ^
  • dependencies and dev_dependencies participate in dependency updates
  • 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
  • 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" }]

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]
parent_bump = "patch"
include_private = false
warn_on_group_mismatch = true
strict_version_conflicts = false
package_type = "cargo"

[defaults.changelog]
path = "{{ path }}/changelog.md"
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 = [
	"Cargo.toml",
	{ path = "crates/sdk_core/extra.toml", type = "cargo" },
]
tag = false
release = false
version_format = "namespaced"

[package.sdk-core.changelog]
path = "crates/sdk_core/CHANGELOG.md"
format = "monochange"

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
  • changelog
  • empty_update_message
  • publish
  • versioned_files
  • tag
  • release
  • version_format

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

extra_changelog_sections can also be set on:

  • [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 }}.

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
  • 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
  • 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 currently 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
  • Python packages → pypi

Private registries and custom publication flows are still external. For those packages, set mode = "external" and handle publication outside monochange.

Placeholder publishing

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

For each managed package with built-in publishing enabled, 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 mc plan-release-publish and mc publish.

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 packages can be configured automatically with npm trust github ...
  • pnpm workspaces use pnpm exec npm trust ... and pnpm publish, so workspace protocol and catalog dependency handling stays aligned with the workspace manager
  • Cargo, jsr, pub.dev, and PyPI currently 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 currently 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, automatic trust setup cannot proceed.

Current implementation limits

The built-in package publishing flow is intentionally narrow for now:

  • 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
  • manual trusted-publishing setup is still required for crates.io, jsr, pub.dev, and PyPI

If your workflow needs any of those today, 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"
  • 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 = "" },
]
versioned_files = [
	{ path = "package.json", type = "npm", fields = ["metadata.bin.monochange.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.

Regex versioned files

Regex entries let you version-stamp any plain-text file — README badges, download links, 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 mc 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 mc 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 top-level commands. Each [cli.<command>] table in monochange.toml becomes mc <command>, with its own help text, inputs, and ordered step list.

mc 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 mc step:* commands. The binary generates those commands from the step schemas, so mc step:discover, mc step:prepare-release, mc step:affected-packages, and the other step commands do not require config entries. Use step:* 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, and check. The step: prefix is reserved for immutable built-in step commands. Do not define [cli.step:*] tables.

Explicit step input inheritance

Config-defined workflow commands have two input layers:

  1. [[cli.<command>.inputs]] declares the flags and arguments accepted by mc <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"], 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", "markdown"], default = "markdown" },
	{ 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.

Built-in mc step:* commands are different: they are generated directly from the step schema, so their CLI flags map to that single step without a [cli.*] wrapper.

[release_notes]
change_templates = [
	"#### {{ summary }}\n\n{{ details }}\n\n{{ context }}",
	"#### {{ summary }}\n\n{{ context }}",
	"#### {{ summary }}\n\n{{ details }}",
	"- {{ summary }}",
]

[package.core]
path = "crates/core"
extra_changelog_sections = [
	{ name = "Security", types = ["security"], default_bump = "patch" },
]

[cli.discover]
help_text = "Discover packages across supported ecosystems"

[[cli.discover.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
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"]
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"]
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"]
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"]

name = "format"
type = "choice"
choices = ["text", "json"]
default = "text"

type = "PrepareRelease"

type = "Command"
command = "cargo test --workspace --all-features"
dry_run_command = "cargo test --workspace --all-features"
shell = true

[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]]
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 mc 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 mc repair-release versus publishing a new patch release.

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.

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"
# api_url = "https://github.company.com/api/v3"  # optional: for GitHub Enterprise

[source.releases]
enabled = true
draft = false
prerelease = false
source = "monochange"

[source.releases]
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

[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",
]

name = "production"
trigger = "release_pr_merge"
release_targets = ["sdk"]
requires = ["main"]

Ecosystem settings

These settings are parsed from config and document intended control points for discovery:

[ecosystems.cargo]
enabled = true
roots = ["crates/*"]
exclude = ["crates/experimental/*"]
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 = [
	{ command = "pnpm install --lockfile-only", cwd = "packages/web" },
]

[ecosystems.deno]
enabled = true
# Deno currently has no inferred lockfile command.

[ecosystems.dart]
enabled = true
lockfile_commands = [{ command = "flutter pub get", cwd = "packages/mobile" }]

[ecosystems.python]
enabled = true
lockfile_commands = [{ command = "uv lock" }]

[ecosystems.go]
enabled = true
# monochange infers `go mod tidy` for go.mod / go.sum refreshes.
lockfile_commands = [{ command = "go mod tidy" }]

Changelog configuration

When [defaults].package_type is set, package entries may omit an explicit type.

monochange currently supports two changelog 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.

You can also customize release-note rendering with a workspace-wide [release_notes] table plus per-package or per-group extra_changelog_sections definitions.

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 — without exposing the transient .changeset/*.md path unless you explicitly reference {{ changeset_path }} in your template.

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

Current implementation notes:

  • defaults.include_private is parsed, but discovery behavior is still centered on the supported fixture-driven CLI commands documented here
  • [ecosystems.*].enabled/roots/exclude are parsed, but discovery still scans all supported ecosystems regardless of those settings today
  • defaults.strict_version_conflicts controls whether conflicting explicit version entries across changesets warn-and-pick-highest (default) or fail planning outright
  • source automation expects [source] with provider release settings and release branch policy under [source.releases], pull request settings under [source.pull_requests], and affected-package policy settings under [changesets.affected]; GitHub remains the default provider
  • live GitHub release and release-request publishing uses octocrab with GITHUB_TOKEN / GH_TOKEN; GitLab and Gitea use direct HTTP APIs
  • release-request publishing still uses local git for branch, commit, and push operations before provider API updates when not in dry-run mode
  • changeset policy commands currently apply only to the GitHub provider and expect [changesets.affected], a changed_paths command input, and reusable diagnostics for GitHub Actions consumption
  • supported command steps today are Validate, Discover, CreateChangeFile, PrepareRelease, CommitRelease, PublishRelease, OpenReleaseRequest, CommentReleasedIssues, AffectedPackages, DiagnoseChangesets, RetargetRelease, and Command
  • see the CLI step reference for detailed per-step guidance, prerequisites, and composition examples

Validation

Run:

mc step:validate

mc step:validate validates:

  • package and group declarations
  • manifest presence for each package type
  • group membership rules
  • versioned_files structural rules (type/regex conflicts, 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"

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.

Release planning

Create a changeset with the CLI:

mc change --package sdk-core --bump minor --reason "public API addition"
mc change --package sdk-core --bump patch --type security --reason "rotate signing keys" --details "Roll the signing key before the release window closes."
mc change --package sdk-core --bump none --type docs --reason "clarify migration guidance" --output .changeset/sdk-core-docs.md
mc 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:

mc 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

Use scalar shorthand for plain bumps (sdk-core: minor) or for configured change types (sdk-core: security). To pin an exact version or combine bump, version, and type, use the object syntax:

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

When a dependent package changes only because another package moved first, author that context explicitly with caused_by:

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

mc step:validate

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:

mc release --dry-run --format json

For human-readable local output, mc release --dry-run now defaults to terminal-friendly markdown. Use --format text when you want the older plain-text style, or keep --format json for automation.

Preferred repository command flow:

mc release --dry-run --format json

When you want a reviewable patch-style preview of the filesystem changes without mutating the workspace, add --diff:

mc release --dry-run --diff
mc 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:

mc step:validate
mc discover --format json
mc step:diagnose-changesets --format json
mc release --dry-run --diff

Use each command for a different question:

  • mc step:validate — is the config and changeset set valid?
  • mc discover --format json — which package ids, groups, and dependency edges exist?
  • mc step:diagnose-changesets --format json — who introduced these changesets and what review context is attached?
  • mc 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
mc release --dry-runHuman review in the terminal
mc release --dry-run --diffHuman review plus exact file patches
mc release --dry-run --format jsonAutomation, scripts, MCP clients
mc 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 suppresses stdout/stderr and uses dry-run behavior for release-oriented commands so the workspace stays unchanged.

mc release

mc release is a config-driven workflow command only when your repository defines a [cli.release] table. mc init writes a minimal starter config and does not seed default workflow aliases, so use the immutable mc 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 only when your config defines them. If a repository has not opted into a named workflow, use the immutable step command instead, for example mc step:discover, mc step:create-change-file, mc step:prepare-release, mc step:affected-packages, mc step:diagnose-changesets, mc step:retarget-release, mc step:publish-readiness, or mc step:plan-publish-rate-limits.

mc step:validate is the immutable built-in step command for normal preflight checks. Do not define [cli.validate] or any [cli.step:*] command in monochange.toml; those names are reserved for built-in commands.

Commands like commit-release combine PrepareRelease with later stateful steps such as CommitRelease. Provider request workflows such as 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 extra_changelog_sections
  • applies workspace-wide release-note templates from [release_notes].change_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 -- mc "${args[@]}" | tee policy.raw
          awk 'BEGIN { capture = 0 } /^\{/ { capture = 1 } capture { print }' policy.raw > policy.json
          jq -e '.status != "failed"' policy.json >/dev/null

Current planning rules:

  • mc 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
  • mc 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
  • mc 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 default to the configured parent_bump, including packages outside a changed version group when they depend on a synchronized member
  • 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:

  • mc step:diagnose-changesets --format json — what is currently pending in .changeset/*.md, and who introduced it?
  • mc step:release-record --from <ref> — what did a past release commit declare durably in git history?
  • mc 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 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 and reuses dry-run behavior for commands that support it.

Concurrency

mc release is designed for sequential execution. Do not run multiple mc 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 detectedRegistry-side setup can be verified by monochangeRegistry-side setup can be automated by monochangeRegistry-native provenance / attestations
npmnpmGitHub Actions, GitLab CI/CDYesGitHub Actions onlyGitHub Actions only via npm trust github ...Yes, 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 currently the only ecosystem where monochange performs bulk trusted-publishing setup itself. 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.

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 mc placeholder-publish --dry-run to see which packages do not exist yet.
  3. If needed, run mc placeholder-publish so the package exists in the registry first.
  4. Complete the registry-side trusted-publishing setup for each package.
  5. Run mc publish --dry-run to confirm monochange now sees the expected trust configuration.
  6. Optionally generate a readiness artifact in CI with mc step:publish-readiness --from HEAD --output .monochange/readiness.json for preflight review or publish planning.
  7. Publish from CI with mc publish --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 → SettingsTrusted 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

  • monochange verifies the current trust configuration first.
  • If trust is missing, monochange can run the trust command automatically before npm publish or pnpm publish.
  • If approval is still required in the browser, npm’s own flow may still require human confirmation.
  • pnpm workspaces stay on pnpm exec npm trust ... and pnpm publish so workspace protocol and catalog dependency handling stay aligned with the workspace manager.

crates.io

Registry-side setup

crates.io supports trusted publishing through CI-issued OIDC. monochange currently 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 mc placeholder-publish. The first publish still uses the normal crates.io token flow.

UI path

  • crate page → SettingsTrusted 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 now 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 currently 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 currently 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 mc placeholder-publish.

UI path

  • https://pub.dev/packages/<package>/admin
  • find the Automated publishing section
  • click Enable publishing from GitHub Actions

Fields to enter

  • Repositoryowner/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, npm is the only registry where monochange performs trusted-publishing enrollment itself. For crates.io, jsr, pub.dev, and PyPI, monochange currently 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 today:

  • auto-enrolling registry-side trusted-publisher records for 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 today, 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:

  • mc release --dry-run --format json refreshes the cached manifest and shows the downstream automation payload
  • mc publish-release previews or publishes provider releases from the structured release notes
  • mc release-pr previews or opens an idempotent provider release request; when [source.pull_requests].verified_commits = true and the command 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
  • mc 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 mc init --provider

The fastest way to configure GitHub automation is using the --provider flag during initialization:

# Initialize with GitHub automation pre-configured
mc 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 commandscommit-release and release-pr commands ready to use
  3. GitHub Actions workflowsrelease.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

mc release --dry-run --format json
mc publish-release --dry-run --format json
mc release-pr --dry-run --format json
mc step:affected-packages --format json --verify --changed-paths crates/monochange/src/lib.rs

Inspecting and repairing a recent release

GitHub automation now 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:

mc step:release-record --from v1.2.3
mc step:tag-release --from HEAD --dry-run --format json
mc repair-release --from v1.2.3 --target HEAD --dry-run
mc repair-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
  • mc step:tag-release consumes that durable record after merge and creates the declared tag set on the default branch

Use --dry-run first for repair-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 mc 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:

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

  • mc step:publish-readiness --from HEAD --output <path> checks package registries before mutation
  • mc publish handles package registries such as crates.io, npm, jsr, and pub.dev
  • mc 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 are the only ecosystem with built-in bulk trust automation today:

  • monochange checks the existing trust configuration first
  • if trust is missing, it runs npm trust github ...
  • pnpm workspaces run the trust command through pnpm exec npm trust ...
  • monochange verifies the result after running the trust command instead of assuming success

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

[release_notes]
change_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"]
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"]
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 now 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 mc step:tag-release --from HEAD, runs mc step:publish-readiness --from HEAD --output <path>, and then runs mc publish
  • tag-triggered or downstream workflows can then build archives, create hosted releases, publish additional assets from the pushed tags, or run a separate mc 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 -- mc "${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 mc 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:

  • mc subagents <target...> generates repo-local agent, subagent, or rule files for supported harnesses
  • mc mcp starts a stdio MCP server so assistants can call monochange tools directly

Install the CLI and skill

Install the CLI:

npm install -g @monochange/cli
monochange --help
mc --help

Install the bundled skill into the current project:

mc help skill
mc skill
mc skill --list
mc skill -a pi -y

mc skill forwards the remaining arguments to the upstream skills add workflow, so you can either keep its interactive prompts or pass the native --agent, --skill, --copy, --all, --global, and --yes flags directly.

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/changesets.md — changeset authoring and lifecycle guidance
  • skills/commands.md — built-in command catalog and workflow selection
  • skills/configuration.mdmonochange.toml setup and editing guidance
  • skills/linting.md[lints] presets, mc 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:

mc help subagents
mc subagents claude
mc subagents pi codex
mc subagents --all --dry-run --format json

Supported targets currently include:

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

Generated subagents are CLI-first. They should prefer:

  1. mc
  2. monochange
  3. 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:

mc mcp

mc 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 mc step:validate before and after release-affecting edits.
  • Use mc discover --format json to inspect package ids, group ownership, and dependency edges.
  • Use mc step:diagnose-changesets --format json or monochange_diagnostics for a structured view of all pending changesets with git and review context.
  • Use monochange_lint_catalog and monochange_lint_explain when you need lint metadata without shelling out.
  • Prefer mc change plus .changeset/*.md files over ad hoc release notes.
  • Use mc release --dry-run --format json before mutating release state.

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_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 now 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 — introduced commit, linked PR, related issues — use mc step:diagnose-changesets --format json directly. It returns stable workspace-relative paths and structured records that agents can parse without reading raw markdown files.

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, GitHub integration — but uses a different configuration surface and adds cross-ecosystem support.

Quick comparison

Featureknopemonochange
Config fileknope.tomlmonochange.toml
CLI binaryknopemonochange / mc
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"]
extra_changelog_sections = [
	{ name = "Notes", types = ["note"] },
	{ name = "Documentation", types = ["docs"] },
]

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" }]
extra_changelog_sections = [
	{ name = "Notes", types = ["note"] },
	{ name = "Documentation", types = ["docs"] },
]

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",
	{ path = "Cargo.lock", dependency = "sdk_core" },
	{ path = "Cargo.toml", dependency = "sdk_core" },
]
changelog = "crates/sdk_core/changelog.md"

[packages.sdk_cli]
versioned_files = [
	"crates/sdk_cli/Cargo.toml",
	{ path = "Cargo.lock", dependency = "sdk_cli" },
]
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"
versioned_files = [
	"Cargo.lock",
	{ path = "Cargo.toml", dependency = "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 mc change to create changesets:

mc 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 publish-release 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 does not currently support regex-based version file updates. For now, handle these with a Command step:

[[cli.release.steps]]
type = "Command"
command = "sed -i 's/my_crate = \"[0-9.]*\"/my_crate = \"{{ version }}\"/' readme.md"
shell = true

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: mc release
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

For PR-based release flows with monochange, add a changeset policy workflow:

- name: run changeset policy
  run: |
    mc 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"]
extra_changelog_sections = [
	{ name = "Notes", types = ["note"] },
	{ name = "Documentation", types = ["docs"] },
]

[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"
extra_changelog_sections = [
	{ name = "Notes", types = ["note"] },
	{ name = "Documentation", types = ["docs"] },
]

[package.my_cli]
path = "crates/my_cli"
extra_changelog_sections = [
	{ name = "Notes", types = ["note"] },
	{ name = "Documentation", types = ["docs"] },
]

[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 `mc 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 mc <command>
  • Run mc step:validate to check config and changesets
  • Run mc release --dry-run to verify the release plan
  • Remove knope from your dependencies and install monochange

Diagnostics

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

mc step:diagnose-changesets

Inspect a specific changeset:

mc step:diagnose-changesets --changeset .changeset/feature.md

You can pass --changeset multiple times. Duplicate paths are deduplicated automatically:

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

mc step:diagnose-changesets --changeset feature.md

And absolute paths resolve correctly too:

mc step:diagnose-changesets --changeset /home/user/project/.changeset/feature.md

JSON output

Machine-readable diagnostics for scripting, CI, or AI consumption:

mc step:diagnose-changesets --format json

The JSON envelope includes:

  • requestedChangesets — 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 evidenceRefs
    • 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
  • lastUpdated — revision where it was most recently changed (omitted when same as introduced)
  • relatedIssues — issues linked by the changeset or the PR that introduced it

Each revision record includes:

  • commit.sha — full commit SHA
  • commit.shortSha — short SHA for display
  • reviewRequest — PR/MR number and URL when the commit is associated with a pull request

Command

Use the generated immutable step command directly:

mc 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

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

Because mc 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 — who introduced it, which PR it belongs to, 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

mc step:diagnose-changesets --format json | jq '[.changesets[] | select(.targets | length == 0)]'

Example: list all open review requests linked to pending changesets

mc step:diagnose-changesets --format json \
  | jq '[.changesets[].context?.introduced?.reviewRequest? | select(. != null) | .id] | unique'

Repairable releases

mc repair-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 mc step:tag-release --from HEAD instead. repair-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 now 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 mc 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.

mc step:release-record --from v1.2.3
mc step:release-record --from HEAD --format json
mc 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 mc repair-release when you want to move a recent release forward to a later commit.

mc repair-release --from v1.2.3 --target HEAD --dry-run
mc repair-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

repair-release 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
mc repair-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:
mc step:release-record --from v1.2.3
  1. You preview the repair:
mc repair-release --from v1.2.3 --target HEAD --dry-run
  1. You execute the repair:
mc repair-release --from v1.2.3 --target HEAD

What repair-release changes

repair-release 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 repair-release for just-created source/provider releases when the right fix is to move the release tags forward to a later commit.

Use 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 repair-release
  • if you need a new immutable published artifact, cut a new patch release

Configuration and step model

The user-facing command is:

mc repair-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 repair-release 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 mc publish, mc placeholder-publish, mc release-pr, mc 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, publish, and affected come from optional [cli.*] tables in monochange.toml; binary commands such as check, init, and mcp stay built in, while typed built-in operations such as validation are exposed as immutable mc step:* commands.

GoalCommandUse it when
Validate config and changesetsmc step:validateYou changed monochange.toml or .changeset/*.md files
Inspect package ids and groupsmc discover --format jsonYou need the normalized workspace model
Create release intentmc change --package <id> --bump <severity> --reason "..."You need a new .changeset/*.md file
Audit pending release contextmc step:diagnose-changesets --format jsonYou need git provenance, PR/MR links, or related issues
Preview the release planmc release --dry-run --diffYou want changelog/version patches without mutating the repo
Create a durable release commitmc commit-releaseYou want a monochange-managed release commit with an embedded ReleaseRecord
Open or update a release requestmc release-prYou want a long-lived release PR/MR branch updated from current release state
Inspect a past release commitmc step:release-record --from <ref>You need the durable release declaration from git history
Check package publish readinessmc step:publish-readiness --from HEAD --output <path>You want a non-mutating preflight report before package publication
Plan ready package publishingmc publish-plan --readiness <path>You want rate-limit batches that exclude non-ready package work
Publish packages to registriesmc publish --output <path>You want cargo publish, npm publish, deno publish, or dart pub publish style package publication
Bootstrap release packagesmc step:placeholder-publish --from HEAD --output <path>You need a release-record-scoped placeholder bootstrap artifact before rerunning readiness
Create post-merge release tagsmc step:tag-release --from HEADYou merged a monochange release commit and now need to create and push its declared tag set
Repair a recent releasemc repair-release --from <tag> --target <commit>You need to retarget a just-created release to a later commit
Publish hosted/provider releasesmc publish-releaseYou want GitHub/GitLab/Gitea release objects from prepared release state

A practical rule of thumb:

  • use mc step:publish-readiness for registry preflight reports and mc publish for registry package publication
  • use mc publish-release for hosted releases from prepared release state
  • use mc release-pr when you want a provider-backed release request branch
  • use mc commit-release when you want a durable local release commit in git history
  • use mc 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 planningmc release --dry-run, mc release, mc step:diagnose-changesets
  2. Package registriesmc step:publish-readiness, mc step:placeholder-publish --from HEAD, mc publish-plan --readiness <path>, mc publish, and lower-level mc placeholder-publish
  3. Hosted providersmc release-pr, mc publish-release, mc repair-release

Keeping those layers separate is important. Package publication and hosted-release publication are not the same job.

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
Dry-run release diff previewsBuilt in via mc release --dry-run --diff
Durable release history and post-merge taggingBuilt in via ReleaseRecord, mc step:release-record, mc step:tag-release, and mc repair-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 automationBuilt in
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 today
Release-retarget sync for hosted releasesGitHub first

CI setup assumption

The workflow sketches below assume the job already has:

  • the monochange CLI available as mc
  • 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 mc publish-release

The important current implementation detail is that mc step:publish-readiness can write a preflight artifact from the ReleaseRecord on HEAD, mc step:placeholder-publish --from HEAD --output <path> can run release-record-scoped first-time placeholder setup and record the result, mc publish publishes directly from prepared release or HEAD release state, mc step:tag-release can create the declared release tags from that same durable record, and mc 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 mc step:tag-release --from HEAD immediately after release-commit detection, then run mc step:publish-readiness --from HEAD --output <path>, use mc step:placeholder-publish --from HEAD --output <path> only when first-time package setup is required, optionally inspect mc publish-plan --readiness <path>, and finally run mc publish --output .monochange/publish-result.json. Rerun mc 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 mc publish --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 mc 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:

mc 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 -- mc 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 -- mc step:publish-readiness --from HEAD --output .monochange/readiness.json
          devenv shell -- mc publish

What monochange does here:

  • resolves the GitHub workflow context
  • checks current npm trust configuration
  • runs npm trust github ... when trust is missing
  • uses pnpm exec npm trust ... in pnpm workspaces
  • verifies the trust result after configuration

Use this when you want the most automated trusted-publishing path monochange currently supports.

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 -- mc 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 -- mc step:publish-readiness --from HEAD --output .monochange/readiness.json
          devenv shell -- mc 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, mc step:publish-readiness now 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 currently auto-configure crates.io trust the way it can for npm on GitHub
  • if you want the most literal crates.io/OIDC workflow today, 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 mc step:placeholder-publish --from HEAD --output .monochange/bootstrap-result.json 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 -- mc 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 -- mc step:publish-readiness --from HEAD --output .monochange/readiness.json
          devenv shell -- mc 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 for you today
  • 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 -- mc 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 -- mc step:publish-readiness --from HEAD --output .monochange/readiness.json
          devenv shell -- mc 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 today
  • if you want the most copy-pasteable pub.dev flow today, 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 mc step:release-record --from HEAD --format json in CI
  3. if the command succeeds, run mc step:publish-readiness --from HEAD --output .monochange/readiness.json
  4. run mc publish only after readiness succeeds
  5. if release-record detection or readiness fails, exit early before registry mutation

That pattern works well because mc step:publish-readiness and mc publish consume the durable ReleaseRecord from HEAD; readiness gives you a reviewable preflight report, while mc publish 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 today.

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 mc step:release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
        mc step:tag-release --from HEAD
        mc step:publish-readiness --from HEAD --output .monochange/readiness.json
        mc publish
      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 mc step:publish-readiness and mc publish.

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 mc step:release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
        mc step:tag-release --from HEAD
        mc step:publish-readiness --from HEAD --output .monochange/readiness.json
        mc publish
      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 mc step:release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
        mc step:tag-release --from HEAD
        mc step:publish-readiness --from HEAD --output .monochange/readiness.json
        mc publish
      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 mc step:release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
        mc step:tag-release --from HEAD
        mc step:publish-readiness --from HEAD --output .monochange/readiness.json
        mc publish
      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 now

monochange now supports the core post-merge pieces of this shape directly:

  • mc release-pr can open or update a release request branch from current release state
  • mc commit-release can create a durable monochange release commit with an embedded ReleaseRecord
  • mc step:release-record --from HEAD can detect whether the latest commit is a monochange release commit
  • mc step:tag-release --from HEAD can create and push the declared tag set from that merged release commit
  • mc step:publish-readiness can write a readiness artifact from that same durable record on HEAD, and mc publish 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 now:

  1. on every push to main, run mc release-pr 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 mc step:release-record --from HEAD --format json
  5. if the latest commit is a release commit, run mc step:tag-release --from HEAD
  6. after tags exist, run mc step:publish-readiness --from HEAD --output <path> and then mc publish 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 mc 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: mc release-pr

      - name: create release tags
        if: steps.release_record.outputs.is_release_commit == 'true'
        run: mc step:tag-release --from HEAD

      - name: publish packages
        if: steps.release_record.outputs.is_release_commit == 'true'
        run: |
          mc step:publish-readiness --from HEAD --output .monochange/readiness.json
          mc 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 mc step:release-record --from HEAD --format json >/tmp/release-record.json 2>/dev/null; then
        mc step:tag-release --from HEAD
        mc step:publish-readiness --from HEAD --output .monochange/readiness.json
        mc publish
      else
        mc release-pr
      fi

Choosing a CI pattern

Use this decision rule:

  • Need human review before release files land? → use mc release-pr
  • Need a durable local release commit? → use mc commit-release
  • Need package registries after merge? → detect ReleaseRecord on HEAD, run mc step:tag-release --from HEAD, then run mc step:publish-readiness --from HEAD --output <path> and mc publish
  • Need hosted provider releases from prepared release state? → use mc publish-release
  • Need to bootstrap release packages that do not exist yet? → use mc step:placeholder-publish --from HEAD --output <path>; reserve names outside a release with lower-level mc placeholder-publish
  • Need GitHub npm trusted publishing with the least custom glue? → use trusted_publishing = true with mc step:publish-readiness and mc publish
  • 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 mc step:publish-readiness and mc publish

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 -- mc 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 -- mc step:tag-release --from HEAD

      - name: publish packages
        run: |
          devenv shell -- mc step:publish-readiness --from HEAD --output .monochange/readiness.json
          devenv shell -- mc publish --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 mc publish 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 mc publish

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 mc step:publish-readiness + mc publish 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 mc step:publish-readiness + mc publish is often fine, but keep setup package-specificregistry linking is still manual today
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 mc publish --dry-run after registry enrollment changes
  6. optionally run mc step:publish-readiness --from HEAD --output <path> as a preflight before real mc publish
  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

mc publish-plan previews package-registry publish work against monochange’s built-in ecosystem rate-limit metadata.

mc step:publish-readiness --from HEAD --output .monochange/readiness.json
mc publish-plan --readiness .monochange/readiness.json --format json
mc publish-plan --mode placeholder --format json
mc publish-plan --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

mc publish-plan 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.

Current 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 mc step:publish-readiness --from HEAD --output <path>, then mc publish-plan --readiness <path>, then mc publish when you want CI to fail early instead of discovering registry throttling mid-release. Rerun mc 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 mc publish-plan --mode placeholder without a readiness artifact.

Filtering and enforcement

Both mc publish and mc 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 mc publish-plan --readiness <path> --package <id> so the plan can validate that the artifact covers the selected package subset. The later mc publish --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

mc publish-plan --ci github-actions renders a GitHub Actions job matrix snippet.

mc publish-plan --ci gitlab-ci renders a GitLab CI matrix snippet.

Both snippets use explicit mc publish --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 mc step:publish-readiness --from HEAD --package ... --output <path> when you want a preflight report for that subset; publish the batch with mc publish --package ....

Manifest linting with mc check

monochange can lint monorepo package manifests through mc check, using rules configured under [lints] in monochange.toml.

Use this guide when the task is to configure or explain monochange’s manifest lint rules.

These are the rules that run through mc 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.

What mc check does

mc check runs two phases:

  1. normal workspace validation, similar to mc step:validate
  2. manifest lint rules for supported package ecosystems

Common commands:

mc check
mc check --fix
mc check --format json
mc lint list
mc lint explain cargo/recommended

Use --fix when you want monochange to apply auto-fixes where a rule supports them.

Where lint rules live

Configure presets, global rules, and scoped overrides in the top-level [lints] section of monochange.toml:

[lints]
use = ["cargo/recommended", "npm/recommended", "dart/recommended"]

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

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.rules]
"changesets/duplicate" = "error"
"changesets/no_section_headings" = "error"
"changesets/summary" = { level = "error", required = true, heading_level = 2, min_length = 12, max_length = 80, forbid_trailing_period = true, forbid_conventional_commit_prefix = true }
"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" }

Supported changeset rule ids:

  • changesets/duplicate — validates that a changeset does not target the same effective package more than once.
  • changesets/no_section_headings — rejects headings that duplicate a change type used by that changeset.
  • changesets/summary — configures the one-line summary heading. Options: required, heading_level, min_length, max_length, forbid_trailing_period, forbid_conventional_commit_prefix.
  • changesets/bump/<severity> — configures rules for major, minor, or patch entries. Options: required_sections, forbidden_headings, min_body_chars, max_body_chars, require_code_block, required_bump.
  • changesets/types/<type> — configures rules for a configured changelog type such as breaking, feature, fix, security, or a custom type like unicorns. It accepts the same scoped options as bump rules. The <type> segment must match a configured changelog type.

Current rule coverage

Today, built-in manifest lint rules exist for:

  • Cargo manifests (Cargo.toml)
  • npm-family manifests (package.json)
  • Dart / Flutter manifests (pubspec.yaml)

Lint suites still live in ecosystem crates, but monochange routes all manifest lint configuration through the top-level [lints] section via preset selection, rule overrides, and scoped matches.

Cargo manifest lint rules

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

Useful option:

  • fix — defaults to true

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.

Useful options:

  • require_workspace — defaults to true
  • fix — defaults to true

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.

Useful option:

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

Useful option:

  • fix — defaults to true

cargo/unlisted-package-private

Why: a Cargo package that is not listed in monochange.toml should not be accidentally publishable.

Without the rule: an unmanaged crate can remain publicly publishable by accident.

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

Useful option:

  • fix — defaults to true

npm-family manifest lint rules

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, and Bun workspaces where internal packages should not drift to plain registry ranges.

Useful options:

  • require_for_private — defaults to false
  • fix — defaults to true

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

Useful option:

  • fix — defaults to true

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.

Useful option:

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

Useful option:

  • fix — defaults to true

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 suggest removing the redundant non-dev entry when appropriate.

Useful option:

  • fix — defaults to true

npm/unlisted-package-private

Why: a package not declared in monochange.toml should not remain publishable by accident.

Without the rule: an unmanaged package can still look publishable.

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

Useful option:

  • fix — defaults to true

Dart manifest lint rules

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.

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

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

Useful option:

  • fix — defaults to true

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.

Useful options:

  • allow_for_private — defaults to true
  • 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"] }

Workspace-aware Dart rules

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.

Useful option:

  • mode — choose "path" or "hosted"

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.

Flutter-only rules

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.

dart/assets-sorted

Why: stable ordering for flutter.assets and flutter.fonts reduces noisy diffs in Flutter packages.

Useful option:

  • fix — defaults to true

Dart presets

  • dart/recommended enables metadata/publishability checks, dart/sdk-constraint-present, and dart/dependency-sorted as a warning.
  • dart/strict adds 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, while promoting dart/dependency-sorted to an error.

Use mc lint list to inspect registered rules and presets, and mc lint explain <id> to understand a rule or preset before enabling it.

What mc check looks like in practice

Use plain text for local review:

mc check

Apply safe auto-fixes where possible:

mc check --fix

Use JSON for CI or MCP-style tooling:

mc check --format json

mc check fails when lint errors are present, so it is appropriate for CI gates.

For repository work:

mc step:validate
mc check
mc release --dry-run --diff

If you changed shared docs too:

devenv shell docs:check

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. Human progress output is enabled only when stderr is a terminal.
  • 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:

  • 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 live under the active step
  • completed PrepareRelease and DisplayVersions steps print per-phase timings so slow phases are visible without a separate trace

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:

  • command_started
  • step_started
  • command_output
  • step_finished
  • step_failed
  • step_skipped
  • command_finished

Shared fields:

  • sequence: monotonically increasing event sequence number for the command run
  • command: CLI command name, such as release
  • dryRun: whether the command is running in dry-run mode
  • totalSteps: total step count for the command
  • stepIndex: 1-based step index for step events
  • stepKind: built-in step kind, such as PrepareRelease
  • stepDisplayName: rendered human label for the step
  • stepName: explicit configured name, or null when omitted

Event-specific fields:

  • command_output adds stream and text
  • step_finished adds durationMs and phaseTimings
  • step_failed adds durationMs and error
  • step_skipped may add condition
  • command_finished adds durationMs

Example:

{"sequence":0,"event":"command_started","command":"release","dryRun":true,"totalSteps":2}
{"sequence":1,"event":"step_started","command":"release","dryRun":true,"stepIndex":1,"totalSteps":2,"stepKind":"PrepareRelease","stepDisplayName":"plan release","stepName":"plan release"}
{"sequence":2,"event":"step_finished","command":"release","dryRun":true,"stepIndex":1,"totalSteps":2,"stepKind":"PrepareRelease","stepDisplayName":"plan release","stepName":"plan release","durationMs":243,"phaseTimings":[{"label":"discover release workspace","durationMs":97}]}

Benchmark integration

The binary benchmark workflow uses --progress-format json to extract PrepareRelease phase timings for both mc release --dry-run and mc 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.mjs 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 mc 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 mc step:validate

Setting only MC_TELEMETRY_FILE also enables the local sink for that command:

MC_TELEMETRY_FILE=/tmp/mc-telemetry.jsonl mc 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 mc 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.mjs run-fixture \
  --main-bin /tmp/mc-main \
  --pr-bin /tmp/mc-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 mc 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 mc step:<kebab-step-name>, for example mc step:discover, mc step:prepare-release, and mc 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 mc <command>. mc 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 (Validate, Discover, AffectedPackages, DiagnoseChangesets, RetargetRelease, VerifyReleaseBranch). 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 mc <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"], 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", "markdown"], default = "markdown" },
	{ 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.

Built-in mc step:* 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
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
VerifyReleaseBranchverify a ref is reachable from configured release branches[source.releases]early release CI gates; enforced internally by tag and publish paths
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 and reuses dry-run behavior for commands that support it
  • 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 mc release --dry-run and mc release.

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

Interactive steps do not show the spinner by default when monochange is waiting on the user. For step kinds that support it, you can also set show_progress = false to suppress 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

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

mc step:validate
mc step:validate

validate is a built-in step command, so do not define [cli.validate] in monochange.toml. Use mc 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

  • formattext 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"]
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 mc 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 mc 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
  • bumpnone, 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
  • 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

  • formattext 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"]
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

  • formattext 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"]
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 mc 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
  • formattext 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.

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.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
default = "text"

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

  • formatmarkdown, 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. 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 now default their human-readable format input to markdown. Use text when you explicitly want the older plain-text style, or json for automation.

When you only need the resolved package and group versions, use the dedicated DisplayVersions step or the built-in mc 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"]
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"]
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:

  • PrepareReleasePublishRelease
  • PrepareReleaseOpenReleaseRequest
  • PrepareReleaseCommitRelease
  • PrepareReleaseCommand

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 now reuse a prepared release artifact instead of recomputing the release plan from scratch.

The default path is automatic:

mc release
mc release-pr --dry-run

mc release stores the prepared state in .monochange/prepared-release-cache.json, and later commands with a PrepareRelease step reuse it when the git HEAD, workspace status, tracked release inputs, and relevant configuration still match.

That .monochange/ directory is meant for local monochange artifacts. Keep it gitignored so reusable prepared state, the cached release manifest, and other local release metadata do not pollute reviewable commits.

If you need to pass the artifact between explicit jobs or custom commands, use --prepared-release:

mc release --prepared-release /tmp/release-plan.json
mc release-pr --prepared-release /tmp/release-plan.json --format json

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 stdout/stderr and forces dry-run behavior when the command supports dry-run semantics

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 mc step:release-record and mc repair-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/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.

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

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:

  • mc 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.

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

  • formattext, markdown, or json
  • modepublish (default) or placeholder
  • package — optional repeated package ids used to filter the plan
  • readiness — optional path to a JSON artifact from mc 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 mc 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 mc 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 the built-in top-level mc publish, mc step:publish-readiness, and mc placeholder-publish commands.

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 mc publish 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 mc step:publish-readiness --from HEAD --output <path> first only when you want a reviewable preflight report.

Inputs

  • formattext 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"]
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"]
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

  • formattext 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

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

  • formattext 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].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"]
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 today.

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
  • 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"]
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 mc versions.

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

Inputs

  • formattext, markdown, 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. 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 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

Use it as the built-in summary command

mc versions
mc versions --format markdown
mc 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 a dedicated versions command is clearer

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

  • formattext, 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.

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
  • reports only packages that need action by default; pass --show-all to include already-published and skipped packages
  • 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 = "Show already-published and skipped placeholder packages instead of only packages that need action"

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

  • formattext, 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, flutter, python, go); only packages targeting the selected ecosystems are published
  • resume — optional path to a JSON result artifact from an earlier real mc publish 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

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
  • 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 mc 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.steps]]
name = "publish packages"
type = "PublishPackages"
inputs = ["format", "package", "group", "ecosystem", "resume", "output"]

Composition ideas

Preview readiness before publishing

Use mc step:publish-readiness when you want a reviewable preflight report, then publish directly from the same release state:

mc step:publish-readiness --from HEAD --output .monochange/readiness.json
mc publish --output .monochange/publish-result.json

The readiness artifact is informational for PublishPackages; it is not required by mc publish. If a real publish fails after writing .monochange/publish-result.json, fix the registry/auth issue and rerun with mc publish --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

Common mistakes

  • confusing PublishPackages with PublishRelease: the former publishes to package registries, the latter creates hosted provider releases (such as GitHub releases)
  • assuming mc publish consumes the JSON file from mc step:publish-readiness; use readiness for preflight review or mc publish-plan --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