1 Commits

Author SHA1 Message Date
ci 8971121167 ci: restructure release pipeline into a single atomic release commit
CI / Sync project version with tag (hand-pushed tags only) (pull_request) Has been skipped
CI / Publish package to Gitea package registry (pull_request) Has been skipped
CI / Lint (ruff check) (pull_request) Successful in 19s
CI / Type check (ty) (pull_request) Successful in 16s
CI / Format (ruff format) (pull_request) Successful in 24s
CI / Tests (pull_request) Successful in 2m27s
CI / Release (bump, changelog, badges, tag) on merge to master (pull_request) Has been skipped
Every merge to master previously produced three separate commits (bump
version -> update changelog, tagged here -> update README badges), so
the published tag never carried the current release's own README
badges, and the badge commit leaked into the next release's changelog
since no cliff.toml parser skipped it.

- Extract the bump/changelog/badge assembly into
  .gitea/scripts/release-commit.sh, used by both the merge-to-master
  path and the hand-pushed-tag sync path, so every tag now points at
  one complete "chore: release vX.Y.Z" commit. Push the commit and its
  tag atomically.
- sync-version-on-tag now refuses to touch a tag whose commit isn't
  reachable from master (rather than silently rewriting an unreviewed
  tree), and builds a proper release commit via the same script when
  it does need to correct a hand-pushed tag's version.
- publish-package now depends only on sync-version-on-tag: since a tag
  can only pass that guard if its commit is already on master, and
  master is always fully checked, re-running the lint/type/test matrix
  on tag pushes was redundant.
- Factor the repeated checkout/setup-uv/env/sync steps into a local
  composite action (.gitea/actions/setup), fix `test`'s `needs` to
  include ruff-format, and bump actions/upload-artifact to v4.
- cliff.toml: skip "chore: release ..." commits from the changelog.

Verified by dry-running release-commit.sh against a scratch worktree
for all three code paths (patch bump, --no-bump, explicit VERSION
sync), confirming idempotency and that the resulting commit carries
pyproject.toml, .bumpversion.toml, uv.lock, CHANGELOG.md and README.md
together.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnotyatakKNS4NLjDbfYw1
2026-09-03 18:29:38 +02:00
4 changed files with 125 additions and 127 deletions
+20
View File
@@ -0,0 +1,20 @@
name: Setup uv + sync deps
description: >-
Install uv, point its cache at the runner-local mount, and sync the
project with the cpu + dev extras. Every CI job does this identically;
the caller must still mount /srv/act-runner-cache/uv:/uv-cache on its
own container (a composite action can't set that) and check out the
repo before this runs.
runs:
using: composite
steps:
- uses: astral-sh/setup-uv@v5
with:
enable-cache: false
- shell: bash
run: |
echo "UV_CACHE_DIR=/uv-cache" >> "$GITHUB_ENV"
echo "UV_LINK_MODE=copy" >> "$GITHUB_ENV"
- shell: bash
run: uv sync --extra cpu --extra dev
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Build one complete release commit: version bump (or sync to a given
# version, or no bump at all), changelog entry, and refreshed README
# badges — all in a single commit, tagged at the end. Used by both the
# merge-to-master release job and the hand-pushed-tag sync job, so "the
# tag == a complete release commit" holds either way a release gets made.
#
# Usage: release-commit.sh [--no-bump|VERSION]
# (no argument) bump the current patch version (normal merge path).
# --no-bump don't touch the version — the branch already bumped it
# (e.g. a manual minor/major bump); just build the
# changelog/badge commit for whatever version is current.
# VERSION sync the project to this exact version (tag-sync path).
#
# Preconditions: repo is checked out with full history (fetch-depth: 0),
# `uv` is available and synced, and git user.name/user.email are configured.
# Idempotent: if the resulting tag already exists, this is a no-op.
set -euo pipefail
VERSION_ARG="${1:-}"
if [ "$VERSION_ARG" = "--no-bump" ]; then
echo "Version already bumped by this branch; using current version as-is"
elif [ -n "$VERSION_ARG" ]; then
echo "Syncing project version to $VERSION_ARG"
uv version "$VERSION_ARG" --no-sync
uv lock
else
CURRENT_VERSION=$(uv version --short)
echo "Bumping patch version from $CURRENT_VERSION"
uv run bump-my-version bump patch --current-version "$CURRENT_VERSION" --no-commit --no-tag
# bump-my-version's pre_commit_hooks (uv lock + git add uv.lock) only run
# on its own commit path, which we skipped with --no-commit — so do it here.
uv lock
fi
VERSION=$(uv version --short)
TAG="v$VERSION"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists; nothing to do"
exit 0
fi
uv run git-cliff --tag "$TAG" --unreleased --prepend CHANGELOG.md
TEST_COUNT=$(uv run pytest --collect-only -q 2>/dev/null | grep -oE '^[0-9]+ tests? collected' | grep -oE '^[0-9]+')
sed -i -E "s|badge/version-[^-]+-informational|badge/version-${VERSION}-informational|" README.md
sed -i -E "s|badge/tests-[0-9]+%20passing-brightgreen|badge/tests-${TEST_COUNT}%20passing-brightgreen|" README.md
# .bumpversion.toml stores its own current_version, which bump-my-version
# rewrites even with --no-commit — must be staged or the next run sees a
# dirty tree and bump-my-version refuses (allow_dirty = false).
git add pyproject.toml .bumpversion.toml uv.lock CHANGELOG.md README.md
if git diff --cached --quiet; then
echo "Nothing changed; skipping release commit"
exit 0
fi
git commit -m "chore: release $TAG"
git tag -a "$TAG" -m "$TAG"
echo "Created release commit and tag $TAG"
+41 -126
View File
@@ -12,6 +12,7 @@ env:
jobs: jobs:
ruff-check: ruff-check:
name: Lint (ruff check) name: Lint (ruff check)
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.gitea.com/runner-images:ubuntu-latest image: docker.gitea.com/runner-images:ubuntu-latest
@@ -19,17 +20,12 @@ jobs:
- /srv/act-runner-cache/uv:/uv-cache - /srv/act-runner-cache/uv:/uv-cache
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5 - uses: ./.gitea/actions/setup
with:
enable-cache: false
- run: |
echo "UV_CACHE_DIR=/uv-cache" >> "$GITHUB_ENV"
echo "UV_LINK_MODE=copy" >> "$GITHUB_ENV"
- run: uv sync --extra cpu --extra dev
- run: uv run ruff check . - run: uv run ruff check .
ruff-format: ruff-format:
name: Format (ruff format) name: Format (ruff format)
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.gitea.com/runner-images:ubuntu-latest image: docker.gitea.com/runner-images:ubuntu-latest
@@ -37,17 +33,12 @@ jobs:
- /srv/act-runner-cache/uv:/uv-cache - /srv/act-runner-cache/uv:/uv-cache
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5 - uses: ./.gitea/actions/setup
with:
enable-cache: false
- run: |
echo "UV_CACHE_DIR=/uv-cache" >> "$GITHUB_ENV"
echo "UV_LINK_MODE=copy" >> "$GITHUB_ENV"
- run: uv sync --extra cpu --extra dev
- run: uv run ruff format --check . - run: uv run ruff format --check .
type-check: type-check:
name: Type check (ty) name: Type check (ty)
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.gitea.com/runner-images:ubuntu-latest image: docker.gitea.com/runner-images:ubuntu-latest
@@ -55,18 +46,13 @@ jobs:
- /srv/act-runner-cache/uv:/uv-cache - /srv/act-runner-cache/uv:/uv-cache
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5 - uses: ./.gitea/actions/setup
with:
enable-cache: false
- run: |
echo "UV_CACHE_DIR=/uv-cache" >> "$GITHUB_ENV"
echo "UV_LINK_MODE=copy" >> "$GITHUB_ENV"
- run: uv sync --extra cpu --extra dev
- run: uv run ty check . - run: uv run ty check .
test: test:
name: Tests name: Tests
needs: [ruff-check, type-check] if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: [ruff-check, ruff-format, type-check]
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.gitea.com/runner-images:ubuntu-latest image: docker.gitea.com/runner-images:ubuntu-latest
@@ -74,21 +60,15 @@ jobs:
- /srv/act-runner-cache/uv:/uv-cache - /srv/act-runner-cache/uv:/uv-cache
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5 - uses: ./.gitea/actions/setup
with:
enable-cache: false
- run: |
echo "UV_CACHE_DIR=/uv-cache" >> "$GITHUB_ENV"
echo "UV_LINK_MODE=copy" >> "$GITHUB_ENV"
- run: uv sync --extra cpu --extra dev
- run: uv run pytest --cov --cov-report=term-missing --cov-report=xml - run: uv run pytest --cov --cov-report=term-missing --cov-report=xml
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v4
with: with:
name: coverage-report name: coverage-report
path: coverage.xml path: coverage.xml
bump-version: release:
name: Bump version, tag, and update changelog on merge to master name: Release (bump, changelog, badges, tag) on merge to master
needs: [ruff-check, ruff-format, type-check, test] needs: [ruff-check, ruff-format, type-check, test]
if: github.ref == 'refs/heads/master' && github.event_name == 'push' if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -98,7 +78,7 @@ jobs:
- /srv/act-runner-cache/uv:/uv-cache - /srv/act-runner-cache/uv:/uv-cache
steps: steps:
# CI_TOKEN needs write:repository scope (not just read) — this job # CI_TOKEN needs write:repository scope (not just read) — this job
# pushes commits and tags to master, unlike ruff-check/ruff-format/ # pushes a commit and a tag to master, unlike ruff-check/ruff-format/
# type-check/test above, which only need to check out the repo. # type-check/test above, which only need to check out the repo.
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -114,22 +94,14 @@ jobs:
else else
echo "is_merge=false" >> "$GITHUB_OUTPUT" echo "is_merge=false" >> "$GITHUB_OUTPUT"
fi fi
- uses: astral-sh/setup-uv@v5 - uses: ./.gitea/actions/setup
if: steps.merge_check.outputs.is_merge == 'true'
with:
enable-cache: false
- run: |
echo "UV_CACHE_DIR=/uv-cache" >> "$GITHUB_ENV"
echo "UV_LINK_MODE=copy" >> "$GITHUB_ENV"
if: steps.merge_check.outputs.is_merge == 'true'
- run: uv sync --extra cpu --extra dev
if: steps.merge_check.outputs.is_merge == 'true' if: steps.merge_check.outputs.is_merge == 'true'
- name: Configure git identity - name: Configure git identity
if: steps.merge_check.outputs.is_merge == 'true' if: steps.merge_check.outputs.is_merge == 'true'
run: | run: |
git config user.name "gitea-actions" git config user.name "gitea-actions"
git config user.email "actions@git.larsbogner.de" git config user.email "actions@git.larsbogner.de"
- name: Bump patch version if this merge didn't already bump it - name: Build the release commit
if: steps.merge_check.outputs.is_merge == 'true' if: steps.merge_check.outputs.is_merge == 'true'
run: | run: |
OLD_VERSION=$(git show "${{ github.event.before }}:pyproject.toml" 2>/dev/null | grep -m1 '^version = ' | sed -E 's/version = "(.*)"/\1/') OLD_VERSION=$(git show "${{ github.event.before }}:pyproject.toml" 2>/dev/null | grep -m1 '^version = ' | sed -E 's/version = "(.*)"/\1/')
@@ -140,116 +112,64 @@ jobs:
fi fi
if [ "$OLD_VERSION" = "$CURRENT_VERSION" ]; then if [ "$OLD_VERSION" = "$CURRENT_VERSION" ]; then
echo "Version unchanged by this merge ($CURRENT_VERSION); bumping patch" echo "Version unchanged by this merge ($CURRENT_VERSION); bumping patch"
uv run bump-my-version bump patch --current-version "$CURRENT_VERSION" .gitea/scripts/release-commit.sh
else else
echo "Branch already bumped the version ($OLD_VERSION -> $CURRENT_VERSION); skipping auto-bump" echo "Branch already bumped the version ($OLD_VERSION -> $CURRENT_VERSION); building release commit without bumping"
.gitea/scripts/release-commit.sh --no-bump
fi fi
- name: Update changelog for the current version if not already tagged - name: Push the release commit and its tag together
if: steps.merge_check.outputs.is_merge == 'true' if: steps.merge_check.outputs.is_merge == 'true'
run: | run: |
VERSION=$(uv version --short) VERSION=$(uv version --short)
TAG="v$VERSION" TAG="v$VERSION"
if git rev-parse "$TAG" >/dev/null 2>&1; then if git rev-parse "$TAG" >/dev/null 2>&1 && [ "$(git rev-parse "$TAG")" = "$(git rev-parse HEAD)" ]; then
echo "Tag $TAG already exists; skipping changelog update" git push --atomic origin HEAD:master "refs/tags/$TAG"
else else
uv run git-cliff --tag "$TAG" --unreleased --prepend CHANGELOG.md echo "No new release commit/tag to push (already released, or nothing changed)"
git add CHANGELOG.md git push origin HEAD:master
if ! git diff --cached --quiet -- CHANGELOG.md; then
git commit -m "chore: update changelog for $TAG"
else
git restore --staged CHANGELOG.md
fi
fi
- name: Push commits and tag the current version
if: steps.merge_check.outputs.is_merge == 'true'
run: |
git push origin HEAD:master
VERSION=$(uv version --short)
TAG="v$VERSION"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists"
else
git tag -a "$TAG" -m "$TAG"
git push origin "refs/tags/$TAG"
fi fi
update-badges: sync-version-on-tag:
name: Update README badges (version, test count) name: Sync project version with tag (hand-pushed tags only)
needs: [ruff-check, ruff-format, type-check, test, bump-version] if: startsWith(github.ref, 'refs/tags/')
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.gitea.com/runner-images:ubuntu-latest image: docker.gitea.com/runner-images:ubuntu-latest
volumes: volumes:
- /srv/act-runner-cache/uv:/uv-cache - /srv/act-runner-cache/uv:/uv-cache
steps: steps:
# ref: master (not the triggering SHA) so this picks up whatever
# bump-version just pushed, rather than badging the pre-bump commit.
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
token: ${{ secrets.CI_TOKEN }} token: ${{ secrets.CI_TOKEN }}
ref: master fetch-depth: 0
- uses: astral-sh/setup-uv@v5 - name: Require the tagged commit to already be on master
with:
enable-cache: false
- run: |
echo "UV_CACHE_DIR=/uv-cache" >> "$GITHUB_ENV"
echo "UV_LINK_MODE=copy" >> "$GITHUB_ENV"
- run: uv sync --extra cpu --extra dev
- name: Compute version and test count
id: stats
run: | run: |
VERSION=$(uv version --short) git fetch origin master
TEST_COUNT=$(uv run pytest --collect-only -q 2>/dev/null | grep -oE '^[0-9]+ tests? collected' | grep -oE '^[0-9]+') if ! git merge-base --is-ancestor "${{ github.sha }}" origin/master; then
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "::error::Tag ${GITHUB_REF_NAME} points at a commit not on master; refusing to publish an unreviewed tree. Push the commit to master first, or delete and re-push the tag once it is."
echo "test_count=$TEST_COUNT" >> "$GITHUB_OUTPUT" exit 1
- name: Rewrite badge lines in README.md
run: |
sed -i -E "s|badge/version-[^-]+-informational|badge/version-${{ steps.stats.outputs.version }}-informational|" README.md
sed -i -E "s|badge/tests-[0-9]+%20passing-brightgreen|badge/tests-${{ steps.stats.outputs.test_count }}%20passing-brightgreen|" README.md
- name: Commit and push if the badges actually changed
run: |
git config user.name "gitea-actions"
git config user.email "actions@git.larsbogner.de"
git add README.md
if ! git diff --cached --quiet -- README.md; then
git commit -m "chore: update README badges (version ${{ steps.stats.outputs.version }}, ${{ steps.stats.outputs.test_count }} tests)"
git push origin HEAD:master
else
echo "Badges already up to date"
fi fi
- uses: ./.gitea/actions/setup
sync-version-on-tag: - name: Check tag against project version, build a release commit if they differ
name: Sync project version with tag
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.CI_TOKEN }}
- uses: astral-sh/setup-uv@v5
- name: Check tag against project version, update if they differ
run: | run: |
TAG_VERSION="${GITHUB_REF_NAME#v}" TAG_VERSION="${GITHUB_REF_NAME#v}"
CURRENT_VERSION=$(uv version --short) CURRENT_VERSION=$(uv version --short)
if [ "$TAG_VERSION" != "$CURRENT_VERSION" ]; then if [ "$TAG_VERSION" = "$CURRENT_VERSION" ]; then
echo "Tag version ($TAG_VERSION) != project version ($CURRENT_VERSION); updating pyproject.toml" echo "Tag version matches project version ($CURRENT_VERSION)"
uv version "$TAG_VERSION" --no-sync else
echo "Tag version ($TAG_VERSION) != project version ($CURRENT_VERSION); building a release commit"
git config user.name "gitea-actions" git config user.name "gitea-actions"
git config user.email "actions@git.larsbogner.de" git config user.email "actions@git.larsbogner.de"
git add pyproject.toml uv.lock .gitea/scripts/release-commit.sh "$TAG_VERSION"
git commit -m "chore: sync project version to tag ${GITHUB_REF_NAME}"
git push origin HEAD:master git push origin HEAD:master
git push origin ":refs/tags/${GITHUB_REF_NAME}" git push origin ":refs/tags/${GITHUB_REF_NAME}"
git tag -f "${GITHUB_REF_NAME}" HEAD git tag -f "${GITHUB_REF_NAME}" HEAD
git push origin "refs/tags/${GITHUB_REF_NAME}" git push origin "refs/tags/${GITHUB_REF_NAME}"
else
echo "Tag version matches project version ($CURRENT_VERSION)"
fi fi
publish-package: publish-package:
name: Publish package to Gitea package registry name: Publish package to Gitea package registry
needs: [ruff-check, ruff-format, type-check, test, sync-version-on-tag] needs: [sync-version-on-tag]
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
@@ -262,12 +182,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: ${{ github.ref_name }} ref: ${{ github.ref_name }}
- uses: astral-sh/setup-uv@v5 - uses: ./.gitea/actions/setup
with:
enable-cache: false
- run: |
echo "UV_CACHE_DIR=/uv-cache" >> "$GITHUB_ENV"
echo "UV_LINK_MODE=copy" >> "$GITHUB_ENV"
- run: uv build - run: uv build
# CI_TOKEN needs write:package scope (in addition to write:repository, # CI_TOKEN needs write:package scope (in addition to write:repository,
# used elsewhere) for this upload to authenticate. # used elsewhere) for this upload to authenticate.
+1 -1
View File
@@ -37,7 +37,7 @@ commit_preprocessors = [
protect_breaking_commits = false protect_breaking_commits = false
commit_parsers = [ commit_parsers = [
{ message = "^Merge ", skip = true }, { message = "^Merge ", skip = true },
{ message = "^chore: (bump version|update changelog|sync project version)", skip = true }, { message = "^chore: (release|bump version|update changelog|sync project version)", skip = true },
{ message = "^Add", group = "<!-- 0 -->Added" }, { message = "^Add", group = "<!-- 0 -->Added" },
{ message = "^(Fix|Clamp|Clip)", group = "<!-- 1 -->Fixed" }, { message = "^(Fix|Clamp|Clip)", group = "<!-- 1 -->Fixed" },
{ message = "^(Remove|Drop|Deprecate)", group = "<!-- 2 -->Removed" }, { message = "^(Remove|Drop|Deprecate)", group = "<!-- 2 -->Removed" },