Repository navigation
Release #14
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. | |
| 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" |