Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .agent/hooks/policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@
],
"blocked_pre_commit_skip_ids": [
"quality-gates",
"commitizen-commit-msg"
"commitizen-commit-msg",
"prevent-accidental-major"
],
"blocked_pre_commit_skip_reason": "SKIP is prohibited here when it bypasses the repo's quality-gates or commitizen-commit-msg hooks.",
"blocked_pre_commit_skip_reason": "SKIP is prohibited here when it bypasses the repo's quality-gates, commitizen-commit-msg, or prevent-accidental-major hooks.",
"blocked_dynamic_git_config_reason": "Dynamic git config is prohibited here for git commit and git push because it can hide hook bypasses or force pushes."
}
213 changes: 109 additions & 104 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
name: Release

# Release automation via the release-PR pattern, so the committed version in
# pyproject.toml can advance under the protected `main` ruleset (no direct push,
# no bypass token). Two phases, selected by whether the committed version already
# has a matching tag:
# - prepare: committed version IS tagged -> compute the next version from
# Conventional Commits (Commitizen custom bump map) and open/refresh a
# "release" PR that bumps pyproject + CHANGELOG. Breaking changes stand down
# for a manual major.
# - publish: committed version is NOT tagged (a release PR just merged) ->
# tag it, build, and create the GitHub Release with the wheel + sdist.
# Major releases are manual: run this workflow via "Run workflow" with
# increment = MAJOR.
# Continuous release on merge to main. After CI passes on main, compute the
# Conventional Commits increment (custom policy via tools/release_increment.py),
# bump pyproject + uv.lock + CHANGELOG with Commitizen, then commit + tag and push
# straight to the protected `main` branch using the Oak Semantic Release Bot
# GitHub App token (a ruleset bypass actor). The bump commit carries `[skip ci]`
# so it does not re-trigger CI and loop. Every qualifying merge therefore advances
# the version and publishes a GitHub Release with the wheel + sdist.
#
# Major releases stay manual: a `!`/`BREAKING CHANGE` marker makes the auto-release
# stand down (and tools/prevent_accidental_major.py blocks the marker at commit
# time); cut the major deliberately via "Run workflow" with increment = MAJOR.
on:
push:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
increment:
description: "Release increment to prepare (use MAJOR for a deliberate major release)"
description: "Release increment to force (use MAJOR for a deliberate major release)"
type: choice
options: [MAJOR, MINOR, PATCH]
default: MAJOR
Expand All @@ -28,23 +29,38 @@ on:
permissions:
contents: read

# Serialise releases on a ref; never cancel a release mid-flight.
# Serialise releases so a rapid second merge never races the in-flight bump push.
concurrency:
group: release-${{ github.ref }}
group: release
cancel-in-progress: false

jobs:
release:
name: Release
# Only release after CI succeeded on main, or on a deliberate manual dispatch.
if: >-
github.event_name == 'workflow_dispatch'
|| github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
# Mint a short-lived installation token for the release bot. The bot is a
# bypass actor on the `main` ruleset, so the bump commit + tag push through.
- name: Generate the release-bot token
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

- name: Check out full history and tags
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
# Keep the bot token in the git config so `git push` to protected main
# is authenticated as the bypass actor.
token: ${{ steps.app-token.outputs.token }}

- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
Expand All @@ -57,108 +73,97 @@ jobs:

- name: Configure the bot git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config user.name "oak-semantic-release-bot[bot]"
git config user.email "oak-semantic-release-bot[bot]@users.noreply.github.com"

- name: Determine the release phase
id: phase
- name: Compute the release increment
id: increment
env:
FORCED_INCREMENT: ${{ inputs.increment }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
version=$(uv run python -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
echo "version=${version}" >> "$GITHUB_OUTPUT"
if git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; then
phase=prepare
prev="v${version}"
echo "previous=${prev}" >> "$GITHUB_OUTPUT"
if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then
increment="${FORCED_INCREMENT}"
elif git rev-parse -q --verify "refs/tags/${prev}" >/dev/null; then
# cz_conventional_commits ignores [tool.commitizen].bump_map, so we
# compute the increment from that single policy source ourselves.
increment="$(git log "${prev}..HEAD" --pretty=format:'%B%x00' | uv run python tools/release_increment.py)"
else
phase=publish
# The committed version is not tagged yet (a fresh fork of this
# template): release it as-is so the adopter's first version is the
# one they chose, rather than bumping past it.
echo "No tag for committed ${prev}; releasing it as the bootstrap version."
echo "mode=bootstrap" >> "$GITHUB_OUTPUT"
echo "release=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "phase=${phase}" >> "$GITHUB_OUTPUT"
echo "Committed version v${version} -> phase ${phase}"
case "${increment}" in
BREAKING)
echo "::warning::Breaking change since ${prev}; cut a major via Run workflow (increment = MAJOR). Auto-release stood down."
echo "release=false" >> "$GITHUB_OUTPUT"
;;
NONE)
echo "No releasable commits since ${prev}."
echo "release=false" >> "$GITHUB_OUTPUT"
;;
MAJOR | MINOR | PATCH)
echo "increment=${increment}" >> "$GITHUB_OUTPUT"
echo "mode=bump" >> "$GITHUB_OUTPUT"
echo "release=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "unexpected increment '${increment}'"
exit 1
;;
esac

# --- PUBLISH: the committed version has no tag, so a release PR just merged.
- name: Tag and publish the GitHub Release
if: steps.phase.outputs.phase == 'publish'
- name: Apply the release
id: release_step
if: steps.increment.outputs.release == 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.phase.outputs.version }}
MODE: ${{ steps.increment.outputs.mode }}
INCREMENT: ${{ steps.increment.outputs.increment }}
PREV: ${{ steps.increment.outputs.previous }}
run: |
set -euo pipefail
tag="v${VERSION}"
git tag -a "${tag}" -m "${tag}"
git push origin "${tag}"
uv build
notes="$(mktemp)"
if uv run cz changelog --dry-run "${VERSION}" > "${notes}" 2>/dev/null && [ -s "${notes}" ]; then
gh release create "${tag}" --title "${tag}" --notes-file "${notes}" ./dist/*
if [ "${MODE}" = "bump" ]; then
uv run cz bump --increment "${INCREMENT}" --changelog --files-only --yes
# Regenerate the lock so its project version matches the bump.
uv lock
version=$(uv run python -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
if [ -z "${version}" ]; then echo "could not read the bumped version"; exit 1; fi
# `[skip ci]` stops the bump push re-triggering CI -> Release (a loop).
git add pyproject.toml uv.lock CHANGELOG.md
git commit -m "bump: version ${PREV} -> v${version} [skip ci]"
git tag -a "v${version}" -m "v${version}"
git push origin HEAD:main
git push origin "v${version}"
else
gh release create "${tag}" --title "${tag}" --generate-notes ./dist/*
# bootstrap: tag the committed version as-is (no bump, no commit).
version=$(uv run python -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
git tag -a "v${version}" -m "v${version}"
git push origin "v${version}"
fi
# Set only on full success (set -e aborts earlier) — the publish step
# gates on this, so it never runs against a half-applied release.
echo "version=${version}" >> "$GITHUB_OUTPUT"

- name: Note an ignored manual dispatch
if: steps.phase.outputs.phase == 'publish' && github.event_name == 'workflow_dispatch'
env:
VERSION: ${{ steps.phase.outputs.version }}
run: |
echo "::warning::A manual release was requested, but committed version v${VERSION} is not yet tagged, so the pending release was published instead. Re-run after it completes to prepare the next release."

# --- PREPARE: the committed version is tagged, so look for the next release.
- name: Compute the next release and bump the files
id: prepare
if: steps.phase.outputs.phase == 'prepare'
- name: Build and publish the GitHub Release
if: steps.release_step.outputs.version != ''
env:
PREV_VERSION: ${{ steps.phase.outputs.version }}
INCREMENT: ${{ inputs.increment }}
EVENT_NAME: ${{ github.event_name }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
VERSION: ${{ steps.release_step.outputs.version }}
run: |
set -euo pipefail
prev="v${PREV_VERSION}"
if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then
increment="${INCREMENT}"
tag="v${VERSION}"
uv build
notes="$(mktemp)"
if uv run cz changelog --dry-run "${VERSION}" > "${notes}" 2>/dev/null && [ -s "${notes}" ]; then
gh release create "${tag}" --title "${tag}" --notes-file "${notes}" ./dist/*
else
# cz_conventional_commits ignores [tool.commitizen].bump_map, so we
# compute the increment from that policy ourselves and pass it
# explicitly below (see tools/release_increment.py).
increment="$(git log "${prev}..HEAD" --pretty=format:'%B%x00' | uv run python tools/release_increment.py)"
case "${increment}" in
BREAKING)
echo "::warning::Breaking change detected since ${prev}. Cut a major manually via Run workflow (increment = MAJOR); auto-release stood down."
echo "prepared=false" >> "$GITHUB_OUTPUT"
exit 0
;;
NONE)
echo "No releasable commits since ${prev}."
echo "prepared=false" >> "$GITHUB_OUTPUT"
exit 0
;;
MINOR | PATCH) ;;
*)
echo "unexpected increment '${increment}'"
exit 1
;;
esac
gh release create "${tag}" --title "${tag}" --generate-notes ./dist/*
fi
uv run cz bump --increment "${increment}" --files-only --changelog --yes
next=$(uv run python -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
if [ -z "${next}" ]; then echo "could not read the bumped version"; exit 1; fi
echo "version=${next}" >> "$GITHUB_OUTPUT"
echo "prepared=true" >> "$GITHUB_OUTPUT"

# Pinned to a commit SHA (this action runs with the job's
# contents/pull-requests write token); Dependabot bumps it via the comment.
- name: Open or refresh the release PR
if: steps.phase.outputs.phase == 'prepare' && steps.prepare.outputs.prepared == 'true'
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
branch: release/next
base: main
title: "chore(release): v${{ steps.prepare.outputs.version }}"
commit-message: "chore(release): v${{ steps.prepare.outputs.version }}"
labels: release
body: |
Automated release PR. Merging this cuts release **v${{ steps.prepare.outputs.version }}**.

It lands the version bump in `pyproject.toml`, `uv.lock`, and
`CHANGELOG.md`; the Release workflow then tags `v${{ steps.prepare.outputs.version }}`
and publishes the GitHub Release with the built wheel + sdist.

Note: this PR is opened with the default token, so CI does not run on
it automatically.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ __pycache__/
.pytest_cache/
.hypothesis/
.ruff_cache/
.sonarlint/
.vscode/
.coverage
.coverage.*
coverage.xml
Expand Down
5 changes: 5 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ repos:
language: system
stages: [pre-commit, pre-push]
pass_filenames: false
- id: prevent-accidental-major
name: prevent accidental major version
entry: uv run python -m tools.prevent_accidental_major
language: system
stages: [commit-msg]
- id: commitizen-commit-msg
name: commit message (commitizen)
entry: uv run cz check --allow-abort --commit-msg-file
Expand Down
28 changes: 16 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,15 +174,12 @@ uv run cz check --message "feat: add truthful commit-msg enforcement"

## Releases

Releases are automated from Conventional Commits, with the version kept
**committed** in `pyproject.toml`. Because `main` is protected, the bump flows
through a one-click **release PR**:

1. Merging changes to `main` makes the Release workflow open or refresh a
`chore(release): vX.Y.Z` PR that bumps `pyproject.toml`, `uv.lock`, and
`CHANGELOG.md`.
2. Merging that release PR tags `vX.Y.Z` and publishes a GitHub Release with the
built wheel + sdist attached.
Releases are **continuous**: every merge to `main` advances the version. After
CI passes on `main`, the Release workflow computes the Conventional Commits
increment, and the **Oak Semantic Release Bot** (a `main`-ruleset bypass actor)
bumps `pyproject.toml`, `uv.lock`, and `CHANGELOG.md`, commits + tags `vX.Y.Z`,
pushes straight to `main`, and publishes a GitHub Release with the built wheel +
sdist attached. The bump commit carries `[skip ci]` so it does not loop.

The bump level is computed by Commitizen with this repo's policy:

Expand All @@ -192,9 +189,16 @@ The bump level is computed by Commitizen with this repo's policy:
| everything else (`chore`, `docs`, `perf`, `refactor`, `build`, `ci`, …) | patch |
| `!` / `BREAKING CHANGE` | no auto-release — a **major** is required |

**Major versions are manual.** A breaking change makes the automation stand
down; cut the major deliberately via the Release workflow's *Run workflow*
button (`increment = MAJOR`). Releases publish to GitHub Releases only (no PyPI).
**Major versions are manual.** A breaking marker makes the auto-release stand
down; cut the major deliberately via the Release workflow's *Run workflow* button
(`increment = MAJOR`). To stop a breaking marker landing by accident (which would
silently halt the auto-release), the `prevent-accidental-major` commit-msg hook
rejects `type!:` / `BREAKING CHANGE` in commits. Releases publish to GitHub
Releases only (no PyPI).

The workflow needs the `RELEASE_APP_ID` / `RELEASE_APP_PRIVATE_KEY` secrets and
the bot added as a ruleset bypass actor — see
[docs/repository-governance.md](docs/repository-governance.md).

## Governance

Expand Down
Loading