docs-site/developers/releasing.mdx
Releases are prepared with a script that a maintainer runs locally, then built and published by a GitHub Actions workflow:
tools/prepare_release.py — Run first, on your own machine.
Validates the version, checks out the matching release/YY.MM branch, checks
that CI passed, makes sure no duplicate tag or release exists, syncs
translations, then commits the updated .version and pushes the branch. CI
then runs automatically on the resulting commit (for release/** branches).
The CI check can be skipped with --skip-ci-check.
.github/workflows/release.yml — Dispatch after CI passes on the
prepared commit. Builds installers and wheels for all platforms (Linux
x86/ARM, macOS Intel/ARM, Windows), and can optionally sign macOS/Windows
artifacts, create a draft GitHub release, publish wheels to TestPyPI, and
publish wheels to PyPI.
release.yml is workflow_dispatch and uses a release
concurrency group, so two release builds cannot run simultaneously.
The prepare script runs on your machine, so it needs:
gh CLI, with push access to ankitects/anki and to the two
translation repos.rsync on the PATH. It copies the translation templates.packaging module, which the project's uv
environment provides.The script pushes translation template commits to the translation repos before
it commits .version, so a failure after that point leaves those pushes in
place. Re-running the script is safe: the translation step is a no-op when there
is nothing new to copy.
Versions follow calendar versioning with PEP 440: YY.MM for stable releases
(e.g. 26.04), with optional .patch (e.g. 26.04.1) and pre-release
suffixes (b1, rc1, a1). Months must be zero-padded.
Examples: 26.05b1 (beta), 26.05rc1 (release candidate), 26.05 (stable),
26.05.1 (patch).
All releases are cut from a release/YY.MM branch. The branch name uses only
the major version (YY.MM), not the full pre-release suffix — betas, release
candidates, and the stable release all come from the same branch.
Create a release branch from main:
git checkout -b release/26.05 main
git push origin release/26.05
CI runs automatically on push to release/** branches.
Prepare the release (updates .version on the branch):
just release::prepare --version 26.05b1
Verify on TestPyPI:
just release::testpypi --ref release/26.05
Publish the full release:
just release::public --ref release/26.05
For subsequent pre-releases or the stable release from the same cycle,
repeat steps 3-5 with the new version (e.g. 26.05b2, 26.05rc1, 26.05).
After the stable release, merge the release branch back to main to pick up
the .version bump and any cherry-picked fixes:
git checkout main
git merge release/26.05
git push origin main
Delete the release branch after the stable release is published.
For security fixes, an admin should first create a security advisory with a temporary private fork. Work on the fix in the private fork via the normal PR workflow. Do not open a public PR or publish the advisory until the fix is ready for release.
Once the fix is ready:
Create a release branch from the latest release tag:
git checkout -b release/26.05 26.05
Cherry-pick the fix onto the release branch.
Push the branch and wait for CI:
git push origin release/26.05
Prepare and publish:
just release::prepare --version 26.05.1
just release::public --ref release/26.05
Merge the release branch back to main.
For security patches, publish the advisory and credit the reporter if applicable.
flowchart LR
A["<b>prepare_release.py</b>
<i>local</i>
validate version
check out release branch
check CI
check duplicate tag
sync translations
update .version
push to branch"] --> B["<b>CI (ci.yml)</b>
runs automatically
on release/** branches"]
B --> C["<b>release.yml</b>
build all platforms
optionally sign macOS/Windows
optionally create draft GitHub release
optionally publish to TestPyPI/PyPI"]
style A fill:#2d333b,stroke:#539bf5,color:#adbac7
style B fill:#2d333b,stroke:#e5c07b,color:#adbac7
style C fill:#2d333b,stroke:#7ee787,color:#adbac7
flowchart TD
prepare[prepare
<i>validate version,
check CI, check duplicates</i>]
prepare --> mac["build-and-sign-mac
ARM"]
prepare --> macint["build-and-sign-mac-intel
Intel"]
prepare --> win["build-and-sign-windows"]
prepare --> lin[build-linux-x86
<i>installer + wheels</i>]
prepare --> linarmw[build-linux-arm-wheels]
prepare --> linarmi[build-linux-arm-installer]
mac --> release
macint --> release
win --> release
lin --> release
linarmw --> release
linarmi --> release
release["release
draft GitHub release
<i>if draft-release</i>"]
mac --> testpypi
macint --> testpypi
win --> testpypi
lin --> testpypi
linarmw --> testpypi
linarmi --> testpypi
testpypi["publish-testpypi
TestPyPI
<i>if publish-testpypi or publish-pypi</i>"]
release --> pypi
testpypi --> pypi
pypi["publish-pypi
PyPI
<i>if publish-pypi</i>"]
style prepare fill:#2d333b,stroke:#539bf5,color:#adbac7
style mac fill:#2d333b,stroke:#e5c07b,color:#adbac7
style macint fill:#2d333b,stroke:#e5c07b,color:#adbac7
style win fill:#2d333b,stroke:#e5c07b,color:#adbac7
style lin fill:#2d333b,stroke:#e5c07b,color:#adbac7
style linarmw fill:#2d333b,stroke:#e5c07b,color:#adbac7
style linarmi fill:#2d333b,stroke:#e5c07b,color:#adbac7
style release fill:#2d333b,stroke:#7ee787,color:#adbac7
style testpypi fill:#2d333b,stroke:#7ee787,color:#adbac7
style pypi fill:#2d333b,stroke:#7ee787,color:#adbac7
prepare_release.py: takes a version argument and an optional
--skip-ci-check flag. The release branch is derived from the version, so there
is no branch argument.
release: takes a version (must match .version for public release
operations) and five boolean inputs:
sign signs macOS and Windows artifacts.draft-release creates the draft GitHub release.publish-testpypi publishes wheels to TestPyPI.publish-pypi publishes wheels to PyPI.skip-ci-check skips the CI status check.All booleans default to false. Non-release runs use the .version
already in the repo, so builds work without a prepare step.
For a normal public release, enable the first four booleans: sign=true,
draft-release=true, publish-testpypi=true, and publish-pypi=true.
The release workflow uses GitHub environments as manual approval gates. Jobs that access signing credentials or publish artifacts require a reviewer to approve the deployment before they run:
release — Required when sign, draft-release, publish-testpypi, or
publish-pypi is enabled. Protects code-signing secrets, the release token,
and PyPI/TestPyPI trusted publishing/OIDC.When sign is disabled, the macOS and Windows build jobs run without the
release environment so they do not require approval and cannot access signing
secrets.
The release.yml workflow uses independent boolean inputs to control what gets
signed and published:
| Input | Effect |
|---|---|
sign | Signs macOS and Windows artifacts. Requires the release environment. When false, those jobs upload unsigned artifacts and do not access signing secrets. |
draft-release | Creates a draft GitHub release with generated release notes and installer artifacts. Requires sign=true, the release environment, passing CI (unless skipped), no duplicate tag/release, and version matching .version. |
publish-testpypi | Publishes wheels to TestPyPI. Requires the release environment. |
publish-pypi | Publishes wheels to PyPI. Requires the release environment, passing CI (unless skipped), and version matching .version. It also runs and waits for the TestPyPI publish job first. It does not require signing unless draft-release=true. |
skip-ci-check | Skips the CI status check. Useful for hotfix releases. |
version | For draft-release or publish-pypi: must match .version. For build-only, signed-only, or TestPyPI-only runs: ignored (.version from the branch is used automatically). |
The release module defined in release.just wraps both the prepare script and
the release.yml dispatches. release::prepare runs locally and takes no
--ref. Every other recipe dispatches release.yml and requires an explicit
--ref argument pointing to the release branch.
Run just --list --list-submodules to see all available recipes and their
arguments.
release.yml can be dispatched from any branch for testing. The release guards
only apply when draft-release or publish-pypi is enabled. To run a test
build:
release.yml from your branch with all boolean inputs left false:
just release::build --ref <your-branch>
.version from the branch as-is (the version input is
ignored for non-release runs), so no prepare step is needed.To test the signing flow from a feature branch:
release, temporarily add your
branch to the allowed deployment branches.just release::sign --ref <your-branch>
Note:
workflow_dispatchworkflows only appear in the GitHub Actions UI if the workflow file exists on the default branch. Ifrelease.ymlis new or modified on your branch, usegh workflow runto trigger it — the UI dropdown won't show it until it's merged to main.
github.sha. It does not
write .version — that is done by the prepare script. If you dispatch release
before the prepare commit has been pushed, the build will use whatever
.version was HEAD at dispatch time.draft-release=true with sign=false is rejected — draft releases must use
signed installer artifacts.publish-pypi=true, wheels are published to TestPyPI first, then to
PyPI after the TestPyPI job succeeds. If draft-release=true is also set,
PyPI publishing waits for the draft GitHub release to succeed too.26.05b1) are automatically marked as
pre-releases on the GitHub draft release.