diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 761e9279..7d918137 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,18 +1,64 @@ 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 + outputs: + version: ${{ steps.normalize.outputs.version }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - 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) + 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="${{ 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 + 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 +82,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 +115,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 +146,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 +193,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 +235,7 @@ jobs: needs: build-dashboard runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/download-dashboard @@ -208,12 +254,17 @@ 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 + - name: Download wheel and sdist artifacts uses: actions/download-artifact@v8 with: @@ -227,13 +278,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}" - python3 - "$version" <<'PYCHECK' + python3 - "$VERSION" <<'PYCHECK' import hashlib, json, os, sys, urllib.request from pathlib import Path @@ -274,25 +324,40 @@ 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}" 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 "$VERSION" + git push origin "$VERSION" + + - name: Create GitHub release + if: "!inputs.dry-run" + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$VERSION" \ + --title "v${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