Skip to content

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 plan so the user knows about a pending gate before it fires.

A plugin can also:

  • claim ownership of a component for a changed file that none of its paths globs matched (affects) - useful when a more authoritative, queryable dependency graph exists than a hand-maintained glob (e.g. a Go import graph).
  • report problems in multicz validate (validate) - e.g. a dependency query its other hooks rely on that fails.

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:

[plugins.deprecation]

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

def post_plan(self, ctx: PluginContext) -> list[Violation]: ...

Called once after the plan is computed and before any file is written. Return a list of Violation objects:

  • Severity.error aborts the bump (exit code 1).
  • Severity.warning prints but lets the bump proceed.
  • Severity.info is 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

def enrich_changelog(
    self, ctx: PluginContext, component: str,
) -> list[ChangelogEntry]: ...

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

def status_lines(self, ctx: PluginContext) -> list[str]: ...

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.

affects

def affects(
    self, ctx: OwnershipContext, component: str, paths: list[str],
) -> bool: ...

Unlike the three hooks above, affects doesn't run against a computed Plan - it runs while multicz is still deciding which component(s) a change belongs to, so it receives the lighter OwnershipContext instead of PluginContext (no plan field - there isn't one yet):

field content
ctx.config the parsed multicz config
ctx.repo absolute Path to the repository root
ctx.plugin_config the [plugins.<name>] slice, same as elsewhere

Both multicz changed and the planner's direct pass call affects only as a fallback, once plain paths matching has already failed to attribute a changed file to component. Return True to claim it anyway. Call frequency differs by caller: changed calls it once per component with every unmatched path batched together; the planner calls it once per (component, commit) pair. A plugin whose answer requires an expensive external call (shelling out to a compiler's dependency query, for instance) should cache on self across calls within a single run.

The changelog that bump writes, multicz changelog and multicz release-notes attribute commits the same way, so a commit that bumps a component only because of affects is listed in that component's changelog and release notes instead of leaving the release with "No notable changes". Every match under overlap_policy = "all" counts there too, as it does for the planner.

Both callers evaluate every component independently - nothing stops two different components from each getting True for the same path. A change to a dependency shared by two binaries can legitimately bump both; this is unaffected by overlap_policy, which only governs plain paths matching.

A plugin that raises is caught by the runner, logged as a RuntimeWarning, and treated as if it returned False.

validate

def validate(self, ctx: OwnershipContext) -> list[Violation]: ...

Called once by multicz validate, with the same OwnershipContext as affects - there is no plan to validate against. Return Violation objects for anything that would make the plugin's other hooks answer wrongly: a failing dependency query, a config section it cannot use. Each one is reported as a finding with the check identifier plugin:<name>:

  • Severity.error fails the command (exit code 1).
  • Severity.warning fails it under --strict (exit code 2), the usual CI gate.
  • Severity.info is purely informational.

This is how a plugin whose affects degrades to "no opinion" on failure can still stop CI: affects stays safe, validate says why it would have answered False.

A plugin that raises is caught by the runner, logged as a RuntimeWarning, and treated as if it returned [].

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

OwnershipContext

@dataclass
class OwnershipContext:
    config: Any      # multicz.config.Config
    repo: Path
    plugin_config: dict[str, Any]

Passed to affects and validate - lighter than PluginContext, with no plan field.

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 whose remove_in ≤ next_version raises a violation. By default Severity.error; flip to a warning with mode = "warning" during initial rollout.
  • enrich_changelog - emits a Deprecated section for markers newly added in this release window and a Removed section 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):

module  --(depends_on)-->  root  --(deploy pipeline)-->  config-*

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 - one Upstream: <name> (v… → v…) section per upstream whose tag advanced. Commits are filtered by the same release_commit_pattern and per-component ignored_types that the planner uses, and only commits touching files owned by the upstream are kept.
  • status_lines - advertises pending drift in multicz status / multicz plan so 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:

src/newsy/__init__.py
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)"]
  1. name must match the entry-point key in pyproject.toml and the [plugins.<name>] section a consumer writes in their multicz.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:

pyproject.toml
[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/.

A second worked example implements only affects: examples/go-deps-plugin/ claims a Go component via go list -deps when its paths glob misses an internal/ import.

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).