name: release

on:
  workflow_dispatch:
    inputs:
      mode:
        description: 'What to do'
        required: true
        type: choice
        default: release
        options:
          # release:    bump, tag, publish X.Y.Z to PyPI, GitHub Release
          # prerelease: bump, tag, publish X.Y.Z<tag> to PyPI (pip skips it
          #             unless --pre or pinned), GitHub Release
          # test:       publish a throwaway X.Y.Z.dev<run> to TestPyPI; no
          #             commit, tag, or GitHub Release. Repeatable.
          # dry-run:    compute the version and build, but publish nothing
          - release
          - prerelease
          - test
          - dry-run
      bump:
        description: 'Version bump type'
        required: true
        type: choice
        options:
          - patch
          - minor
          - major
      prerelease_tag:
        description: 'Pre-release suffix (e.g. b1, rc1). Required when mode is prerelease.'
        required: false
        type: string
        default: ''

# Serialize releases so two near-simultaneous dispatches cannot race on the
# version bump.
concurrency:
  group: release
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  release:
    name: Bump version and build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: write
    outputs:
      version: ${{ steps.version.outputs.new }}
      docs-version: ${{ steps.version.outputs.docs_version }}
      docs-aliases: ${{ steps.version.outputs.docs_aliases }}
      docs-set-default: ${{ steps.version.outputs.docs_set_default }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          fetch-depth: 0
          token: ${{ secrets.GITHUB_TOKEN }}

      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
        with:
          python-version: '3.14'

      - name: Configure git
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"

      - name: Validate inputs
        env:
          MODE: ${{ inputs.mode }}
          PRERELEASE_TAG: ${{ inputs.prerelease_tag }}
        run: |
          if [ "$MODE" = "prerelease" ] && [ -z "$PRERELEASE_TAG" ]; then
            echo "::error::prerelease_tag is required when mode is prerelease (e.g. b1, rc1)"
            exit 1
          fi
          if [ "$MODE" != "prerelease" ] && [ -n "$PRERELEASE_TAG" ]; then
            echo "::error::prerelease_tag is only valid when mode is prerelease"
            exit 1
          fi

      - name: Compute new version
        id: version
        env:
          MODE: ${{ inputs.mode }}
          BUMP: ${{ inputs.bump }}
          PRERELEASE_TAG: ${{ inputs.prerelease_tag }}
          RUN_NUMBER: ${{ github.run_number }}
        run: |
          current=$(sed -nE 's/^version = "([^"]+)".*/\1/p' pyproject.toml)
          echo "current=$current" >> "$GITHUB_OUTPUT"

          IFS='.' read -r major minor patch <<< "${current%%[a-zA-Z]*}"

          case "$BUMP" in
            major) major=$((major + 1)); minor=0; patch=0 ;;
            minor) minor=$((minor + 1)); patch=0 ;;
            patch) patch=$((patch + 1)) ;;
          esac

          new_version="${major}.${minor}.${patch}"
          case "$MODE" in
            prerelease)
              # Canonical spellings only, so the tag, the PyPI version and the
              # docs directory are one string; see RELEASE.md.
              # No leading zeros: `b01` matches a naive pattern but normalizes
              # to `b1`, so the tag and the version PyPI indexes would differ,
              # and the docs deploy would reject it after both are unrecoverable.
              if ! echo "$PRERELEASE_TAG" | grep -qE '^(a|b|rc)(0|[1-9][0-9]*)$'; then
                echo "::error::prerelease_tag must be a canonical PEP 440 pre-release segment: a, b or rc followed by a number (e.g. b1, rc1)"
                exit 1
              fi
              new_version="${new_version}${PRERELEASE_TAG}"
              ;;
            # A throwaway .devN build keyed on the run number: valid PEP 440,
            # sorts below the real X.Y.Z, and unique per run so TestPyPI's
            # immutable-version rule never bites on repeated test publishes.
            test)
              new_version="${new_version}.dev${RUN_NUMBER}"
              ;;
          esac

          echo "new=$new_version" >> "$GITHUB_OUTPUT"
          echo "Bumping $current -> $new_version (mode: $MODE)"

          # Docs-site parameters, decided here because this is where the mode is
          # known rather than inferred from tag text. See RELEASE.md.
          case "$MODE" in
            release)
              echo "docs_version=${major}.${minor}" >> "$GITHUB_OUTPUT"
              echo "docs_aliases=latest" >> "$GITHUB_OUTPUT"
              echo "docs_set_default=true" >> "$GITHUB_OUTPUT"
              ;;
            prerelease)
              echo "docs_version=$new_version" >> "$GITHUB_OUTPUT"
              echo "docs_aliases=" >> "$GITHUB_OUTPUT"
              echo "docs_set_default=false" >> "$GITHUB_OUTPUT"
              ;;
            *)
              # `test` and `dry-run` never tag, so they never deploy docs.
              echo "docs_version=" >> "$GITHUB_OUTPUT"
              echo "docs_aliases=" >> "$GITHUB_OUTPUT"
              echo "docs_set_default=false" >> "$GITHUB_OUTPUT"
              ;;
          esac

      - name: Update version
        env:
          NEW_VERSION: ${{ steps.version.outputs.new }}
        run: |
          sed -i "s/^version = \".*\"/version = \"$NEW_VERSION\"/" pyproject.toml
          sed -i "s/^__version__ = ['\"].*['\"]/__version__ = '$NEW_VERSION'/" pika/__init__.py

      # Build once, before mutating main, so a broken build aborts the release
      # before any commit or tag. Every downstream publish consumes this exact
      # artifact rather than rebuilding, so the bytes tested are the bytes
      # shipped. Runs in all modes, including dry-run, so a dry run validates
      # that the project still builds.
      - name: Build distribution
        run: |
          python -m pip install build twine --upgrade
          python -m build --sdist --wheel --outdir dist/ .
          twine check dist/*

      - name: Upload distribution artifact
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: dist
          path: dist/
          if-no-files-found: error

      - name: Commit, tag, and push
        if: ${{ inputs.mode == 'release' || inputs.mode == 'prerelease' }}
        env:
          NEW_VERSION: ${{ steps.version.outputs.new }}
        run: |
          git add pyproject.toml pika/__init__.py
          git commit -m "release: pika $NEW_VERSION"
          git tag -a "$NEW_VERSION" -m "pika $NEW_VERSION"
          git push origin HEAD:${{ github.ref }} --follow-tags

      - name: Verify tag exists on remote
        if: ${{ inputs.mode == 'release' || inputs.mode == 'prerelease' }}
        env:
          NEW_VERSION: ${{ steps.version.outputs.new }}
        run: |
          git ls-remote --tags origin "refs/tags/$NEW_VERSION" | grep -q "$NEW_VERSION" \
            || { echo "::error::Tag $NEW_VERSION not found on remote after push"; exit 1; }

      - name: Dry-run summary
        if: ${{ inputs.mode == 'dry-run' }}
        env:
          NEW_VERSION: ${{ steps.version.outputs.new }}
        run: |
          echo "::notice::Dry run - would release pika $NEW_VERSION (no commit, tag, or publish)"
          git --no-pager diff

  # The publish jobs only download and publish the artifact built by the
  # release job; they never rebuild, so the bytes tested are the bytes
  # shipped. Authentication uses the PYPI_API_TOKEN / TEST_PYPI_API_TOKEN
  # repository secrets rather than trusted publishing, so the API token is
  # exposed only to the publish jobs, not to the build.
  publish-pypi:
    name: Publish to PyPI
    needs: release
    if: ${{ inputs.mode == 'release' || inputs.mode == 'prerelease' }}
    runs-on: ubuntu-latest
    environment:
      name: pypi
      url: https://pypi.org/project/pika/${{ needs.release.outputs.version }}/
    steps:
      - name: Download distribution artifact
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: dist
          path: dist/

      - name: Publish to PyPI
        uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
        with:
          password: ${{ secrets.PYPI_API_TOKEN }}
          # Attestations require trusted publishing; with token auth they
          # must be disabled explicitly or the action errors.
          attestations: false

  publish-test-pypi:
    name: Publish to TestPyPI
    needs: release
    if: ${{ inputs.mode == 'test' }}
    runs-on: ubuntu-latest
    environment:
      name: testpypi
      url: https://test.pypi.org/project/pika/${{ needs.release.outputs.version }}/
    steps:
      - name: Download distribution artifact
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: dist
          path: dist/

      - name: Publish to TestPyPI
        uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
        with:
          repository-url: https://test.pypi.org/legacy/
          password: ${{ secrets.TEST_PYPI_API_TOKEN }}
          # Attestations require trusted publishing; with token auth they
          # must be disabled explicitly or the action errors.
          attestations: false

  smoke-test:
    name: Smoke-test the published wheel
    needs: [release, publish-pypi, publish-test-pypi]
    # Run for whichever publish actually happened. This is skipped for
    # dry-run (neither publish ran) and fires for the one publish job whose
    # index received the wheel.
    if: >-
      always() && needs.release.result == 'success' &&
      (needs.publish-pypi.result == 'success' ||
       needs.publish-test-pypi.result == 'success')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
        with:
          python-version: '3.14'

      - name: Start RabbitMQ
        run: ./.ci/linux/gha-setup.sh

      - name: Install pika and smoke-test
        env:
          NEW_VERSION: ${{ needs.release.outputs.version }}
          # The TestPyPI index when the test publish ran, else empty (real
          # PyPI). pika has no runtime dependencies, so installing from the
          # TestPyPI index needs no fallback index for dependency resolution.
          INDEX_URL: ${{ needs.publish-test-pypi.result == 'success' && 'https://test.pypi.org/simple/' || '' }}
        run: |
          python -m venv smoke-venv
          pip_args=()
          if [ -n "$INDEX_URL" ]
          then
            pip_args+=(--index-url "$INDEX_URL")
          fi
          # PyPI/TestPyPI are eventually consistent: a fresh install can 404
          # for a minute or two right after publish. Retry until the
          # just-published version installs, up to ~5 minutes.
          attempt=0
          until smoke-venv/bin/pip install "${pip_args[@]}" "pika==$NEW_VERSION"
          do
            attempt=$((attempt + 1))
            if (( attempt >= 20 ))
            then
              echo "::error::pika $NEW_VERSION not installable after $attempt attempts"
              exit 1
            fi
            echo "pika $NEW_VERSION not available yet; retrying in 15s (attempt $attempt)"
            sleep 15
          done
          smoke-venv/bin/python .ci/smoke_test.py

  github-release:
    name: Create GitHub Release
    needs: [release, publish-pypi]
    if: ${{ inputs.mode == 'release' || inputs.mode == 'prerelease' }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          fetch-depth: 0
          ref: ${{ needs.release.outputs.version }}

      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
          TAG: ${{ needs.release.outputs.version }}
          IS_PRERELEASE: ${{ inputs.mode == 'prerelease' }}
        run: |
          PRERELEASE_FLAG=""
          if [ "$IS_PRERELEASE" = "true" ]; then
            PRERELEASE_FLAG="--prerelease"
          fi

          gh release create "$TAG" \
            --title "pika $TAG" \
            --generate-notes \
            $PRERELEASE_FLAG

  # Last, so a release that fails partway never publishes its docs, and called
  # with `uses:` so the deploy's conclusion is this run's. See RELEASE.md for the
  # ordering, the `smoke-test` dependency and how to recover a skipped deploy.
  deploy-docs:
    needs: [release, publish-pypi, smoke-test, github-release]
    if: ${{ inputs.mode == 'release' || inputs.mode == 'prerelease' }}
    # Required: a called workflow cannot elevate the caller's token.
    permissions:
      contents: write
    uses: ./.github/workflows/_deploy-docs.yaml
    with:
      ref: ${{ needs.release.outputs.version }}
      version: ${{ needs.release.outputs.docs-version }}
      aliases: ${{ needs.release.outputs.docs-aliases }}
      set-default: ${{ needs.release.outputs.docs-set-default == 'true' }}
