Versioning in materialize-monitoring#

All artifacts of this repo have separate versioning streams. They generally follow semantic versioning (SemVer).

Components#

Components are defined in packages/components.yaml. Each component declares:

  • changelog — whether the component maintains its own changelog stream.
  • title — the human-readable name used in CHANGELOG.md headings.
  • version_paths — files whose version field is rewritten when the component is bumped (the write targets).
  • content_paths — the paths whose changes are attributed to this component (the attribution inputs).
  • content_exclude — paths to subtract from content_paths, typically generated outputs that belong to a dependency.
  • dependencies — other components whose bumps cascade into this one.
  • artifacts — glob patterns (repo-root-relative) for files attached as GitHub Release assets when the component is published; resolved file names must be unique.
  • chart — the Helm chart directory a component publishes, when it is not charts/<component name>. Read by the release workflow rather than by the Rust tooling. Only dashboards needs it, because it ships charts/materialize-monitoring-dashboards under a component name that predates the chart.

Each changed file is attributed to the component with the longest matching content_paths entry, after dropping any component that excludes it. Generated outputs route to their source: the umbrella chart excludes its pre-rendered/ tree, and pre-rendered/pipelines and pre-rendered/metrics are claimed by the pipelines component, so a pipeline change appears under Pipelines (and rolls up into the chart via cascade) rather than as a first-class chart change.

The dashboards are the exception, and deliberately. They are not a generated output routed out of a chart — they are a chart, materialize-monitoring-dashboards, which the dashboards component owns outright along with its sources under packages/dashboards/ and packages/queries/. So there is no content_exclude on its pre-rendered/ tree, and the umbrella chart does not list dashboards among its dependencies: nothing it ships comes from them any more, and a dashboard change that cascaded into it would publish an umbrella release identical to its predecessor except the version.

The title is the component’s identity in CHANGELOG.md. latest_released looks a component up by its title, so renaming one in components.yaml alone makes the tooling report it as having no prior release. Renaming is supported, and takes a paired edit to the latest released heading in CHANGELOG.md to re-establish the baseline — see Renaming a component. Both components that have grown a second artifact went through it: materialize-monitoring when the Terraform module joined it, and dashboards when the Helm chart did.

The component key is a different matter: it is the release tag prefix and the version-update branch name, so it cannot be rewritten after the fact.

A component may have an empty version_paths (its version lives only in CHANGELOG.md) or changelog: false (it is rebuilt on dependency changes but keeps no changelog of its own, like docs).

How changes are attributed#

Changes are attributed per merged pull request, using that commit’s own diff (<commit>^1..<commit>), not git log -- <path>. A plain path log is unreliable here for two reasons: Git history simplification prunes merge commits, and the crates/ → packages/ move means today’s paths do not match historical ones.

PRs are found by walking the first-parent history of the range, and both of GitHub’s merge styles are recognized, because this repo uses both:

StyleParentsSubjectPR title from
Merge commit2Merge pull request #N from <branch>first non-empty line of the body
Squash commit1the PR title, with a (#N) suffixthe subject, suffix stripped

Most human PRs land as merge commits and most renovate PRs land as squash commits, so recognizing only the first would drop a large share of dependency bumps — including the ones whose descriptions carry the richest release notes.

A merge commit is itself evidence of an integration event, so one without a #N still earns an entry, labeled by branch or short hash. A lone commit is not — it could be any direct push to the branch — so without a (#N) there is no PR to attribute it to and it is skipped. changelog --verbose lists what it skipped under == Commits with no PR reference ==, so a genuinely missed PR is visible rather than silently dropped.

A PR is attributed to a component when it changes a path under one of that component’s content_paths. Each changed file is assigned to the component with the longest matching content_paths entry; ties resolve to declaration order in components.yaml. A single PR can therefore appear in several streams when it touches several components’ paths.

The current CHANGELOG.md is the authoritative baseline. The tooling attributes forward from the last released ref rather than trying to reconstruct history.

Cascade#

A component’s section lists its first-class changes — the PRs that touched its own paths — followed by a ### Dependencies subsection. When a dependency bumps, the dependent bumps too and records an Included <dep> @ vPREV..vNEW entry under ### Dependencies, with that dependency’s own PRs nested beneath it, recursively through the dependency graph. The range spans the dependency’s latest released version to the version this release includes; a brand-new dependency with no prior release shows a single version. “Included” rather than “Updated” because the new version need not be released yet. This keeps each component’s release notes self-contained and cumulative: the detail travels with the rollup, rather than a bare “updated to vX.Y.Z” with no context. The nested PRs sit in a collapsed <details> inside the entry, because a rarely released dependent accumulates every PR its dependencies merged in the meantime. An entry with nothing nested, which a bump by cascade alone produces, stays a plain bullet. A dependency that is reached only through another one is rolled up inside that one’s entry. A PR already shown as a first-class change in a section is not repeated under that section’s dependencies, and each dependency is rolled up once per section. When a PR touches two sibling dependencies, the one declared first in dependencies claims it; order the main content components ahead of shared/common ones so changes surface in the more specific stream. A single PR can still appear in several components’ sections; that duplication is intentional, so each component’s release reads completely on its own.

Under each PR’s bullet sit its link and then any release notes the author wrote in the PR description, so a reader gets the consumer-facing detail without opening the PR. See Release notes from PR descriptions for what is picked up and what is dropped.

How versions are synced#

The tooling reads each component’s version from CHANGELOG.md. An unreleased section is a _Changes Pending_ placeholder. A version-update PR populates the placeholder, promotes it to a released section, and rewrites that component’s version_paths to the released version. For more information, see Releasing. Bumping a pyproject.toml also rewrites the matching package’s version in uv.lock, so the lockfile does not drift behind the version files. Bumping a Chart.yaml also rewrites the helm-docs version badge in the README.md beside it, so the PR does not wait on auto-format for that line. The next version defaults to a minor bump. To release a patch or a major, edit the placeholder heading, which the tooling reads and never overrides. For which bump a change needs and why that edit is easy to lose, see Choosing the next version. For what a version bump is allowed to change, see Stability guarantees.

Tooling#

Both subcommands live in mz-monitoring-build and default to a dry run; --write (on release) applies changes.

  • mz-monitoring-build changelog --since <ref> [--until <ref>] [--verbose] is read-only: it reports which merged PRs each component would collect and the version each would bump to — a preview for validating components.yaml against real history.
  • mz-monitoring-build release --component <name> --since <ref> [--write] generates a version-update/<component> PR: it promotes that component’s _Changes Pending_ placeholder in place into a populated released section, inserts a fresh placeholder at the top, and bumps the component’s version_paths. With a GITHUB_TOKEN in the environment it also reads the merged PRs’ descriptions for their release notes; without one it prints a warning and omits them, so its output is otherwise reproducible offline.

The shared logic (component model, changelog parsing, attribution, cascade rendering, version rewriting) lives in the versioning module and is unit-tested without invoking git. Release-note extraction lives alongside it in release_notes, and is likewise unit-tested against real PR-description shapes without touching the network.

Release PR automation#

The orchestration that drives release from CI is built: propose-bumps runs on every merge to main and creates or updates the version-update/* PRs for components with changes, publish-release creates the <component>/vX.Y.Z tag and GitHub Release when such a PR merges. The per-component tag doubles as that component’s --since boundary. See Releasing for the full state machine and the workflows that drive it.

Stability guarantees#

The published version of this is Stability Guarantees and Deprecation Policy; the rationale, the surface inventory, and the alternatives considered are in the design doc. This section is the policy of record, and is kept in step with the published page.

The design doc predates both, and argued for a stricter pre-1.0 position than this section takes. It’s a dated record, so it stays as written; where the two differ, this section governs.

Surfaces are graded by how much control we have over them.

ClassWhat it coversObligation
CommittedAlert names and their severity/component values; recording-rule names; Terraform inputs and outputs; dashboard identities; metric-tier names; the content of queries declared stability: canonical — what the expression computes, not the id it is filed under; artifact and OCI names — the chart name and the container image repository, but not the tag; the monitoring.materialize.cloud/* namespaceFull cycle below
Coordinatedmz_* metric names and labels — the Materialize product defines themDocumenting a change is RECOMMENDED. No dual-publish and no cooldown obligation, since we control neither the names nor the upstream timing
No promiseQuery ids at every level, including canonical (their consumers are dashboards in this repo); the content of experimental and best-effort queries; chart value paths (consumers are our own Terraform); container image tags; dashboard internals and variables; subchart values; Kubernetes, Grafana, and Prometheus API shapesChangelog note when it changes

canonical is how a query’s content opts into the committed surface. The registry’s stability ladder runs unused → playground → experimental → best-effort → canonical, and promoting a query to canonical commits what it computes. The id is not the promise: ids are internal handles whose consumers are dashboards in this repo, and one may be renamed freely. What a customer builds on is the expression and its meaning — the units, the aggregation, the label set it returns — because that is what they copied into a panel of their own. Re-filing that expression under a new id changes nothing for them; quietly changing what it computes changes everything.

That keeps the obligation deliberate rather than ambient: 76 of the 374 declarations are canonical today, and the other 298 carry no commitment about their content. best-effort still owes a deprecation before removal, but that is internal discipline about our own dashboards, not a commitment to a customer.

The cycle, for the committed surface:

  1. Announce — stability: deprecated where the field exists, plus a release-note bullet starting **Deprecated:** that names the replacement.
  2. Overlap — old and new both work for at least 30 days, measured from that release’s tag date.
  3. Remove — a later major, with a **Removed:** bullet. stability: unsupported tombstones the identifier so it is never reused for different semantics.

The cycle binds from v1.0.0. A break to the committed surface lands only in a major, and only after its cycle. A release before v1.0.0 could carry a breaking change in a minor.

Additions are free, in any release.

A behavior change is not, on its own, a break. The cycle is keyed to identifiers, not to conduct. Committing to every observable behavior of this stack would be unbounded, and an obligation nobody can enumerate is one nobody can keep. The bounded exception is the content of canonical queries above, which is committed precisely because it is enumerated.

A changed default is a behavior change, so it can ride a minor. It still has to leave the committed surface working. Describe it in the PR’s release notes, because a consumer might need to react to it.

Ceremony is graded by failure mode, not by surface size. A renamed metric leaves a visibly blank panel that a customer can diagnose and fix on their own schedule. A renamed or removed alert leaves silence, and silence during an incident is indistinguishable from health. So alerts get the strictest treatment — cooldown, dual-publish, explicit notes, a named owner on removal — and metrics get the lightest. This deliberately inverts the “the label/metric contract is the public API” framing: on a failure-mode reading, metrics are among the most forgiving things we publish.

A chart value path change isn’t a breaking change. The consumer of values.yaml is our own Terraform module, which pins a chart version, so bumping the module absorbs a rename. Direct helm install users are the exception, and that list is short enough to notify individually.

Enforcement builds nothing. Every committed identifier already appears in a generated, committed artifact — terraform-docs output for module variables, pre-rendered/ for dashboards and tiers, packages/queries/ for alerts — so a rename already shows as a diff in the PR. CODEOWNERS covers those paths, and the release-process check is the reviewer’s half.

Designing to avoid the cycle#

The cycle is the fallback, not the goal. Most breaking changes are avoidable at design time, and these are the levers worth reaching for first.

Publish a recording rule, not a raw metric. The strongest lever we have. A record: name is ours, and the schema already says the expression behind it may change freely. So an artifact that reads ext:consensus_commit_latency_seconds:p99 instead of a raw mz_persist_* histogram turns an upstream rename into an expression edit with no customer-visible change at all. That converts a coordinated-surface risk, which we do not control, into an owned one we fully control. The normalized ext:* layer is the first use, and Authoring Recording Rules is the naming convention it set.

Add; do not rename. Additions are free in any release. A new identifier alongside the old costs nothing, and the cycle only starts when you want the old one gone — which is often not required. Leaving a superseded alert or output in place is cheaper than removing it, and honest as long as it still works.

Do not publish what you do not need. Every committed identifier is a permanent obligation. A Terraform output added “just in case”, or an alert shipped before anyone routes on it, is a promise nobody asked for. The cheapest non-breaking change is one to a surface that was never published.

Template what varies. %%{mzSqlPrefix} is the working example: one query definition renders against either the mz_ or v2_mz_ namespace, so the difference never reaches a name we publish. Where a value differs by deployment, or looks likely to move, parameterize rather than bake it in.

Name for the condition, not the grade. Many alerts end in -critical, -high, or -elevated, and several families differ only by that suffix, so re-grading one of them forces a rename. Put the severity in the severity label, and name the alert for what is wrong. Severity values are committed too, so this shrinks the exposure rather than removing it. A label change misroutes an alert that still fires under a name people can find; a rename breaks both at once. Every shipped alert name is committed, so renaming one costs a full cycle: name a new alert for its condition from the start.

When a rename is genuinely required, accept both. Keep the old identifier working rather than merely present. For a Terraform variable, that means you retain it and let the new one win, rather than delete it and document the replacement. No module variable follows this pattern, so there’s no example in the module to copy.

Design principles#

Components will change; the tooling must not care. components.yaml is the only source of truth for the component set, and the tooling is fully data-driven — no component name is hardcoded. Merging components (e.g. folding Dashboards, Pipelines, alerts, and scrapers into one “Supplemental Assets” stream) is just unioning their content_paths under one entry; renaming is a title change. CHANGELOG.md is keyed by component title and is append-only history: the tooling treats a title with no prior section as “start fresh” and leaves sections for retired titles untouched as historical record. A newly merged or renamed stream seeds its starting version by setting the unreleased version manually in CHANGELOG.md.

Renaming has one consequence worth knowing before you do it: the changelog is keyed by title, so the lookup for a component’s latest release only matches sections carrying the current title. The first release attempt after a rename therefore reports no prior release and skips the component. The fix is to rename the latest released section’s heading to match — older sections can keep the old title, since only the newest one is consulted.

One shared lib, spanning Rust and Python. mzmon-lib deliberately covers both ecosystems rather than splitting into per-language streams. The library is transparent to consumers of the repo — it should not matter to them whether a change was Rust or Python — dependency bumps often land in both at once, and cross-language work (e.g. Datadog dashboards in Rust) touches both. Per-language splitting is a build-cost optimization we can make later if it is ever warranted; it is not warranted now.

Some paths are intentionally changelog-exempt. .claude/ (no build impact), legacy/ (frozen), and most root meta files own no component, so their changes are attributed nowhere by design. Shared build and CI infra lives in repo-common (changelog: false), so it is owned but never produces changelog entries or cascade noise.