Skip to content

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.

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 pvliesdonk-scholar-mcp and uv add pvliesdonk-scholar-mcp still resolve to the newest stable. Ask for a candidate by name to get one:

pip install pvliesdonk-scholar-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 pvliesdonk-scholar-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 scholar-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/scholar-mcp/releases/download/vX.Y.Z-rc.N/scholar-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 scholar-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 pvliesdonk-scholar-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.

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: stable dispatch 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, which may live in another repository, so a cross-repo epic stays visible. Either way the warning never blocks, since the cut may still be intentional.

Stabilisation branches

A short-lived release/X.Y branch is the exception tool, for two cases:

  1. 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: stable dispatch promotes the final X.Y.Z.
  2. A shipped release needs a patch while main has 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 that carries the release's changelog section back to main, reviewed and CI-gated like any other change, with no direct pushes to protected branches. 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.

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.md in 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; a patch release adds a dated section to its series page. The page is part of every release pull request, exactly like the changelog. The Release Prepare workflow's notes job drafts or refreshes it. The drafting agent reads the release range through the GitHub API, working from the linked issues and pull requests rather than commit subjects, and the finished page is committed onto the release pull request's branch. Reviewing the release pull request covers the notes. Merging it lands the page in the release's own tag, and the published docs and the release body's summary and deep link all read the page from there. Release candidates carry their notes draft the same way, so a candidate is the full release artifact, narrative included. Every causal claim in a page must cite a linked issue or pull request, and the drafting agent runs the same prose lint as the rest of this site.

The release pull request stays a draft until the notes job lands its page, so a release without its notes can never merge by accident. A drafting failure fails the prepare run visibly and leaves the pull request in draft; re-run the notes job, or re-dispatch Release Prepare with skip_notes to release without a notes refresh. For a release shipped that way, the Release Notes workflow's manual dispatch drafts a standalone notes pull request as a backfill. Later hand edits to a released page redeploy that minor's versioned docs through the Release Notes Publish workflow.

A notes refresh is incremental by default: the drafting agent researches only the commits since the page's last accepted draft and leaves accepted prose alone. To rewrite a page from scratch under the current drafting rules, dispatch Release Prepare with full_redraft; the rewrite lands inside the release pull request like any other draft. The Release Notes workflow's manual dispatch accepts the same flag in both of its modes: a target version re-drafts against an open release pull request without re-running the whole prepare, and a tag produces a standalone backfill pull request for a shipped release.