multicz 0.3.0__tar.gz → 0.5.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.5.0/PKG-INFO ADDED
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.3
2
+ Name: multicz
3
+ Version: 0.5.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
+ <p align="center">
36
+ <img src="https://github.com/goabonga/multicz/raw/main/docs/demo.gif" alt="multicz demo" width="720">
37
+ </p>
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ uv tool install multicz # or: pipx install multicz / pip install multicz
43
+ ```
44
+
45
+ ## Quickstart
46
+
47
+ ```bash
48
+ # 1. scaffold a multicz.toml in the repo root
49
+ multicz init
50
+
51
+ # 2. see what would bump from the current commit window
52
+ multicz changed
53
+ multicz explain api
54
+
55
+ # 3. apply the bump (writes version files + changelog, commits, tags)
56
+ multicz bump --commit --tag
57
+
58
+ # 4. ship it
59
+ git push --follow-tags
60
+ ```
61
+
62
+ For multi-component setups (api + chart, app + image + helm, ...), declare
63
+ each component's paths in `multicz.toml` and let mirrors and triggers cascade
64
+ related versions. See the [docs](https://goabonga.github.io/multicz/) for the
65
+ full configuration reference.
66
+
67
+ ## What it does
68
+
69
+ - **Per-component versions** - each component has its own version line and
70
+ its own git tag (`api-v1.2.0`, `chart-v0.5.0`).
71
+ - **Conventional-commit driven** - `feat:` → minor, `fix:` → patch,
72
+ `BREAKING CHANGE:` → major. Scopes route the bump to the right component.
73
+ - **Mirrors and triggers** - bump api `1.2.0` → `1.3.0` and the Helm chart's
74
+ `appVersion` follows; the chart's own version cascades a patch.
75
+ - **No network, no auto-updates.** Pure `git` + filesystem. Same input yields
76
+ the same plan byte-for-byte.
77
+
78
+ ## Documentation
79
+
80
+ Published at **<https://goabonga.github.io/multicz/>**:
81
+
82
+ - [Get started](https://goabonga.github.io/multicz/get-started/) - install, minimal config, first bump
83
+ - [Concepts](https://goabonga.github.io/multicz/concepts/) - components, mirrors, triggers, cascades, bump policies
84
+ - [Configuration](https://goabonga.github.io/multicz/configuration/) - full `multicz.toml` reference
85
+ - [CLI](https://goabonga.github.io/multicz/cli/) - every command and flag
86
+ - [Recipes](https://goabonga.github.io/multicz/recipes/) - FastAPI + Helm walkthrough, CI matrix gating, release candidates
87
+ - [Why multicz?](https://goabonga.github.io/multicz/why/) - vs. semantic-release, Commitizen, Changesets, bump-my-version
88
+ - [Security](https://goabonga.github.io/multicz/security/) - guarantees and CI hardening
89
+
90
+ ## License
91
+
92
+ [MIT](https://github.com/goabonga/multicz/blob/main/LICENSE)
@@ -0,0 +1,64 @@
1
+ # multicz
2
+
3
+ Multi-component versioning for monorepos. Bump a Python app, its Docker image,
4
+ and the Helm chart that deploys it from a single conventional-commit history -
5
+ each with its own version line and its own git tag.
6
+
7
+ <p align="center">
8
+ <img src="https://github.com/goabonga/multicz/raw/main/docs/demo.gif" alt="multicz demo" width="720">
9
+ </p>
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ uv tool install multicz # or: pipx install multicz / pip install multicz
15
+ ```
16
+
17
+ ## Quickstart
18
+
19
+ ```bash
20
+ # 1. scaffold a multicz.toml in the repo root
21
+ multicz init
22
+
23
+ # 2. see what would bump from the current commit window
24
+ multicz changed
25
+ multicz explain api
26
+
27
+ # 3. apply the bump (writes version files + changelog, commits, tags)
28
+ multicz bump --commit --tag
29
+
30
+ # 4. ship it
31
+ git push --follow-tags
32
+ ```
33
+
34
+ For multi-component setups (api + chart, app + image + helm, ...), declare
35
+ each component's paths in `multicz.toml` and let mirrors and triggers cascade
36
+ related versions. See the [docs](https://goabonga.github.io/multicz/) for the
37
+ full configuration reference.
38
+
39
+ ## What it does
40
+
41
+ - **Per-component versions** - each component has its own version line and
42
+ its own git tag (`api-v1.2.0`, `chart-v0.5.0`).
43
+ - **Conventional-commit driven** - `feat:` → minor, `fix:` → patch,
44
+ `BREAKING CHANGE:` → major. Scopes route the bump to the right component.
45
+ - **Mirrors and triggers** - bump api `1.2.0` → `1.3.0` and the Helm chart's
46
+ `appVersion` follows; the chart's own version cascades a patch.
47
+ - **No network, no auto-updates.** Pure `git` + filesystem. Same input yields
48
+ the same plan byte-for-byte.
49
+
50
+ ## Documentation
51
+
52
+ Published at **<https://goabonga.github.io/multicz/>**:
53
+
54
+ - [Get started](https://goabonga.github.io/multicz/get-started/) - install, minimal config, first bump
55
+ - [Concepts](https://goabonga.github.io/multicz/concepts/) - components, mirrors, triggers, cascades, bump policies
56
+ - [Configuration](https://goabonga.github.io/multicz/configuration/) - full `multicz.toml` reference
57
+ - [CLI](https://goabonga.github.io/multicz/cli/) - every command and flag
58
+ - [Recipes](https://goabonga.github.io/multicz/recipes/) - FastAPI + Helm walkthrough, CI matrix gating, release candidates
59
+ - [Why multicz?](https://goabonga.github.io/multicz/why/) - vs. semantic-release, Commitizen, Changesets, bump-my-version
60
+ - [Security](https://goabonga.github.io/multicz/security/) - guarantees and CI hardening
61
+
62
+ ## License
63
+
64
+ [MIT](https://github.com/goabonga/multicz/blob/main/LICENSE)
@@ -1,6 +1,9 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 Chris <goabonga@pm.me>
3
+
1
4
  [project]
2
5
  name = "multicz"
3
- version = "0.3.0"
6
+ version = "0.5.0"
4
7
  description = "Multi-component versioning for monorepos: bump apps, charts, and images independently from conventional commits."
5
8
  readme = "README.md"
6
9
  authors = [
@@ -46,6 +49,9 @@ dev = [
46
49
  "pytest-cov>=5.0",
47
50
  "ruff>=0.6",
48
51
  ]
52
+ docs = [
53
+ "zensical>=0.0.39",
54
+ ]
49
55
 
50
56
  [tool.ruff]
51
57
  line-length = 100
@@ -1,4 +1,7 @@
1
- """multicz — multi-component versioning for monorepos."""
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 Chris <goabonga@pm.me>
3
+
4
+ """multicz - multi-component versioning for monorepos."""
2
5
 
3
6
  from importlib.metadata import PackageNotFoundError, version
4
7
 
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 Chris <goabonga@pm.me>
3
+
4
+ from .cli import app
5
+
6
+ app()
@@ -1,3 +1,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 Chris <goabonga@pm.me>
3
+
1
4
  """Render and insert per-component CHANGELOG.md sections.
2
5
 
3
6
  A *section* is the chunk of markdown describing a single release for one
@@ -16,8 +19,8 @@ component:
16
19
  The list of section buckets and their titles is configurable via
17
20
  ``ProjectSettings.changelog_sections`` so each project can pick its own
18
21
  vocabulary (Features/Fixes vs. keep-a-changelog's Added/Changed/Fixed,
19
- etc.). Commits whose type matches no section are silently dropped keep
20
- the changelog focused on user-visible changes unless
22
+ etc.). Commits whose type matches no section are silently dropped - keep
23
+ the changelog focused on user-visible changes - unless
21
24
  ``other_section_title`` is set, which buckets them under that title.
22
25
 
23
26
  When written into an existing file the new section lands directly after
@@ -42,10 +45,22 @@ class CascadeEntry:
42
45
  """A non-commit reason for a bump (mirror or trigger), surfaced in
43
46
  the changelog so cascade-only releases describe what made them
44
47
  happen instead of rendering ``_No notable changes._``.
48
+
49
+ ``section`` and ``format`` are optional per-entry overrides for the
50
+ default ``cascade_title`` / ``cascade_format`` passed to
51
+ :func:`render_body`. When ``section`` matches an existing
52
+ commit-driven section (``Features``, ``Fixes``, ``Breaking
53
+ changes``, ...), the cascade line is appended to that section's
54
+ bucket. Otherwise it creates a new section under that name. An
55
+ explicit empty-string ``section`` (``""``) is **not** the disable
56
+ switch — to disable the cascade rendering entirely, leave
57
+ ``section=None`` and pass ``cascade_title=""`` to ``render_body``.
45
58
  """
46
59
 
47
60
  upstream: str
48
61
  upstream_version: str
62
+ section: str | None = None
63
+ format: str | None = None
49
64
 
50
65
  _PREAMBLE = (
51
66
  "# Changelog\n"
@@ -79,23 +94,32 @@ def render_body(
79
94
  """
80
95
  sections = list(sections) if sections is not None else _default_changelog_sections()
81
96
  relevant = [c for c in commits if c.is_conventional]
82
- cascade_lines: list[str] = []
83
- if cascades and cascade_title:
97
+
98
+ # Group cascades by their *resolved* section name. Per-entry overrides
99
+ # take precedence over the global cascade_title; an empty resolved
100
+ # section name means "drop this entry" (preserves the legacy
101
+ # cascade_title="" disable switch when no per-entry section is set).
102
+ cascade_groups: dict[str, list[str]] = {}
103
+ if cascades:
84
104
  for entry in cascades:
85
- cascade_lines.append(
86
- cascade_format.format(
87
- upstream=entry.upstream,
88
- upstream_version=entry.upstream_version,
89
- )
105
+ section_name = entry.section if entry.section is not None else cascade_title
106
+ if not section_name:
107
+ continue
108
+ fmt = entry.format if entry.format is not None else cascade_format
109
+ line = fmt.format(
110
+ upstream=entry.upstream,
111
+ upstream_version=entry.upstream_version,
90
112
  )
91
- if not relevant and not cascade_lines:
113
+ cascade_groups.setdefault(section_name, []).append(line)
114
+
115
+ if not relevant and not cascade_groups:
92
116
  return "_No notable changes._\n"
93
117
 
94
118
  breaking: list[Commit] = []
95
119
  if breaking_title:
96
120
  breaking = [c for c in relevant if c.breaking]
97
121
 
98
- buckets: dict[str, list[Commit]] = {}
122
+ commit_buckets: dict[str, list[Commit]] = {}
99
123
  breaking_set = {id(c) for c in breaking}
100
124
  for section in sections:
101
125
  type_set = {t.lower() for t in section.types}
@@ -104,7 +128,7 @@ def render_body(
104
128
  if id(c) not in breaking_set and c.type.lower() in type_set
105
129
  ]
106
130
  if items:
107
- buckets[section.title] = items
131
+ commit_buckets[section.title] = items
108
132
 
109
133
  if other_title:
110
134
  claimed = {t.lower() for s in sections for t in s.types}
@@ -113,35 +137,52 @@ def render_body(
113
137
  if id(c) not in breaking_set and c.type.lower() not in claimed
114
138
  ]
115
139
  if leftovers:
116
- buckets[other_title] = leftovers
140
+ commit_buckets[other_title] = leftovers
117
141
 
118
- ordered: list[tuple[str, list[Commit]]] = (
119
- [(breaking_title, breaking)] if breaking else []
120
- )
121
- for section in sections:
122
- if section.title in buckets:
123
- ordered.append((section.title, buckets[section.title]))
124
- if other_title and other_title in buckets:
125
- ordered.append((other_title, buckets[other_title]))
142
+ def _commit_line(commit: Commit) -> str:
143
+ scope = f"**{commit.scope}**: " if commit.scope else ""
144
+ return f"{scope}{commit.subject} (`{commit.sha[:7]}`)"
145
+
146
+ # Build the final ordered list of (title, lines). For each
147
+ # commit-driven section we render the commits first, then append any
148
+ # cascade lines targeting that same title — so a mirror routed to
149
+ # ``Features`` lands at the bottom of the existing ``### Features``
150
+ # bucket rather than creating a parallel one.
151
+ ordered: list[tuple[str, list[str]]] = []
152
+
153
+ if breaking:
154
+ merged = [_commit_line(c) for c in breaking]
155
+ merged.extend(cascade_groups.pop(breaking_title, []))
156
+ ordered.append((breaking_title, merged))
126
157
 
127
- if not ordered and not cascade_lines:
158
+ for section in sections:
159
+ if section.title in commit_buckets:
160
+ merged = [_commit_line(c) for c in commit_buckets[section.title]]
161
+ merged.extend(cascade_groups.pop(section.title, []))
162
+ ordered.append((section.title, merged))
163
+
164
+ if other_title and other_title in commit_buckets:
165
+ merged = [_commit_line(c) for c in commit_buckets[other_title]]
166
+ merged.extend(cascade_groups.pop(other_title, []))
167
+ ordered.append((other_title, merged))
168
+
169
+ # Whatever cascade sections are left have no matching commit bucket —
170
+ # render them after the commit-driven sections in their original
171
+ # insertion order.
172
+ for title, lines in cascade_groups.items():
173
+ ordered.append((title, list(lines)))
174
+
175
+ if not ordered:
128
176
  return "_No notable changes._\n"
129
177
 
130
- lines: list[str] = []
178
+ out: list[str] = []
131
179
  for title, items in ordered:
132
- lines.append(f"### {title}")
133
- lines.append("")
134
- for commit in items:
135
- scope = f"**{commit.scope}**: " if commit.scope else ""
136
- lines.append(f"- {scope}{commit.subject} (`{commit.sha[:7]}`)")
137
- lines.append("")
138
- if cascade_lines:
139
- lines.append(f"### {cascade_title}")
140
- lines.append("")
141
- for entry in cascade_lines:
142
- lines.append(f"- {entry}")
143
- lines.append("")
144
- return "\n".join(lines)
180
+ out.append(f"### {title}")
181
+ out.append("")
182
+ for item in items:
183
+ out.append(f"- {item}")
184
+ out.append("")
185
+ return "\n".join(out)
145
186
 
146
187
 
147
188
  def render_section(
@@ -223,7 +264,7 @@ def update_changelog_file(
223
264
  """Render a new section and merge it into ``path`` (creating the file if needed).
224
265
 
225
266
  ``drop_prereleases=True`` removes any prior ``## [<version>-<pre>.<n>]``
226
- sections from the file before inserting the new release section
267
+ sections from the file before inserting the new release section -
227
268
  used by the ``promote`` finalize strategy.
228
269
  """
229
270
  section = render_section(
@@ -1,3 +1,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 Chris <goabonga@pm.me>
3
+
1
4
  """Command line interface for multicz."""
2
5
 
3
6
  from __future__ import annotations
@@ -64,7 +67,7 @@ err = Console(stderr=True)
64
67
 
65
68
 
66
69
  _BARE_CONFIG = """\
67
- # multicz.toml generic stub. Edit paths and bump_files to match your repo.
70
+ # multicz.toml - generic stub. Edit paths and bump_files to match your repo.
68
71
  # Run `multicz init` (without --bare) to scan the working tree and generate
69
72
  # a config tailored to the manifests it actually contains.
70
73
 
@@ -130,7 +133,7 @@ def init(
130
133
  ``charts/*/Chart.yaml``, ``package.json``, ``Cargo.toml``, ``go.mod``,
131
134
  ``gradle.properties`` and ``debian/changelog``; one component is
132
135
  emitted per detected manifest. ``--bare`` writes a generic
133
- single-component stub instead useful when bootstrapping a brand
136
+ single-component stub instead - useful when bootstrapping a brand
134
137
  new repo.
135
138
 
136
139
  \b
@@ -277,7 +280,7 @@ def _append_step_summary(path: Path, lines: list[str]) -> None:
277
280
 
278
281
  Mirrors GitHub Actions' ``$GITHUB_STEP_SUMMARY`` semantics: each
279
282
  step's content is appended; the runner concatenates everything into
280
- the workflow's run-page summary. Safe to call from local shells
283
+ the workflow's run-page summary. Safe to call from local shells -
281
284
  the file is just a text file.
282
285
  """
283
286
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -306,7 +309,7 @@ def _append_plan_summary(path: Path, plan_obj, *, header: str) -> None:
306
309
  lines.append("")
307
310
  for bump in plan_obj:
308
311
  lines.append(
309
- f"### `{bump.component}` {bump.current} → {bump.next} "
312
+ f"### `{bump.component}` - {bump.current} → {bump.next} "
310
313
  f"({bump.kind})"
311
314
  )
312
315
  lines.append("")
@@ -340,7 +343,7 @@ def _append_bump_summary(
340
343
  tag_index = {t.split("-v", 1)[0] if "-v" in t else None: t for t in tags}
341
344
  # Fall back to format string lookup when tag_format isn't `<comp>-v<ver>`.
342
345
  for name, info in applied.items():
343
- tag = tag_index.get(name) or ""
346
+ tag = tag_index.get(name) or "-"
344
347
  for t in tags:
345
348
  if config.tag_format_for(name).format(
346
349
  component=name, version=info["next"]
@@ -442,7 +445,7 @@ def plan_cmd(
442
445
  force: list[str] = typer.Option(
443
446
  None, "--force",
444
447
  help="Force-bump <name>:<kind>. Repeatable. Bypasses commit "
445
- "detection use for manual rebuilds (CVE base image refresh, "
448
+ "detection - use for manual rebuilds (CVE base image refresh, "
446
449
  "weekly artefact rebuild, …).",
447
450
  ),
448
451
  summary: Path = typer.Option(
@@ -538,7 +541,7 @@ def state(
538
541
  repo, config = _load()
539
542
  if config.project.state_file is None:
540
543
  err.print(
541
- "[red]no state_file configured[/] set "
544
+ "[red]no state_file configured[/] - set "
542
545
  "[bold][project].state_file[/] in multicz.toml"
543
546
  )
544
547
  raise typer.Exit(code=1)
@@ -577,7 +580,7 @@ def changed(
577
580
  None, "--since",
578
581
  help="Reference to compare against (e.g. origin/main, HEAD~5). "
579
582
  "When omitted, each component is compared against its own "
580
- "last tag same window as the planner uses for bumps.",
583
+ "last tag - same window as the planner uses for bumps.",
581
584
  ),
582
585
  output: str = typer.Option(
583
586
  "text", "--output", "-o", help="text | json",
@@ -605,7 +608,7 @@ def changed(
605
608
  component: ${{ fromJson(needs.detect.outputs.changed) }}
606
609
 
607
610
  Without --since, the answer is per-component (same window as the
608
- planner). With --since, every component shares the reference
611
+ planner). With --since, every component shares the reference -
609
612
  ideal for "what changed in this PR vs main".
610
613
 
611
614
  Release commits matching ``project.release_commit_pattern`` are
@@ -744,7 +747,7 @@ def release_notes_cmd(
744
747
  notes.
745
748
 
746
749
  \b
747
- Default (notes for the upcoming bump same set as `plan`):
750
+ Default (notes for the upcoming bump - same set as `plan`):
748
751
  multicz release-notes api
749
752
  multicz release-notes --all
750
753
 
@@ -948,7 +951,7 @@ def explain(
948
951
  bump = plan_obj.bumps.get(component)
949
952
  if bump is None:
950
953
  console.print(
951
- f"[bold]{component}[/]: [dim]no bump pending "
954
+ f"[bold]{component}[/]: [dim]no bump pending - "
952
955
  "no relevant commits since the last tag[/]"
953
956
  )
954
957
  return
@@ -1013,7 +1016,7 @@ def _porcelain_paths(repo: Path) -> set[str]:
1013
1016
 
1014
1017
  Used to identify candidate paths to hash before/after running
1015
1018
  ``post_bump`` hooks. A pure set diff would miss a file that's
1016
- dirty both before and after with different content the
1019
+ dirty both before and after with different content - the
1017
1020
  canonical case being ``uv run`` itself silently re-syncing
1018
1021
  ``uv.lock`` before multicz even gets to run.
1019
1022
  """
@@ -1108,7 +1111,7 @@ def _component_relevant_commits(
1108
1111
  (project + component, union) are skipped entirely.
1109
1112
 
1110
1113
  When ``since_stable`` is True, the range starts at the previous
1111
- *stable* tag instead used by the ``consolidate`` and ``promote``
1114
+ *stable* tag instead - used by the ``consolidate`` and ``promote``
1112
1115
  finalize strategies.
1113
1116
  """
1114
1117
  import re
@@ -1217,10 +1220,10 @@ def _release_commit_message(
1217
1220
 
1218
1221
  Available placeholders:
1219
1222
 
1220
- * ``{summary}`` ``api 1.2.0 -> 1.3.0, chart 0.4.0 -> 0.5.0``
1221
- * ``{components}`` ``api v1.3.0, chart v0.5.0`` (versions only, ``v`` prefixed)
1222
- * ``{body}`` bullet list with kind annotations
1223
- * ``{count}`` number of components bumped
1223
+ * ``{summary}`` - ``api 1.2.0 -> 1.3.0, chart 0.4.0 -> 0.5.0``
1224
+ * ``{components}`` - ``api v1.3.0, chart v0.5.0`` (versions only, ``v`` prefixed)
1225
+ * ``{body}`` - bullet list with kind annotations
1226
+ * ``{count}`` - number of components bumped
1224
1227
 
1225
1228
  Literal ``{`` and ``}`` in a template should be escaped as ``{{`` / ``}}``.
1226
1229
  """
@@ -1284,12 +1287,12 @@ def bump(
1284
1287
  None, "--commit-message", "-m",
1285
1288
  help="Verbatim release commit message (overrides the project's "
1286
1289
  "release_commit_message template). Like 'git commit -m', no "
1287
- "placeholders are expanded the string is used as-is.",
1290
+ "placeholders are expanded - the string is used as-is.",
1288
1291
  ),
1289
1292
  force: list[str] = typer.Option(
1290
1293
  None, "--force",
1291
1294
  help="Force-bump <name>:<kind>. Repeatable. Bypasses commit "
1292
- "detection use for manual rebuilds (e.g. weekly base "
1295
+ "detection - use for manual rebuilds (e.g. weekly base "
1293
1296
  "image refresh: `--force api:patch`).",
1294
1297
  ),
1295
1298
  sign: bool = typer.Option(
@@ -1328,7 +1331,7 @@ def bump(
1328
1331
  console.print_json(data={"bumps": {}})
1329
1332
  else:
1330
1333
  console.print(
1331
- "[dim]no bumps pending "
1334
+ "[dim]no bumps pending - "
1332
1335
  "use [bold]--force <name>:<kind>[/] for a manual bump[/]"
1333
1336
  )
1334
1337
  return
@@ -1390,10 +1393,33 @@ def bump(
1390
1393
  upstream_planned = plan.bumps.get(reason.upstream)
1391
1394
  if upstream_planned is None:
1392
1395
  continue
1396
+ # When the cascade comes from a mirror, look up
1397
+ # the matching mirror declaration on the upstream
1398
+ # component to pick up its optional
1399
+ # `changelog_section` / `changelog_format`. Trigger
1400
+ # cascades have no such customization handle and
1401
+ # always use the project-level defaults.
1402
+ section_override: str | None = None
1403
+ format_override: str | None = None
1404
+ if isinstance(reason, MirrorReason):
1405
+ upstream_component = config.components.get(
1406
+ reason.upstream
1407
+ )
1408
+ if upstream_component is not None:
1409
+ for mirror in upstream_component.mirrors:
1410
+ if (
1411
+ str(mirror.file) == reason.file
1412
+ and mirror.key == reason.key
1413
+ ):
1414
+ section_override = mirror.changelog_section
1415
+ format_override = mirror.changelog_format
1416
+ break
1393
1417
  cascade_entries.append(
1394
1418
  CascadeEntry(
1395
1419
  upstream=reason.upstream,
1396
1420
  upstream_version=upstream_planned.next,
1421
+ section=section_override,
1422
+ format=format_override,
1397
1423
  )
1398
1424
  )
1399
1425
  seen_upstreams.add(reason.upstream)
@@ -1462,7 +1488,7 @@ def bump(
1462
1488
  # Cargo.toml. Files modified by hooks are auto-detected and folded
1463
1489
  # into ``written`` so they ride the release commit.
1464
1490
  #
1465
- # Detection compares content hashes not just the dirty-paths set
1491
+ # Detection compares content hashes - not just the dirty-paths set -
1466
1492
  # because the entry point is typically ``uv run multicz bump``, and
1467
1493
  # ``uv run`` re-syncs the venv (which can rewrite ``uv.lock``) before
1468
1494
  # multicz code runs at all. By the time we snapshot, uv.lock is
@@ -1,3 +1,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 Chris <goabonga@pm.me>
3
+
1
4
  """Conventional Commit parsing over a git history range.
2
5
 
3
6
  Resolves the latest tag matching a component's tag prefix, lists the commits
@@ -88,7 +91,7 @@ class Commit:
88
91
  ``minor``: ``feat``.
89
92
  ``patch``: ``fix``, ``perf``, ``revert``. A revert is a
90
93
  user-visible change (something was removed or restored), and a
91
- patch is the conservative answer the next release isn't a
94
+ patch is the conservative answer - the next release isn't a
92
95
  feature or breaking change, but it isn't nothing either.
93
96
 
94
97
  Other types (``chore``, ``docs``, ``style``, ``refactor``,
@@ -1,3 +1,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 Chris <goabonga@pm.me>
3
+
1
4
  """Map repository file paths to declared components.
2
5
 
3
6
  Each component owns a set of gitignore-style glob ``paths`` and optional
@@ -1,3 +1,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 Chris <goabonga@pm.me>
3
+
1
4
  """Configuration schema for multicz.
2
5
 
3
6
  The config lives in ``multicz.toml`` at the repo root. It declares one or more
@@ -48,6 +51,29 @@ class FileKey(BaseModel):
48
51
  key: str | None = None
49
52
 
50
53
 
54
+ class Mirror(FileKey):
55
+ """A mirror target with optional changelog customization.
56
+
57
+ Inherits ``file`` / ``key`` from :class:`FileKey`. Adds two optional
58
+ fields that customize how the resulting cascade line is rendered in
59
+ the *downstream* component's CHANGELOG when this mirror triggers a
60
+ bump there:
61
+
62
+ * ``changelog_section`` - routes the cascade line to a specific
63
+ section. Can be the title of an existing section
64
+ (``Features``, ``Fixes``, ``Dependencies``, ...) or any custom
65
+ name. When unset, the line falls under the project-level
66
+ ``cascade_section_title`` (default: ``Dependencies``).
67
+ * ``changelog_format`` - overrides the default cascade phrase for
68
+ this specific mirror. The template accepts ``{upstream}`` and
69
+ ``{upstream_version}`` placeholders. When unset, the project-level
70
+ ``cascade_changelog_format`` applies.
71
+ """
72
+
73
+ changelog_section: str | None = None
74
+ changelog_format: str | None = None
75
+
76
+
51
77
  class Artifact(BaseModel):
52
78
  """A build artifact a component produces.
53
79
 
@@ -84,7 +110,7 @@ class DebianSettings(BaseModel):
84
110
 
85
111
  The component's version is read from the topmost stanza of
86
112
  ``changelog`` (default ``debian/changelog``) and a new stanza is
87
- *prepended* on every bump older stanzas are never rewritten.
113
+ *prepended* on every bump - older stanzas are never rewritten.
88
114
  """
89
115
 
90
116
  model_config = ConfigDict(extra="forbid")
@@ -103,7 +129,7 @@ class Component(BaseModel):
103
129
  paths: list[str] = Field(min_length=1)
104
130
  exclude_paths: list[str] = Field(default_factory=list)
105
131
  bump_files: list[FileKey] = Field(default_factory=list)
106
- mirrors: list[FileKey] = Field(default_factory=list)
132
+ mirrors: list[Mirror] = Field(default_factory=list)
107
133
  # depends_on lists upstream components whose bump should cascade into
108
134
  # this one. ``triggers`` is kept as a parse-time alias for users who
109
135
  # already wrote it that way; both names normalise to ``depends_on``.
@@ -310,12 +336,12 @@ class Config(BaseModel):
310
336
  f"component name {name!r} is too long "
311
337
  f"(max {COMPONENT_NAME_MAX_LEN} chars). Component names "
312
338
  "appear in git tags, file paths, JSON output, and release "
313
- "notes keep them short."
339
+ "notes - keep them short."
314
340
  )
315
341
  if not COMPONENT_NAME_RE.match(name):
316
342
  raise ValueError(
317
343
  f"invalid component name {name!r}: must match "
318
- f"{COMPONENT_NAME_RE.pattern} "
344
+ f"{COMPONENT_NAME_RE.pattern} - "
319
345
  "no slashes, colons, spaces, or path-like characters; "
320
346
  "must start and end with a letter or digit. Component "
321
347
  "names land in git tags, file paths, JSON output, and "