From 3523cfaaa6fe4348bb51170e5fe1ac9ff5a9fe8c Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Wed, 11 Nov 2026 10:45:01 +0200 Subject: [PATCH 1/6] Add release workflow --- .github/workflows/release.yml | 152 ++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..af3924a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,152 @@ +# Publishes the release drafted by draft-release.yml. Runs when the release PR lands on main +# (or manually, as a fallback / dry run): detects the pending draft from CHANGELOG.md, tests and +# builds the sdist and wheel without any publishing rights, then uploads them to PyPI from the +# `release` environment, attaches them to the GitHub Release and un-drafts it, which creates the tag. +name: Release +on: + push: + branches: [main] + paths: [CHANGELOG.md] + workflow_dispatch: + inputs: + dry_run: + description: "Dry run: detect the release, test and build, but do not publish" + required: false + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + detect: + name: Detect pending release + runs-on: ubuntu-latest + outputs: + version: ${{ steps.pending.outputs.version }} + tag: ${{ steps.pending.outputs.tag }} + go: ${{ steps.decide.outputs.go }} + steps: + - name: Refuse to release from a non-default branch + env: + REF: ${{ github.ref_name }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [ "$REF" != "$DEFAULT_BRANCH" ]; then + echo "::error::Releases must run from $DEFAULT_BRANCH, got $REF" + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Resolve pending release + id: pending + uses: railsware/github-actions/resolve-pending-release@master + + # push: skip quietly when nothing is pending (an ordinary CHANGELOG edit). + # dispatch: a real run needs a pending draft; a dry run doesn't. + - name: Decide + id: decide + env: + EVENT: ${{ github.event_name }} + PENDING: ${{ steps.pending.outputs.pending }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + if [ "$PENDING" = "true" ] || [ "$DRY_RUN" = "true" ]; then + echo "go=true" >> "$GITHUB_OUTPUT" + elif [ "$EVENT" = "workflow_dispatch" ]; then + echo "::error::No draft release for the top CHANGELOG.md version; run Draft Python Release first" + exit 1 + else + echo "Nothing to release" + echo "go=false" >> "$GITHUB_OUTPUT" + fi + + build: + name: Test and build + needs: detect + if: needs.detect.outputs.go == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # .tool-versions pins an older Python than requires-python, so set it explicitly. + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + python-version: '3.13' + enable-cache: false + + - name: Check version matches CHANGELOG.md + env: + EXPECTED: ${{ needs.detect.outputs.version }} + run: | + actual=$(uv version --short) + if [ "$actual" != "$EXPECTED" ]; then + echo "::error::pyproject.toml is $actual but CHANGELOG.md is $EXPECTED" + exit 1 + fi + + - name: Test and build + run: | + uv run --with-requirements requirements.test.txt pytest -q + uv build + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: dist + path: dist/ + if-no-files-found: error + + release: + name: Publish + needs: [detect, build] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + environment: release + permissions: + contents: write # upload assets to and un-draft the release + id-token: write # PyPI trusted publishing + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: dist + path: dist/ + + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + enable-cache: false + + - name: Check PyPI release + uses: railsware/github-actions/check-sdk-pypi-release@master + with: + package: mailtrap + version: ${{ needs.detect.outputs.version }} + + - name: Check GitHub release + uses: railsware/github-actions/check-sdk-github-release@master + with: + tag: ${{ needs.detect.outputs.tag }} + + # Uploading the built files runs none of their code, so nothing from the dependency tree runs here. + - name: Publish to PyPI + run: uv publish --trusted-publishing always dist/* + + - name: Attach build artifacts to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.detect.outputs.tag }} + run: gh release upload "$TAG" dist/* --repo "$GITHUB_REPOSITORY" + + - name: Publish GitHub release + uses: railsware/github-actions/publish-github-release@master + with: + tag: ${{ needs.detect.outputs.tag }} + target: ${{ github.sha }} From 1856c76f5be9b5ee7f315f433ee081dc7f884c07 Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Wed, 11 Nov 2026 13:57:18 +0200 Subject: [PATCH 2/6] Inline Check PyPI release --- .github/workflows/release.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af3924a..5fa65dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,10 +125,15 @@ jobs: enable-cache: false - name: Check PyPI release - uses: railsware/github-actions/check-sdk-pypi-release@master - with: - package: mailtrap - version: ${{ needs.detect.outputs.version }} + env: + VERSION: ${{ needs.detect.outputs.version }} + run: | + status=$(curl -sS -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/mailtrap/$VERSION/json") + case "$status" in + 404) echo "mailtrap $VERSION is not published to PyPI yet" ;; + 200) echo "::error::mailtrap $VERSION is already published to PyPI"; exit 1 ;; + *) echo "::error::Failed to query PyPI for mailtrap $VERSION (HTTP $status)"; exit 1 ;; + esac - name: Check GitHub release uses: railsware/github-actions/check-sdk-github-release@master From e85fdf642611a16e535b60c8ed9f067f559d84fb Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Wed, 11 Nov 2026 15:21:07 +0200 Subject: [PATCH 3/6] Treat an unreleased CHANGELOG version as pending detect runs with a read-only token, which can't see draft releases, so resolve-pending-release now reports the newest CHANGELOG.md version as pending until it has a published release. A real dispatch on a version that is already released fails; the publish job's draft check confirms the draft after approval. --- .github/workflows/release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fa65dd..0f2164d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,8 @@ jobs: uses: railsware/github-actions/resolve-pending-release@master # push: skip quietly when nothing is pending (an ordinary CHANGELOG edit). - # dispatch: a real run needs a pending draft; a dry run doesn't. + # dispatch: a real run needs an unreleased version; a dry run doesn't. detect can't see drafts + # (read-only token), so the publish job confirms the draft after approval. - name: Decide id: decide env: @@ -61,7 +62,7 @@ jobs: if [ "$PENDING" = "true" ] || [ "$DRY_RUN" = "true" ]; then echo "go=true" >> "$GITHUB_OUTPUT" elif [ "$EVENT" = "workflow_dispatch" ]; then - echo "::error::No draft release for the top CHANGELOG.md version; run Draft Python Release first" + echo "::error::The top CHANGELOG.md version is already released; run Draft Python Release first" exit 1 else echo "Nothing to release" From 5815e2fc5ffc29517f43f56ea53c524d5292a920 Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Wed, 11 Nov 2026 15:33:03 +0200 Subject: [PATCH 4/6] Make a re-run finish a partially published release Attach the build artifacts while the release is still a draft and before the PyPI upload, with --clobber so a re-run can overwrite them; after the upload only the un-draft is left. uv publish --check-url skips files PyPI already has with the same hash, so a re-run after a partial publish completes the release instead of stopping at "already published". PyPI still rejects a different file under an existing name, which replaces the separate version check. --- .github/workflows/release.yml | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f2164d..588f75f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,31 +125,24 @@ jobs: with: enable-cache: false - - name: Check PyPI release - env: - VERSION: ${{ needs.detect.outputs.version }} - run: | - status=$(curl -sS -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/mailtrap/$VERSION/json") - case "$status" in - 404) echo "mailtrap $VERSION is not published to PyPI yet" ;; - 200) echo "::error::mailtrap $VERSION is already published to PyPI"; exit 1 ;; - *) echo "::error::Failed to query PyPI for mailtrap $VERSION (HTTP $status)"; exit 1 ;; - esac - - name: Check GitHub release uses: railsware/github-actions/check-sdk-github-release@master with: tag: ${{ needs.detect.outputs.tag }} - # Uploading the built files runs none of their code, so nothing from the dependency tree runs here. - - name: Publish to PyPI - run: uv publish --trusted-publishing always dist/* - + # Attached while still a draft (invisible to users) and before the registry publish, so the + # un-draft is the only step after it; --clobber lets a re-run overwrite assets. - name: Attach build artifacts to GitHub Release env: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.detect.outputs.tag }} - run: gh release upload "$TAG" dist/* --repo "$GITHUB_REPOSITORY" + run: gh release upload "$TAG" dist/* --clobber --repo "$GITHUB_REPOSITORY" + + # Uploading the built files runs none of their code, so nothing from the dependency tree runs here. + # --check-url skips files PyPI already has with the same hash (a re-run after a partial publish) + # and fails on a file whose hash differs, so a different build of a released version can't go out. + - name: Publish to PyPI + run: uv publish --trusted-publishing always --check-url https://pypi.org/simple/ dist/* - name: Publish GitHub release uses: railsware/github-actions/publish-github-release@master From 31be02e32da277ee1f7a2b31d60c81a4ca9ec36e Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Wed, 11 Nov 2026 16:00:33 +0200 Subject: [PATCH 5/6] Check the full ref in the default-branch guard github.ref_name is the short name, so a manual run started from a tag named like the default branch passed the check and would build that tag's commit. Comparing github.ref with refs/heads/ accepts only the branch. --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 588f75f..6908c90 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,10 +33,10 @@ jobs: steps: - name: Refuse to release from a non-default branch env: - REF: ${{ github.ref_name }} + REF: ${{ github.ref }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | - if [ "$REF" != "$DEFAULT_BRANCH" ]; then + if [ "$REF" != "refs/heads/$DEFAULT_BRANCH" ]; then echo "::error::Releases must run from $DEFAULT_BRANCH, got $REF" exit 1 fi From 27c3297a453c64002ad12538c4c1fd240524521d Mon Sep 17 00:00:00 2001 From: Igor Dobryn Date: Wed, 11 Nov 2026 16:09:13 +0200 Subject: [PATCH 6/6] Log what detect decided and warn on a blind dry run A dry run continues whether or not the top CHANGELOG.md version is released, so it passed even when detection was wrong. Decide now logs pending and dry_run, and a dry run on an already released version gets a warning that it didn't exercise detection. --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6908c90..ab40360 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,6 +59,10 @@ jobs: PENDING: ${{ steps.pending.outputs.pending }} DRY_RUN: ${{ inputs.dry_run }} run: | + echo "pending=$PENDING dry_run=$DRY_RUN" + if [ "$DRY_RUN" = "true" ] && [ "$PENDING" != "true" ]; then + echo "::warning::The top CHANGELOG.md version is already released, so this dry run doesn't exercise detection" + fi if [ "$PENDING" = "true" ] || [ "$DRY_RUN" = "true" ]; then echo "go=true" >> "$GITHUB_OUTPUT" elif [ "$EVENT" = "workflow_dispatch" ]; then