Release #16
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |