multicz 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
multicz-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,1644 @@
1
+ Metadata-Version: 2.3
2
+ Name: multicz
3
+ Version: 0.1.0
4
+ Summary: Multi-component versioning for monorepos: bump apps, charts, and images independently from conventional commits.
5
+ Keywords: semver,monorepo,helm,conventional-commits,release,versioning
6
+ Author: Chris
7
+ Author-email: Chris <goabonga@pm.me>
8
+ License: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Build Tools
16
+ Classifier: Topic :: Software Development :: Version Control
17
+ Requires-Dist: typer>=0.12
18
+ Requires-Dist: pydantic>=2.7
19
+ Requires-Dist: tomlkit>=0.13
20
+ Requires-Dist: ruamel-yaml>=0.18
21
+ Requires-Dist: pathspec>=0.12
22
+ Requires-Dist: packaging>=24.0
23
+ Requires-Python: >=3.12
24
+ Project-URL: Homepage, https://github.com/goabonga/multicz
25
+ Project-URL: Repository, https://github.com/goabonga/multicz
26
+ Project-URL: Issues, https://github.com/goabonga/multicz/issues
27
+ Description-Content-Type: text/markdown
28
+
29
+ # multicz
30
+
31
+ Multi-component versioning for monorepos. Bump a Python app, its Docker image,
32
+ and the Helm chart that deploys it from a single conventional-commit history —
33
+ each with its own version line and its own git tag.
34
+
35
+ ## The problem
36
+
37
+ You have one repo with a few moving parts:
38
+
39
+ ```
40
+ repo/
41
+ ├── src/ # FastAPI app
42
+ ├── pyproject.toml # → version 1.2.0
43
+ ├── Dockerfile # built and tagged from the app version
44
+ └── charts/myapp/
45
+ ├── Chart.yaml # version: 0.4.0 / appVersion: 1.2.0
46
+ └── templates/ # kubernetes manifests
47
+ ```
48
+
49
+ A change to `src/` is a new app release; a change only under
50
+ `charts/myapp/templates/` is a new chart release for the *same* app.
51
+ Standard tools bump everything together or force you to script per-folder
52
+ logic. `multicz` makes the rule explicit in `multicz.toml`.
53
+
54
+ ## Security
55
+
56
+ Multicz is a release tool: it modifies version files, writes commits,
57
+ creates tags, and (with `--push`) sends them to remote. The threat
58
+ model is straightforward — the security guarantees should match.
59
+
60
+ ### Properties guaranteed by the implementation
61
+
62
+ * **No network access by default.** Multicz only invokes `git`. There
63
+ are no HTTP calls, no fetching of registries, no auto-updates. The
64
+ network only enters the picture when *you* pass `--push`.
65
+ * **Deterministic planning.** Same git history + same `multicz.toml`
66
+ yields the same plan. There's no implicit time-of-day, no remote
67
+ state lookup, no learned heuristic. Repeat runs are
68
+ byte-identical (modulo the timestamp written into `CHANGELOG.md` /
69
+ `debian/changelog` / `state.json`, which is wall-clock UTC).
70
+ * **Explicit changed files from git.** Multicz uses
71
+ `git diff-tree --name-only` per commit — the exact set of paths
72
+ actually touched, not heuristics. A `path_overlap` finding from
73
+ `validate` reads from `git ls-files`; nothing is sniffed from a
74
+ watcher or filesystem scan.
75
+ * **No code execution from config.** The TOML schema is
76
+ pydantic-validated with `extra="forbid"`. There are no callbacks,
77
+ no Python imports from data, no shell-out templates.
78
+
79
+ ### Hardening options
80
+
81
+ | concern | option |
82
+ |---|---|
83
+ | Tampered release commits | `[project].sign_commits = true` or `multicz bump --sign` (passes `-S` to `git commit`) |
84
+ | Tampered tags | `[project].sign_tags = true` or `multicz bump --sign` (passes `-s` to `git tag`) |
85
+ | Manual edits bypassing the bump flow | `[project].state_file = ".multicz/state.json"` + `multicz validate` (drift detection) |
86
+ | Non-conventional commits sneaking into a release | `[project].unknown_commit_policy = "error"` |
87
+ | Overlapping component paths leaking changes silently | `[project].overlap_policy = "error"` (default) |
88
+ | Path / mirror / trigger cycles | `multicz validate` — runs as a CI gate before `bump` |
89
+
90
+ ### CI hardening checklist
91
+
92
+ 1. **Pin `multicz`** by exact version in your CI install step
93
+ (`pip install multicz==1.2.0` or
94
+ `uv tool install --frozen multicz`).
95
+ 2. **Run `multicz validate --strict` first**. It catches misconfigured
96
+ `bump_files`, mirror cycles, and path overlaps before anything is
97
+ written.
98
+ 3. **Use `multicz plan --dry-run`** (or `multicz plan --output json`)
99
+ to inspect the bump in PR previews, not at release time.
100
+ 4. **Sign commits and tags** in CI. GitHub Actions accepts a GPG key
101
+ via `crazy-max/ghaction-import-gpg`; GitLab via `git config user.signingkey`
102
+ then enabling `sign_commits` / `sign_tags` in `multicz.toml`.
103
+ 5. **Limit who can `--push`**. Multicz never pushes unless asked.
104
+ Keep the release job behind a manual approval / protected branch.
105
+ 6. **Audit the state file** if you've enabled it. `git log -p .multicz/state.json`
106
+ gives a tamper-evident trail of every release.
107
+
108
+ The example pipelines in [`examples/ci/`](examples/ci/) follow these
109
+ recommendations.
110
+
111
+ ## Where the config lives
112
+
113
+ By default, `multicz` looks for a dedicated `multicz.toml` at the repo
114
+ root. As a fallback (walked up the directory tree from the cwd), it
115
+ also accepts:
116
+
117
+ - `pyproject.toml` under `[tool.multicz]` — natural for Python projects
118
+ - `package.json` under a `"multicz"` key — natural for Node.js projects
119
+
120
+ Search order at each directory level:
121
+
122
+ 1. `multicz.toml` (always wins when present)
123
+ 2. `pyproject.toml` *with* a `[tool.multicz]` table
124
+ 3. `package.json` *with* a `"multicz"` key
125
+
126
+ A `pyproject.toml` without `[tool.multicz]` is silently skipped — it's
127
+ not treated as the multicz config — so projects that already have a
128
+ pyproject for tooling reasons aren't hijacked.
129
+
130
+ Examples:
131
+
132
+ ```toml
133
+ # pyproject.toml
134
+ [project]
135
+ name = "myapp"
136
+ version = "1.0.0"
137
+
138
+ [tool.multicz.components.api]
139
+ paths = ["src/**", "pyproject.toml"]
140
+ bump_files = [{ file = "pyproject.toml", key = "project.version" }]
141
+
142
+ [tool.multicz.components.web]
143
+ paths = ["frontend/**"]
144
+ bump_files = [{ file = "frontend/package.json", key = "version" }]
145
+ ```
146
+
147
+ ```json
148
+ {
149
+ "name": "monorepo",
150
+ "version": "1.0.0",
151
+ "multicz": {
152
+ "components": [
153
+ { "name": "web", "paths": ["frontend/**"] },
154
+ { "name": "mobile", "paths": ["mobile/**"] }
155
+ ]
156
+ }
157
+ }
158
+ ```
159
+
160
+ `multicz init` still writes a dedicated `multicz.toml`. To inline the
161
+ config into `pyproject.toml` or `package.json`, copy the body of the
162
+ generated `multicz.toml` under the appropriate parent key.
163
+
164
+ ## Install
165
+
166
+ ```sh
167
+ uv add --dev multicz # or: pip install multicz
168
+ ```
169
+
170
+ ## Why not `semantic-release`, Commitizen, Changesets, or `bump-my-version`?
171
+
172
+ Multicz isn't trying to replace any of these — they're better than
173
+ multicz at what they're designed for. The reason it exists is that
174
+ none of them cleanly modelled the same shape of repository.
175
+
176
+ **[`semantic-release`](https://github.com/semantic-release/semantic-release)**
177
+ is excellent for a single-package repo (one `package.json`, one
178
+ release stream, one tag scheme). Multi-package support exists via
179
+ plugins (`semantic-release-monorepo`, `semantic-release-plus`) but
180
+ feels grafted on, and the workflow centres on auto-publishing to a
181
+ registry. Multicz takes the opposite stance: components are
182
+ first-class, and publishing is left to CI.
183
+
184
+ **[Commitizen](https://commitizen-tools.github.io/commitizen/)** has
185
+ two faces — `cz commit` (interactive wizard for writing conventional
186
+ commits) and `cz bump` (semver bumper). Multicz cares about the
187
+ second; we recommend `cz commit` *or* `multicz check` as a
188
+ `commit-msg` hook for the first. `cz bump` itself is single-version:
189
+ one `pyproject.toml`, one `[tool.commitizen]` block, one tag.
190
+
191
+ **[Changesets](https://github.com/changesets/changesets)** is the
192
+ state of the art for JS monorepos: each PR adds a "changeset" file
193
+ declaring the intended bump, and the release tool aggregates them.
194
+ That model excels when the team writes the changeset by hand — the
195
+ intent is encoded explicitly, not inferred from commits. It's less
196
+ natural when you also have a Helm chart that should mirror the API
197
+ version automatically, a `.deb` source package, or a Cargo workspace
198
+ member.
199
+
200
+ **[`bump-my-version`](https://github.com/callowayproject/bump-my-version)**
201
+ (successor to `bump2version`) is great for the "many files, one
202
+ version" problem: pattern-based replacements across version strings
203
+ that need to stay in sync. It doesn't read commits — you tell it the
204
+ bump kind explicitly. Multicz keeps the multi-file substitution and
205
+ adds commit detection plus per-component independence.
206
+
207
+ **Other related tools** — `release-please`, `poetry-bumpversion`,
208
+ `knope`, `cargo-release`, `hatch version` — each solve a slice of the
209
+ problem. None that I tried can express *"a commit touching `src/`
210
+ bumps `api` minor; the chart cascades a patch because its
211
+ `appVersion` mirrors api"* in a single config without scripting
212
+ around the tool.
213
+
214
+ ### What multicz does differently
215
+
216
+ * **Components, not packages.** Everything is keyed by component name
217
+ (`api`, `chart`, `frontend`). A component can be backed by any
218
+ manifest — `pyproject.toml`, `Chart.yaml`, `package.json`,
219
+ `Cargo.toml`, `go.mod`, `gradle.properties`, `debian/changelog` —
220
+ or none at all (tag-driven Go modules).
221
+ * **File ownership via globs.** `paths = ["src/**", "Dockerfile"]`
222
+ declares what a component owns, gitignore-style. Multiple
223
+ components can share or exclude paths via `overlap_policy`.
224
+ * **Mirrors with cascade semantics.** A `mirror` writes a component's
225
+ version into another component's file (the canonical case: api
226
+ version → Helm chart's `appVersion`). The receiving component
227
+ cascades a patch bump so the chart pins exactly one app version per
228
+ release.
229
+ * **No publishing.** Multicz never pushes images, packages a chart,
230
+ or uploads to a registry. It tells CI *what* changed, *what version*
231
+ to use, and *what artefacts to publish*; CI does the work.
232
+ * **Multi-format substitution.** TOML, YAML, JSON, `.properties` and
233
+ plain files are all supported with formatting preserved (comments,
234
+ key order, quote style).
235
+ * **Stateless by default.** Every command re-derives from git tags
236
+ and the in-tree manifests. The optional `state_file` is for teams
237
+ that want an audit trail and drift detection.
238
+
239
+ ### When you should reach for something else
240
+
241
+ * You have a single Python package and want a one-command bumper →
242
+ `bump-my-version`, `cz bump`, or `hatch version`.
243
+ * You have a JS monorepo and your team is happy writing changesets
244
+ by hand → `changesets` is more battle-tested.
245
+ * You have one repo per package and want auto-publish on every
246
+ release → `semantic-release` + its release plugin.
247
+ * You don't want any commit grammar at all → `bump-my-version` (you
248
+ drive the kind manually).
249
+
250
+ If your repo has multiple deliverables, mirrors between them, and
251
+ you want commits to drive the bumps without writing release notes by
252
+ hand — that's the case multicz exists for.
253
+
254
+ ## Quickstart
255
+
256
+ ```sh
257
+ multicz init # writes a starter multicz.toml
258
+ $EDITOR multicz.toml # declare your components
259
+ multicz status # show which components would bump and why
260
+ multicz bump --dry-run # plan the bump without touching files
261
+ multicz bump # apply the plan
262
+ ```
263
+
264
+ ## How it works
265
+
266
+ Components can be declared in either of two equivalent TOML syntaxes:
267
+
268
+ ```toml
269
+ # Dict-of-tables (concise; default emitted by `multicz init`)
270
+ [components.api]
271
+ paths = ["src/**", "pyproject.toml"]
272
+
273
+ [components.web]
274
+ paths = ["frontend/**"]
275
+ ```
276
+
277
+ ```toml
278
+ # Array-of-tables (preferred when you have many components or want
279
+ # to keep declaration order obvious in the file layout)
280
+ [[components]]
281
+ name = "api"
282
+ paths = ["src/**", "pyproject.toml"]
283
+
284
+ [[components]]
285
+ name = "web"
286
+ paths = ["frontend/**"]
287
+ ```
288
+
289
+ Each component declares:
290
+
291
+ * `paths` — gitignore-style globs of files it owns;
292
+ * `bump_files` — where the canonical version is written;
293
+ * `mirrors` — files that should reflect this component's version (e.g. a
294
+ Helm chart's `appVersion` mirroring the app version);
295
+ * `triggers` — other components whose bumps should trigger this one;
296
+ * `changelog` — path to a `CHANGELOG.md` the planner should keep in sync.
297
+
298
+ The planner runs three passes:
299
+
300
+ 1. **direct** — for every component, look at conventional commits since its
301
+ last tag whose changed files map to it; pick the strongest implied bump
302
+ (`feat` → minor, `fix`/`perf` → patch, `!`/`BREAKING CHANGE` → major).
303
+ 2. **triggers** — propagate bumps along declared upstream edges.
304
+ 3. **mirror cascade** — when a component A writes its version into a file
305
+ owned by component B, B receives a patch bump. This keeps Helm chart
306
+ immutability: `chart-0.5.0` always pins the same `appVersion`.
307
+
308
+ ## Example: FastAPI + Helm chart
309
+
310
+ ```toml
311
+ [components.api]
312
+ paths = ["src/**", "pyproject.toml", "tests/**", "Dockerfile"]
313
+ bump_files = [{ file = "pyproject.toml", key = "project.version" }]
314
+ mirrors = [{ file = "charts/myapp/Chart.yaml", key = "appVersion" }]
315
+ changelog = "CHANGELOG.md"
316
+
317
+ [components.chart]
318
+ paths = ["charts/myapp/**"]
319
+ bump_files = [{ file = "charts/myapp/Chart.yaml", key = "version" }]
320
+ changelog = "charts/myapp/CHANGELOG.md"
321
+ ```
322
+
323
+ Behavior:
324
+
325
+ | change | api | image tag | chart.version | appVersion |
326
+ |---|---|---|---|---|
327
+ | `src/main.py` (feat) | minor | follows api | patch (cascade) | mirror |
328
+ | `Dockerfile` (CVE base) | patch | follows api | patch (cascade) | mirror |
329
+ | `charts/myapp/templates/dep.yaml` | — | — | patch | — |
330
+ | `charts/myapp/values.yaml` (config) | — | — | patch | — |
331
+
332
+ The Docker image tag is `api.version` itself — read it from CI:
333
+
334
+ ```sh
335
+ TAG=$(multicz get api)
336
+ docker build -t registry/myapp:$TAG .
337
+ docker push registry/myapp:$TAG
338
+ helm package charts/myapp
339
+ ```
340
+
341
+ ## CLI
342
+
343
+ | command | what it does |
344
+ |---|---|
345
+ | `multicz init` | write a starter `multicz.toml` |
346
+ | `multicz init --print` | render the discovered config to stdout (no file written) |
347
+ | `multicz init --print --bare` | render the generic stub to stdout |
348
+ | `multicz init --detect` | summary of detected components without rendering full TOML |
349
+ | `multicz init --detect --output json` | machine-readable detection shape |
350
+ | `multicz status` | brief table of pending bumps with reason summaries |
351
+ | `multicz status --since origin/main` | preview the bump plan for a PR (vs main) |
352
+ | `multicz changed` | components with files changed since their last tag (CI matrix) |
353
+ | `multicz changed --since origin/main` | what changed in this branch vs main |
354
+ | `multicz plan` | per-component plan with explicit reasons (commit / trigger / mirror) |
355
+ | `multicz plan --since <ref>` | recompute the plan against a custom baseline |
356
+ | `multicz explain <comp> --since <ref>` | scope explain to a specific window |
357
+ | `multicz plan --output json` | machine-readable shape for CI |
358
+ | `multicz explain <component>` | full breakdown — every commit, the matched files, every cascade |
359
+ | `multicz bump` | apply bumps to all configured files |
360
+ | `multicz bump --dry-run` | plan without writing |
361
+ | `multicz bump --commit --tag` | release in one shot: write, commit, tag |
362
+ | `multicz bump --commit --tag --push` | …and push commit + tags with `--follow-tags` |
363
+ | `multicz bump --commit -m "..."` | verbatim release-commit message (overrides the template) |
364
+ | `multicz bump --sign` | GPG-sign the release commit and tags (also via `[project].sign_commits/sign_tags`) |
365
+ | `multicz plan --summary $GITHUB_STEP_SUMMARY` | append a markdown plan to GitHub's step-summary file |
366
+ | `multicz bump --summary $GITHUB_STEP_SUMMARY` | append a markdown release block (commit, tags, push) |
367
+ | `multicz bump --force api:patch` | manual bump for rebuilds without commits |
368
+ | `multicz bump --force api:minor --force chart:major` | repeatable across components |
369
+ | `multicz bump --output json` | emit `{"bumps": {...}, "git": {...}}` for CI |
370
+ | `multicz get <component>` | read the current version from the primary bump file |
371
+ | `multicz changelog [-c name]` | per-component conventional-commit log since the last tag |
372
+ | `multicz changelog --output md` | the same, grouped into Breaking / Features / Fixes / Perf / Other |
373
+ | `multicz release-notes <comp>` | one-shot release notes for the upcoming bump (no file written) |
374
+ | `multicz release-notes --tag <tag>` | retrospective notes for a past release tag |
375
+ | `multicz release-notes --all --output md` | one block per bumping component, ready for `gh release create` |
376
+ | `multicz bump --no-changelog` | bump versions without touching declared `CHANGELOG.md` files |
377
+ | `multicz bump --pre rc` | enter / continue a release-candidate cycle (`1.2.3` → `1.3.0-rc.1` → `1.3.0-rc.2`) |
378
+ | `multicz bump --finalize` | drop a pre-release suffix (`1.3.0-rc.2` → `1.3.0`) — works with no new commits |
379
+ | `multicz check <file>` | validate a commit message — wire as a `commit-msg` hook |
380
+ | `multicz artifacts <comp>` | list what CI should build/push for the current version |
381
+ | `multicz artifacts --all --output json` | machine-readable artifact refs for the whole repo |
382
+ | `multicz validate` | run every config + repo sanity check (CI gate) |
383
+ | `multicz state` | inspect the optional persistent state file (audit trail) |
384
+ | `multicz validate --strict` | also fail on warnings (overlapping paths, useless mirrors, …) |
385
+ | `multicz validate --output json` | machine-readable findings shape |
386
+
387
+ ### Version scheme (semver vs PEP 440)
388
+
389
+ Pre-release versions render differently across ecosystems:
390
+
391
+ | ecosystem | form | example |
392
+ |---|---|---|
393
+ | npm, Cargo, Helm, generic | semver 2.0 | `1.3.0-rc.1` |
394
+ | Python (canonical PEP 440) | dotless | `1.3.0rc1` |
395
+ | Debian source packages | tilde | `1.3.0~rc1` |
396
+
397
+ The default `version_scheme = "semver"` works for npm, Cargo, Helm,
398
+ and is **also accepted** by PEP 440 (just normalized internally). For
399
+ projects that want strict canonical Python output, opt into pep440
400
+ per-component:
401
+
402
+ ```toml
403
+ [components.api]
404
+ paths = ["src/**", "pyproject.toml"]
405
+ bump_files = [{ file = "pyproject.toml", key = "project.version" }]
406
+ version_scheme = "pep440"
407
+
408
+ [components.chart]
409
+ paths = ["charts/myapp/**"]
410
+ bump_files = [{ file = "charts/myapp/Chart.yaml", key = "version" }]
411
+ # default semver — Helm requires it
412
+ ```
413
+
414
+ A run of `multicz bump --pre rc --commit --tag` writes:
415
+
416
+ ```
417
+ pyproject.toml version = "1.3.0rc1"
418
+ charts/.../Chart.yaml
419
+ version: 0.4.1-rc.1 ← chart's own scheme (semver)
420
+ appVersion: 1.3.0rc1 ← mirror copies api's rendered form
421
+ git tags
422
+ api-v1.3.0rc1
423
+ chart-v0.4.1-rc.1
424
+ ```
425
+
426
+ PEP 440 compact label aliases are applied on output: `--pre alpha`
427
+ with `scheme = "pep440"` produces `1.3.0a1` (canonical), not
428
+ `1.3.0alpha1`. Both forms are still parseable, so ordering and
429
+ re-reads stay correct across schemes.
430
+
431
+ `format = "debian"` is incompatible with `version_scheme = "pep440"` —
432
+ the Debian flow uses semver internally and applies its own
433
+ `~rc1` notation at write time. Configs that combine the two are
434
+ rejected at load.
435
+
436
+ ### Empty release / manual bump
437
+
438
+ When there are no commits the planner can act on, `multicz bump` is a
439
+ no-op:
440
+
441
+ ```
442
+ $ multicz bump
443
+ no bumps pending — use --force <name>:<kind> for a manual bump
444
+ ```
445
+
446
+ Exit code is 0 — "nothing to do" is success, not failure.
447
+
448
+ For the cases where you genuinely need a release without code changes
449
+ (weekly base-image rebuild for security patches, dependency-only
450
+ update, deliberate retag), `--force NAME:KIND` is the manual escape
451
+ hatch:
452
+
453
+ ```sh
454
+ # Single forced bump
455
+ multicz bump --force api:patch
456
+
457
+ # Multiple components in one go
458
+ multicz bump --force api:minor --force chart:major
459
+
460
+ # Compose with --pre / --finalize / --commit / --tag
461
+ multicz bump --force api:minor --pre rc --commit --tag
462
+ ```
463
+
464
+ `--force` shows up in the plan and explain output as a `ManualReason`
465
+ so the audit trail is preserved:
466
+
467
+ ```json
468
+ {
469
+ "kind": "manual",
470
+ "note": "--force api:patch"
471
+ }
472
+ ```
473
+
474
+ Promotion semantics: if the component would already bump from commits,
475
+ `--force` is **upgraded** (never downgraded). A `feat:` (minor) plus
476
+ `--force api:patch` stays at minor; `feat:` plus `--force api:major`
477
+ jumps to major. The strongest level always wins.
478
+
479
+ Validation is upfront and explicit:
480
+
481
+ ```
482
+ $ multicz bump --force api:weird
483
+ invalid kind 'weird': must be major, minor, or patch
484
+ exit=1
485
+
486
+ $ multicz bump --force unknown:patch
487
+ unknown component: unknown
488
+ exit=1
489
+
490
+ $ multicz bump --force no-colon
491
+ invalid --force spec 'no-colon': expected NAME:KIND (e.g. api:patch)
492
+ exit=1
493
+ ```
494
+
495
+ `--force` does **not** add anything to the changelog (no commit to
496
+ list), so the rendered `CHANGELOG.md` will say
497
+ `_No notable changes._` for the forced section. If you want a custom
498
+ note, also pass `--commit-message`:
499
+
500
+ ```sh
501
+ multicz bump --force api:patch --commit \
502
+ -m "chore(release): rebuild api for CVE-2024-1234"
503
+ ```
504
+
505
+ ### Release commit message
506
+
507
+ `multicz bump --commit` writes a single release commit. Its message
508
+ is rendered from `[project].release_commit_message`, which defaults
509
+ to:
510
+
511
+ ```
512
+ chore(release): bump {summary}
513
+
514
+ {body}
515
+ ```
516
+
517
+ Producing the historical shape:
518
+
519
+ ```
520
+ chore(release): bump api 1.2.0 -> 1.3.0, chart 0.4.0 -> 0.5.0
521
+
522
+ - api: 1.2.0 -> 1.3.0 (minor)
523
+ - chart: 0.4.0 -> 0.5.0 (patch)
524
+ ```
525
+
526
+ Available placeholders:
527
+
528
+ | placeholder | example |
529
+ |---|---|
530
+ | `{summary}` | `api 1.2.0 -> 1.3.0, chart 0.4.0 -> 0.5.0` |
531
+ | `{components}` | `api v1.3.0, chart v0.5.0` |
532
+ | `{body}` | bullet list with kind annotations |
533
+ | `{count}` | `2` |
534
+
535
+ Examples:
536
+
537
+ ```toml
538
+ [project]
539
+ # Compact one-liner
540
+ release_commit_message = "chore(release): {components}"
541
+ # -> chore(release): api v1.3.0, chart v0.5.0
542
+
543
+ # Spell out the count
544
+ release_commit_message = "release: {count} components ({summary})"
545
+ # -> release: 2 components (api 1.2.0 -> 1.3.0, chart 0.4.0 -> 0.5.0)
546
+ ```
547
+
548
+ Literal `{` and `}` must be escaped as `{{` / `}}`.
549
+
550
+ For one-off releases, override the entire message with `-m`:
551
+
552
+ ```sh
553
+ multicz bump --commit --tag -m "release: hotfix for the production outage"
554
+ ```
555
+
556
+ `-m` is verbatim like `git commit -m` — no placeholders are expanded.
557
+
558
+ > **If you change the prefix**, also update
559
+ > `release_commit_pattern` so the auto-filter still matches:
560
+ > ```toml
561
+ > release_commit_pattern = "^release"
562
+ > release_commit_message = "release: {components}"
563
+ > ```
564
+
565
+ ### Release candidates
566
+
567
+ A typical RC workflow:
568
+
569
+ ```sh
570
+ # starting from api-v1.2.3, with new feat commits on the branch
571
+ multicz bump --pre rc --commit --tag # → api-v1.3.0-rc.1
572
+ # more fixes
573
+ multicz bump --pre rc --commit --tag # → api-v1.3.0-rc.2
574
+ # QA approves — ship the final
575
+ multicz bump --finalize --commit --tag # → api-v1.3.0
576
+ ```
577
+
578
+ `--pre <label>` accepts any label (`rc`, `alpha`, `beta`, `dev`, …) and
579
+ the counter resets when you switch labels. `--finalize` is allowed even
580
+ when no commits landed since the last RC tag — finalising IS a release
581
+ event in its own right. Without either flag, a `multicz bump` from a
582
+ pre-release version auto-finalises.
583
+
584
+ For Debian-format components the changelog stanza renders with `~`
585
+ notation so `apt`'s ordering puts pre-releases *before* the final:
586
+ `mypkg (1.3.0~rc1-1)` < `mypkg (1.3.0-1)`. The git tag itself stays in
587
+ semver form (`mypkg-v1.3.0-rc.1`).
588
+
589
+ #### Finalize strategy
590
+
591
+ `[project].finalize_strategy` controls what the changelog looks like
592
+ after `--finalize`:
593
+
594
+ | value | behaviour |
595
+ |---|---|
596
+ | `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. |
597
+ | `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. |
598
+ | `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. |
599
+
600
+ ### `validate`
601
+
602
+ `multicz validate` is the recommended first step in any CI pipeline —
603
+ it surfaces config and repo problems before they cause a botched
604
+ release. Each finding has three levels:
605
+
606
+ | level | examples |
607
+ |---|---|
608
+ | `error` | a `bump_file` doesn't exist, a trigger cycle, an unparseable `debian/changelog` — the planner can't run safely |
609
+ | `warning` | two components claim the same file (`first-match-wins` makes the loser silent), a mirror that loops back to its own component |
610
+ | `info` | a mirror to a file no component owns (no cascade fires), a `debian/changelog` that hasn't been created yet |
611
+
612
+ Exit codes: `0` = clean (warnings/info don't fail), `1` = at least one
613
+ error, `2` = `--strict` and at least one warning.
614
+
615
+ ```sh
616
+ $ multicz validate
617
+ ✗ lib: bump_file 'missing.toml' does not exist (bump_files_exist)
618
+ ! lib: shares files with 'api' (e.g. 'src/main.py') (path_overlap)
619
+ i api: mirror target 'other.yaml' is not owned by any component (mirror_target_unowned)
620
+ ✗ mirror cascade cycle: cycle_a -> cycle_b -> cycle_a (mirror_cycle)
621
+
622
+ 2 errors, 1 warning, 1 info
623
+ ```
624
+
625
+ The check identifier in parentheses (`bump_files_exist`,
626
+ `mirror_cycle`, …) is stable so CI logs and PR comments can grep on
627
+ it. `--output json` emits the same data as a structured payload with
628
+ a counts summary.
629
+
630
+ ### Choosing the commit window (`--since`)
631
+
632
+ By default, every component compares against **its own** latest tag:
633
+ the planner picks `api-v1.2.0` for `api` and `chart-v0.5.0` for `chart`,
634
+ each scoped to that component's tag prefix. That's the right behaviour
635
+ when you're cutting a release from `main`.
636
+
637
+ For other workflows, override the reference globally with `--since`:
638
+
639
+ | use case | command |
640
+ |---|---|
641
+ | PR preview ("what would bump if I merge this branch?") | `multicz plan --since origin/main` |
642
+ | What changed in this branch (for CI matrix) | `multicz changed --since origin/main` |
643
+ | Inspect commits from a specific point | `multicz status --since HEAD~10` |
644
+ | Migrate from a legacy global tag scheme | `multicz plan --since v1.0.0` |
645
+ | Recover from removed/recreated tags | `multicz plan --since <known sha>` |
646
+
647
+ `--since` accepts anything `git rev-parse` accepts: tags, branches,
648
+ SHAs, `HEAD~N`, etc.
649
+
650
+ The override only moves the **commit window** used to compute bump
651
+ kinds. The "current version" resolution (latest tag → primary
652
+ `bump_file` → `initial_version`) is unaffected — so even with
653
+ `--since origin/main`, the planner still bumps from the latest
654
+ released version, not from main. That's deliberate: PRs preview the
655
+ "if merged" version without re-deriving history.
656
+
657
+ `bump` intentionally does **not** take `--since`. Combining a custom
658
+ window with a write+tag is a footgun (you can create tags that
659
+ contradict the actual history). Workflow:
660
+
661
+ ```sh
662
+ multicz plan --since origin/main # preview
663
+ # … inspect, decide …
664
+ multicz bump --commit --tag --push # run the regular bump
665
+ ```
666
+
667
+ ### `changed` (CI matrix gating)
668
+
669
+ `multicz changed` is the lightest possible question — *did anything
670
+ change* — designed for CI to only run jobs for the components a PR
671
+ actually touched. Distinct from `plan`: `plan` says "would bump",
672
+ `changed` says "any activity, regardless of whether it's release-
673
+ worthy".
674
+
675
+ ```sh
676
+ multicz changed # per-component (since each one's last tag)
677
+ multicz changed --since origin/main # every component vs main (PR gating)
678
+ multicz changed --output json
679
+ ```
680
+
681
+ Default text output is one component name per line — pipeable into
682
+ shell loops:
683
+
684
+ ```sh
685
+ for comp in $(multicz changed --since origin/main); do
686
+ echo "rebuilding $comp"
687
+ done
688
+ ```
689
+
690
+ JSON output exposes both lists, ideal for `fromJson` in GitHub Actions
691
+ matrices:
692
+
693
+ ```yaml
694
+ jobs:
695
+ detect:
696
+ runs-on: ubuntu-latest
697
+ outputs:
698
+ changed: ${{ steps.c.outputs.list }}
699
+ steps:
700
+ - uses: actions/checkout@v4
701
+ with: { fetch-depth: 0 }
702
+ - id: c
703
+ run: |
704
+ echo "list=$(multicz changed --since origin/main \
705
+ --output json | jq -c .changed)" >> $GITHUB_OUTPUT
706
+
707
+ test:
708
+ needs: detect
709
+ if: needs.detect.outputs.changed != '[]'
710
+ strategy:
711
+ matrix:
712
+ component: ${{ fromJson(needs.detect.outputs.changed) }}
713
+ runs-on: ubuntu-latest
714
+ steps:
715
+ - run: cd ${{ matrix.component }} && make test
716
+ ```
717
+
718
+ Release commits matching `project.release_commit_pattern` are
719
+ filtered out so a previous `multicz bump --commit` doesn't keep
720
+ flagging every component forever.
721
+
722
+ ### `plan` and `explain`
723
+
724
+ `multicz plan` is the canonical way to inspect what a release would do
725
+ before running it. The text form is grouped per component:
726
+
727
+ ```
728
+ api: 1.2.0 → 1.3.0 (minor)
729
+ • abc1234 feat(api): add login flow
730
+
731
+ chart: 0.4.0 → 0.4.1 (patch)
732
+ • mirror cascade from api (charts/myapp/Chart.yaml:appVersion)
733
+ ```
734
+
735
+ `multicz plan --output json` emits a structured payload — exactly what a
736
+ CI step needs to gate releases or post a comment on a PR. `schema_version`
737
+ lets consumers guard against future breaking changes:
738
+
739
+ ```json
740
+ {
741
+ "schema_version": 1,
742
+ "bumps": {
743
+ "api": {
744
+ "current_version": "1.2.0",
745
+ "next_version": "1.3.0",
746
+ "kind": "minor",
747
+ "reasons": [
748
+ {
749
+ "kind": "commit",
750
+ "sha": "abc1234...",
751
+ "type": "feat",
752
+ "scope": "api",
753
+ "breaking": false,
754
+ "subject": "add login flow",
755
+ "files": ["src/auth.py", "src/main.py"],
756
+ "bump_kind": "minor"
757
+ }
758
+ ],
759
+ "artifacts": [
760
+ {"type": "docker", "ref": "ghcr.io/foo/api:1.3.0"}
761
+ ]
762
+ },
763
+ "chart": {
764
+ "current_version": "0.4.0",
765
+ "next_version": "0.4.1",
766
+ "kind": "patch",
767
+ "reasons": [
768
+ {
769
+ "kind": "mirror",
770
+ "upstream": "api",
771
+ "file": "charts/myapp/Chart.yaml",
772
+ "key": "appVersion"
773
+ }
774
+ ],
775
+ "artifacts": []
776
+ }
777
+ }
778
+ }
779
+ ```
780
+
781
+ Canonical `jq` queries CI scripts can rely on:
782
+
783
+ ```sh
784
+ # anything pending?
785
+ multicz plan --output json | jq -e '.bumps | length > 0'
786
+
787
+ # a single component's next version
788
+ multicz plan --output json | jq -r '.bumps.api.next_version'
789
+
790
+ # every Docker ref to push (after bump --output json)
791
+ multicz bump --commit --tag --output json | \
792
+ jq -r '.bumps[].artifacts[] | select(.type == "docker") | .ref'
793
+
794
+ # tags freshly created (from bump output, with --tag)
795
+ multicz bump --commit --tag --output json | jq -r '.git.tags[]'
796
+ ```
797
+
798
+ End-to-end pipelines for the three big platforms are in
799
+ [`examples/ci/`](examples/ci/):
800
+
801
+ | platform | workflow file |
802
+ |---|---|
803
+ | GitHub Actions | [`examples/ci/github-actions/release.yml`](examples/ci/github-actions/release.yml) |
804
+ | GitLab CI/CD | [`examples/ci/gitlab-ci.yml`](examples/ci/gitlab-ci.yml) |
805
+ | Azure Pipelines | [`examples/ci/azure-pipelines.yml`](examples/ci/azure-pipelines.yml) |
806
+
807
+ Reason kinds: `commit`, `trigger`, `mirror`, `manual` (e.g. an explicit
808
+ `--finalize`). Each carries its own structured fields.
809
+
810
+ `multicz explain <component>` zooms in on a single component with the
811
+ full per-commit breakdown — useful when the plan looks unexpected and
812
+ you want to see *which files* of a commit actually mapped to the
813
+ component:
814
+
815
+ ```
816
+ Component: api
817
+ Current version: 1.2.0
818
+ Next version: 1.3.0 (minor)
819
+
820
+ Reasons:
821
+ 1. abc1234 feat(api): add login flow
822
+ SHA: abc1234...
823
+ Type: feat(api) → minor
824
+ Files matched in this component:
825
+ - src/auth.py
826
+ - src/main.py
827
+ ```
828
+
829
+ ### Release notes (`gh release create`)
830
+
831
+ `multicz release-notes` is the single-shot, no-file-written counterpart
832
+ to the persistent `CHANGELOG.md`. Designed to be piped into
833
+ `gh release create` or pasted into a GitHub/GitLab Release UI.
834
+
835
+ ```sh
836
+ gh release create api-v1.3.0 --notes "$(multicz release-notes --tag api-v1.3.0)"
837
+ ```
838
+
839
+ Three modes:
840
+
841
+ ```sh
842
+ # upcoming bump for one component (preview before `multicz bump --tag`)
843
+ multicz release-notes api
844
+
845
+ # upcoming bumps for every bumping component (one --all output to paste)
846
+ multicz release-notes --all
847
+
848
+ # retrospective: what shipped in a past tagged release
849
+ multicz release-notes --tag api-v1.3.0
850
+ ```
851
+
852
+ Critical detail for past tags: the previous-tag lookup is
853
+ **stable-aware**. A stable release tag (`api-v1.3.0`) reads commits
854
+ since the previous *stable* tag (`api-v1.2.0`) — not since the most
855
+ recent RC — so the notes consolidate everything that shipped in 1.3.0
856
+ over the whole RC cycle. A pre-release tag (`api-v1.3.0-rc.2`) reads
857
+ commits since the immediately previous tag (`api-v1.3.0-rc.1`) so
858
+ each RC only shows the delta.
859
+
860
+ Output formats:
861
+
862
+ - `md` (default) — sections (`### Features`, `### Fixes`, …) and bullets
863
+ - `text` — plain ASCII, useful in `git log`-style scripts
864
+ - `json` — `{"sections": [{"component": "...", "from_version": "...",
865
+ "to_version": "...", "commits": [...]}]}` for further processing
866
+
867
+ The body honours every project-level rendering knob:
868
+ `changelog_sections`, `breaking_section_title`, `other_section_title`,
869
+ `ignored_types`. So whatever shape your `CHANGELOG.md` takes,
870
+ `release-notes` produces identical sections.
871
+
872
+ ### Per-component CHANGELOG.md
873
+
874
+ When a component declares `changelog = "path/to/CHANGELOG.md"`, every
875
+ `multicz bump` automatically prepends a new keep-a-changelog section to
876
+ that file:
877
+
878
+ ```markdown
879
+ ## [1.3.0] - 2026-04-30
880
+
881
+ ### Features
882
+
883
+ - **api**: add login (`abc1234`)
884
+
885
+ ### Fixes
886
+
887
+ - null token (`def5678`)
888
+ ```
889
+
890
+ The file is created with a small preamble on first use, and subsequent
891
+ runs insert the new section directly above the latest existing release.
892
+ Pass `--no-changelog` to opt out for a single bump.
893
+
894
+ #### Configuring sections
895
+
896
+ By default, only `feat`, `fix`, and `perf` are rendered (under "Features",
897
+ "Fixes", "Performance"). Anything else (`chore`, `docs`, `test`, `style`,
898
+ `ci`, `build`, `refactor`, `revert`) is silently dropped to keep the
899
+ changelog focused on user-visible changes.
900
+
901
+ To pick your own vocabulary — for example keep-a-changelog's
902
+ Added/Changed/Fixed — declare sections in `[project]`:
903
+
904
+ ```toml
905
+ [project]
906
+ breaking_section_title = "Breaking changes" # set to "" to disable the bucket
907
+ other_section_title = "" # set to e.g. "Misc" to keep unmatched
908
+
909
+ [[project.changelog_sections]]
910
+ title = "Added"
911
+ types = ["feat"]
912
+
913
+ [[project.changelog_sections]]
914
+ title = "Fixed"
915
+ types = ["fix"]
916
+
917
+ [[project.changelog_sections]]
918
+ title = "Changed"
919
+ types = ["refactor", "perf"]
920
+ ```
921
+
922
+ Sections render in declaration order, after the implicit "Breaking changes"
923
+ bucket (if any commit has `!` or a `BREAKING CHANGE:` footer). One commit
924
+ type can appear in multiple sections; commits whose type matches no section
925
+ are dropped (or land in `other_section_title` if you set it).
926
+
927
+ ### Commit-msg hook
928
+
929
+ ```sh
930
+ # .git/hooks/commit-msg
931
+ #!/bin/sh
932
+ exec multicz check "$1"
933
+ ```
934
+
935
+ ### One-shot CI release
936
+
937
+ ```yaml
938
+ - run: |
939
+ multicz bump --commit --tag --push
940
+ TAG=$(multicz get api)
941
+ docker build -t registry/myapp:$TAG .
942
+ docker push registry/myapp:$TAG
943
+ helm package charts/myapp
944
+ ```
945
+
946
+ ### Supported file formats
947
+
948
+ `bump_files` and `mirrors` can point at:
949
+
950
+ * `.toml` — comments and key order preserved (tomlkit)
951
+ * `.yaml` / `.yml` — comments and quote style preserved (ruamel.yaml)
952
+ * `.json` — indent and key order preserved (e.g. `package.json`)
953
+ * `.properties` — line-based `key=value` substitution (e.g. `gradle.properties`)
954
+ * anything else — treated as a one-line `VERSION` file (`key = ` omitted)
955
+
956
+ ### Debian packages (`format = "debian"`)
957
+
958
+ `multicz` writes a proper `debian/changelog` instead of a markdown
959
+ `CHANGELOG.md` for components built as `.deb`:
960
+
961
+ ```toml
962
+ [components.mypkg]
963
+ paths = ["debian/**", "src/**"]
964
+ format = "debian"
965
+
966
+ [components.mypkg.debian]
967
+ changelog = "debian/changelog" # default
968
+ distribution = "UNRELEASED" # default — change to "unstable" before upload
969
+ urgency = "medium" # default
970
+ debian_revision = 1 # appended as -<n> to the upstream version
971
+ # maintainer = "Name <email>" # falls back to debian/control then git config
972
+ # epoch = 2 # rare, prepended as "<n>:"
973
+ ```
974
+
975
+ On `multicz bump`, the upstream version is read from the topmost stanza
976
+ of `debian/changelog`, the new upstream is computed from the conventional
977
+ commits since the last tag, and a fresh stanza is **prepended** to the
978
+ file:
979
+
980
+ ```
981
+ mypkg (1.3.0-1) UNRELEASED; urgency=medium
982
+
983
+ * feat: Add login flow
984
+ * fix(api): Null token on logout
985
+
986
+ -- Chris <chris@example.com> Fri, 01 May 2026 10:01:44 +0000
987
+
988
+ mypkg (1.2.3-1) unstable; urgency=medium
989
+
990
+ * Initial release.
991
+
992
+ -- Chris <chris@example.com> Sun, 01 Jan 2023 00:00:00 +0000
993
+ ```
994
+
995
+ Old stanzas are never rewritten, matching the contract of `dch(1)`.
996
+
997
+ ### `init` modes
998
+
999
+ `multicz init` has three output modes that compose with the existing
1000
+ `--bare` flag:
1001
+
1002
+ ```sh
1003
+ # default: discover the working tree, write multicz.toml
1004
+ multicz init
1005
+
1006
+ # render the discovered config to stdout, no file written
1007
+ multicz init --print > custom-name.toml
1008
+
1009
+ # render the generic stub to stdout (composes with --bare)
1010
+ multicz init --print --bare
1011
+
1012
+ # inspection only — show what would be detected, no rendering
1013
+ multicz init --detect
1014
+
1015
+ # machine-readable detection (paths, bump_files, mirrors, format, …)
1016
+ multicz init --detect --output json
1017
+ ```
1018
+
1019
+ `--print` and `--detect` are non-destructive: the filesystem is
1020
+ untouched, so they're safe to run inside CI without `--force`.
1021
+
1022
+ `--detect` is the lightest possible answer to *"what would init pick up
1023
+ in this repo?"*:
1024
+
1025
+ ```
1026
+ $ multicz init --detect
1027
+ Detected 2 component(s):
1028
+ • api (pyproject.toml)
1029
+ mirrors → charts/myapp/Chart.yaml:appVersion
1030
+ • myapp (charts/myapp/Chart.yaml)
1031
+ ```
1032
+
1033
+ `--print` returns the byte-for-byte TOML — pipe it into a file with a
1034
+ custom name, or into a diff against an existing config. Combinations
1035
+ rejected at parse time: `--detect + --bare` and `--detect + --print`.
1036
+
1037
+ ### Workspace rules
1038
+
1039
+ The user's natural worry: *"what happens with nested workspaces?"*. Four
1040
+ explicit rules govern how `multicz init` resolves them.
1041
+
1042
+ #### 1. Is the root manifest a component?
1043
+
1044
+ | ecosystem | root has version? | root has workspace block? | root → component? |
1045
+ |---|---|---|---|
1046
+ | Python | `[project].version` set | with `[tool.uv.workspace]` | **yes** |
1047
+ | Python | no `[project]` table | with `[tool.uv.workspace]` | **no** (orchestrator) |
1048
+ | Cargo | `[package]` set | with `[workspace]` | **yes** |
1049
+ | Cargo | no `[package]` | with `[workspace]` | **no** (virtual workspace) |
1050
+ | Node.js | any `version` | `workspaces` declared | **no** (members only) |
1051
+ | Node.js | `version` set | no `workspaces` | **yes** (single-package) |
1052
+
1053
+ A workspace orchestrator with no version is **never** a component — its
1054
+ job is to delegate, not to ship. A root that doubles as a package
1055
+ (common for Python and Cargo) IS a component, alongside its members.
1056
+
1057
+ #### 2. Do workspace members inherit the version?
1058
+
1059
+ Each ecosystem decides:
1060
+
1061
+ | ecosystem | per-member? | shared? |
1062
+ |---|---|---|
1063
+ | uv (`[tool.uv.workspace]`) | members own their `[project].version` | — |
1064
+ | Cargo `[workspace.package].version` | when present, members inherit via `version.workspace = true` | yes |
1065
+ | Cargo without `workspace.package.version` | members own their `[package].version` | — |
1066
+ | npm/yarn/pnpm `workspaces` | each `package.json` has its own `version` | — |
1067
+
1068
+ When Cargo declares `[workspace.package].version`, multicz collapses
1069
+ the workspace into a **single component** bumping that one key.
1070
+ Members that inherit are silently skipped to avoid double-bumping.
1071
+ Mixed members (some inheriting, some declaring their own `[package].version`)
1072
+ are not currently supported — declare uniformly.
1073
+
1074
+ #### 3. Are excluded members really ignored?
1075
+
1076
+ | declaration | excludes |
1077
+ |---|---|
1078
+ | `[tool.uv.workspace].exclude = ["packages/legacy"]` | uv |
1079
+ | `[workspace].exclude = ["crates/legacy"]` | Cargo |
1080
+ | `"workspaces": ["packages/*", "!packages/legacy"]` | npm / yarn |
1081
+ | `pnpm-workspace.yaml`: `packages: ['packages/*', '!packages/legacy']` | pnpm |
1082
+
1083
+ All four are honored — excluded members never appear as components.
1084
+ The cross-ecosystem rule is consistent: if the workspace declaration
1085
+ excludes a path, multicz skips it.
1086
+
1087
+ #### 4. What if two manifests share the same name?
1088
+
1089
+ `_unique` auto-suffixes the second one with the manifest type:
1090
+
1091
+ | collision | result |
1092
+ |---|---|
1093
+ | python `api` + chart `api` | `api`, `api-chart` |
1094
+ | python `api` + python `api` (rare) | `api`, `api-py` |
1095
+ | chart `foo` + chart `foo` (different dirs) | `foo`, `foo-chart-2` |
1096
+
1097
+ Suffix order is deterministic — the **first** manifest discovered keeps
1098
+ the bare name. To force a different naming, edit `multicz.toml`
1099
+ manually after `init` (the discovery only runs at `init` time; the
1100
+ planner reads whatever names you've declared).
1101
+
1102
+ #### Reference layout (covered by integration tests)
1103
+
1104
+ ```
1105
+ repo/
1106
+ ├── pyproject.toml # root: [project] + [tool.uv.workspace]
1107
+ ├── services/
1108
+ │ ├── api/pyproject.toml # uv workspace member
1109
+ │ └── worker/pyproject.toml # uv workspace member
1110
+ ├── packages/
1111
+ │ └── client/package.json # npm package (no workspace block)
1112
+ └── charts/
1113
+ └── api/Chart.yaml # name collides with services/api
1114
+ ```
1115
+
1116
+ `multicz init` produces:
1117
+
1118
+ | component | source | mirrors |
1119
+ |---|---|---|
1120
+ | `monorepo` | root pyproject (workspace + `[project]`) | — |
1121
+ | `api` | `services/api/pyproject.toml` | → `charts/api/Chart.yaml:appVersion` |
1122
+ | `worker` | `services/worker/pyproject.toml` | none (no chart with that name) |
1123
+ | `client` | `packages/client/package.json` | — |
1124
+ | `api-chart` | `charts/api/Chart.yaml` (suffixed: collides with python `api`) | — |
1125
+
1126
+ ### Auto-discovery languages
1127
+
1128
+ `multicz init` detects the following manifests across the working tree
1129
+ and seeds one component per project:
1130
+
1131
+ | ecosystem | manifest | name source |
1132
+ |---|---|---|
1133
+ | 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 |
1134
+ | Helm | `**/Chart.yaml` | `name:` field |
1135
+ | Rust | `**/Cargo.toml` | `[package].name` (workspaces collapse to one component when `[workspace.package].version` is shared) |
1136
+ | Go | `**/go.mod` | last segment of `module …` (strips `/vN`) — tag-driven, no version file |
1137
+ | Gradle | root `gradle.properties` with `version=` | `rootProject.name` from `settings.gradle[.kts]` |
1138
+ | Node.js | root `package.json` (or workspace members via `workspaces` / `pnpm-workspace.yaml`) | `name` field (npm scopes stripped) |
1139
+ | Debian | `debian/changelog` | package name from the top stanza header |
1140
+
1141
+ Common noise dirs (`.git`, `node_modules`, `.venv`, `target`, `build`,
1142
+ `dist`, `vendor`, …) are excluded from the scan.
1143
+
1144
+ ## Configuration reference
1145
+
1146
+ See [`examples/fastapi-helm/multicz.toml`](examples/fastapi-helm/multicz.toml)
1147
+ for a fully commented example.
1148
+
1149
+ ## Component naming
1150
+
1151
+ Component names land in many places — git tags, file paths
1152
+ (`CHANGELOG.md` location), JSON output, release-notes headings, the
1153
+ `--force NAME:KIND` CLI syntax. They're locked to a safe alphabet:
1154
+
1155
+ | accepts | examples |
1156
+ |---|---|
1157
+ | `^[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` |
1158
+
1159
+ Rejected with a clear error at config-load time:
1160
+
1161
+ | input | reason |
1162
+ |---|---|
1163
+ | `api/v1` | slash → file-path injection in `CHANGELOG.md` location |
1164
+ | `../api` | path traversal |
1165
+ | `chart:prod` | conflicts with `--force NAME:KIND` |
1166
+ | `my app` | whitespace breaks shell tools |
1167
+ | `-foo`, `foo-`, `.hidden`, `foo.` | leading/trailing special chars |
1168
+ | `''` (empty) | empty key |
1169
+ | anything > 64 chars | excessive length |
1170
+
1171
+ ```
1172
+ $ multicz status
1173
+ invalid /path/to/multicz.toml:
1174
+ components: invalid component name 'api/v1': must match
1175
+ ^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$ — no slashes, colons,
1176
+ spaces, or path-like characters; must start and end with a letter
1177
+ or digit. Component names land in git tags, file paths, JSON
1178
+ output, and release notes; keeping them simple avoids escaping
1179
+ issues downstream.
1180
+ ```
1181
+
1182
+ ## Tagging strategy
1183
+
1184
+ Each component gets its own git tag whose name is built from
1185
+ `tag_format`, with two placeholders:
1186
+
1187
+ | placeholder | substituted with |
1188
+ |---|---|
1189
+ | `{component}` | the component name (the dict key, or `name` in array form) |
1190
+ | `{version}` | the new version produced by the bump |
1191
+
1192
+ The default is `tag_format = "{component}-v{version}"` so a typical
1193
+ release looks like:
1194
+
1195
+ ```
1196
+ api-v1.3.0
1197
+ api-v1.4.0-rc.1
1198
+ chart-v0.5.0
1199
+ frontend-v2.1.0
1200
+ mypkg-v1.3.0 # debian-format components keep semver in the tag
1201
+ ```
1202
+
1203
+ Tags are **annotated** (created with `-m`), which makes them work in
1204
+ environments that have `tag.gpgSign = true` and lets `git describe`
1205
+ land on them naturally.
1206
+
1207
+ ### Per-component override
1208
+
1209
+ `tag_format` can be set on a component to override the project-wide
1210
+ default:
1211
+
1212
+ ```toml
1213
+ [project]
1214
+ tag_format = "{component}-v{version}"
1215
+
1216
+ [components.api]
1217
+ paths = ["src/**", "pyproject.toml"]
1218
+
1219
+ [components.legacy]
1220
+ paths = ["legacy/**"]
1221
+ tag_format = "v{version}" # keep the historical scheme
1222
+ ```
1223
+
1224
+ Each component's rendered prefix (the bit before `{version}`) must be
1225
+ unique across the project — otherwise `git tag --list <prefix>*` would
1226
+ return tags from another component and the planner would read the
1227
+ wrong "current" version. multicz refuses to load a config where two
1228
+ components produce the same prefix and tells you which two to fix:
1229
+
1230
+ ```
1231
+ components 'foo' and 'bar' share the same tag prefix 'v'; tags would
1232
+ collide. Set a unique tag_format on at least one of them.
1233
+ ```
1234
+
1235
+ ### Migration from a single-tag scheme
1236
+
1237
+ A common starting point is a legacy repo with global tags like
1238
+ `v1.2.0`, `v1.3.0`. To adopt multicz:
1239
+
1240
+ 1. Decide whether the legacy tags belong to **one** of the new
1241
+ components (typically the main app). Set `tag_format = "v{version}"`
1242
+ on that component so its history continues seamlessly.
1243
+ 2. Give every other component a different prefix (the default
1244
+ `{component}-v{version}` does that for free).
1245
+ 3. The planner reads the current version using this priority — git
1246
+ tag matching the resolved `tag_format`, then the value in the
1247
+ component's primary `bump_file` (`pyproject.toml`'s
1248
+ `[project].version`, etc.), then `initial_version`. So even before
1249
+ you cut your first multicz tag, the in-tree version is honoured.
1250
+
1251
+ Concretely:
1252
+
1253
+ ```toml
1254
+ [project]
1255
+ tag_format = "{component}-v{version}"
1256
+
1257
+ [components.api]
1258
+ paths = ["src/**", "pyproject.toml"]
1259
+ tag_format = "v{version}" # legacy tags stay under "v" prefix
1260
+
1261
+ [components.chart]
1262
+ paths = ["charts/**"] # default "chart-v…" — fresh history
1263
+ ```
1264
+
1265
+ `multicz status` now shows `api` reading its version from the
1266
+ existing `v1.2.0` tag while `chart` starts at `initial_version`.
1267
+
1268
+ ## Artifacts (what CI should build and push)
1269
+
1270
+ `multicz` does **not** build or push artifacts itself. It surfaces the
1271
+ information CI needs to do so, decoupled from your specific image
1272
+ registry, chart repository, or package index. Declare what each
1273
+ component publishes:
1274
+
1275
+ ```toml
1276
+ [components.api]
1277
+ paths = ["src/**", "pyproject.toml"]
1278
+ bump_files = [{ file = "pyproject.toml", key = "project.version" }]
1279
+
1280
+ [[components.api.artifacts]]
1281
+ type = "docker"
1282
+ ref = "ghcr.io/foo/api:{version}"
1283
+
1284
+ [[components.api.artifacts]]
1285
+ type = "docker"
1286
+ ref = "registry.acme.com/api:{version}"
1287
+
1288
+ [components.chart]
1289
+ paths = ["charts/myapp/**"]
1290
+ bump_files = [{ file = "charts/myapp/Chart.yaml", key = "version" }]
1291
+
1292
+ [[components.chart.artifacts]]
1293
+ type = "helm"
1294
+ ref = "{component}-{version}.tgz"
1295
+
1296
+ [[components.chart.artifacts]]
1297
+ type = "oci"
1298
+ ref = "oci://registry.acme.com/charts/{component}:{version}"
1299
+ ```
1300
+
1301
+ `ref` accepts `{version}` and `{component}` placeholders. `type` is
1302
+ free-form so CI can filter on it (`docker`, `helm`, `oci`, `npm`,
1303
+ `pypi`, …).
1304
+
1305
+ Three places surface the rendered artifacts:
1306
+
1307
+ ```sh
1308
+ # Direct lookup against the current version
1309
+ multicz artifacts api
1310
+ # api (1.2.0)
1311
+ # [docker] ghcr.io/foo/api:1.2.0
1312
+ # [docker] registry.acme.com/api:1.2.0
1313
+
1314
+ # Against an explicit target version
1315
+ multicz artifacts api --version 1.4.0-rc.1
1316
+
1317
+ # JSON for CI scripts
1318
+ multicz artifacts --all --output json
1319
+ ```
1320
+
1321
+ `multicz plan --output json` and `multicz bump --output json` both
1322
+ include an `artifacts` array per component rendered against the
1323
+ *planned* (or just-applied) version. CI can drive the actual
1324
+ build/push from a single payload:
1325
+
1326
+ ```yaml
1327
+ - run: |
1328
+ RELEASE=$(multicz bump --commit --tag --output json)
1329
+ echo "$RELEASE" | jq -r '.bumps[].artifacts[] | select(.type=="docker") | .ref' \
1330
+ | xargs -I{} sh -c 'docker build -t {} . && docker push {}'
1331
+ echo "$RELEASE" | jq -r '.bumps[].artifacts[] | select(.type=="helm") | .ref' \
1332
+ | xargs -I{} sh -c 'helm package . && helm push {}'
1333
+ ```
1334
+
1335
+ ## Optional state file
1336
+
1337
+ `multicz` is normally stateless — every command recomputes from git
1338
+ tags and the in-tree manifests. For monorepos that want a persistent
1339
+ audit trail or **drift detection** (catch manual edits that bypassed
1340
+ `multicz bump`), opt into a state file:
1341
+
1342
+ ```toml
1343
+ [project]
1344
+ state_file = ".multicz/state.json"
1345
+ ```
1346
+
1347
+ After every successful `multicz bump`, the file is written next to the
1348
+ version updates and lands in the release commit (when `--commit` is
1349
+ used):
1350
+
1351
+ ```json
1352
+ {
1353
+ "version": 1,
1354
+ "git_head": "fe9a637d223e570fc873ecac9ee4e53c3c05ee31",
1355
+ "git_head_short": "fe9a637",
1356
+ "timestamp": "2026-05-01T17:46:27Z",
1357
+ "components": {
1358
+ "api": {
1359
+ "version": "1.3.0",
1360
+ "tag": "api-v1.3.0",
1361
+ "tag_sha": null
1362
+ }
1363
+ }
1364
+ }
1365
+ ```
1366
+
1367
+ `multicz state` prints the snapshot. `multicz state --output json`
1368
+ emits the same JSON for `jq` consumption.
1369
+
1370
+ ### Drift detection in `validate`
1371
+
1372
+ When `state_file` is set, `multicz validate` adds two checks:
1373
+
1374
+ * **`state_drift`** (warning) — the recorded version doesn't match the
1375
+ current value in the primary `bump_file`. Fires when someone edits
1376
+ `pyproject.toml` / `Chart.yaml` / `package.json` manually without
1377
+ going through `multicz bump`:
1378
+ ```
1379
+ ! api: state recorded version '1.3.0' but pyproject.toml now reads
1380
+ '9.9.9' — someone may have edited the file outside multicz bump
1381
+ (state_drift)
1382
+ ```
1383
+ * **`state_unknown_component`** (warning) — the state references a name
1384
+ no longer declared in `multicz.toml` (typically after a component
1385
+ was renamed or removed without clearing state).
1386
+
1387
+ The state file is **opt-in**. The default stateless flow remains the
1388
+ recommended setup for most repos — the planner always re-derives from
1389
+ git, which is the source of truth.
1390
+
1391
+ ## Path ownership and overlap
1392
+
1393
+ The matcher uses **first-match-wins** by default: when two components
1394
+ both claim a file (e.g. `api` and `worker` both listing `src/**`), the
1395
+ component declared first in the config silently owns it, and the
1396
+ others lose. That's predictable but easy to miss.
1397
+
1398
+ `project.overlap_policy` makes the choice explicit:
1399
+
1400
+ ```toml
1401
+ [project]
1402
+ overlap_policy = "error" # default
1403
+ ```
1404
+
1405
+ | value | `validate` | runtime behaviour |
1406
+ |---|---|---|
1407
+ | `error` (default) | error | refuses to plan/bump until you resolve the overlap |
1408
+ | `first-match` | warning | first-declared component owns the file (the others lose) |
1409
+ | `allow` | silent | same runtime as `first-match` — suppresses the finding |
1410
+ | `all` | info | a shared file bumps **every** claiming component |
1411
+
1412
+ The `all` mode is genuinely useful for monorepos where several
1413
+ components share code:
1414
+
1415
+ ```toml
1416
+ [project]
1417
+ overlap_policy = "all"
1418
+
1419
+ [components.api]
1420
+ paths = ["src/**", "pyproject.toml"]
1421
+
1422
+ [components.worker]
1423
+ paths = ["src/**", "workers/**"]
1424
+ ```
1425
+
1426
+ A `feat:` commit touching `src/common.py` now bumps both `api` and
1427
+ `worker`. With `error` (the default) that same commit refuses to plan
1428
+ until you tighten the paths or add `exclude_paths`.
1429
+
1430
+ ## Bump kind by commit type
1431
+
1432
+ | commit | bump |
1433
+ |---|---|
1434
+ | `feat: …` | minor |
1435
+ | `feat!: …` or `BREAKING CHANGE:` footer | major |
1436
+ | `fix: …` | patch |
1437
+ | `perf: …` | patch |
1438
+ | `revert: …` | patch — a revert is user-visible activity |
1439
+ | `chore`, `docs`, `style`, `test`, `build`, `ci`, `refactor` | none |
1440
+ | anything not matching `<type>(<scope>)?: <subject>` | controlled by `unknown_commit_policy` (default: ignored) |
1441
+
1442
+ A `revert: feat(api): drop login` is treated as a `patch` because
1443
+ something user-visible changed — a feature was removed (or restored).
1444
+ The conservative bump avoids saying "no change" when there clearly
1445
+ was one. Override per-component with `bump_policy = "scoped"` if you
1446
+ need a tighter scope rule, or with `ignored_types = ["revert"]` if
1447
+ you really want them silent.
1448
+
1449
+ The default `[project].changelog_sections` now includes a `Reverts`
1450
+ section so reverted commits show up in `CHANGELOG.md` and
1451
+ `release-notes` output:
1452
+
1453
+ ```markdown
1454
+ ## [1.3.1] - 2026-05-01
1455
+
1456
+ ### Reverts
1457
+
1458
+ - drop login flow (`abc1234`)
1459
+ ```
1460
+
1461
+ The section only renders when the release window contains revert
1462
+ commits — projects without reverts see the same output as before.
1463
+
1464
+ ## Non-conventional commits
1465
+
1466
+ A commit like `update stuff` (no `<type>:` prefix) doesn't fit the
1467
+ conventional grammar. The default behaviour silently skips it — but
1468
+ that can hide real activity. `project.unknown_commit_policy` makes
1469
+ the choice explicit:
1470
+
1471
+ ```toml
1472
+ [project]
1473
+ unknown_commit_policy = "ignore" # default
1474
+ # or "patch"
1475
+ # or "error"
1476
+ ```
1477
+
1478
+ | value | planner behaviour |
1479
+ |---|---|
1480
+ | `ignore` (default) | silent skip — backwards-compatible |
1481
+ | `patch` | the commit produces a `NonConventionalReason` at patch level, visible in `plan` / `explain` / JSON |
1482
+ | `error` | refuse to plan, list every offending SHA with a remediation hint |
1483
+
1484
+ `error` mode renders a clean CLI message instead of a traceback:
1485
+
1486
+ ```
1487
+ $ multicz plan
1488
+ ✗ 2 non-conventional commit(s) blocking the plan (unknown_commit_policy='error')
1489
+ - 1b233e5: update stuff
1490
+ - 53f374b: wip
1491
+
1492
+ Either rewrite their headers as conventional commits (`git rebase -i`),
1493
+ or set unknown_commit_policy = "ignore" (or "patch") in [project].
1494
+ ```
1495
+
1496
+ Use it in CI as a strict gate; `ignore` (default) keeps the existing
1497
+ laissez-faire experience.
1498
+
1499
+ ## Ignoring commit types
1500
+
1501
+ Some commit types should never appear in any bump or changelog —
1502
+ typically `chore(deps):` updates that incidentally touch `src/`, or
1503
+ `ci: tweak workflow.yml` against a `.github/**` path owned by a
1504
+ component. `ignored_types` makes that explicit:
1505
+
1506
+ ```toml
1507
+ [project]
1508
+ ignored_types = ["chore", "ci", "docs", "test", "style"]
1509
+ ```
1510
+
1511
+ You can also opt-in per-component (the effective set is the union):
1512
+
1513
+ ```toml
1514
+ [components.api]
1515
+ ignored_types = ["fix"] # api ignores 'fix' on top of project-wide rules
1516
+ ```
1517
+
1518
+ A commit whose type is in the effective set is fully filtered:
1519
+
1520
+ | | with `ignored_types = ["chore", "ci"]` |
1521
+ |---|---|
1522
+ | `feat: real change` | ✓ bumps, in changelog |
1523
+ | `fix: bug` | ✓ bumps, in changelog |
1524
+ | `chore(deps): bump typer` | ✗ ignored |
1525
+ | `ci: tweak release workflow` | ✗ ignored |
1526
+
1527
+ The filter is stricter than `release_commit_pattern` (which targets
1528
+ one specific message shape): `ignored_types` short-circuits before
1529
+ the bump kind is even consulted, so `feat!: ...` is also dropped
1530
+ if `feat` is in the list. That's the explicit cost of the choice.
1531
+
1532
+ ## Component dependencies
1533
+
1534
+ Sometimes a component should bump *because another component bumps* —
1535
+ typically a Helm chart that ships a Python service: when the service
1536
+ bumps, the chart needs a fresh build with the new app version.
1537
+
1538
+ ```toml
1539
+ [components.api]
1540
+ paths = ["src/**", "pyproject.toml"]
1541
+ bump_files = [{ file = "pyproject.toml", key = "project.version" }]
1542
+
1543
+ [components.chart]
1544
+ paths = ["charts/myapp/**"]
1545
+ bump_files = [{ file = "charts/myapp/Chart.yaml", key = "version" }]
1546
+ depends_on = ["api"] # chart bumps whenever api does
1547
+ ```
1548
+
1549
+ When `api` goes from `1.2.0` to `1.3.0` (minor), `chart` cascades a
1550
+ bump too. The kind is governed by `project.trigger_policy`:
1551
+
1552
+ | value | behaviour | when to use |
1553
+ |---|---|---|
1554
+ | `match-upstream` (default) | dependent inherits the upstream's kind — `api` minor → `chart` minor | the dependent is conceptually "the same release" as the upstream |
1555
+ | `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) |
1556
+
1557
+ ```toml
1558
+ [project]
1559
+ trigger_policy = "patch" # chart always patches when api bumps
1560
+ ```
1561
+
1562
+ > **Mirrors vs. `depends_on`** — both create cascades, but they're
1563
+ > different concepts:
1564
+ >
1565
+ > * `mirrors` writes a component's *version* into another component's
1566
+ > *file* (e.g. api version → `Chart.yaml:appVersion`). The receiving
1567
+ > component cascades a patch *because the file inside its paths
1568
+ > changed*. Use it when the file content needs to track a sibling.
1569
+ > * `depends_on` is the explicit "X depends on Y" relationship.
1570
+ > No file is written; the cascade is purely logical. Use it when
1571
+ > you want the relationship without the version mirror.
1572
+ >
1573
+ > A chart with `appVersion` typically declares **both** — the mirror
1574
+ > for the field, and `depends_on = ["api"]` is then redundant
1575
+ > (the cascade fires either way).
1576
+
1577
+ > **Backwards compatibility** — the old name `triggers = [...]` still
1578
+ > parses. It's silently merged into `depends_on`. New configs should
1579
+ > use `depends_on`.
1580
+
1581
+ ## Per-component bump policy
1582
+
1583
+ When a single commit touches multiple components, each component
1584
+ *also* gets that commit's bump kind by default. So a:
1585
+
1586
+ ```
1587
+ feat: change API contract and update Helm values
1588
+ ```
1589
+
1590
+ with files in both `src/` and `charts/myapp/values.yaml` bumps **api**
1591
+ *and* **chart** to minor — even though the chart only got a config
1592
+ tweak.
1593
+
1594
+ Components that want stricter semantics can opt into
1595
+ `bump_policy = "scoped"`:
1596
+
1597
+ ```toml
1598
+ [components.chart]
1599
+ paths = ["charts/myapp/**"]
1600
+ bump_files = [{ file = "charts/myapp/Chart.yaml", key = "version" }]
1601
+ bump_policy = "scoped"
1602
+ ```
1603
+
1604
+ | commit | api | chart |
1605
+ |---|---|---|
1606
+ | `feat: cross-cutting change` (no scope) | minor | minor — no scope means "applies broadly" |
1607
+ | `feat(api): rewrite contract` | minor (scope matches) | **patch** — demoted, scope ≠ chart |
1608
+ | `feat(chart): add value` | — | minor (scope matches) |
1609
+ | `fix: typo` | patch | patch (already patch, no demotion) |
1610
+
1611
+ The demotion is surfaced explicitly in `multicz explain`:
1612
+
1613
+ ```
1614
+ 2. af74ec5 feat(api): rewrite contract
1615
+ Type: feat(api) → patch
1616
+ Demoted from minor (bump_policy='scoped', different scope)
1617
+ ```
1618
+
1619
+ …and in the JSON output:
1620
+
1621
+ ```json
1622
+ {"kind": "commit", "type": "feat", "scope": "api",
1623
+ "bump_kind": "patch", "original_kind": "minor", ...}
1624
+ ```
1625
+
1626
+ Two values are supported:
1627
+
1628
+ - `as-commit` (default): the commit's natural kind applies to every
1629
+ touched component. Matches semantic-release / lerna / nx semantics.
1630
+ - `scoped`: when a commit's scope names a different component,
1631
+ demote `minor`/`major` to `patch`. No-scope commits still propagate
1632
+ as-is.
1633
+
1634
+ ## Helm chart immutability
1635
+
1636
+ Helm charts are content-addressed by `name-version.tgz`. If `chart-0.5.0`
1637
+ references `appVersion: 1.2.0` in some pulls and `appVersion: 1.3.0` in
1638
+ others, you've effectively shipped two different artifacts under the same
1639
+ name. `multicz` refuses that: any time the mirrored `appVersion` changes,
1640
+ the chart version moves with it.
1641
+
1642
+ ## License
1643
+
1644
+ MIT