Skip to content

Releasing

This guide is for maintainers cutting a new release of gopher-mcp to PyPI. It covers the one-time publishing setup, the per-release steps, and how to test a release safely.

The canonical repository is cameronrye/gopher-mcp.

Overview

  • Semantic Versioning — releases follow SemVer (X.Y.Z): MAJOR for incompatible changes, MINOR for backward-compatible features, PATCH for backward-compatible fixes.
  • Tags trigger publishing — pushing a Git tag matching v* (e.g. v0.4.0) runs .github/workflows/release.yml, which validates, tests, builds, publishes to PyPI, publishes the MCP registry entry and the container image, and creates the GitHub Release. Nothing publishes without a tag.
  • CI must already be green on the tagged commitvalidate-release refuses the tag unless a completed, successful ci.yml run exists for that exact SHA. See The CI gate.
  • Trusted Publishing (OIDC) — uploads to PyPI use GitHub's OpenID Connect identity, so there are no API tokens to manage. Packages are also signed and attested via Sigstore.
  • Pre-releases — tags like v0.4.0-rc.1, -beta.1, or -alpha.1 are detected automatically, marked as a pre-release on GitHub, and published to PyPI as a pre-release (so pip install gopher-mcp won't pick them up by default).

Approval gate

The PyPI publish step runs in the protected pypi GitHub environment and pauses for a manual approval from a maintainer before uploading; everything before it (validation, tests, build) runs automatically. The GitHub Release is created last, after the PyPI upload succeeds, so a public release never advertises a pip install gopher-mcp==X that PyPI cannot serve. If approval is declined or the upload fails, no release is published; if the release step itself fails after a successful upload, re-run that job — the tag and the built artifacts are still there.

The MCP Registry and container-image publishes hang off publish-pypi but are deliberately not dependencies of the GitHub Release, so a registry or GHCR outage cannot block or empty a release. If one of them fails, the release is still complete; re-run that job on its own.

The CI gate

ci.yml triggers on pushes and pull requests, never on tags, and the release workflow's own test-and-build job is a single ubuntu-latest / Python 3.11 leg. Without a gate the 12-way OS/Python matrix would sit entirely outside the release path, and a Windows- or macOS-only regression could reach PyPI with a fully green Release run — which nearly happened for 0.8.0, where all three windows-latest legs failed on the release-preparation commit while every ubuntu job passed.

So validate-release polls gh run list --commit "$GITHUB_SHA" --workflow ci.yml for up to fifteen minutes and fails the tag unless a completed, successful run comes back. In practice: push to main, wait for CI to go green on that commit, then push the tag. A tag pushed immediately behind its commit is fine — the gate waits for the run rather than false-failing — but a tag on a commit whose CI failed stops the release before anything is built.

One-time setup

Trusted publishing must be configured once on PyPI (and, optionally, TestPyPI) before the first release can succeed. This is the only manual publishing configuration required.

PyPI

  1. Sign in to PyPI with an account that has two-factor authentication enabled.
  2. Go to the project's Publishing settings (or Your projects → gopher-mcp → Settings → Publishing). For a brand-new project, add a pending trusted publisher under Account → Publishing so the first upload can register the name.
  3. Add a GitHub Actions trusted publisher with these exact values:

    Setting Value
    Repository owner cameronrye
    Repository name gopher-mcp
    Workflow filename release.yml
    Environment name pypi

These must match the workflow exactly — the publish-pypi job in release.yml runs in the pypi environment with id-token: write.

Repeat the same steps on TestPyPI to enable safe upload tests via the manual publish workflow:

Setting Value
Repository owner cameronrye
Repository name gopher-mcp
Workflow filename publish.yml
Environment name testpypi

GitHub environments

Both pypi and testpypi environments should exist in Settings → Environments with required reviewers so deployments pause for approval. The pypi environment should be restricted to protected branches.

The rest of this repository's GitHub configuration — branch protection and the exact required status checks, repository settings, secrets, labels and code owners — is written down in Repository Setup. It is maintainer runbook material and is deliberately not in the site navigation.

Release steps

1. Bump the version

The version string is written in three places, but only one is the source of truth:

Location What to do
pyproject.toml (version = "X.Y.Z") Source of truth. Update this. The release workflow checks the tag against it.
server.json (three places: top-level version, each package's version, and the image tag inside the OCI package's identifier) Update all three. The release workflow fails the tag run if any one of them differs from the tag.
uv.lock (the gopher-mcp package entry) Regenerate by running uv lock (or any uv sync) so the lockfile records the new version.
src/gopher_mcp/__init__.py No action needed__version__ is derived at runtime from the installed package metadata, not hardcoded.

The helper script scripts/prepare-release.py automates the pyproject.toml and server.json bumps, promotes the ## [Unreleased] section into a dated one, and rewrites the compare links at the foot of the CHANGELOG (points [Unreleased] at the new tag and adds the release's own definition). It does not update uv.lock — run uv lock yourself after bumping:

# Update pyproject.toml + CHANGELOG, then run the full preparation checks.
# Prompts interactively before editing the version and before creating a tag.
uv run python scripts/prepare-release.py --version 0.5.0

# Re-sync the lockfile so uv.lock records the new project version.
uv lock

Keep uv.lock in sync

The lockfile's gopher-mcp entry can silently lag behind pyproject.toml. Always run uv lock after a version bump and commit the result, or the package version in the lockfile will be stale.

You can also bump everything manually: edit version in pyproject.toml, edit all three version places in server.json, run uv lock, and add the CHANGELOG entry by hand.

server.json carries the version in three places, not two

The MCP registry manifest repeats the version at the top level and inside each package — and the third is not a version key at all. The registry rejects a version key on an OCI package ("include version in identifier instead"), so for that entry the image tag inside identifier is the version. The release workflow rejects a tag if any of the three differs, so missing one means deleting and re-pushing the tag. Check all three with python -c "import json; d=json.load(open('server.json')); print(d['version'], *[p.get('version') or p['identifier'].rsplit(':', 1)[1] for p in d['packages']])". scripts/prepare-release.py bumps all three, and its "Checking Version Consistency" step compares them against pyproject.toml before you tag.

validate-release also validates the whole manifest against the official MCP registry schema its $schema names, so a field that drifts away from the schema fails the tag run rather than the registry publish.

2. Update the CHANGELOG

CHANGELOG.md follows Keep a Changelog. Move the items under ## [Unreleased] into a new dated section. The release workflow requires a matching ## [X.Y.Z] heading and uses that section as the GitHub Release notes.

## [Unreleased]

## [0.5.0] - 2026-06-15

### Added
- ...

### Fixed
- ...

prepare-release.py creates this section automatically from the [Unreleased] content, but review it for accuracy.

It also maintains the link definitions at the foot of the file — [Unreleased] moves to compare against the new tag, and the release gets its own [X.Y.Z]: .../compare/vPREVIOUS...vX.Y.Z. These matter more than they look: Markdown renders an undefined reference as literal text, so a missing definition ships a bare [X.Y.Z] on the GitHub Release page and the docs site, and a stale [Unreleased] link shows the release that just shipped as though it were still pending. Nothing fails when they are wrong, so if you bump by hand, fix them by hand too.

3. Validate locally

Run the validation script to mirror what CI will check (tests with ≥95% coverage, lint, format, type-check, security scans, package build, docs build, and a functionality smoke test):

uv run python scripts/validate-release.py

Equivalent individual checks:

uv run task quality     # ruff lint + mypy + tests
uv build                # build wheel + sdist
uv run python -m twine check dist/*

4. Commit, tag, and push

git add pyproject.toml server.json uv.lock CHANGELOG.md
git commit -m "Prepare release v0.5.0"
git push origin main

The tag must be created from a commit on main (the workflow rejects tags that aren't reachable from main) and the version must match pyproject.toml.

git tag -a v0.5.0 -m "Release version 0.5.0"
git show v0.5.0            # sanity-check the tag
git push origin v0.5.0     # this starts the release

Warning

Pushing the v* tag starts the automated release immediately.

5. Watch, approve, and verify

  1. Open the Actions tab and follow the Release run. It proceeds through: validate (including the CI gate above) → test & build → publish to PyPI → and then, in parallel and with none of them able to block the others, publish to the MCP Registry, publish the container image, and create the GitHub Release.
  2. When the Publish to PyPI job pauses for the pypi environment, review the prior steps and approve the deployment.
  3. Verify the results:

    • GitHub Releases — notes and attached artifacts (.whl + .tar.gz), pre-release flag correct.
    • PyPI project page — new version present, metadata renders.
    • The MCP Registry serves the new version. The publish-registry job reads it back from the public search endpoint itself, so a green job is the proof; the entry is io.github.cameronrye/gopher-mcp.
    • The container image — ghcr.io/cameronrye/gopher-mcp:<version>, plus :latest for a stable release only. A pre-release tag never moves latest.
    • Installation:
    pip install gopher-mcp==0.5.0
    gopher-mcp --version
    gopher-mcp --help
    

Pre-release checklist

Work through this before tagging. (Most items are also enforced by CI, but checking locally avoids a failed release run.)

  • Decide the version number per SemVer (major / minor / patch).
  • All intended PRs are merged to main; no pending work that belongs in the release.
  • Hook revisions refreshed, but not with a bare autoupdate. Two of the revs deliberately track uv.lock (ruff-pre-commit and bandit, so the hook and CI's uv run ruff check . / uv run bandit agree on the rule set), and a bare uv run pre-commit autoupdate bumps those to the newest upstream tag and breaks exactly that. Update only the revs with no lockfile counterpart:
uv run pre-commit autoupdate \
  --repo https://github.com/pre-commit/pre-commit-hooks \
  --repo https://github.com/compilerla/conventional-pre-commit \
  --repo https://github.com/igorshubovych/markdownlint-cli \
  --repo https://github.com/rbubley/mirrors-prettier
uv run pre-commit run --all-files

Move ruff-pre-commit and bandit only together with uv lock --upgrade, so the rev and the locked version land in the same commit. Nothing else bumps any of them — Dependabot has no pre-commit ecosystem and no pre-commit.ci app is installed here — so without this step the local gate drifts away from CI between releases.

  • Lint and format pass: uv run ruff check . and uv run ruff format --check ..
  • Type checking passes: uv run mypy src.
  • Tests pass with coverage ≥95%: uv run pytest.
  • Security scans pass: uv run bandit -r src/ and uv run pip-audit. pip-audit is advisory-only in CI and in the release workflow (the nightly audit tracks findings in a security-audit issue), so check it here rather than relying on the tag run to stop you.
  • Docs build cleanly: uv run mkdocs build --strict.
  • CI is green on the commit you are about to tag — the release workflow enforces this, see The CI gate.
  • README.md and configuration examples reflect any new behavior.
  • CHANGELOG.md has a complete, dated ## [X.Y.Z] section; breaking changes and any migration notes are called out.
  • CHANGELOG.md's link definitions are current: [Unreleased] compares against the tag you are about to create, and [X.Y.Z] exists. prepare-release.py does both; a hand-edited bump does neither, and nothing downstream catches it — the heading just renders as literal [X.Y.Z] in the release notes.
  • version in pyproject.toml matches the tag you will create.
  • Every server.json version matches the tag: the top-level version, each package's version, and the image tag inside the OCI package's identifier (the registry rejects a version key on an OCI package, so for that entry the tag is the version). scripts/prepare-release.py moves all of them, and release.yml's "Validate version consistency" step checks all of them — including the image tag, which matters because a stale one would publish a registry entry pointing at the previous release's container. Two things that sound like they check versions and do not: scripts/validate-release.py, which despite the name runs quality gates only, and anything comparing uv.lock's own gopher-mcp pin to the tag — a stale pin is caught instead by CI's uv sync --locked, which fails with "the lockfile needs to be updated".
  • README.md carries the mcp-name: io.github.cameronrye/gopher-mcp marker. The MCP Registry proves ownership of the PyPI package by reading the live long description for packages[0].version and looking for that line, and README.md is the long description — without it publish-registry fails with a namespace/ownership error.
  • uv.lock regenerated (uv lock) and committed.
  • Package builds and installs: uv build && uv run python -m twine check dist/*.
  • scripts/validate-release.py passes.
  • Trusted publishing and the pypi/testpypi GitHub environments are configured (see One-time setup).

Testing a release safely

You don't have to risk a real publish to exercise the pipeline.

A pre-release version runs the complete release.yml workflow end to end and publishes to PyPI flagged as a pre-release — safe because pip won't install it by default, and it can be yanked if needed.

git tag -a v0.5.0-rc.1 -m "Release candidate 0.5.0-rc.1"
git push origin v0.5.0-rc.1

Remember to set the matching pre-release version in pyproject.toml and server.json, and add a ## [0.5.0-rc.1] CHANGELOG entry first, since the consistency and changelog checks still apply.

TestPyPI

The separate publish.yml workflow exists for upload tests against TestPyPI. Trigger it manually:

  1. Actions → Publish to TestPyPI → Run workflow. (publish.yml is named Publish to TestPyPI; Publish to PyPI is a job inside release.yml and cannot be triggered by hand.)
  2. Choose testpypi as the target.
  3. Approve the testpypi environment and watch the upload succeed.

This validates building and OIDC publishing without touching production PyPI, but it does not exercise the GitHub Release or tag-based flow.

Local dry run

Fast feedback with no external side effects:

# Skip the long test phase while you iterate on packaging
uv run python scripts/prepare-release.py --version 0.5.0-test --skip-tests

uv run python scripts/validate-release.py
uv build && uv run python -m twine check dist/*
pip install dist/*.whl

After a release

  • Watch GitHub Issues and Discussions for installation or regression reports.
  • Update any version badges or external references if applicable.
  • Move newly merged changes under ## [Unreleased] in the CHANGELOG as work continues toward the next version.

Rollback and yanking

If a published release has a serious problem:

  1. Yank the bad version on PyPI (project page → Manage → the release → Yank). Yanking hides it from new installs without breaking pins that already reference it — it does not delete the files.
  2. Ship a patch (e.g. v0.5.1): branch the fix from the release tag, follow the normal release steps, and note the issue in the CHANGELOG.
  3. Document the problem and resolution in a GitHub issue.

To abort a release that is still in flight:

# Cancel the running workflow in the Actions tab, then remove the tag
git tag -d v0.5.0
git push origin :refs/tags/v0.5.0

Once the underlying issue is fixed, re-run the normal release steps with a new tag.