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" },