Releasing#
A release is a git tag. Everything else follows from it automatically.
That is not a Scenet invention: a GitHub Release is by definition metadata attached to a tag, and the Python Packaging Authority’s own publishing guide triggers on exactly this. The tag is the single source of truth.
Cutting a release#
Four commands.
uv version --bump minor
major, minor or patch. This edits project.version in pyproject.toml and nothing
else. Check what it will do first with --dry-run.
Set the same version in server.json,
in both places it appears — the MCP registry manifest has to name the release it points at, and
a test fails until it does.
Then write the changelog section. CHANGELOG.md follows
Keep a Changelog; rename the ## [Unreleased]
heading to ## [0.2.0] - 2026-08-23 and start a fresh Unreleased above it.
Write it for people. These become the release notes verbatim. A list of commit subjects is not release notes — it is a list of commit subjects.
main is protected, so the version bump and changelog go through a pull request like
anything else:
git switch -c release/0.2.0
git commit -am "chore: release 0.2.0"
git push -u origin release/0.2.0
gh pr create --fill && gh pr checks --watch
gh pr merge --squash --delete-branch
Then tag the merged commit on main:
git switch main && git pull
git tag -a v0.2.0 -m "scenet 0.2.0"
git push origin v0.2.0
Pushing the tag is what starts everything. Tag only a green main — the release
workflow re-runs every gate anyway, but discovering a failure after the tag exists means
deleting a tag, and v* tags are protected against exactly that.
What happens next#
push tag v0.2.0
│
├─ verify tag == project.version?
│ CHANGELOG has a [0.2.0] section?
│ format, lint, types, full test suite
│
├─ build uv build → sdist + wheel
│ install the wheel in a clean environment and exercise it
│
├─ extension npm ci → tsc → vsce package → scenet.vsix
│
├─ publish ⏸ waits for you to approve the `pypi` environment
│ → pypi.org, via Trusted Publishing, with attestations
│
└─ release → GitHub Release, notes from CHANGELOG,
sdist + wheel + vsix attached
Everything that can fail runs before anything is published. PyPI never allows
re-uploading a version — not after a delete, not ever — so a half-finished release burns
that number permanently. The verify job exists to make that impossible.
You will get an email when the run reaches publish. Open it, click Review
deployments, tick pypi, Approve and deploy. That click is the last gate between a
tag and PyPI.
Removing that gate#
The approval is a required reviewer on the pypi GitHub Environment, not anything in
the workflow file. It can be removed, and the release then runs unattended from the tag:
gh api -X PUT repos/creatoan/scenet/environments/pypi --input - <<'JSON'
{"wait_timer": 0, "reviewers": null, "deployment_branch_policy": null}
JSON
What you give up. PyPI never allows re-uploading a version — not after a delete, not
ever — so an accidental or mistaken tag burns that number permanently, and the click is
what makes a tag reversible right up to the last moment. What you keep is not nothing:
verify still runs format, lint, types and the full suite before anything is published,
the tag must match project.version, the CHANGELOG must have a section for it, and v*
tags are protected against deletion.
It is a real trade. Releases stop being synchronous, which matters when the person who tagged is not around to approve; and the gate protects against a mistake that cannot be undone. Decide once, deliberately, rather than discovering it mid-release.
Listing in the MCP registry#
Optional, and after PyPI has the release. The
official MCP registry stores metadata only:
server.json describes how to launch scenet mcp from the PyPI package, and the registry checks
that the package’s description — the README — carries the mcp-name marker for the
io.github.creatoan/scenet namespace.
This is done by a workflow, not from a laptop. mcp-publisher login github — the interactive
login — fails for an organisation namespace: the registry’s GitHub App is not installed on the
organisation, so the registry sees only io.github.<your-user>/* and answers 403 for
io.github.creatoan/*, whatever the role and whatever the membership visibility. That is an open
upstream bug reported by several people
(#1468,
#1537,
#1649). The workflow,
.github/workflows/registry.yml, logs in the other way the registry supports — GitHub’s OIDC token,
which proves the namespace through the repository it runs in — and needs no secret.
To list a release that already exists, once: Actions → MCP Registry → Run workflow, giving the
tag (v0.8.0). To list every future release automatically, set the opt-in variable, which makes
release.yml call the same workflow after the release is out:
gh variable set MCP_REGISTRY_PUBLISH --body true
Nothing waits on it: a failed listing leaves a complete release and can be re-run from the Actions tab. Leave the variable unset until a hand-run has succeeded once — that login route is unproven for this namespace until it has.
The workflow checks that server.json names the tag’s version and that the version is on PyPI,
then runs mcp-publisher validate, login github-oidc and publish. publish has no dry-run
flag: an unknown flag is ignored, so publish --dry-run publishes. Use validate, which checks
the manifest against the live registry and changes nothing.
mcp-publisher is pinned to a version and a SHA-256 in the workflow, because it runs with the
credential in reach. To update it, download the new linux_amd64 tarball, check it, and replace
both values.
A listing can be withdrawn. mcp-publisher status --status deprecated|deleted <name> <version>
(or --all-versions) changes a version’s status, with an optional --message.
PyPI is opt-in#
The publish job is skipped unless the repository variable PYPI_TRUSTED_PUBLISHER
is true:
gh variable set PYPI_TRUSTED_PUBLISHER --body true
That exists because a trusted publisher has to be registered through a web form on pypi.org – there is no API, deliberately, since it is the thing that grants publishing rights. Until somebody has done that, the job cannot succeed, and gating it on an explicit switch is better than every release failing on a step nobody enabled.
A release still happens without it. The GitHub Release is created either way, with
the sdist, the wheel and the .vsix attached, and the notes say to install from the
wheel URL rather than claiming a pip install scenet that would not work. The release
is only blocked if PyPI is enabled and fails – announcing a version whose upload
broke halfway would be worse than not announcing it.
Rehearsing first#
Before the first real release, or any release you are nervous about, run the TestPyPI
rehearsal workflow from the Actions tab. It publishes to test.pypi.org — a throwaway
instance of the same software, periodically wiped — and then installs the result back in a
clean environment to prove the artifact works.
TestPyPI also refuses duplicate versions, so pass a dev1, dev2… suffix when you
re-rehearse the same version.
If something goes wrong#
The tag does not match project.version. verify fails before anything is published.
Delete the tag, fix, re-tag:
git tag -d v0.2.0 && git push origin :refs/tags/v0.2.0
Published to PyPI, then found a bug. You cannot replace it. Yank the release on PyPI — which hides it from new installs while leaving it available to anyone who pinned it — and release a patch version. Yanking is the recovery mechanism; deleting is not.
The GitHub Release step failed after PyPI succeeded. The package is out. Re-run just that job from the Actions tab; it is idempotent apart from the release already existing.
The AI-generation disclosure is a release requirement#
Every published surface must carry the statement that this project is deliberately AI-generated. Not as a courtesy — as a condition of publishing. Anyone who finds the package on PyPI, installs the extension, or opens the playground is entitled to know what they are looking at before they decide to rely on it.
Surface |
Where it comes from |
|---|---|
The PyPI project page |
|
The GitHub landing page |
|
The documentation site |
|
The playground |
the header of |
The VS Code extension |
the |
tests/test_disclosure.py checks all of them, and checks that the README’s disclosure is
above the fold and is a heading rather than a footnote. It is tested rather than trusted
because it is exactly the kind of thing that quietly disappears: somebody rewrites a
description, trims a README, restructures a page, and it goes with them. Nobody notices,
because nothing was watching.
Adding a new published surface means adding it to that test. A new marketplace listing, a new landing page, a package on another index — each is a place a stranger can arrive without having read anything else.
Versioning#
Semantic versioning, applied to two things:
The Python API — everything in
scenet.__all__. Names outside it are internal and may change in a minor release.The language — a construct that stops compiling is a major change. New optional keys are minor.
format_version inside a Panel Core document is separate, and bumped only when the shape
of that document changes incompatibly.
While the version is below 1.0.0, minor versions may break things. That is what being
below 1.0 means, and the status is alpha for a reason.
What is not automated, deliberately#
Nothing decides on its own that a release should happen. There is no bot reading commit messages and opening a release pull request.
That is a choice. Conventional-commit automation trades a small amount of typing for a strict format on every commit message forever, and for a project of this size the arithmetic does not work out. Deciding that a release is worth making is a judgement, and it is a cheap one to make by hand.