Plugins¶
multicz ships with a small plugin system that lets external code
participate in the release pipeline without forking the core. Plugins
can:
- gate a
multicz bump(block the bump on policy violations), - enrich the rendered changelog and release notes with sections of their own,
- surface advice in
multicz status/multicz planso the user knows about a pending gate before it fires.
A plugin is a Python package that registers a class under the
multicz.plugins entry-point group.
There is no privileged loader path: built-in plugins use the exact
same mechanism as third-party ones.
Activation¶
Discovery alone is not activation. Even a freshly-installed plugin
stays dormant until the project's multicz.toml declares it:
The empty section is the minimum opt-in — it means "run with all defaults". Three states are possible per plugin:
| state | meaning |
|---|---|
active |
[plugins.<name>] is declared and enabled is not false. The runner invokes every hook. |
disabled |
section declared, but enabled = false. Opt-out without deleting the section. |
inactive |
plugin discovered (entry point registered) but no [plugins.<name>] section. Hooks are not called. |
multicz plugins lists every discovered plugin with its current
state:
┃ Plugin ┃ Status ┃ Module ┃ Config section ┃
│ deprecation │ inactive │ multicz.plugins.builtin.… │ (not in multicz.toml — add [plugins.deprecation] to activate) │
│ newsy │ active │ newsy │ [plugins.newsy] directory='changes.d', … │
Same command with --output json for CI consumption — each row
carries status, configured, and enabled fields plus the
plugin's resolved module / class / config dict.
Tip
A plugin you don't recognize in multicz plugins came in via a
transitive dependency. Until you add [plugins.<name>], it
can't affect your bumps — so the safe default for an unfamiliar
plugin is to leave it inactive.
Hooks¶
A plugin is a class implementing three hooks. The runner calls them in this order during a bump:
sequenceDiagram
participant CLI as multicz bump
participant R as Plugin runner
participant P as Plugin
CLI->>R: plan = build_plan()
R->>P: status_lines(ctx)
P-->>R: ["3 due for removal, …"]
R->>P: post_plan(ctx)
P-->>R: [Violation(error, …)]
Note over R: aborts if any Severity.error
R->>P: enrich_changelog(ctx, component)
P-->>R: [ChangelogEntry(section="Removed", …)]
CLI->>CLI: render changelog + release notes
Every hook receives the same PluginContext:
| field | content |
|---|---|
ctx.config |
the parsed multicz config (whole file — read other sections if you need them) |
ctx.repo |
absolute Path to the repository root |
ctx.plan |
the computed Plan; iterate ctx.plan or look up ctx.plan.bumps[component] |
ctx.plugin_config |
only the [plugins.<name>] slice of the user's config, defaulted to {} if absent |
post_plan¶
Called once after the plan is computed and before any file is written.
Return a list of Violation objects:
Severity.erroraborts the bump (exit code 1).Severity.warningprints but lets the bump proceed.Severity.infois purely informational.
A plugin that raises is caught by the runner, logged as a
RuntimeWarning, and treated as if it returned []. Other plugins
still run.
enrich_changelog¶
Called per component during changelog / release-notes rendering.
Return ChangelogEntry objects; each one becomes a
section in the rendered markdown. Sections returned with the same
title (section="Removed", etc.) merge with whatever the
conventional-commit renderer produced — no duplicate H3.
status_lines¶
Called by multicz status and multicz plan. Each returned string is
printed verbatim under the bump table, prefixed with a magenta arrow.
Rich markup ([bold], [red], …) is supported — escape literal
brackets with \[ if you mean them literally.
Data types¶
Violation¶
@dataclass(frozen=True, slots=True)
class Violation:
severity: Severity # "error" | "warning" | "info"
message: str
plugin: str
file: Path | None = None
line: int | None = None
component: str | None = None
ChangelogEntry¶
@dataclass(frozen=True, slots=True)
class ChangelogEntry:
section: str # e.g. "Removed", "Deprecated"
component: str
lines: tuple[str, ...] = () # one rendered bullet per line
Built-in plugins¶
deprecation¶
Enforces a removal policy on @deprecated(since=..., remove_in=...)
markers (and # DEPRECATED since=.. remove_in=.. comments). Behaviour:
post_plan— every marker whoseremove_in ≤ next_versionraises a violation. By defaultSeverity.error; flip to a warning withmode = "warning"during initial rollout.enrich_changelog— emits aDeprecatedsection for markers newly added in this release window and aRemovedsection for markers whose deadline matches the planned version.status_lines— one summary line per component:deprecation[api 1.0.0 → 2.0.0]: 1 added, 1 due for removal, 1 upcoming.
Config keys:
[plugins.deprecation]
# Refuse the bump on past-due markers ("error", default) or just warn ("warning").
mode = "error"
# Override the scan globs. When omitted, falls back to each component's `paths`.
scan = ["src/**/*.py"]
# Different globs per component, when a single project mixes scan needs.
[plugins.deprecation.scan_per_component]
api = ["src/api/**/*.py"]
worker = ["src/worker/**/*.py"]
Runnable example: examples/deprecation-plugin/.
upstream-notes¶
Injects the commits of upstream components — not just their version
— into a downstream component's changelog and release notes. Aimed at
depends_on chains where a deploy pipeline commits after the
upstream release (Terraform, GitOps rollouts, chart-of-charts):
When config-prod bumps via a deploy: commit, the plugin adds one
section per upstream, listing the commits merged since the previous
config-prod release:
### Upstream: root (v1.3.0 → v1.4.0)
- feat(network): add private endpoint subnet (a1b2c3d)
### Upstream: module (v0.9.1 → v0.9.2)
- fix: pin azurerm provider (d4e5f6a)
Baseline resolution — for each upstream, the "previous" version is the
highest upstream tag merged into the downstream's previously
released tag (git tag --merged <comp-prev-tag>); the "new" version
is the latest upstream tag reachable from HEAD. So even when the
deploy commit lands after the upstream release (separate pipeline
run), everything tagged upstream since the last downstream release is,
by construction, what this deployment ships.
Behaviour:
enrich_changelog— oneUpstream: <name> (v… → v…)section per upstream whose tag advanced. Commits are filtered by the samerelease_commit_patternand per-componentignored_typesthat the planner uses, and only commits touching files owned by the upstream are kept.status_lines— advertises pending drift inmulticz status/multicz planso the section isn't a surprise at bump time.
Config keys:
[plugins.upstream-notes]
# Max bullets per Upstream section; the plugin appends "… and N more"
# past this cap.
max_commits = 30
# When true, prereleases (rc / beta / …) count as valid upstream heads.
include_prereleases = false
# Explicit upstream mapping. When absent, falls back to the transitive
# closure of each component's ``depends_on``.
[plugins.upstream-notes.upstreams]
config-prod = ["root", "module"]
config-staging = ["root", "module"]
Writing a plugin¶
The fastest path is to subclass BasePlugin, which provides no-op
defaults so you only implement the hooks you care about:
from multicz.plugins import (
BasePlugin,
ChangelogEntry,
Severity,
Violation,
)
class NewsyPlugin(BasePlugin):
name = "newsy" # (1)!
def post_plan(self, ctx) -> list[Violation]:
return [] if self._fragments(ctx) else [
Violation(
severity=Severity.error,
message="no changelog fragments — add one under changes.d/",
plugin=self.name,
)
]
def enrich_changelog(self, ctx, component) -> list[ChangelogEntry]:
return [
ChangelogEntry(section=section, component=component, lines=lines)
for section, lines in self._render(ctx).items()
]
def status_lines(self, ctx) -> list[str]:
return [f"newsy: {len(self._fragments(ctx))} fragment(s)"]
namemust match the entry-point key inpyproject.tomland the[plugins.<name>]section a consumer writes in theirmulticz.toml. Pick something short, kebab-case, namespace-y if collisions are likely.
Register it via the entry-point group — this is what makes it discoverable to any multicz install:
[project]
name = "newsy"
dependencies = ["multicz"]
[project.entry-points."multicz.plugins"]
newsy = "newsy:NewsyPlugin"
That's the whole API surface. Multicz handles discovery, config slicing, hook ordering, and exception isolation; the plugin only has to implement the hooks it cares about.
Runnable example, full code + README: examples/custom-plugin/.
Reference¶
- Plugin protocol + dataclasses:
multicz.plugins.protocol - Runner + entry-point discovery:
multicz.plugins.runner,multicz.plugins.registry - Built-in implementations:
multicz.plugins.builtin.* - CLI integration points:
multicz plugins(this listing),multicz status/plan/bump(call sites for the three hooks).