Releasing¶
This page describes the release process: bumping the version, authoring the release notes, and publishing to PyPI + GHCR + GitHub Releases + the MCP Registry. Pushing a version tag runs the whole pipeline; the tag is the only trigger.
Versioning¶
The project follows Semantic Versioning:
MAJOR.MINOR.PATCHMAJOR: breaking API changesMINOR: new features, backward-compatiblePATCH: bug fixes, backward-compatible
Single source of truth¶
The canonical version lives in
apps/server/pyproject.toml’s
[project] version field. Every other place that carries the version is
derived from it, and one command writes them all:
uv run --directory apps/server poe sync-versions # write
uv run --directory apps/server poe check-versions # verify only (what CI runs)
Location |
How it stays in sync |
|---|---|
|
Edit this (canonical). |
|
|
|
|
|
Resolved at import time from the installed wheel’s METADATA, which is generated from |
|
Resolved at Sphinx build time from the installed package metadata, with a |
|
Injected at build time from |
Docker image labels ( |
Injected via |
Git tag, PyPI version, GitHub Release |
All sourced from the tag that triggered the run. |
Important
The release workflow verifies these files, it never writes them. Nothing
is pushed back to the repository while a release runs, so a published
artifact can never disagree with the commit it was built from. Run
poe sync-versions and commit before tagging — poe check-versions fails
the run otherwise.
Pre-release checklist¶
Before pushing the tag:
All CI checks pass on
main, andmainis pushed.apps/server/pyproject.toml’sversionis bumped (the only version string you edit by hand).CHANGELOG.mdhas a section for the new version (rename## [Unreleased]to## [X.Y.Z] - YYYY-MM-DDand add a fresh[Unreleased]).release-notes/X.Y.Z.jsonexists and renders.poe sync-versionshas been run and committed (threepackage.jsonfiles plusserver.json).uv lockis up to date (uv lock).poe check,uv run reuse lintandpoe check-versionsare green.Tutorials that quote the previous version’s feature set are updated.
Triggering a release¶
Option A: push the tag (the normal path)¶
The tag is the release decision. It must point at a commit that is already on
main — the validate job checks the ancestry and refuses a tag that is not
on the baseline:
git tag -a v0.5.2 -m "v0.5.2"
git push origin v0.5.2
The workflow resolves the version from the tag name and runs the full pipeline against that commit.
Option B: workflow_dispatch (re-run an existing tag)¶
Manually trigger from the GitHub Actions UI (or the CLI) to rebuild and re-publish an existing tag — useful after a transient failure or to refresh the release notes:
gh workflow run release.yml -f version=0.5.2
The tag must already exist; this path never rewrites it.
Release pipeline¶
The release.yml workflow runs these jobs:
graph TD
A[validate] --> B[build]
B --> C[publish-pypi]
B --> D[publish-ghcr]
C --> E[github-release]
D --> E
C --> F[publish-mcp]
D --> F
1. Validate¶
Resolves the version from the tag (or the dispatch input) and rejects anything that is not
X.Y.Z.Confirms the tag exists and its commit is an ancestor of
origin/main.Confirms
apps/server/pyproject.tomlcarries the tag’s version.Runs
apps/server/scripts/sync_versions.py --check: every derived manifest must already agree (verification only — nothing is written).Confirms
CHANGELOG.mddocuments the version.Renders
release-notes/<version>.jsonand uploads it as therelease-notesartifact.
2. Build¶
uv build --package novelai-image-mcpproduces wheel + sdist.Uploads them as a CI artifact (
python-distributions).
3. Publish to PyPI¶
Uses OIDC trusted publishing (no API token).
Requires the GitHub environment
pypito be configured for trusted publishing on PyPI’s side.
4. Publish to GHCR¶
Builds the Docker image (multi-platform via Buildx).
Pushes to
ghcr.io/<owner>/<repo>:<version>,:<major>.<minor>,:<major>, and:sha-<short>.Signs the image with cosign (keyless, via OIDC).
5. GitHub Release¶
Renders the JSON release notes and appends the auto-generated commit list.
Creates the GitHub Release for the existing tag (
--verify-tag) with the wheel + sdist attached. A re-run updates the notes and re-uploads the assets instead of failing.
6. Publish to MCP Registry¶
Runs after PyPI and GHCR are live (
needs: [validate, publish-pypi, publish-ghcr]).Installs
mcp-publisher, validatesserver.jsonagainst the registry schema, logs in via GitHub OIDC, publishes, then verifies the server is listed atregistry.modelcontextprotocol.io.Inlined rather than a separate tag-triggered workflow so one tag push publishes everywhere atomically. The standalone
publish-mcp.ymlremains available for manual re-publication after aserver.jsonfix.
Cutting a release¶
A complete release, end to end:
# 1. Make sure main is green and current
git checkout main
git pull
uv run --directory apps/server poe check
# 2. Bump the canonical version (the ONLY version string you edit)
# apps/server/pyproject.toml: version = "0.5.2"
# 3. Close the changelog section
# CHANGELOG.md: rename [Unreleased] to [0.5.2] - 2026-10-13,
# add a fresh empty [Unreleased]
# 4. Author the announcement
# release-notes/0.5.2.json (see the format above)
# 5. Propagate the version and re-lock
uv run --directory apps/server poe sync-versions
uv lock
# 6. Verify everything the workflow will verify, then commit and push
uv run --directory apps/server poe check
uv run --directory apps/server poe check-versions
uv run reuse lint
git add apps/server/pyproject.toml CHANGELOG.md release-notes/0.5.2.json \
package.json apps/server/package.json apps/docs/package.json \
server.json uv.lock
git commit -m "🏷️ chore(release): 0.5.2"
git push
# 7. Tag the release commit and push the tag — this triggers the pipeline
git tag -a v0.5.2 -m "v0.5.2"
git push origin v0.5.2
# 8. Watch it
gh run watch
Post-release¶
Verify the package on PyPI: https://pypi.org/project/novelai-image-mcp/
Verify the image on GHCR:
docker pull ghcr.io/<owner>/<repo>:0.5.2Verify the GitHub Release: https://github.com/xinvxueyuan/NovelAI-Image-MCP/releases
Verify the MCP Registry listing (automatic via the
publish-mcpjob): https://registry.modelcontextprotocol.io/Announce in the project’s discussion / chat.
Rollback¶
If a release is broken:
Do not re-publish the same version on PyPI (PyPI doesn’t allow re-uploads). Yank the broken release via the PyPI web UI.
Cut a patch release with the fix (
0.5.3).Mark the broken release as a pre-release / yanked on GitHub.
History¶
Releases up to and including 0.5.1 were triggered by pushing a
releases/X.Y.Z branch, with the workflow writing the synced manifests back
to that branch. The branches were removed once the tag flow landed; the
historical vX.Y.Z tags remain and are the record of those releases.
See also¶
CHANGELOG — what changed in each release
release-notes/README.md— the JSON notes format.github/release.yml— grouping for the appended commit list