Build a Python package once and publish it to both PyPI and anaconda.org from a single tag.
- No PyPI token anywhere. PyPI uses trusted publishing (OIDC).
- No conda installation. The conda package is built by
rattler-build, a single static binary — no
Miniforge, no
conda install, noanaconda-client. - No duplicated metadata. The conda recipe is generated from
pyproject.toml, so the conda package cannot drift from the PyPI one. - One sdist feeds both, so the two packages are identical at the source level.
jobs:
publish:
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # required: mints the OIDC token for PyPI
contents: read
steps:
- uses: actions/checkout@v5
- uses: PlohnenSoftware/PyPI-Anaconda-Publish@v2
with:
test: pytest
python_versions: auto
tag: ${{ github.ref_type == 'tag' && github.ref_name || '' }}
anaconda_owner: your-channel
anaconda_api_key: ${{ secrets.ANACONDA_API_KEY }}
dry_run: ${{ github.event_name == 'pull_request' }}Leave anaconda_owner unset to publish to PyPI only; rattler-build is then
never downloaded.
Every example below is synthetic — the package names, channel names and URLs are placeholders. Swap in your own.
Build and publish a pure-Python project to PyPI on every tag, no conda:
# .github/workflows/release.yml
name: Release
on:
push:
tags: ["v*.*.*"]
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # mints the OIDC token for PyPI
contents: read
steps:
- uses: actions/checkout@v5
- uses: PlohnenSoftware/PyPI-Anaconda-Publish@v2
with:
test: pytest
tag: ${{ github.ref_name }}That is the whole job. The version is read from pyproject.toml; the tag only
triggers the run and is checked against that version before anything is built.
prepare(optional) — generate the project, before any metadata is read- Verify — the version against a tag and against a module's
__version__ - Test — across every Python version the project supports, in one job
- Build — wheel + sdist, then
twine check --strict - Conda (optional) — generate the recipe from
pyproject.toml, build with rattler-build - Publish — PyPI via OIDC and anaconda.org, independently
Everything that can fail cheaply happens in steps 1–5, so nothing is uploaded unless every artifact already exists and passes its checks. That matters because a PyPI version number is burned the moment it is uploaded and can never be reused.
Neither upload can stop the other from being attempted. Each records its own outcome and the job fails afterwards if either did:
| PyPI | anaconda.org | Job |
|---|---|---|
| ✅ | ✅ | passes |
| ❌ | ✅ | fails — but the conda package is published |
| ✅ | ❌ | fails — re-upload the conda package by hand |
| skipped | ✅ | passes |
By the time uploads start, only network calls remain, so a failure in one says nothing about the other. Letting the anaconda.org upload proceed after a PyPI failure costs nothing — conda uploads are replaceable — and saves a manual re-upload.
project_dir (default .) — the directory holding the pyproject.toml to
build, relative to the repository root. Point it at a sub-package in a monorepo.
with:
project_dir: packages/coreprepare (default empty) — a shell command run from the repository root
before any metadata is read. Use it to create the project that project_dir
points at: stub generators, codegen, vendoring, a slimmed runtime package.
with:
prepare: uv run codegen build --out .build/generated
project_dir: .build/generatedThe published version always comes from [project] version in
pyproject.toml — and it must be static (dynamic versions are rejected).
Two optional checks catch mismatches before a build burns a version number:
tag (default empty) — a tag the version must equal. A leading v is
stripped, so v1.4.0 matches version 1.4.0.
with:
tag: ${{ github.ref_type == 'tag' && github.ref_name || '' }}Tagging v1.4.0 while the file still says 1.3.0 re-publishes 1.3.0, which
PyPI rejects as a duplicate — after the build, when finding out is no longer
useful. Passing tag: turns that into an immediate, legible failure.
version_module (default empty) — a Python file whose __version__ must
equal the project version, to catch drift between the metadata and the code.
with:
version_module: src/myproject/__init__.pytest (default empty) — the bare command run via uv run inside
project_dir. Leave it empty to skip testing entirely. Give the command, not
the runner: pytest, not uv run pytest.
with:
test: pytestpython_versions (default empty) — which Python versions to run test
against:
| Value | Effect |
|---|---|
| empty | run once on the runner's default interpreter |
auto |
intersect requires-python with the stable CPython releases uv can fetch |
"3.10 3.13" |
test exactly those versions |
with:
test: pytest
python_versions: autoThis runs inside one job — uv downloads interpreters on demand — so there is no build matrix, which matters because a matrix would mean a reusable workflow, and reusable workflows cannot use PyPI trusted publishing (see Setup). See Python version support for the details.
The two publishers are independent; each can be turned off on its own.
publish_pypi (default true) — upload the wheel and sdist to PyPI via
trusted publishing (OIDC). Set it false to publish to anaconda.org only —
useful before a PyPI trusted publisher exists.
with:
publish_pypi: falsepypi_publish_url (default empty) — an alternative index URL, for
rehearsing on TestPyPI.
with:
pypi_publish_url: https://test.pypi.org/legacy/anaconda_owner (default empty) — the anaconda.org user or organisation to
upload to. Leaving it empty skips conda entirely: rattler-build is never
downloaded and no conda package is built.
with:
anaconda_owner: my-channelanaconda_label (default main) — the label to upload under, e.g. dev
for a pre-release channel.
with:
anaconda_label: devanaconda_api_key (default empty) — the anaconda.org token. Required
whenever anaconda_owner is set and dry_run is false.
with:
anaconda_api_key: ${{ secrets.ANACONDA_API_KEY }}build_number (default 0) — the conda build number, for republishing an
unchanged version as a fresh build.
with:
build_number: 1dry_run (default false) — build, test and check everything, upload
nothing. This is the cheapest way to find metadata errors before a tag exists.
with:
dry_run: ${{ github.event_name == 'pull_request' }}rattler_build_version (default latest) — the rattler-build release tag
to fetch, or latest. Pin it for reproducibility.
with:
rattler_build_version: v0.8.0Optional. This table holds only what conda needs that pyproject.toml cannot
already express; everything else (name, version, dependencies, license) is read
from [project]. It lives in pyproject.toml:
[tool.conda-recipe]
noarch = true # false for compiled extensions
build-channels = ["conda-forge"] # where dependencies come from, not where you publish
maintainers = ["some-handle"]
test-imports = ["myproject"] # imported in the conda test env, with pip check
name-map = { PyYAML = "pyyaml" } # PyPI name -> conda-forge name, where they differ
requirement-overrides = {} # escape hatch for markers/extras conda cannot express
extra-run = [] # conda-only runtime requirementsnoarch (default true) — mark the package noarch: python, so a single
build serves every platform. Set false for compiled extensions, and matrix the
calling job over runners (see Architecture support).
build-channels (default ["conda-forge"]) — where build dependencies
come from, not where you publish.
maintainers — recorded as recipe-maintainers in the generated recipe.
test-imports — modules imported in the conda package's test environment,
followed by a pip check.
name-map — map a PyPI name to its conda-forge name where they differ.
Either spelling works as a key.
requirement-overrides — escape hatch for requirements conda cannot
express (environment markers, extras, direct URLs). A requirement whose
normalized name matches a key here is substituted verbatim and the rest of the
translation is skipped.
extra-run — runtime requirements to add to the conda package only.
Dependency translation fails loudly rather than silently dropping anything.
Environment markers, extras and direct URL requirements have no conda
equivalent, so they raise an error naming the requirement and pointing at
requirement-overrides. ~= expands to conda's two-sided form
(~=1.4.5 → >=1.4.5,<1.5.0a0), and names are normalised per PEP 503.
PyPI + conda, tested across every supported version, rehearsed on PRs:
- uses: PlohnenSoftware/PyPI-Anaconda-Publish@v2
with:
test: pytest
python_versions: auto
version_module: src/myproject/__init__.py
tag: ${{ github.ref_type == 'tag' && github.ref_name || '' }}
anaconda_owner: my-channel
anaconda_api_key: ${{ secrets.ANACONDA_API_KEY }}
dry_run: ${{ github.event_name == 'pull_request' }}| Input | Default | Meaning |
|---|---|---|
project_dir |
. |
Directory holding the pyproject.toml to build |
prepare |
— | Shell command run from the repo root before anything else |
test |
— | Command run via uv run; empty skips testing |
python_versions |
— | Versions to test against, or auto |
version_module |
— | File whose __version__ must match the project version |
tag |
— | Tag the version must match, e.g. v1.2.3 |
publish_pypi |
true |
Publish to PyPI |
pypi_publish_url |
— | Alternative index, e.g. TestPyPI |
anaconda_owner |
— | anaconda.org owner; empty skips conda entirely |
anaconda_label |
main |
anaconda.org label |
anaconda_api_key |
— | anaconda.org token |
build_number |
0 |
conda build number |
dry_run |
false |
Build and check, upload nothing |
rattler_build_version |
latest |
Pin rattler-build |
| Output | Meaning |
|---|---|
version |
The project version that was built |
Pure-Python projects are built once, and that single build is valid on every architecture. No matrix, no cross-compilation, no per-architecture upload:
| Artifact | Tag | Covers |
|---|---|---|
| wheel | py3-none-any |
every OS and architecture |
| conda package | noarch: python |
linux-64, linux-aarch64, linux-ppc64le, osx-64, osx-arm64, win-64, … |
The conda package lands in the channel's noarch/ folder and conda serves it to
every platform. Per rattler-build: "Packages marked as noarch: python contain
only pure Python code without compiled extensions. These packages work across
all Python versions and platforms from a single build."
The action itself runs on any runner. rattler-build is fetched as the static
binary matching the host — Linux x86_64 / aarch64 / ppc64le, macOS x86_64 /
arm64, Windows x86_64 / aarch64 — and verified against the published .sha256.
noarch stops applying, because a compiled package is per-platform:
- Set
noarch = falsein[tool.conda-recipe]. - Matrix the calling job over runners. Each run builds and uploads its own platform's artifact under the same package name, which is how anaconda.org expects multi-platform packages to arrive.
jobs:
publish:
strategy:
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, macos-13, windows-latest]
runs-on: ${{ matrix.runner }}For ABI3 extensions there is a middle option — one build per platform still
covering every Python version. See rattler-build's version_independent.
Two questions with two different answers.
Which versions can install it? Everything requires-python allows, from the
same single artifact. A py3-none-any wheel and a noarch: python conda package
both carry the floor as a constraint (python >=3.10) rather than being built
once per version. Nothing to configure.
Which versions are actually tested? That is python_versions:
with:
test: pytest
python_versions: autoauto reads requires-python, intersects it with the stable CPython releases
uv can fetch — betas and free-threaded builds filtered out — and runs the test
command on each. Today >=3.10 expands to 3.10 3.11 3.12 3.13 3.14.
This happens inside one job, because uv downloads interpreters on demand. No build matrix — which matters, because a matrix would mean a reusable workflow, and reusable workflows cannot use PyPI trusted publishing (see below).
Pin the list explicitly when you want a predictable release gate:
with:
python_versions: "3.10 3.14" # floor and ceiling onlyEmpty tests once on the runner's default interpreter.
At https://pypi.org/manage/account/publishing/ for a project that does not exist yet, or the project's Publishing settings for one that does:
| Field | Value |
|---|---|
| Owner | your GitHub org or user |
| Repository | your repository, not this one |
| Workflow name | your caller's filename, e.g. publish.yml |
| Environment name | pypi |
Register the caller. This is a composite action, so its steps run inside your
job and the OIDC job_workflow_ref claim points at your workflow file.
That is also why it is an action and not a reusable workflow. PyPI cannot match a reusable workflow's claims against a publisher config, so trusted publishing fails there with
invalid-publisher(warehouse#11096).
The workflow filename is load-bearing: renaming it breaks publishing with a 403 until the publisher config is updated to match.
The account that creates a pending publisher becomes the owner of the project on first publish.
Create a single environment — pypi by convention — and use that same name in
the PyPI publisher config and in your job's environment:. It covers TestPyPI
too: register a publisher there with the same environment name and switch index
with pypi_publish_url. No second environment needed.
Restrict its deployment branches to the tag pattern v*. With no PyPI secret to
gate on, the OIDC claims are the only thing controlling who can publish, and the
environment name is one of those claims.
Only for repositories that publish conda packages. Create a token with Allow write access to the API site at https://anaconda.org/settings/access, as a user with upload rights to the target organisation, and add it as a repository secret.
The input is
anaconda_api_key, not..._token— that is the name rattler-build reads.
pyproject.tomlwith a static[project] version. Dynamic versions are rejected: the tag check and the conda recipe both need to read the version without building.id-token: writeon the calling job, for PyPI.- Nothing else. uv is installed by the action; Python comes from the runner.
.github/workflows/test.yml runs the action against test/fixture in dry-run
mode on Linux and macOS, covering the conda path, the PyPI-only path and the
generated-project path. Get it green before moving the v1 tag — consumers pin
a moving major tag, so a bad push breaks every one of them at once.
Releases use major tags only — v1, v2 — with no patch tags. The tag is
signed and moved forward for every compatible change:
git tag -f -s v1 -m "v1" && git push --force origin v1A breaking input change means a new v2, leaving v1 where it is.
MIT — see LICENSE. © PlohnenSoftware.