Releasing¶
This page describes the release process: bumping the version, building
artifacts, and publishing to PyPI + GHCR + GitHub Releases. Most of the
process is automated by release.yml.
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 at release time — you only ever edit one file:
Location |
How it stays in sync |
|---|---|
|
Edit this (canonical). |
|
Resolved at import time from the installed wheel’s METADATA, which is generated from |
|
Auto-updated by the |
|
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 |
What you do NOT edit¶
The release workflow (release.yml)
runs the .github/actions/sync-version composite action as part of the
validate job. That action:
Re-reads
apps/server/pyproject.tomlto confirm the canonical version matches the release input.Updates every Node
package.jsonto that version withjq.Asserts that every version-bearing file agrees. The release fails fast if anything has drifted.
For branch-based releases (releases/X.Y.Z), the synced package.json
edits are pushed back to the same branch as a 🔧 chore(release): sync package.json versions to X.Y.Z commit, so the workspace tree stays
consistent after the run.
Note
Tutorials and changelogs that mention historical versions (e.g. “v0.1.0 supports references but not ControlNet”) are content, not config — the sync action does not touch them. Update them by hand when the relevant feature changes.
Pre-release checklist¶
Before triggering a release:
All CI checks pass on
main.apps/server/pyproject.toml’sversionis bumped to the target (this is the only version string you need to edit).CHANGELOG.mdhas an entry for the new version (under## [Unreleased]→ rename to## [X.Y.Z] - YYYY-MM-DD).uv.lockis up to date (uv lock).Tutorials that quote the previous version’s feature set have been updated to reflect the new behaviour.
The release branch (
releases/X.Y.Z) has been pushed.
Triggering a release¶
Option A: release branch (preferred)¶
Push a branch named releases/X.Y.Z to trigger the workflow:
git checkout -b releases/0.2.0
# Edit apps/server/pyproject.toml: version = "0.2.0"
# Edit CHANGELOG.md
git add apps/server/pyproject.toml CHANGELOG.md
git commit -m "🏷️ chore(release): 0.2.0"
git push -u origin releases/0.2.0
The workflow extracts the version from the branch name and runs the full release pipeline.
Option B: workflow_dispatch¶
Manually trigger from the GitHub Actions UI:
Go to Actions → 🚀 Release → Run workflow.
Enter the version (e.g.
0.2.0).Click Run workflow.
Useful for re-running a failed release after fixing the underlying issue.
Release pipeline¶
The release.yml workflow runs these jobs in order:
graph TD
A[validate] --> B[build]
B --> C[publish-pypi]
B --> D[publish-ghcr]
C --> E[github-release]
D --> E
1. Validate¶
Verifies the input version matches
apps/server/pyproject.toml.Calls
.github/actions/sync-version, which auto-bumps every Nodepackage.jsonto the validated version and re-asserts that every version-bearing file agrees. For branch-based releases, the syncedpackage.jsonedits are pushed back to the release branch.
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¶
Generates release notes via
gh api .../generate-notes(configured by.github/release.yml).Creates a GitHub Release with the wheel + sdist attached.
Cutting a release¶
A typical release flow:
# 1. Make sure main is green
git checkout main
git pull
uv run --directory apps/server poe check
# 2. Bump version — this is the ONLY version string you edit
# Edit apps/server/pyproject.toml: version = "0.2.0"
# (The release workflow will propagate this to every package.json
# and commit them back to the release branch automatically.)
# 3. Update CHANGELOG
# Edit CHANGELOG.md: rename [Unreleased] to [0.2.0] - 2026-07-25
# 4. Update tutorials that pin to a previous version
# e.g. "v0.1.0 supports references but not ControlNet"
# 5. Re-lock
uv lock
# 6. Commit + push
git add apps/server/pyproject.toml CHANGELOG.md uv.lock
git commit -m "🏷️ chore(release): 0.2.0"
git push
# 7. Create the release branch (triggers the workflow).
# The workflow's sync-version step will auto-bump the package.json
# files and push a follow-up commit to this branch.
git checkout -b releases/0.2.0
git push -u origin releases/0.2.0
# 8. Watch the workflow
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.2.0Verify the GitHub Release: https://github.com/xinvxueyuan/NovelAI-Image-MCP/releases
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:
twine uninstallor via the PyPI web UI.Cut a patch release with the fix (
0.2.1).Mark the broken release as a pre-release / yanked on GitHub.
See also¶
CHANGELOG — what changed in each release
.github/release.yml— release-notes auto-generation config