diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67cea63..c434c89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,13 @@ -name: Publish release +name: Start release on: - # The pypi job checks the dispatch, run identity, and main ancestry before consuming the artifact. - workflow_run: # zizmor: ignore[dangerous-triggers] - workflows: [Start release] - types: [completed] - branches: [main] + # Keep publishing in the dispatch that tested the sdist to avoid trusting artifacts from another run. + workflow_dispatch: + inputs: + version: + description: Version to publish from main, without the leading v + required: true + type: string permissions: {} @@ -13,38 +15,100 @@ concurrency: group: publish-release cancel-in-progress: false +env: + # Explicit installs use the Python archive hashes embedded in the pinned uv binary. + UV_PYTHON_DOWNLOADS: manual + UV_MANAGED_PYTHON: 'true' + jobs: + ci: + if: github.repository == 'astral-experiments/astral-html-python' && github.ref == 'refs/heads/main' + permissions: + contents: read + uses: $/.github/workflows/ci.yml + + prepare: + name: Prepare the tested source distribution + needs: ci + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: sdist-and-abi3 + path: dist + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: '0.13.6' + checksum: f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645 + enable-cache: false + - run: uv python install 3.14.10 + - name: Select the tested sdist and its changelog entry + env: + VERSION: ${{ inputs.version }} + run: | + sdists=(dist/*.tar.gz) + if [[ ${#sdists[@]} != 1 || ${sdists[0]} != "dist/astral_html-$VERSION.tar.gz" ]]; then + echo "::error::Requested version $VERSION does not match the built sdist" + exit 1 + fi + if git show-ref --verify --quiet "refs/tags/v$VERSION"; then + test "$(git rev-parse "refs/tags/v$VERSION^{commit}")" = "$GITHUB_SHA" + fi + mkdir release + awk -v heading="## $VERSION" ' + /^## / { selected = ($0 == heading); next } + selected { print } + ' CHANGELOG.md > release/notes.md + if ! grep -q '[^[:space:]]' release/notes.md; then + echo "::error::Missing changelog entry for $VERSION" + exit 1 + fi + mv "${sdists[0]}" release/ + printf 'v%s\n' "$VERSION" > release/tag + uv run --locked --no-build --only-group dev --python 3.14.10 python - <<'PY' > release/prerelease + import os + from packaging.version import Version + + print(str(Version(os.environ["VERSION"]).is_prerelease).lower()) + PY + - id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + # Each prepare attempt uploads its own bundle. Retried publishers use its immutable ID. + name: release-${{ github.run_attempt }} + path: release/ + if-no-files-found: error + pypi: - # workflow_run has publishing privileges. Only consume a successful manual run in this repository. - if: >- - github.repository == 'astral-experiments/astral-html-python' && - github.event.workflow_run.head_repository.full_name == github.repository && - github.event.workflow_run.path == '.github/workflows/start-release.yml' && - github.event.workflow_run.event == 'workflow_dispatch' && - github.event.workflow_run.conclusion == 'success' + name: Publish to PyPI + needs: prepare runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/project/astral-html/ permissions: - actions: read contents: read - id-token: write + id-token: write # Exchange the job's OIDC token for a short-lived PyPI credential. steps: - name: Verify the source commit is on main env: GH_TOKEN: ${{ github.token }} - RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} - # workflow_run reports a short branch name, which a same-named tag could also have. + # Main may have been force-pushed since dispatch or while awaiting environment approval. run: | main_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq .object.sha) - gh api "repos/$GITHUB_REPOSITORY/compare/$RELEASE_SHA...$main_sha" --jq .status | grep -Ex 'ahead|identical' + gh api "repos/$GITHUB_REPOSITORY/compare/$GITHUB_SHA...$main_sha" --jq .status | grep -Ex 'ahead|identical' - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: release-${{ github.event.workflow_run.run_attempt }} + artifact-ids: ${{ needs.prepare.outputs.artifact-id }} path: release - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: version: '0.13.6' @@ -53,30 +117,25 @@ jobs: - run: uv publish --trusted-publishing always --check-url https://pypi.org/simple release/*.tar.gz github: - needs: pypi + name: Create the GitHub release + needs: [prepare, pypi] runs-on: ubuntu-latest permissions: - actions: read - contents: write + contents: write # Create the version tag and its GitHub release. steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # workflow_run's GITHUB_SHA can be newer than the commit CI tested. - ref: ${{ github.event.workflow_run.head_sha }} - persist-credentials: false - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: release-${{ github.event.workflow_run.run_attempt }} + artifact-ids: ${{ needs.prepare.outputs.artifact-id }} path: release - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - name: Tag the published commit and release its changelog entry env: GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} run: | tag=$(cat release/tag) - gh auth setup-git - git push origin "HEAD:refs/tags/$tag" + # A retry can reuse the published commit's tag, but must never move an existing tag. + gh api "repos/$GH_REPO/git/refs" --method POST -f ref="refs/tags/$tag" -f sha="$GITHUB_SHA" --silent || + test "$(gh api "repos/$GH_REPO/git/ref/tags/$tag" --jq .object.sha)" = "$GITHUB_SHA" if gh release view "$tag" --json isDraft --jq '.isDraft == false' | grep -qx true; then exit 0 fi diff --git a/.github/workflows/start-release.yml b/.github/workflows/start-release.yml deleted file mode 100644 index e510190..0000000 --- a/.github/workflows/start-release.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Start release - -on: - workflow_dispatch: - inputs: - version: - description: Version to publish from main, without the leading v - required: true - type: string - -permissions: - contents: read - -env: - # Explicit installs use the Python archive hashes embedded in the pinned uv binary. - UV_PYTHON_DOWNLOADS: manual - UV_MANAGED_PYTHON: 'true' - -jobs: - ci: - if: github.repository == 'astral-experiments/astral-html-python' && github.ref == 'refs/heads/main' - uses: $/.github/workflows/ci.yml - - prepare: - needs: ci - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: sdist-and-abi3 - path: dist - - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - with: - version: '0.13.6' - checksum: f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645 - enable-cache: false - - run: uv python install 3.14.10 - - name: Select the tested sdist and its changelog entry - env: - VERSION: ${{ inputs.version }} - run: | - sdists=(dist/*.tar.gz) - if [[ ${#sdists[@]} != 1 || ${sdists[0]} != "dist/astral_html-$VERSION.tar.gz" ]]; then - echo "::error::Requested version $VERSION does not match the built sdist" - exit 1 - fi - if git show-ref --verify --quiet "refs/tags/v$VERSION"; then - test "$(git rev-parse "refs/tags/v$VERSION^{commit}")" = "$GITHUB_SHA" - fi - mkdir release - awk -v heading="## $VERSION" ' - /^## / { selected = ($0 == heading); next } - selected { print } - ' CHANGELOG.md > release/notes.md - if ! grep -q '[^[:space:]]' release/notes.md; then - echo "::error::Missing changelog entry for $VERSION" - exit 1 - fi - mv "${sdists[0]}" release/ - printf 'v%s\n' "$VERSION" > release/tag - uv run --locked --no-build --only-group dev --python 3.14.10 python - <<'PY' > release/prerelease - import os - from packaging.version import Version - - print(str(Version(os.environ["VERSION"]).is_prerelease).lower()) - PY - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - # A rerun may rebuild the sdist. Bind publication to this attempt's bytes. - name: release-${{ github.run_attempt }} - path: release/ - if-no-files-found: error