Skip to content

Release checklist

The procedure for cutting a release of openauc-io. It is written down so a release is a repeatable act rather than a remembered one.

Status — 0.1.0a1 is released

v0.1.0a1 was tagged and published as a GitHub pre-release, and openauc 0.1.0a1 is on PyPI. The machinery below has now been run to completion, not only in dry-run form.

Publication went through Trusted Publishing: publish.yml mints a short-lived OIDC token per run, and the protected pypi GitHub environment is the only place that token is issued from. There is no PyPI API token.

This note records a point in time, not a permanent property of the project. Update it when the next release is made — the "After the release" steps below say so, and no test pins it.

What is automated and what is not

Step Automated
Lint, format, types, tests, strict docs build scripts/release_check.py, and CI on every pull request
Coverage floor fail_under in pyproject.toml, enforced by every pytest run
Build, metadata check, artifact verification, clean-environment smoke test .github/workflows/release.yml (Release dry run)
Tagging Manual
GitHub Release creation and publication Manual
Rebuild + verification of the tagged source .github/workflows/publish.yml, after the Release is published
PyPI upload .github/workflows/publish.yml, after the Release is published

The dry-run workflow holds permissions: contents: read and contains no upload step, so it cannot publish even if invoked by mistake. It remains publish-free deliberately, and tests pin that.

The two release workflows

Release dry run (release.yml) Publish to PyPI (publish.yml)
Trigger workflow_dispatch, pull_request release: published only
Publishes Never Yes, to PyPI
OIDC identity None id-token: write, publish job only
GitHub environment None pypi

publish.yml has two jobs, and the split is the security boundary:

  • build-and-verify checks out github.sha — the immutable commit the Release was published from, rather than re-resolving the tag name — runs the full release check suite, builds from a clean dist/, runs twine check --strict, runs scripts/verify_artifacts.py — which on a release event also asserts that the tag is v<version>, derived from the sources rather than hard-coded — smoke-tests the real wheel in a fresh virtualenv, and uploads the distributions as an Actions artifact. It holds no publishing identity.
  • publish-to-pypi downloads that artifact and uploads it with pypa/gh-action-pypi-publish. It does not check out the repository, install dependencies or run any project code. Its only privilege is id-token: write.

Every external action in publish.yml — not just the publishing one — is pinned to a full commit SHA with the release named in a comment beside it, and a test fails the suite if any pin is reverted to a mutable tag.

There is no workflow_dispatch on publish.yml: a published GitHub Release is the only way to start a publication. GitHub's ordinary re-run of that run, or of its failed jobs, is still available and keeps the original event's commit and tag, so it republishes exactly the same thing. Re-running is the right move when publication failed before any distribution was accepted — a mistyped Trusted Publisher or a missing pypi environment, say. That is exactly what happened on the first attempt at 0.1.0a1: the OIDC exchange was rejected with invalid-publisher ("valid token, but no corresponding publisher"), which fails before any upload, so correcting the publisher configuration and re-running the failed job of the same run was sufficient. If publication partly succeeded, or the version already exists on PyPI, investigate the PyPI state and follow release recovery instead; skip-existing is not set, deliberately, so that condition fails loudly rather than passing in silence.

Publication identity

Both of these are human configuration steps performed in GitHub and PyPI settings. No repository code performs them. Both now exist; what follows is what to verify before each release, not what to create again.

1. The pypi GitHub environment

An environment named exactly pypi under Settings → Environments, carrying the protection rules available for the repository — required reviewers, and a deployment-branch/tag rule limiting it to release tags — so that publication has a human gate in addition to the Release gate. Publication of 0.1.0a1 paused at that gate until a human approved it.

2. The PyPI Trusted Publisher

The openauc project on PyPI has an active Trusted Publisher under Manage project → Publishing. Confirm it still reads exactly:

Field Value
PyPI project name openauc
Owner ronfinn
Repository name openauc-io
Workflow name publish.yml
Environment name pypi

Any mismatch — including an empty environment field — makes the OIDC exchange fail with invalid-publisher. Verify it; do not add a second publisher, and do not create a pending publisher: pending publishers exist only to create a project that does not yet exist on PyPI, which was how openauc was first created and is no longer the applicable procedure for this repository.

Trusted Publishing uses a short-lived OIDC token minted per run. There is no PyPI API token, username or password anywhere in this repository or its secrets, and none should ever be added.

Before the release

  1. Working tree clean, on a release branch cut from current main.
  2. Confirm the version in src/openauc/__init__.py — hatch reads it from there — and that CITATION.cff declares the same version. A test enforces this.
  3. CHANGELOG.md has a section for the version, with its entries written and the release date filled in.
  4. Run every gate:
$ uv sync --all-groups
$ uv run python scripts/release_check.py
  1. Build and verify the artifacts:
$ rm -rf dist
$ uv build
$ uv run python scripts/verify_artifacts.py
$ uvx twine check --strict dist/*
  1. Install the wheel alone into a throwaway environment and exercise it:
$ python -m venv /tmp/openauc-smoke
$ /tmp/openauc-smoke/bin/pip install dist/openauc-*.whl
$ /tmp/openauc-smoke/bin/openauc version
$ /tmp/openauc-smoke/bin/openauc formats
  1. Run the Release dry run workflow from the Actions tab and confirm it is green.
  2. Confirm the pypi GitHub environment exists and is protected.
  3. Confirm the project's active PyPI Trusted Publisher is configured exactly as tabulated above.

The release itself

Only once every step above passes.

Human, in order:

  1. Create the tag v<version> on the release commit and push it. Pushing the tag publishes nothing — no workflow reacts to it.
  2. Create the GitHub Release from that tag, with the CHANGELOG.md section as its body.
  3. Mark it as a pre-release for any aN/bN/rcN version — so, for v0.1.0a1, yes.
  4. Publish the Release. A draft Release triggers nothing; publishing is the act that authorises the upload.

Automated, and only now:

  1. publish.yml rebuilds and re-verifies the tagged source, confirming the tag matches the packaged version.
  2. It publishes those verified distributions to PyPI over Trusted Publishing OIDC, with attestations left at their default.

After the release

Verify, by hand:

  1. The project and version exist on PyPI.
  2. pip install openauc==<version> succeeds in a fresh environment — for a pre-release, with --pre or the exact version.
  3. The installed version is the expected one, and openauc version and openauc formats work.
  4. The publication carries the provenance/attestation information PyPI shows for Trusted Publishing uploads.

Then update the repository:

  1. Replace - unreleased in the CHANGELOG.md section heading with the release date, and open the next ## [Unreleased] section.
  2. Update the status note at the top of this page, the version line on the project index, and the README installation instructions, so none still says the version is unpublished. 0.1.0a1 is the worked example.
  3. Record the release in docs/project/roadmap.md and the development log.
  4. Bump the version in src/openauc/__init__.py and CITATION.cff, and update CITATION.cff's publication metadata, when work on the next version begins.

None of these are enforced by tests, deliberately: the tests pin what the machinery may do, not which point in the release cycle the repository currently occupies. Those durable boundaries are:

  • the Release dry run may build and verify, and may never publish;
  • only publish.yml may publish to PyPI, and only on a published Release;
  • no automation manufactures or pushes a release tag;
  • no automation creates or publishes a GitHub Release.

No test depends on whether v0.1.0a1 currently exists — a clone that has fetched the tag must still pass the whole suite.

Next step