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 +1644 -0
- multicz-0.1.0/README.md +1616 -0
- multicz-0.1.0/pyproject.toml +65 -0
- multicz-0.1.0/src/multicz/__init__.py +10 -0
- multicz-0.1.0/src/multicz/changelog.py +196 -0
- multicz-0.1.0/src/multicz/cli.py +1677 -0
- multicz-0.1.0/src/multicz/commits.py +271 -0
- multicz-0.1.0/src/multicz/components.py +62 -0
- multicz-0.1.0/src/multicz/config.py +425 -0
- multicz-0.1.0/src/multicz/debian.py +266 -0
- multicz-0.1.0/src/multicz/discovery.py +652 -0
- multicz-0.1.0/src/multicz/planner.py +652 -0
- multicz-0.1.0/src/multicz/state.py +103 -0
- multicz-0.1.0/src/multicz/validation.py +403 -0
- multicz-0.1.0/src/multicz/writers.py +192 -0
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
|