Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 79 additions & 33 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,68 +2,114 @@
#
# 1. In repository Settings -> Actions -> General, ensure "Allow all actions and reusable workflows" is selected, and that under "Workflow permissions", "Read repository contents and packages permissions" is checked.
#
name: Release on Tag
# Releases are driven by the version in pyproject.toml: when a commit lands on
# main with a version higher than the latest GitHub release, this workflow
# tags it, builds and pushes the Docker image to GHCR, publishes to PyPI, and
# creates a GitHub release. No manual tagging required.
name: Release
on:
push:
tags:
- '*'
branches:
- main
permissions:
contents: write
packages: write
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install dependencies
run: python -m pip install --upgrade pip && pip install poetry && poetry install
- name: Get Version
id: get-version
run: echo "APP_VERSION=$(poetry version -s)" >> $GITHUB_OUTPUT
- name: Ensure tag matches version
if: github.ref_name != steps.get-version.outputs.APP_VERSION
fetch-depth: 0
- name: Check version vs. latest release
id: version_check
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "ERROR: tag name (${{ github.ref_name }}) does not match current version in version.py (${{ steps.get-version.outputs.APP_VERSION }})"
exit 2
CURRENT_VERSION=$(python3 -c "
import re, pathlib
text = pathlib.Path('pyproject.toml').read_text()
m = re.search(r'^version\s*=\s*\"([^\"]+)\"', text, re.MULTILINE)
print(m.group(1))
")
echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"

LATEST_TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName // empty' 2>/dev/null || true)

if [ -z "$LATEST_TAG" ]; then
echo "No existing releases found — first release"
echo "should_release=true" >> "$GITHUB_OUTPUT"
else
LATEST_VERSION="${LATEST_TAG#v}"
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')
")
Comment on lines +44 to +48

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')
")

echo "Latest release: $LATEST_TAG, current version: $CURRENT_VERSION, should_release: $SHOULD"
echo "should_release=$SHOULD" >> "$GITHUB_OUTPUT"
fi
- name: Set up Docker Buildx
if: steps.version_check.outputs.should_release == 'true'
uses: docker/setup-buildx-action@v4
- name: Login to GHCR
if: steps.version_check.outputs.should_release == 'true'
run: echo "${{secrets.GITHUB_TOKEN}}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
- name: Lowercase image repository name
if: steps.version_check.outputs.should_release == 'true'
id: image
run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Docker Build and Push
if: steps.version_check.outputs.should_release == 'true'
uses: docker/build-push-action@v7
with:
push: true
sbom: true
labels: |
org.opencontainers.image.url=https://github.com/${{ github.repository }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.version=${{ github.ref_name }}
org.opencontainers.image.version=${{ steps.version_check.outputs.current_version }}
org.opencontainers.image.revision=${{ github.sha }}
tags: |
ghcr.io/${{ steps.image.outputs.repo }}:${{ github.ref_name }}
ghcr.io/${{ steps.image.outputs.repo }}:${{ steps.version_check.outputs.current_version }}
ghcr.io/${{ steps.image.outputs.repo }}:latest
- 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 }}
Comment on lines 76 to 80

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.

- name: Create Release
id: create_release
uses: softprops/action-gh-release@v3
- name: Create git tag
if: steps.version_check.outputs.should_release == 'true'
run: |
VERSION="${{ steps.version_check.outputs.current_version }}"
git tag "$VERSION"
git push origin "$VERSION"
- name: Create GitHub Release
if: steps.version_check.outputs.should_release == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
body: |
Release ${{ github.ref_name }}.
Docker images: https://github.com/orgs/Decaturmakers/packages/container/package/machine-access-control
Python packages: https://pypi.org/project/machine_access_control/
draft: false
prerelease: false
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ steps.version_check.outputs.current_version }}"
REPO="${{ github.repository }}"
IMAGE="ghcr.io/${{ steps.image.outputs.repo }}"

# Generate changelog via GitHub API
CHANGELOG=$(gh api \
"repos/$REPO/releases/generate-notes" \
-f tag_name="$VERSION" \
-f target_commitish="main" \
--jq '.body')

# Build release body in a file to avoid shell escaping issues
{
printf '%s\n' "$CHANGELOG"
printf '\n---\n\n## Docker Image\n\n```bash\n'
printf 'docker pull %s:%s\n' "$IMAGE" "$VERSION"
printf 'docker pull %s:latest\n' "$IMAGE"
printf '```\n'
printf '\n## Python Package\n\nhttps://pypi.org/project/machine_access_control/%s/\n' "$VERSION"
} > /tmp/release_body.md

gh release create "$VERSION" \
--title "Release $VERSION" \
--notes-file /tmp/release_body.md
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ poetry run neon-fob-adder --csv members.csv --field account_id

Both tools use the same environment variables: ``NEON_ORG``, ``NEON_KEY``, and ``NEONGETTER_CONFIG``.

### Releasing
Releases are automated and driven by the version in `pyproject.toml` — do **not** create or push git tags manually.

```bash
# Bump the version (minor shown; use major/patch as appropriate), then commit and merge to main
poetry version minor
```

On every push to `main`, `.github/workflows/release.yml` compares the `pyproject.toml` version against the latest GitHub release. If it is higher, the workflow automatically creates the git tag (bare version, e.g. `0.14.0` — no `v` prefix), builds and pushes the Docker image to GHCR, publishes to PyPI, and creates a GitHub release with generated notes. If the version is unchanged, the workflow no-ops.

## Architecture

### Core Components
Expand Down
2 changes: 1 addition & 1 deletion docs/source/contributing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,4 @@ your approach.
Release Process
---------------

Use ``poetry version`` to increment the version number, commit push and merge that. Tag the repo and push the tag. `GitHub Actions <https://github.com/Decaturmakers/machine-access-control/actions>`__ will run a Docker build, push to GHCR (GitHub Container Registry), build to PyPI, and create a release on the repo.
Releases are driven by the version number in ``pyproject.toml``. Use ``poetry version`` to increment the version number, then commit, push, and merge that to ``main``. When a commit lands on ``main`` with a version higher than the latest GitHub release, the release `GitHub Actions <https://github.com/Decaturmakers/machine-access-control/actions>`__ workflow automatically tags the commit, runs a Docker build and pushes it to GHCR (GitHub Container Registry), builds and publishes to PyPI, and creates a release on the repo. No manual tagging is required.
Loading