From 8971121167dfc32333dd66b2ca295b1ca255b8a9 Mon Sep 17 00:00:00 2001 From: ci Date: Thu, 3 Sep 2026 18:29:38 +0200 Subject: [PATCH] ci: restructure release pipeline into a single atomic release commit 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 Claude-Session: https://claude.ai/code/session_01NnotyatakKNS4NLjDbfYw1 --- .gitea/actions/setup/action.yml | 20 ++++ .gitea/scripts/release-commit.sh | 63 ++++++++++++ .gitea/workflows/ci.yml | 167 ++++++++----------------------- cliff.toml | 2 +- 4 files changed, 125 insertions(+), 127 deletions(-) create mode 100644 .gitea/actions/setup/action.yml create mode 100755 .gitea/scripts/release-commit.sh diff --git a/.gitea/actions/setup/action.yml b/.gitea/actions/setup/action.yml new file mode 100644 index 0000000..040a106 --- /dev/null +++ b/.gitea/actions/setup/action.yml @@ -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 diff --git a/.gitea/scripts/release-commit.sh b/.gitea/scripts/release-commit.sh new file mode 100755 index 0000000..5975f5b --- /dev/null +++ b/.gitea/scripts/release-commit.sh @@ -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" diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 008feb9..4b14d2b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -12,6 +12,7 @@ env: jobs: ruff-check: name: Lint (ruff check) + if: ${{ !startsWith(github.ref, 'refs/tags/') }} runs-on: ubuntu-latest container: image: docker.gitea.com/runner-images:ubuntu-latest @@ -19,17 +20,12 @@ jobs: - /srv/act-runner-cache/uv:/uv-cache steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 - 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 + - uses: ./.gitea/actions/setup - run: uv run ruff check . ruff-format: name: Format (ruff format) + if: ${{ !startsWith(github.ref, 'refs/tags/') }} runs-on: ubuntu-latest container: image: docker.gitea.com/runner-images:ubuntu-latest @@ -37,17 +33,12 @@ jobs: - /srv/act-runner-cache/uv:/uv-cache steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 - 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 + - uses: ./.gitea/actions/setup - run: uv run ruff format --check . type-check: name: Type check (ty) + if: ${{ !startsWith(github.ref, 'refs/tags/') }} runs-on: ubuntu-latest container: image: docker.gitea.com/runner-images:ubuntu-latest @@ -55,18 +46,13 @@ jobs: - /srv/act-runner-cache/uv:/uv-cache steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 - 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 + - uses: ./.gitea/actions/setup - run: uv run ty check . test: name: Tests - needs: [ruff-check, type-check] + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + needs: [ruff-check, ruff-format, type-check] runs-on: ubuntu-latest container: image: docker.gitea.com/runner-images:ubuntu-latest @@ -74,21 +60,15 @@ jobs: - /srv/act-runner-cache/uv:/uv-cache steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 - 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 + - uses: ./.gitea/actions/setup - run: uv run pytest --cov --cov-report=term-missing --cov-report=xml - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: coverage-report path: coverage.xml - bump-version: - name: Bump version, tag, and update changelog on merge to master + release: + name: Release (bump, changelog, badges, tag) on merge to master needs: [ruff-check, ruff-format, type-check, test] if: github.ref == 'refs/heads/master' && github.event_name == 'push' runs-on: ubuntu-latest @@ -98,7 +78,7 @@ jobs: - /srv/act-runner-cache/uv:/uv-cache steps: # 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. - uses: actions/checkout@v4 with: @@ -114,22 +94,14 @@ jobs: else echo "is_merge=false" >> "$GITHUB_OUTPUT" fi - - uses: astral-sh/setup-uv@v5 - 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 + - uses: ./.gitea/actions/setup if: steps.merge_check.outputs.is_merge == 'true' - name: Configure git identity if: steps.merge_check.outputs.is_merge == 'true' run: | git config user.name "gitea-actions" 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' run: | 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 if [ "$OLD_VERSION" = "$CURRENT_VERSION" ]; then 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 - 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 - - 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' run: | VERSION=$(uv version --short) TAG="v$VERSION" - if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "Tag $TAG already exists; skipping changelog update" + if git rev-parse "$TAG" >/dev/null 2>&1 && [ "$(git rev-parse "$TAG")" = "$(git rev-parse HEAD)" ]; then + git push --atomic origin HEAD:master "refs/tags/$TAG" else - uv run git-cliff --tag "$TAG" --unreleased --prepend CHANGELOG.md - git add CHANGELOG.md - 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" + echo "No new release commit/tag to push (already released, or nothing changed)" + git push origin HEAD:master fi - update-badges: - name: Update README badges (version, test count) - needs: [ruff-check, ruff-format, type-check, test, bump-version] - if: github.ref == 'refs/heads/master' && github.event_name == 'push' + sync-version-on-tag: + name: Sync project version with tag (hand-pushed tags only) + if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest container: image: docker.gitea.com/runner-images:ubuntu-latest volumes: - /srv/act-runner-cache/uv:/uv-cache 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 with: token: ${{ secrets.CI_TOKEN }} - ref: master - - uses: astral-sh/setup-uv@v5 - 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 + fetch-depth: 0 + - name: Require the tagged commit to already be on master run: | - VERSION=$(uv version --short) - TEST_COUNT=$(uv run pytest --collect-only -q 2>/dev/null | grep -oE '^[0-9]+ tests? collected' | grep -oE '^[0-9]+') - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "test_count=$TEST_COUNT" >> "$GITHUB_OUTPUT" - - 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" + git fetch origin master + if ! git merge-base --is-ancestor "${{ github.sha }}" origin/master; then + 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." + exit 1 fi - - sync-version-on-tag: - 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 + - uses: ./.gitea/actions/setup + - name: Check tag against project version, build a release commit if they differ run: | TAG_VERSION="${GITHUB_REF_NAME#v}" CURRENT_VERSION=$(uv version --short) - if [ "$TAG_VERSION" != "$CURRENT_VERSION" ]; then - echo "Tag version ($TAG_VERSION) != project version ($CURRENT_VERSION); updating pyproject.toml" - uv version "$TAG_VERSION" --no-sync + if [ "$TAG_VERSION" = "$CURRENT_VERSION" ]; then + echo "Tag version matches project version ($CURRENT_VERSION)" + else + echo "Tag version ($TAG_VERSION) != project version ($CURRENT_VERSION); building a release commit" git config user.name "gitea-actions" git config user.email "actions@git.larsbogner.de" - git add pyproject.toml uv.lock - git commit -m "chore: sync project version to tag ${GITHUB_REF_NAME}" + .gitea/scripts/release-commit.sh "$TAG_VERSION" git push origin HEAD:master git push origin ":refs/tags/${GITHUB_REF_NAME}" git tag -f "${GITHUB_REF_NAME}" HEAD git push origin "refs/tags/${GITHUB_REF_NAME}" - else - echo "Tag version matches project version ($CURRENT_VERSION)" fi publish-package: 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/') runs-on: ubuntu-latest container: @@ -262,12 +182,7 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ github.ref_name }} - - uses: astral-sh/setup-uv@v5 - with: - enable-cache: false - - run: | - echo "UV_CACHE_DIR=/uv-cache" >> "$GITHUB_ENV" - echo "UV_LINK_MODE=copy" >> "$GITHUB_ENV" + - uses: ./.gitea/actions/setup - run: uv build # CI_TOKEN needs write:package scope (in addition to write:repository, # used elsewhere) for this upload to authenticate. diff --git a/cliff.toml b/cliff.toml index 7207e63..224bf71 100644 --- a/cliff.toml +++ b/cliff.toml @@ -37,7 +37,7 @@ commit_preprocessors = [ protect_breaking_commits = false commit_parsers = [ { 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 = "Added" }, { message = "^(Fix|Clamp|Clip)", group = "Fixed" }, { message = "^(Remove|Drop|Deprecate)", group = "Removed" },