multicz 0.1.0__py3-none-any.whl

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/cli.py ADDED
@@ -0,0 +1,1677 @@
1
+ """Command line interface for multicz."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import typer
10
+ from packaging.version import Version
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from . import __version__
15
+ from .changelog import render_body, update_changelog_file
16
+ from .commits import (
17
+ DEFAULT_TYPES,
18
+ commits_in_range,
19
+ commits_since,
20
+ latest_stable_tag,
21
+ latest_tag,
22
+ previous_stable_tag,
23
+ previous_tag,
24
+ tag_prefix,
25
+ validate_message,
26
+ )
27
+ from .components import ComponentMatcher
28
+ from .config import CONFIG_FILENAME, Component, find_config, load_config
29
+ from .debian import (
30
+ drop_prerelease_stanzas,
31
+ format_debian_version,
32
+ prepend_stanza,
33
+ render_stanza,
34
+ )
35
+ from .discovery import discover_components, render_config
36
+ from .planner import (
37
+ CommitReason,
38
+ MirrorReason,
39
+ NonConventionalCommitsError,
40
+ TriggerReason,
41
+ build_plan,
42
+ )
43
+ from .state import (
44
+ STATE_SCHEMA_VERSION,
45
+ ComponentState,
46
+ State,
47
+ load_state,
48
+ now_iso,
49
+ write_state,
50
+ )
51
+ from .validation import validate as run_validation
52
+ from .writers import read_value, write_value
53
+
54
+ app = typer.Typer(
55
+ name="multicz",
56
+ help="Multi-component versioning for monorepos.",
57
+ no_args_is_help=True,
58
+ add_completion=False,
59
+ )
60
+ console = Console()
61
+ err = Console(stderr=True)
62
+
63
+
64
+ _BARE_CONFIG = """\
65
+ # multicz.toml — generic stub. Edit paths and bump_files to match your repo.
66
+ # Run `multicz init` (without --bare) to scan the working tree and generate
67
+ # a config tailored to the manifests it actually contains.
68
+
69
+ [project]
70
+ commit_convention = "conventional"
71
+ tag_format = "{component}-v{version}"
72
+ initial_version = "0.1.0"
73
+
74
+ [components.app]
75
+ paths = ["src/**", "pyproject.toml"]
76
+ bump_files = [
77
+ { file = "pyproject.toml", key = "project.version" },
78
+ ]
79
+ changelog = "CHANGELOG.md"
80
+ """
81
+
82
+
83
+ def _version_callback(value: bool) -> None:
84
+ if value:
85
+ console.print(f"multicz {__version__}")
86
+ raise typer.Exit()
87
+
88
+
89
+ @app.callback()
90
+ def main(
91
+ version: bool = typer.Option(
92
+ False, "--version", "-V", callback=_version_callback, is_eager=True,
93
+ help="Show the multicz version and exit.",
94
+ ),
95
+ ) -> None:
96
+ """Multi-component versioning for monorepos."""
97
+
98
+
99
+ @app.command()
100
+ def init(
101
+ path: Path = typer.Option(
102
+ None, "--path", "-p", help="Directory to write multicz.toml into.",
103
+ file_okay=False, dir_okay=True, resolve_path=True,
104
+ ),
105
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite an existing config."),
106
+ bare: bool = typer.Option(
107
+ False, "--bare",
108
+ help="Skip auto-discovery and write a generic single-component stub.",
109
+ ),
110
+ print_only: bool = typer.Option(
111
+ False, "--print",
112
+ help="Print the rendered config to stdout instead of writing a file. "
113
+ "Composes with --bare. Useful for `multicz init --print > file`.",
114
+ ),
115
+ detect: bool = typer.Option(
116
+ False, "--detect",
117
+ help="Scan and summarise detected components without rendering the "
118
+ "full TOML. Use --output json for machine-readable output.",
119
+ ),
120
+ output: str = typer.Option(
121
+ "text", "--output", "-o",
122
+ help="text | json (only meaningful with --detect)",
123
+ ),
124
+ ) -> None:
125
+ """Generate a multicz.toml tailored to the working tree.
126
+
127
+ By default the working tree is scanned for ``pyproject.toml``,
128
+ ``charts/*/Chart.yaml``, ``package.json``, ``Cargo.toml``, ``go.mod``,
129
+ ``gradle.properties`` and ``debian/changelog``; one component is
130
+ emitted per detected manifest. ``--bare`` writes a generic
131
+ single-component stub instead — useful when bootstrapping a brand
132
+ new repo.
133
+
134
+ \b
135
+ Three output modes:
136
+ (default) write multicz.toml to disk
137
+ --print render to stdout (composes with --bare)
138
+ --detect summary of what would be detected, no full config rendered
139
+ """
140
+ if detect and bare:
141
+ err.print("[red]--detect cannot be combined with --bare[/]")
142
+ raise typer.Exit(code=1)
143
+ if detect and print_only:
144
+ err.print("[red]--detect cannot be combined with --print[/]")
145
+ raise typer.Exit(code=1)
146
+
147
+ target_dir = path or Path.cwd()
148
+
149
+ # Compute components (or skip when --bare)
150
+ components: dict[str, Component] | None = None
151
+ if not bare:
152
+ components = discover_components(target_dir)
153
+ if not components:
154
+ err.print(
155
+ "[yellow]no manifests detected[/] under "
156
+ f"{target_dir} (looked for pyproject.toml, "
157
+ "charts/*/Chart.yaml, package.json, Cargo.toml, go.mod, "
158
+ "gradle.properties, debian/changelog). Use [bold]--bare[/] "
159
+ "to write a generic stub."
160
+ )
161
+ raise typer.Exit(code=1)
162
+
163
+ if detect:
164
+ # `components` is non-None here because --detect+--bare is rejected
165
+ assert components is not None
166
+ if output == "json":
167
+ payload = {
168
+ name: {
169
+ "paths": list(c.paths),
170
+ "format": c.format,
171
+ "bump_files": [
172
+ {"file": str(b.file), "key": b.key}
173
+ for b in c.bump_files
174
+ ],
175
+ "mirrors": [
176
+ {"file": str(m.file), "key": m.key}
177
+ for m in c.mirrors
178
+ ],
179
+ "changelog": str(c.changelog) if c.changelog else None,
180
+ }
181
+ for name, c in components.items()
182
+ }
183
+ console.print_json(data=payload)
184
+ return
185
+ console.print(f"[bold]Detected {len(components)} component(s):[/]")
186
+ for name, comp in components.items():
187
+ primary = comp.bump_files[0].file if comp.bump_files else None
188
+ line = f" • [bold]{name}[/]"
189
+ if primary is not None:
190
+ line += f" [dim]({primary.as_posix()})[/]"
191
+ elif comp.format == "debian":
192
+ line += " [dim](debian/changelog)[/]"
193
+ else:
194
+ line += " [dim](tag-driven)[/]"
195
+ if comp.format != "default":
196
+ line += f" [yellow]format={comp.format}[/]"
197
+ if comp.mirrors:
198
+ targets = ", ".join(
199
+ f"{m.file.as_posix()}:{m.key}" if m.key else m.file.as_posix()
200
+ for m in comp.mirrors
201
+ )
202
+ line += f"\n mirrors → {targets}"
203
+ console.print(line)
204
+ return
205
+
206
+ content = _BARE_CONFIG if bare else render_config(components) # type: ignore[arg-type]
207
+
208
+ if print_only:
209
+ # `print` (vs console.print) avoids any rich markup so the output
210
+ # is byte-for-byte usable for redirection.
211
+ print(content, end="")
212
+ return
213
+
214
+ target = target_dir / CONFIG_FILENAME
215
+ if target.exists() and not force:
216
+ err.print(f"[red]{target} already exists.[/] Use --force to overwrite.")
217
+ raise typer.Exit(code=1)
218
+ target.write_text(content, encoding="utf-8")
219
+ console.print(f"[green]wrote[/] {target}{' [dim](bare stub)[/]' if bare else ''}")
220
+ if components is not None:
221
+ console.print(f"[dim]detected:[/] {', '.join(components)}")
222
+
223
+
224
+ def _load() -> tuple[Path, object]:
225
+ from pydantic import ValidationError
226
+ try:
227
+ config_path = find_config()
228
+ except FileNotFoundError as exc:
229
+ err.print(f"[red]{exc}[/]")
230
+ raise typer.Exit(code=1) from exc
231
+ try:
232
+ return config_path.parent, load_config(config_path)
233
+ except ValidationError as exc:
234
+ err.print(f"[red]invalid {config_path}:[/]")
235
+ for error in exc.errors():
236
+ loc = " -> ".join(str(p) for p in error["loc"])
237
+ err.print(f" [yellow]{loc}[/]: {error['msg']}")
238
+ raise typer.Exit(code=1) from exc
239
+ except ValueError as exc:
240
+ err.print(f"[red]invalid {config_path}:[/] {exc}")
241
+ raise typer.Exit(code=1) from exc
242
+
243
+
244
+ def _parse_force_specs(specs: list[str], config) -> dict[str, str]:
245
+ """Parse ``--force <name>:<kind>`` flags into a dict.
246
+
247
+ Validates the component name and kind upfront so the user gets a
248
+ clear error before the planner runs.
249
+ """
250
+ valid_kinds = {"major", "minor", "patch"}
251
+ parsed: dict[str, str] = {}
252
+ for spec in specs or []:
253
+ if ":" not in spec:
254
+ err.print(
255
+ f"[red]invalid --force spec[/] {spec!r}: "
256
+ "expected NAME:KIND (e.g. api:patch)"
257
+ )
258
+ raise typer.Exit(code=1)
259
+ name, _, kind = spec.partition(":")
260
+ if name not in config.components:
261
+ err.print(f"[red]unknown component:[/] {name}")
262
+ raise typer.Exit(code=1)
263
+ if kind not in valid_kinds:
264
+ err.print(
265
+ f"[red]invalid kind[/] {kind!r}: "
266
+ "must be major, minor, or patch"
267
+ )
268
+ raise typer.Exit(code=1)
269
+ parsed[name] = kind
270
+ return parsed
271
+
272
+
273
+ def _append_step_summary(path: Path, lines: list[str]) -> None:
274
+ """Append a markdown block to ``path``.
275
+
276
+ Mirrors GitHub Actions' ``$GITHUB_STEP_SUMMARY`` semantics: each
277
+ step's content is appended; the runner concatenates everything into
278
+ the workflow's run-page summary. Safe to call from local shells —
279
+ the file is just a text file.
280
+ """
281
+ path.parent.mkdir(parents=True, exist_ok=True)
282
+ with path.open("a", encoding="utf-8") as fh:
283
+ fh.write("\n".join(lines))
284
+ fh.write("\n")
285
+
286
+
287
+ def _append_plan_summary(path: Path, plan_obj, *, header: str) -> None:
288
+ """Render a plan as a markdown summary and append it."""
289
+ lines = [f"## {header}", ""]
290
+ if not plan_obj:
291
+ lines.append("_No bumps pending._")
292
+ _append_step_summary(path, lines)
293
+ return
294
+
295
+ lines.extend([
296
+ "| component | current | next | kind |",
297
+ "|---|---|---|---|",
298
+ ])
299
+ for bump in plan_obj:
300
+ lines.append(
301
+ f"| `{bump.component}` | `{bump.current}` | "
302
+ f"`{bump.next}` | {bump.kind} |"
303
+ )
304
+ lines.append("")
305
+ for bump in plan_obj:
306
+ lines.append(
307
+ f"### `{bump.component}` — {bump.current} → {bump.next} "
308
+ f"({bump.kind})"
309
+ )
310
+ lines.append("")
311
+ for reason in bump.reasons:
312
+ lines.append(f"- {reason.summary()}")
313
+ lines.append("")
314
+ _append_step_summary(path, lines)
315
+
316
+
317
+ def _append_bump_summary(
318
+ path: Path,
319
+ applied: dict,
320
+ config,
321
+ git_summary: dict,
322
+ *,
323
+ dry_run: bool,
324
+ ) -> None:
325
+ """Render the applied bump (post-write) as a markdown summary."""
326
+ header = "Released" if not dry_run else "Would release"
327
+ lines = [f"## {header}", ""]
328
+ if not applied:
329
+ lines.append("_No bumps pending._")
330
+ _append_step_summary(path, lines)
331
+ return
332
+
333
+ lines.extend([
334
+ "| component | current | next | kind | tag |",
335
+ "|---|---|---|---|---|",
336
+ ])
337
+ tags = git_summary.get("tags") or []
338
+ tag_index = {t.split("-v", 1)[0] if "-v" in t else None: t for t in tags}
339
+ # Fall back to format string lookup when tag_format isn't `<comp>-v<ver>`.
340
+ for name, info in applied.items():
341
+ tag = tag_index.get(name) or "—"
342
+ for t in tags:
343
+ if config.tag_format_for(name).format(
344
+ component=name, version=info["next"]
345
+ ) == t:
346
+ tag = t
347
+ break
348
+ lines.append(
349
+ f"| `{name}` | `{info['current']}` | `{info['next']}` | "
350
+ f"{info['kind']} | `{tag}` |"
351
+ )
352
+ lines.append("")
353
+ if git_summary.get("commit"):
354
+ lines.append(f"**Release commit:** `{git_summary['commit'][:12]}`")
355
+ if tags:
356
+ lines.append(f"**Tags created:** {', '.join(f'`{t}`' for t in tags)}")
357
+ if git_summary.get("pushed"):
358
+ lines.append("**Pushed:** yes")
359
+ if git_summary.get("signed_commit"):
360
+ lines.append("**Signed commit:** yes")
361
+ if git_summary.get("signed_tags"):
362
+ lines.append("**Signed tags:** yes")
363
+ _append_step_summary(path, lines)
364
+
365
+
366
+ def _build_plan_or_exit(repo, config, **kwargs):
367
+ """Wrap build_plan() and surface NonConventionalCommitsError as a clean
368
+ typer.Exit instead of a raw traceback."""
369
+ try:
370
+ return build_plan(repo, config, **kwargs)
371
+ except NonConventionalCommitsError as exc:
372
+ err.print(
373
+ f"[red]✗ {len(exc.offenders)} non-conventional commit(s) "
374
+ "blocking the plan[/] [dim](unknown_commit_policy='error')[/]"
375
+ )
376
+ for sha, subject in exc.offenders:
377
+ err.print(f" - {sha[:7]}: {subject}")
378
+ err.print(
379
+ "\n[dim]Either rewrite their headers as conventional commits "
380
+ "(`git rebase -i`), or set "
381
+ "[bold]unknown_commit_policy = \"ignore\"[/] (or "
382
+ "[bold]\"patch\"[/]) in [project].[/]"
383
+ )
384
+ raise typer.Exit(code=1) from exc
385
+
386
+
387
+ @app.command()
388
+ def status(
389
+ since: str = typer.Option(
390
+ None, "--since",
391
+ help="Override the commit window: use this ref instead of each "
392
+ "component's last tag. Useful for PR previews "
393
+ "(--since origin/main).",
394
+ ),
395
+ ) -> None:
396
+ """Brief summary of pending bumps (alias of ``plan`` without reasons)."""
397
+ repo, config = _load()
398
+ plan_obj = _build_plan_or_exit(repo, config, since=since)
399
+ if not plan_obj:
400
+ console.print("[dim]no bumps pending[/]")
401
+ return
402
+
403
+ table = Table(show_header=True, header_style="bold")
404
+ table.add_column("component")
405
+ table.add_column("current")
406
+ table.add_column("→")
407
+ table.add_column("next")
408
+ table.add_column("kind")
409
+ table.add_column("reasons", overflow="fold")
410
+ for bump in plan_obj:
411
+ table.add_row(
412
+ bump.component,
413
+ str(bump.current),
414
+ "→",
415
+ str(bump.next),
416
+ bump.kind,
417
+ "\n".join(bump.reason_summaries()),
418
+ )
419
+ console.print(table)
420
+
421
+
422
+ @app.command(name="plan")
423
+ def plan_cmd(
424
+ output: str = typer.Option("text", "--output", "-o", help="text | json"),
425
+ pre: str = typer.Option(
426
+ None, "--pre",
427
+ help="Plan as if invoked with `bump --pre <label>`.",
428
+ ),
429
+ finalize: bool = typer.Option(
430
+ False, "--finalize",
431
+ help="Plan as if invoked with `bump --finalize`.",
432
+ ),
433
+ since: str = typer.Option(
434
+ None, "--since",
435
+ help="Override the commit window: use this ref instead of each "
436
+ "component's last tag. Useful for PR previews "
437
+ "(--since origin/main) or migration scenarios "
438
+ "(--since HEAD~10).",
439
+ ),
440
+ force: list[str] = typer.Option(
441
+ None, "--force",
442
+ help="Force-bump <name>:<kind>. Repeatable. Bypasses commit "
443
+ "detection — use for manual rebuilds (CVE base image refresh, "
444
+ "weekly artefact rebuild, …).",
445
+ ),
446
+ summary: Path = typer.Option(
447
+ None, "--summary",
448
+ help="Append a markdown summary of the plan to this file. "
449
+ "Wire to $GITHUB_STEP_SUMMARY in CI to get a release "
450
+ "preview at the top of the workflow run page.",
451
+ dir_okay=False,
452
+ ),
453
+ ) -> None:
454
+ """Print the bump plan: every component that would change, the new
455
+ version, and the *reasons* (conventional commits, trigger cascades,
456
+ mirror cascades) that drove each decision.
457
+
458
+ The text form is grouped per component for visual scanning; the JSON
459
+ form (``--output json``) is the machine-readable shape suited for CI:
460
+
461
+ \b
462
+ {
463
+ "bumps": {
464
+ "api": {
465
+ "current": "1.2.0",
466
+ "next": "1.3.0",
467
+ "kind": "minor",
468
+ "reasons": [
469
+ {"kind": "commit", "sha": "abc1234", "type": "feat",
470
+ "subject": "add login", "files": ["src/auth.py"], ...}
471
+ ]
472
+ }
473
+ }
474
+ }
475
+ """
476
+ if pre is not None and finalize:
477
+ err.print("[red]--pre and --finalize are mutually exclusive[/]")
478
+ raise typer.Exit(code=1)
479
+
480
+ repo, config = _load()
481
+ forced = _parse_force_specs(force, config) if force else {}
482
+ plan_obj = _build_plan_or_exit(
483
+ repo, config, pre=pre, finalize=finalize, since=since, force=forced or None
484
+ )
485
+
486
+ if summary is not None:
487
+ _append_plan_summary(summary, plan_obj, header="Release plan")
488
+
489
+ if output == "json":
490
+ payload = {
491
+ "schema_version": 1,
492
+ "bumps": {
493
+ bump.component: {
494
+ "current_version": str(bump.current),
495
+ "next_version": bump.next,
496
+ "kind": bump.kind,
497
+ "reasons": [r.to_dict() for r in bump.reasons],
498
+ "artifacts": [
499
+ a.render(component=bump.component, version=bump.next)
500
+ for a in config.components[bump.component].artifacts
501
+ ],
502
+ }
503
+ for bump in plan_obj
504
+ },
505
+ }
506
+ console.print_json(data=payload)
507
+ return
508
+
509
+ if not plan_obj:
510
+ console.print("[dim]no bumps pending[/]")
511
+ return
512
+
513
+ for bump in plan_obj:
514
+ header = (
515
+ f"[bold]{bump.component}[/]: "
516
+ f"{bump.current} → {bump.next} "
517
+ f"[cyan]({bump.kind})[/]"
518
+ )
519
+ console.print(header)
520
+ for reason in bump.reasons:
521
+ console.print(f" • {reason.summary()}")
522
+ console.print()
523
+
524
+
525
+ @app.command()
526
+ def state(
527
+ output: str = typer.Option("text", "--output", "-o", help="text | json"),
528
+ ) -> None:
529
+ """Inspect the optional state file written after each successful bump.
530
+
531
+ The state file is opt-in via ``[project].state_file = "..."``. It
532
+ records the per-component version, the expected tag name (when
533
+ ``--tag`` was used at bump time), the SHA the bump was computed
534
+ against, and a UTC timestamp.
535
+ """
536
+ repo, config = _load()
537
+ if config.project.state_file is None:
538
+ err.print(
539
+ "[red]no state_file configured[/] — set "
540
+ "[bold][project].state_file[/] in multicz.toml"
541
+ )
542
+ raise typer.Exit(code=1)
543
+
544
+ path = repo / config.project.state_file
545
+ state_obj = load_state(path)
546
+ if state_obj is None:
547
+ if output == "json":
548
+ console.print_json(data=None)
549
+ else:
550
+ console.print(
551
+ f"[dim]{config.project.state_file} not yet written[/]"
552
+ )
553
+ return
554
+
555
+ if output == "json":
556
+ console.print_json(data=state_obj.to_dict())
557
+ return
558
+
559
+ console.print(
560
+ f"[bold]state[/] {config.project.state_file} "
561
+ f"(schema v{state_obj.version})"
562
+ )
563
+ console.print(f" git_head: {state_obj.git_head_short or state_obj.git_head}")
564
+ console.print(f" timestamp: {state_obj.timestamp}")
565
+ for name, comp in state_obj.components.items():
566
+ line = f" [bold]{name}[/]: {comp.version}"
567
+ if comp.tag:
568
+ line += f" [dim]({comp.tag})[/]"
569
+ console.print(line)
570
+
571
+
572
+ @app.command()
573
+ def changed(
574
+ since: str = typer.Option(
575
+ None, "--since",
576
+ help="Reference to compare against (e.g. origin/main, HEAD~5). "
577
+ "When omitted, each component is compared against its own "
578
+ "last tag — same window as the planner uses for bumps.",
579
+ ),
580
+ output: str = typer.Option(
581
+ "text", "--output", "-o", help="text | json",
582
+ ),
583
+ ) -> None:
584
+ """List components whose files changed since the given reference.
585
+
586
+ Designed for CI matrix gating: only run tests/builds for components
587
+ that actually changed.
588
+
589
+ \b
590
+ GitHub Actions example:
591
+ jobs:
592
+ detect:
593
+ outputs:
594
+ changed: ${{ steps.c.outputs.list }}
595
+ steps:
596
+ - id: c
597
+ run: echo "list=$(multicz changed --since origin/main \\
598
+ --output json | jq -c .changed)" >> $GITHUB_OUTPUT
599
+ test:
600
+ needs: detect
601
+ strategy:
602
+ matrix:
603
+ component: ${{ fromJson(needs.detect.outputs.changed) }}
604
+
605
+ Without --since, the answer is per-component (same window as the
606
+ planner). With --since, every component shares the reference —
607
+ ideal for "what changed in this PR vs main".
608
+
609
+ Release commits matching ``project.release_commit_pattern`` are
610
+ filtered out so a previous ``multicz bump --commit`` doesn't keep
611
+ flagging components as changed forever.
612
+ """
613
+ import re
614
+
615
+ repo, config = _load()
616
+ matcher = ComponentMatcher(config.components)
617
+ release_re = re.compile(config.project.release_commit_pattern)
618
+
619
+ changed_list: list[str] = []
620
+ unchanged_list: list[str] = []
621
+ for name in config.components:
622
+ if since is None:
623
+ prefix = tag_prefix(config.tag_format_for(name), name)
624
+ ref: str | None = latest_tag(repo, prefix)
625
+ else:
626
+ ref = since
627
+ commits = commits_since(repo, ref)
628
+ owns_change = False
629
+ for c in commits:
630
+ if release_re.match(_commit_header(c)):
631
+ continue
632
+ for f in c.files:
633
+ if matcher.match(f) == name:
634
+ owns_change = True
635
+ break
636
+ if owns_change:
637
+ break
638
+ if owns_change:
639
+ changed_list.append(name)
640
+ else:
641
+ unchanged_list.append(name)
642
+
643
+ if output == "json":
644
+ console.print_json(
645
+ data={"changed": changed_list, "unchanged": unchanged_list}
646
+ )
647
+ return
648
+
649
+ for name in changed_list:
650
+ print(name)
651
+
652
+
653
+ @app.command()
654
+ def artifacts(
655
+ component: str = typer.Argument(
656
+ None,
657
+ help="Component to render artifacts for. Required unless --all is set.",
658
+ ),
659
+ all_: bool = typer.Option(
660
+ False, "--all",
661
+ help="Render artifacts for every component.",
662
+ ),
663
+ version_override: str = typer.Option(
664
+ None, "--version",
665
+ help="Render with this explicit version instead of the current one.",
666
+ ),
667
+ output: str = typer.Option(
668
+ "text", "--output", "-o", help="text | json",
669
+ ),
670
+ ) -> None:
671
+ """List the artifacts a component (or all components) would publish.
672
+
673
+ multicz does not build or push artifacts itself; this command surfaces
674
+ the rendered refs from the [components.<name>.artifacts] declarations
675
+ so CI scripts can drive `docker build/push`, `helm package/push`, etc.
676
+
677
+ \b
678
+ Default version: the current version (from the latest tag, or the
679
+ primary bump_file). Pass --version X to render against an explicit
680
+ target (typically what `multicz bump --output json` would produce).
681
+ """
682
+ if component is None and not all_:
683
+ err.print("[red]specify a component or --all[/]")
684
+ raise typer.Exit(code=1)
685
+ if component is not None and all_:
686
+ err.print("[red]--all is exclusive with a component name[/]")
687
+ raise typer.Exit(code=1)
688
+
689
+ repo, config = _load()
690
+ targets = list(config.components) if all_ else [component]
691
+ payload: dict[str, dict] = {}
692
+ for name in targets:
693
+ if name not in config.components:
694
+ err.print(f"[red]unknown component:[/] {name}")
695
+ raise typer.Exit(code=1)
696
+ comp = config.components[name]
697
+ if version_override is not None:
698
+ version = version_override
699
+ else:
700
+ from .planner import _current_version
701
+ version = str(_current_version(repo, config, name))
702
+ rendered = [
703
+ a.render(component=name, version=version) for a in comp.artifacts
704
+ ]
705
+ payload[name] = {"version": version, "artifacts": rendered}
706
+
707
+ if output == "json":
708
+ console.print_json(data=payload)
709
+ return
710
+
711
+ for name, data in payload.items():
712
+ if not data["artifacts"]:
713
+ console.print(f"[dim]{name}: no artifacts declared[/]")
714
+ continue
715
+ console.print(f"[bold]{name}[/] ({data['version']})")
716
+ for a in data["artifacts"]:
717
+ console.print(f" [{a['type']}] {a['ref']}")
718
+
719
+
720
+ @app.command(name="release-notes")
721
+ def release_notes_cmd(
722
+ component: str = typer.Argument(
723
+ None,
724
+ help="Component to render notes for. Required unless --all or --tag is set.",
725
+ ),
726
+ all_: bool = typer.Option(
727
+ False, "--all",
728
+ help="Render notes for every component with a planned bump.",
729
+ ),
730
+ tag: str = typer.Option(
731
+ None, "--tag",
732
+ help="Render notes for a past release tag (e.g. api-v1.3.0).",
733
+ ),
734
+ output: str = typer.Option(
735
+ "md", "--output", "-o", help="md | text | json",
736
+ ),
737
+ ) -> None:
738
+ """Generate release notes for an upcoming bump or a past tag.
739
+
740
+ Designed for piping into ``gh release create`` or pasting into a
741
+ GitHub/GitLab Release UI: no file is written, the output IS the
742
+ notes.
743
+
744
+ \b
745
+ Default (notes for the upcoming bump — same set as `plan`):
746
+ multicz release-notes api
747
+ multicz release-notes --all
748
+
749
+ Retrospective (what shipped in a tagged release):
750
+ multicz release-notes --tag api-v1.3.0
751
+
752
+ Stable release tags look at commits since the previous *stable*
753
+ tag (so v1.3.0 lists everything since v1.2.0, not just since
754
+ v1.3.0-rc.2). Pre-release tags use the immediately previous tag
755
+ so each rc shows only the delta.
756
+ """
757
+ if tag is None and not all_ and component is None:
758
+ err.print(
759
+ "[red]specify a component, --all, or --tag <tag>[/]"
760
+ )
761
+ raise typer.Exit(code=1)
762
+ if tag is not None and (all_ or component is not None):
763
+ err.print(
764
+ "[red]--tag is exclusive with a component name and --all[/]"
765
+ )
766
+ raise typer.Exit(code=1)
767
+
768
+ repo, config = _load()
769
+ matcher = ComponentMatcher(config.components)
770
+
771
+ sections: list[dict] = []
772
+
773
+ if tag is not None:
774
+ owner = _component_for_tag(config, tag)
775
+ if owner is None:
776
+ err.print(
777
+ f"[red]tag {tag!r} doesn't match any component's tag_format[/]"
778
+ )
779
+ raise typer.Exit(code=1)
780
+ prefix = tag_prefix(config.tag_format_for(owner), owner)
781
+ target_version = Version(tag[len(prefix):])
782
+ if target_version.is_prerelease:
783
+ prev = previous_tag(repo, prefix, tag)
784
+ else:
785
+ prev = previous_stable_tag(repo, prefix, tag)
786
+ commits = _filtered_commits_in_range(
787
+ owner, config, repo, matcher, since=prev, end=tag
788
+ )
789
+ sections.append({
790
+ "component": owner,
791
+ "from": prev,
792
+ "from_version": prev[len(prefix):] if prev else None,
793
+ "to_version": str(target_version),
794
+ "commits": commits,
795
+ })
796
+ else:
797
+ plan_obj = _build_plan_or_exit(repo, config)
798
+ if all_:
799
+ targets = list(plan_obj.bumps)
800
+ else:
801
+ if component not in config.components:
802
+ err.print(f"[red]unknown component:[/] {component}")
803
+ raise typer.Exit(code=1)
804
+ targets = [component]
805
+
806
+ for name in targets:
807
+ bump = plan_obj.bumps.get(name)
808
+ if bump is None:
809
+ if not all_:
810
+ console.print(
811
+ f"[dim]no pending bump for {name}[/]"
812
+ )
813
+ return
814
+ continue
815
+ commits = _component_relevant_commits(name, config, repo, matcher)
816
+ sections.append({
817
+ "component": name,
818
+ "from": None,
819
+ "from_version": str(bump.current),
820
+ "to_version": bump.next,
821
+ "commits": commits,
822
+ })
823
+
824
+ if not sections:
825
+ if output == "json":
826
+ console.print_json(data={"sections": []})
827
+ else:
828
+ console.print("[dim]nothing to release[/]")
829
+ return
830
+
831
+ if output == "json":
832
+ console.print_json(data={
833
+ "sections": [
834
+ {
835
+ "component": s["component"],
836
+ "from_version": s["from_version"],
837
+ "to_version": s["to_version"],
838
+ "commits": [
839
+ {
840
+ "sha": c.sha,
841
+ "type": c.type,
842
+ "scope": c.scope,
843
+ "breaking": c.breaking,
844
+ "subject": c.subject,
845
+ }
846
+ for c in s["commits"]
847
+ ],
848
+ }
849
+ for s in sections
850
+ ]
851
+ })
852
+ return
853
+
854
+ if output == "text":
855
+ for s in sections:
856
+ range_label = (
857
+ f"{s['from_version']} → {s['to_version']}"
858
+ if s["from_version"]
859
+ else s["to_version"]
860
+ )
861
+ console.print(f"[bold]{s['component']}[/] {range_label}")
862
+ for c in s["commits"]:
863
+ bang = "!" if c.breaking else ""
864
+ scope = f"({c.scope})" if c.scope else ""
865
+ console.print(
866
+ f" - {c.type}{scope}{bang}: {c.subject} "
867
+ f"[dim]({c.sha[:7]})[/]"
868
+ )
869
+ console.print()
870
+ return
871
+
872
+ # md (default)
873
+ chunks: list[str] = []
874
+ multi = len(sections) > 1 or all_
875
+ for s in sections:
876
+ body = render_body(
877
+ s["commits"],
878
+ sections=config.project.changelog_sections,
879
+ breaking_title=config.project.breaking_section_title,
880
+ other_title=config.project.other_section_title,
881
+ )
882
+ if multi:
883
+ range_label = (
884
+ f"{s['from_version']} → {s['to_version']}"
885
+ if s["from_version"]
886
+ else s["to_version"]
887
+ )
888
+ chunks.append(f"## {s['component']} {range_label}\n\n{body}".rstrip() + "\n")
889
+ else:
890
+ chunks.append(body.rstrip() + "\n")
891
+ print("\n".join(chunks).rstrip() + "\n")
892
+
893
+
894
+ def _component_for_tag(config, tag: str) -> str | None:
895
+ """Find the component whose tag_format produces a prefix that ``tag`` starts with."""
896
+ best_match: tuple[int, str] | None = None
897
+ for name in config.components:
898
+ prefix = tag_prefix(config.tag_format_for(name), name)
899
+ if tag.startswith(prefix):
900
+ # Prefer the longest match (more specific prefix wins)
901
+ score = len(prefix)
902
+ if best_match is None or score > best_match[0]:
903
+ best_match = (score, name)
904
+ return best_match[1] if best_match else None
905
+
906
+
907
+ def _filtered_commits_in_range(
908
+ name: str, config, repo: Path, matcher, *, since: str | None, end: str
909
+ ):
910
+ """Same filtering as _component_relevant_commits but for a custom range."""
911
+ import re
912
+
913
+ release_re = re.compile(config.project.release_commit_pattern)
914
+ ignored = config.ignored_types_for(name)
915
+ return [
916
+ c
917
+ for c in commits_in_range(repo, since, end)
918
+ if c.is_conventional
919
+ and not release_re.match(_commit_header(c))
920
+ and c.type.lower() not in ignored
921
+ and any(matcher.match(f) == name for f in c.files)
922
+ ]
923
+
924
+
925
+ @app.command()
926
+ def explain(
927
+ component: str = typer.Argument(..., help="Component to explain."),
928
+ since: str = typer.Option(
929
+ None, "--since",
930
+ help="Override the commit window for this explanation.",
931
+ ),
932
+ ) -> None:
933
+ """Detailed breakdown of why ``component`` is in the bump plan.
934
+
935
+ Lists every reason with the structured fields: for commits, the SHA,
936
+ type, scope, breaking marker, subject, and the changed files that
937
+ actually matched the component's paths; for trigger/mirror cascades,
938
+ the upstream component and the file/key that propagated.
939
+ """
940
+ repo, config = _load()
941
+ if component not in config.components:
942
+ err.print(f"[red]unknown component:[/] {component}")
943
+ raise typer.Exit(code=1)
944
+
945
+ plan_obj = _build_plan_or_exit(repo, config, since=since)
946
+ bump = plan_obj.bumps.get(component)
947
+ if bump is None:
948
+ console.print(
949
+ f"[bold]{component}[/]: [dim]no bump pending — "
950
+ "no relevant commits since the last tag[/]"
951
+ )
952
+ return
953
+
954
+ console.print(f"[bold]Component:[/] {component}")
955
+ console.print(f" Current version: {bump.current}")
956
+ console.print(
957
+ f" Next version: {bump.next} [cyan]({bump.kind})[/]"
958
+ )
959
+ if bump.pre:
960
+ console.print(f" Pre-release: {bump.pre}")
961
+ if bump.finalize:
962
+ console.print(" Finalize: yes")
963
+ console.print()
964
+ console.print("[bold]Reasons:[/]")
965
+ for index, reason in enumerate(bump.reasons, start=1):
966
+ if isinstance(reason, CommitReason):
967
+ console.print(f" {index}. {reason.summary()}")
968
+ console.print(f" SHA: {reason.sha}")
969
+ scope = f"({reason.scope})" if reason.scope else ""
970
+ console.print(f" Type: {reason.type}{scope} → {reason.bump_kind}")
971
+ if reason.original_kind is not None:
972
+ console.print(
973
+ f" [yellow]Demoted from {reason.original_kind} "
974
+ "(bump_policy='scoped', different scope)[/]"
975
+ )
976
+ if reason.breaking:
977
+ console.print(" Breaking: yes")
978
+ console.print(" Files matched in this component:")
979
+ for path in reason.files:
980
+ console.print(f" - {path}")
981
+ elif isinstance(reason, TriggerReason):
982
+ console.print(f" {index}. {reason.summary()}")
983
+ console.print(f" Upstream: {reason.upstream}")
984
+ console.print(f" Upstream kind: {reason.upstream_kind}")
985
+ elif isinstance(reason, MirrorReason):
986
+ console.print(f" {index}. {reason.summary()}")
987
+ console.print(f" Upstream: {reason.upstream}")
988
+ target = reason.file
989
+ if reason.key:
990
+ target += f":{reason.key}"
991
+ console.print(f" Wrote: {target}")
992
+ else: # ManualReason
993
+ console.print(f" {index}. {reason.summary()}")
994
+
995
+
996
+ def _git(repo: Path, *args: str) -> str:
997
+ result = subprocess.run(
998
+ ["git", *args], cwd=repo, capture_output=True, text=True
999
+ )
1000
+ if result.returncode != 0:
1001
+ err.print(
1002
+ f"[red]git {' '.join(args)} failed ({result.returncode}):[/] "
1003
+ f"{result.stderr.strip()}"
1004
+ )
1005
+ raise typer.Exit(code=1)
1006
+ return result.stdout
1007
+
1008
+
1009
+ def _resolve_maintainer(repo: Path, configured: str | None) -> str:
1010
+ """Pick a Debian-format maintainer string ``Name <email>``.
1011
+
1012
+ Priority: explicit config -> ``Maintainer:`` line in ``debian/control``
1013
+ -> ``git config user.name`` + ``git config user.email`` -> placeholder.
1014
+ """
1015
+ if configured:
1016
+ return configured
1017
+ control = repo / "debian" / "control"
1018
+ if control.is_file():
1019
+ for line in control.read_text(encoding="utf-8").splitlines():
1020
+ if line.startswith("Maintainer:"):
1021
+ return line[len("Maintainer:"):].strip()
1022
+ name_proc = subprocess.run(
1023
+ ["git", "config", "user.name"],
1024
+ cwd=repo, capture_output=True, text=True,
1025
+ )
1026
+ email_proc = subprocess.run(
1027
+ ["git", "config", "user.email"],
1028
+ cwd=repo, capture_output=True, text=True,
1029
+ )
1030
+ name = name_proc.stdout.strip()
1031
+ email = email_proc.stdout.strip()
1032
+ if name and email:
1033
+ return f"{name} <{email}>"
1034
+ return "Unknown <unknown@example.com>"
1035
+
1036
+
1037
+ def _component_relevant_commits(
1038
+ name: str,
1039
+ config, # Config
1040
+ repo: Path,
1041
+ matcher: ComponentMatcher,
1042
+ *,
1043
+ since_stable: bool = False,
1044
+ ):
1045
+ """Conventional commits owning ``name`` since the component's last tag.
1046
+
1047
+ Filters applied:
1048
+
1049
+ * release commits matching ``project.release_commit_pattern`` are
1050
+ skipped so the chore(release) lines don't pollute the changelog.
1051
+ * commits whose type is in the component's effective ``ignored_types``
1052
+ (project + component, union) are skipped entirely.
1053
+
1054
+ When ``since_stable`` is True, the range starts at the previous
1055
+ *stable* tag instead — used by the ``consolidate`` and ``promote``
1056
+ finalize strategies.
1057
+ """
1058
+ import re
1059
+
1060
+ prefix = tag_prefix(config.tag_format_for(name), name)
1061
+ since = (
1062
+ latest_stable_tag(repo, prefix)
1063
+ if since_stable
1064
+ else latest_tag(repo, prefix)
1065
+ )
1066
+ release_re = re.compile(config.project.release_commit_pattern)
1067
+ ignored = config.ignored_types_for(name)
1068
+ return [
1069
+ c
1070
+ for c in commits_since(repo, since)
1071
+ if c.is_conventional
1072
+ and not release_re.match(_commit_header(c))
1073
+ and c.type.lower() not in ignored
1074
+ and any(matcher.match(f) == name for f in c.files)
1075
+ ]
1076
+
1077
+
1078
+ def _commit_header(commit) -> str:
1079
+ if commit.scope:
1080
+ return f"{commit.type}({commit.scope}): {commit.subject}"
1081
+ return f"{commit.type}: {commit.subject}"
1082
+
1083
+
1084
+ def _is_finalize(planned) -> bool:
1085
+ """A finalize op is any planned bump that turns a pre-release into a
1086
+ final version (either via --finalize or auto-finalize when --pre isn't
1087
+ set on a current pre-release)."""
1088
+ return planned.current.is_prerelease and planned.pre is None
1089
+
1090
+
1091
+ def _bump_debian(
1092
+ name: str,
1093
+ comp, # Component
1094
+ config, # Config
1095
+ repo: Path,
1096
+ matcher: ComponentMatcher,
1097
+ new_version: str,
1098
+ *,
1099
+ is_finalize: bool,
1100
+ dry_run: bool,
1101
+ written: list[Path],
1102
+ changelogs_updated: list[str],
1103
+ ) -> None:
1104
+ """Apply a debian-format bump: render and prepend a fresh stanza.
1105
+
1106
+ The git tag uses the semver form (``mypkg-v1.3.0-rc.1``) so multicz can
1107
+ re-read it later via :class:`packaging.version.Version`; only the
1108
+ *changelog file* gets the Debian-style ``~rc1`` rendering.
1109
+
1110
+ On finalize, the project's :attr:`finalize_strategy` controls whether
1111
+ the new stanza enumerates commits since the last RC (``annotate``) or
1112
+ since the last *stable* tag (``consolidate`` / ``promote``), and whether
1113
+ the now-superseded ``~rc*`` stanzas are removed from the file
1114
+ (``promote`` only).
1115
+ """
1116
+ settings = comp.debian
1117
+ if dry_run:
1118
+ return
1119
+
1120
+ strategy = config.project.finalize_strategy
1121
+ use_stable_since = is_finalize and strategy in {"consolidate", "promote"}
1122
+
1123
+ relevant = _component_relevant_commits(
1124
+ name, config, repo, matcher, since_stable=use_stable_since
1125
+ )
1126
+ debian_version = format_debian_version(
1127
+ new_version,
1128
+ debian_revision=settings.debian_revision,
1129
+ epoch=settings.epoch,
1130
+ )
1131
+ maintainer = _resolve_maintainer(repo, settings.maintainer)
1132
+ stanza = render_stanza(
1133
+ package=name,
1134
+ version=debian_version,
1135
+ distribution=settings.distribution,
1136
+ urgency=settings.urgency,
1137
+ commits=relevant,
1138
+ maintainer=maintainer,
1139
+ )
1140
+
1141
+ changelog_path = repo / settings.changelog
1142
+ existing = (
1143
+ changelog_path.read_text(encoding="utf-8")
1144
+ if changelog_path.is_file()
1145
+ else ""
1146
+ )
1147
+ if is_finalize and strategy == "promote":
1148
+ existing = drop_prerelease_stanzas(existing, new_version)
1149
+ changelog_path.parent.mkdir(parents=True, exist_ok=True)
1150
+ changelog_path.write_text(prepend_stanza(existing, stanza), encoding="utf-8")
1151
+ if changelog_path not in written:
1152
+ written.append(changelog_path)
1153
+ changelogs_updated.append(str(settings.changelog))
1154
+
1155
+
1156
+ def _release_commit_message(
1157
+ applied: dict[str, dict[str, str]],
1158
+ template: str,
1159
+ ) -> str:
1160
+ """Render the release commit message from a template with placeholders.
1161
+
1162
+ Available placeholders:
1163
+
1164
+ * ``{summary}`` — ``api 1.2.0 -> 1.3.0, chart 0.4.0 -> 0.5.0``
1165
+ * ``{components}`` — ``api v1.3.0, chart v0.5.0`` (versions only, ``v`` prefixed)
1166
+ * ``{body}`` — bullet list with kind annotations
1167
+ * ``{count}`` — number of components bumped
1168
+
1169
+ Literal ``{`` and ``}`` in a template should be escaped as ``{{`` / ``}}``.
1170
+ """
1171
+ summary = ", ".join(
1172
+ f"{name} {info['current']} -> {info['next']}"
1173
+ for name, info in applied.items()
1174
+ )
1175
+ components = ", ".join(
1176
+ f"{name} v{info['next']}" for name, info in applied.items()
1177
+ )
1178
+ body = "\n".join(
1179
+ f"- {name}: {info['current']} -> {info['next']} ({info['kind']})"
1180
+ for name, info in applied.items()
1181
+ )
1182
+ rendered = template.format(
1183
+ summary=summary,
1184
+ components=components,
1185
+ body=body,
1186
+ count=len(applied),
1187
+ )
1188
+ if not rendered.endswith("\n"):
1189
+ rendered += "\n"
1190
+ return rendered
1191
+
1192
+
1193
+ @app.command()
1194
+ def bump(
1195
+ dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Plan only, do not write."),
1196
+ component: list[str] = typer.Option(
1197
+ None, "--component", "-c", help="Restrict to these components (repeatable).",
1198
+ ),
1199
+ output: str = typer.Option("text", "--output", "-o", help="text | json"),
1200
+ commit: bool = typer.Option(
1201
+ False, "--commit", "-C",
1202
+ help="Stage written files and create a chore(release) commit.",
1203
+ ),
1204
+ tag: bool = typer.Option(
1205
+ False, "--tag", "-t",
1206
+ help="Create one annotated git tag per bumped component.",
1207
+ ),
1208
+ push: bool = typer.Option(
1209
+ False, "--push",
1210
+ help="Push the release commit and tags to origin (--follow-tags).",
1211
+ ),
1212
+ no_changelog: bool = typer.Option(
1213
+ False, "--no-changelog",
1214
+ help="Skip CHANGELOG.md updates even if components declare one.",
1215
+ ),
1216
+ pre: str = typer.Option(
1217
+ None, "--pre",
1218
+ help="Enter or continue a pre-release cycle with this label "
1219
+ "(e.g. 'rc', 'alpha', 'beta'). Increments the counter when "
1220
+ "the current version is already in the same cycle.",
1221
+ ),
1222
+ finalize: bool = typer.Option(
1223
+ False, "--finalize",
1224
+ help="Drop a pre-release suffix and ship the final version. Works "
1225
+ "even when there are no new commits since the rc tag.",
1226
+ ),
1227
+ commit_message: str = typer.Option(
1228
+ None, "--commit-message", "-m",
1229
+ help="Verbatim release commit message (overrides the project's "
1230
+ "release_commit_message template). Like 'git commit -m', no "
1231
+ "placeholders are expanded — the string is used as-is.",
1232
+ ),
1233
+ force: list[str] = typer.Option(
1234
+ None, "--force",
1235
+ help="Force-bump <name>:<kind>. Repeatable. Bypasses commit "
1236
+ "detection — use for manual rebuilds (e.g. weekly base "
1237
+ "image refresh: `--force api:patch`).",
1238
+ ),
1239
+ sign: bool = typer.Option(
1240
+ False, "--sign",
1241
+ help="GPG-sign the release commit AND tags. Equivalent to setting "
1242
+ "[project].sign_commits=true and [project].sign_tags=true. "
1243
+ "Either source enables signing; the CLI flag never disables.",
1244
+ ),
1245
+ summary: Path = typer.Option(
1246
+ None, "--summary",
1247
+ help="Append a markdown summary of what was released to this file. "
1248
+ "Wire to $GITHUB_STEP_SUMMARY in CI to surface the release on "
1249
+ "the workflow run page.",
1250
+ dir_okay=False,
1251
+ ),
1252
+ ) -> None:
1253
+ """Compute and apply the bump plan to all configured files."""
1254
+ if pre is not None and finalize:
1255
+ err.print("[red]--pre and --finalize are mutually exclusive[/]")
1256
+ raise typer.Exit(code=1)
1257
+ if commit_message is not None and not commit:
1258
+ err.print("[red]--commit-message requires --commit[/]")
1259
+ raise typer.Exit(code=1)
1260
+
1261
+ repo, config = _load()
1262
+ forced = _parse_force_specs(force, config) if force else {}
1263
+ plan = _build_plan_or_exit(
1264
+ repo, config, pre=pre, finalize=finalize, force=forced or None
1265
+ )
1266
+
1267
+ if component:
1268
+ plan.bumps = {n: b for n, b in plan.bumps.items() if n in set(component)}
1269
+
1270
+ if not plan:
1271
+ if output == "json":
1272
+ console.print_json(data={"bumps": {}})
1273
+ else:
1274
+ console.print(
1275
+ "[dim]no bumps pending — "
1276
+ "use [bold]--force <name>:<kind>[/] for a manual bump[/]"
1277
+ )
1278
+ return
1279
+
1280
+ matcher = ComponentMatcher(config.components)
1281
+ applied: dict[str, dict[str, str]] = {}
1282
+ written: list[Path] = []
1283
+ changelogs_updated: list[str] = []
1284
+ for planned in plan:
1285
+ comp = config.components[planned.component]
1286
+ new_version = str(planned.next)
1287
+
1288
+ is_final = _is_finalize(planned)
1289
+
1290
+ if comp.format == "debian":
1291
+ _bump_debian(
1292
+ planned.component,
1293
+ comp,
1294
+ config,
1295
+ repo,
1296
+ matcher,
1297
+ new_version,
1298
+ is_finalize=is_final,
1299
+ dry_run=dry_run,
1300
+ written=written,
1301
+ changelogs_updated=changelogs_updated,
1302
+ )
1303
+ else:
1304
+ targets: list[tuple[Path, str | None]] = []
1305
+ for bump_file in comp.bump_files:
1306
+ targets.append((repo / bump_file.file, bump_file.key))
1307
+ for mirror in comp.mirrors:
1308
+ targets.append((repo / mirror.file, mirror.key))
1309
+
1310
+ for file, key in targets:
1311
+ if not dry_run:
1312
+ write_value(file, key, new_version)
1313
+ if file not in written:
1314
+ written.append(file)
1315
+
1316
+ if comp.changelog and not no_changelog and not dry_run:
1317
+ strategy = config.project.finalize_strategy
1318
+ use_stable_since = is_final and strategy in {"consolidate", "promote"}
1319
+ relevant = _component_relevant_commits(
1320
+ planned.component, config, repo, matcher,
1321
+ since_stable=use_stable_since,
1322
+ )
1323
+ changelog_path = repo / comp.changelog
1324
+ update_changelog_file(
1325
+ changelog_path,
1326
+ new_version,
1327
+ relevant,
1328
+ sections=config.project.changelog_sections,
1329
+ breaking_title=config.project.breaking_section_title,
1330
+ other_title=config.project.other_section_title,
1331
+ drop_prereleases=is_final and strategy == "promote",
1332
+ )
1333
+ if changelog_path not in written:
1334
+ written.append(changelog_path)
1335
+ changelogs_updated.append(str(comp.changelog))
1336
+
1337
+ applied[planned.component] = {
1338
+ "current": str(planned.current),
1339
+ "next": new_version,
1340
+ "kind": planned.kind,
1341
+ }
1342
+
1343
+ git_summary: dict[str, str | list[str]] = {}
1344
+ # Optional state file: written before the commit so it lands in the
1345
+ # release commit alongside the version-file changes.
1346
+ if not dry_run and config.project.state_file is not None:
1347
+ state_path = repo / config.project.state_file
1348
+ try:
1349
+ head_before = _git(repo, "rev-parse", "HEAD").strip()
1350
+ except Exception:
1351
+ head_before = ""
1352
+ components_state: dict[str, ComponentState] = {}
1353
+ for name, info in applied.items():
1354
+ tag_name: str | None = None
1355
+ if tag:
1356
+ tag_name = config.tag_format_for(name).format(
1357
+ component=name, version=info["next"]
1358
+ )
1359
+ components_state[name] = ComponentState(
1360
+ version=info["next"],
1361
+ tag=tag_name,
1362
+ tag_sha=None,
1363
+ )
1364
+ state_obj = State(
1365
+ version=STATE_SCHEMA_VERSION,
1366
+ git_head=head_before,
1367
+ git_head_short=head_before[:7] if head_before else "",
1368
+ timestamp=now_iso(),
1369
+ components=components_state,
1370
+ )
1371
+ write_state(state_path, state_obj)
1372
+ if state_path not in written:
1373
+ written.append(state_path)
1374
+
1375
+ sign_commits_flag = sign or config.project.sign_commits
1376
+ sign_tags_flag = sign or config.project.sign_tags
1377
+
1378
+ if not dry_run and commit and written:
1379
+ rel_paths = [str(p.relative_to(repo)) for p in written]
1380
+ _git(repo, "add", "--", *rel_paths)
1381
+ if commit_message is not None:
1382
+ msg = commit_message # CLI override is verbatim, no placeholders
1383
+ else:
1384
+ msg = _release_commit_message(
1385
+ applied, config.project.release_commit_message
1386
+ )
1387
+ commit_args = ["commit", "-m", msg]
1388
+ if sign_commits_flag:
1389
+ commit_args.insert(1, "-S") # before -m so git accepts it
1390
+ _git(repo, *commit_args)
1391
+ sha = _git(repo, "rev-parse", "HEAD").strip()
1392
+ git_summary["commit"] = sha
1393
+
1394
+ tags_created: list[str] = []
1395
+ if not dry_run and tag:
1396
+ for name, info in applied.items():
1397
+ tag_name = config.tag_format_for(name).format(
1398
+ component=name, version=info["next"]
1399
+ )
1400
+ tag_args = ["tag"]
1401
+ if sign_tags_flag:
1402
+ # -s creates a signed annotated tag; -m supplies the message.
1403
+ tag_args.append("-s")
1404
+ tag_args.extend(["-m", f"{name} {info['next']}", tag_name])
1405
+ _git(repo, *tag_args)
1406
+ tags_created.append(tag_name)
1407
+ git_summary["tags"] = tags_created
1408
+ if sign_tags_flag:
1409
+ git_summary["signed_tags"] = "yes"
1410
+ if sign_commits_flag and "commit" in git_summary:
1411
+ git_summary["signed_commit"] = "yes"
1412
+
1413
+ if not dry_run and push:
1414
+ _git(repo, "push", "--follow-tags")
1415
+ git_summary["pushed"] = "yes"
1416
+
1417
+ # Write the markdown summary for both --output json and --output text
1418
+ # so a CI step can simultaneously capture JSON for jq AND populate
1419
+ # $GITHUB_STEP_SUMMARY in the same invocation.
1420
+ if summary is not None:
1421
+ _append_bump_summary(
1422
+ summary, applied, config, git_summary, dry_run=dry_run
1423
+ )
1424
+
1425
+ if output == "json":
1426
+ bumps_payload = {
1427
+ name: {
1428
+ "current_version": info["current"],
1429
+ "next_version": info["next"],
1430
+ "kind": info["kind"],
1431
+ "artifacts": [
1432
+ a.render(component=name, version=info["next"])
1433
+ for a in config.components[name].artifacts
1434
+ ],
1435
+ }
1436
+ for name, info in applied.items()
1437
+ }
1438
+ console.print_json(
1439
+ data={
1440
+ "schema_version": 1,
1441
+ "bumps": bumps_payload,
1442
+ "dry_run": dry_run,
1443
+ "git": git_summary,
1444
+ "changelogs": changelogs_updated,
1445
+ }
1446
+ )
1447
+ return
1448
+
1449
+ verb = "would bump" if dry_run else "bumped"
1450
+ for name, info in applied.items():
1451
+ console.print(
1452
+ f"[green]{verb}[/] [bold]{name}[/] {info['current']} → {info['next']} "
1453
+ f"([cyan]{info['kind']}[/])"
1454
+ )
1455
+ if changelogs_updated:
1456
+ console.print(f"[green]updated changelog[/] {', '.join(changelogs_updated)}")
1457
+ if git_summary.get("commit"):
1458
+ console.print(f"[green]committed[/] {git_summary['commit'][:7]}")
1459
+ if tags_created:
1460
+ console.print(f"[green]tagged[/] {', '.join(tags_created)}")
1461
+ if git_summary.get("pushed"):
1462
+ console.print("[green]pushed[/]")
1463
+
1464
+
1465
+ @app.command(name="get")
1466
+ def get_value(target: str = typer.Argument(..., help="component[.field]")) -> None:
1467
+ """Read the current value of a component's version (or mirrored field).
1468
+
1469
+ Examples:
1470
+
1471
+ \b
1472
+ multicz get api # version from the first bump_file
1473
+ multicz get api.image_tag # not yet implemented (reserved)
1474
+ """
1475
+ repo, config = _load()
1476
+ name, _, field = target.partition(".")
1477
+ if name not in config.components:
1478
+ err.print(f"[red]unknown component:[/] {name}")
1479
+ raise typer.Exit(code=1)
1480
+ comp = config.components[name]
1481
+ if not comp.bump_files:
1482
+ err.print(f"[red]component {name} has no bump_files[/]")
1483
+ raise typer.Exit(code=1)
1484
+ if field and field != "version":
1485
+ err.print(f"[red]unsupported field:[/] {field} (only 'version' is exposed today)")
1486
+ raise typer.Exit(code=1)
1487
+ primary = comp.bump_files[0]
1488
+ print(read_value(repo / primary.file, primary.key))
1489
+
1490
+
1491
+ @app.command()
1492
+ def changelog(
1493
+ component: str = typer.Option(None, "--component", "-c"),
1494
+ output: str = typer.Option("text", "--output", "-o", help="text | md"),
1495
+ ) -> None:
1496
+ """Print a per-component log of conventional commits since the last tag."""
1497
+ repo, config = _load()
1498
+ matcher = ComponentMatcher(config.components)
1499
+ names = [component] if component else list(config.components)
1500
+ plan = _build_plan_or_exit(repo, config)
1501
+
1502
+ md_chunks: list[str] = []
1503
+
1504
+ for name in names:
1505
+ if name not in config.components:
1506
+ err.print(f"[red]unknown component:[/] {name}")
1507
+ raise typer.Exit(code=1)
1508
+ prefix = tag_prefix(config.tag_format_for(name), name)
1509
+ since = latest_tag(repo, prefix)
1510
+ relevant = [
1511
+ c
1512
+ for c in commits_since(repo, since)
1513
+ if c.is_conventional and any(matcher.match(f) == name for f in c.files)
1514
+ ]
1515
+
1516
+ if output == "md":
1517
+ planned = plan.bumps.get(name)
1518
+ heading = f"## {name}"
1519
+ if planned:
1520
+ heading += f" {planned.current} → {planned.next}"
1521
+ elif since:
1522
+ heading += f" (since {since})"
1523
+ body = render_body(
1524
+ relevant,
1525
+ sections=config.project.changelog_sections,
1526
+ breaking_title=config.project.breaking_section_title,
1527
+ other_title=config.project.other_section_title,
1528
+ )
1529
+ md_chunks.append(f"{heading}\n\n{body}")
1530
+ else:
1531
+ header = f"## {name}"
1532
+ if since:
1533
+ header += f" (since {since})"
1534
+ console.print(f"\n[bold]{header}[/]")
1535
+ if not relevant:
1536
+ console.print(" [dim]no changes[/]")
1537
+ continue
1538
+ for commit in relevant:
1539
+ scope = f"({commit.scope})" if commit.scope else ""
1540
+ bang = "!" if commit.breaking else ""
1541
+ console.print(
1542
+ f" - {commit.type}{scope}{bang}: {commit.subject}"
1543
+ f" [dim]({commit.sha[:7]})[/]"
1544
+ )
1545
+
1546
+ if output == "md":
1547
+ print("\n".join(chunk.rstrip() + "\n" for chunk in md_chunks).rstrip() + "\n")
1548
+
1549
+
1550
+ @app.command(name="validate")
1551
+ def validate_cmd(
1552
+ strict: bool = typer.Option(
1553
+ False, "--strict",
1554
+ help="Exit non-zero on warnings too (CI gate).",
1555
+ ),
1556
+ output: str = typer.Option(
1557
+ "text", "--output", "-o", help="text | json",
1558
+ ),
1559
+ ) -> None:
1560
+ """Run every config + repo sanity check and report the findings.
1561
+
1562
+ Checks performed:
1563
+
1564
+ \b
1565
+ - bump_files exist on disk
1566
+ - components don't claim overlapping paths (first-match-wins is
1567
+ explicit, not silent)
1568
+ - mirror targets are owned by another component (otherwise no
1569
+ cascade fires) and don't loop back to the same component
1570
+ - declared triggers form no cycle
1571
+ - mirror cascades form no cycle
1572
+ - declared changelog paths are reachable
1573
+ - the planner can resolve the current version of every component
1574
+ - debian/changelog files (when format='debian') parse correctly
1575
+
1576
+ Exit code:
1577
+
1578
+ \b
1579
+ 0 no errors (warnings/info don't fail unless --strict)
1580
+ 1 at least one error
1581
+ 2 --strict and at least one warning
1582
+ """
1583
+ repo, config = _load()
1584
+ findings = run_validation(repo, config)
1585
+ errors = [f for f in findings if f.level == "error"]
1586
+ warnings = [f for f in findings if f.level == "warning"]
1587
+ infos = [f for f in findings if f.level == "info"]
1588
+
1589
+ if output == "json":
1590
+ console.print_json(data={
1591
+ "findings": [f.to_dict() for f in findings],
1592
+ "summary": {
1593
+ "errors": len(errors),
1594
+ "warnings": len(warnings),
1595
+ "info": len(infos),
1596
+ },
1597
+ })
1598
+ else:
1599
+ if not findings:
1600
+ console.print("[green]✓ no issues found[/]")
1601
+ else:
1602
+ colors = {"error": "red", "warning": "yellow", "info": "blue"}
1603
+ tags = {"error": "✗", "warning": "!", "info": "i"}
1604
+ for finding in findings:
1605
+ color = colors[finding.level]
1606
+ tag = tags[finding.level]
1607
+ comp = (
1608
+ f"[bold]{finding.component}[/]: "
1609
+ if finding.component
1610
+ else ""
1611
+ )
1612
+ console.print(
1613
+ f"[{color}]{tag}[/] {comp}{finding.message} "
1614
+ f"[dim]({finding.check})[/]"
1615
+ )
1616
+ console.print()
1617
+ counts: list[str] = []
1618
+ if errors:
1619
+ counts.append(
1620
+ f"[red]{len(errors)} error{'s' if len(errors) != 1 else ''}[/]"
1621
+ )
1622
+ if warnings:
1623
+ counts.append(
1624
+ f"[yellow]{len(warnings)} "
1625
+ f"warning{'s' if len(warnings) != 1 else ''}[/]"
1626
+ )
1627
+ if infos:
1628
+ counts.append(f"[blue]{len(infos)} info[/]")
1629
+ console.print(", ".join(counts))
1630
+
1631
+ if errors:
1632
+ raise typer.Exit(code=1)
1633
+ if strict and warnings:
1634
+ raise typer.Exit(code=2)
1635
+
1636
+
1637
+ @app.command()
1638
+ def check(
1639
+ file: str = typer.Argument(
1640
+ ..., help="Commit message file (use '-' to read from stdin).",
1641
+ ),
1642
+ types: list[str] = typer.Option(
1643
+ None, "--type",
1644
+ help="Restrict allowed commit types (repeatable). Defaults to the full set.",
1645
+ ),
1646
+ ) -> None:
1647
+ """Validate a commit message file against the conventional-commits regex.
1648
+
1649
+ Designed for use as a ``commit-msg`` git hook:
1650
+
1651
+ \b
1652
+ .git/hooks/commit-msg
1653
+ -----
1654
+ #!/bin/sh
1655
+ exec multicz check "$1"
1656
+ """
1657
+ if file == "-":
1658
+ message = sys.stdin.read()
1659
+ else:
1660
+ path = Path(file)
1661
+ if not path.is_file():
1662
+ err.print(f"[red]not a file:[/] {file}")
1663
+ raise typer.Exit(code=1)
1664
+ message = path.read_text(encoding="utf-8")
1665
+
1666
+ allowed = tuple(types) if types else DEFAULT_TYPES
1667
+ error = validate_message(message, allowed_types=allowed)
1668
+ if error is not None:
1669
+ err.print(f"[red]invalid commit message:[/] {error}")
1670
+ first = next((line for line in message.splitlines() if line.strip()), "")
1671
+ if first:
1672
+ err.print(f"[dim]got:[/] {first}")
1673
+ raise typer.Exit(code=1)
1674
+
1675
+
1676
+ if __name__ == "__main__": # pragma: no cover
1677
+ app()