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.
What to read next
- Start here — install,
mc init, validation, discovery, and--dry-run - Installation — npm, Cargo, optional assistant tooling, and repository development setup
- Your first release plan — generated config first, package ids before groups
- Configuration reference — the full package, group, changelog, and CLI model
- Release planning — changesets, dry runs, diff previews, and planning rules
- Advanced: GitHub automation — provider publishing and release requests
- Advanced: CI, package publishing, and release PR flows — per-provider CI patterns, trusted publishing, and long-running release PR design notes
- Advanced: Assistant setup and MCP — optional AI-assisted workflows
- Reference: Manifest linting with
mc check—[lints]rules for Cargo and npm-family manifests
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, andpub.dev - CI examples now prefer the official registry-maintained workflows for
crates.ioandpub.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.
| Goal | Command | Use it when |
|---|---|---|
| Validate config and changesets | mc step:validate | You changed monochange.toml or .changeset/*.md files |
| Inspect package ids and groups | mc discover --format json | You need the normalized workspace model |
| Create release intent | mc change --package <id> --bump <severity> --reason "..." | You need a new .changeset/*.md file |
| Audit pending release context | mc step:diagnose-changesets --format json | You need git provenance, PR/MR links, or related issues |
| Preview the release plan | mc release --dry-run --diff | You want changelog/version patches without mutating the repo |
| Create a durable release commit | mc commit-release | You want a monochange-managed release commit with an embedded ReleaseRecord |
| Open or update a release request | mc release-pr | You want a long-lived release PR/MR branch updated from current release state |
| Inspect a past release commit | mc step:release-record --from <ref> | You need the durable release declaration from git history |
| Check package publish readiness | mc step:publish-readiness --from HEAD --output <path> | You want a non-mutating preflight report before package publication |
| Plan ready package publishing | mc publish-plan --readiness <path> | You want rate-limit batches that exclude non-ready package work |
| Publish packages to registries | mc publish --output <path> | You want cargo publish, npm publish, deno publish, or dart pub publish style package publication |
| Bootstrap release packages | mc step:placeholder-publish --from HEAD --output <path> | You need a release-record-scoped placeholder bootstrap artifact before rerunning readiness |
| Create post-merge release tags | mc step:tag-release --from HEAD | You merged a monochange release commit and now need to create and push its declared tag set |
| Repair a recent release | mc repair-release --from <tag> --target <commit> | You need to retarget a just-created release to a later commit |
| Publish hosted/provider releases | mc publish-release | You 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-changesetsfor 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/cliand the bundled agent skill as@monochange/skill - publish end-user documentation through the mdBook in
docs/
What the JSON output includes
Discovery output includes:
- normalized package records
- dependency edges
- release groups derived from configured groups
- warnings
Release-plan output includes:
- per-package bump decisions
- synchronized group outcomes
- compatibility evidence
- warnings and unresolved items
- optional
fileDiffspreviews when you request--diff
Contributing to monochange itself
If you are working on the monochange repository, run the full local validation suite before opening a PR:
lint:all
test:all
build:all
build:book
Start here
monochange is easiest to learn with one safe local walkthrough.
In about 10 minutes you will:
- install the CLI
- generate a starter
monochange.tomlwithmc 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:
- monochange discovers packages.
- You author explicit changes against package ids.
- monochange propagates dependent bumps for you.
- Groups synchronize packages that intentionally share release identity.
That is why most beginner flows should start with package ids, not groups.
If you need a silent safety check, run mc release --quiet. Quiet mode suppresses stdout/stderr and keeps release-oriented commands in dry-run behavior.
If you hit a problem
mc initsays a config already exists: keep the existingmonochange.tomland continue withmc step:validate, or pass--forceto regenerate.mc step:validatereports problems: fix the reported config or changeset issue, then rerunmc step:validate.mc changerejects your target: rerunmc discover --format jsonand copy a valid package id.- You are not sure what to do next: continue with Your first release plan.
Next steps
- Installation — install paths, optional assistant tooling, and repository development setup
- Your first release plan — a fuller walkthrough built around
mc init - Discovery — what discovery finds and how ids are rendered
- Configuration — evolve the generated config once the basics feel familiar
- Release planning — compare preview modes, grouped releases, and planning rules
- Advanced: GitHub automation — provider publishing, release PRs, and automation
- Advanced: Assistant setup and MCP — optional AI-assisted workflows
- Reference: Manifest linting with
mc check—[lints]rules for Cargo and npm-family manifests
Installation
If you want the fastest path to a first successful run, install the prebuilt CLI from npm.
Fastest path: npm
npm install -g @monochange/cli
monochange --help
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 agentsREFERENCE.md— broader high-context reference with more examplesskills/README.md— index of focused deep divesskills/adoption.md— setup-depth questions, migration guidance, and recommendation patternsskills/changesets.md— changeset authoring and lifecycle guidanceskills/commands.md— built-in command catalog and workflow selectionskills/configuration.md—monochange.tomlsetup and editing guidanceskills/linting.md—[lints]presets,mc check, and manifest-focused examplesexamples/README.md— condensed scenario examples for quick recommendations
This layout keeps the top-level skill small while still making the richer guidance available when an assistant needs more context.
Assistant-specific setup is covered in Advanced: Assistant setup and MCP.
CLI 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:
- Configures the
[source]section — adds provider-specific settings for releases and pull/merge requests - Generates provider CLI commands — includes
commit-releaseandrelease-prcommands inmonochange.toml - Creates workflow files (GitHub only) — writes
.github/workflows/release.ymland.github/workflows/changeset-policy.yml - Auto-detects owner/repo — parses
git remote get-url originto pre-populate[source]
Example generated configuration with --provider github:
[source]
provider = "github"
owner = "ifiokjr" # auto-detected from git remote
repo = "monochange" # auto-detected from git remote
[source.releases]
enabled = true
draft = false
prerelease = false
source = "monochange"
[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 automation —
release.ymlrefreshes the release PR on normalmainpushes, then tags and publishes when the merged release commit lands onmain - Changeset policy enforcement —
changeset-policy.ymlvalidates PRs have required changeset coverage
For GitLab and Gitea, the [source] section is configured but workflows are not generated (use their respective CI configuration files).
2. Validate the generated workspace
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 jsonif some packages still need a bootstrap0.0.0release so they exist in their registries first - use
mc publish --dry-run --format jsonto preview built-in package publication tocrates.io,npm,jsr, orpub.dev - before real package publication, optionally write a readiness artifact with
mc step:publish-readiness --from HEAD --output .monochange/readiness.jsonfor preflight review, then runmc publish - use
mc publish-release --dry-run --format jsononly for hosted/provider releases such as GitHub releases
Package ids vs. group ids
Use this rule of thumb:
- package ids first — most authored changes belong to one package
- group ids later — use a group id only when the change is intentionally owned by the whole group
That keeps your first changes simple while still letting monochange synchronize grouped packages when needed.
First-failure recovery
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.jsonpackages - Deno workspaces and standalone
deno.json/deno.jsoncpackages - Dart and Flutter workspaces plus standalone
pubspec.yamlpackages - Python uv workspaces, Poetry projects, and standalone
pyproject.tomlpackages - Go modules discovered from standalone
go.modfiles
Run discovery:
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 inmonochange.toml
Ecosystems
monochange uses ecosystem adapters to translate native package-manager files into one release-planning model. Each adapter answers the same questions:
- which package manifests exist in the repository?
- which packages depend on other packages?
- where should a package version and internal dependency references be rewritten during a release?
- should lockfiles be rewritten directly, refreshed with a command, or left to external tooling?
- can monochange publish the package directly, or should publication stay external?
Capability matrix
| Ecosystem | Package type | Discovery sources | Version and dependency updates | Lockfile behavior | Built-in registry publishing |
|---|---|---|---|---|---|
| Cargo | cargo | Cargo.toml workspaces and standalone crates | Cargo.toml package versions and internal dependency requirements | Direct Cargo.lock rewrite by default; configure cargo generate-lockfile, cargo check, or another command when you need package-manager resolution | crates.io |
| npm-family | npm | npm workspaces, pnpm workspaces, Bun workspaces, and standalone package.json packages | package.json versions and dependency ranges | Direct package-lock.json, pnpm-lock.yaml, bun.lock, and bun.lockb updates by default; command overrides support package-manager refreshes | npm |
| Deno | deno | Deno workspaces and standalone deno.json / deno.jsonc packages | Deno manifest versions, exports/imports metadata, and dependency references | Direct deno.lock update when possible; no inferred lockfile command | jsr |
| Dart / Flutter | dart, flutter | Dart and Flutter workspaces plus standalone pubspec.yaml packages | pubspec.yaml versions and dependency ranges | Direct pubspec.lock update by default; configure dart pub get or flutter pub get when you need full solver refreshes | pub.dev |
| Python | python | uv workspaces, Poetry projects, and standalone pyproject.toml packages | PEP 621 [project] and Poetry [tool.poetry] package versions plus dependency specifiers | Does not mutate uv.lock or poetry.lock directly; infers uv lock and poetry lock --no-update commands; unknown Python lockfiles are skipped | pypi |
| Go | go | Standalone go.mod modules | Internal require directives in go.mod; package versions stay in VCS tags | Does not mutate go.sum directly; infers go mod tidy so the Go toolchain refreshes go.mod and checksum data | Go module proxy via VCS tags |
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_filesentries, 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.lockis updated directly by default for fast release preparation- incomplete or complex lockfile cases can be delegated to explicit lockfile commands
- built-in publishing targets
crates.io - publish readiness validates common crates.io requirements, including
publish,description, and license metadata
npm, pnpm, and Bun
The npm-family adapter covers JavaScript and TypeScript packages that share package.json as their manifest format.
Use npm-family support when your repository has:
- npm workspaces declared in
package.json - pnpm workspaces declared in
pnpm-workspace.yaml - Bun workspaces and Bun lockfiles
- standalone
package.jsonpackages - internal workspace dependencies that use npm-compatible version ranges
npm-family behavior:
- package ids come from
package.jsonnames - internal dependency ranges default to the
^prefix dependencies,devDependencies, andpeerDependenciesparticipate in dependency updates- direct lockfile support covers
package-lock.json,pnpm-lock.yaml,bun.lock, andbun.lockb - built-in publishing targets the public
npmregistry - GitHub npm trusted-publishing 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.jsonordeno.jsoncpackage manifests- Deno workspace members
importsentries that connect internal packages- packages published to
jsr
Deno behavior:
- internal dependency ranges default to
^ importsare the primary dependency fielddeno.lockcan be updated directly when present- monochange does not infer a default Deno lockfile command; configure one if your release flow needs
deno cache,deno task, or another resolver step - built-in publishing targets
jsr
Dart and Flutter
Dart and Flutter share the 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
fluttersection - Dart or Flutter workspace layouts
- packages published to
pub.dev
Dart / Flutter behavior:
- package type is
dartfor pure Dart packages andflutterfor Flutter packages - internal dependency ranges default to
^ dependenciesanddev_dependenciesparticipate in dependency updatespubspec.lockcan be rewritten directly by default- configure
dart pub getorflutter pub getas lockfile commands when you need the Pub solver to refresh files instead of the direct updater - built-in publishing targets
pub.dev
Python
Python support is centered on pyproject.toml. It covers modern PEP 621 projects, Poetry projects, uv workspaces, and standalone packages discovered by scanning for manifests.
Use Python support when your repository has:
- a uv workspace declared under
[tool.uv.workspace] - PEP 621 package metadata under
[project] - Poetry package metadata under
[tool.poetry] - standalone Python packages with
pyproject.toml - internal dependencies that should receive version bumps alongside released workspace packages
Python discovery works in two passes:
- If the repository root has a
pyproject.tomlwith uv workspace members, monochange expands the member globs and reads each member manifest. - monochange then scans for standalone
pyproject.tomlfiles that were not already included by the uv workspace pass.
When a manifest has both PEP 621 and Poetry metadata, monochange prefers [project]. If [project].dynamic contains "version", monochange treats the package version as dynamic and does not rewrite the version field.
Python version and dependency behavior:
- package names and dependency names are normalized using Python’s PEP 503 style normalization for dependency graph matching
- PEP 440 versions are parsed into the shared semantic-version model when possible
- internal dependency ranges default to the
>=prefix - PEP 621
dependenciesare runtime dependencies - PEP 621
optional-dependenciesare development/optional dependency edges for release-planning purposes - Poetry
dependenciesare runtime dependencies, except the specialpythonconstraint is skipped - Poetry dependency groups under
[tool.poetry.group.<name>.dependencies]are development dependencies - release preparation rewrites
pyproject.tomlpackage versions and matching dependency specifiers while preserving extras such ashttpx[cli]
Python lockfile behavior is command-based by design:
uv.lockinfersuv lockpoetry.lockinferspoetry lock --no-update- unknown Python lockfile names are ignored rather than guessed
- configuring
[ecosystems.python].lockfile_commandsoverrides the inferred commands
Built-in Python publishing targets PyPI. monochange builds Python artifacts with uv build --out-dir dist and publishes them with uv publish, using --trusted-publishing always when trusted publishing is enabled and --trusted-publishing never otherwise. Placeholder publishing creates a minimal Hatchling project with a normalized module directory under src/.
Example Python package configuration:
[package.api]
path = "services/api"
type = "python"
changelog = true
[package.api.publish]
enabled = true
mode = "builtin"
registry = "pypi"
trusted_publishing = true
[ecosystems.python]
dependency_version_prefix = ">="
# Optional: override inferred uv/Poetry lockfile commands.
lockfile_commands = [{ command = "uv lock", cwd = "." }]
Go
Go support is centered on go.mod files. Go module versions live in VCS tags rather than manifest fields, so monochange updates internal require directives and records enough metadata for tag-based publishing.
Use Go support when your repository has:
- one or more modules declared by
go.mod - multi-module layouts where submodules need path-prefixed tags such as
api/v1.2.3 - internal module dependencies that should receive version bumps alongside released workspace modules
- packages published through normal Go module proxy discovery
Go behavior:
- package ids come from the module path in the
moduledirective - internal dependency ranges default to exact Go module versions with a leading
v, matching Go module semantics requiredirectives participate in dependency updates, including groupedrequire (...)blocks- Go v2+ semantic import versioning remains encoded in module paths, not a separate manifest version field
go.sumis treated as checksum data, not as a lockfile to patch directly- monochange infers
go mod tidywhengo.mod/go.sumchanges need package-manager refreshes - built-in publishing creates VCS tags: root modules use
v1.2.3, while submodules use path-prefixed tags such asapi/v1.2.3 - readiness and publish checks query the Go module proxy for
<module>/@v/<version>.infovisibility
Example Go package configuration:
[package.api]
path = "services/api"
type = "go"
changelog = true
[package.api.publish]
enabled = true
mode = "builtin"
registry = "go_proxy"
trusted_publishing = false
[ecosystems.go]
# Optional: override inferred tidy commands.
lockfile_commands = [{ command = "go mod tidy", cwd = "services/api" }]
Choosing external publishing
Use mode = "external" when an ecosystem or registry is not handled by monochange’s built-in publisher, or when your organization needs custom signing, provenance, approval, rate-limit, private-registry behavior, a Python publishing toolchain other than the built-in uv build / uv publish flow, or a Go publishing workflow that signs, pushes, or annotates tags outside monochange.
That keeps the package in release planning while leaving upload mechanics to your existing publishing workflow.
Configuration
Repository configuration lives in monochange.toml.
JSON Schema
A JSON Schema for editor support is published with the book at https://monochange.github.io/monochange/schemas/monochange.schema.json. That URL is the moving “current” alias for the latest docs. Stable generated copies use public schema-version suffixes, starting with https://monochange.github.io/monochange/schemas/monochange.v0.1.schema.json.
Schema-aware TOML editors such as Taplo can opt in with a comment directive at the top of monochange.toml:
#:schema https://monochange.github.io/monochange/schemas/monochange.schema.json
The same file is also available from GitHub raw content at https://raw.githubusercontent.com/monochange/monochange/main/docs/src/schemas/monochange.schema.json. Regenerate committed schema assets with schema:update and verify them with schema:check; lint:all runs the check in CI.
Shared documentation
This book is maintained with mdt so shared content blocks stay synchronized across pages.
- Shared blocks live in
.templates/*.t.md - Consumer files include them with
<!-- {=templateName} -->directives - Run
mdt update(ordocs:updatein this repository) after changing any template or consumer block - Run
mdt check(ordocs:check) before opening a PR to verify synchronization
When you edit a template such as .templates/cli-steps.t.md, the changes propagate to every documentation file that references it. This keeps the book, readmes, and inline help consistent without manual copying.
Defaults
[defaults]
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:
pathtype, unless[defaults].package_typeis set
Supported type values:
cargonpmdenodartflutterpython
Optional package fields:
type, when[defaults].package_typeis setchangelogempty_update_messagepublishversioned_filestagreleaseversion_format
changelog accepts three forms on packages:
true→ use{{ path }}/CHANGELOG.mdfalse→ disable the package changelog"some/path.md"→ use that exact path
[defaults].changelog also accepts three forms:
true→ default every package to{{ path }}/CHANGELOG.mdfalse→ default every package to no changelog"{{ path }}/changelog.md"or another pattern → replace{path}with each package path
A package-level changelog value overrides the default for that package.
The table form also accepts initial_header. monochange renders this Markdown only when a changelog file is created from empty content. Existing changelog preambles are preserved and are not rewritten on later releases. If initial_header is omitted or blank, monochange uses the selected format’s built-in header: keep_a_changelog gets the Keep a Changelog/SemVer preamble, and monochange gets the monochange-managed preamble. Package and group changelog tables can override the default header.
[defaults.changelog]
path = "{{ path }}/changelog.md"
format = "keep_a_changelog"
initial_header = """
# Changelog
All notable changes to this project will be documented in this file.
This changelog is managed by [monochange](https://github.com/monochange/monochange).
"""
initial_header templates can use release context such as {{ monochange_version }}, {{ config_path }}, {{ monochange_config_path }}, {{ workspace_root }}, {{ changelog_path }}, {{ changelog_format }}, {{ package }}, {{ package_name }}, {{ package_id }}, {{ package_path }}, {{ group }}, {{ group_name }}, {{ group_id }}, {{ member_count }}, {{ members }}, {{ release_owner }}, {{ release_owner_kind }}, {{ version }}, {{ new_version }}, and {{ current_version }}.
empty_update_message lets changelog targets render a readable fallback entry when a version update is required but no direct release notes were recorded for that target. This is especially useful for grouped packages that keep their own changelog entries even when only another member of the group changed.
empty_update_message can be set on:
[defaults][package.<id>][group.<id>]
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 publishingmode-builtinorexternalregistry- public registry override for the package ecosystemtrusted_publishing-true/falseor a table withenabled,repository,workflow, andenvironmentattestations.require_registry_provenance- require registry-native package provenance when the selected registry/provider capability supports itrate_limits.enforce- block built-in publish runs when the selected package set exceeds a known single registry windowplaceholder.readme- inline placeholder README contentpublish_order.dependency_fields- ecosystem-level dependency fields used to topologically order package publishesplaceholder.readme_file- workspace-relative file to use as placeholder README content
Inheritance flows from [ecosystems.<name>.publish] to matching packages, and package-level values override the inherited ecosystem defaults. Configure shared trusted-publishing, attestation, and context policy on the ecosystem, then use package-level publish settings for opt-outs or package-specific workflows.
Built-in publishing 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_proxyvia 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.readmeorplaceholder.readme_fileoverrides it
placeholder.readme and placeholder.readme_file are mutually exclusive. If both are set, config validation fails.
Publish order dependency fields
publish_order.dependency_fields controls which manifest dependency fields create publish-order edges for an ecosystem. npm defaults to dependencies and devDependencies, so peer packages do not block publishing unless opted in. Cargo defaults stay dependencies, dev-dependencies, and build-dependencies. Deno defaults to dependencies and imports, Dart/Flutter default to dependencies and dev_dependencies, Python defaults to dependencies, and Go defaults to require. Optional Python extras (optional-dependencies) and Poetry groups (group.dependencies) only affect publish order when you opt in.
[ecosystems.npm.publish_order]
# Add peer and custom package.json fields.
dependency_fields = ["dependencies", "devDependencies", "peerDependencies", "catalogDependencies"]
[ecosystems.npm.publish_order]
# Or remove devDependencies from publish ordering.
dependency_fields = ["dependencies"]
[ecosystems.python.publish_order]
# Include optional dependency groups in Python publish ordering.
dependency_fields = ["dependencies", "optional-dependencies", "group.dependencies"]
[ecosystems.go.publish_order]
# An empty list disables Go require-based publish ordering.
dependency_fields = []
The same resolved policy is used by 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 ...andpnpm publish, so workspace protocol and catalog dependency handling stays aligned with the workspace manager - Cargo,
jsr,pub.dev, andPyPIcurrently 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, andenvironmentvalues in config - otherwise
[source]plus GitHub Actions environment such asGITHUB_WORKFLOW_REFandGITHUB_JOB - and, when possible, the workflow job environment declared in
.github/workflows/<file>.yml
If monochange cannot determine the GitHub repository or workflow for an npm package, 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, andPyPI
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, andversion_formatoverride member package release identity - package changelogs and package
versioned_filesstill apply when grouped - grouped packages can customize fallback changelog entries with
empty_update_messagewhen no direct package notes are present [group.<id>.changelog].includecan filter which member-targeted changesets appear in the group changelog without changing release planning or package changelogs
For grouped changelog filtering, use the changelog table form:
[group.sdk.changelog]
path = "docs/sdk-changelog.md"
include = ["sdk-cli"]
include accepts:
"all"- include direct group-targeted changesets and all member-targeted changesets (default)"group-only"- include only direct group-targeted changesets[]or["package-id", ...]- include direct group-targeted changesets plus member-targeted changesets only when every target in that group is listed
Versioned files
versioned_files are additional managed files beyond native manifests.
Examples:
# package-scoped shorthand infers the package ecosystem
versioned_files = ["Cargo.toml"]
versioned_files = ["**/crates/*/Cargo.toml"]
# explicit typed entries remain available
versioned_files = [{ path = "group.toml", type = "cargo", name = "sdk-core" }]
versioned_files = [{ path = "docs/version.txt", type = "cargo" }]
versioned_files = [
{ path = "Cargo.toml", type = "cargo", fields = ["workspace.metadata.bin.monochange.version"], prefix = "" },
]
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:
regexentries cannot settype,prefix,fields, orname— they operate on raw text- the regex must include a
(?<version>...)named capture group - the
pathfield supports glob patterns (e.g.**/README.md) - regex entries work on packages, groups, and ecosystem-level
versioned_files
Lockfile commands
By default monochange rewrites supported lockfiles directly from the release plan. That keeps normal 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, andbun.lockb - Deno:
deno.lock - Dart / Flutter:
pubspec.lock
For Python projects, monochange infers package-manager lockfile commands instead of mutating lockfiles directly: uv.lock uses uv lock, and poetry.lock uses poetry lock --no-update. Unknown Python lockfile names are skipped rather than guessed.
If you configure lockfile_commands for an ecosystem, monochange stops using the built-in direct updater for that ecosystem and those commands fully own lockfile refresh. Use that escape hatch only when your workspace needs package-manager-side regeneration beyond version rewrites.
For Cargo specifically, monochange no longer falls back to cargo generate-lockfile automatically when a lockfile looks incomplete. That keeps 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:
[[cli.<command>.inputs]]declares the flags and arguments accepted bymc <command>.inputson each step decides which of those parsed command inputs are visible while that step runs.
Command inputs are not inherited automatically. A step receives a command input only when the step explicitly lists it. This makes wrappers predictable when a command-level flag and a step-specific input share the same name.
Use the array shorthand when a step should inherit command inputs unchanged:
[cli.discover]
inputs = [
{ name = "format", type = "choice", choices = ["text", "json"], 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
namelabels such asprepare releaseandpublish release; keep or replace those labels when you want progress output to stay readable - custom command variables become available when
variablesis present: map your own names to variables such asversion,group_version,released_packages,changed_files, andchangesets always_run = trueon any step causes it to run even when a previous step has failed, which is useful for cleanup, notification, or dry-run preview stepsupdate_release_json = trueon aCommitReleasestep allows the step to create or overwrite the release record file when it is missing or differs from the expected content; the default (false) treats a missing or mismatched record as an errordry_run_commandon aCommandstep replacescommandonly when the CLI command is run with--dry-rundry_run = trueon a[cli.<command>]table forces the entire command to run in dry-run mode even when the user does not pass--dry-runshell = trueruns the command through the current shell; the default mode runs the executable directly after shell-style splitting
Performance tip: keep the default 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:
monochangekeeps the current heading-and-bullets layoutkeep_a_changelogrenders section headings such as### Features,### Fixes, and### Breaking changes
Defaults can set a repository-wide changelog path pattern and format, while package and group changelog tables can override either field.
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:
| Variable | Meaning | Notes |
|---|---|---|
{{ summary }} | rendered release-note summary heading | always available |
{{ details }} | optional long-form details body | omitted when the changeset has no details |
{{ package }} | owning package id for the rendered entry | useful in shared templates |
{{ version }} | release version for the current target | package or group version |
{{ target_id }} | release target id | package id or group id |
{{ bump }} | resolved bump severity | none, patch, minor, or major |
{{ type }} | changeset note type | e.g. feature, fix, security; omitted when absent |
{{ context }} | compact default metadata block | preferred rendered block for human-readable notes |
{{ changeset_path }} | source .changeset/*.md path | tracked in manifests and still available for custom templates, but not shown by default in {{ context }} |
{{ change_owner }} | plain-text hosted actor label | usually something like @ifiokjr |
{{ change_owner_link }} | markdown link to the hosted actor | falls back to plain text when no URL is available |
{{ review_request }} | plain-text PR/MR label | e.g. PR #31 or MR !42 |
{{ review_request_link }} | markdown link to the PR/MR | falls back to plain text when no URL is available |
{{ introduced_commit }} | short SHA for the commit that first introduced the changeset | plain text only |
{{ introduced_commit_link }} | markdown link to the introducing commit | preferred for changelog output |
{{ last_updated_commit }} | short SHA for the most recent commit that changed the changeset | only populated when different from {{ introduced_commit }} |
{{ last_updated_commit_link }} | markdown link to the most recent commit that changed the changeset | only populated when different from {{ introduced_commit }} |
{{ closed_issues }} | plain-text list of issues closed by the linked review request | typically #12, #18 |
{{ closed_issue_links }} | markdown links to issues closed by the linked review request | preferred for changelog output |
{{ related_issues }} | plain-text list of related issues that were referenced but not closed | host support may vary |
{{ related_issue_links }} | markdown links to related issues that were referenced but not closed | host support may vary |
The *_link variants render markdown links when the hosting provider exposes URLs. By default {{ context }} renders the highest-value metadata for readers — owner, review request, introduced commit, last updated commit when different, and linked issues — 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_privateis parsed, but discovery behavior is still centered on the supported fixture-driven CLI commands documented here[ecosystems.*].enabled/roots/excludeare parsed, but discovery still scans all supported ecosystems regardless of those settings todaydefaults.strict_version_conflictscontrols whether conflicting explicitversionentries 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
octocrabwithGITHUB_TOKEN/GH_TOKEN; GitLab and Gitea use direct HTTP APIs - release-request publishing still uses local
gitfor 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], achanged_pathscommand input, and reusable diagnostics for GitHub Actions consumption - supported command steps today are
Validate,Discover,CreateChangeFile,PrepareRelease,CommitRelease,PublishRelease,OpenReleaseRequest,CommentReleasedIssues,AffectedPackages,DiagnoseChangesets,RetargetRelease, andCommand - 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_filesstructural rules (type/regex conflicts, capture groups)versioned_filescontent checks: file existence, version field readability, regex pattern matching.changeset/*.mdtargets and overlap rules- Cargo workspace version-group constraints
[source]url scheme security (https://required)
Groups and shared release identity
A configured group forces multiple packages to share one planned version and one outward release identity.
[package.sdk-core]
path = "cargo/sdk-core"
type = "cargo"
[package.web-sdk]
path = "packages/web-sdk"
type = "npm"
[group.sdk]
packages = ["sdk-core", "web-sdk"]
tag = true
release = true
version_format = "primary"
When any member releases:
- the highest required bump in the group wins
- every member in the group receives that bump
- one planned group version is calculated from the highest current member version
- the group owns outward release identity
- member package changelogs can still be updated individually
- group changelog and group
versioned_filescan also be updated - grouped packages can use
empty_update_messagewhen their own changelog needs a version-only update with no direct notes - dependents of newly synced members still receive propagated parent bumps
- unmatched members (not found during discovery) produce warnings; unresolvable members (invalid IDs) produce errors
- mismatched current versions produce warnings when
warn_on_group_mismatch = true
A changeset may reference the group id:
---
sdk: minor
---
#### coordinated SDK release
But a changeset may not reference both the group id and one of its members in the same file.
To keep a group changelog focused on public surfaces while leaving package changelogs detailed, configure the grouped changelog table:
[group.sdk.changelog]
path = "changelog.md"
include = ["sdk-cli"]
Direct group-targeted changesets are always included. Member-targeted changesets are filtered only for the group changelog; package changelogs and release planning remain unchanged.
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.jsoncaptures what monochange is preparing right now during command execution. ReleaseRecordcaptures what a release commit historically declared inside the monochange-managed release commit body.
Use the manifest when you want execution-time automation such as CI artifacts, MCP/server responses, previews, or downstream machine-readable release data.
Use the release record when you want to rediscover or repair a release later from a tag or descendant commit.
That is why ReleaseRecord does not replace the cached release manifest: one is an execution-time automation artifact, the other is a durable git-history artifact.
When you need to inspect or repair a recent release, see Repairable releases.
Generate a plan directly when you want to inspect the raw planner output:
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:
| Command | Best for |
|---|---|
mc release --dry-run | Human review in the terminal |
mc release --dry-run --diff | Human review plus exact file patches |
mc release --dry-run --format json | Automation, scripts, MCP clients |
mc release --dry-run --format json --diff | Automation that also needs file patch details |
When you want command semantics without any command-line noise, add --quiet. Quiet mode 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
monochangeorkeep_a_changelogformat - groups release notes into default
Breaking changes,Features,Fixes, andNotessections, with package/group overrides available throughextra_changelog_sections - applies workspace-wide release-note templates from
[release_notes].change_templates - refreshes the cached
.monochange/release-manifest.jsonartifact duringPrepareReleasefor downstream automation - can preview or publish provider releases via
PublishRelease - can preview or open/update release requests via
OpenReleaseRequest - can comment on released issues via
CommentReleasedIssues - can evaluate pull-request changeset policy via
AffectedPackagesusing changed paths and labels supplied by CI - applies group-owned release identity for outward
tag,release, andversion_format - deletes consumed change files only after a successful non-dry-run execution
- leaves the workspace untouched during
--dry-runexcept for explicitly requested outputs such as a rendered release manifest or release preview
A GitHub Actions check can pass changed paths and labels directly into a policy workflow, for example:
name: changeset-policy
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
concurrency:
group: changeset-policy-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check:
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: checkout repository
uses: actions/checkout@v6
- name: setup
uses: ./.github/actions/devenv
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: collect changed files
id: changed
uses: tj-actions/changed-files@v46
- name: run changeset policy
env:
PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }}
CHANGED_FILES: ${{ steps.changed.outputs.all_changed_files }}
shell: bash
run: |
set -euo pipefail
mapfile -t labels < <(jq -r '.[]' <<<"$PR_LABELS_JSON")
args=(step:affected-packages --format json --verify)
for path in $CHANGED_FILES; do
args+=(--changed-paths "$path")
done
for label in "${labels[@]}"; do
args+=(--label "$label")
done
devenv shell -- 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 changedefaults--bumptopatch; use--bump nonewhen you want a type-only or version-only entry, and pass--versionto pin an explicit release version- markdown change files use package/group ids as the only top-level frontmatter keys, with scalar shorthand for
none/patch/minor/majoror configured change types, plus object syntax forbump,version,type, andcaused_by - when
versionis given withoutbump, the bump is inferred by comparing the current and target versions - explicit versions from grouped members propagate to the group version; conflicts take the highest semver or fail when
defaults.strict_version_conflicts = true - prefer package ids over group ids in authored changesets when possible; direct package changes still propagate to dependents and synchronize configured groups
- optional change
typevalues can route entries into custom changelog sections, and configured sectiondefault_bumpvalues let scalar type shorthand imply the desired semver behavior caused_byreferences package or group ids and suppresses only the matching dependency-propagation records; use object syntax whenever you need itmc changeaccepts repeated--caused-by <id>flags, and--bump noneis the right fit when you want to acknowledge an affected package without forcing a user-facing version bumpmc changecan write to a deterministic path with--output ...- change templates support detailed multi-line release-note entries through
{{ details }}, compact metadata blocks through{{ context }}, and fine-grained linked metadata like{{ change_owner_link }},{{ review_request_link }}, and{{ closed_issue_links }} - dependents 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, andversion_formatmetadata - release-manifest JSON captures release targets, changelog payloads, authored changesets, linked changeset context metadata, changed files, and the synchronized release plan for downstream automation
PublishReleasereuses the same structured release data to build provider release requests for grouped and package-owned releasesOpenReleaseRequestreuses the same structured release data to render release-request summaries, branch names, and idempotent provider updatesCommentReleasedIssuescan use linked changeset context metadata to add follow-up comments to closed issues after a release is publishedAffectedPackagesevaluates changed paths, skip labels, and changed.changeset/*.mdfiles into reusable pass/skip/fail diagnostics and optional failure comments- CLI text and JSON output render workspace paths relative to the repository root for stable snapshots and automation
Diagnostics vs. release records
These commands answer different questions:
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— ifHEADis the merged release commit, which release tags should be created now?
Use diagnostics before you release. Use release records after a release exists and you need to inspect it. Use 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_proxyvia VCS tags
For those registries, monochange can also manage or verify trusted publishing when the registry supports publishing directly from a verified GitHub Actions identity. PyPI is supported by the built-in publisher through uv build and uv publish; PyPI trusted-publisher enrollment is still completed manually in the PyPI project settings.
Different registries use different names for the same general pattern:
- trusted publishing
- OIDC publishing
- automated publishing
- trusted publishers
The goal is the same in every case:
- publish from CI instead of local machines
- avoid long-lived registry tokens where possible
- restrict publish rights to a specific repository, workflow, and sometimes environment
Provider and registry capability matrix
Trusted publishing support is not uniform across registries or CI providers. monochange models these dimensions separately so it can be strict where support is verifiable and honest where setup still needs manual review.
| Ecosystem | Registry | Trusted-publishing providers modeled by monochange | Current CI identity can be detected | Registry-side setup can be verified by monochange | Registry-side setup can be automated by monochange | Registry-native provenance / attestations |
|---|---|---|---|---|---|---|
| npm | npm | GitHub Actions, GitLab CI/CD | Yes | GitHub Actions only | GitHub Actions only via npm trust github ... | Yes, npm provenance |
| cargo | crates.io | GitHub Actions | Yes | No | No | No registry-native package provenance |
| deno | jsr | GitHub Actions | Yes | No | No | Yes, JSR package provenance |
| dart / flutter | pub.dev | GitHub Actions, Google Cloud Build | Yes | No | No | No registry-native package provenance |
| python | PyPI | GitHub Actions, GitLab CI/CD, Google Cloud Build | Yes | No | No | Yes, PEP 740 digital attestations |
| go | Go proxy | None; VCS tags are used instead | N/A | N/A | Creates module tags | Source-control provenance only |
| custom/private | custom | None by default | Provider may be detected | No | No | Unknown |
monochange also detects CircleCI publish-time identity, but none of the built-in public registry combinations above are treated as CircleCI trusted-publishing support today. Unknown local shells and unsupported providers are never treated as trusted.
npm is 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.repositorypublish.trusted_publishing.workflowpublish.trusted_publishing.environment- otherwise
[source] - otherwise GitHub Actions runtime values such as
GITHUB_REPOSITORY,GITHUB_WORKFLOW_REF, andGITHUB_JOB
If your workflow filename or environment cannot be inferred reliably, set them explicitly in monochange.toml.
Attestation and provenance policy
Trusted publishing and attestations answer different questions:
- trusted publishing decides which CI/OIDC identity may publish a package
- registry-native package provenance records where a published package came from in registries that support it, such as npm provenance, JSR provenance, and PyPI PEP 740 attestations
- GitHub release artifact attestations cover release assets uploaded to GitHub Releases and are separate from package-registry provenance
Trusted publishing does not automatically require registry provenance. Enable provenance explicitly for registries where you want monochange to enforce it:
[ecosystems.npm.publish]
trusted_publishing = true
[ecosystems.npm.publish.attestations]
require_registry_provenance = true
Packages inherit publish.attestations from their ecosystem publish settings. Package-level settings can override or opt out:
[package.legacy.publish.attestations]
require_registry_provenance = false
When publish.attestations.require_registry_provenance = true, built-in release publishing fails before invoking the registry command unless all of these are true:
publish.trusted_publishing = trueis effective for the package.- The current environment exposes a verifiable CI/OIDC identity.
- The provider/registry capability matrix says registry-native provenance is available for that identity.
Today this is enforceable for npm trusted publishing and JSR publishing from supported OIDC contexts. The capability matrix records PyPI PEP 740 attestations as registry-native provenance, but monochange rejects require_registry_provenance for PyPI until the built-in Python publisher exposes a publish command that can require uploading those attestations. It also rejects crates.io, pub.dev, Go proxy publication, and custom registries because those flows do not expose registry-native package provenance that monochange can require without creating false assurance.
For GitHub release assets, keep the policy under [source.releases.attestations]:
[source]
provider = "github"
owner = "ifiokjr"
repo = "monochange"
[source.releases.attestations]
require_github_artifact_attestations = true
This setting is accepted only for the GitHub source provider. It records that release assets are expected to be covered by GitHub Artifact Attestations; the workflow that builds/uploads assets must still grant attestations: write and run the GitHub attestation action for the uploaded subjects.
GitHub Actions baseline
Most registries require the publish job to request an OIDC token.
permissions:
contents: read
id-token: write
If you use a protected deployment environment, keep the workflow and registry settings aligned:
jobs:
publish:
environment: publisher
Use the same environment name in GitHub Actions and in the registry configuration.
When publish.trusted_publishing = true, release publishing is a mandatory CI/OIDC flow. Built-in publish commands reject local/manual execution before invoking registry CLIs, require the configured GitHub repository/workflow/environment to match the current job, require id-token: write, and refuse long-lived npm token environment variables such as NPM_TOKEN or NODE_AUTH_TOKEN. Use publish.trusted_publishing = false only for packages that intentionally opt out.
Recommended rollout
Use this sequence when adopting trusted publishing for an existing workspace:
- Set
publish.trusted_publishing = truefor the target ecosystem, then override individual packages only when they differ. - Run
mc placeholder-publish --dry-runto see which packages do not exist yet. - If needed, run
mc placeholder-publishso the package exists in the registry first. - Complete the registry-side trusted-publishing setup for each package.
- Run
mc publish --dry-runto confirm monochange now sees the expected trust configuration. - Optionally generate a readiness artifact in CI with
mc step:publish-readiness --from HEAD --output .monochange/readiness.jsonfor preflight review or publish planning. - 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 → Settings → Trusted publishing
Fields to enter for GitHub Actions
- Organization or user — GitHub owner
- Repository — GitHub repository name
- Workflow filename — for example
publish.yml - Environment name — optional, for example
publisher
Only the workflow filename is required, not the full .github/workflows/... path.
CLI setup commands
These are the same commands monochange models for npm trusted publishing.
List the current trusted publishers for a package:
npm trust list <package-name> --json
Configure a package for a GitHub workflow:
npm trust github <package-name> \
--repo owner/repo \
--file publish.yml \
--yes
Add an environment restriction:
npm trust github <package-name> \
--repo owner/repo \
--file publish.yml \
--env publisher \
--yes
If the workspace uses pnpm, use the pnpm-wrapped form:
pnpm exec npm trust github <package-name> \
--repo owner/repo \
--file publish.yml \
--env publisher \
--yes
Workflow requirements
At minimum, the publish workflow should have:
permissions:
contents: read
id-token: write
monochange notes
- monochange verifies the current trust configuration first.
- If trust is missing, monochange can run the trust command automatically before
npm publishorpnpm 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 ...andpnpm publishso 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 → Settings → Trusted Publishing
Fields to enter for GitHub Actions
- Repository owner — GitHub owner
- Repository name — GitHub repository name
- Workflow filename — for example
release.yml - Environment — optional, for example
release
Use the workflow filename only, not the full .github/workflows/... path.
Workflow setup
A typical GitHub Actions release job looks like this:
name: Publish to crates.io
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: rust-lang/crates-io-auth-action@v1
id: auth
- run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
If you configure an environment on crates.io, the GitHub job must use the same environment name.
monochange notes
- monochange does not create the
crates.iotrusted-publisher record for you yet. - monochange 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-publishinghttps://rust-lang.github.io/rfcs/3691-trusted-publishing-cratesio.html
jsr
Registry-side setup
JSR supports tokenless publishing from GitHub Actions.
Manual setup step
- go to the package on
jsr.io - open Settings
- link the package to the GitHub repository that is allowed to publish it
monochange 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-packageshttps://jsr.io/docs/trust
pub.dev
Registry-side setup
pub.dev calls this automated publishing.
Automated publishing on pub.dev authenticates with a temporary GitHub-signed OIDC token instead of a long-lived pub credential.
Prerequisites
- the package must already exist on
pub.dev - you must be an uploader or admin for the package
- the repository must be on GitHub
If the package does not exist yet, publish it once first or use 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
- Repository —
owner/repo - Tag pattern — a string containing
{{version}}
Examples:
- single-package repo:
v{{version}} - monorepo package-specific tag:
my_package-v{{version}}
For a monorepo, give each package its own tag pattern so a tag for one package cannot publish another package by accident. The official pub.dev guidance also recommends a separate workflow file per package when one repository publishes multiple Dart packages. For a broader monorepo strategy across registries, see Multi-package publishing patterns.
Optional hardening
- click Require GitHub Actions environment on the package admin page
- choose an environment name such as
pub.dev - use the same environment name in the GitHub workflow
Workflow requirements
pub.dev only accepts GitHub Actions automated publishing when the workflow was triggered by a tag push. It rejects branch-triggered and manually dispatched workflows for this publishing flow.
That means the GitHub workflow trigger must align exactly with the configured tag pattern.
Recommended reusable workflow
pub.dev strongly encourages the reusable workflow maintained by dart-lang/setup-dart:
name: Publish to pub.dev
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
publish:
permissions:
id-token: write
uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1
# with:
# working-directory: path/to/package/within/repository
If you require a GitHub Actions environment on pub.dev, pass the same environment name to the reusable workflow:
jobs:
publish:
permissions:
id-token: write
uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1
with:
environment: pub.dev
# working-directory: path/to/package/within/repository
Custom workflow
If you need custom code generation or build steps, set up Dart yourself and publish manually after the OIDC-authenticated setup step:
dart pub publish --force
For Flutter packages, the equivalent publish command is:
flutter pub publish --force
monochange notes
- monochange does not configure pub.dev automated publishing for you yet.
- monochange reports the package admin URL so you can finish the setup manually.
- pub.dev is stricter than the others here because the workflow must be tag-triggered, not just manually dispatched or branch-triggered.
- The reusable workflow from
dart-lang/setup-dartis the officially recommended path and is worth preferring unless you need custom pre-publish steps. - Keep the Git tag,
pubspec.yamlversion, and tag pattern aligned. - Protect matching tags, and use GitHub environment protection rules when you need an approval gate before publishing.
Useful references:
https://dart.dev/tools/pub/automated-publishinghttps://pub.dev/packages/<package>/admin
Mapping monochange config to registry values
Use this cheat sheet when a registry asks for workflow details.
| Registry field | Value to use |
|---|---|
| repository owner / organization / namespace | GitHub owner from [source] or publish.trusted_publishing.repository |
| repository name / project | repository part of owner/repo |
| workflow filename | publish.trusted_publishing.workflow, for example publish.yml |
| environment | publish.trusted_publishing.environment, for example publisher |
| pub.dev tag pattern | choose a tag rule that matches your release workflow, for example v{{version}} or my_package-v{{version}} |
If monochange cannot infer the GitHub repository or workflow for a package, set them explicitly in monochange.toml.
Security recommendations
- prefer trusted publishing over long-lived registry tokens whenever the registry supports it
- keep
id-token: writeonly on the publish job instead of the entire workflow when possible - use a protected GitHub environment such as
publisherfor high-value publish jobs - restrict tag creation and release workflows to trusted maintainers
- use package-specific tags in monorepos when a registry supports tag-based publish authorization
When to keep mode = "external"
Keep a package on mode = "external" when:
- the registry is private or custom
- you need retry or delayed requeue behavior that monochange does not manage yet
- your registry requires a CI pattern that differs substantially from monochange’s built-in publish flow
In those cases, you can still use the same registry-side trusted-publishing setup while letting your own workflow own the actual publish command. The same approach is often the cleanest fit for multi-package repositories that need package-specific tags or workflows; see Multi-package publishing patterns.
Possible future automation for manual registries
monochange is intentionally conservative here.
Today, 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 publishingjsr— better repository-link diagnostics and package metadata checks before publish, especially when the package already exists but repository-side linking is incompletepub.dev— stronger validation that tag patterns, workflow triggers, working directories, and optional environments still match the automated-publishing setup expected by pub.dev- PyPI — stronger validation that the project trusted-publisher settings match the workflow name, environment, and package path monochange expects before running
uv publish
Areas that monochange does not promise 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 jsonrefreshes the cached manifest and shows the downstream automation payloadmc publish-releasepreviews or publishes provider releases from the structured release notesmc release-prpreviews or opens an idempotent provider release request; when[source.pull_requests].verified_commits = trueand 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 verifiedmc step:affected-packagesevaluates 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:
- Complete source configuration —
[source],[source.releases], and[source.pull_requests]sections - Automation CLI commands —
commit-releaseandrelease-prcommands ready to use - GitHub Actions workflows —
release.ymlandchangeset-policy.ymlfor CI/CD - Auto-detected repository info — parses your git remote to pre-fill owner and repo
CLI commands
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
ReleaseRecorddescribes the durable release declaration stored in the release commit bodymc step:tag-releaseconsumes 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 mutationmc publishhandles package registries such ascrates.io,npm,jsr, andpub.devmc publish-releasehandles hosted source-provider releases such as GitHub releases
When publish.trusted_publishing is enabled, monochange can derive GitHub trust metadata from the workflow runtime and the configured [source] block. npm packages 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.ymlrefreshes the dedicated release PR branch on normalmainpushes- the same workflow detects when
HEADis already a merged monochange release commit, runsmc step:tag-release --from HEAD, runsmc step:publish-readiness --from HEAD --output <path>, and then runsmc 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-releasejob when you still want manifest-driven hosted-release publication
That split keeps tag creation on the default branch side of the merge and lets downstream automation consume the exact durable release metadata that monochange stored in git history.
For release asset workflows, prefer tag or manual dispatch triggers over draft release.created triggers. Draft releases do not reliably emit release.created, and immutable releases need every archive to be uploaded and attested before the release is finalized. A hardened GitHub release asset job should request contents: write, id-token: write, and attestations: write, upload the .tar.gz and .zip archives, then attest the archive files directly instead of treating checksum files as a substitute.
After a release finishes, verify an archive with GitHub’s attestation CLI:
gh attestation verify monochange-x86_64-unknown-linux-gnu-v1.2.3.tar.gz \
--repo monochange/monochange
For release repair, GitHub is also the first provider with hosted-release retarget sync support. monochange uses the durable release record plus tag names from that record to keep the hosted release view aligned with moved tags.
GitHub Actions policy workflow
name: changeset-policy
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
concurrency:
group: changeset-policy-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check:
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: checkout repository
uses: actions/checkout@v6
- name: setup
uses: ./.github/actions/devenv
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: collect changed files
id: changed
uses: tj-actions/changed-files@v46
- name: run changeset policy
env:
PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }}
CHANGED_FILES: ${{ steps.changed.outputs.all_changed_files }}
shell: bash
run: |
set -euo pipefail
mapfile -t labels < <(jq -r '.[]' <<<"$PR_LABELS_JSON")
args=(step:affected-packages --format json --verify)
for path in $CHANGED_FILES; do
args+=(--changed-paths "$path")
done
for label in "${labels[@]}"; do
args+=(--label "$label")
done
devenv shell -- 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]inmonochange.toml - running a real
changeset-policyGitHub Actions workflow that shells intomc step:affected-packages - publishing the CLI npm packages from
.github/workflows/publish.ymlwith the protectedpublisherenvironment andid-token: write, withoutNODE_AUTH_TOKENorNPM_TOKEN
For monochange’s own npm packages, register every package under the GitHub trusted-publishing context monochange/monochange, workflow file publish.yml, and environment publisher before the first tokenless publish:
@monochange/cli@monochange/cli-darwin-arm64@monochange/cli-darwin-x64@monochange/cli-linux-arm64-gnu@monochange/cli-linux-arm64-musl@monochange/cli-linux-x64-gnu@monochange/cli-linux-x64-musl@monochange/cli-win32-arm64-msvc@monochange/cli-win32-x64-msvc
After publishing, verify npm provenance from the package page or with npm’s provenance metadata for the released version. The expected publisher identity is the publish.yml workflow in monochange/monochange; a run that lacks npm trusted-publishing setup should fail instead of falling back to a long-lived registry token.
Supported providers
The --provider flag supports three source providers:
| Provider | --provider value | Workflow generation | Release automation | Pull/merge requests |
|---|---|---|---|---|
| GitHub | github | Yes — GitHub Actions | Yes | Yes |
| GitLab | gitlab | No — use .gitlab-ci.yml | Yes | Yes |
| Gitea | gitea | No — use Gitea Actions | Yes | Yes |
All providers configure the [source] section in monochange.toml with appropriate settings for releases and pull/merge requests. GitLab and Gitea require manual CI configuration since they don’t support GitHub Actions workflow files.
If you are comparing provider-specific CI layouts or designing a long-running release PR branch, continue with Advanced: CI, package publishing, and release PR flows.
Subagents and MCP
monochange ships two assistant-facing surfaces:
mc subagents <target...>generates repo-local agent, subagent, or rule files for supported harnessesmc mcpstarts 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 agentsREFERENCE.md— broader high-context reference with more examplesskills/README.md— index of focused deep divesskills/adoption.md— setup-depth questions, migration guidance, and recommendation patternsskills/changesets.md— changeset authoring and lifecycle guidanceskills/commands.md— built-in command catalog and workflow selectionskills/configuration.md—monochange.tomlsetup and editing guidanceskills/linting.md—[lints]presets,mc check, and manifest-focused examplesexamples/README.md— condensed scenario examples for quick recommendations
This layout keeps the top-level skill small while still making the richer guidance available when an assistant needs more context.
Generate repo-local subagents
Start with:
mc help subagents
mc subagents claude
mc subagents pi codex
mc subagents --all --dry-run --format json
Supported targets currently include:
claudevscodecopilotpicodexcursor
Generated subagents are CLI-first. They should prefer:
mcmonochangenpx -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.
Recommended repo-local guidance
Keep instructions like these close to your project guidance:
- Read
monochange.tomlbefore proposing release workflow changes. - Run
mc step:validatebefore and after release-affecting edits. - Use
mc discover --format jsonto inspect package ids, group ownership, and dependency edges. - Use
mc step:diagnose-changesets --format jsonormonochange_diagnosticsfor a structured view of all pending changesets with git and review context. - Use
monochange_lint_catalogandmonochange_lint_explainwhen you need lint metadata without shelling out. - Prefer
mc changeplus.changeset/*.mdfiles over ad hoc release notes. - Use
mc release --dry-run --format jsonbefore mutating release state.
Current MCP tools
The MCP server is JSON-first and focuses on reviewable operations:
monochange_validate— validatemonochange.tomland.changesettargetsmonochange_discover— discover packages, dependencies, and groups across the repositorymonochange_diagnostics— inspect pending changesets with git and review context as structured JSONmonochange_change— write a.changesetmarkdown file for one or more package or group idsmonochange_release_preview— prepare a dry-run release preview from discovered.changesetfilesmonochange_release_manifest— generate a dry-run release manifest JSON document for downstream automationmonochange_affected_packages— evaluate changeset policy from changed paths and optional labelsmonochange_lint_catalog— list registered manifest lint rules and presetsmonochange_lint_explain— explain one manifest lint rule or presetmonochange_analyze_changes— analyze git diff state and return ecosystem-specific semantic changesmonochange_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
| Feature | knope | monochange |
|---|---|---|
| Config file | knope.toml | monochange.toml |
| CLI binary | knope | monochange / mc |
| Changeset directory | .changeset/ | .changeset/ |
| Changeset format | Markdown frontmatter | Markdown frontmatter |
| Conventional commits | Supported | Not supported |
| Single-package config | [package] | [package.<id>] |
| Multi-package config | [packages.<name>] | [package.<id>] |
| Version groups | Implicit (single [package]) | Explicit [group.<id>] |
| Workflows | [[workflows]] | [cli.<command>] |
| GitHub config | [github] | [source] (provider-neutral) |
| Ecosystem support | Rust, Go, JS | Rust, npm, pnpm, Bun, Deno, Dart, Flutter, Python |
| Dependency propagation | Not built-in | Automatic parent bumps |
Step 1 — Replace the config file
Delete knope.toml and create monochange.toml at the repository root.
Step 2 — Migrate package declarations
Single-package knope repository
knope uses a bare [package] table for single-package repos:
# knope.toml
[package]
versioned_files = [
{ path = "Cargo.toml", type = "cargo" },
{ path = "Cargo.lock", type = "cargo" },
]
changelog = "changelog.md"
scopes = ["core", "cli"]
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
scopesfilter conventional commits to specific packages. monochange does not use conventional commits — use changeset frontmatter keys instead.
Multi-package knope repository
knope uses [packages.<name>] for multi-package repos:
# knope.toml
[packages.sdk_core]
versioned_files = [
"crates/sdk_core/Cargo.toml",
{ 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.tomlas a versioned file — monochange discovers and updates native manifests automatically.
Step 3 — Migrate version groups
knope’s single [package] table implicitly groups all crates under one version. When migrating a repo that uses [package] with multiple versioned_files dependency entries, create an explicit [group.<id>]:
# monochange.toml
[group.main]
packages = ["sdk_core", "sdk_cli"]
tag = true
release = true
version_format = "primary"
[group.main.changelog]
path = "changelog.md"
Group behavior:
- all members share one synchronized version
tag,release, andversion_formatare owned by the group- member packages can still have their own changelogs
- members without direct changes get a configurable
empty_update_messagefallback
Step 4 — Migrate workflows to CLI commands
knope uses [[workflows]] arrays. monochange uses [cli.<command>] map entries that become top-level CLI subcommands.
knope workflow
# knope.toml
[[workflows]]
name = "release"
[[workflows.steps]]
type = "PrepareRelease"
[[workflows.steps]]
type = "Command"
command = "dprint fmt"
[[workflows.steps]]
type = "Command"
command = "git add --all"
[[workflows.steps]]
type = "Command"
command = 'git commit -m "chore: prepare releases {{ version }}"'
[[workflows.steps]]
type = "Command"
command = "git push"
[[workflows.steps]]
type = "Release"
monochange equivalent
# monochange.toml
[cli.release]
help_text = "Prepare a release from discovered change files"
[[cli.release.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
default = "text"
[[cli.release.steps]]
type = "PrepareRelease"
[cli.publish-release]
help_text = "Prepare a release and publish provider releases"
[[cli.publish-release.steps]]
type = "PrepareRelease"
[[cli.publish-release.steps]]
type = "PublishRelease"
Workflow step mapping
| knope step | monochange step | Notes |
|---|---|---|
PrepareRelease | PrepareRelease | Same name, same purpose |
CreateChangeFile | CreateChangeFile | Same name |
Release | PublishRelease | knope’s Release creates GitHub releases; monochange calls this PublishRelease and supports multiple providers |
Command | Command | Same name; monochange adds dry_run_command and shell = true |
| — | OpenReleaseRequest | New: open/update a release PR |
| — | PrepareRelease | New: refresh the cached .monochange/release-manifest.json artifact for downstream CI |
| — | AffectedPackages | New: PR changeset policy enforcement |
| — | Validate | New: validate config and changesets |
| — | Discover | New: list workspace packages |
| — | CommentReleasedIssues | New: comment on closed issues referenced in changesets |
Common knope workflow → monochange command recipes
Create a changeset (knope document-change):
# monochange.toml
[cli.change]
help_text = "Create a change file"
[[cli.change.inputs]]
name = "package"
type = "string_list"
required = true
[[cli.change.inputs]]
name = "bump"
type = "choice"
choices = ["patch", "minor", "major"]
default = "patch"
[[cli.change.inputs]]
name = "reason"
type = "string"
required = true
[[cli.change.steps]]
type = "CreateChangeFile"
Open a release PR (no knope equivalent):
# monochange.toml
[cli.release-pr]
help_text = "Open or update a release pull request"
[[cli.release-pr.steps]]
type = "PrepareRelease"
[[cli.release-pr.steps]]
type = "OpenReleaseRequest"
Key difference: knope workflows often include manual
git add,git commit, andgit pushCommand steps. monochange handles git operations internally when usingPublishReleaseorOpenReleaseRequest, so you can drop those manual steps.
Step 5 — Migrate GitHub configuration
knope
# knope.toml
[github]
owner = "my-org"
repo = "my-repo"
monochange
monochange uses a provider-neutral [source] table. GitHub is the default provider:
# monochange.toml
[source]
provider = "github" # default, can be omitted
owner = "my-org"
repo = "my-repo"
[source.releases]
enabled = true
source = "monochange"
[source.releases]
branches = ["main"]
enforce_for_tags = true
enforce_for_publish = true
enforce_for_commit = false
changeset_context_timeout_seconds = 120
[source.pull_requests]
enabled = true
branch_prefix = "monochange/release"
base = "main"
title = "chore(release): prepare release"
labels = ["release", "automated"]
monochange also supports GitLab and Gitea providers:
[source]
provider = "gitlab"
owner = "my-group"
repo = "my-project"
host = "gitlab.example.com"
Step 6 — Migrate changeset files
monochange and knope both use markdown-frontmatter changesets under .changeset/. The format is compatible, but there are differences in how packages are referenced.
knope changeset
---
my_crate: minor
---
# add new feature
Details about the feature.
monochange changeset
Same format — but use declared package ids or group ids as keys:
---
my_crate: minor
---
# add new feature
Details about the feature.
If you have a group, you can target the group directly:
---
main: minor
---
# coordinated release across all packages
Note: a changeset may not reference both a group id and one of its member package ids in the same file. Use either the group id or individual package ids.
Step 7 — Handle knope-specific features
Conventional commits
knope can derive version bumps from conventional commit messages. monochange does not support conventional commits — all version changes must come from changeset files.
If your knope config uses conventional commits alongside changesets:
# knope.toml — remove this
[changes]
ignore_conventional_commits = false # or absent
Switch to changeset-only workflows. Use 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.tomlwith[defaults]and[package.<id>]entries - Add
[group.<id>]if packages should share a version - Replace
[[workflows]]with[cli.<command>]entries - Replace
[github]with[source] - Remove
scopesand[changes]sections (no conventional commits) - Update
.changeset/*.mdfrontmatter keys to use declared package/group ids - Update CI workflows from
knope <command>tomc <command> - Run
mc step:validateto check config and changesets - Run
mc release --dry-runto 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 queriedchangesets— fullPreparedChangesetrecords, each with:path— workspace-relative path to the changeset filesummary— the first paragraph of the markdown bodydetails— optional follow-up paragraphstargets— package/group bump entries, each withkind,id,bump,origin, and optionalevidenceRefscontext— 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 committedlastUpdated— revision where it was most recently changed (omitted when same asintroduced)relatedIssues— issues linked by the changeset or the PR that introduced it
Each revision record includes:
commit.sha— full commit SHAcommit.shortSha— short SHA for displayreviewRequest— 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:
mc discover --format json— understand the workspace package graphmc step:diagnose-changesets --format json— see all pending changesets, linked PRs, and introduced commitsmc release --dry-run --format json— preview the computed release planmc change ...— add, update, or remove changesets as neededmc 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:
| Artifact | What it means | When it exists | What it is for |
|---|---|---|---|
cached release manifest (.monochange/release-manifest.json) | what monochange is preparing right now | during command execution and cached locally | CI, MCP/server consumers, previews, downstream automation, and AI/agent workflows |
ReleaseRecord | what this release commit historically declared | inside the monochange-managed release commit body | later inspection and repair from git history |
Plain-language summary:
- manifest = “what monochange is preparing right now”
- release record = “what this release commit historically declared”
If you prefer the emphasized version:
- manifest = “what monochange is preparing right now”
- release record = “what this release commit historically declared”
The important consequence is that ReleaseRecord does not replace the cached release manifest.
Use the manifest when you want execution-time automation. Use the release record when you want history-time inspection, post-merge tagging, or repair.
Where the release record lives
monochange writes the durable ReleaseRecord into the body of the monochange-managed release commit.
That means the repair anchor travels with git history itself instead of living in a mutable receipt file somewhere in the repository tree.
The release commit body contains:
- a compact human-readable release summary
- a reserved monochange release-record block with structured JSON
The ReleaseRecord JSON schema is published with the book at https://monochange.github.io/monochange/schemas/release-record.schema.json. Stable generated copies use public schema-version suffixes, starting with https://monochange.github.io/monochange/schemas/release-record.v0.1.schema.json.
How monochange finds a release later
Use 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:
- resolve the supplied ref to a commit
- walk first-parent ancestry
- stop at the first valid monochange
ReleaseRecord - report the release commit that declared it plus the distance from the input ref
That lets you inspect a release directly from its tag or from later fix commits.
Repairing a recent release
Use 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:
- finds the canonical release record from history
- derives the full release set from that record
- validates descendant-only safety rules by default
- previews the retarget plan in dry-run mode
- moves the whole tag set together when run for real
- syncs hosted release state when the provider supports it
Dry-run first
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:
- monochange creates a release request commit with an embedded release record.
- That release is tagged and published.
- You add a follow-up fix commit or two.
- You inspect the durable history record:
mc step:release-record --from v1.2.3
- You preview the repair:
mc repair-release --from v1.2.3 --target HEAD --dry-run
- 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.fromretarget.targetretarget.record_commitretarget.resolved_from_commitretarget.distanceretarget.tagsretarget.provider_resultsretarget.status
Provider scope in v1
GitHub is the first provider with release retarget sync support.
When provider sync is unsupported, monochange reports that clearly in dry-run and real execution paths rather than pretending the operation completed.
Keep using release manifests for automation
The new history-oriented repair workflow does not remove the execution-time manifest workflow.
Keep using the cached manifest JSON from PrepareRelease when you want:
- machine-readable release plans in CI
- MCP/server responses for assistants
- deterministic previews for downstream automation
- a stable execution-time snapshot of what monochange is about to do
Use ReleaseRecord and 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.
| Goal | Command | Use it when |
|---|---|---|
| Validate config and changesets | mc step:validate | You changed monochange.toml or .changeset/*.md files |
| Inspect package ids and groups | mc discover --format json | You need the normalized workspace model |
| Create release intent | mc change --package <id> --bump <severity> --reason "..." | You need a new .changeset/*.md file |
| Audit pending release context | mc step:diagnose-changesets --format json | You need git provenance, PR/MR links, or related issues |
| Preview the release plan | mc release --dry-run --diff | You want changelog/version patches without mutating the repo |
| Create a durable release commit | mc commit-release | You want a monochange-managed release commit with an embedded ReleaseRecord |
| Open or update a release request | mc release-pr | You want a long-lived release PR/MR branch updated from current release state |
| Inspect a past release commit | mc step:release-record --from <ref> | You need the durable release declaration from git history |
| Check package publish readiness | mc step:publish-readiness --from HEAD --output <path> | You want a non-mutating preflight report before package publication |
| Plan ready package publishing | mc publish-plan --readiness <path> | You want rate-limit batches that exclude non-ready package work |
| Publish packages to registries | mc publish --output <path> | You want cargo publish, npm publish, deno publish, or dart pub publish style package publication |
| Bootstrap release packages | mc step:placeholder-publish --from HEAD --output <path> | You need a release-record-scoped placeholder bootstrap artifact before rerunning readiness |
| Create post-merge release tags | mc step:tag-release --from HEAD | You merged a monochange release commit and now need to create and push its declared tag set |
| Repair a recent release | mc repair-release --from <tag> --target <commit> | You need to retarget a just-created release to a later commit |
| Publish hosted/provider releases | mc publish-release | You want GitHub/GitLab/Gitea release objects from prepared release state |
A practical rule of thumb:
- use
mc step:publish-readinessfor registry preflight reports andmc publishfor registry package publication - use
mc publish-releasefor hosted releases from prepared release state - use
mc release-prwhen you want a provider-backed release request branch - use
mc commit-releasewhen you want a durable local release commit in git history - use
mc step:tag-releasewhen that durable release commit has merged and you want to create its tag set on the default branch
The three automation layers
monochange has three related but different automation layers:
- Release planning —
mc release --dry-run,mc release,mc step:diagnose-changesets - Package registries —
mc step:publish-readiness,mc step:placeholder-publish --from HEAD,mc publish-plan --readiness <path>,mc publish, and lower-levelmc placeholder-publish - Hosted providers —
mc 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
| Capability | Current status |
|---|---|
| Multi-ecosystem discovery | Cargo, npm/pnpm/Bun, Deno, Dart, Flutter, Python, Go |
| Package release planning | Built in |
| Grouped/shared versioning | Built in |
| Dry-run release diff previews | Built in via mc release --dry-run --diff |
| Durable release history and post-merge tagging | Built in via ReleaseRecord, mc step:release-record, mc step:tag-release, and mc repair-release |
| Hosted provider releases | GitHub, GitLab, Gitea, Forgejo |
| Hosted release requests | GitHub, GitLab, Gitea, Forgejo |
| Python release planning | Built in for discovery, version rewrites, dependency rewrites, lockfile command inference, and PyPI publishing |
| Go release planning | Built in for go.mod discovery, dependency rewrites, go mod tidy inference, and Go proxy tag publishing |
| Built-in registry publishing | crates.io, npm, jsr, pub.dev, pypi, Go proxy tags; use external mode for custom registries |
| GitHub npm trusted-publishing automation | Built in |
GitHub trusted-publishing guidance for crates.io, jsr, pub.dev, and PyPI | Built in, but manual registry enrollment is still required |
| GitLab trusted-publishing auto-derivation | Not built in today |
| Release-retarget sync for hosted releases | GitHub first |
CI setup assumption
The workflow sketches below assume the job already has:
- the
monochangeCLI available asmc - the native ecosystem toolchain it needs (
npm/pnpm,cargo,deno,dart,flutter,uv,poetry, or your external publishing tool) - repository checkout with enough history for release-record inspection
In the monochange repository itself, that usually means entering the devenv shell. In other repositories, it may mean installing @monochange/cli or monochange explicitly before the publish step.
GitHub flows
Common GitHub shape
For GitHub Actions, the most common structure is:
- a workflow prepares or updates a release PR branch
- a release commit lands on
main - a post-merge workflow detects the release commit
- that workflow creates the declared tags and publishes packages from the durable release commit
- hosted release objects or extra assets come either from downstream tag-driven workflows or from a separate workflow that still uses
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-readinessnow blocks packages whose currentCargo.tomlcannot be published:publish = false,publish = [...]withoutcrates-io, missingdescription, or missing bothlicenseandlicense-file - workspace-inherited Cargo metadata such as
description = { workspace = true }andlicense = { workspace = true }is accepted when[workspace.package]supplies the value - already-published Cargo versions remain non-blocking and are skipped when current readiness and the saved readiness artifact agree
- monochange does not currently auto-configure
crates.iotrust the way it can for npm on GitHub - if you want the most literal crates.io/OIDC workflow today,
mode = "external"plusrust-lang/crates-io-auth-action@v1is the clearest path
Recommended setup:
- configure
trusted_publishing = true - bootstrap missing release packages with
mc step:placeholder-publish --from HEAD --output .monochange/bootstrap-result.jsonif needed, then rerun readiness - manually enroll the repository/workflow in
crates.io - choose either:
mode = "builtin"and let monochange own the publish command, ormode = "external"and use the official crates.io auth action directly
GitHub + Deno / JSR trusted publishing
Config:
[source]
provider = "github"
owner = "owner"
repo = "repo"
[ecosystems.deno.publish]
enabled = true
mode = "builtin"
trusted_publishing = true
registry = "jsr"
Workflow sketch:
name: publish-jsr
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: ./.github/actions/devenv
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: detect monochange release commit
shell: bash
run: |
set -euo pipefail
if ! devenv shell -- 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.devtrusted publishing today - if you want the most copy-pasteable pub.dev flow today,
mode = "external"plus the reusabledart-lang/setup-dartworkflow is the clearest path
GitHub post-merge package publish flow
If you want package publication to happen after the release PR merges, the simplest current pattern is:
- merge the release PR so the monochange release commit lands on
main - run
mc step:release-record --from HEAD --format jsonin CI - if the command succeeds, run
mc step:publish-readiness --from HEAD --output .monochange/readiness.json - run
mc publishonly after readiness succeeds - 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 = falseunless the registry workflow is one you manage externally - use CI secrets or external publishing logic when the registry requires a setup monochange does not automate on GitLab
GitLab + npm
Config:
[ecosystems.npm.publish]
enabled = true
mode = "builtin"
trusted_publishing = false
Workflow sketch:
publish_npm:
image: node:22
stage: publish
rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
script:
- corepack enable
- git fetch --force --tags origin
- |
set -euo pipefail
if 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:
- every merge to
mainupdates a dedicated release branch and PR - that branch contains the prepared release commit and release files
- the release PR stays open and keeps tracking the latest releasable state
- when the PR merges, publication happens from that merged release commit
What monochange supports now
monochange now supports the core post-merge pieces of this shape directly:
mc release-prcan open or update a release request branch from current release statemc commit-releasecan create a durable monochange release commit with an embeddedReleaseRecordmc step:release-record --from HEADcan detect whether the latest commit is a monochange release commitmc step:tag-release --from HEADcan create and push the declared tag set from that merged release commitmc step:publish-readinesscan write a readiness artifact from that same durable record onHEAD, andmc publishcan publish directly from the durable release record
The important tag semantics
Tags are not branch-scoped.
A git tag points at a commit object, not at a branch name.
That means:
- if you create a tag on a release-PR commit, the tag exists immediately even before merge
- if that exact commit is later merged into
main, the tag still points at the same commit and is now reachable frommain - if the release branch is later rebased, force-pushed, or regenerated, the old tag does not move automatically
That is why pre-merge tagging on a long-running release PR is usually the wrong move.
Recommended workflow
For the long-running release PR model, the recommended shape is now:
- on every push to
main, runmc release-prto refresh the dedicated release PR branch - do not create tags on the release PR branch
- merge the release PR when you are ready
- on the post-merge workflow, run
mc step:release-record --from HEAD --format json - if the latest commit is a release commit, run
mc step:tag-release --from HEAD - after tags exist, run
mc step:publish-readiness --from HEAD --output <path>and thenmc publishfor 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
ReleaseRecordonHEAD, runmc step:tag-release --from HEAD, then runmc step:publish-readiness --from HEAD --output <path>andmc 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-levelmc placeholder-publish - Need GitHub npm trusted publishing with the least custom glue? → use
trusted_publishing = truewithmc step:publish-readinessandmc publish - Need GitLab CI with custom auth/bootstrap? → keep
mode = "external"as the escape hatch
Related guides
Advanced: Multi-package publishing patterns
This guide covers the practical publishing patterns that work well when one repository releases multiple packages across one or more ecosystems.
Use it when:
- one monochange workspace publishes more than one public package
- different registries need different publish triggers
- some packages stay on
mode = "builtin"while others are clearer onmode = "external" - trusted publishing must be enrolled per package instead of once per repository
Start with the release boundary
For multi-package repositories, keep one idea fixed:
- monochange plans releases at the workspace level
- registries authorize publishing at the package level
That means the release plan can be shared, while publish automation often needs to stay package-specific.
A good default is:
- let monochange prepare one release commit for the workspace
- decide which packages use built-in publishing and which use external publishing
- keep each registry’s trusted-publishing enrollment aligned with the exact package workflow that will publish it
Choose the simplest publish pattern that matches the registry
Pattern 1: One post-merge publish job runs 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:
- Build the selected publish requests from the prepared release or
HEADrelease state. - Materialize the workspace dependency graph.
- Consider only dependencies where both packages are part of the selected publish set.
- Ignore development dependency edges.
- Topologically sort the publish requests so dependencies are emitted before dependents.
For example, with this internal package graph:
core # no dependencies
utils # depends on core
api # depends on utils
app # depends on core, utils, api
monochange publishes in this order:
core
utils
api
app
If multiple packages are independent at the same depth, their order is deterministic by package id, registry, and version.
A package with no selected dependencies is eligible first. A package is not published until all of its selected publish-relevant dependencies have been ordered before it. Dependencies outside the selected publish set do not block ordering. Development-only cycles are ignored. Runtime, build, peer, workspace, and unknown dependency cycles fail before publishing anything, with a cycle diagnostic.
Pattern 2: Package-specific external workflows publish from tags
Use this when the registry expects each package to have its own tag trigger, working directory, or workflow.
This is often the clearest fit for:
pub.dev- some
crates.iosetups - mixed workspaces where one package needs registry-native steps that do not match
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
| Registry | Recommended multi-package pattern | Why |
|---|---|---|
| npm | one post-merge mc step:publish-readiness + mc publish job when possible | monochange can automate npm trusted-publishing setup on GitHub |
| crates.io | one job per crate when using external OIDC auth | trusted publishing is enrolled per crate and workflow context matters |
| jsr | built-in mc step:publish-readiness + mc publish is often fine, but keep setup package-specific | registry linking is still manual today |
| pub.dev | package-specific tags and often one workflow per package | automated publishing is tag-driven and package-specific |
Keep config, tags, and workflows aligned
For each published package, keep these values aligned:
- package id in
monochange.toml - registry package name
- trusted-publishing repository/workflow/environment values
- workflow trigger
- tag pattern, when the registry uses tags
- working directory, when the registry workflow publishes from a subdirectory
If those drift apart, trusted-publishing validation will be confusing even when release planning is correct.
When to use package-level overrides
Use package-level publishing config when one package differs from the ecosystem default.
[ecosystems.dart.publish]
enabled = true
mode = "external"
trusted_publishing = true
registry = "pub.dev"
[package.dart_client.publish.trusted_publishing]
workflow = "publish-dart-client.yml"
environment = "pub.dev"
[package.example_app.publish]
enabled = false
This is the right move when:
- one package publishes from a different workflow file
- one package needs a protected environment but others do not
- one package is internal and should not publish publicly
- one ecosystem default is correct for most packages, but not all of them
Practical rollout for an existing monorepo
- decide which packages are public and which stay unpublished
- choose
builtinorexternalper ecosystem or package - register trusted publishing for each package at the registry
- prefer package-specific tags where a registry is tag-authorized
- run
mc publish --dry-runafter registry enrollment changes - optionally run
mc step:publish-readiness --from HEAD --output <path>as a preflight before realmc publish - keep the workflow filename and environment stable once a registry record is enrolled
Common mistakes
Avoid these failure modes:
- using one broad tag pattern that lets a tag for package A publish package B
- reusing one trusted-publishing record across packages that actually publish from different workflows
- changing a workflow filename after registry enrollment without updating the registry record
- keeping
mode = "builtin"for packages that really need registry-native external publish steps - forgetting that
pub.devautomated publishing is tag-triggered
Related guides
- for registry-side trusted-publishing setup details, see Trusted publishing and OIDC
- for end-to-end CI examples, see CI, package publishing, and release PR flows
- for publishing config fields and inheritance, see Configuration reference
Publish rate-limit planning
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 metadatanpm— conservative advisory metadata when exact package publish quotas are not officially documentedjsr— official publish-window metadatapub.dev— conservative daily publish planning metadata for CI batching
Use 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:
- normal workspace validation, similar to
mc step:validate - 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 formajor,minor, orpatchentries. 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 asbreaking,feature,fix,security, or a custom type likeunicorns. 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:
workspaceorversiondefault-features/default_featuresfeatures- other keys like
optional,path,registry,package,git,branch,tag,rev
Without the rule:
serde = { features = ["derive"], workspace = true }
With the rule:
serde = { workspace = true, features = ["derive"] }
Useful option:
fix— defaults totrue
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 totruefix— defaults totrue
cargo/required-package-fields
Why: published crates should consistently carry the metadata your repository expects.
Default required fields:
descriptionlicenserepository
Without the rule:
[package]
name = "example"
version = "0.1.0"
With the rule: monochange reports the missing fields so package metadata stays consistent.
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 totrue
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 totrue
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 tofalsefix— defaults totrue
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 totrue
npm/required-package-fields
Why: package metadata should stay consistent across publishable npm packages.
Default required fields:
descriptionrepositorylicense
Without the rule:
{
"name": "@acme/app",
"version": "1.0.0"
}
With the rule: monochange reports the missing metadata fields.
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 totrue
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 totrue
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 totrue
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 workspacerequire_upper_bound— set tofalseif your policy intentionally omits an upper bound
Example:
[lints.rules]
"dart/sdk-constraint-modern" = { level = "warning", minimum = "3.6.0", require_upper_bound = false }
dart/dependency-sorted
Why: alphabetized dependencies, dev_dependencies, and dependency_overrides blocks reduce review noise and make Dart manifest diffs easier to scan.
Useful option:
fix— defaults totrue
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 totrueallow_packages— list package names that may keepdependency_overrides
Example:
[lints.rules]
"dart/no-unexpected-dependency-overrides" = { level = "warning", allow_for_private = true, allow_packages = ["app_shell"] }
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 totrue
Dart presets
dart/recommendedenables metadata/publishability checks,dart/sdk-constraint-present, anddart/dependency-sortedas a warning.dart/strictaddsdart/sdk-constraint-modern,dart/no-unexpected-dependency-overrides,dart/internal-path-dependency-policy,dart/workspace-internal-version-consistency,dart/flutter-package-metadata-consistent, anddart/assets-sorted, while promotingdart/dependency-sortedto 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.
Recommended workflow
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
PrepareReleaseandDisplayVersionssteps 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_startedstep_startedcommand_outputstep_finishedstep_failedstep_skippedcommand_finished
Shared fields:
sequence: monotonically increasing event sequence number for the command runcommand: CLI command name, such asreleasedryRun: whether the command is running in dry-run modetotalSteps: total step count for the commandstepIndex: 1-based step index for step eventsstepKind: built-in step kind, such asPrepareReleasestepDisplayName: rendered human label for the stepstepName: explicit configuredname, ornullwhen omitted
Event-specific fields:
command_outputaddsstreamandtextstep_finishedaddsdurationMsandphaseTimingsstep_failedaddsdurationMsanderrorstep_skippedmay addconditioncommand_finishedaddsdurationMs
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_namecommand_source:configuredorgenerated_stepdry_runshow_diffprogress_format:auto,unicode,ascii, orjsonstep_countduration_msoutcome:successorerrorerror_kind: sanitized error category ornull
command_step
Emitted for each CLI step that succeeds, fails, or is skipped.
Attributes:
command_namestep_indexstep_kindskippedduration_msoutcome:success,skipped, orerrorerror_kind: sanitized error category ornull
Local event shape
Each line is one JSON object:
{
"resource": {
"service.name": "monochange",
"service.version": "0.2.0"
},
"scope": {
"name": "monochange.telemetry",
"version": "0.1.0"
},
"time_unix_nano": 1777338000000000000,
"severity_text": "INFO",
"body": {
"string_value": "command_run"
},
"attributes": {
"command_name": "validate",
"command_source": "configured",
"dry_run": false,
"show_diff": false,
"progress_format": "auto",
"step_count": 1,
"duration_ms": 42,
"outcome": "success",
"error_kind": null
}
}
Privacy boundaries
The local sink intentionally records only low-cardinality command metadata and sanitized error categories. It does not record package names, paths, repository URLs, branch names, tag names, commit hashes, issue numbers, pull request numbers, shell command strings, environment values, changeset content, changelog content, release notes, or raw error messages.
Future work
Remote export, a user-facing telemetry command group, persistent opt-in configuration, hosted dashboards, and richer workspace-shape events are tracked as follow-up issues. Until those are implemented, telemetry remains local-only and best-effort.
Hosted release benchmarks
The default binary benchmark workflow uses synthetic local fixtures so every pull request can run quickly in CI.
When you need to measure hosted-provider overhead for the real 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 statusto succeed withreposcope- permission to create repositories under the chosen owner
The generator stores an authenticated HTTPS remote in the disposable fixture clone so it can push the seeded PR branches without depending on SSH agent state. Use a temporary output directory for hosted runs.
Benchmark the hosted fixture
Build the main and PR binaries first, then run the benchmark script against a clone of the hosted fixture repository:
gh repo clone ifiokjr/monochange-release-benchmark-fixture /tmp/monochange-release-benchmark-fixture
pnpm node scripts/benchmark-cli.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 releasedelta betweenmainand the PR binary - the
prepare release totalrow in the phase table - hosted-specific phases such as
enrich changeset context via github
If the hosted run still shows a regression or an unexpectedly large absolute cost, capture a trace against the same fixture checkout and attach both the benchmark markdown and trace notes to the relevant issue or pull request.
CLI step reference
monochange CLI commands are built in two layers:
- immutable built-in step commands: every built-in step except
Commandis exposed directly asmc step:<kebab-step-name>, for examplemc step:discover,mc step:prepare-release, andmc step:affected-packages. These commands are generated by the binary, derive their flags from the step schema, and do not require a[cli.*]entry inmonochange.toml. - config-driven workflow commands: every
[cli.<command>]table inmonochange.tomlbecomesmc <command>.mc initdoes not seed default workflow aliases; add these tables when you want a named workflow that chains steps, adds custom inputs, or runsCommandsteps.
A step is the smallest execution unit in a monochange workflow. Some steps are standalone (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:
- what state the command needs
- which step produces that state
- which later step consumes it
- what side effects are acceptable in normal mode vs
--dry-run
The reference pages in this section document each built-in step with:
- what the step does
- why you would choose it over a shell-only
Command - which inputs it accepts
- what prerequisite state it needs
- what it contributes to later steps
- examples of how it composes into full workflows
Explicit step input inheritance
Config-defined workflow commands have two input layers:
[[cli.<command>.inputs]]declares the flags and arguments accepted bymc <command>.inputson each step decides which of those parsed command inputs are visible while that step runs.
Command inputs are not inherited automatically. A step receives a command input only when the step explicitly lists it. This makes wrappers predictable when a command-level flag and a step-specific input share the same name.
Use the array shorthand when a step should inherit command inputs unchanged:
[cli.discover]
inputs = [
{ name = "format", type = "choice", choices = ["text", "json"], 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
| Step | Use it when you want to… | Requires previous step? | Typical follow-up |
|---|---|---|---|
Validate | fail fast on invalid config, groups, or changesets | no | CI gate or local preflight |
Discover | inspect normalized package discovery across ecosystems | no | local inspection, debug commands |
CreateChangeFile | author a .changeset/*.md file from CLI inputs | no | run independently, or before planning |
PrepareRelease | build the release result, update files, and refresh the cached manifest | no | CommitRelease, PublishRelease, OpenReleaseRequest, CommentReleasedIssues, Command |
DisplayVersions | display planned package and group versions without mutating release files | no | PrepareRelease |
CommitRelease | create a local release commit with an embedded ReleaseRecord | PrepareRelease | OpenReleaseRequest, manual review, custom Command |
VerifyReleaseBranch | verify a ref is reachable from configured release branches | [source.releases] | early release CI gates; enforced internally by tag and publish paths |
PublishRelease | create or update hosted provider releases | PrepareRelease + [source] | CommentReleasedIssues, custom notification commands |
OpenReleaseRequest | create or update a hosted release PR/MR | PrepareRelease + [source] | provider review, follow-up Command steps |
PlanPublishRateLimits | plan package-registry publish work against known rate limits | no | PublishPackages, PlaceholderPublish |
PlaceholderPublish | publish 0.0.0 placeholder versions for missing registry packages | no | normally before PublishPackages |
PublishPackages | publish package versions to registries using built-in ecosystem workflows | prepared or HEAD release state | custom Command steps using publish.* |
CommentReleasedIssues | post release follow-up comments to closed issues | PrepareRelease + GitHub source | normally after PublishRelease |
AffectedPackages | evaluate changeset coverage for changed files | no | CI enforcement, custom failure messaging |
DiagnoseChangesets | inspect changeset context, commit provenance, and linked review metadata | no | local debugging, CI inspection |
RetargetRelease | repair a recent release by moving its tag set | no | custom Command steps using retarget.* |
Command | run arbitrary shell/program commands with monochange context | depends on your workflow | any external tool |
A note on composition
monochange executes steps in order.
That means composition is explicit:
- a step can only consume state created by an earlier step in the same command
- a later step never runs “in parallel” with an earlier one
--dry-runflows through the whole command and changes the behavior of steps that support previews--quietsuppresses stdout/stderr and reuses dry-run behavior for commands that support it- a plain
Commandstep can bridge monochange and external tools, but built-in steps are preferable when you want stable semantics, structured JSON, or provider-aware behavior
In practice, most workflows fit one of four patterns:
- validation / inspection
ValidateDiscoverDisplayVersionsAffectedPackagesDiagnoseChangesets
- change authoring
CreateChangeFile
- release preparation and publication
PrepareRelease- then one or more of
CommitRelease,PublishRelease,OpenReleaseRequest,CommentReleasedIssues,Command
- post-release repair
RetargetRelease- optionally followed by
Command
Shared concepts
Step-local name
Every step can declare a name = "..." label.
Use that when you want human-friendly progress output such as plan release, publish tags, or announce release instead of the raw step kind.
Progress rendering
monochange can stream step progress on stderr while keeping command output on stdout.
Use --progress-format auto|unicode|ascii|json or MONOCHANGE_PROGRESS_FORMAT to choose the renderer:
autoenables the human renderer only when stderr is a terminalunicodeforces the human renderer with Unicode symbolsasciiforces the human renderer with ASCII-safe symbolsjsonemits newline-delimited progress events for automation and benchmarks
PrepareRelease and DisplayVersions steps also report per-phase timings when they compute release state. Those timings power the benchmark phase-budget checks for 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.*afterPrepareReleasemanifest.pathafterPrepareReleaseaffected.*afterAffectedPackagesretarget.*afterRetargetReleaserelease_commit.*afterCommitReleasesteps.<id>.stdoutandsteps.<id>.stderrafter aCommandstep withid = "..."
Those namespaces are the main reason to prefer built-in steps over reimplementing the same workflow in shell.
Pages in this section
- Validate
- Discover
- CreateChangeFile
- AffectedPackages
- DiagnoseChangesets
- RetargetRelease
- PrepareRelease
- CommitRelease
- VerifyReleaseBranch
- PlanPublishRateLimits
- PublishRelease
- OpenReleaseRequest
- CommentReleasedIssues
- Command
- DisplayVersions
- PlaceholderPublish
- PublishPackages
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_urland[source].hostmust usehttps://; insecurehttp://schemes are rejected to prevent cleartext token transmission
Validate returns a normal success/failure result for the command and does not prepare release state for later steps.
When validation fails, monochange renders the offending file path and line/column first, then shows a focused source snippet plus a fix hint when one is available. That makes malformed changesets and config entries much faster to correct from CI logs or local terminal output.
That last point matters: Validate is a gate, not a state-producing step.
When to place it in a workflow
Put Validate first when a later Command step would otherwise run expensive tooling or provider calls.
Typical pattern:
ValidateCommandfor extra project-specific checks- maybe another standalone step such as
AffectedPackages
Example
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-releasechecks - repo health checks before other steps
Bad fit:
- anything that needs release outputs such as
release.* - anything that should mutate files or provider state
Common mistake
Do not expect Validate to make PrepareRelease unnecessary. It only checks whether the repository is valid; it does not compute the release state that publication-oriented steps need.
Discover
What it does
Discover runs monochange package discovery and renders the result in text or json form.
It is the step to use when you want to inspect how monochange sees the repository before you involve changesets or release logic.
Why use it
Use Discover when you need visibility into:
- which packages monochange found
- which ids were assigned
- which manifest paths were normalized
- whether a repository layout is discoverable the way you expect
This is particularly valuable in mixed-ecosystem monorepos where discovery rules are part of the product contract.
Inputs
format—textorjson
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. Discover is standalone.
Side effects and outputs
- discovers packages across supported ecosystems
- emits a report for the overall CLI command output
- does not prepare release state for later steps
Example
[cli.discover]
help_text = "Discover packages across supported ecosystems"
[[cli.discover.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
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, anddetails - interactive authoring when
interactive = true
Why use it
Use CreateChangeFile when you want monochange itself to remain the source of truth for authored change files.
That gives you a few advantages over rolling your own shell template generator:
- package and group references resolve through the same config model used for release planning
- default bump/type behavior stays aligned with monochange parsing rules
- interactive mode can guide authors instead of forcing them to remember frontmatter details
- the generated file shape stays compatible with
mc step:validate,PrepareRelease, and diagnostics tooling
Inputs
interactive— boolean; use interactive prompting instead of explicit package argumentspackage— list of package or group ids to targetbump—none,patch,minor, ormajorversion— explicit version pin for the changereason— summary linetype— optional release-note typecaused_by— optional list of package or group ids that explain dependency-only follow-up changesdetails— optional long-form bodyoutput— optional explicit file path
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. CreateChangeFile is standalone.
Side effects and outputs
- writes a new changeset file
- reports the written path
- does not prepare release state for later steps
- automatically hides the progress spinner during interactive prompting so the selector UI stays readable
- automatically wraps package/group ids in quotes when the authored frontmatter key contains YAML-sensitive characters such as
@or/
Example
[cli.change]
help_text = "Create a change file for one or more packages"
[[cli.change.inputs]]
name = "interactive"
type = "boolean"
short = "i"
[[cli.change.inputs]]
name = "package"
type = "string_list"
[[cli.change.inputs]]
name = "bump"
type = "choice"
choices = ["none", "patch", "minor", "major"]
default = "patch"
[[cli.change.inputs]]
name = "version"
type = "string"
[[cli.change.inputs]]
name = "type"
type = "string"
[[cli.change.inputs]]
name = "caused_by"
type = "string_list"
[[cli.change.inputs]]
name = "reason"
type = "string"
[[cli.change.inputs]]
name = "details"
type = "string"
[[cli.change.inputs]]
name = "output"
type = "string"
[[cli.change.steps]]
type = "CreateChangeFile"
inputs = [
"interactive",
"package",
"bump",
"version",
"type",
"caused_by",
"reason",
"details",
"output",
]
Composition ideas
Non-interactive wrapper for contributors
[cli.change-fix]
help_text = "Create a patch changeset for one package"
[[cli.change-fix.inputs]]
name = "package"
type = "string_list"
required = true
[[cli.change-fix.inputs]]
name = "reason"
type = "string"
required = true
[[cli.change-fix.steps]]
type = "CreateChangeFile"
inputs = { bump = "patch", package = "{{ inputs.package }}", reason = "{{ inputs.reason }}" }
This is a good example of why built-in step inputs matter: the wrapper command is still using CreateChangeFile semantics rather than generating markdown manually.
Interactive authoring command
You can also create a dedicated interactive authoring command that always opts in to prompts.
[cli.change-interactive]
help_text = "Create a change file interactively"
[[cli.change-interactive.steps]]
type = "CreateChangeFile"
show_progress = false
inputs = { interactive = true }
Good fit / bad fit
Good fit:
- contributor-facing commands
- wrappers that standardize bump policies
- interactive authoring helpers
Bad fit:
- release execution
- commands that need
release.*context
Common mistakes
- omitting
packagein non-interactive mode - expecting
CreateChangeFileto release anything immediately - using raw manifest paths when configured package ids are the stable interface
- forgetting
caused_bywhen a dependent package only changed because another package or group moved first
AffectedPackages
What it does
AffectedPackages evaluates changed files into affected package coverage and changeset policy results.
It can answer questions such as:
- which packages are affected by this change set?
- are those changes covered by changesets?
- should verification be skipped because of labels?
Why use it
Use AffectedPackages when you want a CI-oriented policy step instead of a release step.
It is the best fit for:
- pull request checks
- pre-merge policy enforcement
- reusable GitHub Actions or other CI jobs
- custom failure messaging based on affected-package status
Inputs
format—textorjsonchanged_paths— explicit changed pathsfrom— revision to diff against; takes priority overchanged_pathsverify— whether to enforce non-zero failure on uncovered packageslabel— skip labels supplied from CI
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. AffectedPackages is standalone.
Side effects and outputs
- computes the changeset policy evaluation
- exposes
affected.statusandaffected.summaryto laterCommandsteps - can be used as a pure reporting step or an enforcing gate depending on
verify
Example
[cli.affected]
help_text = "Evaluate pull-request changeset policy"
[[cli.affected.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
default = "text"
[[cli.affected.inputs]]
name = "changed_paths"
type = "string_list"
required = true
[[cli.affected.inputs]]
name = "label"
type = "string_list"
[[cli.affected.inputs]]
name = "verify"
type = "boolean"
[[cli.affected.steps]]
type = "AffectedPackages"
inputs = ["format", "changed_paths", "label", "verify"]
Composition ideas
Evaluate and then print a custom summary
[cli.affected-report]
help_text = "Evaluate affected packages and print a custom summary"
[[cli.affected-report.inputs]]
name = "changed_paths"
type = "string_list"
required = true
[[cli.affected-report.steps]]
type = "AffectedPackages"
[[cli.affected-report.steps]]
type = "Command"
command = "echo affected status {{ affected.status }}: {{ affected.summary }}"
shell = true
Use it as a PR-only command
This step is often best kept in a dedicated CI command rather than bundled into normal release preparation. It answers a different question: “is the pull request policy-complete?” not “what should be released?”
Why choose it over a plain git diff script?
Because it reuses monochange’s own understanding of package paths, groups, ignored paths, additional paths, skip labels, and changeset coverage.
Common mistakes
- providing both
fromandchanged_pathsand forgettingfromwins - assuming this step prepares release state
- treating verification results as equivalent to a release plan
DiagnoseChangesets
What it does
DiagnoseChangesets inspects discovered changesets and reports how monochange interpreted them.
That includes parsed targets, notes, bump or version intent, provenance, and linked review metadata.
It is the inspection step you reach for when a changeset exists but you want to understand why monochange is treating it a certain way.
Why use it
Use DiagnoseChangesets when you need visibility into:
- which package or group targets a changeset resolved to
- what bump or explicit version monochange inferred
- which commit introduced or last updated the changeset
- which review request or linked issues were attached to it
- why a release note, policy decision, or provider comment included that changeset
This makes it especially useful for debugging rich release-note context and CI policy behavior.
Inputs
format—textorjsonchangeset— one or more explicit changeset paths; omit to inspect all discovered changesets
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. DiagnoseChangesets is standalone.
It does not require PrepareRelease, and it does not modify workspace state.
Side effects and outputs
DiagnoseChangesets is read-only.
It:
- produces a diagnostics report
- does not prepare release state
- does not edit files
- is useful both for human debugging and for machine-readable CI inspection
In practice:
- use
format = "text"for local debugging - use
format = "json"when another tool should consume the results
Example
[cli.diagnostics]
help_text = "Inspect changeset context and provenance"
[[cli.diagnostics.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
default = "text"
[[cli.diagnostics.inputs]]
name = "changeset"
type = "string_list"
[[cli.diagnostics.steps]]
type = "DiagnoseChangesets"
inputs = ["format", "changeset"]
Composition ideas
Diagnose a targeted changeset set in CI
[cli.diagnostics-json]
help_text = "Inspect selected changesets as JSON"
[[cli.diagnostics-json.inputs]]
name = "changeset"
type = "string_list"
[[cli.diagnostics-json.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
default = "json"
[[cli.diagnostics-json.steps]]
type = "DiagnoseChangesets"
Use it as a maintainer support command
Many teams expose DiagnoseChangesets for maintainers only, because it shortens the time needed to explain:
- why a changeset rendered a certain note
- why it resolved to a package or group id
- why it linked to a certain review request or issue
Pair it with external tooling through Command
If you need custom summarization or uploads, run DiagnoseChangesets as JSON and keep that command separate from release planning. It is usually clearer to treat diagnosis as its own workflow instead of trying to hide it inside a release command.
Good fit / bad fit
Good fit:
- maintainer debugging commands
- CI jobs that inspect authored changesets without publishing anything
- support workflows where you need interpreted changeset context, not just raw markdown
Bad fit:
- commands that should mutate manifests, changelogs, or releases
- workflows that need
release.*state - cases where simply reading the markdown file is enough
Why choose it over opening the markdown file directly?
Because the raw file is only part of the picture.
DiagnoseChangesets shows the interpreted result after monochange resolves package ids, provenance, linked review metadata, and related issue context. That is usually the information you actually need when debugging release behavior.
Common mistakes
- expecting
DiagnoseChangesetsto modify anything - assuming it prepares release state for later publication steps
- using it when a simpler
mc step:validatefailure would already answer the question - forgetting to switch to
jsonoutput when another tool should consume the results
RetargetRelease
What it does
RetargetRelease repairs an already-recorded release.
It finds the release’s durable ReleaseRecord, plans a retarget operation, and then moves the release tag set to a later commit.
This is intentionally separate from PrepareRelease-driven steps. It works from git history and durable release metadata, not from newly prepared release state.
Why use it
Use RetargetRelease when the release already happened but the tags or hosted release state need to move.
It is a repair step, not a planning step.
Typical use cases include:
- a release commit landed, but tags must move to a later fix commit
- the hosted release should stay aligned with the corrected tag position
- a recent release needs to be repaired without generating a brand-new release plan
- a previous
CommitReleaseleft the durable release record you now want to reuse safely
Inputs
from— tag or commit-ish used to discover the release recordtarget— commit-ish to move the release to; defaults toHEADforce— allow non-descendant retargetssync_provider— whether hosted provider state should be synchronizedformat—textorjson
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None.
Unlike publication-oriented steps, RetargetRelease does not require PrepareRelease first.
Side effects and outputs
RetargetRelease is a stateful maintenance step.
It can:
- discover the release record from history
- plan and optionally execute tag movement
- optionally synchronize provider release state
- expose a rich
retarget.*namespace to laterCommandsteps
Commonly useful fields include:
retarget.fromretarget.targetretarget.record_commitretarget.resolved_from_commitretarget.distanceretarget.tagsretarget.provider_resultsretarget.status
In --dry-run mode, it reports the planned repair without mutating tags or provider state.
Safety model
RetargetRelease is designed to make repair explicit.
A few rules matter in practice:
- you identify the release to repair with
from - by default, the target is
HEAD - non-descendant repairs require
force = true - provider synchronization is optional and controlled with
sync_provider
That means you can start with a safe preview, confirm the proposed movement, and only then run the real repair.
Example
[cli.repair-release]
help_text = "Repair a recent release by retargeting its tags"
[[cli.repair-release.inputs]]
name = "from"
type = "string"
required = true
[[cli.repair-release.inputs]]
name = "target"
type = "string"
default = "HEAD"
[[cli.repair-release.inputs]]
name = "force"
type = "boolean"
default = "false"
[[cli.repair-release.inputs]]
name = "sync_provider"
type = "boolean"
default = "true"
[[cli.repair-release.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:
- run the repair command with
--dry-run - inspect
retarget.status,retarget.tags, and the proposed target - rerun without
--dry-runonce the plan is correct
Good fit / bad fit
Good fit:
- release repair workflows
- operational commands owned by maintainers or release engineers
- commands that need structured
retarget.*output for notifications or audits
Bad fit:
- normal release publishing flows
- commands that should create a brand-new release plan
- situations where a simple patch release is the safer response
Why choose it over manually moving tags?
Because the built-in step repairs the release as a coherent unit based on the stored ReleaseRecord.
That means it can:
- find the release record from history
- reason about the release as monochange recorded it
- coordinate provider synchronization at the same time
- expose structured repair results to later steps
A manual tag move can change refs, but it does not preserve that workflow-level structure.
Common mistakes
Do not mix up RetargetRelease and PrepareRelease.
PrepareReleaseanswers: “what should be released now?”RetargetReleaseanswers: “how should an already-recorded release be repaired?”
Also avoid:
- skipping
--dry-runwhen the repair is high risk - using
forcewithout first understanding why the target is not a descendant - treating retargeting as a substitute for publishing a new patch release when a real follow-up release is more appropriate
PrepareRelease
What it does
PrepareRelease is the core release execution step.
It discovers packages, loads authored changesets, computes the release plan, updates manifests and changelogs, and prepares the structured release result that later steps can consume.
In other words: most release-oriented commands are really PrepareRelease plus something else.
Why use it
Use PrepareRelease whenever the command needs real release state.
It is the step that unlocks:
- release file updates
- changelog rendering
- release target calculation
- structured
release.*template context - the cached
.monochange/release-manifest.jsonartifact exposed asmanifest.path - later steps such as
CommitRelease,PublishRelease,OpenReleaseRequest, andCommentReleasedIssues
If your command eventually needs release metadata, start with PrepareRelease rather than trying to reconstruct that state in shell.
Inputs
format—markdown,text, orjson
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. PrepareRelease is the producer step for the rest of the release workflow.
Side effects and outputs
PrepareRelease is stateful.
It can produce:
- updated manifests
- updated changelogs
- deleted or consumed changeset files
- release target information
- a cached release manifest at
.monochange/release-manifest.json - final command output in markdown, text, or JSON form
- structured
release.*template values for laterCommandsteps manifest.pathfor laterCommandsteps that need the on-disk JSON artifact
Built-in release-oriented commands 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:
PrepareRelease→PublishReleasePrepareRelease→OpenReleaseRequestPrepareRelease→CommitReleasePrepareRelease→Command
Prepare once, consume several outputs
Because the later steps all depend on the same prepared state, you should generally do one PrepareRelease and then fan out from it with several typed steps rather than trying to run several independent release commands.
Reuse prepared state across separate commands
When you do need to split the workflow across separate commands, monochange can 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 (
RetargetReleaseis separate)
Common mistakes
- putting
PublishReleaseorOpenReleaseRequestbeforePrepareRelease - assuming
PrepareReleaseis just a read-only planner in non-dry-run mode - forgetting that later
Commandsteps can consume its structured output directly - forgetting that
--quietsuppresses 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
ReleaseRecordneeded for later repair or inspection flows - hand off a prepared release to later custom
Commandsteps without reconstructing commit metadata yourself
Inputs
CommitRelease accepts one optional step-level boolean input:
| Input | Type | Default | Description |
|---|---|---|---|
update_release_json | boolean | false | When true, allows CommitRelease to create or overwrite the .monochange/releases/<id>/release.json record if it is missing or does not match the expected content. When false (the default), a missing or mismatched record is treated as an error. |
This input is useful when a previous step (such as PrepareRelease or a Command step that runs dprint fmt) may have modified the release record file, and you want CommitRelease to accept the regenerated content rather than fail with a mismatch error.
CommitRelease compares release records semantically (parsed JSON values), so formatting-only differences such as indentation or key ordering are ignored and never trigger a mismatch.
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
CommitRelease needs prepared release state.
You can provide that state in either of two ways:
- run a previous
PrepareReleasestep in the same command - reuse a saved prepared release artifact from
.monochange/prepared-release-cache.jsonor--prepared-release
CommitRelease is a consumer step. It does not plan a release on its own.
Side effects and outputs
In normal mode, CommitRelease creates a local commit.
In --dry-run mode, it previews the commit payload without creating the commit.
Before committing, CommitRelease validates the .monochange/releases/<id>/release.json record on disk. If the file exists, the step compares it against the expected content semantically (parsed JSON values), so formatting-only differences such as indentation or key ordering do not trigger a mismatch. If the file is missing or semantically different, the step either errors (default) or overwrites the file, depending on the update_release_json input.
It exposes a structured release_commit.* namespace to later Command steps. Commonly useful fields include:
release_commit.subjectrelease_commit.bodyrelease_commit.commitrelease_commit.tracked_pathsrelease_commit.dry_runrelease_commit.status
Use those values when you want later steps to:
- print the release commit sha
- generate custom notifications
- attach commit metadata to CI artifacts
- feed the created commit into external tooling
Example
[cli.commit-release]
help_text = "Prepare a release and create a local release commit"
[[cli.commit-release.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
default = "text"
[[cli.commit-release.steps]]
type = "PrepareRelease"
inputs = ["format"]
[[cli.commit-release.steps]]
type = "CommitRelease"
Composition ideas
Prepare, commit, then print commit metadata
[cli.commit-and-show]
help_text = "Prepare a release, create a commit, and print the commit sha"
[[cli.commit-and-show.steps]]
type = "PrepareRelease"
[[cli.commit-and-show.steps]]
type = "CommitRelease"
[[cli.commit-and-show.steps]]
type = "Command"
command = "echo release commit {{ release_commit.commit }}"
shell = true
Prepare, format, then commit with record overwrite
If a formatting tool such as dprint fmt runs between PrepareRelease and CommitRelease, it may change the whitespace or key ordering of the generated release.json. By default, CommitRelease would treat this as a mismatch and error. Set update_release_json = true to allow CommitRelease to overwrite the formatted file with the semantically-equivalent regenerated content:
[[cli.release-pr.steps]]
type = "PrepareRelease"
name = "prepare release"
[[cli.release-pr.steps]]
type = "Command"
name = "format changed files"
command = "dprint fmt --allow-no-files {{ changed_files }} .monochange/releases/"
[[cli.release-pr.steps]]
type = "CommitRelease"
name = "create release commit"
update_release_json = true
This pattern is the recommended way to combine automated formatting with release record durability.
Prepare, commit, then open a release request
A strong provider-facing pattern is:
PrepareReleaseCommitReleaseOpenReleaseRequest
That sequence keeps the release branch and provider request aligned with the durable release commit that monochange created.
Prepare once, then let custom tooling consume commit metadata
If your team has custom chat notifications, CI uploads, or deployment hooks, CommitRelease is a better producer than a hand-written git commit command because later steps can read structured release_commit.* values directly.
Good fit / bad fit
Good fit:
- release workflows that should leave behind a durable git record
- teams that want provider automation to begin from a known release commit
- workflows that may later need
RetargetRelease
Bad fit:
- validation or inspection-only commands
- workflows that do not prepare a release first
- commands where a plain custom shell commit is acceptable and no monochange release record is needed
Why choose it over a plain git commit command?
Because CommitRelease understands prepared release state and writes monochange’s ReleaseRecord contract for you.
A raw shell commit can create a commit, but it cannot automatically preserve the release metadata that later monochange repair and inspection features rely on unless you reimplement that format yourself.
Common mistakes
- treating
CommitReleaseas a replacement forPrepareRelease - assuming the cached
.monochange/release-manifest.jsonartifact must be committed forCommitReleaseto succeed - assuming it publishes releases or opens a release request by itself
- forgetting that
--dry-runpreviews the commit rather than creating it - reaching for a custom
git commitcommand and then losing durable release metadata - running a formatter (such as
dprint fmt) betweenPrepareReleaseandCommitReleasewithout settingupdate_release_json = trueon theCommitReleasestep
VerifyReleaseBranch
What it does
VerifyReleaseBranch checks that a git ref resolves to a commit reachable from one of the configured release branches.
The policy lives under [source.releases]:
[source.releases]
branches = ["main", "release/*"]
enforce_for_tags = true
enforce_for_publish = true
enforce_for_commit = false
branches accepts multiple branch names and glob patterns. The check uses commit reachability, so it also works in detached CI checkouts when the tag or HEAD commit is present in the repository history.
Inputs
from— git ref to verify. Defaults toHEAD.
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Example
[cli.verify-release-branch]
help_text = "Verify this checkout is on an allowed release branch"
[[cli.verify-release-branch.inputs]]
name = "from"
kind = "string"
default = "HEAD"
[[cli.verify-release-branch.steps]]
type = "VerifyReleaseBranch"
[cli.verify-release-branch.steps.inputs]
from = "{{ inputs.from }}"
Built-in enforcement
You usually do not need to add this step manually for protected release operations:
mc step:tag-releaseenforces[source.releases]whenenforce_for_tags = true.PublishReleaseandPublishPackagesenforce[source.releases]during real publish runs whenenforce_for_publish = true.CommitReleaseenforces[source.releases]only whenenforce_for_commit = true.
Use the explicit step when you want an early, standalone CI gate before other workflow work runs.
PlanPublishRateLimits
PlanPublishRateLimits inspects monochange’s built-in ecosystem rate-limit catalog and renders a publish schedule before any registry mutation happens.
Use it when you want to answer questions like:
- how many package publishes fit in one registry window
- which packages should be split into later batches
- whether a filtered package set is safe to publish now
- what GitHub Actions or GitLab CI batch snippet should drive the publish run
Inputs
format—text,markdown, orjsonmode—publish(default) orplaceholderpackage— optional repeated package ids used to filter the planreadiness— optional path to a JSON artifact frommc step:publish-readiness; only valid whenmode = "publish"ci— optionalgithub-actionsorgitlab-cisnippet renderer
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Produces
A structured publish-rate-limit report containing:
- registry windows
- batch counts
- explicit package ids per batch
- evidence and confidence metadata for each built-in policy
- only versions that are still missing from their registries, so reruns reflect the remaining work
- when
readinessis provided, only package ids ready in both the artifact and the fresh local readiness check
Examples
Plan a normal publish
[cli.publish-plan]
help_text = "Plan package-registry publish work against known ecosystem rate limits"
[[cli.publish-plan.inputs]]
name = "format"
type = "choice"
default = "json"
choices = ["text", "markdown", "json"]
[[cli.publish-plan.inputs]]
name = "readiness"
type = "path"
help_text = "JSON artifact from 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
format—textorjson
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
- a previous
PrepareReleasestep in the same command [source]configuration
Side effects and outputs
- in dry-run mode, builds preview release requests
- in normal mode, creates or updates provider releases
- contributes release request/result data to the command’s final output
Example
[cli.publish-release]
help_text = "Prepare a release and publish hosted releases"
[[cli.publish-release.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
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
format—textorjson
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
- a previous
PrepareReleasestep in the same command [source]configuration
Side effects and outputs
- in dry-run mode, previews the request payload
- in normal mode, performs git and provider operations needed to open or update the request
- contributes release-request data to the command result
GitHub Actions verified commit behavior
When OpenReleaseRequest publishes a GitHub release pull request in normal mode, monochange first uses local git as the durable fallback path: it checks out the release branch, stages the tracked release files, creates the release commit, and pushes that branch before opening or updating the pull request.
When [source.pull_requests].verified_commits = true and the command is running inside GitHub Actions for the same repository as [source], the GitHub provider then tries to replace that pushed fallback commit with a GitHub-verified commit:
- It builds the GitHub API client from
GITHUB_TOKENorGH_TOKEN. - It reads the pushed fallback commit through the Git Database API.
- It creates a new Git commit object with the same message, tree, and parents.
- It accepts the replacement only when GitHub returns
verification.verified = truefor the new commit. - It confirms the release branch still points at the fallback commit, then moves the branch ref to the verified commit.
Verified commit replacement is opt-in and defaults to off. Any failure keeps the original pushed git commit in place. That includes missing tokens, non-GitHub Actions environments, repository mismatches, GitHub returning an unverified commit, API errors, or the release branch moving between the fallback push and the ref update. The fallback is intentional: release PR automation should keep working even when verified commit replacement is unavailable.
Dry runs never create commits, push branches, or call the provider APIs. Non-GitHub providers continue to use their normal release-request behavior.
Example
[cli.release-pr]
help_text = "Prepare a release and open or update a release request"
[[cli.release-pr.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
default = "text"
[[cli.release-pr.steps]]
type = "PrepareRelease"
inputs = ["format"]
[[cli.release-pr.steps]]
type = "OpenReleaseRequest"
inputs = ["format"]
Composition ideas
Prepare, commit, then open a release request
[cli.release-pr-from-commit]
help_text = "Prepare a release, create the release commit, and open a release PR"
[[cli.release-pr-from-commit.steps]]
type = "PrepareRelease"
[[cli.release-pr-from-commit.steps]]
type = "CommitRelease"
[[cli.release-pr-from-commit.steps]]
type = "OpenReleaseRequest"
Open a request and run an extra notification step
[cli.release-pr-notify]
help_text = "Open a release request and notify another system"
[[cli.release-pr-notify.steps]]
type = "PrepareRelease"
[[cli.release-pr-notify.steps]]
type = "OpenReleaseRequest"
[[cli.release-pr-notify.steps]]
type = "Command"
command = "echo opened release request for {{ release.version }}"
shell = true
Why choose it over a custom git + provider script?
Because OpenReleaseRequest already knows:
- which release targets were prepared
- which files changed
- how monochange wants release requests described
- how dry-run should behave
Common mistake
Do not assume OpenReleaseRequest can infer a release on its own. It is not a replacement for PrepareRelease.
CommentReleasedIssues
What it does
CommentReleasedIssues uses prepared release context to comment on issues linked from the release’s changeset and review metadata.
It is a post-publication communication step, not a planning step.
Why use it
Use CommentReleasedIssues when you want monochange to close the loop after publication by posting structured release follow-up comments.
This is especially valuable when:
- issues are part of the public release workflow
- you want issue comments to stay tied to the exact prepared release data
- you want a dry-run preview before touching hosted issue state
Inputs
format—textorjson
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
- a previous
PrepareReleasestep in the same command [source].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:
PrepareReleasePublishReleaseCommentReleasedIssues
That ordering reflects the real-world intent: only comment after the release event exists.
Comment and then run a reporting step
[cli.publish-comment-report]
help_text = "Publish a release, comment on issues, and print a short report"
[[cli.publish-comment-report.steps]]
type = "PrepareRelease"
[[cli.publish-comment-report.steps]]
type = "PublishRelease"
[[cli.publish-comment-report.steps]]
type = "CommentReleasedIssues"
[[cli.publish-comment-report.steps]]
type = "Command"
command = "echo issue comments processed for {{ release.version }}"
shell = true
Why choose it over a custom GitHub API script?
Because the built-in step already consumes monochange’s linked issue and review metadata model. A shell script would need to rediscover which issues matter for the release.
Common mistake
Using CommentReleasedIssues without a GitHub source configuration. This step is intentionally provider-specific 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 modewhen— optional boolean condition controlling whether the step runsdry_run_command— optional replacement command used only when the command runs with--dry-runshell— whether to run through a shell (true,false, or a custom shell binary name)id— optional identifier that exposessteps.<id>.stdoutandsteps.<id>.stderrto later stepsvariables— optional custom variable mapping for command substitutioninputs— optional step-local input overridesshow_progress— optional boolean; set tofalsewhen the command itself is interactive and spinner output would get in the wayalways_run— optional boolean; set totrueto run this step even when a previous step has failed
Prerequisites
Command itself has no built-in prerequisite.
What it can see depends on where you place it:
- after
PrepareRelease, it can consumerelease.*andmanifest.path - after
AffectedPackages, it can consumeaffected.* - after
RetargetRelease, it can consumeretarget.* - after
CommitRelease, it can consumerelease_commit.* - after another named
Command, it can consumesteps.<id>.*
Side effects and outputs
- runs an external command
- records stdout/stderr when
idis present - can act as a consumer or producer step in a workflow chain
Example
[cli.test]
help_text = "Run project tests"
[[cli.test.steps]]
type = "Command"
command = "cargo test --workspace --all-features"
dry_run_command = "cargo test --workspace --all-features --no-run"
shell = true
Composition ideas
Consume prepared release context
[cli.release-with-notes]
help_text = "Prepare a release and print a custom summary"
[[cli.release-with-notes.inputs]]
name = "format"
type = "choice"
choices = ["text", "json"]
default = "text"
[[cli.release-with-notes.steps]]
type = "PrepareRelease"
inputs = ["format"]
[[cli.release-with-notes.steps]]
type = "Command"
command = "echo Releasing {{ release.version }} for {{ released_packages }}"
shell = true
Reuse earlier command output
[cli.release-with-generated-notes]
help_text = "Prepare a release, generate notes, and upload them"
[[cli.release-with-generated-notes.steps]]
type = "PrepareRelease"
[[cli.release-with-generated-notes.steps]]
type = "Command"
id = "notes"
command = "printf 'version=%s\n' '{{ release.version }}'"
shell = true
[[cli.release-with-generated-notes.steps]]
type = "Command"
command = "printf '%s\n' '{{ steps.notes.stdout }}'"
shell = true
Consume repair state
[cli.repair-and-notify]
help_text = "Repair a release and print the retarget result"
[[cli.repair-and-notify.inputs]]
name = "from"
type = "string"
required = true
[[cli.repair-and-notify.inputs]]
name = "target"
type = "string"
default = "HEAD"
[[cli.repair-and-notify.steps]]
type = "RetargetRelease"
inputs = ["from", "target"]
[[cli.repair-and-notify.steps]]
type = "Command"
command = "echo moved {{ retarget.tags }} to {{ retarget.target }} with status {{ retarget.status }}"
shell = true
Why choose Command carefully?
Because it is powerful enough to bypass monochange’s typed guarantees.
That is useful, but it also means:
- validation cannot reason deeply about your command string
- provider-aware dry-run semantics are now partly your responsibility
- shell quoting and portability become part of the workflow design
Recommended usage pattern
A good workflow usually looks like this:
- use built-in steps to create stable state
- use
Commandonly for the final custom integration points - give important custom steps an
idso later steps can consume structured stdout
Common mistakes
- using
Commandto reimplementPublishReleaseorOpenReleaseRequest - forgetting
dry_run_commandwhen the real command would mutate external systems - omitting
idand then having no clean way to reuse the command’s output later - relying on shell features without setting
shell = trueor a custom shell name
DisplayVersions
What it does
DisplayVersions computes monochange’s planned package and group versions and renders only that summary.
Use it when you want the release-version answer without the rest of the release preview.
Why use it
Use DisplayVersions when you want a dedicated read-only command such as 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
format—text,markdown, orjson
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. DisplayVersions is standalone.
Side effects and outputs
DisplayVersions is read-only.
It:
- computes the same planned package and group versions used by monochange release workflows
- renders a compact summary in
text,markdown, orjson - does not update manifests, changelogs, or consumed changesets
- does not require a previous
PrepareReleasestep
Example
[cli.versions]
help_text = "Display planned package and group versions"
[[cli.versions.inputs]]
name = "format"
type = "choice"
choices = ["text", "markdown", "json"]
default = "text"
[[cli.versions.steps]]
name = "display versions"
type = "DisplayVersions"
Composition ideas
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
PrepareReleasein publish or release-request workflows - bundling it into long multi-step commands when a dedicated
versionscommand 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
PublishPackagescan update it with a real version
The step inspects each package’s publish configuration, checks the registry to see if the package already exists, and only attempts to publish when the package is missing.
Why use it
Use PlaceholderPublish when you want monochange to handle the initial registry bootstrap rather than running manual publish commands.
That gives you:
- automatic registry detection (the step skips packages that already exist)
- ecosystem-aware publish commands (
cargo publish,npm publish,dart pub publish,deno publish, and so on) - rate-limit planning before any mutation happens
- dry-run previews that show what would be published without touching registries
- structured
publish.*template context for laterCommandsteps
Use PublishPackages instead when you want to publish the real planned versions from a prepared release.
Inputs
format—text,markdown, orjsonpackage— optional repeated package ids used to filter the publish set
Step-level when condition
All CLI steps support an optional when = "..." condition.
If the expression resolves to false at runtime, monochange skips the step and continues with the next step.
when = "{{ inputs.enabled }}"
Step-level always_run flag
All CLI steps support an optional always_run = true flag.
When set, the step executes even if a previous step in the same command has failed. This is useful for cleanup, notification, or dry-run preview steps that must run regardless of earlier outcomes.
always_run = true
Prerequisites
None. PlaceholderPublish does not require a previous PrepareRelease step.
Side effects and outputs
- in dry-run mode, plans and previews placeholder publish operations without touching registries
- in normal mode, publishes
0.0.0placeholder versions for missing packages - reports only packages that need action by default; pass
--show-allto include already-published and skipped packages - contributes
publish.*andpublish_rate_limits.*template context to the command result
Example
[cli.placeholder-publish]
help_text = "Publish placeholder package versions for missing registry packages"
[[cli.placeholder-publish.inputs]]
name = "format"
type = "choice"
choices = ["text", "markdown", "json"]
default = "text"
[[cli.placeholder-publish.inputs]]
name = "package"
type = "string_list"
[[cli.placeholder-publish.inputs]]
name = "show-all"
type = "boolean"
help_text = "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
PlaceholderPublishwithPublishPackages: the former publishes0.0.0placeholders, the latter publishes the real planned versions - forgetting that
PlaceholderPublishdoes not requirePrepareRelease, butPublishPackagesdoes - expecting placeholder versions to be updated automatically: placeholder publish is a one-time bootstrap step
PublishPackages
What it does
PublishPackages publishes package versions to their target registries using monochange’s built-in ecosystem workflows.
The step derives publish work from durable monochange release state: a prepared release artifact when the command has one, or the release record discoverable from HEAD otherwise. It does not require a readiness artifact. Before publishing, it orders selected package publications by internal publish-relevant dependencies so dependencies are attempted before dependents. Runtime, build, peer, workspace, and unknown dependency kinds participate in ordering and cycle validation; development-only dependency cycles are ignored.
For each package with a planned release version, the step:
- resolves the registry from the package’s publish configuration
- validates publish-relevant dependency cycles before registry mutation
- publishes dependencies before dependents within the selected publish set
- checks whether the version already exists (skipping if it does)
- plans against registry rate limits before attempting any mutation
- runs the ecosystem-specific publish command (
cargo publish,npm publish,dart pub publish,flutter pub publish,deno publish, and so on) - produces a structured report of what was published, skipped, or planned
You can filter the publish set with the package input, or use an empty set to publish everything from the selected release state.
Publication order
Package publication order is dependency-aware. monochange publishes packages with no selected dependencies first, then publishes packages that depend on those packages, walking up the dependency tree until packages that depend on the most selected packages are published last.
The order is computed like this:
- Build the selected publish requests from the prepared release or
HEADrelease state. - Materialize the workspace dependency graph.
- Consider only dependencies where both packages are part of the selected publish set.
- Ignore development dependency edges.
- Topologically sort the publish requests so dependencies are emitted before dependents.
For example, with this internal package graph:
core # no dependencies
utils # depends on core
api # depends on utils
app # depends on core, utils, api
monochange publishes in this order:
core
utils
api
app
If multiple packages are independent at the same depth, their order is deterministic by package id, registry, and version.
A package with no selected dependencies is eligible first. A package is not published until all of its selected publish-relevant dependencies have been ordered before it. Dependencies outside the selected publish set do not block ordering. Development-only cycles are ignored. Runtime, build, peer, workspace, and unknown dependency cycles fail before publishing anything, with a cycle diagnostic.
Why use it
Use PublishPackages when you want monochange to handle the full package-registry publication workflow rather than scripting individual publish commands.
That gives you:
- one publish step for all supported ecosystems
- automatic dependency ordering across internal package publications
- publish-relevant cycle detection before registry mutation
- automatic rate-limit planning and enforcement
- version-existence checks that prevent duplicate publish attempts
- dry-run previews that show the full publish plan without touching registries
- structured
publish.*template context for laterCommandsteps
Use PlaceholderPublish instead when you need to bootstrap a package that does not yet exist in its registry with a minimal 0.0.0 placeholder.
Inputs
format—text,markdown, orjsonpackage— optional repeated package ids used to filter the publish setgroup— optional repeated group ids; all packages in each group are added to the publish setecosystem— optional repeated ecosystem names (cargo,npm,deno,dart,flutter,python,go); only packages targeting the selected ecosystems are publishedresume— optional path to a JSON result artifact from an earlier realmc publishrun; completed package versions are skipped and failed or pending work is retriedoutput— optional path where monochange writes the package publish result JSON artifact for retry/resume 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
HEADthat contains the package publication targets - no cycles among selected publish-relevant internal dependencies; development-only cycles are allowed
- for built-in Cargo publishes to crates.io, a publishable current
Cargo.toml: nopublish = false, anypublish = [...]list includescrates-io,descriptionis set, and eitherlicenseorlicense-fileis set; workspace-inherited values are accepted
Side effects and outputs
- in dry-run mode, plans and previews publish operations without touching registries
- in normal mode, validates release-branch policy and publish-relevant dependency cycles, then publishes package versions to their configured registries
- when
outputis set, writes the package publish result artifact even if a registry publish command fails, then exits non-zero for failed package outcomes - contributes
publish.*andpublish_rate_limits.*template context to the command result
Example
[cli.publish]
help_text = "Publish package versions from monochange release state using built-in workflows"
[[cli.publish.inputs]]
name = "format"
type = "choice"
choices = ["text", "markdown", "json"]
default = "text"
[[cli.publish.inputs]]
name = "package"
type = "string_list"
[[cli.publish.inputs]]
name = "group"
type = "string_list"
help_text = "Group ids whose member packages should be published"
[[cli.publish.inputs]]
name = "ecosystem"
type = "string_list"
help_text = "Ecosystems to publish (cargo, npm, deno, dart, flutter, python, go)"
[[cli.publish.inputs]]
name = "resume"
type = "path"
help_text = "JSON result artifact from an earlier 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
PublishPackageswithPublishRelease: the former publishes to package registries, the latter creates hosted provider releases (such as GitHub releases) - assuming
mc publishconsumes the JSON file frommc step:publish-readiness; use readiness for preflight review ormc publish-plan --readiness, not as aPublishPackagesinput - omitting
outputin CI, which makes partial registry failures harder to resume safely - expecting development-only dependency cycles to block publishing; only publish-relevant dependency kinds participate in cycle validation
- running
PublishPackageswithout rate-limit planning: usePlanPublishRateLimitsfirst when you are unsure about registry windows