Auto-bump patch version and create git tag on merge to master (CI) #50

Closed
opened 2026-08-14 10:02:18 +02:00 by lars · 2 comments
Owner

Context

Right now the only version automation in .gitea/workflows/ci.yml is sync-version-on-tag: when a tag is pushed manually, it corrects pyproject.toml's version to match the tag if they've drifted. Tags themselves, and every version bump, are still entirely manual.

This flips that around: version bumps should drive tags, not the other way around.

  1. A new bump-version CI job, using bump-my-version as the bump mechanism instead of hand-rolled uv version --bump.
  2. On every merge to master, auto-bump the patch version — but only if the merged branch didn't already bump the version itself (covers a deliberate minor/major bump, or a manual patch bump, done by hand in the PR branch).
  3. Whichever way the version changed (auto patch bump, or a manual bump baked into the branch), create and push a matching vX.Y.Z git tag.
  4. Only actual merge commits should trigger this — a regular/direct commit pushed straight to master must not trigger a bump or tag.

The existing sync-version-on-tag job stays as-is — it's still a useful safety net for a tag created/moved by hand that doesn't match pyproject.toml.

Design

Detecting "did the branch already bump the version": compare pyproject.toml's version at github.event.before (master's tip right before this push) against the version after the push. This works regardless of merge strategy since it's diffing master's own history, not inspecting individual PR commits.

Bump mechanism: bump-my-version, added as a dev extra dependency, invoked via uv run bump-my-version bump patch --current-version "$(uv version --short)". The explicit --current-version override means we never rely on .bumpversion.toml's own stored current_version staying in sync with pyproject.tomlpyproject.toml (read via the existing uv version --short convention) remains the single source of truth, exactly as today.

Tagging: kept as plain git tag/git push, done uniformly in its own step for both cases (auto-bumped or already-bumped-by-the-branch) — simpler than relying on bump-my-version's own tag=true machinery, which would only cover the auto-bump path. .bumpversion.toml sets tag = false accordingly; bump-my-version's job is strictly "bump + commit."

uv.lock: giant's own version field is duplicated inside uv.lock (see grep -A2 'name = "giant"' uv.lock). bump-my-version's pre_commit_hooks = ["uv lock", "git add uv.lock"] regenerates and stages it before the commit, mirroring the existing sync-version-on-tag job's git add pyproject.toml uv.lock.

Only merge commits trigger this: a direct/regular commit pushed straight to master should not trigger a bump or tag — only an actual merge (a commit with 2+ parents, as gitea produces for "Merge pull request" merges — see e.g. dc4cad7 in this repo's history) should. Checked with git rev-parse HEAD^@ | wc -l (parent count of the current HEAD) in a dedicated first step, whose output gates every later step in the job. Squash-merges and rebase-merges produce single-parent commits, so under this rule they're treated like regular commits and won't auto-bump/tag.

Guarding against loops: the bump commit's message ends in [skip ci] (the convention sync-version-on-tag already relies on), so pushing it doesn't re-trigger the full pipeline. Independently, the merge-commit gate above is itself already a second guard: the bump commit and the tag are single-parent, non-merge commits, so even without [skip ci] this job would ignore them. The bump-version job is also gated to github.ref == 'refs/heads/master' && github.event_name == 'push', so it never fires on PRs or on tag pushes (the top-level on.push trigger covers both branches and tags).

Ordering: bump-version lists needs: [ruff-check, ruff-format, type-check, test], so master only gets bumped/tagged after CI is green on that push.

Changes

1. pyproject.toml

Add bump-my-version to the dev extra:

dev = [
    "pytest>=8,<10",
    "pytest-cov>=5,<8",
    "ruff>=0.15,<1",
    "ty>=0.0.50,<0.1",
    "bump-my-version>=1.2,<2",
    "giant[convert,analysis,geometry,wandb]",
]

2. New file .bumpversion.toml (repo root)

[tool.bumpversion]
current_version = "0.3.0"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"
replace = "{new_version}"
regex = false
allow_dirty = false
commit = true
tag = false
message = "chore: bump version {current_version} -> {new_version} [skip ci]"
pre_commit_hooks = ["uv lock", "git add uv.lock"]

[[tool.bumpversion.files]]
filename = "pyproject.toml"
search = "version = \"{current_version}\""
replace = "version = \"{new_version}\""

(current_version here is cosmetic bookkeeping only — every CI invocation overrides it via --current-version.)

3. .gitea/workflows/ci.yml

Add a new job (needs: [ruff-check, ruff-format, type-check, test], gated to github.ref == 'refs/heads/master' && github.event_name == 'push'):

  bump-version:
    name: Bump version and 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
    container:
      image: docker.gitea.com/runner-images:ubuntu-latest
      volumes:
        - /srv/act-runner-cache/uv:/uv-cache
    steps:
      - uses: actions/checkout@v4
        with:
          token: ${{ secrets.CI_TOKEN }}
          fetch-depth: 0
      - name: Check whether this push is a merge commit
        id: merge_check
        run: |
          PARENTS=$(git rev-parse HEAD^@ | wc -l)
          echo "HEAD has $PARENTS parent(s)"
          if [ "$PARENTS" -ge 2 ]; then
            echo "is_merge=true" >> "$GITHUB_OUTPUT"
          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
        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
        if: steps.merge_check.outputs.is_merge == 'true'
        run: |
          OLD_VERSION=$(git show "${{ github.event.before }}:pyproject.toml" | grep -m1 '^version = ' | sed -E 's/version = "(.*)"/\1/')
          CURRENT_VERSION=$(uv version --short)
          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"
            git push origin HEAD:master
          else
            echo "Branch already bumped the version ($OLD_VERSION -> $CURRENT_VERSION); skipping auto-bump"
          fi
      - name: Tag the current version if not already tagged
        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"
          else
            git tag -a "$TAG" -m "$TAG"
            git push origin "refs/tags/$TAG"
          fi

Reuses the same CI_TOKEN secret, container image, and uv-cache volume as the other jobs; git identity matches sync-version-on-tag. HEAD^@ (git's "all parents of HEAD" syntax) checked via git rev-parse is what determines merge-vs-regular-commit; every step after the check carries the same if: guard.

Verification

  • uv sync --extra cpu --extra dev locally, then sanity-check the config without touching anything:
    • uv run bump-my-version show-bump — confirms the parse/serialize path (0.3.0 → patch → 0.3.1).
    • uv run bump-my-version bump patch --current-version "$(uv version --short)" --dry-run -v — confirms it targets the right line in pyproject.toml and would run the uv lock hook, without writing anything.
  • uv run ruff check . / uv run ruff format --check . / uv run ty check . — new .bumpversion.toml and pyproject.toml edit don't affect these, but run for hygiene.
  • Real end-to-end verification needs an actual push to master on the gitea remote (this job can't be dry-run in isolation) — the cleanest way is to let the merge of the PR implementing this issue be the first real test: watch the Actions run, confirm bump-version bumps 0.3.0 → 0.3.1, pushes a [skip ci] commit, and pushes tag v0.3.1, and confirm the [skip ci] commit doesn't re-trigger the pipeline.
## Context Right now the only version automation in `.gitea/workflows/ci.yml` is `sync-version-on-tag`: when a tag is pushed manually, it corrects `pyproject.toml`'s version to match the tag if they've drifted. Tags themselves, and every version bump, are still entirely manual. This flips that around: version bumps should drive tags, not the other way around. 1. A new `bump-version` CI job, using [bump-my-version](https://github.com/callowayproject/bump-my-version) as the bump mechanism instead of hand-rolled `uv version --bump`. 2. On every **merge** to `master`, auto-bump the **patch** version — but only if the merged branch didn't already bump the version itself (covers a deliberate minor/major bump, or a manual patch bump, done by hand in the PR branch). 3. Whichever way the version changed (auto patch bump, or a manual bump baked into the branch), create and push a matching `vX.Y.Z` git tag. 4. Only actual merge commits should trigger this — a regular/direct commit pushed straight to `master` must not trigger a bump or tag. The existing `sync-version-on-tag` job stays as-is — it's still a useful safety net for a tag created/moved by hand that doesn't match `pyproject.toml`. ## Design **Detecting "did the branch already bump the version"**: compare `pyproject.toml`'s version at `github.event.before` (master's tip right before this push) against the version after the push. This works regardless of merge strategy since it's diffing master's own history, not inspecting individual PR commits. **Bump mechanism**: `bump-my-version`, added as a `dev` extra dependency, invoked via `uv run bump-my-version bump patch --current-version "$(uv version --short)"`. The explicit `--current-version` override means we never rely on `.bumpversion.toml`'s own stored `current_version` staying in sync with `pyproject.toml` — `pyproject.toml` (read via the existing `uv version --short` convention) remains the single source of truth, exactly as today. **Tagging**: kept as plain `git tag`/`git push`, done uniformly in its own step for *both* cases (auto-bumped or already-bumped-by-the-branch) — simpler than relying on bump-my-version's own `tag=true` machinery, which would only cover the auto-bump path. `.bumpversion.toml` sets `tag = false` accordingly; bump-my-version's job is strictly "bump + commit." **uv.lock**: `giant`'s own `version` field is duplicated inside `uv.lock` (see `grep -A2 'name = "giant"' uv.lock`). bump-my-version's `pre_commit_hooks = ["uv lock", "git add uv.lock"]` regenerates and stages it before the commit, mirroring the existing `sync-version-on-tag` job's `git add pyproject.toml uv.lock`. **Only merge commits trigger this**: a direct/regular commit pushed straight to `master` should *not* trigger a bump or tag — only an actual merge (a commit with 2+ parents, as gitea produces for "Merge pull request" merges — see e.g. `dc4cad7` in this repo's history) should. Checked with `git rev-parse HEAD^@ | wc -l` (parent count of the current `HEAD`) in a dedicated first step, whose output gates every later step in the job. Squash-merges and rebase-merges produce single-parent commits, so under this rule they're treated like regular commits and won't auto-bump/tag. **Guarding against loops**: the bump commit's message ends in `[skip ci]` (the convention `sync-version-on-tag` already relies on), so pushing it doesn't re-trigger the full pipeline. Independently, the merge-commit gate above is itself already a second guard: the bump commit and the tag are single-parent, non-merge commits, so even without `[skip ci]` this job would ignore them. The `bump-version` job is also gated to `github.ref == 'refs/heads/master' && github.event_name == 'push'`, so it never fires on PRs or on tag pushes (the top-level `on.push` trigger covers both branches and tags). **Ordering**: `bump-version` lists `needs: [ruff-check, ruff-format, type-check, test]`, so master only gets bumped/tagged after CI is green on that push. ## Changes ### 1. `pyproject.toml` Add `bump-my-version` to the `dev` extra: ```toml dev = [ "pytest>=8,<10", "pytest-cov>=5,<8", "ruff>=0.15,<1", "ty>=0.0.50,<0.1", "bump-my-version>=1.2,<2", "giant[convert,analysis,geometry,wandb]", ] ``` ### 2. New file `.bumpversion.toml` (repo root) ```toml [tool.bumpversion] current_version = "0.3.0" parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)" serialize = ["{major}.{minor}.{patch}"] search = "{current_version}" replace = "{new_version}" regex = false allow_dirty = false commit = true tag = false message = "chore: bump version {current_version} -> {new_version} [skip ci]" pre_commit_hooks = ["uv lock", "git add uv.lock"] [[tool.bumpversion.files]] filename = "pyproject.toml" search = "version = \"{current_version}\"" replace = "version = \"{new_version}\"" ``` (`current_version` here is cosmetic bookkeeping only — every CI invocation overrides it via `--current-version`.) ### 3. `.gitea/workflows/ci.yml` Add a new job (`needs: [ruff-check, ruff-format, type-check, test]`, gated to `github.ref == 'refs/heads/master' && github.event_name == 'push'`): ```yaml bump-version: name: Bump version and 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 container: image: docker.gitea.com/runner-images:ubuntu-latest volumes: - /srv/act-runner-cache/uv:/uv-cache steps: - uses: actions/checkout@v4 with: token: ${{ secrets.CI_TOKEN }} fetch-depth: 0 - name: Check whether this push is a merge commit id: merge_check run: | PARENTS=$(git rev-parse HEAD^@ | wc -l) echo "HEAD has $PARENTS parent(s)" if [ "$PARENTS" -ge 2 ]; then echo "is_merge=true" >> "$GITHUB_OUTPUT" 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 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 if: steps.merge_check.outputs.is_merge == 'true' run: | OLD_VERSION=$(git show "${{ github.event.before }}:pyproject.toml" | grep -m1 '^version = ' | sed -E 's/version = "(.*)"/\1/') CURRENT_VERSION=$(uv version --short) 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" git push origin HEAD:master else echo "Branch already bumped the version ($OLD_VERSION -> $CURRENT_VERSION); skipping auto-bump" fi - name: Tag the current version if not already tagged 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" else git tag -a "$TAG" -m "$TAG" git push origin "refs/tags/$TAG" fi ``` Reuses the same `CI_TOKEN` secret, container image, and uv-cache volume as the other jobs; git identity matches `sync-version-on-tag`. `HEAD^@` (git's "all parents of HEAD" syntax) checked via `git rev-parse` is what determines merge-vs-regular-commit; every step after the check carries the same `if:` guard. ## Verification - `uv sync --extra cpu --extra dev` locally, then sanity-check the config without touching anything: - `uv run bump-my-version show-bump` — confirms the parse/serialize path (`0.3.0 → patch → 0.3.1`). - `uv run bump-my-version bump patch --current-version "$(uv version --short)" --dry-run -v` — confirms it targets the right line in `pyproject.toml` and would run the `uv lock` hook, without writing anything. - `uv run ruff check .` / `uv run ruff format --check .` / `uv run ty check .` — new `.bumpversion.toml` and `pyproject.toml` edit don't affect these, but run for hygiene. - Real end-to-end verification needs an actual push to `master` on the gitea remote (this job can't be dry-run in isolation) — the cleanest way is to let the merge of the PR implementing this issue be the first real test: watch the Actions run, confirm `bump-version` bumps `0.3.0 → 0.3.1`, pushes a `[skip ci]` commit, and pushes tag `v0.3.1`, and confirm the `[skip ci]` commit doesn't re-trigger the pipeline.
Author
Owner

I'd also like to use the commit messages to auto-create a changelog for every version — worth folding into this bump-version CI job (or a follow-up) so each tagged release gets a generated changelog entry alongside the version bump.

I'd also like to use the commit messages to auto-create a changelog for every version — worth folding into this bump-version CI job (or a follow-up) so each tagged release gets a generated changelog entry alongside the version bump.
Author
Owner

Fixed in 5b478d2 on fix/issue-50.

Added a bump-version CI job (needs the four existing checks, gated to actual merge commits on master via HEAD^@'s parent count) that auto-bumps the patch version with bump-my-version when a merged branch didn't already bump it, then creates and pushes a matching vX.Y.Z tag — exactly as designed in the issue body. sync-version-on-tag is untouched.

Folded in the changelog request from the comment above (per your choice when I asked during planning): the same job now also runs git-cliff to generate a changelog entry and prepends it to CHANGELOG.md before pushing. cliff.toml is tuned to this repo's plain imperative commit style rather than Conventional Commits — commits are grouped Added/Fixed/Removed/Changed by leading verb, '(gitea #N)' is linkified to the issue, and merge/[skip ci] commits are dropped. CHANGELOG.md starts fresh (no backfill of v0.2.0-v0.3.3).

Added tests/test_release_tooling.py to check .bumpversion.toml's search pattern actually matches pyproject.toml, and that cliff.toml groups/links/filters commits correctly against a synthetic repo (skipped if the git-cliff binary isn't on PATH).

Left open: real end-to-end verification needs an actual merge to master — the cleanest test is watching this PR's own merge bump 0.3.3 -> 0.3.4, push the bump + changelog commits, and push tag v0.3.4, then confirming the [skip ci] commits don't re-trigger the pipeline.

Fixed in 5b478d2 on fix/issue-50. Added a bump-version CI job (needs the four existing checks, gated to actual merge commits on master via HEAD^@'s parent count) that auto-bumps the patch version with bump-my-version when a merged branch didn't already bump it, then creates and pushes a matching vX.Y.Z tag — exactly as designed in the issue body. sync-version-on-tag is untouched. Folded in the changelog request from the comment above (per your choice when I asked during planning): the same job now also runs git-cliff to generate a changelog entry and prepends it to CHANGELOG.md before pushing. cliff.toml is tuned to this repo's plain imperative commit style rather than Conventional Commits — commits are grouped Added/Fixed/Removed/Changed by leading verb, '(gitea #N)' is linkified to the issue, and merge/[skip ci] commits are dropped. CHANGELOG.md starts fresh (no backfill of v0.2.0-v0.3.3). Added tests/test_release_tooling.py to check .bumpversion.toml's search pattern actually matches pyproject.toml, and that cliff.toml groups/links/filters commits correctly against a synthetic repo (skipped if the git-cliff binary isn't on PATH). Left open: real end-to-end verification needs an actual merge to master — the cleanest test is watching this PR's own merge bump 0.3.3 -> 0.3.4, push the bump + changelog commits, and push tag v0.3.4, then confirming the [skip ci] commits don't re-trigger the pipeline.
lars closed this issue 2026-08-18 10:40:34 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: lars/giant#50