name: Qdrant Edge Release Build

on:
  workflow_dispatch:
    inputs:
      publish:
        description: 'Publish to PyPI'
        type: boolean
        default: false
        required: true
  push:
    branches: [ dev ]

permissions:
  contents: read

concurrency:
  group: edge-py-release-${{ github.ref }}
  cancel-in-progress: false

jobs:
  edge-py-linux:
    name: Build Python wheels [Linux]

    runs-on: ubuntu-latest

    strategy:
      matrix:
        # On `push` to `dev`, only build the single most popular flavor:
        # x86_64 manylinux_2_17 (widest glibc compatibility). Thanks to
        # abi3, that one wheel covers every CPython >= 3.10, including the
        # latest. The full matrix (aarch64, manylinux_2_28, musl, PyPy)
        # only runs on manual `workflow_dispatch` releases.
        target: ${{ github.event_name == 'push' && fromJSON('["x86_64"]') || fromJSON('["x86_64", "aarch64"]') }}
        manylinux: ${{ github.event_name == 'push' && fromJSON('["manylinux_2_17"]') || fromJSON('["manylinux_2_17", "manylinux_2_28"]') }}
        python: ${{ github.event_name == 'push' && fromJSON('["python3.10"]') || fromJSON('["python3.10", "pypy3.11"]') }}

        include: >-
          ${{ github.event_name == 'push' && fromJSON('[]') || fromJSON('[
          {"arch": "x86_64", "target": "x86_64-unknown-linux-musl", "manylinux": "musllinux_1_2", "python": "python3.10"},
          {"arch": "aarch64", "target": "aarch64-unknown-linux-musl", "manylinux": "musllinux_1_2", "python": "python3.10"}
          ]') }}

        exclude:
          - target: aarch64
            manylinux: manylinux_2_17

    steps:
      - name: Checkout Qdrant
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Set pre-release version
        # Bump the patch component and append a `-dev<run_number>` SemVer
        # pre-release suffix so the dev version sorts strictly ABOVE the
        # latest published stable (e.g. Cargo.toml `0.7.2` -> Cargo.toml
        # `0.7.3-dev137` -> wheel `0.7.3.dev137`). Cargo rejects PEP 440
        # syntax `.dev` because it isn't valid SemVer, so we write SemVer
        # here and let maturin translate to PEP 440 when building the wheel.
        # Combined with `pip install --pre`, this lets consumers pull "the
        # current dev" without knowing the run number, and prevents pip
        # from silently preferring the older stable release.
        if: github.event_name == 'push'
        shell: bash
        working-directory: lib/edge/python
        run: |
          awk -v run="${GITHUB_RUN_NUMBER}" '
            !patched && /^version = "/ {
              # Assumes SemVer X.Y.Z inside the quotes; bump patch, append -devN.
              split($0, quoted, "\"")
              split(quoted[2], parts, ".")
              parts[3] = parts[3] + 1
              print "version = \"" parts[1] "." parts[2] "." parts[3] "-dev" run "\""
              patched = 1
              next
            }
            { print }
          ' Cargo.toml > Cargo.toml.new
          mv Cargo.toml.new Cargo.toml
          grep '^version' Cargo.toml

      - name: Build Python wheel
        uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
        with:
          command: build
          args: --release --features abi3 --interpreter ${{ matrix.python }} --out dist
          working-directory: lib/edge/python
          target: ${{ matrix.target }}
          manylinux: ${{ matrix.manylinux }}

      - name: Upload Python wheel
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: qdrant-edge-${{ matrix.manylinux }}-${{ matrix.arch || matrix.target }}-${{ matrix.python }}
          path: lib/edge/python/dist


  edge-py-macos:
    name: Build Python wheels [macOS]

    runs-on: macos-latest

    # Dev pushes only build Linux x86_64; the full platform matrix is
    # reserved for manual `workflow_dispatch` releases.
    if: github.event_name != 'push'

    strategy:
      matrix:
        target: [x86_64, aarch64]
        python:
          - label: python3.10
            version: '3.10'
          - pypy3.11

    steps:
      - name: Install Rust
        uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable

      - name: Install Python
        id: setup-python
        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
        with:
          python-version: ${{ matrix.python.version || matrix.python }}

      - name: Checkout Qdrant
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Set pre-release version
        # See the identical step on the Linux job for the rationale (SemVer
        # `-dev<N>` + patch bump so maturin emits PEP 440 `X.Y.(Z+1).dev<N>`).
        if: github.event_name == 'push'
        shell: bash
        working-directory: lib/edge/python
        run: |
          awk -v run="${GITHUB_RUN_NUMBER}" '
            !patched && /^version = "/ {
              split($0, quoted, "\"")
              split(quoted[2], parts, ".")
              parts[3] = parts[3] + 1
              print "version = \"" parts[1] "." parts[2] "." parts[3] "-dev" run "\""
              patched = 1
              next
            }
            { print }
          ' Cargo.toml > Cargo.toml.new
          mv Cargo.toml.new Cargo.toml
          grep '^version' Cargo.toml

      - name: Build Python wheel
        uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
        with:
          command: build
          args: --release --features abi3 --interpreter ${{ steps.setup-python.outputs.python-path }} --out dist
          working-directory: lib/edge/python
          target: ${{ matrix.target }}

      - name: Upload Python wheel
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: qdrant-edge-macos-${{ matrix.target }}-${{ matrix.python.label || matrix.python }}
          path: lib/edge/python/dist


  edge-py-windows:
    name: Build Python wheels [Windows]

    runs-on: windows-latest

    # Dev pushes only build Linux x86_64; the full platform matrix is
    # reserved for manual `workflow_dispatch` releases.
    if: github.event_name != 'push'

    strategy:
      matrix:
        target: [x86_64]
        python:
          - label: python3.10
            version: '3.10'
          - pypy3.11

    steps:
      - name: Install Rust
        uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable

      - name: Install Python
        id: setup-python
        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
        with:
          python-version: ${{ matrix.python.version || matrix.python }}

      - name: Checkout Qdrant
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Set pre-release version
        # See the identical step on the Linux job for the rationale (SemVer
        # `-dev<N>` + patch bump so maturin emits PEP 440 `X.Y.(Z+1).dev<N>`).
        if: github.event_name == 'push'
        shell: bash
        working-directory: lib/edge/python
        run: |
          awk -v run="${GITHUB_RUN_NUMBER}" '
            !patched && /^version = "/ {
              split($0, quoted, "\"")
              split(quoted[2], parts, ".")
              parts[3] = parts[3] + 1
              print "version = \"" parts[1] "." parts[2] "." parts[3] "-dev" run "\""
              patched = 1
              next
            }
            { print }
          ' Cargo.toml > Cargo.toml.new
          mv Cargo.toml.new Cargo.toml
          grep '^version' Cargo.toml

      - name: Build Python wheel
        uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
        with:
          command: build
          args: --release --features abi3 --interpreter ${{ steps.setup-python.outputs.python-path }} --out dist
          working-directory: lib/edge/python
          target: ${{ matrix.target }}

      - name: Upload Python wheel
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: qdrant-edge-windows-${{ matrix.target }}-${{ matrix.python.label || matrix.python }}
          path: lib/edge/python/dist


  edge-py-test:
    name: Test Python wheels [${{ matrix.os }}, ${{ matrix.python }}]

    runs-on: ${{ matrix.os }}
    needs:
      - edge-py-linux
      - edge-py-macos
      - edge-py-windows

    # macOS/Windows builds are intentionally skipped on `push`, which by
    # default would skip this job too — override with `!cancelled()` and
    # only require the jobs that were supposed to run.
    if: >-
      ${{ !cancelled()
      && needs.edge-py-linux.result == 'success'
      && (github.event_name == 'push' || (needs.edge-py-macos.result == 'success' && needs.edge-py-windows.result == 'success')) }}

    strategy:
      fail-fast: false
      matrix:
        # Dev pushes test the single x86_64 Linux wheel on the latest
        # stable Python only. Production releases (workflow_dispatch)
        # verify the abi3 wheel against every stable CPython plus PyPy.
        os: ${{ github.event_name == 'push' && fromJSON('["ubuntu-latest"]') || fromJSON('["macos-latest", "ubuntu-24.04-arm", "ubuntu-latest", "windows-latest"]') }}
        python: ${{ github.event_name == 'push' && fromJSON('["3.14"]') || fromJSON('["3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.11"]') }}

    steps:
      - name: Checkout Qdrant
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Install Just
        uses: ./.github/actions/setup-just

      - name: Install uv
        uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2

      - name: Install Python
        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
        with:
          python-version: ${{ matrix.python }}

      - name: Download wheels
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          pattern: qdrant-edge-*
          merge-multiple: true
          path: lib/edge/python/dist

      - name: Setup Qdrant
        uses: ./.github/actions/setup-qdrant

      - name: Install and test wheel
        working-directory: lib/edge/python
        run: |
          uv venv --allow-existing --python ${{ matrix.python }}
          uv pip install --no-index --find-links dist/ qdrant-edge-py
          uv pip install requests
          just py-examples


  merge-artifacts:
    name: Merge release artifacts

    runs-on: ubuntu-latest
    needs:
      - edge-py-linux
      - edge-py-macos
      - edge-py-windows
      - edge-py-test

    # `!cancelled()` because macOS/Windows builds are skipped on `push`;
    # `edge-py-test` succeeding already implies every required build passed.
    if: ${{ !cancelled() && needs.edge-py-test.result == 'success' && (inputs.publish || github.event_name == 'push') }}

    steps:
      - name: Merge release artifacts
        uses: actions/upload-artifact/merge@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: qdrant-edge
          pattern: qdrant-edge-*
          delete-merged: false


  publish:
    name: Publish to PyPI

    runs-on: ubuntu-latest
    needs:
      - merge-artifacts

    # Stable releases go to PyPI via manual workflow_dispatch.
    # Dev pre-releases from `push` to `dev` go to GCS instead (see publish-gcs).
    if: inputs.publish

    steps:
      - name: Install Python
        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0

      - name: Download release wheels
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: qdrant-edge

      - name: Publish to PyPI
        env:
          TWINE_USERNAME: __token__
          TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN_EDGE }}
        run: |
          pip install --upgrade twine
          twine upload --skip-existing *


  publish-gcs:
    name: Publish dev wheels to GCS

    runs-on: ubuntu-latest
    needs:
      - merge-artifacts

    # Only publish dev pre-releases (from push to `dev`) to the GCS bucket.
    # Manual workflow_dispatch runs target PyPI via the `publish` job above.
    if: github.event_name == 'push'

    env:
      GCS_BUCKET: qdrant-debug
      GCS_ENDPOINT: https://storage.googleapis.com
      # Sub-folder inside the same bucket used by debug-tools.yml
      GCS_PREFIX: edge-py-dev
      # GCS interop ignores the region, but the AWS CLI requires one to be set.
      AWS_REGION: auto

    steps:
      - name: Download release wheels
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: qdrant-edge
          path: dist

      - name: Upload wheels and regenerate index
        env:
          # GCS HMAC interoperability keys (AWS-style), stored as repo secrets.
          AWS_ACCESS_KEY_ID: ${{ secrets.GCS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.GCS_SECRET_ACCESS_KEY }}
          # Recent AWS CLI adds default CRC32 integrity checksums that GCS's
          # S3-compatible UploadPart rejects (SignatureDoesNotMatch). Opt out so
          # multipart uploads of the larger wheels work.
          AWS_REQUEST_CHECKSUM_CALCULATION: when_required
          AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
        run: |
          # Extract the current version from a freshly built wheel filename.
          # Wheel filename format (PEP 427):
          #   {distribution}-{version}-{python-tag}-{abi-tag}-{platform-tag}.whl
          # PEP 440 versions never contain `-`, so a simple field split is safe.
          current_version=$(ls dist/*.whl | head -1 | sed 's|.*/qdrant_edge_py-\([^-]*\)-.*|\1|')
          echo "Publishing qdrant-edge-py ${current_version}"

          # Upload the freshly built wheels. Object overwrite is a no-op for the
          # same content; different content would replace (unlike PyPI's immutable
          # filenames), but our `.dev<run_number>` versioning gives every push a
          # unique filename so collisions shouldn't happen.
          aws s3 cp dist/ "s3://${GCS_BUCKET}/${GCS_PREFIX}/wheels/" \
            --recursive --exclude '*' --include '*.whl' \
            --endpoint-url "$GCS_ENDPOINT" --region "$AWS_REGION"

          # Delete every wheel that doesn't belong to the current version, so
          # only the latest dev release is kept in the bucket. Done AFTER upload
          # so there's never a window with zero wheels available.
          aws s3 ls "s3://${GCS_BUCKET}/${GCS_PREFIX}/wheels/" \
            --endpoint-url "$GCS_ENDPOINT" --region "$AWS_REGION" \
            | awk '{print $NF}' \
            | grep '\.whl$' \
            | grep -vF "qdrant_edge_py-${current_version}-" \
            | while read -r stale; do
                echo "Removing stale wheel: ${stale}"
                aws s3 rm "s3://${GCS_BUCKET}/${GCS_PREFIX}/wheels/${stale}" \
                  --endpoint-url "$GCS_ENDPOINT" --region "$AWS_REGION"
              done

          # Regenerate the pip-compatible HTML index. After the cleanup above,
          # only the current version's wheels remain in the bucket.
          base_url="${GCS_ENDPOINT}/${GCS_BUCKET}/${GCS_PREFIX}/wheels"
          {
            echo '<!DOCTYPE html>'
            echo '<html><body>'
            aws s3 ls "s3://${GCS_BUCKET}/${GCS_PREFIX}/wheels/" \
              --endpoint-url "$GCS_ENDPOINT" --region "$AWS_REGION" \
              | awk '{print $NF}' \
              | grep '\.whl$' \
              | sort \
              | while read -r wheel; do
                  echo "<a href=\"${base_url}/${wheel}\">${wheel}</a><br>"
                done
            echo '</body></html>'
          } > qdrant-edge-py.html

          aws s3 cp qdrant-edge-py.html "s3://${GCS_BUCKET}/${GCS_PREFIX}/qdrant-edge-py.html" \
            --endpoint-url "$GCS_ENDPOINT" --region "$AWS_REGION" \
            --content-type text/html

          # Surface the install command in the job summary
          index_url="${GCS_ENDPOINT}/${GCS_BUCKET}/${GCS_PREFIX}/qdrant-edge-py.html"
          {
            echo "## qdrant-edge-py dev wheels uploaded"
            echo ""
            echo "Published version: ${current_version}"
            echo ""
            echo "Index: <${index_url}>"
            echo ""
            echo "Install the current dev build (no version pin needed):"
            echo '```'
            echo "pip install --find-links ${index_url} qdrant-edge-py --pre"
            echo '```'
            echo ""
            echo "Pin a specific dev version:"
            echo '```'
            echo "pip install --find-links ${index_url} qdrant-edge-py==${current_version}"
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"
