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
124 changes: 91 additions & 33 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ name: Release
# 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:
Expand All @@ -35,9 +42,9 @@ on:
default: false
type: boolean

# Each job asks for what it needs; nothing is granted workflow-wide.
permissions:
contents: write
id-token: write # Required for PyPI trusted publishing
contents: read

# Two releases at once would race on the tag and on PyPI.
concurrency:
Expand All @@ -49,9 +56,13 @@ defaults:
shell: bash

jobs:
release:
build:
runs-on: ubuntu-latest

outputs:
version: ${{ steps.version.outputs.version }}
previous_tag: ${{ steps.previous.outputs.tag }}

services:
memcached:
image: memcached:1.6-alpine
Expand All @@ -64,9 +75,23 @@ jobs:
- 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
Expand Down Expand Up @@ -225,65 +250,82 @@ jobs:

# 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 }}
# 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: dry-run-v${{ steps.version.outputs.version }}
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"

# 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 }}
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:$BRANCH"
git push origin HEAD:refs/heads/master
fi

- name: Tag the release
if: ${{ !inputs.dry_run }}
env:
VERSION: ${{ steps.version.outputs.version }}
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
if: ${{ !inputs.dry_run }}
uses: softprops/action-gh-release@v3
with:
tag_name: v${{ steps.version.outputs.version }}
name: v${{ steps.version.outputs.version }}
tag_name: v${{ needs.build.outputs.version }}
name: v${{ needs.build.outputs.version }}
body_path: release-notes.md
draft: false
prerelease: false
Expand All @@ -292,15 +334,31 @@ jobs:
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
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 }}
VERSION: ${{ needs.build.outputs.version }}
PREVIOUS_TAG: ${{ needs.build.outputs.previous_tag }}
run: |
{
echo "### Released v$VERSION"
Expand Down
33 changes: 24 additions & 9 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,22 +265,37 @@ The workflow runs in this order:
it, rewrites the compare links at the bottom, and writes the release body:
each entry's bold summary and issue links, and a link to the full entries
on the documentation site.
4. **The permanent part**, kept together at the end: commit the version bump
and the promoted changelog, push it, tag, push the tag by refspec, create
the GitHub release from the promoted section, publish to PyPI.
4. **The build.** `uv build`. The bumped files, the release notes and
`dist/` are uploaded as one artifact, which the next two jobs download
instead of building anything again.
5. **The permanent part**, kept together at the end: commit the version bump
and the promoted changelog, push it to master, tag, push the tag by refspec,
create the GitHub release from the promoted section, publish to PyPI.

Steps 1–4 are the `build` job, which installs every dev dependency and so gets
read access to the repository and nothing else: no git credentials, no PyPI
token. The commit, tag and GitHub release are the `release` job, the only one
that can write to the repository, and it installs nothing. Publishing is the
`publish` job, the only one that can mint a PyPI token, running in the `pypi`
environment so the trusted publisher and any protection rules can be tied to it.

A release runs from `master` only. Dispatched on any other ref, the run fails
at its first step, and the `release` and `publish` jobs check the ref again.

### Rehearsing a release

Dispatch it with **dry run** ticked. Everything runs — the gate, the version
bump, the tag check, the changelog promotion, `uv build` — and the four steps
that write somewhere permanent (commit, tag, GitHub release, PyPI) are skipped.
Until this existed, the first real exercise of the release path was a release.
Dispatch it with **dry run** ticked. Everything in `build` runs — the gate,
the version bump, the tag check, the changelog promotion, `uv build` — and the
`release` and `publish` jobs, which write somewhere permanent (commit, tag,
GitHub release, PyPI), are skipped. Until this existed, the first real exercise
of the release path was a release. A dry run may be dispatched on any branch,
which is how a change to the workflow itself is rehearsed before it is merged.

A dry run answers two questions, and the job summary reports both: whether the
bump and the promotion actually landed in `pyproject.toml`, `uv.lock` and
`CHANGELOG.md` (shown as a `git diff --stat`, staged by nothing), and what
would have been published. The release notes and the built `dist/` are attached
to the run as an artifact, because the notes are markdown and reading them in
would have been published. The release notes, the built `dist/` and the bumped
files are attached to the run as an artifact, because the notes are markdown and reading them in
the job summary renders them a second time — which is not what the release page
would show.

Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description = "A caching library for FastAPI with support for Cache-Control, ETa
readme = "README.md"
requires-python = ">=3.10"
license = "Apache-2.0"
license-files = ["LICENSE"]
authors = [
{ name = "allen0099", email = "s96016641@gmail.com" }
]
Expand All @@ -22,6 +23,7 @@ classifiers = [
"Framework :: FastAPI",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
"Typing :: Typed",
]
keywords = ["fastapi", "cache", "etag", "cache-control", "redis", "memcached", "in-memory"]
dependencies = [
Expand Down
Loading