diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 761e9279..ccf17c7c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,18 +1,55 @@ 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 jobs: + preflight: + name: Preflight checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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: | + 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 + build-dashboard: + needs: preflight name: Build dashboard assets runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/dashboard-build @@ -36,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 @@ -69,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 @@ -100,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 @@ -147,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 @@ -189,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 @@ -212,8 +249,11 @@ jobs: runs-on: ubuntu-latest environment: pypi permissions: + contents: write id-token: write steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Download wheel and sdist artifacts uses: actions/download-artifact@v8 with: @@ -227,12 +267,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 +314,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 +337,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..5052415a 100644 --- a/docs/content/docs/more/changelog.mdx +++ b/docs/content/docs/more/changelog.mdx @@ -7,22 +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). +Patch release with no user-facing changes. ## 0.16.0