Release Process¶
main is trunk: every change merges there, and merging a feature is not
releasing. A merge feeds the rolling edge channel and nothing else. A
release is a separate, deliberate event: a maintainer dispatches the
Release Prepare workflow, reviews the release pull request it opens,
and merges it. The merge is what tags and publishes. This page describes
that flow, what each kind of release publishes, and where releases come
from.
Cutting a release, step by step¶
The checklist a maintainer follows; each step links the section that explains it.
- Draft the release notes. Invoke the
writing-release-notesskill with your coding agent (it lives at.agents/skills/writing-release-notes/SKILL.md). It researches the range through the GitHub API, writesdocs/releases/next.md, runs Vale and a strict MkDocs build, and opens an ordinary pull request. Review and merge that pull request first; Release Prepare refuses to run when reviewed notes are missing. When user-visible behaviour lands after a first release candidate, invoke the skill'srefresh-known-targetmode and merge its pull request before the next dispatch. See How the notes pages are produced. - Dispatch Release Prepare. In the Actions tab, run Release
Prepare on
main(or on arelease/X.Ybranch for a stabilisation release). Leavechannelonautounless you are promoting a candidate to stable. See The release pull request. - Review the release pull request. Check the computed version against
the breaking-change policy in
AGENTS.md, read the changelog section, confirm the promoted notes page is the one you merged in step 1, and compare itsnotes-range-endwith the release delta so the notes cover every commit being released. Never press GitHub's "Update branch" on it; dispatch Release Prepare again instead. - Merge it. The merge is the release: the Release workflow tags the commit, creates the GitHub release and publishes every channel.
- Verify the fan-out. Each publish runs as its own job in the Release
workflow:
publish-pypi,publish-docker,publish-linux-packagesandpublish-mcpbon every release;publish-plugin-zipwhen the project ships the Claude Code plugin channel;publish-claude-pluginandpublish-registryonly for a stable release that is the newest one (a release candidate, or a patch to an older series, skips both by design). The documentation site deploys for the new version. A failed publish job can be re-run from the workflow run; the tag and release already exist, and every job derives from the reviewed version string, so a re-run publishes the same artefacts. - If the release was cut from a
release/X.Ybranch, expect the automated pull request that ports the release back tomain, and merge it with a merge commit. See Stabilisation branches.
Channels¶
| Channel | Version identity | What it promises |
|---|---|---|
edge |
None; the commit is the identity | The newest merged code. Every merge to main rebuilds the rolling Docker tag plus an .mcpb bundle and a Claude Code plugin .zip as workflow artifacts, and the rolling unstable docs version deploys from the same trigger. It leaves no git tag, GitHub release, or PyPI entry behind. |
| Pre-release | vX.Y.Z-rc.N, computed and reviewed in its release pull request |
A stabilisation step toward exactly that version, normally cut from a release/X.Y branch. Publishes a GitHub release with wheels, sdist, .deb/.rpm packages, .mcpb bundle, plugin .zip, and SBOM attached, plus the wheel on PyPI and a Docker image under its immutable version tag and the rolling rc tag. Skips the marketplace and registry entries and the docs deploy. |
| Stable | vX.Y.Z |
The full artifact set: PyPI, Docker, Linux packages, GitHub release assets (wheels, sdist, .mcpb bundle, SBOM), marketplace and registry entries, versioned docs. |
Pre-releases do reach PyPI, and that does not put them in front of
ordinary installers. A PEP 440 resolver skips pre-releases unless the
requirement pins one or you pass --pre, so pip install markdown-vault-mcp
and uv add markdown-vault-mcp still resolve to the newest stable. Ask for a
candidate by name to get one:
pip install markdown-vault-mcp==X.Y.ZrcN
Note the spelling. Tags and the changelog use SemVer (vX.Y.Z-rc.N);
PyPI uses the PEP 440 canonical form (X.Y.ZrcN).
Candidates need PyPI because the .mcpb bundle points there rather than
carrying the code. Its manifest launches uvx --from markdown-vault-mcp[all]==<version>,
so a candidate absent from PyPI ships a bundle that fails on the tester's
machine at install time. edge still publishes nothing to PyPI: it has no
version identity to publish under.
Rolling pointers are ordering-aware. The Docker latest, vX, and vX.Y
tags, the GitHub latest-release pointer, the docs latest alias, and the
marketplace and registry entries follow a release only when it is the
newest in the relevant series, so a patch release cut from an old
release/X.Y branch never moves them back to older content. The Docker
rc tag follows the same rule on the pre-release side: it moves only
while the candidate's version is still ahead of the newest stable. This holds
even when two releases overlap: each rolling channel checks the tag
ordering again inside its own publish job. See
Image tags for the Docker tag list.
Testing a candidate's Claude Code plugin¶
Every release attaches markdown-vault-mcp-plugin-<version>.zip, and every
merge to main produces the same archive as the plugin-zip-edge workflow
artifact. Load one without installing anything:
claude --plugin-url https://github.com/pvliesdonk/markdown-vault-mcp/releases/download/vX.Y.Z-rc.N/markdown-vault-mcp-plugin-X.Y.Z-rc.N.zip
The plugin loads for that session only and leaves no install record, so this
is the way to try a candidate without disturbing the installed copy. Check it
came up with /plugin, or from the shell:
claude --plugin-url <url> plugin details markdown-vault-mcp
The zip carries its own wheel and launches it from ${CLAUDE_PLUGIN_ROOT},
so it works at versions no index serves: every candidate, and the edge
build, whose constant 0.0.0-dev could never be published. Its
dependencies still resolve normally at launch, so the machine needs network
access the first time the server starts.
The marketplace entry is the other half of the channel and works
differently: it is a thin pointer that installs markdown-vault-mcp from PyPI,
it is what /plugin install reads, and it follows stable releases only.
Claude Code has no marketplace-free way to install a plugin permanently, so
a candidate is something you load per session rather than install.
The release pull request¶
Dispatching Release Prepare on the branch to release from has the
release tool, knope, compute the next version from
the conventional commits since the last release in that branch's history.
It stamps the version-coupled files (pyproject.toml and CHANGELOG.md
natively; uv.lock's self-version entry on every prepare and the
install-channel manifests on stable prepares, both through
scripts/stamp_manifests.py) and opens a release pull request against
the dispatched branch. The dispatch's channel input
picks the release kind: auto prepares a release candidate on
release/X.Y branches and a stable elsewhere, and an explicit rc or
stable choice overrides that. The optional override_version input
replaces the computed version with an explicit one, for the rare range
whose commits the tool counts nothing in; the workflow refuses a
dispatch on any branch other than the default branch or release/X.Y,
and refuses a computed version whose tag already exists, or a release
candidate whose stable version is already tagged.
The release pull request is an ordinary pull request: full CI runs on it, and the reviewer checks the computed version against the breaking-change policy and reads the changelog section in the diff. Merging it is the release decision. On merge, the Release workflow tags the merge commit and creates the GitHub release, then hands off to the publish fan-out; every publish gate derives from the reviewed version string.
Two rules keep the flow sound:
- Never press GitHub's "Update branch" button on a release pull request. If the base branch moves while the pull request is open, dispatch Release Prepare again: it recreates the preparation branch from the base and refreshes the same pull request in place.
- A release candidate promotes through a plain
channel: stabledispatch over the same commits. A guard verifies that nothing but release stamps and release-notes pages changed since the last candidate, first when the promotion is prepared (a drifted promotion refuses before its pull request even opens) and again before any tag is created; any other change forces a new candidate instead of a silently different stable.
Releasing from trunk¶
The default release path is trunk. When trunk is quiescent (no epic that
must ship whole is mid-flight), dispatch Release Prepare on main and
merge the release pull request: no branch, no ceremony. The prepare
workflow prints an advisory warning when a release-named milestone still
has open issues, or when an open ships-atomically epic shows work in
flight. It counts the epic's native sub-issues even when they live in
another repository, which keeps a cross-repo epic visible. The warning
never blocks: the cut may still be intentional.
Stabilisation branches¶
A short-lived release/X.Y branch is the exception tool, for two cases:
- Trunk carries unfinished work that the release must exclude. The
branch is cut from the last quiescent commit behind the head, release
candidates are cut there while fixes land, and a plain
channel: stabledispatch promotes the finalX.Y.Z. - A shipped release needs a patch while
mainhas moved on. The branch can be created retroactively from the release tag, so no branch needs to exist in advance of the need.
Fixes flow from trunk to the branch: they land on main first and are
cherry-picked over. After a stable release cut from a release/X.Y
branch, an automated job opens an ordinary pull request back to main,
reviewed and CI-gated like any other change, with no direct pushes to
protected branches. When that release is the newest stable, the pull
request is a merge of the release commit together with its version
stamps, because the next release on main computes its version and its
changelog range from the last stable in its own history. Merge it with a
merge commit; the other merge methods discard that history, and the next
Release Prepare on main refuses until the port has landed. If the
merge hits a conflict outside the release's own files (its version
stamps, its notes page, the notes index and the staging notes), the job
opens the files-only pull request instead and its description names the
conflicting files: merge the release commit by hand, with a merge
commit, before the next release from main. When the release patches
an older series, or a higher release has merged but not yet tagged, the
pull request carries only the changelog section, the notes page and the
index entry, and merges any way you like.
Release candidates port nothing: the stable's changelog section covers
the whole cycle.
Release branches carry shipped releases, so they get the same protection
as main: pull requests plus green CI, applied by the shipped rulesets.
See Repository Protection.
Template updates¶
Releases of this project and updates from its template are separate events; the weekly template update pull request is covered in Working Through a Template Update.
Where to read about a release¶
Each release is described in three places with distinct jobs:
- The GitHub release body carries the release's notes summary, its machine-written changelog section, and pointers: the versioned docs, the compare view, and a deep link to the notes page.
- The release notes pages on this docs site are the canonical human-facing narrative of what changed and why it matters.
CHANGELOG.mdin the repository is the machine-written audit trail, generated from conventional commits into each release pull request.
How the notes pages are produced¶
The pages under Release Notes cover one minor series each; patch releases add undated version headings to their series page. Humans prepare the narrative before release automation starts, as steps 1 to 4 of Cutting a release, step by step describe.
The first release candidate for a stable identity consumes next.md into the
canonical minor page. Later candidates and stable promotion reuse the same
vX.Y.Z entry. When user-visible behavior lands after the first candidate,
invoke refresh-known-target and merge the normal notes pull request before
re-dispatching Release Prepare. Corrections and redrafts use the same ordinary
pull-request path for shipped pages.
No notes bypass exists. Missing or ambiguous staging makes deterministic promotion refuse the prepare. Every causal claim must cite a linked issue or pull request, and the skill runs Vale and strict MkDocs before opening its pull request. Later canonical-page edits redeploy that minor through the deterministic Release Notes Publish workflow.