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 commit —
validate-releaserefuses the tag unless a completed, successfulci.ymlrun 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.1are detected automatically, marked as a pre-release on GitHub, and published to PyPI as a pre-release (sopip install gopher-mcpwon'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
- Sign in to PyPI with an account that has two-factor authentication enabled.
- 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.
-
Add a GitHub Actions trusted publisher with these exact values:
Setting Value Repository owner cameronryeRepository name gopher-mcpWorkflow filename release.ymlEnvironment name pypi
These must match the workflow exactly — the publish-pypi job in
release.yml runs in the pypi environment with id-token: write.
TestPyPI (optional, recommended)
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.
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):
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
- 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.
- When the
Publish to PyPIjob pauses for thepypienvironment, review the prior steps and approve the deployment. -
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-registryjob reads it back from the public search endpoint itself, so a green job is the proof; the entry isio.github.cameronrye/gopher-mcp. - The container image —
ghcr.io/cameronrye/gopher-mcp:<version>, plus:latestfor a stable release only. A pre-release tag never moveslatest. - Installation:
- GitHub Releases — notes
and attached artifacts (
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 trackuv.lock(ruff-pre-commitandbandit, so the hook and CI'suv run ruff check ./uv run banditagree on the rule set), and a bareuv run pre-commit autoupdatebumps 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 .anduv 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/anduv run pip-audit.pip-auditis advisory-only in CI and in the release workflow (the nightly audit tracks findings in asecurity-auditissue), 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.mdand configuration examples reflect any new behavior. -
CHANGELOG.mdhas 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.pydoes 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. -
versioninpyproject.tomlmatches the tag you will create. - Every
server.jsonversion matches the tag: the top-levelversion, each package'sversion, and the image tag inside the OCI package'sidentifier(the registry rejects aversionkey on an OCI package, so for that entry the tag is the version).scripts/prepare-release.pymoves 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 comparinguv.lock's owngopher-mcppin to the tag — a stale pin is caught instead by CI'suv sync --locked, which fails with "the lockfile needs to be updated". -
README.mdcarries themcp-name: io.github.cameronrye/gopher-mcpmarker. The MCP Registry proves ownership of the PyPI package by reading the live long description forpackages[0].versionand looking for that line, andREADME.mdis the long description — without itpublish-registryfails with a namespace/ownership error. -
uv.lockregenerated (uv lock) and committed. - Package builds and installs:
uv build && uv run python -m twine check dist/*. -
scripts/validate-release.pypasses. - Trusted publishing and the
pypi/testpypiGitHub environments are configured (see One-time setup).
Testing a release safely
You don't have to risk a real publish to exercise the pipeline.
Pre-release tags (recommended)
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.
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:
- Actions → Publish to TestPyPI → Run workflow. (
publish.ymlis namedPublish to TestPyPI;Publish to PyPIis a job insiderelease.ymland cannot be triggered by hand.) - Choose
testpypias the target. - Approve the
testpypienvironment 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:
- 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.
- 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. - 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.