The shared release automation of Algorithmiq repositories: release branches, release candidates, the release PR, release tags, backports, the changelog and the PR checks. The release process describes how it works for the people using it; the rest of this README is about adopting and maintaining the automation.
Each repository keeps three short caller stubs, its own publish workflow (because that depends on how the project is built) and a changelog preview recipe. Everything else lives here, once, and is pinned by tag.
- The release process: branches, pull requests, versions, releases
- What is central and what stays per repository
- Adopting release-tools: the stubs to copy
- Versioning and upgrading
- One-time organisation setup
- Per-repository setup
- Caveats
- Design: how the central files reach the caller, and why
- Enforcing the PR checks with an organisation ruleset
- Open questions
- Developing release-tools, and how it releases itself
Every repository that adopts release-tools works this way. People only make decisions: start a release, label fixes for backporting, and approve the changelog. The release bot does the rest.
| Branch | Purpose | Lifetime |
|---|---|---|
main |
Where all work lands, through squash-merged pull requests. Always releasable. | Permanent |
release/X.Y |
Stabilising release X.Y and shipping its patch releases. Only receives bug fixes backported from main. |
Until release/X.Y+1 is created; then it is unsupported |
release-pr/X.Y, changelog/vX.Y.Z, backport branches |
The bot's branches for its PRs | Managed by the bot |
Work branches (e.g. fix/login-timeout) |
One change, one pull request | Deleted after merging |
Only the newest release is supported: older releases get no fixes, and users upgrade.
-
Branch off
mainand targetmain, bug fixes included. Only the bot opens PRs against release branches. -
PRs are squash-merged, so the PR title and description become the commit. The title becomes the changelog entry, so write it for users:
type: description, with an optional scope (fix(io): …), an optional gitmoji after the colon (fix(io): 🐛 …, stripped from the changelog), and!after the type for a breaking change (feat!: remove the old solver API).Type Use for In the changelog featnew functionality Features fixbug fixes Fixes deprecatemarking something as deprecated Deprecations, with the migration notes perfperformance improvements Performance docsdocumentation only Documentation refactor,test,build,ci,chore,stylechanges users don't notice no (unless breaking) -
Breaking changes and deprecations need a
## Migrationsection in the PR description saying what users have to change, with a before/after example as code blocks. It is copied into the changelog under the entry, which lists breaking changes first. -
The PR checks enforce the title format and the Migration section, and re-run when the title or description is edited.
-
A bug fix that should also reach the current release gets the
backportlabel, before or after merging. The bot cherry-picks the squashed commit onto the newestrelease/*branch and opens a backport PR with the same title. If the cherry-pick conflicts, the PR is a draft with the conflict committed: resolve it on that branch and mark it ready. Features are never backported.
Semantic versioning, MAJOR.MINOR.PATCH. Patch releases only fix
bugs and never break anything. Before 1.0, a minor release may break existing code; from
1.0 on, only a major release may. Release candidates are X.Y.0rcN; tags carry a v
prefix (v0.5.0, v0.5.0rc1).
Public functionality is deprecated before it is removed: it keeps working and emits a
DeprecationWarning naming the replacement and the earliest version that removes it, for
at least one minor release (from 1.0 on, until the next major release). The removal is
then a breaking change with migration notes. Names starting with _ and anything
documented as experimental are exempt.
- Start. When
mainis ready, a maintainer checks that no deprecation is due for removal and runs Actions → Release → Run workflow onmain, choosing minor or major. The bot createsrelease/X.Y, tags the first release candidatevX.Y.0rc1(published as a pre-release) and opens the release PRchore: release vX.Y.0, which holds the version and the new changelog section. - Release candidates. Until
X.Y.0is out, every backport merged intorelease/X.Ytags the next candidate and updates the release PR. Testing candidates is optional (pip install --pre <package>==X.Y.0rcN). - Release. A maintainer reviews the changelog in the release PR and merges it. The
bot tags
vX.Y.Z; the repository's publish workflow builds, drafts the GitHub release with every file attached, publishes to PyPI and publishes the release, which makes it immutable. The bot then copiesCHANGELOG.mdback tomain. - Patch releases. After
X.Y.0, the first backport opens the release PR forX.Y.1; merge it when the fixes should ship. Patch releases have no candidates.
A published release never changes. A broken one is yanked on PyPI and replaced by a new
patch release. Only the bot creates v* tags.
| Piece | Where | Per-repository input |
|---|---|---|
Release logic: start a release, release candidates, release PR, final tag, changelog copy to main |
.github/workflows/release.yml (reusable) |
version-files |
| Version arithmetic and tagging | release.py, run by release.yml |
none |
| Changelog format | cliff.toml, used by release.yml, the release-notes action and the local preview |
none |
Backport PRs for the backport label |
.github/workflows/backport.yml (reusable) |
none |
| PR title and migration notes checks | .github/workflows/pr-checks.yml (reusable) |
none |
| Release notes for a tag | actions/release-notes (composite) |
none |
| Draft GitHub release with files | actions/draft-release (composite) |
the files to attach |
| Publish the draft release | actions/publish-release (composite) |
none |
| Triggers, permissions, the bot's credentials | each repository's stubs | |
| Build, version check, PyPI upload | each repository's publish workflow (deploy.yml) |
everything |
just changelog |
each repository's justfile |
none |
| PR template with the Migration section | each repository's .github/pull_request_template.md |
anything else in it |
CHANGELOG.md |
each repository, written by the bot |
PR titles use the types feat fix deprecate perf docs refactor test build ci chore style
in every repository. The first five appear in the changelog; the others are left out
unless they are breaking (!). A gitmoji after the type
(feat: ✨ add X, or :sparkles:) is optional and stripped from the changelog. The
changelog starts at the newest final tag, so a repository tags v0.0.0 where it adopts
the process. Each entry ends with an absolute link to its PR ([#12](https://github.com/…/pull/12)),
built from GITHUB_REPOSITORY: GitHub doesn't autolink #12 in CHANGELOG.md, nor reliably
in release notes. The just changelog recipes take the repository from the origin remote.
Copy these files, replace v1.0.0 with the current release of release-tools, 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.
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).
# Releases: see https://github.com/Algorithmiq/release-tools#readme
name: Release
on:
workflow_dispatch:
inputs:
bump:
description: "Kind of release to start"
type: choice
options: [minor, major]
default: minor
push:
branches: ["release/**"]
tags: ["v*"]
permissions:
contents: read
jobs:
release:
uses: Algorithmiq/release-tools/.github/workflows/release.yml@v1.0.0
with:
client-id: ${{ vars.RELEASE_BOT_CLIENT_ID }}
bump: ${{ inputs.bump }}
# 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 }}# 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.0.0
with:
client-id: ${{ vars.RELEASE_BOT_CLIENT_ID }}
secrets:
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}# 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.0.0The 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.
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.0.0
- uses: Algorithmiq/release-tools/actions/draft-release@v1.0.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.0.0release-notes writes the tag's changelog entry for a final release
(git cliff --current) and everything since the last final release for a release
candidate (git cliff --unreleased --tag vX.Y.ZrcN), and outputs the file's path.
draft-release takes notes-file and files (one glob per line, each must match), and
marks 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.
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"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. -->
## Checklistrelease-tools follows the release process itself and uses Semantic Versioning
from 1.0.0:
- Major: consumers must change something: a stub's inputs, secrets or required permissions, the names of the PR checks, or the release behaviour.
- Minor: new optional inputs or features, changelog format changes that need no action.
- Patch: fixes.
Consumers pin an exact tag (@v1.0.0) in all three stubs and in the actions of the
publish workflow, and keep them in step. Dependabot's github-actions ecosystem updates
both reusable workflows and actions; group them so that one PR moves every reference:
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
release-tools:
patterns:
- "Algorithmiq/release-tools*"Release candidates of release-tools (v1.1.0rc1) are real tags: pin one in a repository to
try a change before it is released. Immutable releases make every published tag permanent,
so a pinned version can never change under a consumer.
An organisation owner does this once. The commands need gh with the admin:org scope
(gh auth refresh -h github.com -s admin:org) and jq.
-
Create the release bot GitHub App (web UI only): https://github.com/organizations/Algorithmiq/settings/apps/new.
- Name
algorithmiq-release-bot; homepagehttps://github.com/Algorithmiq; webhook not active. - Repository permissions: Contents read and write (release branches, tags, the
changelog), Pull requests read and write (release, backport and changelog PRs,
auto-merge), Workflows read and write (pushing branches whose commits touch
.github/workflows/). Metadata read-only is added automatically. Nothing else, no events. Installable only on this account. - Note the App ID (a number, for the rulesets) and the Client ID (
Iv23…, for the workflows). Generate a private key and keep the.pemonly until step 2. - Install it on
release-toolsand on every repository that adopts the process. release-tools needs it for its own releases, and the release workflow uses it to read release-tools when that repository is not public.
- Name
-
Store the credentials for the repositories that use them, release-tools included (
--reposreplaces the list, so always pass all of them):gh variable set RELEASE_BOT_CLIENT_ID --org Algorithmiq --visibility selected \ --repos release-tools,AEGISS --body "$CLIENT_ID" gh secret set RELEASE_BOT_PRIVATE_KEY --org Algorithmiq --visibility selected \ --repos release-tools,AEGISS < algorithmiq-release-bot.*.private-key.pem
-
Allow the actions. If the organisation restricts which actions and reusable workflows may run, allow
Algorithmiq/release-tools/*and the actions it uses (actions/checkout,actions/create-github-app-token,amannn/action-semantic-pull-request,korthout/backport-action). The allow list of the calling repository applies to everything a reusable workflow uses. -
Visibility of release-tools. Public is simplest and preferred. If it is internal or private instead:
- release-tools → Settings → Actions → General → Access: "Accessible from repositories in the 'Algorithmiq' organization". Without it, no other repository can call its workflows or actions. (docs)
- A public repository can only call public workflows, so release-tools must be public as soon as any public repository adopts it. (docs)
- "Internal" exists only with GitHub Enterprise Cloud; otherwise it is private.
- Dependabot needs access to release-tools to update the pins: add it to the organisation's "Grant Dependabot access to private repositories" list (in the organisation's code security settings).
- Local changelog previews use the
ghvariant ofjust changelog.
Nothing in the workflows changes: they read release-tools with a read-only token of the release bot either way (see Design).
A repository admin does this when a repository adopts the process. Set
REPO=Algorithmiq/<name> and APP_ID=<the App ID> first. Steps 1 to 7 come before
merging the PR that adds the stubs, 8 after it.
-
Install the app and share the credentials with the repository: add it to the app's installation (Organisation settings → GitHub Apps → algorithmiq-release-bot → Configure), and to the
--reposlists of the variable and the secret. -
Tag
v0.0.0, the start of the changelog, on the last commit that should not appear in it, before the tag ruleset (step 5) exists. The first release is then0.1.0(or1.0.0when started with major).git tag -a v0.0.0 -m "Start of the changelog" <commit> git push origin v0.0.0 just changelog # what the first release will contain
-
Settings. Squash-merge only, with the PR title and description as the commit message (otherwise the migration notes never reach the changelog), auto-merge, automatic deletion of head branches, immutable releases, and the
backportlabel:gh api -X PATCH repos/$REPO -F allow_squash_merge=true -F allow_merge_commit=false \ -F allow_rebase_merge=false -F allow_auto_merge=true -F delete_branch_on_merge=true \ -f squash_merge_commit_title=PR_TITLE -f squash_merge_commit_message=PR_BODY gh api -X PUT repos/$REPO/immutable-releases gh label create backport -R $REPO --color B60205 \ --description "Also ship this fix in the current release"
-
Files. The three stubs, the publish workflow, the
justfilerecipe, the Migration section of the PR template, an emptyCHANGELOG.mdif there is none, and the Dependabot group. Delete any local copies ofrelease.pyandcliff.toml. -
Tag ruleset: only the release bot creates, moves or deletes
v*tags.gh api -X POST repos/$REPO/rulesets --input - <<EOF {"name": "release tags", "target": "tag", "enforcement": "active", "conditions": {"ref_name": {"include": ["refs/tags/v*"], "exclude": []}}, "rules": [{"type": "creation"}, {"type": "update"}, {"type": "deletion"}], "bypass_actors": [{"actor_id": $APP_ID, "actor_type": "Integration", "bypass_mode": "always"}]} EOF
-
release/*ruleset: changes only through squash-merged PRs that pass CI, and only the bot creates the branches. List the repository's own required checks next to the PR checks (15368is the GitHub Actions app, which reports them):gh api -X POST repos/$REPO/rulesets --input - <<EOF {"name": "release branches", "target": "branch", "enforcement": "active", "conditions": {"ref_name": {"include": ["refs/heads/release/*"], "exclude": []}}, "rules": [ {"type": "creation"}, {"type": "deletion"}, {"type": "non_fast_forward"}, {"type": "required_linear_history"}, {"type": "pull_request", "parameters": {"allowed_merge_methods": ["squash"], "dismiss_stale_reviews_on_push": true, "require_code_owner_review": false, "require_last_push_approval": false, "required_approving_review_count": 1, "required_review_thread_resolution": false}}, {"type": "required_status_checks", "parameters": {"strict_required_status_checks_policy": true, "required_status_checks": [{"context": "PR checks / PR title", "integration_id": 15368}, {"context": "PR checks / Migration notes", "integration_id": 15368}]}}], "bypass_actors": [{"actor_id": $APP_ID, "actor_type": "Integration", "bypass_mode": "always"}]} EOF
-
PyPI Trusted Publishing for the publish workflow: on PyPI, add a (pending) publisher with the repository, the workflow file name and the environment (
pypiin the example). The first release candidate is published to PyPI straight away. -
After the stubs are on
main: require the PR checks onmainand let the bot bypass themainruleset.pull_request_targetruns the workflow file of the base branch, so the checks never report until the stub is onmain; requiring them earlier blocks every PR, including the one adding them. The bypass lets the bot's changelog PR merge without a review (see Caveats).id=$(gh api repos/$REPO/rulesets --jq '.[] | select(.name == "main") | .id') gh api repos/$REPO/rulesets/$id | jq --argjson app "$APP_ID" '{name, target, enforcement, conditions, rules: (.rules | map(if .type == "required_status_checks" then .parameters.required_status_checks += [{"context": "PR checks / PR title", "integration_id": 15368}, {"context": "PR checks / Migration notes", "integration_id": 15368}] else . end)), bypass_actors: (.bypass_actors + [{"actor_id": $app, "actor_type": "Integration", "bypass_mode": "always"}])}' \ | gh api -X PUT repos/$REPO/rulesets/$id --input -
This assumes a
mainruleset with arequired_status_checksrule; create one first if there is none.
The first release: check that no deprecation is due for removal, then
Actions → Release → Run workflow on main (or
gh workflow run release.yml -R $REPO --ref main -f bump=minor).
- Trailers. git-cliff reads
Key: valuelines at the end of a commit message as git trailers and drops them from the body. A PR description that ends with its Migration section, or whose notes end withBefore: …/After: …lines, loses those lines from the changelog. Keep the Migration section above a later heading of the template, and write before/after examples as code blocks. (tests/test_cliff.pypins this behaviour.) - Auto-merge of the changelog PR. After a final release the bot opens a PR copying
CHANGELOG.mdtomainand enables auto-merge. Whether auto-merge honours the bot's ruleset bypass (instead of waiting for a review) is unverified. If the PR waits, approve it by hand and update this note. - Check names. The PR checks report as
PR checks / PR titleandPR checks / Migration notes. A repository that required the contextsPR titleandMigration notesof its own copy must switch its rulesets to the new names right after merging the stub:pull_request_targetruns the workflow file ofmain, so the PR adding the stub still reports the old names, and every later PR the new ones. job.workflow_*is GitHub.com-only (not on GitHub Enterprise Server), and actionlint 1.7.12 doesn't know it yet:.github/actionlint.yamlignores the two properties inrelease.yml. Consumers' stubs don't use it.- Release jobs check release-tools out into
.release-tools/inside the caller's working tree. It is never added to a commit (the workflow only addsCHANGELOG.mdand the version files). - Concurrency. The release jobs queue per ref in the group
release-<ref>of the calling repository.
Inside a reusable workflow the github context is the caller's
(docs),
so github.sha and github.workflow_ref say nothing about release-tools. Three options
were considered:
- (a)
job.workflow_repositoryandjob.workflow_sha, chosen. Thejobcontext identifies the workflow file that defines the current job; for a reusable workflow, the called file. GitHub documents exactly this use: a reusable workflow checking out its own repository atjob.workflow_sha(job context; added to the docs in github/docs#60709, April 2026; used in production by github/gh-aw).release.ymlchecks out${{ job.workflow_repository }}at${{ job.workflow_sha }}, the commit of the tag the caller pinned, so the workflow logic,release.pyandcliff.tomlalways come from one ref. It costs the stubs nothing. - (b) Composite actions called from the reusable workflow. A composite action sees its
files through
github.action_path, but a reusable workflow callingAlgorithmiq/release-tools/<action>@<ref>must hard-code<ref>. The workflow atv1.2.0would then run actions from whatever tag was written into it, unless every release rewrote its own references before tagging, which the release process can't do (the tag doesn't exist yet when the release PR is written). Rejected. - (c) Consumers calling composite actions directly. Version-consistent, but each
stub would carry the jobs, conditions, token steps and checkouts that make up most of
release.yml: about a hundred lines per repository instead of thirty. Rejected.
The checkout of release-tools uses a second token of the release bot, limited to
contents: read on release-tools
(repositories and permission-* inputs).
For a public release-tools that token isn't needed, but it keeps one code path for every
visibility: an internal or private repository can't be read with the caller's
GITHUB_TOKEN, and the Actions Access setting only lets the runner download actions
and reusable workflows, not check the repository out
(docs).
backport.yml and pr-checks.yml need no files from here. The publish actions are
composite actions called directly from the consumer's deploy.yml, where the consumer
writes the ref: release-notes finds cliff.toml at ${{ github.action_path }}/../../cliff.toml,
which the runner downloads with the action at that ref.
The stubs pass client-id and private-key explicitly. secrets: inherit would also
work, but would hand every secret of the repository to release-tools and hide which one it
needs. Workflow-level env of the caller does not reach a reusable workflow
(docs),
hence the version-files input.
An organisation ruleset can require workflows to pass before merging, running a workflow from release-tools in every targeted repository with no stub at all (docs). It is an option, not the default, because of these caveats:
- It needs GitHub Enterprise Cloud.
- Ruleset workflows only run for the default activity types:
opened,synchronizeandreopenedforpull_request_target;typesfilters are ignored (docs). Editing a PR's title or description does not re-run the checks, which defeats a title check: a fixed title stays red until the next push. - The enforced file must trigger on
pull_requestorpull_request_target, so it would be a wrapper such asself-pr-checks.ymlrather than the reusablepr-checks.yml. Whether its./.github/workflows/pr-checks.ymlreference resolves to release-tools when run as a ruleset workflow in another repository is unverified. - The workflow's repository must be at least as visible as the targeted ones (a public one works everywhere), with the Access setting if it isn't public.
- The rule blocks direct pushes on the targeted branches. Rulesets for
release/*must use "Do not require workflow checks on creation" so that the bot can still create the branches. PRs opened before the rule only get the checks after their next push.
The upside is central enforcement that a repository can't remove. Reconsider it if the
edited limitation is lifted.
- Visibility of release-tools (public preferred). Everything works either way; see One-time organisation setup for what an internal or private repository needs.
- Does auto-merge of the changelog PR honour the bot's ruleset bypass? Unverified until the first release.
job.workflow_*on real runs. Documented and used by GitHub's own gh-aw, but not yet exercised by this repository; the first run of release-tools' own release workflow will show it.- Dependabot and release candidates. Whether Dependabot proposes
vX.Y.ZrcNtags of release-tools as updates is untested. If it does, add anignorerule for them.
just build # uv sync: pytest, ruff, ty, prek, git-cliff, shellcheck
just lint # every hook: ruff, ty, actionlint (+ shellcheck), workflow and action schemas
just test # release.py, cliff.toml, the actions, and that the examples matchtests/test_release.pycovers each command ofrelease.py;tests/test_release_e2e.pydrives two release series through it the wayrelease.ymldoes, against a scratch repository with a local bareorigin, and runs the release PR step ofrelease.ymlitself with a stand-in forgh.tests/test_cliff.pyruns git-cliff 2.14.2 on scratch histories: gitmoji stripping, migration notes, blank lines, ignored release candidates,--current,--prepend.tests/test_actions.pyshellchecks the composite actions and runs them with stand-ins forpipxandgh.tests/test_examples.pychecks that this README, theexamples/and the dogfooding stubs agree, that the stubs pass what the reusable workflows declare, and that every reference uses the same release-tools tag and git-cliff version.
Requires Python 3.12 or later: release.py runs on the runner's system Python and only
uses the standard library.
release-tools follows the same process with its own workflows (dogfooding):
self-release.yml,
self-backport.yml and
self-pr-checks.yml are the consumer stubs
above with uses: ./.github/workflows/<file> instead of a pinned tag, and
self-publish.yml publishes the GitHub release with
./actions/*. A local reference runs the workflow from the same commit as the event, so a
change is exercised by release-tools itself (release candidates included) before any
consumer pins it. Nothing is built or uploaded: the release is the tag and its notes.
It has no version files (version-files: ""). Set it up like any other repository (the
per-repository steps above, without PyPI), tag v0.0.0 on the first commit, and start the
first release with major to get v1.0.0rc1 and then v1.0.0.