Skip to content

goabonga/multicz

Repository files navigation

multicz

Multi-component versioning for monorepos. Bump a Python app, its Docker image, and the Helm chart that deploys it from a single conventional-commit history — each with its own version line and its own git tag.

The problem

You have one repo with a few moving parts:

repo/
├── src/                 # FastAPI app
├── pyproject.toml       # → version 1.2.0
├── Dockerfile           # built and tagged from the app version
└── charts/myapp/
    ├── Chart.yaml       # version: 0.4.0 / appVersion: 1.2.0
    └── templates/       # kubernetes manifests

A change to src/ is a new app release; a change only under charts/myapp/templates/ is a new chart release for the same app. Standard tools bump everything together or force you to script per-folder logic. multicz makes the rule explicit in multicz.toml.

Security

Multicz is a release tool: it modifies version files, writes commits, creates tags, and (with --push) sends them to remote. The threat model is straightforward — the security guarantees should match.

Properties guaranteed by the implementation

  • No network access by default. Multicz only invokes git. There are no HTTP calls, no fetching of registries, no auto-updates. The network only enters the picture when you pass --push.
  • Deterministic planning. Same git history + same multicz.toml yields the same plan. There's no implicit time-of-day, no remote state lookup, no learned heuristic. Repeat runs are byte-identical (modulo the timestamp written into CHANGELOG.md / debian/changelog / state.json, which is wall-clock UTC).
  • Explicit changed files from git. Multicz uses git diff-tree --name-only per commit — the exact set of paths actually touched, not heuristics. A path_overlap finding from validate reads from git ls-files; nothing is sniffed from a watcher or filesystem scan.
  • No code execution from config. The TOML schema is pydantic-validated with extra="forbid". There are no callbacks, no Python imports from data, no shell-out templates.

Hardening options

concern option
Tampered release commits [project].sign_commits = true or multicz bump --sign (passes -S to git commit)
Tampered tags [project].sign_tags = true or multicz bump --sign (passes -s to git tag)
Manual edits bypassing the bump flow [project].state_file = ".multicz/state.json" + multicz validate (drift detection)
Non-conventional commits sneaking into a release [project].unknown_commit_policy = "error"
Overlapping component paths leaking changes silently [project].overlap_policy = "error" (default)
Path / mirror / trigger cycles multicz validate — runs as a CI gate before bump

CI hardening checklist

  1. Pin multicz by exact version in your CI install step (pip install multicz==1.2.0 or uv tool install --frozen multicz).
  2. Run multicz validate --strict first. It catches misconfigured bump_files, mirror cycles, and path overlaps before anything is written.
  3. Use multicz plan --dry-run (or multicz plan --output json) to inspect the bump in PR previews, not at release time.
  4. Sign commits and tags in CI. GitHub Actions accepts a GPG key via crazy-max/ghaction-import-gpg; GitLab via git config user.signingkey then enabling sign_commits / sign_tags in multicz.toml.
  5. Limit who can --push. Multicz never pushes unless asked. Keep the release job behind a manual approval / protected branch.
  6. Audit the state file if you've enabled it. git log -p .multicz/state.json gives a tamper-evident trail of every release.

The example pipelines in examples/ci/ follow these recommendations.

Where the config lives

By default, multicz looks for a dedicated multicz.toml at the repo root. As a fallback (walked up the directory tree from the cwd), it also accepts:

  • pyproject.toml under [tool.multicz] — natural for Python projects
  • package.json under a "multicz" key — natural for Node.js projects

Search order at each directory level:

  1. multicz.toml (always wins when present)
  2. pyproject.toml with a [tool.multicz] table
  3. package.json with a "multicz" key

A pyproject.toml without [tool.multicz] is silently skipped — it's not treated as the multicz config — so projects that already have a pyproject for tooling reasons aren't hijacked.

Examples:

# pyproject.toml
[project]
name = "myapp"
version = "1.0.0"

[tool.multicz.components.api]
paths = ["src/**", "pyproject.toml"]
bump_files = [{ file = "pyproject.toml", key = "project.version" }]

[tool.multicz.components.web]
paths = ["frontend/**"]
bump_files = [{ file = "frontend/package.json", key = "version" }]
{
  "name": "monorepo",
  "version": "1.0.0",
  "multicz": {
    "components": [
      { "name": "web", "paths": ["frontend/**"] },
      { "name": "mobile", "paths": ["mobile/**"] }
    ]
  }
}

multicz init still writes a dedicated multicz.toml. To inline the config into pyproject.toml or package.json, copy the body of the generated multicz.toml under the appropriate parent key.

Install

uv add --dev multicz   # or: pip install multicz

Why not semantic-release, Commitizen, Changesets, or bump-my-version?

Multicz isn't trying to replace any of these — they're better than multicz at what they're designed for. The reason it exists is that none of them cleanly modelled the same shape of repository.

semantic-release is excellent for a single-package repo (one package.json, one release stream, one tag scheme). Multi-package support exists via plugins (semantic-release-monorepo, semantic-release-plus) but feels grafted on, and the workflow centres on auto-publishing to a registry. Multicz takes the opposite stance: components are first-class, and publishing is left to CI.

Commitizen has two faces — cz commit (interactive wizard for writing conventional commits) and cz bump (semver bumper). Multicz cares about the second; we recommend cz commit or multicz check as a commit-msg hook for the first. cz bump itself is single-version: one pyproject.toml, one [tool.commitizen] block, one tag.

Changesets is the state of the art for JS monorepos: each PR adds a "changeset" file declaring the intended bump, and the release tool aggregates them. That model excels when the team writes the changeset by hand — the intent is encoded explicitly, not inferred from commits. It's less natural when you also have a Helm chart that should mirror the API version automatically, a .deb source package, or a Cargo workspace member.

bump-my-version (successor to bump2version) is great for the "many files, one version" problem: pattern-based replacements across version strings that need to stay in sync. It doesn't read commits — you tell it the bump kind explicitly. Multicz keeps the multi-file substitution and adds commit detection plus per-component independence.

Other related toolsrelease-please, poetry-bumpversion, knope, cargo-release, hatch version — each solve a slice of the problem. None that I tried can express "a commit touching src/ bumps api minor; the chart cascades a patch because its appVersion mirrors api" in a single config without scripting around the tool.

What multicz does differently

  • Components, not packages. Everything is keyed by component name (api, chart, frontend). A component can be backed by any manifest — pyproject.toml, Chart.yaml, package.json, Cargo.toml, go.mod, gradle.properties, debian/changelog — or none at all (tag-driven Go modules).
  • File ownership via globs. paths = ["src/**", "Dockerfile"] declares what a component owns, gitignore-style. Multiple components can share or exclude paths via overlap_policy.
  • Mirrors with cascade semantics. A mirror writes a component's version into another component's file (the canonical case: api version → Helm chart's appVersion). The receiving component cascades a patch bump so the chart pins exactly one app version per release.
  • No publishing. Multicz never pushes images, packages a chart, or uploads to a registry. It tells CI what changed, what version to use, and what artefacts to publish; CI does the work.
  • Multi-format substitution. TOML, YAML, JSON, .properties and plain files are all supported with formatting preserved (comments, key order, quote style).
  • Stateless by default. Every command re-derives from git tags and the in-tree manifests. The optional state_file is for teams that want an audit trail and drift detection.

When you should reach for something else

  • You have a single Python package and want a one-command bumper → bump-my-version, cz bump, or hatch version.
  • You have a JS monorepo and your team is happy writing changesets by hand → changesets is more battle-tested.
  • You have one repo per package and want auto-publish on every release → semantic-release + its release plugin.
  • You don't want any commit grammar at all → bump-my-version (you drive the kind manually).

If your repo has multiple deliverables, mirrors between them, and you want commits to drive the bumps without writing release notes by hand — that's the case multicz exists for.

Quickstart

multicz init           # writes a starter multicz.toml
$EDITOR multicz.toml   # declare your components
multicz status         # show which components would bump and why
multicz bump --dry-run # plan the bump without touching files
multicz bump           # apply the plan

How it works

Components can be declared in either of two equivalent TOML syntaxes:

# Dict-of-tables (concise; default emitted by `multicz init`)
[components.api]
paths = ["src/**", "pyproject.toml"]

[components.web]
paths = ["frontend/**"]
# Array-of-tables (preferred when you have many components or want
# to keep declaration order obvious in the file layout)
[[components]]
name = "api"
paths = ["src/**", "pyproject.toml"]

[[components]]
name = "web"
paths = ["frontend/**"]

Each component declares:

  • paths — gitignore-style globs of files it owns;
  • bump_files — where the canonical version is written;
  • mirrors — files that should reflect this component's version (e.g. a Helm chart's appVersion mirroring the app version);
  • triggers — other components whose bumps should trigger this one;
  • changelog — path to a CHANGELOG.md the planner should keep in sync;
  • post_bump — shell commands run after the writes to regenerate lockfiles (uv lock, npm install --package-lock-only, cargo update --workspace, helm dependency update charts/foo, bundle lock, composer update --lock, go mod tidy, …). Files modified by these commands are auto-detected and folded into the release commit, so the lockfile and the version it pins land atomically.

The planner runs three passes:

  1. direct — for every component, look at conventional commits since its last tag whose changed files map to it; pick the strongest implied bump (feat → minor, fix/perf → patch, !/BREAKING CHANGE → major).
  2. triggers — propagate bumps along declared upstream edges.
  3. mirror cascade — when a component A writes its version into a file owned by component B, B receives a patch bump. This keeps Helm chart immutability: chart-0.5.0 always pins the same appVersion.

Example: FastAPI + Helm chart

[components.api]
paths = ["src/**", "pyproject.toml", "tests/**", "Dockerfile"]
bump_files = [{ file = "pyproject.toml", key = "project.version" }]
mirrors    = [{ file = "charts/myapp/Chart.yaml", key = "appVersion" }]
changelog  = "CHANGELOG.md"

[components.chart]
paths      = ["charts/myapp/**"]
bump_files = [{ file = "charts/myapp/Chart.yaml", key = "version" }]
changelog  = "charts/myapp/CHANGELOG.md"

Behavior:

change api image tag chart.version appVersion
src/main.py (feat) minor follows api patch (cascade) mirror
Dockerfile (CVE base) patch follows api patch (cascade) mirror
charts/myapp/templates/dep.yaml patch
charts/myapp/values.yaml (config) patch

The Docker image tag is api.version itself — read it from CI:

TAG=$(multicz get api)
docker build -t registry/myapp:$TAG .
docker push registry/myapp:$TAG
helm package charts/myapp

CLI

command what it does
multicz init write a starter multicz.toml
multicz init --print render the discovered config to stdout (no file written)
multicz init --print --bare render the generic stub to stdout
multicz init --detect summary of detected components without rendering full TOML
multicz init --detect --output json machine-readable detection shape
multicz status brief table of pending bumps with reason summaries
multicz status --since origin/main preview the bump plan for a PR (vs main)
multicz changed components with files changed since their last tag (CI matrix)
multicz changed --since origin/main what changed in this branch vs main
multicz plan per-component plan with explicit reasons (commit / trigger / mirror)
multicz plan --since <ref> recompute the plan against a custom baseline
multicz explain <comp> --since <ref> scope explain to a specific window
multicz plan --output json machine-readable shape for CI
multicz explain <component> full breakdown — every commit, the matched files, every cascade
multicz bump apply bumps to all configured files
multicz bump --dry-run plan without writing
multicz bump --commit --tag release in one shot: write, commit, tag
multicz bump --commit --tag --push …and push commit + tags with --follow-tags
multicz bump --commit -m "..." verbatim release-commit message (overrides the template)
multicz bump --sign GPG-sign the release commit and tags (also via [project].sign_commits/sign_tags)
multicz plan --summary $GITHUB_STEP_SUMMARY append a markdown plan to GitHub's step-summary file
multicz bump --summary $GITHUB_STEP_SUMMARY append a markdown release block (commit, tags, push)
multicz bump --force api:patch manual bump for rebuilds without commits
multicz bump --force api:minor --force chart:major repeatable across components
multicz bump --output json emit {"bumps": {...}, "git": {...}} for CI
multicz get <component> read the current version from the primary bump file
multicz changelog [-c name] per-component conventional-commit log since the last tag
multicz changelog --output md the same, grouped into Breaking / Features / Fixes / Perf / Other
multicz release-notes <comp> one-shot release notes for the upcoming bump (no file written)
multicz release-notes --tag <tag> retrospective notes for a past release tag
multicz release-notes --all --output md one block per bumping component, ready for gh release create
multicz bump --no-changelog bump versions without touching declared CHANGELOG.md files
multicz bump --pre rc enter / continue a release-candidate cycle (1.2.31.3.0-rc.11.3.0-rc.2)
multicz bump --finalize drop a pre-release suffix (1.3.0-rc.21.3.0) — works with no new commits
multicz check <file> validate a commit message — wire as a commit-msg hook
multicz artifacts <comp> list what CI should build/push for the current version
multicz artifacts --all --output json machine-readable artifact refs for the whole repo
multicz validate run every config + repo sanity check (CI gate)
multicz state inspect the optional persistent state file (audit trail)
multicz validate --strict also fail on warnings (overlapping paths, useless mirrors, …)
multicz validate --output json machine-readable findings shape

Version scheme (semver vs PEP 440)

Pre-release versions render differently across ecosystems:

ecosystem form example
npm, Cargo, Helm, generic semver 2.0 1.3.0-rc.1
Python (canonical PEP 440) dotless 1.3.0rc1
Debian source packages tilde 1.3.0~rc1

The default version_scheme = "semver" works for npm, Cargo, Helm, and is also accepted by PEP 440 (just normalized internally). For projects that want strict canonical Python output, opt into pep440 per-component:

[components.api]
paths = ["src/**", "pyproject.toml"]
bump_files = [{ file = "pyproject.toml", key = "project.version" }]
version_scheme = "pep440"

[components.chart]
paths = ["charts/myapp/**"]
bump_files = [{ file = "charts/myapp/Chart.yaml", key = "version" }]
# default semver — Helm requires it

A run of multicz bump --pre rc --commit --tag writes:

pyproject.toml      version = "1.3.0rc1"
charts/.../Chart.yaml
  version:    0.4.1-rc.1     ← chart's own scheme (semver)
  appVersion: 1.3.0rc1       ← mirror copies api's rendered form
git tags
  api-v1.3.0rc1
  chart-v0.4.1-rc.1

PEP 440 compact label aliases are applied on output: --pre alpha with scheme = "pep440" produces 1.3.0a1 (canonical), not 1.3.0alpha1. Both forms are still parseable, so ordering and re-reads stay correct across schemes.

format = "debian" is incompatible with version_scheme = "pep440" — the Debian flow uses semver internally and applies its own ~rc1 notation at write time. Configs that combine the two are rejected at load.

Empty release / manual bump

When there are no commits the planner can act on, multicz bump is a no-op:

$ multicz bump
no bumps pending — use --force <name>:<kind> for a manual bump

Exit code is 0 — "nothing to do" is success, not failure.

For the cases where you genuinely need a release without code changes (weekly base-image rebuild for security patches, dependency-only update, deliberate retag), --force NAME:KIND is the manual escape hatch:

# Single forced bump
multicz bump --force api:patch

# Multiple components in one go
multicz bump --force api:minor --force chart:major

# Compose with --pre / --finalize / --commit / --tag
multicz bump --force api:minor --pre rc --commit --tag

--force shows up in the plan and explain output as a ManualReason so the audit trail is preserved:

{
  "kind": "manual",
  "note": "--force api:patch"
}

Promotion semantics: if the component would already bump from commits, --force is upgraded (never downgraded). A feat: (minor) plus --force api:patch stays at minor; feat: plus --force api:major jumps to major. The strongest level always wins.

Validation is upfront and explicit:

$ multicz bump --force api:weird
invalid kind 'weird': must be major, minor, or patch
exit=1

$ multicz bump --force unknown:patch
unknown component: unknown
exit=1

$ multicz bump --force no-colon
invalid --force spec 'no-colon': expected NAME:KIND (e.g. api:patch)
exit=1

--force does not add anything to the changelog (no commit to list), so the rendered CHANGELOG.md will say _No notable changes._ for the forced section. If you want a custom note, also pass --commit-message:

multicz bump --force api:patch --commit \
  -m "chore(release): rebuild api for CVE-2024-1234"

Release commit message

multicz bump --commit writes a single release commit. Its message is rendered from [project].release_commit_message, which defaults to:

chore(release): bump {summary}

{body}

Producing the historical shape:

chore(release): bump api 1.2.0 -> 1.3.0, chart 0.4.0 -> 0.5.0

- api: 1.2.0 -> 1.3.0 (minor)
- chart: 0.4.0 -> 0.5.0 (patch)

Available placeholders:

placeholder example
{summary} api 1.2.0 -> 1.3.0, chart 0.4.0 -> 0.5.0
{components} api v1.3.0, chart v0.5.0
{body} bullet list with kind annotations
{count} 2

Examples:

[project]
# Compact one-liner
release_commit_message = "chore(release): {components}"
# -> chore(release): api v1.3.0, chart v0.5.0

# Spell out the count
release_commit_message = "release: {count} components ({summary})"
# -> release: 2 components (api 1.2.0 -> 1.3.0, chart 0.4.0 -> 0.5.0)

Literal { and } must be escaped as {{ / }}.

For one-off releases, override the entire message with -m:

multicz bump --commit --tag -m "release: hotfix for the production outage"

-m is verbatim like git commit -m — no placeholders are expanded.

If you change the prefix, also update release_commit_pattern so the auto-filter still matches:

release_commit_pattern  = "^release"
release_commit_message  = "release: {components}"

Release candidates

A typical RC workflow:

# starting from api-v1.2.3, with new feat commits on the branch
multicz bump --pre rc --commit --tag      # → api-v1.3.0-rc.1
# more fixes
multicz bump --pre rc --commit --tag      # → api-v1.3.0-rc.2
# QA approves — ship the final
multicz bump --finalize --commit --tag    # → api-v1.3.0

--pre <label> accepts any label (rc, alpha, beta, dev, …) and the counter resets when you switch labels. --finalize is allowed even when no commits landed since the last RC tag — finalising IS a release event in its own right. Without either flag, a multicz bump from a pre-release version auto-finalises.

For Debian-format components the changelog stanza renders with ~ notation so apt's ordering puts pre-releases before the final: mypkg (1.3.0~rc1-1) < mypkg (1.3.0-1). The git tag itself stays in semver form (mypkg-v1.3.0-rc.1).

Finalize strategy

[project].finalize_strategy controls what the changelog looks like after --finalize:

value behaviour
consolidate (default) the finalize section/stanza lists every commit since the previous stable tag, so the new entry contains the cumulative change list. RC sections stay below as history.
promote same commit selection as consolidate, plus the now-superseded ## [1.3.0-rc.*] markdown sections (and mypkg (1.3.0~rc*-*) Debian stanzas) are removed from the file. The final entry stands alone.
annotate the section enumerates only commits since the last tag (rc included), so the finalize section may be _No notable changes._ when no commits landed between the last rc and finalize. Each tag keeps its own dedicated section.

validate

multicz validate is the recommended first step in any CI pipeline — it surfaces config and repo problems before they cause a botched release. Each finding has three levels:

level examples
error a bump_file doesn't exist, a trigger cycle, an unparseable debian/changelog — the planner can't run safely
warning two components claim the same file (first-match-wins makes the loser silent), a mirror that loops back to its own component
info a mirror to a file no component owns (no cascade fires), a debian/changelog that hasn't been created yet

Exit codes: 0 = clean (warnings/info don't fail), 1 = at least one error, 2 = --strict and at least one warning.

$ multicz validate
✗ lib: bump_file 'missing.toml' does not exist  (bump_files_exist)
! lib: shares files with 'api' (e.g. 'src/main.py')  (path_overlap)
i api: mirror target 'other.yaml' is not owned by any component  (mirror_target_unowned)
✗ mirror cascade cycle: cycle_a -> cycle_b -> cycle_a  (mirror_cycle)

2 errors, 1 warning, 1 info

The check identifier in parentheses (bump_files_exist, mirror_cycle, …) is stable so CI logs and PR comments can grep on it. --output json emits the same data as a structured payload with a counts summary.

Choosing the commit window (--since)

By default, every component compares against its own latest tag: the planner picks api-v1.2.0 for api and chart-v0.5.0 for chart, each scoped to that component's tag prefix. That's the right behaviour when you're cutting a release from main.

For other workflows, override the reference globally with --since:

use case command
PR preview ("what would bump if I merge this branch?") multicz plan --since origin/main
What changed in this branch (for CI matrix) multicz changed --since origin/main
Inspect commits from a specific point multicz status --since HEAD~10
Migrate from a legacy global tag scheme multicz plan --since v1.0.0
Recover from removed/recreated tags multicz plan --since <known sha>

--since accepts anything git rev-parse accepts: tags, branches, SHAs, HEAD~N, etc.

The override only moves the commit window used to compute bump kinds. The "current version" resolution (latest tag → primary bump_fileinitial_version) is unaffected — so even with --since origin/main, the planner still bumps from the latest released version, not from main. That's deliberate: PRs preview the "if merged" version without re-deriving history.

bump intentionally does not take --since. Combining a custom window with a write+tag is a footgun (you can create tags that contradict the actual history). Workflow:

multicz plan --since origin/main          # preview
# … inspect, decide …
multicz bump --commit --tag --push        # run the regular bump

changed (CI matrix gating)

multicz changed is the lightest possible question — did anything change — designed for CI to only run jobs for the components a PR actually touched. Distinct from plan: plan says "would bump", changed says "any activity, regardless of whether it's release- worthy".

multicz changed                       # per-component (since each one's last tag)
multicz changed --since origin/main   # every component vs main (PR gating)
multicz changed --output json

Default text output is one component name per line — pipeable into shell loops:

for comp in $(multicz changed --since origin/main); do
  echo "rebuilding $comp"
done

JSON output exposes both lists, ideal for fromJson in GitHub Actions matrices:

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      changed: ${{ steps.c.outputs.list }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - id: c
        run: |
          echo "list=$(multicz changed --since origin/main \
                       --output json | jq -c .changed)" >> $GITHUB_OUTPUT

  test:
    needs: detect
    if: needs.detect.outputs.changed != '[]'
    strategy:
      matrix:
        component: ${{ fromJson(needs.detect.outputs.changed) }}
    runs-on: ubuntu-latest
    steps:
      - run: cd ${{ matrix.component }} && make test

Release commits matching project.release_commit_pattern are filtered out so a previous multicz bump --commit doesn't keep flagging every component forever.

plan and explain

multicz plan is the canonical way to inspect what a release would do before running it. The text form is grouped per component:

api: 1.2.0 → 1.3.0 (minor)
  • abc1234 feat(api): add login flow

chart: 0.4.0 → 0.4.1 (patch)
  • mirror cascade from api (charts/myapp/Chart.yaml:appVersion)

multicz plan --output json emits a structured payload — exactly what a CI step needs to gate releases or post a comment on a PR. schema_version lets consumers guard against future breaking changes:

{
  "schema_version": 1,
  "bumps": {
    "api": {
      "current_version": "1.2.0",
      "next_version": "1.3.0",
      "kind": "minor",
      "reasons": [
        {
          "kind": "commit",
          "sha": "abc1234...",
          "type": "feat",
          "scope": "api",
          "breaking": false,
          "subject": "add login flow",
          "files": ["src/auth.py", "src/main.py"],
          "bump_kind": "minor"
        }
      ],
      "artifacts": [
        {"type": "docker", "ref": "ghcr.io/foo/api:1.3.0"}
      ]
    },
    "chart": {
      "current_version": "0.4.0",
      "next_version": "0.4.1",
      "kind": "patch",
      "reasons": [
        {
          "kind": "mirror",
          "upstream": "api",
          "file": "charts/myapp/Chart.yaml",
          "key": "appVersion"
        }
      ],
      "artifacts": []
    }
  }
}

Canonical jq queries CI scripts can rely on:

# anything pending?
multicz plan --output json | jq -e '.bumps | length > 0'

# a single component's next version
multicz plan --output json | jq -r '.bumps.api.next_version'

# every Docker ref to push (after bump --output json)
multicz bump --commit --tag --output json | \
  jq -r '.bumps[].artifacts[] | select(.type == "docker") | .ref'

# tags freshly created (from bump output, with --tag)
multicz bump --commit --tag --output json | jq -r '.git.tags[]'

End-to-end pipelines for the three big platforms are in examples/ci/:

platform workflow file
GitHub Actions examples/ci/github-actions/release.yml
GitLab CI/CD examples/ci/gitlab-ci.yml
Azure Pipelines examples/ci/azure-pipelines.yml

Reason kinds: commit, trigger, mirror, manual (e.g. an explicit --finalize). Each carries its own structured fields.

multicz explain <component> zooms in on a single component with the full per-commit breakdown — useful when the plan looks unexpected and you want to see which files of a commit actually mapped to the component:

Component: api
  Current version: 1.2.0
  Next version:    1.3.0 (minor)

Reasons:
  1. abc1234 feat(api): add login flow
      SHA:   abc1234...
      Type:  feat(api) → minor
      Files matched in this component:
        - src/auth.py
        - src/main.py

Release notes (gh release create)

multicz release-notes is the single-shot, no-file-written counterpart to the persistent CHANGELOG.md. Designed to be piped into gh release create or pasted into a GitHub/GitLab Release UI.

gh release create api-v1.3.0 --notes "$(multicz release-notes --tag api-v1.3.0)"

Three modes:

# upcoming bump for one component (preview before `multicz bump --tag`)
multicz release-notes api

# upcoming bumps for every bumping component (one --all output to paste)
multicz release-notes --all

# retrospective: what shipped in a past tagged release
multicz release-notes --tag api-v1.3.0

Critical detail for past tags: the previous-tag lookup is stable-aware. A stable release tag (api-v1.3.0) reads commits since the previous stable tag (api-v1.2.0) — not since the most recent RC — so the notes consolidate everything that shipped in 1.3.0 over the whole RC cycle. A pre-release tag (api-v1.3.0-rc.2) reads commits since the immediately previous tag (api-v1.3.0-rc.1) so each RC only shows the delta.

Output formats:

  • md (default) — sections (### Features, ### Fixes, …) and bullets
  • text — plain ASCII, useful in git log-style scripts
  • json{"sections": [{"component": "...", "from_version": "...", "to_version": "...", "commits": [...]}]} for further processing

The body honours every project-level rendering knob: changelog_sections, breaking_section_title, other_section_title, ignored_types. So whatever shape your CHANGELOG.md takes, release-notes produces identical sections.

Per-component CHANGELOG.md

When a component declares changelog = "path/to/CHANGELOG.md", every multicz bump automatically prepends a new keep-a-changelog section to that file:

## [1.3.0] - 2026-04-30

### Features

- **api**: add login (`abc1234`)

### Fixes

- null token (`def5678`)

The file is created with a small preamble on first use, and subsequent runs insert the new section directly above the latest existing release. Pass --no-changelog to opt out for a single bump.

Configuring sections

By default, only feat, fix, and perf are rendered (under "Features", "Fixes", "Performance"). Anything else (chore, docs, test, style, ci, build, refactor, revert) is silently dropped to keep the changelog focused on user-visible changes.

To pick your own vocabulary — for example keep-a-changelog's Added/Changed/Fixed — declare sections in [project]:

[project]
breaking_section_title = "Breaking changes"   # set to "" to disable the bucket
other_section_title = ""                       # set to e.g. "Misc" to keep unmatched

[[project.changelog_sections]]
title = "Added"
types = ["feat"]

[[project.changelog_sections]]
title = "Fixed"
types = ["fix"]

[[project.changelog_sections]]
title = "Changed"
types = ["refactor", "perf"]

Sections render in declaration order, after the implicit "Breaking changes" bucket (if any commit has ! or a BREAKING CHANGE: footer). One commit type can appear in multiple sections; commits whose type matches no section are dropped (or land in other_section_title if you set it).

Commit-msg hook

# .git/hooks/commit-msg
#!/bin/sh
exec multicz check "$1"

One-shot CI release

- run: |
    multicz bump --commit --tag --push
    TAG=$(multicz get api)
    docker build -t registry/myapp:$TAG .
    docker push registry/myapp:$TAG
    helm package charts/myapp

Supported file formats

bump_files and mirrors can point at:

  • .toml — comments and key order preserved (tomlkit)
  • .yaml / .yml — comments and quote style preserved (ruamel.yaml)
  • .json — indent and key order preserved (e.g. package.json)
  • .properties — line-based key=value substitution (e.g. gradle.properties)
  • anything else — treated as a one-line VERSION file (key = omitted)

Debian packages (format = "debian")

multicz writes a proper debian/changelog instead of a markdown CHANGELOG.md for components built as .deb:

[components.mypkg]
paths  = ["debian/**", "src/**"]
format = "debian"

[components.mypkg.debian]
changelog       = "debian/changelog"     # default
distribution    = "UNRELEASED"           # default — change to "unstable" before upload
urgency         = "medium"               # default
debian_revision = 1                      # appended as -<n> to the upstream version
# maintainer    = "Name <email>"         # falls back to debian/control then git config
# epoch         = 2                      # rare, prepended as "<n>:"

On multicz bump, the upstream version is read from the topmost stanza of debian/changelog, the new upstream is computed from the conventional commits since the last tag, and a fresh stanza is prepended to the file:

mypkg (1.3.0-1) UNRELEASED; urgency=medium

  * feat: Add login flow
  * fix(api): Null token on logout

 -- Chris <chris@example.com>  Fri, 01 May 2026 10:01:44 +0000

mypkg (1.2.3-1) unstable; urgency=medium

  * Initial release.

 -- Chris <chris@example.com>  Sun, 01 Jan 2023 00:00:00 +0000

Old stanzas are never rewritten, matching the contract of dch(1).

init modes

multicz init has three output modes that compose with the existing --bare flag:

# default: discover the working tree, write multicz.toml
multicz init

# render the discovered config to stdout, no file written
multicz init --print > custom-name.toml

# render the generic stub to stdout (composes with --bare)
multicz init --print --bare

# inspection only — show what would be detected, no rendering
multicz init --detect

# machine-readable detection (paths, bump_files, mirrors, format, …)
multicz init --detect --output json

--print and --detect are non-destructive: the filesystem is untouched, so they're safe to run inside CI without --force.

--detect is the lightest possible answer to "what would init pick up in this repo?":

$ multicz init --detect
Detected 2 component(s):
  • api (pyproject.toml)
      mirrors → charts/myapp/Chart.yaml:appVersion
  • myapp (charts/myapp/Chart.yaml)

--print returns the byte-for-byte TOML — pipe it into a file with a custom name, or into a diff against an existing config. Combinations rejected at parse time: --detect + --bare and --detect + --print.

Workspace rules

The user's natural worry: "what happens with nested workspaces?". Four explicit rules govern how multicz init resolves them.

1. Is the root manifest a component?

ecosystem root has version? root has workspace block? root → component?
Python [project].version set with [tool.uv.workspace] yes
Python no [project] table with [tool.uv.workspace] no (orchestrator)
Cargo [package] set with [workspace] yes
Cargo no [package] with [workspace] no (virtual workspace)
Node.js any version workspaces declared no (members only)
Node.js version set no workspaces yes (single-package)

A workspace orchestrator with no version is never a component — its job is to delegate, not to ship. A root that doubles as a package (common for Python and Cargo) IS a component, alongside its members.

2. Do workspace members inherit the version?

Each ecosystem decides:

ecosystem per-member? shared?
uv ([tool.uv.workspace]) members own their [project].version
Cargo [workspace.package].version when present, members inherit via version.workspace = true yes
Cargo without workspace.package.version members own their [package].version
npm/yarn/pnpm workspaces each package.json has its own version

When Cargo declares [workspace.package].version, multicz collapses the workspace into a single component bumping that one key. Members that inherit are silently skipped to avoid double-bumping. Mixed members (some inheriting, some declaring their own [package].version) are not currently supported — declare uniformly.

3. Are excluded members really ignored?

declaration excludes
[tool.uv.workspace].exclude = ["packages/legacy"] uv
[workspace].exclude = ["crates/legacy"] Cargo
"workspaces": ["packages/*", "!packages/legacy"] npm / yarn
pnpm-workspace.yaml: packages: ['packages/*', '!packages/legacy'] pnpm

All four are honored — excluded members never appear as components. The cross-ecosystem rule is consistent: if the workspace declaration excludes a path, multicz skips it.

4. What if two manifests share the same name?

_unique auto-suffixes the second one with the manifest type:

collision result
python api + chart api api, api-chart
python api + python api (rare) api, api-py
chart foo + chart foo (different dirs) foo, foo-chart-2

Suffix order is deterministic — the first manifest discovered keeps the bare name. To force a different naming, edit multicz.toml manually after init (the discovery only runs at init time; the planner reads whatever names you've declared).

Reference layout (covered by integration tests)

repo/
├── pyproject.toml              # root: [project] + [tool.uv.workspace]
├── services/
│   ├── api/pyproject.toml      # uv workspace member
│   └── worker/pyproject.toml   # uv workspace member
├── packages/
│   └── client/package.json     # npm package (no workspace block)
└── charts/
    └── api/Chart.yaml          # name collides with services/api

multicz init produces:

component source mirrors
monorepo root pyproject (workspace + [project])
api services/api/pyproject.toml charts/api/Chart.yaml:appVersion
worker services/worker/pyproject.toml none (no chart with that name)
client packages/client/package.json
api-chart charts/api/Chart.yaml (suffixed: collides with python api)

Auto-discovery languages

multicz init detects the following manifests across the working tree and seeds one component per project:

ecosystem manifest name source
Python **/pyproject.toml [project].name (PEP 621 / uv / hatch / modern Poetry) or [tool.poetry].name (legacy Poetry) — [tool.uv.workspace].members and exclude are honoured
Helm **/Chart.yaml name: field
Rust **/Cargo.toml [package].name (workspaces collapse to one component when [workspace.package].version is shared)
Go **/go.mod last segment of module … (strips /vN) — tag-driven, no version file
Gradle root gradle.properties with version= rootProject.name from settings.gradle[.kts]
Node.js root package.json (or workspace members via workspaces / pnpm-workspace.yaml) name field (npm scopes stripped)
Debian debian/changelog package name from the top stanza header

Common noise dirs (.git, node_modules, .venv, target, build, dist, vendor, …) are excluded from the scan.

Configuration reference

See examples/fastapi-helm/multicz.toml for a fully commented example.

Component naming

Component names land in many places — git tags, file paths (CHANGELOG.md location), JSON output, release-notes headings, the --force NAME:KIND CLI syntax. They're locked to a safe alphabet:

accepts examples
^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$ (≤ 64 chars) api, api-v1, api.v1, api_v1, myapp-chart, API, a

Rejected with a clear error at config-load time:

input reason
api/v1 slash → file-path injection in CHANGELOG.md location
../api path traversal
chart:prod conflicts with --force NAME:KIND
my app whitespace breaks shell tools
-foo, foo-, .hidden, foo. leading/trailing special chars
'' (empty) empty key
anything > 64 chars excessive length
$ multicz status
invalid /path/to/multicz.toml:
  components: invalid component name 'api/v1': must match
  ^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$ — no slashes, colons,
  spaces, or path-like characters; must start and end with a letter
  or digit. Component names land in git tags, file paths, JSON
  output, and release notes; keeping them simple avoids escaping
  issues downstream.

Tagging strategy

Each component gets its own git tag whose name is built from tag_format, with two placeholders:

placeholder substituted with
{component} the component name (the dict key, or name in array form)
{version} the new version produced by the bump

The default is tag_format = "{component}-v{version}" so a typical release looks like:

api-v1.3.0
api-v1.4.0-rc.1
chart-v0.5.0
frontend-v2.1.0
mypkg-v1.3.0          # debian-format components keep semver in the tag

Tags are annotated (created with -m), which makes them work in environments that have tag.gpgSign = true and lets git describe land on them naturally.

Per-component override

tag_format can be set on a component to override the project-wide default:

[project]
tag_format = "{component}-v{version}"

[components.api]
paths = ["src/**", "pyproject.toml"]

[components.legacy]
paths = ["legacy/**"]
tag_format = "v{version}"          # keep the historical scheme

Each component's rendered prefix (the bit before {version}) must be unique across the project — otherwise git tag --list <prefix>* would return tags from another component and the planner would read the wrong "current" version. multicz refuses to load a config where two components produce the same prefix and tells you which two to fix:

components 'foo' and 'bar' share the same tag prefix 'v'; tags would
collide. Set a unique tag_format on at least one of them.

Migration from a single-tag scheme

A common starting point is a legacy repo with global tags like v1.2.0, v1.3.0. To adopt multicz:

  1. Decide whether the legacy tags belong to one of the new components (typically the main app). Set tag_format = "v{version}" on that component so its history continues seamlessly.
  2. Give every other component a different prefix (the default {component}-v{version} does that for free).
  3. The planner reads the current version using this priority — git tag matching the resolved tag_format, then the value in the component's primary bump_file (pyproject.toml's [project].version, etc.), then initial_version. So even before you cut your first multicz tag, the in-tree version is honoured.

Concretely:

[project]
tag_format = "{component}-v{version}"

[components.api]
paths = ["src/**", "pyproject.toml"]
tag_format = "v{version}"          # legacy tags stay under "v" prefix

[components.chart]
paths = ["charts/**"]               # default "chart-v…" — fresh history

multicz status now shows api reading its version from the existing v1.2.0 tag while chart starts at initial_version.

Artifacts (what CI should build and push)

multicz does not build or push artifacts itself. It surfaces the information CI needs to do so, decoupled from your specific image registry, chart repository, or package index. Declare what each component publishes:

[components.api]
paths = ["src/**", "pyproject.toml"]
bump_files = [{ file = "pyproject.toml", key = "project.version" }]

[[components.api.artifacts]]
type = "docker"
ref  = "ghcr.io/foo/api:{version}"

[[components.api.artifacts]]
type = "docker"
ref  = "registry.acme.com/api:{version}"

[components.chart]
paths = ["charts/myapp/**"]
bump_files = [{ file = "charts/myapp/Chart.yaml", key = "version" }]

[[components.chart.artifacts]]
type = "helm"
ref  = "{component}-{version}.tgz"

[[components.chart.artifacts]]
type = "oci"
ref  = "oci://registry.acme.com/charts/{component}:{version}"

ref accepts {version} and {component} placeholders. type is free-form so CI can filter on it (docker, helm, oci, npm, pypi, …).

Three places surface the rendered artifacts:

# Direct lookup against the current version
multicz artifacts api
# api (1.2.0)
#   [docker] ghcr.io/foo/api:1.2.0
#   [docker] registry.acme.com/api:1.2.0

# Against an explicit target version
multicz artifacts api --version 1.4.0-rc.1

# JSON for CI scripts
multicz artifacts --all --output json

multicz plan --output json and multicz bump --output json both include an artifacts array per component rendered against the planned (or just-applied) version. CI can drive the actual build/push from a single payload:

- run: |
    RELEASE=$(multicz bump --commit --tag --output json)
    echo "$RELEASE" | jq -r '.bumps[].artifacts[] | select(.type=="docker") | .ref' \
      | xargs -I{} sh -c 'docker build -t {} . && docker push {}'
    echo "$RELEASE" | jq -r '.bumps[].artifacts[] | select(.type=="helm") | .ref' \
      | xargs -I{} sh -c 'helm package . && helm push {}'

Optional state file

multicz is normally stateless — every command recomputes from git tags and the in-tree manifests. For monorepos that want a persistent audit trail or drift detection (catch manual edits that bypassed multicz bump), opt into a state file:

[project]
state_file = ".multicz/state.json"

After every successful multicz bump, the file is written next to the version updates and lands in the release commit (when --commit is used):

{
  "version": 1,
  "git_head": "fe9a637d223e570fc873ecac9ee4e53c3c05ee31",
  "git_head_short": "fe9a637",
  "timestamp": "2026-05-01T17:46:27Z",
  "components": {
    "api": {
      "version": "1.3.0",
      "tag": "api-v1.3.0",
      "tag_sha": null
    }
  }
}

multicz state prints the snapshot. multicz state --output json emits the same JSON for jq consumption.

Drift detection in validate

When state_file is set, multicz validate adds two checks:

  • state_drift (warning) — the recorded version doesn't match the current value in the primary bump_file. Fires when someone edits pyproject.toml / Chart.yaml / package.json manually without going through multicz bump:
    ! api: state recorded version '1.3.0' but pyproject.toml now reads
      '9.9.9' — someone may have edited the file outside multicz bump
      (state_drift)
    
  • state_unknown_component (warning) — the state references a name no longer declared in multicz.toml (typically after a component was renamed or removed without clearing state).

The state file is opt-in. The default stateless flow remains the recommended setup for most repos — the planner always re-derives from git, which is the source of truth.

Path ownership and overlap

The matcher uses first-match-wins by default: when two components both claim a file (e.g. api and worker both listing src/**), the component declared first in the config silently owns it, and the others lose. That's predictable but easy to miss.

project.overlap_policy makes the choice explicit:

[project]
overlap_policy = "error"   # default
value validate runtime behaviour
error (default) error refuses to plan/bump until you resolve the overlap
first-match warning first-declared component owns the file (the others lose)
allow silent same runtime as first-match — suppresses the finding
all info a shared file bumps every claiming component

The all mode is genuinely useful for monorepos where several components share code:

[project]
overlap_policy = "all"

[components.api]
paths = ["src/**", "pyproject.toml"]

[components.worker]
paths = ["src/**", "workers/**"]

A feat: commit touching src/common.py now bumps both api and worker. With error (the default) that same commit refuses to plan until you tighten the paths or add exclude_paths.

Bump kind by commit type

commit bump
feat: … minor
feat!: … or BREAKING CHANGE: footer major
fix: … patch
perf: … patch
revert: … patch — a revert is user-visible activity
chore, docs, style, test, build, ci, refactor none
anything not matching <type>(<scope>)?: <subject> controlled by unknown_commit_policy (default: ignored)

A revert: feat(api): drop login is treated as a patch because something user-visible changed — a feature was removed (or restored). The conservative bump avoids saying "no change" when there clearly was one. Override per-component with bump_policy = "scoped" if you need a tighter scope rule, or with ignored_types = ["revert"] if you really want them silent.

The default [project].changelog_sections now includes a Reverts section so reverted commits show up in CHANGELOG.md and release-notes output:

## [1.3.1] - 2026-05-01

### Reverts

- drop login flow (`abc1234`)

The section only renders when the release window contains revert commits — projects without reverts see the same output as before.

Non-conventional commits

A commit like update stuff (no <type>: prefix) doesn't fit the conventional grammar. The default behaviour silently skips it — but that can hide real activity. project.unknown_commit_policy makes the choice explicit:

[project]
unknown_commit_policy = "ignore"   # default
# or "patch"
# or "error"
value planner behaviour
ignore (default) silent skip — backwards-compatible
patch the commit produces a NonConventionalReason at patch level, visible in plan / explain / JSON
error refuse to plan, list every offending SHA with a remediation hint

error mode renders a clean CLI message instead of a traceback:

$ multicz plan
✗ 2 non-conventional commit(s) blocking the plan (unknown_commit_policy='error')
  - 1b233e5: update stuff
  - 53f374b: wip

Either rewrite their headers as conventional commits (`git rebase -i`),
or set unknown_commit_policy = "ignore" (or "patch") in [project].

Use it in CI as a strict gate; ignore (default) keeps the existing laissez-faire experience.

Ignoring commit types

Some commit types should never appear in any bump or changelog — typically chore(deps): updates that incidentally touch src/, or ci: tweak workflow.yml against a .github/** path owned by a component. ignored_types makes that explicit:

[project]
ignored_types = ["chore", "ci", "docs", "test", "style"]

You can also opt-in per-component (the effective set is the union):

[components.api]
ignored_types = ["fix"]   # api ignores 'fix' on top of project-wide rules

A commit whose type is in the effective set is fully filtered:

with ignored_types = ["chore", "ci"]
feat: real change ✓ bumps, in changelog
fix: bug ✓ bumps, in changelog
chore(deps): bump typer ✗ ignored
ci: tweak release workflow ✗ ignored

The filter is stricter than release_commit_pattern (which targets one specific message shape): ignored_types short-circuits before the bump kind is even consulted, so feat!: ... is also dropped if feat is in the list. That's the explicit cost of the choice.

Component dependencies

Sometimes a component should bump because another component bumps — typically a Helm chart that ships a Python service: when the service bumps, the chart needs a fresh build with the new app version.

[components.api]
paths = ["src/**", "pyproject.toml"]
bump_files = [{ file = "pyproject.toml", key = "project.version" }]

[components.chart]
paths = ["charts/myapp/**"]
bump_files = [{ file = "charts/myapp/Chart.yaml", key = "version" }]
depends_on = ["api"]      # chart bumps whenever api does

When api goes from 1.2.0 to 1.3.0 (minor), chart cascades a bump too. The kind is governed by project.trigger_policy:

value behaviour when to use
match-upstream (default) dependent inherits the upstream's kind — api minor → chart minor the dependent is conceptually "the same release" as the upstream
patch dependent always patches when its upstream bumps — api minor → chart patch the dependent isn't really gaining a feature when its dependency does (typical for a chart that just needs a rebuild)
[project]
trigger_policy = "patch"   # chart always patches when api bumps

Mirrors vs. depends_on — both create cascades, but they're different concepts:

  • mirrors writes a component's version into another component's file (e.g. api version → Chart.yaml:appVersion). The receiving component cascades a patch because the file inside its paths changed. Use it when the file content needs to track a sibling.
  • depends_on is the explicit "X depends on Y" relationship. No file is written; the cascade is purely logical. Use it when you want the relationship without the version mirror.

A chart with appVersion typically declares both — the mirror for the field, and depends_on = ["api"] is then redundant (the cascade fires either way).

Backwards compatibility — the old name triggers = [...] still parses. It's silently merged into depends_on. New configs should use depends_on.

Per-component bump policy

When a single commit touches multiple components, each component also gets that commit's bump kind by default. So a:

feat: change API contract and update Helm values

with files in both src/ and charts/myapp/values.yaml bumps api and chart to minor — even though the chart only got a config tweak.

Components that want stricter semantics can opt into bump_policy = "scoped":

[components.chart]
paths = ["charts/myapp/**"]
bump_files = [{ file = "charts/myapp/Chart.yaml", key = "version" }]
bump_policy = "scoped"
commit api chart
feat: cross-cutting change (no scope) minor minor — no scope means "applies broadly"
feat(api): rewrite contract minor (scope matches) patch — demoted, scope ≠ chart
feat(chart): add value minor (scope matches)
fix: typo patch patch (already patch, no demotion)

The demotion is surfaced explicitly in multicz explain:

2. af74ec5 feat(api): rewrite contract
    Type:  feat(api) → patch
    Demoted from minor (bump_policy='scoped', different scope)

…and in the JSON output:

{"kind": "commit", "type": "feat", "scope": "api",
 "bump_kind": "patch", "original_kind": "minor", ...}

Two values are supported:

  • as-commit (default): the commit's natural kind applies to every touched component. Matches semantic-release / lerna / nx semantics.
  • scoped: when a commit's scope names a different component, demote minor/major to patch. No-scope commits still propagate as-is.

Helm chart immutability

Helm charts are content-addressed by name-version.tgz. If chart-0.5.0 references appVersion: 1.2.0 in some pulls and appVersion: 1.3.0 in others, you've effectively shipped two different artifacts under the same name. multicz refuses that: any time the mirrored appVersion changes, the chart version moves with it.

License

MIT

About

Multi-component versioning for monorepos: bump apps, charts, and images independently from conventional commits.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages