Skip to content

Release

Release #14

Workflow file for this run

name: Release
# The only release path, and manual only. It bumps the version, promotes the
# changelog, tags, publishes to PyPI and writes the GitHub release notes.
#
# There used to be two workflows: this one (minor bump) and `publish.yml`
# (patch bump, with an optional exact version). They were the same eighty
# lines twice over, and which part of the version you got was decided by which
# Actions page you happened to open. It is one workflow with a `bump` input
# now, so the choice is made in the dialog where it belongs.
#
# It also used to guess whether a release was warranted by counting commits
# since the last tag, skipping every step when it found none. A dispatched
# release is already someone saying "release this"; the guess only turned a
# deliberate run into a silent no-op that looks identical to a successful one.
on:
workflow_dispatch:
inputs:
bump:
description: 'Which part of the version to bump'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
version:
description: 'Exact version to release (e.g. 0.4.0). Overrides the bump choice.'
required: false
type: string
dry_run:
description: 'Rehearse: run everything, but do not commit, tag, release or publish'
required: false
default: false
type: boolean
permissions:
contents: write
id-token: write # Required for PyPI trusted publishing
# Two releases at once would race on the tag and on PyPI.
concurrency:
group: release
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
release:
runs-on: ubuntu-latest
services:
memcached:
image: memcached:1.6-alpine
ports:
- 11211:11211
redis:
image: redis:alpine
ports:
- 6379:6379
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # Tags, for the "already released" check below
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.14"
- name: Install uv
uses: astral-sh/setup-uv@v10.2.0
# The extras are redundant — the dev group already pins redis, pymemcache,
# PyJWT and orjson — but they are named anyway. This is the one job where
# a missing backend package would turn the gate below into a formality,
# so it does not rely on a dev-group entry staying put.
- name: Sync dependencies
run: uv sync --group dev --extra jwt --extra redis --extra memcache
# The gate. A release is the one run where a red test result arrives too
# late to matter, so everything CI checks elsewhere is checked here too,
# before anything is written, tagged or published.
- name: Ruff check
run: uv run ruff check fastapi_cachex tests scripts
- name: Ruff format check
run: uv run ruff format --check fastapi_cachex tests scripts
- name: Mypy strict
run: uv run mypy fastapi_cachex --strict
- name: Mypy (tests)
run: uv run mypy tests
- name: Mypy (scripts)
run: uv run mypy scripts
- name: Run tests
run: uv run coverage run -m pytest && uv run coverage report
env:
# The service containers above, which exist only for this job. The
# live-server suites are opt-in because they wipe what they connect
# to, and `CACHEX_REQUIRE_LIVE_SERVERS` makes skipping them a
# failure — releasing on a green run that quietly tested neither
# backend is exactly what this workflow must not do.
CACHEX_TEST_REDIS_PORT: 6379
CACHEX_TEST_MEMCACHED_PORT: 11211
CACHEX_REQUIRE_LIVE_SERVERS: 1
- name: Work out the version
id: version
env:
BUMP: ${{ inputs.bump }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ -n "$VERSION" ]; then
uv version "$VERSION"
else
uv version --bump "$BUMP"
fi
uv lock
new_version="$(uv version --short)"
echo "version=$new_version" >> "$GITHUB_OUTPUT"
echo "Releasing $new_version"
- name: Fail if the tag already exists
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null \
|| git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null; then
echo "::error::v$VERSION is already tagged. Pick another version." >&2
exit 1
fi
- name: Record the previous tag
id: previous
run: |
set -euo pipefail
echo "tag=$(git describe --tags --abbrev=0 2>/dev/null || echo '')" >> "$GITHUB_OUTPUT"
# Promoting the changelog is part of cutting the release, not a chore to
# remember afterwards: the script fails when `## [Unreleased]` is empty,
# so a release with nothing written down stops here instead of shipping
# release notes that say nothing.
- name: Promote the changelog
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
uv run python scripts/changelog_release.py \
--version "$VERSION" \
--release-notes release-notes.md
- name: Write the release notes
env:
VERSION: ${{ steps.version.outputs.version }}
PREVIOUS_TAG: ${{ steps.previous.outputs.tag }}
run: |
set -euo pipefail
{
echo
echo '---'
echo
echo '### Installation'
echo
echo '```bash'
echo "uv add fastapi-cachex==$VERSION"
echo '```'
if [ -n "$PREVIOUS_TAG" ]; then
echo
echo "**Full commit log**: https://github.com/${GITHUB_REPOSITORY}/compare/${PREVIOUS_TAG}...v${VERSION}"
fi
} >> release-notes.md
- name: Build package
run: uv build
# A dry run's whole value is in what you can inspect afterwards, and the
# two questions it exists to answer are "did the bump and the changelog
# promotion actually land in the files?" and "what exactly would have
# been published?". `git diff` here deliberately does not stage anything:
# the real commit step is the only thing that runs `git add`.
- name: Report what the release would have done
if: ${{ inputs.dry_run }}
env:
VERSION: ${{ steps.version.outputs.version }}
PREVIOUS_TAG: ${{ steps.previous.outputs.tag }}
run: |
set -euo pipefail
{
echo '### Dry run — nothing was published'
echo
echo "- Would have released: **v$VERSION**"
echo "- Would have tagged: \`v$VERSION\`"
echo "- Previous tag: ${PREVIOUS_TAG:-none}"
echo
echo 'Changes that the release commit would have carried:'
echo
echo '```'
git --no-pager diff --stat -- pyproject.toml uv.lock CHANGELOG.md
echo '```'
echo
echo 'Build artifacts:'
echo
echo '```'
ls -lh dist/
echo '```'
echo
echo '<details><summary>Release notes that would have been published</summary>'
echo
cat release-notes.md
echo
echo '</details>'
} >> "$GITHUB_STEP_SUMMARY"
# The release body is markdown, and pasting it into the step summary
# renders it a second time, which is not what a GitHub release page shows.
# The artifact is the copy you can actually compare against.
- name: Upload the rehearsal output
if: ${{ inputs.dry_run }}
uses: actions/upload-artifact@v7
with:
name: dry-run-v${{ steps.version.outputs.version }}
path: |
release-notes.md
dist/
if-no-files-found: error
- name: Configure git
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
# Below this line are the steps that change the four things a cancelled
# run cannot take back: the branch, the tags, the GitHub releases, PyPI.
# Nothing above the line touches any of them. (A dry run does leave a
# build artifact attached to the run, and setup-uv may write an Actions
# cache. Both expire on their own and neither is repository state, which
# is why they sit above the line.)
#
# EVERY STEP BELOW THIS LINE MUST CARRY `if: ${{ !inputs.dry_run }}`,
# including any step added later, and a new step of that kind belongs
# here rather than above. `Summary` carries the guard as well, even
# though all it writes is the job summary: it *describes* those four
# effects, and a rule with no exceptions to remember is worth more than
# a correctly narrow one.
- name: Commit the release
if: ${{ !inputs.dry_run }}
env:
VERSION: ${{ steps.version.outputs.version }}
BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
git add pyproject.toml uv.lock CHANGELOG.md
if git diff --cached --quiet; then
echo "Nothing to commit; releasing HEAD as it is."
else
git commit -m "chore: release v$VERSION"
git push origin "HEAD:$BRANCH"
fi
- name: Tag the release
if: ${{ !inputs.dry_run }}
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
git tag -a "v$VERSION" -m "Release v$VERSION"
git push origin "refs/tags/v$VERSION"
- name: Create GitHub Release
if: ${{ !inputs.dry_run }}
uses: softprops/action-gh-release@v3
with:
tag_name: v${{ steps.version.outputs.version }}
name: v${{ steps.version.outputs.version }}
body_path: release-notes.md
draft: false
prerelease: false
make_latest: true
files: |
dist/*.whl
dist/*.tar.gz
- name: Publish to PyPI
if: ${{ !inputs.dry_run }}
uses: pypa/gh-action-pypi-publish@release/v1
- name: Summary
if: ${{ !inputs.dry_run }}
env:
VERSION: ${{ steps.version.outputs.version }}
PREVIOUS_TAG: ${{ steps.previous.outputs.tag }}
run: |
{
echo "### Released v$VERSION"
echo
echo "- Previous tag: ${PREVIOUS_TAG:-none}"
echo "- Changelog section promoted to \`## [$VERSION]\`"
echo "- Published to PyPI"
} >> "$GITHUB_STEP_SUMMARY"