Adopting release-tools¶
Copy these files, replace v1.2.0 with the current release of release-tools (v1.2.0 or
later: the stage input is new in v1.2.0), and use the same tag everywhere. The triggers
stay in the stubs: only a repository's own workflow files react to its events. The bot's client ID and private key are passed explicitly
rather than through vars inheritance or secrets: inherit: the reusable workflows
declare them as a required input and a required secret, so a stub that forgets one fails
before anything runs, and no other secret of the repository reaches release-tools.
.github/workflows/release.yml¶
Set version-files to the files that hold the version number, such as VERSION or
pyproject.toml (whose [project] version is updated). Leave it empty if the version
comes from the git tag (setuptools-scm, hatch-vcs). stage chooses where a release
starts (alpha, beta or rc) and promotes a release in beta to release candidates; see
Releasing.
# Releases: see https://github.com/Algorithmiq/release-tools#readme
name: Release
on:
workflow_dispatch:
inputs:
bump:
description: "Release to start: next minor or major (patch releases: add the backport label to the fix PR)"
type: choice
options: [minor, major]
default: minor
stage:
description: "Stage to start at or promote to (patch releases have no pre-releases)"
type: choice
options: [rc, beta, alpha]
default: rc
push:
branches: ["release/**"]
tags: ["v*"]
permissions:
contents: read
jobs:
release:
uses: Algorithmiq/release-tools/.github/workflows/release.yml@v1.2.0
with:
client-id: ${{ vars.RELEASE_BOT_CLIENT_ID }}
bump: ${{ inputs.bump }}
stage: ${{ inputs.stage }}
# Files that hold the version (e.g. "VERSION pyproject.toml"); empty if it
# comes from the git tag (setuptools-scm, hatch-vcs).
version-files: ""
secrets:
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
.github/workflows/backport.yml¶
# Backports: see https://github.com/Algorithmiq/release-tools#readme
name: Backport
on:
pull_request_target:
types: [closed, labeled]
permissions:
contents: read
jobs:
backport:
uses: Algorithmiq/release-tools/.github/workflows/backport.yml@v1.2.0
with:
client-id: ${{ vars.RELEASE_BOT_CLIENT_ID }}
secrets:
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
.github/workflows/pr-checks.yml¶
# PR title and migration notes: see https://github.com/Algorithmiq/release-tools#readme
name: PR checks
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
permissions:
pull-requests: read
jobs:
# Reports the checks "PR checks / PR title" and "PR checks / Migration notes".
pr-checks:
name: PR checks
uses: Algorithmiq/release-tools/.github/workflows/pr-checks.yml@v1.2.0
The checks report as PR checks / PR title and PR checks / Migration notes (a
called workflow's jobs are named <caller job name> / <job name>). Those are the names to
require in the rulesets.
The publish workflow¶
It must follow this structure: run on push: tags: ["v*"], build and
check that the built version matches the tag, create a draft GitHub release with every
file attached, publish to PyPI with Trusted Publishing, and publish the GitHub release last.
The build stays the repository's own; the three release steps are actions from here. The
release-notes action needs the full history and tags (fetch-depth: 0), and the release
jobs need contents: write. A complete example:
# Publishing: see https://github.com/Algorithmiq/release-tools#readme
#
# Runs on the v* tags that the release workflow pushes: build, draft the GitHub release
# with every file attached, publish to PyPI, then publish the GitHub release, which makes
# it immutable. Never on `release: published`. Only the build job is repository-specific.
name: Publish
on:
push:
tags: ["v*"]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
# A publish run must never be cut short half-way through.
cancel-in-progress: false
jobs:
build:
name: Build sdist and wheel
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v10.3.2
- name: Build distributions
run: uv build --out-dir dist
- name: Check the version matches the release tag
run: |
expected="${GITHUB_REF_NAME#v}"
wheels=(dist/*.whl)
actual="$(basename "${wheels[0]}" | cut -d- -f2)"
if [[ "$actual" != "$expected" ]]; then
echo "::error::built version is '$actual', but the release tag is '$expected'"
exit 1
fi
- uses: actions/upload-artifact@v7.0.2
with:
name: dist
path: dist/*
draft_release:
# Immutable releases: files can only be attached while the release is a draft.
name: Create the draft GitHub release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7.0.1
with:
fetch-depth: 0 # git-cliff needs the full history and the tags
- uses: actions/download-artifact@v8.0.2
with:
name: dist
path: dist
- id: notes
uses: Algorithmiq/release-tools/actions/release-notes@v1.2.0
- uses: Algorithmiq/release-tools/actions/draft-release@v1.2.0
with:
notes-file: ${{ steps.notes.outputs.path }}
files: |
dist/*
upload_pypi:
name: Upload to PyPI
needs: draft_release
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
attestations: write
contents: read
steps:
- uses: actions/download-artifact@v8.0.2
with:
name: dist
path: dist
- uses: actions/attest-build-provenance@v4.2.2
with:
subject-path: "dist/*"
- uses: pypa/gh-action-pypi-publish@v1.14.3
publish_release:
# Publishing locks the tag and the files and creates GitHub's release attestation.
name: Publish the GitHub release
needs: upload_pypi
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: Algorithmiq/release-tools/actions/publish-release@v1.2.0
release-notes writes the tag's changelog entry for a final release
(git cliff --current) and everything since the last final release for a pre-release
(git cliff --unreleased --tag vX.Y.0rcN), and outputs the file's path.
draft-release takes notes-file and files (one glob per line, each must match), and
marks alphas, betas and release candidates as pre-releases. publish-release publishes
the draft. All three default to the tag that triggered the run (tag input) and to
github.token (token input), and need pipx, which GitHub-hosted runners have.
justfile: preview the changelog¶
just changelog shows what the next release's entry will look like, with the central
cliff.toml at the version the stubs pin (read from release.yml, so Dependabot's updates
carry over). If release-tools is public:
git_cliff := "git-cliff==2.14.2"
# Preview the changelog entry of the next release, with the central cliff.toml at the version release.yml pins.
changelog:
#!/usr/bin/env bash
set -euo pipefail
ref=$(sed -nE 's|.*Algorithmiq/release-tools/.github/workflows/release.yml@([^[:space:]]+).*|\1|p' \
.github/workflows/release.yml)
# Link the PR numbers like the release PR does: GitHub Actions sets GITHUB_REPOSITORY.
repo=$(git remote get-url origin | sed -E 's#^.*github\.com[:/]##; s#\.git$##')
GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-$repo}" uvx --from {{ git_cliff }} git-cliff --unreleased \
--config-url "https://raw.githubusercontent.com/Algorithmiq/release-tools/$ref/cliff.toml"
--config-url only works for a public repository. If release-tools is internal or private,
fetch the file with the GitHub CLI instead (any member who has run gh auth login):
git_cliff := "git-cliff==2.14.2"
# Preview the changelog entry of the next release, with the central cliff.toml at the version release.yml pins.
changelog:
#!/usr/bin/env bash
set -euo pipefail
ref=$(sed -nE 's|.*Algorithmiq/release-tools/.github/workflows/release.yml@([^[:space:]]+).*|\1|p' \
.github/workflows/release.yml)
# release-tools isn't public: fetch cliff.toml with the GitHub CLI (`gh auth login`).
config="$(mktemp -d)/cliff.toml"
gh api -H "Accept: application/vnd.github.raw" \
"repos/Algorithmiq/release-tools/contents/cliff.toml?ref=$ref" > "$config"
# Link the PR numbers like the release PR does: GitHub Actions sets GITHUB_REPOSITORY.
repo=$(git remote get-url origin | sed -E 's#^.*github\.com[:/]##; s#\.git$##')
GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-$repo}" uvx --from {{ git_cliff }} git-cliff --unreleased \
--config "$config"
PR template¶
The changelog copies the PR description's ## Migration section, up to the next ##
heading, into the entry of a breaking change or deprecation. Keep the section above any
later heading of the template (such as a checklist), so that only the notes are copied, and
never make it the last thing in the description (see Caveats):
<!-- Keep this section above the Checklist: the changelog takes everything up to the next heading. -->
## Migration
<!-- Required for breaking changes (!) and deprecations. Delete otherwise.
What do users need to change? Show a before/after example if possible. -->
## Checklist
- [ ] Bug fix for the current release: add the `backport` label (or ask a maintainer to)