Releasing¶
carrel ships to PyPI from GitHub Actions through Trusted Publishing — no API token exists anywhere. A release is four steps; only the last one touches PyPI.
1. Bump the version (one file)¶
Which number moves (D-023): if an invocation that succeeded before can now exit non-zero, it is a minor bump — a refusal, a new exit code on an existing path, a default that flips from permissive to strict. Everything else is a patch, including new flags, new output fields and fixed crashes. "The new behaviour is better" and "the escape hatch is documented" were both true of the v0.4.1 guard that shipped as a patch and could start failing a cron job; neither is the test.
# edit product.json → "version": "X.Y.Z"
uv run python scripts/sync_product.py # _product.py, pyproject, manifests, CITATION
uv lock # uv.lock pins carrel's own version, and sync_product does not write it
uv.lock is one of the derived copies, and sync_product.py does not write
it. CI runs uv sync under UV_LOCKED=1, so a stale lock fails every job
before a single test runs; locally uv run relocks silently and hides it. Check
before pushing a version bump — read-only, no venv churn:
uv lock --check # not `uv run …` — that relocks first and hides the answer
The uv-lock-current pre-commit hook runs the same command, so the usual failure
— the lock updated on disk by a local uv run, then never staged — is caught at
commit time by a real git hook. Run pre-commit as uvx pre-commit or through
the installed git hook: invoking it through uv run relocks before any hook
runs and so cannot catch it. CI's UV_LOCKED=1 sync is the backstop, and it
fails every job in seconds.
sync_product.py rewrites src/carrel/_product.py, pyproject.toml
(version, description, [project.urls]), every plugin manifest under
plugins/*/.claude-plugin/plugin.json, .claude-plugin/marketplace.json, and
CITATION.cff. Never edit those by hand — tests/test_product_sync.py and the
lint CI job fail when any copy drifts.
Runtime floors for parsers of untrusted input (D-026). carrel opens files it
did not write, so for the dependencies that parse them — pypdf, pillow,
openpyxl, markdown-it-py — check each project's security advisories and raise
the pyproject.toml floor to the newest release that fixes one, provided that
release still supports carrel's requires-python and every platform in its
classifiers, and is not newer than what uv.lock pins (CI tests the lock, so a
floor above it is untested). A floor is what pip install carrel into an existing
environment actually gets; the lock only decides what CI runs.
Classify the raise under D-023 by what it does, not by what it is. Hardening
releases add limits, and a limit can refuse a file the older version accepted —
pypdf 6's LimitReachedError does exactly that — so a raise that crosses such a
release is a minor bump, logged as **Changed (behaviour, dependencies):**.
Only a raise whose releases change no parsing outcome is a patch.
Then add a ## vX.Y.Z — YYYY-MM-DD entry at the top of CHANGELOG.md
(the test suite checks the heading exists; the publish workflow checks it again).
Then regenerate docs/REFERENCE.md (its header names the version) and the doc
samples that print it — the carrel --version banner, the carrel doctor
header, the pack header, catalog export JSON, the claude plugin list
block and the SessionStart hook summary:
uv run python scripts/sync_reference.py
uv run pytest tests/test_docs_drift.py # lists every stale file:line
For each reported site, re-run the command shown in that doc block and paste
its output; the plugin.json templates in PLUGIN_AUTHORING.md just take the
new version. Never hand-edit the numbers in real output — the samples are only
worth having because they are real.
2. Land it through a PR¶
main is protected: push a branch, open a PR, and wait for every required check
to go green — lint, test (py3.12), test (py3.13), test (py3.14),
test-minimal and test-minimal (macos); the list is REQUIRED_CHECKS in
scripts/github-harden.sh. test-minimal (windows) is advisory until it is
promoted, so a red one does not block — read it anyway. Per CLAUDE.md the PR's
/code-review must also have completed, with every confirmed finding fixed in
the PR or deferred in STATE.md. Then squash-merge.
3. Tag and publish the GitHub Release¶
git switch main && git pull --ff-only
sha=$(gh pr view N --json mergeCommit -q .mergeCommit.oid) # N = the release PR
awk '/^## vX\.Y\.Z /{f=1;next} /^## /{f=0} f' CHANGELOG.md > /tmp/notes.md
gh release create vX.Y.Z --title "vX.Y.Z" --target "$sha" \
--notes-file /tmp/notes.md --generate-notes
--target pins the tag to the commit you verified: without it the tag lands
on whatever main points at, so a PR merged in the meantime ships to PyPI as
this version, undescribed and unreviewable (PyPI versions are immutable).
Take the SHA from the release PR itself, not from git rev-parse HEAD after
the pull: if anything merged after the release PR, HEAD is that later commit
and --target would pin exactly the change it exists to keep out.
--notes-file puts the CHANGELOG entry first in the release body — that is
where a behaviour change and its escape hatch have to be visible — and
--generate-notes appends the PR list after it.
The tag must be v + the product.json version — the workflow refuses
anything else. Tags matching v* are protected by a ruleset (no deletion, no
force-move).
4. Watch the publish workflow¶
.github/workflows/publish.yml runs two jobs:
| job | what it does |
|---|---|
build |
verifies tag ↔ product.json ↔ CHANGELOG, regenerates and diffs the generated copies, uv build, installs the wheel in a clean venv and checks carrel --version, uploads dist/ |
publish |
the only job with id-token: write; downloads dist/, publishes with pypa/gh-action-pypi-publish in the pypi environment with PEP 740 attestations |
gh run watch --exit-status
PyPI side (one-time, after any repo move)¶
The trusted publisher on https://pypi.org/manage/project/carrel/settings/publishing/ must read exactly:
| field | value |
|---|---|
| Owner | coltonbearden |
| Repository name | carrel |
| Workflow name | publish.yml |
| Environment name | pypi |
If the repository is transferred or renamed, PyPI rejects the OIDC token until this entry is updated — that is the only manual step in the whole flow.
If a release fails¶
buildred on the tag check → the tag is wrong; delete the release, fixproduct.json, land it, re-release.publishred with an OIDC error → the PyPI trusted-publisher entry above does not match; fix it and re-run the job (gh run rerun <id> --failed).- Never re-upload an existing version: PyPI is immutable. Bump a patch version.