Release Automation¶
Shaken Fist Python projects use a standardised release workflow based on GitHub Actions, PyPI trusted publishers, and Sigstore signing. This page describes the release infrastructure and how to add it to a new project.
How It Works¶
When a maintainer pushes a git tag matching v* (e.g. v0.6.0),
the release.yml workflow:
- Checks CI with
ci-gate, which fails unless the repository's named CI workflows succeeded on the tagged commit (seerequire-ci-success) - Builds the package using
python3 -m buildin a clean venv - Validates the package with
twine check, and checks the wheel holds no test directory withcheck-dist - Waits for approval from a required reviewer (via a GitHub environment)
- Signs the tag using Sigstore/gitsign (keyless, OIDC-based)
- Publishes to PyPI using trusted publishers (no API tokens)
- Creates a GitHub Release with the built artifacts and auto-generated release notes
The release job downloads the distribution to a named path and sets
fail_on_unmatched_files, so that a release which attaches nothing
fails rather than reporting success. Both are load bearing: with a
bare download and the action's default of warning on an unmatched
glob, an empty release is indistinguishable from a good one.
The workflow can also be started by hand, which skips ci-gate, runs
the build, the twine check and the wheel check as a smoke test, and
stops there. Everything from the tag signing down is confined to a
pushed tag with if: github.event_name == 'push' &&
startsWith(github.ref, 'refs/tags/v'). Both clauses are needed: aimed at a branch, an
unguarded sign-tag would take refs/heads/<branch> for a tag name and
force-push refs/tags/refs/heads/<branch>; aimed at an existing tag, a
ref-only guard would let the run re-sign and force-push that tag,
rewriting a signed object someone may already have verified. Re-running
a failed release still works, because "Re-run jobs" replays the original
push event.
These runners are persistent and download-artifact extracts into its
target rather than replacing it, so a publishing job must not read a
directory an earlier job may have left files in. The two jobs solve
that differently. publish-pypi checks out, which cleans the workspace
with git clean -ffdx, and works in dist/ -- it has to, because
pypa/gh-action-pypi-publish delegates to a Docker container action,
and the container is given RUNNER_TEMP at /github/runner_temp while
${{ runner.temp }} expands to the host path, which is not there.
github-release runs a JavaScript action on the host, so it skips the
checkout and downloads into the per-job ${{ runner.temp }} instead.
flowchart TB
tag["Maintainer pushes v0.6.0 tag"]
gate["Check CI passed on the tagged commit"]
build["Build and check package"]
artifacts["Upload artifacts"]
approve["Wait for reviewer approval<br/>(GitHub environment)"]
sign["Sign tag with Sigstore"]
pypi["Publish to PyPI"]
release["Create GitHub Release"]
tag --> gate
gate --> build
build --> artifacts
build --> approve
approve --> sign
sign --> pypi
pypi --> release
Security Properties¶
The release process is designed to eliminate long-lived secrets:
- No PyPI API tokens -- authentication uses OIDC trusted publishers, where PyPI verifies the GitHub Actions workflow identity directly
- No GPG keys -- tag signing uses Sigstore's keyless signing with OIDC identity certificates, recorded in the Rekor transparency log
- Tested commits only --
ci-gaterefuses a tag on a commit whose CI did not pass, before anything is built, so a reviewer approves a release whose CI has already been checked - Multi-party approval -- the
releaseenvironment requires a reviewer to approve before publishing proceeds - Protected tags -- tag rulesets prevent unauthorized users from creating release tags
- Build provenance -- Sigstore attestations cryptographically link published artifacts to the exact source commit
Adding Release Automation to a Project¶
Prerequisites¶
The project must:
- Use
pyproject.tomlwithsetuptools_scm(or similar) for version detection from git tags - Not have an old
release.shscript (remove it first) - Run CI in a way the release gate can count: see "Prerequisites for gated CI" in the template README
Step 1: Copy the Templates¶
Templates are in
templates/release-automation/:
| Template | Destination |
|---|---|
release.yml |
.github/workflows/release.yml |
RELEASE-SETUP.md |
RELEASE-SETUP.md (repo root) |
Replace the placeholders in the copied files:
| Placeholder | Description | Example |
|---|---|---|
{{PROJECT_DISPLAY_NAME}} |
Human-readable name | Occy Strap |
{{PYPI_PACKAGE_NAME}} |
PyPI package name | occystrap |
{{GITHUB_REPO_NAME}} |
GitHub repo name | occystrap |
{{CI_WORKFLOWS}} |
CI workflow files the release gate requires, one per line | ci.yml |
Step 2: Configure PyPI Trusted Publisher¶
- Log in to pypi.org
- If the package has never been published, there is no project to scope a publisher to: add a pending publisher from https://pypi.org/manage/account/publishing/ instead, which also asks for the PyPI project name and becomes an ordinary trusted publisher on the first upload. Otherwise, navigate to your project's Publishing settings
- Add a trusted publisher:
- Owner:
shakenfist - Repository: your repo name
- Workflow:
release.yml - Environment:
release
A wrong value here only fails at publish-pypi, after sign-tag has
already pushed the signed tag, so the recovery is the next version
rather than a re-pushed tag.
Step 3: Create GitHub Environment¶
- Go to Settings > Environments in the repository
- Create an environment named
release - Add required reviewers
- Restrict deployment to tags matching
v*
Step 4: Configure Protected Tags¶
- Go to Settings > Rules > Rulesets
- Create a tag ruleset for
v*restricting creation, deletion and force pushes - Add maintainers to the bypass list, and not GitHub Actions:
sign-tagcollides with none of those rules, so the bypass would only let every workflow create and delete release tags
Tag updates stay unrestricted, so anyone with write access can
rewrite a released tag. RELEASE-SETUP.md step 3 explains why the
fleet accepts that gap rather than close it with Restrict updates.
Step 5: Remove Old Release Scripts¶
Delete any existing release.sh and commit the removal.
Rust Projects¶
ryll, instar and andris release from
templates/release-automation-rust/
instead. It keeps the same CI gate, signing and guards, and adds two
scripts: one proposes the version bump as a pull request through the
merge queue, and the other tags the commit that merged it. Its README
lists the invariants every Rust release keeps, and how each of the
three repositories fills the workflow's build section.
Projects Using This Infrastructure¶
| Project | PyPI Package | Status |
|---|---|---|
| shakenfist | shakenfist |
Live |
| occystrap | occystrap |
Live |
| kerbside | kerbside |
Live |
| agent-python | shakenfist-agent |
Added |
Verifying a Release¶
Tag Signature¶
gitsign verify --certificate-identity-regexp='.*' \
--certificate-oidc-issuer='https://token.actions.githubusercontent.com' \
v0.6.0
PyPI Attestation¶
Check the Provenance section on the package's PyPI page.