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/__init__.py +10 -0
- multicz/changelog.py +196 -0
- multicz/cli.py +1677 -0
- multicz/commits.py +271 -0
- multicz/components.py +62 -0
- multicz/config.py +425 -0
- multicz/debian.py +266 -0
- multicz/discovery.py +652 -0
- multicz/planner.py +652 -0
- multicz/state.py +103 -0
- multicz/validation.py +403 -0
- multicz/writers.py +192 -0
- multicz-0.1.0.dist-info/METADATA +1644 -0
- multicz-0.1.0.dist-info/RECORD +16 -0
- multicz-0.1.0.dist-info/WHEEL +4 -0
- multicz-0.1.0.dist-info/entry_points.txt +3 -0
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()
|