From 6ee81ae92e1b8183afcbe5a90185bfd652472ae9 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Sat, 26 Sep 2026 22:47:22 +0000 Subject: [PATCH] fix(release): release from master only and split build, release, publish jobs release.yml could be dispatched on any branch, pushing the release commit there and publishing unmerged code, and the job that ran every dev dependency also held contents: write and id-token: write. Refuse a non-dry-run dispatch off master. Split the workflow into a build job (gate, bump, changelog, uv build; read-only, no persisted git credentials), a release job (commit, tag, GitHub release; contents: write, installs nothing) and a publish job (the only id-token: write, in the pypi environment). The later jobs download the build artifact instead of rebuilding. Ship LICENSE in the wheel and sdist via license-files, and add the Typing :: Typed classifier. Closes #231 Closes #232 --- .github/workflows/release.yml | 124 +++++++++++++++++++++++++--------- docs/DEVELOPMENT.md | 33 ++++++--- pyproject.toml | 2 + 3 files changed, 117 insertions(+), 42 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21b6247..8e87e21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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: @@ -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 @@ -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 @@ -225,40 +250,59 @@ 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 @@ -266,24 +310,22 @@ jobs: 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 @@ -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" diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 926257e..ee85400 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 0000617..db86ce1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } ] @@ -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 = [