From b0d122b96c56a54f625d5d067f2ba4c9daf90df1 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:29:12 +0530 Subject: [PATCH 1/4] refactor(ci): switch publish to workflow_dispatch Tag-push trigger created orphaned tags when CI failed. Now: - workflow_dispatch with version input + dry-run toggle - preflight verifies version matches pyproject.toml - tag + GitHub Release created only after PyPI confirms --- .github/workflows/publish.yml | 69 +++++++++++++++++++++++++--- docs/content/docs/more/changelog.mdx | 5 ++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 761e9279..22a89a31 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,14 +1,50 @@ name: Publish to PyPI on: - push: - tags: ["[0-9]+.[0-9]+.[0-9]+*"] + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g. 0.16.1)" + required: true + type: string + dry-run: + description: "Build and validate only — skip PyPI upload and tagging" + required: false + type: boolean + default: false permissions: - contents: read + contents: write jobs: + preflight: + name: Preflight checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Verify version matches codebase + run: | + version="${{ inputs.version }}" + pyproject_ver=$(python3 -c " + import re, pathlib + m = re.search(r'^version\s*=\s*\"(.+?)\"', pathlib.Path('pyproject.toml').read_text(), re.M) + print(m.group(1)) + ") + if [ "$pyproject_ver" != "$version" ]; then + echo "::error::Input version ${version} != pyproject.toml version ${pyproject_ver}" + exit 1 + fi + + - name: Check tag does not exist + run: | + if git ls-remote --tags origin | grep -q "refs/tags/${{ inputs.version }}$"; then + echo "::error::Tag ${{ inputs.version }} already exists on remote" + exit 1 + fi + build-dashboard: + needs: preflight name: Build dashboard assets runs-on: ubuntu-latest steps: @@ -212,8 +248,11 @@ jobs: runs-on: ubuntu-latest environment: pypi permissions: + contents: write id-token: write steps: + - uses: actions/checkout@v6 + - name: Download wheel and sdist artifacts uses: actions/download-artifact@v8 with: @@ -227,12 +266,12 @@ jobs: for whl in dist/*.whl; do unzip -qt "$whl" done - check-wheel-contents dist/*.whl + check-wheel-contents --ignore W009,W010 dist/*.whl - name: Check PyPI for existing version id: pypi-check run: | - version="${GITHUB_REF_NAME}" + version="${{ inputs.version }}" python3 - "$version" <<'PYCHECK' import hashlib, json, os, sys, urllib.request from pathlib import Path @@ -274,15 +313,16 @@ jobs: PYCHECK - name: Publish to PyPI - if: steps.pypi-check.outputs.all_uploaded != 'true' + if: steps.pypi-check.outputs.all_uploaded != 'true' && !inputs.dry-run uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 with: packages-dir: dist/ skip-existing: true - name: Verify version on PyPI + if: "!inputs.dry-run" run: | - version="${GITHUB_REF_NAME}" + version="${{ inputs.version }}" for _attempt in 1 2 3 4 5; do status=$(curl -s -o /dev/null -w '%{http_code}' --head \ --connect-timeout 10 --max-time 30 \ @@ -296,3 +336,18 @@ jobs: done echo "::error::taskito==${version} not found on PyPI" exit 1 + + - name: Create git tag + if: "!inputs.dry-run" + run: | + git tag "${{ inputs.version }}" + git push origin "${{ inputs.version }}" + + - name: Create GitHub release + if: "!inputs.dry-run" + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ inputs.version }}" \ + --title "v${{ inputs.version }}" \ + --generate-notes diff --git a/docs/content/docs/more/changelog.mdx b/docs/content/docs/more/changelog.mdx index 24e4ff45..518502b6 100644 --- a/docs/content/docs/more/changelog.mdx +++ b/docs/content/docs/more/changelog.mdx @@ -23,6 +23,11 @@ Patch release: publish workflow hardening. - **Dashboard artifact download retry.** Extracted dashboard download into a reusable composite action with `gh` CLI fallback on transient `download-artifact` failures (hit on Windows in 0.16.0 publish). +- **Publish workflow overhaul.** Switched from tag-push trigger to + `workflow_dispatch` with version input and dry-run toggle. Tag and GitHub + Release are created only after successful PyPI upload — eliminates orphaned + tags from failed publishes. Preflight job verifies version matches + `pyproject.toml` and checks tag doesn't already exist. ## 0.16.0 From b65aec1b713c6eeeb877e8711704cf56ec4d45de Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:29:55 +0530 Subject: [PATCH 2/4] docs: strip CI internals from 0.16.1 changelog --- docs/content/docs/more/changelog.mdx | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/docs/content/docs/more/changelog.mdx b/docs/content/docs/more/changelog.mdx index 518502b6..5052415a 100644 --- a/docs/content/docs/more/changelog.mdx +++ b/docs/content/docs/more/changelog.mdx @@ -7,27 +7,7 @@ All notable changes to taskito are documented here. ## 0.16.1 -Patch release: publish workflow hardening. - -### Fixed - -- **PyPI publish pre-check.** Publish job now queries PyPI before uploading, - compares SHA-256 hashes per artifact, and skips upload when all wheels are - already present. Prevents `400 File already exists` on workflow reruns. -- **PEP 639 compatibility.** Replaced `twine check` with `check-wheel-contents` - for wheel validation. `twine check` broke on PEP 639 `License-File` metadata - emitted by maturin 1.13.3 (`packaging` library incompatibility). Structural - validation via `check-wheel-contents`; PyPI validates metadata on upload. -- **Timeout guards.** Added `timeout=30` on PyPI API calls and - `--connect-timeout 10 --max-time 30` on verification curl. -- **Dashboard artifact download retry.** Extracted dashboard download into a - reusable composite action with `gh` CLI fallback on transient - `download-artifact` failures (hit on Windows in 0.16.0 publish). -- **Publish workflow overhaul.** Switched from tag-push trigger to - `workflow_dispatch` with version input and dry-run toggle. Tag and GitHub - Release are created only after successful PyPI upload — eliminates orphaned - tags from failed publishes. Preflight job verifies version matches - `pyproject.toml` and checks tag doesn't already exist. +Patch release with no user-facing changes. ## 0.16.0 From cda95687c27867652c72f563fd8faaa12a985227 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:22:55 +0530 Subject: [PATCH 3/4] fix(ci): harden publish workflow per review - Scope contents:write to publish job only - Pin actions/checkout to commit SHA - Use exact awk match for tag check (dots aren't wildcards) --- .github/workflows/publish.yml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 22a89a31..ccf17c7c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,14 +14,14 @@ on: default: false permissions: - contents: write + contents: read jobs: preflight: name: Preflight checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Verify version matches codebase run: | @@ -38,8 +38,9 @@ jobs: - name: Check tag does not exist run: | - if git ls-remote --tags origin | grep -q "refs/tags/${{ inputs.version }}$"; then - echo "::error::Tag ${{ inputs.version }} already exists on remote" + version="${{ inputs.version }}" + if git ls-remote --tags origin | awk -v tag="refs/tags/${version}" '$2 == tag { found=1 } END { exit !found }'; then + echo "::error::Tag ${version} already exists on remote" exit 1 fi @@ -48,7 +49,7 @@ jobs: name: Build dashboard assets runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/dashboard-build @@ -72,7 +73,7 @@ jobs: - os: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/download-dashboard @@ -105,7 +106,7 @@ jobs: - os: ubuntu-24.04-arm target: aarch64-unknown-linux-musl steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/download-dashboard @@ -136,7 +137,7 @@ jobs: - x86_64-apple-darwin - aarch64-apple-darwin steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/download-dashboard @@ -183,7 +184,7 @@ jobs: include: - target: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/download-dashboard @@ -225,7 +226,7 @@ jobs: needs: build-dashboard runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/download-dashboard @@ -251,7 +252,7 @@ jobs: contents: write id-token: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Download wheel and sdist artifacts uses: actions/download-artifact@v8 From 1c11903683e9c87c5370fee19d0646522b5bd0e2 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:52:17 +0530 Subject: [PATCH 4/4] fix(ci): normalize v-prefix in version input --- .github/workflows/publish.yml | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ccf17c7c..7d918137 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,12 +20,21 @@ jobs: preflight: name: Preflight checks runs-on: ubuntu-latest + outputs: + version: ${{ steps.normalize.outputs.version }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - name: Verify version matches codebase + - name: Normalize version + id: normalize run: | version="${{ inputs.version }}" + version="${version#v}" + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Verify version matches codebase + run: | + version="${{ steps.normalize.outputs.version }}" pyproject_ver=$(python3 -c " import re, pathlib m = re.search(r'^version\s*=\s*\"(.+?)\"', pathlib.Path('pyproject.toml').read_text(), re.M) @@ -38,7 +47,7 @@ jobs: - name: Check tag does not exist run: | - version="${{ inputs.version }}" + version="${{ steps.normalize.outputs.version }}" if git ls-remote --tags origin | awk -v tag="refs/tags/${version}" '$2 == tag { found=1 } END { exit !found }'; then echo "::error::Tag ${version} already exists on remote" exit 1 @@ -245,12 +254,14 @@ jobs: if-no-files-found: error publish: - needs: [build-wheels-linux, build-wheels-musllinux, build-wheels-macos, build-wheels-windows, build-sdist] + needs: [preflight, build-wheels-linux, build-wheels-musllinux, build-wheels-macos, build-wheels-windows, build-sdist] runs-on: ubuntu-latest environment: pypi permissions: contents: write id-token: write + env: + VERSION: ${{ needs.preflight.outputs.version }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -272,8 +283,7 @@ jobs: - name: Check PyPI for existing version id: pypi-check run: | - version="${{ inputs.version }}" - python3 - "$version" <<'PYCHECK' + python3 - "$VERSION" <<'PYCHECK' import hashlib, json, os, sys, urllib.request from pathlib import Path @@ -323,32 +333,31 @@ jobs: - name: Verify version on PyPI if: "!inputs.dry-run" run: | - version="${{ inputs.version }}" for _attempt in 1 2 3 4 5; do status=$(curl -s -o /dev/null -w '%{http_code}' --head \ --connect-timeout 10 --max-time 30 \ -H "User-Agent: taskito-release/1.0" \ - "https://pypi.org/pypi/taskito/${version}/json") + "https://pypi.org/pypi/taskito/${VERSION}/json") if [ "$status" = "200" ]; then - echo "taskito==${version} live on PyPI" + echo "taskito==${VERSION} live on PyPI" exit 0 fi sleep 15 done - echo "::error::taskito==${version} not found on PyPI" + echo "::error::taskito==${VERSION} not found on PyPI" exit 1 - name: Create git tag if: "!inputs.dry-run" run: | - git tag "${{ inputs.version }}" - git push origin "${{ inputs.version }}" + git tag "$VERSION" + git push origin "$VERSION" - name: Create GitHub release if: "!inputs.dry-run" env: GH_TOKEN: ${{ github.token }} run: | - gh release create "${{ inputs.version }}" \ - --title "v${{ inputs.version }}" \ + gh release create "$VERSION" \ + --title "v${VERSION}" \ --generate-notes