Skip to content

Automate release on pyproject version bump#160

Merged
jantman merged 1 commit into
mainfrom
automate-release-on-version-bump
Jun 13, 2026
Merged

Automate release on pyproject version bump#160
jantman merged 1 commit into
mainfrom
automate-release-on-version-bump

Conversation

@jantman

@jantman jantman commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Switches the release model from "push a git tag" to "bump the version in pyproject.toml on main", matching the pattern used in our other modern repos (e.g. equipment-status-board).

How it works now

release.yml triggers on push to main and:

  1. Reads the version from pyproject.toml.
  2. Compares it to the latest GitHub release.
  3. If higher, it: creates the git tag (bare version, e.g. 0.14.0 — no v prefix, matching existing tags), builds & pushes the Docker image to GHCR, publishes to PyPI, and creates a GitHub release with auto-generated notes plus docker-pull / PyPI pointers.
  4. If unchanged, the workflow no-ops (every merge runs it, but only version bumps release).

No more manual tagging.

Notes

  • Carries forward the lowercase-GHCR-repo fix from Lowercase GHCR image repository name in docker builds #159 and the SBOM / OCI labels. The image version label now comes from the detected version instead of github.ref_name (which would be main on a branch push).
  • Keeps PyPI publishing via JRubics/poetry-publish; the CLAUDE_CODE_OAUTH_TOKEN and PYPI_TOKEN secrets are already present on the repo.
  • Tags are pushed by the workflow but don't re-trigger it (it only listens to branch pushes, not tag pushes).

Docs

  • docs/source/contributing.rst — rewrote the Release Process section.
  • CLAUDE.md — added a Releasing section documenting the auto-release-on-version-bump flow and "do not tag manually."
  • README.rst — no change (it has no release/tag content).

Effect on the pending 0.14.0 release

pyproject.toml is already at 0.14.0 and the latest release is 0.13.0. Merging this PR will itself trigger the new workflow and auto-release 0.14.0 — so this replaces the manual tag-and-release step we paused earlier.

🤖 Generated with Claude Code

Switch the release model from "push a tag" to "bump the version in
pyproject.toml on main." This matches the pattern used in our other
modern repos (e.g. equipment-status-board).

release.yml now triggers on push to main, reads the version from
pyproject.toml, and compares it to the latest GitHub release. When the
version is higher it auto-tags the commit (bare version, no `v` prefix,
matching existing tags), builds and pushes the Docker image to GHCR,
publishes to PyPI, and creates a GitHub release with generated notes
plus docker-pull / PyPI pointers. When the version is unchanged the
workflow no-ops. Carries forward the lowercase-GHCR-repo fix and the
SBOM/OCI labels; the image version label now comes from the detected
version rather than github.ref_name.

Docs updated accordingly: contributing.rst Release Process and a new
Releasing section in CLAUDE.md (README has no release content).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Coverage

Coverage Report
FileStmtsMissBranchBrPartCoverMissing
src/dm_mac
   __init__.py73060100% 
   cli_utils.py15000100% 
   neon_fob_adder.py2321560593%79, 116–117, 124, 270, 333–334, 341, 364–367, 454–456
   neongetter.py211154399%309
   slack_handler.py2200560100% 
   utils.py25040100% 
src/dm_mac/models
   __init__.py0000100% 
   api_schemas.py34000100% 
   machine.py592162001697%615, 695, 987–989, 1101–1110, 1168
   users.py1030320100% 
src/dm_mac/views
   __init__.py0000100% 
   api.py32000100% 
   machine.py1030120100% 
   prometheus.py1320121100% 
TOTAL1772324362598% 

Tests Skipped Failures Errors Time
312 0 💤 0 ❌ 0 🔥 24.562s ⏱️

Comment on lines +44 to +48
SHOULD=$(python3 -c "
current = tuple(int(x) for x in '$CURRENT_VERSION'.split('.'))
latest = tuple(int(x) for x in '$LATEST_VERSION'.split('.'))
print('true' if current > latest else 'false')
")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two related issues in this version comparison block:

1. Shell injection via variable interpolation

$CURRENT_VERSION and $LATEST_VERSION are interpolated directly into a python3 -c source string, landing inside single-quoted Python string literals. The version regex [^"]+ on line 33 permits any character except " — including a single quote, which is sufficient to break out of the Python literal. In a runner holding contents: write, packages: write, and PYPI_TOKEN this is a code-execution sink. (You already avoided this exact pattern later with the comment # Build release body in a file to avoid shell escaping issues — the same concern applies here.)

Context: release.yml L43–49

2. int() crash on non-numeric version components

tuple(int(x) for x in ...) has no error handling. A pre-release version like 0.15.0b1 or 0.15.0.dev0 (produced by poetry version prerelease) splits to ['0', '15', '0b1']int('0b1') raises ValueError and the workflow fails.

Context: release.yml L44–48

Suggested fix: pass versions through the environment (data, not source code) and add error handling:

# Replace lines 44–48 with:
SHOULD=$(CURRENT_VER="$CURRENT_VERSION" LATEST_VER="$LATEST_VERSION" python3 -c "
import os, sys
def to_tuple(v):
    try:
        return tuple(int(x) for x in v.split('.'))
    except ValueError:
        print(f'non-numeric version: {v}', file=sys.stderr); sys.exit(1)
print('true' if to_tuple(os.environ['CURRENT_VER']) > to_tuple(os.environ['LATEST_VER']) else 'false')
")

Comment on lines 76 to 80
- name: Build and publish to pypi
if: steps.version_check.outputs.should_release == 'true'
uses: JRubics/poetry-publish@v2.1
with:
pypi_token: ${{ secrets.PYPI_TOKEN }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-atomic release: PyPI publishes before the GitHub release (the idempotency gate)

should_release is computed by checking whether a GitHub release exists for the current version (line 37). This PyPI publish step (L76–80) runs four steps before "Create GitHub Release" (L87–115). If the job fails or is cancelled after PyPI publishes but before the GitHub release step completes, the next rerun computes should_release=true again (no GitHub release found) and then fails permanently at this step — PyPI rejects re-uploading an already-published version with HTTP 400 ("File already exists"). The release steps are non-atomic and the idempotency gate is written last.

Context: release.yml L75–115

Suggested fixes (pick one):

  • Add skip_existing: true to JRubics/poetry-publish so reruns skip an already-published version rather than erroring, making this step idempotent.
  • Reorder steps so the GitHub release is created before the PyPI publish, so the gating condition becomes true before the irreversible action.

@jantman jantman merged commit 99a3d65 into main Jun 13, 2026
21 checks passed
@jantman jantman deleted the automate-release-on-version-bump branch June 13, 2026 12:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant