Skip to content

Release

Release #16

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.
#
# It runs as three jobs so that the credentials are held only where they are
# used. `build` installs every dev dependency and runs the gate, and can read
# the repository and nothing else. `release` pushes the release commit and tag
# and creates the GitHub release; it installs nothing. `publish` is the only
# job that can mint a PyPI token, runs in the `pypi` environment, and does
# nothing but upload the `dist/` that `build` produced.
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
# Each job asks for what it needs; nothing is granted workflow-wide.
permissions:
contents: read
# Two releases at once would race on the tag and on PyPI.
concurrency:
group: release
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
previous_tag: ${{ steps.previous.outputs.tag }}
services:
memcached:
image: memcached:1.6-alpine
ports:
- 11211:11211
redis:
image: redis:alpine
ports:
- 6379:6379
steps:
# A release pushes its commit to the branch it was dispatched on and tags
# that code, so a run on a feature branch would publish unmerged work.
# A dry run writes nothing, which is what makes it the way to rehearse a
# change to this workflow before it is merged, so it may run anywhere.
- name: Refuse to release from anything but master
if: ${{ github.ref != 'refs/heads/master' && !inputs.dry_run }}
env:
REF: ${{ github.ref }}
run: |
echo "::error::Releases run from refs/heads/master only, not $REF. Tick dry run to rehearse elsewhere." >&2
exit 1
- uses: actions/checkout@v7
with:
fetch-depth: 0 # Tags, for the "already released" check below
# This job runs every dev dependency; leave it no git credentials.
persist-credentials: false
- 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 memcached
# 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. It also fails when an entry has no bold
# one-line summary, because the release notes are those summaries.
- 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. It is also
# how the later jobs get the release: the files the release commit
# carries, the notes and `dist/`, exactly as this job produced them, so
# nothing after the gate builds or bumps anything a second time.
- name: Upload the release files
uses: actions/upload-artifact@v7
with:
name: release-v${{ steps.version.outputs.version }}
path: |
pyproject.toml
uv.lock
CHANGELOG.md
release-notes.md
dist/
if-no-files-found: error
# The jobs below change the four things a cancelled run cannot take back:
# the branch, the tags, the GitHub releases, PyPI. `build` touches none of
# them. (A dry run does leave an 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 belong to `build`.)
#
# EVERY JOB BELOW THIS LINE MUST CARRY THE SAME `if:`, which skips it on a
# dry run and off master, including any job added later. The master check
# repeats the guard in `build` so that it does not rest on one step.
release:
needs: build
if: ${{ !inputs.dry_run && github.ref == 'refs/heads/master' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
# The same commit `build` checked out and tested: both default to
# `github.sha`. If master has moved since, the push below is rejected as
# a non-fast-forward and nothing is tagged.
- uses: actions/checkout@v7
# Overwrites pyproject.toml, uv.lock and CHANGELOG.md with the bumped
# and promoted copies, and adds release-notes.md and dist/.
- name: Download the release files
uses: actions/download-artifact@v8
with:
name: release-v${{ needs.build.outputs.version }}
- name: Configure git
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
- name: Commit the release
env:
VERSION: ${{ needs.build.outputs.version }}
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:refs/heads/master
fi
- name: Tag the release
env:
VERSION: ${{ needs.build.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
uses: softprops/action-gh-release@v3
with:
tag_name: v${{ needs.build.outputs.version }}
name: v${{ needs.build.outputs.version }}
body_path: release-notes.md
draft: false
prerelease: false
make_latest: true
files: |
dist/*.whl
dist/*.tar.gz
publish:
needs: [build, release]
if: ${{ !inputs.dry_run && github.ref == 'refs/heads/master' }}
runs-on: ubuntu-latest
# The trusted publisher can be tied to this environment, and the
# environment can carry protection rules (required reviewers, a wait).
environment:
name: pypi
url: https://pypi.org/project/fastapi-cachex/${{ needs.build.outputs.version }}/
permissions:
id-token: write # Required for PyPI trusted publishing
steps:
- name: Download the release files
uses: actions/download-artifact@v8
with:
name: release-v${{ needs.build.outputs.version }}
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
- name: Summary
env:
VERSION: ${{ needs.build.outputs.version }}
PREVIOUS_TAG: ${{ needs.build.outputs.previous_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"