name: Publish Release

# Action-first kreuzberg release pipeline.
#
# Orchestrates 16 alef language targets:
#   crates · python · node · wasm · ruby · php · cli · homebrew · go · c-ffi
#   java · csharp · elixir · dart · swift · zig · kotlin
# (R is excluded — distributed automatically by R-Universe from this repo's HEAD.)
#
# Mirrors html-to-markdown's publish.yaml composition: every registry
# interaction, every multi-platform build, and every release-asset upload
# routes through a shared kreuzberg-dev/actions composite. Repo-quirky inline
# bash is reserved for matrix expansion / artifact path stitching only.

on:
  workflow_dispatch:
    inputs:
      tag:
        description: "Release tag to build (e.g., v5.0.0-rc.1)"
        required: false
        type: string
      ref:
        description: "Git ref (branch, tag, or commit) to build; defaults to the tag"
        required: false
        type: string
      targets:
        description: "Comma-separated list of release targets, or 'all'"
        required: false
        type: string
        default: "all"
      dry_run:
        description: "Prepare artifacts without publishing"
        required: false
        type: boolean
        default: false
      republish:
        description: "Delete and re-create the tag on current HEAD before publishing"
        required: false
        type: boolean
        default: false
      force_republish:
        description: "Force republish targets whose registry version already exists"
        required: false
        type: boolean
        default: false
  release:
    types: [published]
  repository_dispatch:
    types: [publish-release]

permissions:
  contents: write
  id-token: write

concurrency:
  group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.ref || github.event.inputs.tag)) || github.ref || github.run_id }}
  cancel-in-progress: false

jobs:
  prepare:
    name: Prepare metadata
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.meta.outputs.tag }}
      version: ${{ steps.meta.outputs.version }}
      ref: ${{ steps.meta.outputs.ref }}
      checkout_ref: ${{ steps.meta.outputs.checkout_ref }}
      target_sha: ${{ steps.meta.outputs.target_sha }}
      matrix_ref: ${{ steps.meta.outputs.matrix_ref }}
      is_tag: ${{ steps.meta.outputs.is_tag }}
      is_prerelease: ${{ steps.meta.outputs.is_prerelease }}
      npm_tag: ${{ steps.meta.outputs.npm_tag }}
      dry_run: ${{ steps.meta.outputs.dry_run }}
      force_republish: ${{ steps.meta.outputs.force_republish }}
      release_targets: ${{ steps.meta.outputs.release_targets }}
      release_any: ${{ steps.meta.outputs.release_any }}
      release_python: ${{ steps.meta.outputs.release_python }}
      release_node: ${{ steps.meta.outputs.release_node }}
      release_ruby: ${{ steps.meta.outputs.release_ruby }}
      release_cli: ${{ steps.meta.outputs.release_cli }}
      release_crates: ${{ steps.meta.outputs.release_crates }}
      release_homebrew: ${{ steps.meta.outputs.release_homebrew }}
      release_java: ${{ steps.meta.outputs.release_java }}
      release_csharp: ${{ steps.meta.outputs.release_csharp }}
      release_go: ${{ steps.meta.outputs.release_go }}
      release_wasm: ${{ steps.meta.outputs.release_wasm }}
      release_php: ${{ steps.meta.outputs.release_php }}
      release_elixir: ${{ steps.meta.outputs.release_elixir }}
      release_c_ffi: ${{ steps.meta.outputs.release_c_ffi }}
      release_dart: ${{ steps.meta.outputs.release_dart }}
      release_swift: ${{ steps.meta.outputs.release_swift }}
      release_zig: ${{ steps.meta.outputs.release_zig }}
      release_kotlin: ${{ steps.meta.outputs.release_kotlin }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6.0.2
        with:
          ref: ${{ (inputs.republish == true && (inputs.ref || github.event.repository.default_branch)) || inputs.ref || inputs.tag || github.ref }}
          fetch-depth: 0

      - name: Retag for republish
        if: ${{ inputs.republish == true || github.event.client_payload.republish == true }}
        uses: kreuzberg-dev/actions/retag-for-republish@v1
        with:
          tag: ${{ inputs.tag || github.event.client_payload.tag }}
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Resolve release metadata
        id: meta
        uses: kreuzberg-dev/actions/prepare-release-metadata@v1

      - name: Ensure GitHub Release exists for tag
        if: ${{ steps.meta.outputs.is_tag == 'true' && steps.meta.outputs.dry_run != 'true' }}
        uses: kreuzberg-dev/actions/publish-github-release@v1
        with:
          tag: ${{ steps.meta.outputs.tag }}
          target: ${{ steps.meta.outputs.target_sha }}
          notes: "Release ${{ steps.meta.outputs.tag }}"
          draft: "true"
          prerelease: ${{ steps.meta.outputs.is_prerelease }}

  validate-versions:
    name: Validate language manifest versions
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

      - name: Validate versions
        uses: kreuzberg-dev/actions/validate-versions@v1
        with:
          version: ${{ needs.prepare.outputs.version }}

  # ─── Registry existence checks ────────────────────────────────────────

  check-pypi:
    name: Check PyPI for existing version
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.release_python == 'true' }}
    runs-on: ubuntu-latest
    outputs:
      exists: ${{ steps.check.outputs.exists }}
    steps:
      - id: check
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: pypi
          package: kreuzberg
          version: ${{ needs.prepare.outputs.version }}

  check-npm:
    name: Check npm for existing versions
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' && (needs.prepare.outputs.release_node == 'true' || needs.prepare.outputs.release_wasm == 'true') }}
    runs-on: ubuntu-latest
    outputs:
      node_exists: ${{ steps.check.outputs.exists }}
      wasm_exists: ${{ steps.check.outputs.wasm_exists }}
    steps:
      - id: check
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: npm
          package: "@kreuzberg/node"
          version: ${{ needs.prepare.outputs.version }}
          extra-packages: |
            wasm_exists=@kreuzberg/wasm

  check-rubygems:
    name: Check RubyGems for existing version
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.release_ruby == 'true' }}
    runs-on: ubuntu-latest
    outputs:
      exists: ${{ steps.check.outputs.exists }}
    steps:
      - id: check
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: rubygems
          package: kreuzberg
          version: ${{ needs.prepare.outputs.version }}

  check-maven:
    name: Check Maven Central for existing version
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' && (needs.prepare.outputs.release_java == 'true' || needs.prepare.outputs.release_kotlin == 'true') }}
    runs-on: ubuntu-latest
    outputs:
      java_exists: ${{ steps.check.outputs.exists }}
      kotlin_exists: ${{ steps.check.outputs.kotlin_exists }}
    steps:
      - id: check
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: maven
          package: "dev.kreuzberg:kreuzberg"
          version: ${{ needs.prepare.outputs.version }}
          extra-packages: |
            kotlin_exists=dev.kreuzberg:kreuzberg-android

  check-nuget:
    name: Check NuGet for existing version
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.release_csharp == 'true' }}
    runs-on: ubuntu-latest
    outputs:
      exists: ${{ steps.check.outputs.exists }}
    steps:
      - id: check
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: nuget
          package: Kreuzberg
          version: ${{ needs.prepare.outputs.version }}

  check-packagist:
    name: Check Packagist for existing version
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.release_php == 'true' }}
    runs-on: ubuntu-latest
    outputs:
      exists: ${{ steps.check.outputs.exists }}
    steps:
      - id: check
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: packagist
          package: kreuzberg/kreuzberg
          version: ${{ needs.prepare.outputs.version }}

  check-cratesio:
    name: Check crates.io for existing versions
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.release_crates == 'true' }}
    runs-on: ubuntu-latest
    outputs:
      kreuzberg_exists: ${{ steps.check.outputs.exists }}
      tesseract_exists: ${{ steps.check.outputs.tesseract_exists }}
      paddle_exists: ${{ steps.check.outputs.paddle_exists }}
      cli_exists: ${{ steps.check.outputs.cli_exists }}
      all_exist: ${{ steps.derive.outputs.all_exist }}
    steps:
      - id: check
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: cratesio
          package: kreuzberg
          version: ${{ needs.prepare.outputs.version }}
          extra-packages: |
            tesseract_exists=kreuzberg-tesseract
            paddle_exists=kreuzberg-paddle-ocr
            cli_exists=kreuzberg-cli

      - id: derive
        # Inline: aggregate four check-registry outputs into a single boolean
        # the publish-crates job can guard on. No shared action equivalent.
        run: |
          if [[ "${{ steps.check.outputs.exists }}" == "true" \
            && "${{ steps.check.outputs.tesseract_exists }}" == "true" \
            && "${{ steps.check.outputs.paddle_exists }}" == "true" \
            && "${{ steps.check.outputs.cli_exists }}" == "true" ]]; then
            echo "all_exist=true" >> "$GITHUB_OUTPUT"
          else
            echo "all_exist=false" >> "$GITHUB_OUTPUT"
          fi

  check-hex:
    name: Check Hex.pm for existing versions
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.release_elixir == 'true' }}
    runs-on: ubuntu-latest
    outputs:
      elixir_exists: ${{ steps.check.outputs.exists }}
    steps:
      - id: check
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: hex
          package: kreuzberg
          version: ${{ needs.prepare.outputs.version }}

  check-pub:
    name: Check pub.dev for existing version
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.release_dart == 'true' }}
    runs-on: ubuntu-latest
    outputs:
      exists: ${{ steps.check.outputs.exists }}
    steps:
      - id: check
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: pub
          package: kreuzberg
          version: ${{ needs.prepare.outputs.version }}

  check-zig:
    name: Check Zig package presence
    needs: prepare
    if: ${{ (needs.prepare.outputs.is_tag == 'true' || needs.prepare.outputs.dry_run == 'true') && needs.prepare.outputs.release_zig == 'true' }}
    runs-on: ubuntu-latest
    outputs:
      exists: ${{ steps.check.outputs.exists }}
    steps:
      - id: check
        # `registry: github-release` (not `registry: zig`) — alef check-registry's
        # zig path only verifies the release tag exists. On a real release that's
        # always true, so it short-circuits `publish-zig` to skipped (regression:
        # v4.x shipped without the zig tarball asset because of this). Verify
        # the actual asset is present instead.
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: github-release
          package: kreuzberg-zig-v${{ needs.prepare.outputs.version }}.tar.gz
          version: ${{ needs.prepare.outputs.version }}
          repo: ${{ github.repository }}
          assets: kreuzberg-zig-v${{ needs.prepare.outputs.version }}.tar.gz

  check-homebrew:
    name: Check Homebrew tap for formula
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.release_homebrew == 'true' }}
    runs-on: ubuntu-latest
    outputs:
      exists: ${{ steps.check.outputs.exists }}
    steps:
      - id: check
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: homebrew
          package: kreuzberg
          version: ${{ needs.prepare.outputs.version }}
          tap-repo: kreuzberg-dev/homebrew-tap

  check-cli:
    name: Check GitHub Release for CLI assets
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.release_cli == 'true' }}
    runs-on: ubuntu-latest
    outputs:
      exists: ${{ steps.check.outputs.exists }}
    steps:
      - id: check
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: github-release
          package: kreuzberg
          version: ${{ needs.prepare.outputs.version }}
          repo: kreuzberg-dev/kreuzberg
          tag: ${{ needs.prepare.outputs.tag }}
          asset-prefix: kreuzberg-cli-
          assets: |
            kreuzberg-cli-aarch64-apple-darwin.tar.gz,kreuzberg-cli-aarch64-unknown-linux-gnu.tar.gz,kreuzberg-cli-aarch64-unknown-linux-musl.tar.gz,kreuzberg-cli-x86_64-unknown-linux-gnu.tar.gz,kreuzberg-cli-x86_64-unknown-linux-musl.tar.gz,kreuzberg-cli-x86_64-pc-windows-msvc.zip

  check-go:
    name: Check GitHub Release for Go FFI assets
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.release_go == 'true' }}
    runs-on: ubuntu-latest
    outputs:
      exists: ${{ steps.check.outputs.exists }}
    steps:
      - id: check
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: github-release
          package: kreuzberg
          version: ${{ needs.prepare.outputs.version }}
          repo: kreuzberg-dev/kreuzberg
          tag: ${{ needs.prepare.outputs.tag }}
          asset-prefix: kreuzberg-go-

  check-c-ffi:
    name: Check GitHub Release for C FFI assets
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.release_c_ffi == 'true' }}
    runs-on: ubuntu-latest
    outputs:
      exists: ${{ steps.check.outputs.exists }}
    steps:
      - id: check
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: github-release
          package: kreuzberg
          version: ${{ needs.prepare.outputs.version }}
          repo: kreuzberg-dev/kreuzberg
          tag: ${{ needs.prepare.outputs.tag }}
          asset-prefix: kreuzberg-ffi-

  check-elixir-release:
    name: Check GitHub Release for Elixir NIF assets
    needs: prepare
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.release_elixir == 'true' }}
    runs-on: ubuntu-latest
    outputs:
      exists: ${{ steps.check.outputs.exists }}
    steps:
      - id: check
        uses: kreuzberg-dev/actions/check-registry@v1
        with:
          registry: github-release
          package: kreuzberg
          version: ${{ needs.prepare.outputs.version }}
          repo: kreuzberg-dev/kreuzberg
          tag: ${{ needs.prepare.outputs.tag }}
          asset-prefix: libkreuzberg_nif-

  # ─── Build matrix jobs ────────────────────────────────────────────────

  python-wheels:
    name: Build Python wheels (${{ matrix.os }})
    needs: [prepare, check-pypi]
    if: ${{ needs.prepare.outputs.release_python == 'true' && (needs.check-pypi.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.os }}
    env:
      MACOSX_DEPLOYMENT_TARGET: "10.12"
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-latest]
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - name: Build wheels
        uses: kreuzberg-dev/actions/build-python-wheels@v1
        with:
          python-version: "3.13"
          package-dir: packages/python
          # PyO3 abi3-py310 → one wheel per platform covers Python 3.10+.
          cibw-build: "cp310-*"
          upload-artifact: "false"
          # AlmaLinux 8 base ships OpenSSL 1.1.1 (CentOS 7 base of manylinux2014
          # ships 1.0.2k which openssl-sys 0.9.115+ rejects).
          manylinux-x86_64-image: "manylinux_2_28"
          manylinux-aarch64-image: "manylinux_2_28"
          # manylinux_2_28 (AlmaLinux 8) ships OpenSSL 1.1.1 — manylinux2014 ships 1.0.2k
          # which openssl-sys 0.9.115+ rejects. CARGO_TARGET_*_LINKER points cargo at the
          # in-image gcc instead of looking for triple-prefixed binaries that aren't there.
          cibw-before-build-linux: "dnf install -y openssl-devel pkgconfig && pip install maturin uv && source ~/.cargo/env"
          cibw-environment: 'MATURIN_BUILD_ARGS="--compatibility manylinux_2_28" CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=gcc CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=gcc CC_x86_64_unknown_linux_gnu=gcc CC_aarch64_unknown_linux_gnu=gcc CXX_x86_64_unknown_linux_gnu=g++ CXX_aarch64_unknown_linux_gnu=g++'
          # cibw's bundled rustup-init on Windows defaults host triple to gnu, and
          # rust-toolchain.toml channel="1.95" resolves against that gnu host. Install
          # 1.95-x86_64-pc-windows-msvc explicitly AND set RUSTUP_TOOLCHAIN env var so
          # cargo bypasses the toolchain.toml override (env var > toml in rustup precedence).
          cibw-before-build-windows: "rustup install 1.95-x86_64-pc-windows-msvc --profile minimal --component rust-src --component rustfmt --component clippy && rustup target add x86_64-pc-windows-msvc --toolchain 1.95-x86_64-pc-windows-msvc && pip install maturin uv && set PATH=%USERPROFILE%\\.cargo\\bin;%PATH%"
          cibw-environment-windows: 'MATURIN_BUILD_ARGS="--target x86_64-pc-windows-msvc" RUSTUP_TOOLCHAIN="1.95-x86_64-pc-windows-msvc"'

      - name: Upload wheels
        uses: actions/upload-artifact@v7.0.1
        with:
          name: python-wheels-${{ matrix.os }}
          path: wheelhouse/*.whl
          retention-days: 14

  python-sdist:
    name: Build Python sdist
    # The sdist compiles on the consumer's machine, so its Cargo.toml must carry
    # registry version-deps, not workspace paths. build-python-sdist@v1 rewrites
    # them (--require-registry) then runs maturin sdist, so the core crates must be
    # on crates.io first. The status guard lets it run when publish-crates is
    # skipped, blocking only on its actual failure.
    needs: [prepare, check-pypi, publish-crates]
    if: ${{ !cancelled() && needs.publish-crates.result != 'failure' && needs.prepare.outputs.release_python == 'true' && (needs.check-pypi.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1
      - uses: actions/setup-python@v6.2.0
        with:
          python-version: "3.13"

      - uses: kreuzberg-dev/actions/build-python-sdist@v1
        with:
          manifest-path: crates/kreuzberg-py/Cargo.toml
          output-dir: dist

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: python-sdist
          path: dist/*.tar.gz
          retention-days: 14

  node-bindings:
    name: Build Node bindings (${{ matrix.target }})
    needs: [prepare, check-npm]
    if: ${{ needs.prepare.outputs.release_node == 'true' && (needs.check-npm.outputs.node_exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
          - { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
          - { os: macos-latest, target: aarch64-apple-darwin }
          - { os: windows-latest, target: x86_64-pc-windows-msvc }
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1
        with:
          target: ${{ matrix.target }}

      - uses: kreuzberg-dev/actions/setup-node-workspace@v1
        with:
          node-version: "24"

      - name: Build NAPI binding
        uses: kreuzberg-dev/actions/build-node-napi@v1
        with:
          crate-dir: crates/kreuzberg-node
          build-command: pnpm exec napi build --release --target ${{ matrix.target }} --platform

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: node-bindings-${{ matrix.target }}
          path: crates/kreuzberg-node/*.node
          retention-days: 14

  wasm-bindings:
    name: Build WASM package
    needs: [prepare, check-npm]
    if: ${{ needs.prepare.outputs.release_wasm == 'true' && (needs.check-npm.outputs.wasm_exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    # WASM cold-build of kreuzberg-wasm (no-ORT feature set) runs ~12-15 min.
    # GHA cancelled this job at the ~12-min mark with "runner shutdown signal"
    # in three consecutive runs (preemption, not timeout). Explicit timeout
    # bounds the budget; a dedicated cache-key-prefix keeps the WASM target
    # cache warm across runs and brings rebuilds under 5 min.
    timeout-minutes: 90
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1
        with:
          target: wasm32-unknown-unknown
          cache-key-prefix: wasm-bindings

      - uses: kreuzberg-dev/actions/install-wasi-sdk@v1

      - uses: kreuzberg-dev/actions/setup-node-workspace@v1
        with:
          node-version: "24"

      - uses: kreuzberg-dev/actions/build-wasm-package@v1
        with:
          crate-dir: crates/kreuzberg-wasm
          pack-tarball: "true"

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: wasm-bundle
          path: |
            crates/kreuzberg-wasm/dist/**
            crates/kreuzberg-wasm/dist-node/**
            crates/kreuzberg-wasm/dist-web/**
          retention-days: 14

  ruby-gem:
    name: Build Ruby gem (${{ matrix.label }})
    # build-ruby-gem@v1 rewrites workspace path-deps to registry version-deps
    # (--require-registry) before compiling, so the core crates must be on
    # crates.io first: order after publish-crates. The status guard lets the build
    # still run when publish-crates is skipped (crates already published), blocking
    # only on its actual failure.
    needs: [prepare, check-rubygems, publish-crates]
    if: ${{ !cancelled() && needs.publish-crates.result != 'failure' && needs.prepare.outputs.release_ruby == 'true' && (needs.check-rubygems.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.os }}
    env:
      RB_SYS_CARGO_PROFILE: release
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest, label: linux-x86_64 }
          - { os: ubuntu-24.04-arm, label: linux-aarch64 }
          - { os: macos-latest, label: macos-arm64 }
          # Ruby on Windows is MinGW (rb-sys → x86_64-pc-windows-gnu) but ort-sys
          # only ships msvc prebuilts; the ABIs are incompatible. Skip until ORT
          # publishes mingw binaries or kreuzberg moves to ort-tract on Ruby/Win.
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1

      - uses: ruby/setup-ruby@v1.309.0
        with:
          ruby-version: "3.3"
          bundler-cache: false

      - uses: kreuzberg-dev/actions/build-ruby-gem@v1
        with:
          package-dir: packages/ruby

      - name: Package gem
        # Inline: rake build emits the .gem under pkg/; no shared action wraps it.
        working-directory: packages/ruby
        run: bundle exec rake build
        shell: bash

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: rubygems-${{ matrix.label }}
          path: packages/ruby/pkg/*.gem
          retention-days: 14

  php-extension:
    name: Build PHP extension (php${{ matrix.php }} ${{ matrix.platform.label }})
    # build-php-extension@v1 rewrites path-deps to registry versions before
    # building; the core crates must be published first. The status guard lets the
    # build run when publish-crates is skipped, blocking only on its actual failure.
    needs: [prepare, check-packagist, publish-crates]
    if: ${{ !cancelled() && needs.publish-crates.result != 'failure' && needs.prepare.outputs.release_php == 'true' && (needs.check-packagist.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.platform.os }}
    timeout-minutes: 60
    strategy:
      fail-fast: false
      matrix:
        php: ["8.2", "8.3", "8.4"]
        platform:
          - { os: ubuntu-latest, label: linux-x86_64, target: x86_64-unknown-linux-gnu }
          - { os: ubuntu-24.04-arm, label: linux-arm64, target: aarch64-unknown-linux-gnu }
          - { os: macos-latest, label: macos-arm64, target: aarch64-apple-darwin }
          - { os: windows-latest, label: windows-x86_64, target: x86_64-pc-windows-msvc }
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-php@v1
        with:
          php-version: ${{ matrix.php }}

      - uses: kreuzberg-dev/actions/setup-rust@v1
        with:
          target: ${{ matrix.platform.target }}

      - uses: kreuzberg-dev/actions/build-php-extension@v1
        with:
          crate-name: kreuzberg-php
          lib-name: kreuzberg_php
          php-version: ${{ matrix.php }}
          php-ts: nts

      - uses: kreuzberg-dev/actions/install-alef@v1

      - uses: kreuzberg-dev/actions/package-php-pie@v1
        with:
          php-version: ${{ matrix.php }}
          php-ts: nts
          target: ${{ matrix.platform.target }}
          windows-compiler: ${{ contains(matrix.platform.target, 'windows') && 'vs17' || '' }}
          version: ${{ needs.prepare.outputs.version }}
          output-dir: dist/php-package

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: php-package-${{ matrix.platform.label }}-php${{ matrix.php }}
          path: |
            dist/php-package/php_*.tgz
            dist/php-package/php_*.tgz.sha256
            dist/php-package/php_*.zip
            dist/php-package/php_*.zip.sha256
          retention-days: 14

  cli-binaries:
    name: Build CLI binary (${{ matrix.target }})
    needs: [prepare, check-cli]
    if: ${{ needs.prepare.outputs.release_cli == 'true' && (needs.check-cli.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
          - { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
          - { os: macos-latest, target: aarch64-apple-darwin }
          - { os: windows-latest, target: x86_64-pc-windows-msvc }
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1
        with:
          target: ${{ matrix.target }}

      - uses: kreuzberg-dev/actions/build-rust-cli@v1
        id: build
        with:
          package-name: kreuzberg-cli
          binary-name: kreuzberg
          target: ${{ matrix.target }}

      - name: Package CLI artifact
        # Inline: archive layout (.tar.gz on Unix, .zip on Windows) varies per
        # OS in a way no shared action covers; tiny and self-contained.
        env:
          BINARY_PATH: ${{ steps.build.outputs.binary-path }}
          TARGET: ${{ matrix.target }}
        shell: bash
        run: |
          STAGE="kreuzberg-cli-${TARGET}"
          mkdir -p "$STAGE"
          if [[ "$RUNNER_OS" == "Windows" ]]; then
            cp "$BINARY_PATH" "$STAGE/kreuzberg.exe"
            7z a "${STAGE}.zip" "$STAGE" >/dev/null
          else
            cp "$BINARY_PATH" "$STAGE/kreuzberg"
            tar -czf "${STAGE}.tar.gz" "$STAGE"
          fi

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: cli-${{ matrix.target }}
          path: |
            kreuzberg-cli-${{ matrix.target }}.tar.gz
            kreuzberg-cli-${{ matrix.target }}.zip
          if-no-files-found: ignore
          retention-days: 14

  cli-binaries-musl:
    # Alpine container build: ort-sys ships no musl prebuilts, so we use Alpine's
    # system onnxruntime-dev (apk) plus -C target-feature=-crt-static for cdylib.
    # Each Dockerfile is single-arch (FROM alpine:3.21); arch-matched runners
    # produce arch-matched binaries without qemu emulation.
    name: Build CLI binary (${{ matrix.target }})
    needs: [prepare, check-cli]
    if: ${{ needs.prepare.outputs.release_cli == 'true' && (needs.check-cli.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest, target: x86_64-unknown-linux-musl }
          - { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl }
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - name: Build CLI via Alpine Docker
        run: |
          docker build -f docker/Dockerfile.musl-build \
            --output type=local,dest=./build-output .

      - name: Package CLI artifact (with bundled lib/)
        env:
          TARGET: ${{ matrix.target }}
        shell: bash
        run: |
          STAGE="kreuzberg-cli-${TARGET}"
          mkdir -p "$STAGE/lib"
          cp build-output/kreuzberg "$STAGE/kreuzberg"
          cp build-output/kreuzberg.bin "$STAGE/kreuzberg.bin"
          cp -a build-output/lib/. "$STAGE/lib/"
          chmod +x "$STAGE/kreuzberg"
          tar -czf "${STAGE}.tar.gz" "$STAGE"

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: cli-${{ matrix.target }}
          path: kreuzberg-cli-${{ matrix.target }}.tar.gz
          if-no-files-found: error
          retention-days: 14

  go-ffi-libraries:
    name: Build Go FFI library (${{ matrix.platform }})
    needs: [prepare, check-go]
    if: ${{ needs.prepare.outputs.release_go == 'true' && (needs.check-go.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest, platform: linux-x86_64, target: x86_64-unknown-linux-gnu }
          - { os: ubuntu-24.04-arm, platform: linux-aarch64, target: aarch64-unknown-linux-gnu }
          - { os: macos-latest, platform: macos-arm64, target: aarch64-apple-darwin }
          - { os: windows-latest, platform: windows-x86_64, target: x86_64-pc-windows-msvc }
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1
        with:
          target: ${{ matrix.target }}

      - uses: kreuzberg-dev/actions/build-go-ffi@v1
        with:
          target: ${{ matrix.target }}
          crate-name: kreuzberg-ffi
          lib-name: kreuzberg_ffi
          header-path: crates/kreuzberg-ffi/include/kreuzberg.h
          archive-name: kreuzberg-go-${{ matrix.platform }}.tar.gz
          output-dir: dist/go-ffi

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: go-ffi-${{ matrix.platform }}
          path: dist/go-ffi/*.tar.gz
          if-no-files-found: error
          retention-days: 14

  c-ffi-libraries:
    name: Build C FFI distribution (${{ matrix.platform }})
    needs: [prepare, check-c-ffi]
    if: ${{ needs.prepare.outputs.release_c_ffi == 'true' && (needs.check-c-ffi.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest, platform: linux-x86_64, target: x86_64-unknown-linux-gnu }
          - { os: ubuntu-24.04-arm, platform: linux-aarch64, target: aarch64-unknown-linux-gnu }
          - { os: macos-latest, platform: macos-arm64, target: aarch64-apple-darwin }
          - { os: windows-latest, platform: windows-x86_64, target: x86_64-pc-windows-msvc }
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1
        with:
          target: ${{ matrix.target }}

      - uses: kreuzberg-dev/actions/build-rust-ffi@v1
        with:
          crate-name: kreuzberg-ffi
          target: ${{ matrix.target }}

      - name: Stage C FFI distribution
        # Inline: bundle library + header + LICENSE into a single tarball
        # named to match upload-c-ffi-release's expected asset prefix.
        env:
          TARGET: ${{ matrix.target }}
          PLATFORM: ${{ matrix.platform }}
        shell: bash
        run: |
          STAGE="kreuzberg-ffi-${PLATFORM}"
          mkdir -p "$STAGE/lib" "$STAGE/include"
          find "target/${TARGET}/release" -maxdepth 1 -type f \
            \( -name 'libkreuzberg_ffi.*' -o -name 'kreuzberg_ffi.dll' \) \
            -exec cp {} "$STAGE/lib/" \;
          cp crates/kreuzberg-ffi/include/kreuzberg.h "$STAGE/include/"
          cp LICENSE "$STAGE/" 2>/dev/null || true
          mkdir -p dist/c-ffi
          tar -czf "dist/c-ffi/${STAGE}.tar.gz" "$STAGE"

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: c-ffi-${{ matrix.platform }}
          path: dist/c-ffi/*.tar.gz
          if-no-files-found: error
          retention-days: 14

  java-natives:
    name: Build Java natives (${{ matrix.classifier }})
    needs: [prepare, check-maven]
    if: ${{ needs.prepare.outputs.release_java == 'true' && (needs.check-maven.outputs.java_exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest, classifier: linux-x86_64, target: x86_64-unknown-linux-gnu }
          - { os: ubuntu-24.04-arm, classifier: linux-aarch64, target: aarch64-unknown-linux-gnu }
          - { os: macos-latest, classifier: macos-arm64, target: aarch64-apple-darwin }
          - { os: windows-latest, classifier: windows-x86_64, target: x86_64-pc-windows-msvc }
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1
        with:
          target: ${{ matrix.target }}

      - uses: kreuzberg-dev/actions/build-java-natives@v1
        with:
          target: ${{ matrix.target }}
          crate-name: kreuzberg-ffi
          lib-name: kreuzberg_ffi
          classifier: ${{ matrix.classifier }}

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: java-natives-${{ matrix.classifier }}
          path: dist/java-natives
          if-no-files-found: error
          retention-days: 14

  java-natives-musl:
    # Alpine container build via Dockerfile.musl-ffi (system onnxruntime-dev).
    name: Build Java natives (linux-musl-${{ matrix.arch }})
    needs: [prepare, check-maven]
    if: ${{ needs.prepare.outputs.release_java == 'true' && (needs.check-maven.outputs.java_exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest, arch: x86_64, classifier: linux-musl-x86_64 }
          - { os: ubuntu-24.04-arm, arch: aarch64, classifier: linux-musl-aarch64 }
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - name: Build kreuzberg-ffi via Alpine Docker
        run: |
          docker build -f docker/Dockerfile.musl-ffi \
            --output type=local,dest=./build-output .

      - name: Stage native lib for Java classifier layout
        env:
          CLASSIFIER: ${{ matrix.classifier }}
        run: |
          mkdir -p "dist/java-natives/${CLASSIFIER}"
          cp build-output/libkreuzberg_ffi.so "dist/java-natives/${CLASSIFIER}/libkreuzberg_ffi.so"

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: java-natives-${{ matrix.classifier }}
          path: dist/java-natives
          if-no-files-found: error
          retention-days: 14

  kotlin-android-natives:
    name: Build Kotlin Android natives (${{ matrix.abi }})
    needs: [prepare, check-maven]
    if: ${{ needs.prepare.outputs.release_kotlin == 'true' && (needs.check-maven.outputs.kotlin_exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        include:
          - { abi: arm64-v8a }
          - { abi: x86_64 }
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0
          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1
      - uses: kreuzberg-dev/actions/build-android-natives@v1
        with:
          crate-name: kreuzberg-ffi
          abis: ${{ matrix.abi }}
      - uses: actions/upload-artifact@v7.0.1
        with:
          name: kotlin-android-${{ matrix.abi }}
          path: dist/android-natives
          if-no-files-found: error
          retention-days: 14

  csharp-natives:
    name: Build C# natives (${{ matrix.rid }})
    needs: [prepare, check-nuget]
    if: ${{ needs.prepare.outputs.release_csharp == 'true' && (needs.check-nuget.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest, rid: linux-x64, target: x86_64-unknown-linux-gnu }
          - { os: ubuntu-24.04-arm, rid: linux-arm64, target: aarch64-unknown-linux-gnu }
          - { os: macos-latest, rid: osx-arm64, target: aarch64-apple-darwin }
          - { os: windows-latest, rid: win-x64, target: x86_64-pc-windows-msvc }
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1
        with:
          target: ${{ matrix.target }}

      - uses: kreuzberg-dev/actions/build-csharp-natives@v1
        with:
          target: ${{ matrix.target }}
          crate-name: kreuzberg-ffi
          lib-name: kreuzberg_ffi
          rid: ${{ matrix.rid }}

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: csharp-natives-${{ matrix.rid }}
          path: dist/csharp-natives
          if-no-files-found: error
          retention-days: 14

  csharp-natives-musl:
    # Alpine container build via Dockerfile.musl-ffi (system onnxruntime-dev).
    name: Build C# natives (linux-musl-${{ matrix.arch }})
    needs: [prepare, check-nuget]
    if: ${{ needs.prepare.outputs.release_csharp == 'true' && (needs.check-nuget.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest, arch: x64, rid: linux-musl-x64 }
          - { os: ubuntu-24.04-arm, arch: arm64, rid: linux-musl-arm64 }
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - name: Build kreuzberg-ffi via Alpine Docker
        run: |
          docker build -f docker/Dockerfile.musl-ffi \
            --output type=local,dest=./build-output .

      - name: Stage native lib for NuGet RID layout
        env:
          RID: ${{ matrix.rid }}
        run: |
          mkdir -p "dist/csharp-natives/runtimes/${RID}/native"
          cp build-output/libkreuzberg_ffi.so "dist/csharp-natives/runtimes/${RID}/native/libkreuzberg_ffi.so"

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: csharp-natives-${{ matrix.rid }}
          path: dist/csharp-natives
          if-no-files-found: error
          retention-days: 14

  elixir-natives:
    name: Build Elixir NIF (${{ matrix.label }})
    # build-elixir-natives@v1 rewrites path-deps to registry versions before
    # building (so the hex source fallback resolves); needs published crates. The
    # status guard lets the build run when publish-crates is skipped, blocking only
    # on its actual failure.
    needs: [prepare, check-elixir-release, publish-crates]
    if: ${{ !cancelled() && needs.publish-crates.result != 'failure' && needs.prepare.outputs.release_elixir == 'true' && (needs.check-elixir-release.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.os }}
    timeout-minutes: 90
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest]
        nif: ["2.16", "2.17"]
        include:
          - os: ubuntu-latest
            label: linux-x86_64
            target: x86_64-unknown-linux-gnu
          - os: ubuntu-24.04-arm
            label: linux-aarch64
            target: aarch64-unknown-linux-gnu
          - os: macos-latest
            label: macos-arm64
            target: aarch64-apple-darwin
          - os: windows-latest
            label: windows-x86_64
            target: x86_64-pc-windows-msvc
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - uses: erlef/setup-beam@v1.24.0
        with:
          elixir-version: "1.17"
          otp-version: "27"

      - uses: kreuzberg-dev/actions/setup-rust@v1
        with:
          target: ${{ matrix.target }}

      - uses: kreuzberg-dev/actions/build-elixir-natives@v1
        with:
          target: ${{ matrix.target }}
          nif-crate-name: kreuzberg_nif
          nif-crate-path: packages/elixir/native/kreuzberg_nif
          package-dir: packages/elixir
          nif-version: ${{ needs.prepare.outputs.version }}
          nif-api-version: ${{ matrix.nif }}

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: elixir-${{ matrix.label }}-nif-${{ matrix.nif }}
          path: dist/elixir-natives/*.tar.gz
          if-no-files-found: error
          retention-days: 14

  elixir-natives-musl:
    # Alpine container build via Dockerfile.musl-rustler (system onnxruntime-dev).
    # Replicates RustlerPrecompiled tarball naming the build-elixir-natives action
    # uses for non-musl variants: lib{nif}-v{ver}-nif-{api}-{target}.so.tar.gz + sha256.
    # Like elixir-natives, needs published crates for the hex source fallback.
    name: Build Elixir NIF (linux-musl-${{ matrix.arch }} nif-${{ matrix.nif }})
    needs: [prepare, check-elixir-release, publish-crates]
    if: ${{ !cancelled() && needs.publish-crates.result != 'failure' && needs.prepare.outputs.release_elixir == 'true' && (needs.check-elixir-release.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.os }}
    timeout-minutes: 90
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, ubuntu-24.04-arm]
        nif: ["2.16", "2.17"]
        include:
          - os: ubuntu-latest
            arch: x86_64
            target: x86_64-unknown-linux-musl
          - os: ubuntu-24.04-arm
            arch: aarch64
            target: aarch64-unknown-linux-musl
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - name: Build kreuzberg_nif via Alpine Docker
        run: |
          docker build -f docker/Dockerfile.musl-rustler \
            --output type=local,dest=./build-output .

      - name: Package NIF as RustlerPrecompiled tarball
        env:
          TARGET: ${{ matrix.target }}
          NIF_API: ${{ matrix.nif }}
          NIF_VERSION: ${{ needs.prepare.outputs.version }}
        shell: bash
        run: |
          set -euo pipefail
          mkdir -p dist/elixir-natives staging
          cp build-output/libkreuzberg_nif.so staging/libkreuzberg_nif.so
          TARBALL="libkreuzberg_nif-v${NIF_VERSION}-nif-${NIF_API}-${TARGET}.so.tar.gz"
          tar -czf "dist/elixir-natives/${TARBALL}" -C staging libkreuzberg_nif.so
          ( cd dist/elixir-natives && sha256sum "${TARBALL}" >"${TARBALL}.sha256" )

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: elixir-musl-${{ matrix.arch }}-nif-${{ matrix.nif }}
          path: dist/elixir-natives/*.tar.gz
          if-no-files-found: error
          retention-days: 14

  dart-android-natives:
    name: Build Dart Android natives (${{ matrix.abi }})
    needs: [prepare, check-pub]
    if: ${{ needs.prepare.outputs.release_dart == 'true' && (needs.check-pub.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        include:
          - { abi: arm64-v8a }
          - { abi: x86_64 }
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0
          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1
      - uses: kreuzberg-dev/actions/build-android-natives@v1
        with:
          crate-name: kreuzberg-dart
          abis: ${{ matrix.abi }}
      - uses: actions/upload-artifact@v7.0.1
        with:
          name: dart-android-${{ matrix.abi }}
          path: dist/android-natives
          if-no-files-found: error
          retention-days: 14

  dart-ios-xcframework:
    name: Build Dart iOS XCFramework
    needs: [prepare, check-pub]
    if: ${{ needs.prepare.outputs.release_dart == 'true' && (needs.check-pub.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0
          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1
      - uses: kreuzberg-dev/actions/build-ios-xcframework@v1
        with:
          crate-name: kreuzberg-dart
      - uses: actions/upload-artifact@v7.0.1
        with:
          name: dart-ios-xcframework
          path: dist/ios-xcframework
          if-no-files-found: error
          retention-days: 14

  dart-server-natives:
    name: Build Dart server native (${{ matrix.blob }})
    needs: [prepare, check-pub]
    if: ${{ needs.prepare.outputs.release_dart == 'true' && (needs.check-pub.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - {
              os: ubuntu-latest,
              target: x86_64-unknown-linux-gnu,
              blob: linux-x86_64,
              lib: libkreuzberg_dart.so,
            }
          - {
              os: ubuntu-24.04-arm,
              target: aarch64-unknown-linux-gnu,
              blob: linux-aarch64,
              lib: libkreuzberg_dart.so,
            }
          - {
              os: macos-latest,
              target: aarch64-apple-darwin,
              blob: macos-arm64,
              lib: libkreuzberg_dart.dylib,
            }
          - {
              os: windows-latest,
              target: x86_64-pc-windows-msvc,
              blob: windows-x86_64,
              lib: kreuzberg_dart.dll,
            }
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0
          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1
        with:
          target: ${{ matrix.target }}
      - name: Build Dart server native
        run: cargo build -p kreuzberg-dart --release --target ${{ matrix.target }}
        shell: bash
      - name: Stage native library
        shell: bash
        env:
          TARGET: ${{ matrix.target }}
          LIB: ${{ matrix.lib }}
        run: |
          mkdir -p dist/server-natives
          cp "target/${TARGET}/release/${LIB}" "dist/server-natives/${LIB}"
      - uses: actions/upload-artifact@v7.0.1
        with:
          name: dart-server-${{ matrix.blob }}
          path: dist/server-natives
          if-no-files-found: error
          retention-days: 14

  dart-package:
    name: Build Dart package
    needs: [prepare, check-pub]
    if: ${{ needs.prepare.outputs.release_dart == 'true' && needs.check-pub.outputs.exists != 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1

      - uses: kreuzberg-dev/actions/build-dart-package@v1
        with:
          # flutter_rust_bridge.yaml lives in packages/dart/rust/, where rust_root: .
          package-dir: packages/dart/rust
          crate-name: kreuzberg-dart

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: dart-package
          path: packages/dart
          retention-days: 14

  swift-artifactbundle:
    name: Build Swift artifact bundle
    needs: prepare
    if: ${{ needs.prepare.outputs.release_swift == 'true' }}
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0
          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1
      - uses: kreuzberg-dev/actions/build-swift-artifactbundle@v1
        id: bundle
        with:
          crate-name: kreuzberg-swift
          artifact-name: KreuzbergSwift
          include-macos-x86_64: "false"
          include-ios-x86_64: "false"
      - uses: actions/upload-artifact@v7.0.1
        with:
          name: swift-artifactbundle
          path: ${{ steps.bundle.outputs.bundle-zip }}
          if-no-files-found: error
          retention-days: 14

  swift-package:
    name: Build Swift package
    needs: prepare
    if: ${{ needs.prepare.outputs.release_swift == 'true' }}
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1

      - uses: kreuzberg-dev/actions/build-swift-package@v1
        with:
          package-dir: packages/swift
          crate-name: kreuzberg-swift

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: swift-package
          path: packages/swift
          retention-days: 14

  zig-package:
    name: Build Zig package (smoke check)
    needs: prepare
    if: ${{ needs.prepare.outputs.release_zig == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}
          fetch-depth: 0

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1

      - uses: kreuzberg-dev/actions/build-zig-package@v1
        with:
          ffi-crate: kreuzberg-ffi
          package-dir: packages/zig

  # ─── GitHub Release asset uploads ─────────────────────────────────────

  upload-cli-release:
    name: Upload CLI binaries to GitHub Release
    needs: [prepare, cli-binaries]
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.cli-binaries.result == 'success' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v8.0.1
        with:
          pattern: cli-*
          path: dist/cli
          merge-multiple: true

      - uses: kreuzberg-dev/actions/upload-release-assets@v1
        with:
          tag: ${{ needs.prepare.outputs.tag }}
          working-directory: dist/cli
          dry-run: ${{ needs.prepare.outputs.dry_run }}
          assets: |
            kreuzberg-cli-*.tar.gz
            kreuzberg-cli-*.zip

  upload-go-release:
    name: Upload Go FFI archives to GitHub Release
    needs: [prepare, go-ffi-libraries]
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.go-ffi-libraries.result == 'success' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v8.0.1
        with:
          pattern: go-ffi-*
          path: dist/go-ffi
          merge-multiple: true

      - uses: kreuzberg-dev/actions/upload-release-assets@v1
        with:
          tag: ${{ needs.prepare.outputs.tag }}
          working-directory: dist/go-ffi
          dry-run: ${{ needs.prepare.outputs.dry_run }}
          assets: kreuzberg-go-*.tar.gz

  upload-c-ffi-release:
    name: Upload C FFI archives to GitHub Release
    needs: [prepare, c-ffi-libraries]
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.c-ffi-libraries.result == 'success' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v8.0.1
        with:
          pattern: c-ffi-*
          path: dist/c-ffi
          merge-multiple: true

      - uses: kreuzberg-dev/actions/upload-release-assets@v1
        with:
          tag: ${{ needs.prepare.outputs.tag }}
          working-directory: dist/c-ffi
          dry-run: ${{ needs.prepare.outputs.dry_run }}
          assets: kreuzberg-ffi-*.tar.gz

  upload-elixir-release:
    name: Upload Elixir NIF archives to GitHub Release
    needs: [prepare, elixir-natives, elixir-natives-musl]
    if: ${{ needs.prepare.outputs.is_tag == 'true' && (needs.elixir-natives.result == 'success' || needs.elixir-natives-musl.result == 'success') }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v8.0.1
        with:
          pattern: elixir-*
          path: dist/elixir
          merge-multiple: true

      - uses: kreuzberg-dev/actions/upload-release-assets@v1
        with:
          tag: ${{ needs.prepare.outputs.tag }}
          working-directory: dist/elixir
          dry-run: ${{ needs.prepare.outputs.dry_run }}
          assets: libkreuzberg_nif-*.tar.gz

  upload-php-pie-release:
    name: Upload PHP PIE archives to GitHub Release
    needs: [prepare, php-extension]
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.php-extension.result == 'success' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v8.0.1
        with:
          pattern: php-package-*
          path: dist/php-package
          merge-multiple: true

      - uses: kreuzberg-dev/actions/upload-release-assets@v1
        with:
          tag: ${{ needs.prepare.outputs.tag }}
          working-directory: dist/php-package
          dry-run: ${{ needs.prepare.outputs.dry_run }}
          assets: |
            php_*.tgz
            php_*.tgz.sha256
            php_*.zip
            php_*.zip.sha256

  upload-swift-bundle:
    name: Upload Swift artifact bundle to GitHub Release
    needs: [prepare, swift-artifactbundle]
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.swift-artifactbundle.result == 'success' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v8.0.1
        with:
          name: swift-artifactbundle
          path: dist/swift-bundle

      - uses: kreuzberg-dev/actions/upload-release-assets@v1
        with:
          tag: ${{ needs.prepare.outputs.tag }}
          working-directory: dist/swift-bundle
          dry-run: ${{ needs.prepare.outputs.dry_run }}
          assets: "*.zip"

  # Verifies every expected release asset is present on the GitHub release
  # before the publish-* fan-out depends on those URLs.
  verify-assets:
    name: Verify release assets
    needs:
      - prepare
      - upload-cli-release
      - upload-go-release
      - upload-c-ffi-release
      - upload-elixir-release
      - upload-php-pie-release
    if: ${{ always() && needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.dry_run != 'true' && !contains(needs.*.result, 'failure') }}
    runs-on: ubuntu-latest
    steps:
      - uses: kreuzberg-dev/actions/verify-release-assets@v1
        with:
          tag: ${{ needs.prepare.outputs.tag }}
          expected-assets: |
            # CLI
            kreuzberg-cli-*.tar.gz
            kreuzberg-cli-*.zip
            # Go FFI
            kreuzberg-go-*.tar.gz
            # C FFI
            kreuzberg-ffi-*.tar.gz
            # Elixir NIF
            libkreuzberg_nif-*.tar.gz

  # ─── Publish jobs ─────────────────────────────────────────────────────

  publish-crates:
    name: Publish crates.io packages
    needs: [prepare, validate-versions, check-cratesio]
    if: ${{ needs.prepare.outputs.release_crates == 'true' && needs.prepare.outputs.is_tag == 'true' && (needs.check-cratesio.outputs.all_exist != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.ref }}

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1

      - uses: kreuzberg-dev/actions/publish-crates@v1
        with:
          crates: kreuzberg-tesseract kreuzberg-paddle-ocr kreuzberg kreuzberg-cli
          version: ${{ needs.prepare.outputs.version }}
          dry-run: ${{ needs.prepare.outputs.dry_run }}

  publish-pypi:
    name: Publish to PyPI
    needs: [prepare, python-wheels, python-sdist, check-pypi]
    if: ${{ needs.prepare.outputs.release_python == 'true' && needs.prepare.outputs.is_tag == 'true' && (needs.check-pypi.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/download-artifact@v8.0.1
        with:
          pattern: python-wheels-*
          path: dist
          merge-multiple: true
      - uses: actions/download-artifact@v8.0.1
        with:
          name: python-sdist
          path: dist

      - name: Verify Python wheel package contents
        uses: kreuzberg-dev/actions/verify-package-contents@v1
        with:
          language: python
          artifact-path: dist
          package-name: kreuzberg
          # Per-archive patterns don't apply uniformly: wheels carry the
          # native .so/.dylib/.dll but sdist doesn't; sdist carries the
          # source tree the wheels strip. The basic `python` allowlist also
          # only matches wheels. strict=false reports diffs without blocking.
          strict: "false"

      - uses: kreuzberg-dev/actions/publish-pypi@v1
        with:
          packages-dir: dist
          dry-run: ${{ needs.prepare.outputs.dry_run }}

  publish-rubygems:
    name: Publish Ruby gems
    needs: [prepare, ruby-gem, check-rubygems]
    if: ${{ needs.prepare.outputs.release_ruby == 'true' && needs.prepare.outputs.is_tag == 'true' && (needs.check-rubygems.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/download-artifact@v8.0.1
        with:
          pattern: rubygems-*
          path: dist
          merge-multiple: true

      - uses: ruby/setup-ruby@v1.309.0
        with:
          ruby-version: "3.3"
          bundler-cache: false

      - uses: rubygems/configure-rubygems-credentials@v2.0.0

      - name: Verify Ruby gem package contents
        uses: kreuzberg-dev/actions/verify-package-contents@v1
        with:
          language: ruby
          artifact-path: dist
          package-name: kreuzberg

      - uses: kreuzberg-dev/actions/publish-rubygems@v1
        with:
          gems-dir: dist
          dry-run: ${{ needs.prepare.outputs.dry_run }}

  publish-node:
    name: Publish Node packages
    needs: [prepare, node-bindings, check-npm]
    if: ${{ needs.prepare.outputs.release_node == 'true' && needs.prepare.outputs.is_tag == 'true' && (needs.check-npm.outputs.node_exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.ref }}

          submodules: recursive
      - uses: actions/download-artifact@v8.0.1
        with:
          pattern: node-bindings-*
          path: crates/kreuzberg-node/artifacts
          merge-multiple: true

      - uses: kreuzberg-dev/actions/setup-node-workspace@v1
        with:
          node-version: "24"
          registry-url: "https://registry.npmjs.org/"

      - name: Stage platform packages with napi
        # Inline: napi artifacts → per-platform npm/ subpackages. Equivalent
        # of `napi create-npm-dirs && napi artifacts`; trivially small.
        working-directory: crates/kreuzberg-node
        run: |
          pnpm exec napi create-npm-dirs
          pnpm exec napi artifacts --output-dir artifacts --npm-dir npm
        shell: bash

      - name: Pack platform sub-packages
        # `publish-npm packages-dir` mode expects *.tgz inputs; napi only
        # stages the per-platform directories. Run `npm pack` in each so
        # the publish action has tarballs to upload (or dry-run).
        working-directory: crates/kreuzberg-node
        shell: bash
        run: |
          for d in npm/*/; do
            [ -d "$d" ] || continue
            (cd "$d" && npm pack)
          done

      - name: Verify Node platform sub-package contents
        uses: kreuzberg-dev/actions/verify-package-contents@v1
        with:
          language: node-platform
          artifact-path: crates/kreuzberg-node/npm
          package-name: "@kreuzberg/node"
          # NAPI scaffolds stub package.json dirs for every platform in
          # crates/kreuzberg-node/package.json's `napi.triples.additional`,
          # not just the targets we build (e.g. darwin-x64, win32-arm64-msvc
          # are listed but not built — their npm/*.tgz contains no .node).
          # Use strict=false so verify reports stubs without failing the job.
          strict: "false"

      - name: Publish platform sub-packages
        uses: kreuzberg-dev/actions/publish-npm@v1
        with:
          packages-dir: crates/kreuzberg-node/npm
          npm-tag: ${{ needs.prepare.outputs.npm_tag }}
          dry-run: ${{ needs.prepare.outputs.dry_run }}
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

      - name: Publish main @kreuzberg/node package
        uses: kreuzberg-dev/actions/publish-npm@v1
        with:
          package-dir: crates/kreuzberg-node
          npm-tag: ${{ needs.prepare.outputs.npm_tag }}
          dry-run: ${{ needs.prepare.outputs.dry_run }}
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

  publish-wasm:
    name: Publish WASM package
    needs: [prepare, wasm-bindings, check-npm]
    if: ${{ needs.prepare.outputs.release_wasm == 'true' && needs.prepare.outputs.is_tag == 'true' && (needs.check-npm.outputs.wasm_exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.ref }}

          submodules: recursive
      - uses: actions/download-artifact@v8.0.1
        with:
          name: wasm-bundle
          path: crates/kreuzberg-wasm

      - uses: kreuzberg-dev/actions/setup-node-workspace@v1
        with:
          node-version: "24"
          registry-url: "https://registry.npmjs.org/"

      - uses: kreuzberg-dev/actions/publish-npm@v1
        with:
          package-dir: crates/kreuzberg-wasm
          npm-tag: ${{ needs.prepare.outputs.npm_tag }}
          dry-run: ${{ needs.prepare.outputs.dry_run }}
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

  publish-packagist:
    name: Trigger Packagist update
    needs: [prepare, upload-php-pie-release, check-packagist]
    if: ${{ needs.prepare.outputs.release_php == 'true' && needs.prepare.outputs.is_tag == 'true' && (needs.check-packagist.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    steps:
      - uses: kreuzberg-dev/actions/publish-packagist@v1
        with:
          packagist-username: kreuzberg-dev
          package-name: kreuzberg/kreuzberg
          version: ${{ needs.prepare.outputs.version }}
          repository-url: https://github.com/kreuzberg-dev/kreuzberg
          dry-run: ${{ needs.prepare.outputs.dry_run }}
        env:
          PACKAGIST_API_TOKEN: ${{ secrets.PACKAGIST_API_TOKEN }}

  publish-maven:
    name: Publish Maven (Java) package
    needs: [prepare, java-natives, java-natives-musl, check-maven]
    if: ${{ needs.prepare.outputs.release_java == 'true' && needs.prepare.outputs.is_tag == 'true' && (needs.check-maven.outputs.java_exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.ref }}

          submodules: recursive
      - uses: actions/download-artifact@v8.0.1
        with:
          pattern: java-natives-*
          path: artifacts/java-natives
          merge-multiple: true

      - uses: kreuzberg-dev/actions/stage-java-natives@v1
        with:
          resources-dir: packages/java/src/main/resources/natives
          required-classifiers: >-
            linux-x86_64 linux-aarch64 macos-arm64 windows-x86_64
            linux-musl-x86_64 linux-musl-aarch64
          lib-name: kreuzberg_ffi

      - uses: actions/setup-java@v5.2.0
        with:
          distribution: temurin
          java-version: "21"
          cache: maven
          server-id: central
          server-username: MAVEN_USERNAME
          server-password: MAVEN_PASSWORD
          gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
          gpg-passphrase: MAVEN_GPG_PASSPHRASE

      - uses: kreuzberg-dev/actions/setup-maven@v1

      - uses: kreuzberg-dev/actions/publish-maven@v1
        with:
          pom-file: packages/java/pom.xml
          maven-profile: publish
          extra-args: -DskipTests
          dry-run: ${{ needs.prepare.outputs.dry_run }}
        env:
          MAVEN_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
          MAVEN_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
          MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}

  publish-kotlin-android:
    name: Publish Kotlin Android (Maven Central via Gradle)
    needs: [prepare, kotlin-android-natives, check-maven]
    if: ${{ needs.prepare.outputs.release_kotlin == 'true' && needs.prepare.outputs.is_tag == 'true' && (needs.check-maven.outputs.kotlin_exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.ref }}

          submodules: recursive
      - uses: actions/download-artifact@v8.0.1
        with:
          pattern: kotlin-android-*
          path: kotlin-android-artifacts
          merge-multiple: true

      - name: Stage Android natives into Kotlin Android module
        shell: bash
        run: |
          for abi in arm64-v8a x86_64; do
            dest="packages/kotlin-android/src/main/jniLibs/${abi}"
            mkdir -p "$dest"
            src="kotlin-android-artifacts/${abi}/libkreuzberg_ffi.so"
            if [[ -f "$src" ]]; then
              cp -v "$src" "$dest/"
            fi
          done

      - uses: android-actions/setup-android@v4.0.1
        with:
          packages: "platforms;android-36 build-tools;36.0.0"

      - uses: kreuzberg-dev/actions/publish-maven-gradle@v1
        with:
          working-directory: packages/kotlin-android
          gradle-task: publishAndReleaseToMavenCentral
          dry-run: ${{ needs.prepare.outputs.dry_run }}
        env:
          MAVEN_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
          MAVEN_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
          MAVEN_GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
          MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}

  publish-nuget:
    name: Publish NuGet package
    needs: [prepare, csharp-natives, csharp-natives-musl, check-nuget]
    if: ${{ needs.prepare.outputs.release_csharp == 'true' && needs.prepare.outputs.is_tag == 'true' && (needs.check-nuget.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.ref }}

          submodules: recursive
      - uses: actions/download-artifact@v8.0.1
        with:
          pattern: csharp-natives-*
          path: csharp-natives
          merge-multiple: true

      - name: Stage runtimes/<rid>/native into NuGet project
        # Inline: NuGet expects native libs in runtimes/<rid>/native/ adjacent
        # to the .csproj. Copy from artifact tree into project.
        run: |
          mkdir -p packages/csharp/Kreuzberg/runtimes
          for dir in csharp-natives/runtimes/*/; do
            rid="$(basename "$dir")"
            mkdir -p "packages/csharp/Kreuzberg/runtimes/${rid}/native"
            cp -v "${dir}native/"* "packages/csharp/Kreuzberg/runtimes/${rid}/native/"
          done
        shell: bash

      - uses: actions/setup-dotnet@v5.2.0
        with:
          dotnet-version: "8.0.x"

      - name: Pack NuGet package
        # Inline: dotnet pack with the version override; one-liner.
        run: |
          dotnet pack packages/csharp/Kreuzberg/Kreuzberg.csproj \
            -c Release \
            -p:Version=${{ needs.prepare.outputs.version }} \
            -o dist/nuget
        shell: bash

      - uses: kreuzberg-dev/actions/publish-nuget@v1
        with:
          packages-dir: dist/nuget
          dry-run: ${{ needs.prepare.outputs.dry_run }}
        env:
          NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}

  finalize-github-release-after-uploads:
    name: Finalize GitHub Release (after uploads)
    needs:
      - prepare
      - verify-assets
      - upload-cli-release
      - upload-go-release
      - upload-c-ffi-release
      - upload-elixir-release
      - upload-php-pie-release
      - upload-swift-bundle
    if: ${{ needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.dry_run != 'true' }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: kreuzberg-dev/actions/finalize-release@v1
        with:
          tag: ${{ needs.prepare.outputs.tag }}
          is-prerelease: ${{ needs.prepare.outputs.is_prerelease }}

  publish-hex:
    name: Publish Elixir Hex package
    needs: [prepare, finalize-github-release-after-uploads, check-hex]
    if: ${{ needs.prepare.outputs.release_elixir == 'true' && needs.prepare.outputs.is_tag == 'true' && (needs.check-hex.outputs.elixir_exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.ref }}

          submodules: recursive
      - uses: kreuzberg-dev/actions/setup-rust@v1
      - uses: erlef/setup-beam@v1.24.0
        with:
          elixir-version: "1.17"
          otp-version: "27"

      - name: Generate NIF checksum file
        uses: kreuzberg-dev/actions/generate-elixir-checksums@v1
        with:
          github-repo: kreuzberg-dev/kreuzberg
          tag: ${{ needs.prepare.outputs.tag }}
          version: ${{ needs.prepare.outputs.version }}
          lib-name: kreuzberg_nif
          targets: x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu,x86_64-unknown-linux-musl,aarch64-unknown-linux-musl,aarch64-apple-darwin,x86_64-pc-windows-msvc
          output-path: packages/elixir/checksum-Elixir.Kreuzberg.Native.exs

      - name: Build Elixir Hex tarball with dependency rewrite
        id: build-hex
        uses: kreuzberg-dev/actions/build-elixir-hex@v1
        with:
          package-dir: packages/elixir
          nif-crate-path: packages/elixir/native/kreuzberg_nif
          rewrite-native-deps: "true"
          dry-run: ${{ needs.prepare.outputs.dry_run }}

      - uses: kreuzberg-dev/actions/publish-hex@v1
        with:
          package-dir: packages/elixir
          tarball-path: ${{ steps.build-hex.outputs.archive-path }}
          dry-run: ${{ needs.prepare.outputs.dry_run }}
        env:
          HEX_API_KEY: ${{ secrets.HEX_API_KEY }}

  assemble-dart-bundle:
    name: Assemble Dart package with native artifacts
    needs:
      [
        prepare,
        dart-package,
        dart-android-natives,
        dart-ios-xcframework,
        dart-server-natives,
        check-pub,
      ]
    if: ${{ needs.prepare.outputs.release_dart == 'true' && needs.prepare.outputs.is_tag == 'true' && needs.check-pub.outputs.exists != 'true' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v8.0.1
        with:
          name: dart-package
          path: packages/dart

      - name: Download Android natives
        uses: actions/download-artifact@v8.0.1
        with:
          pattern: dart-android-*
          path: dart-android-artifacts
          merge-multiple: true

      - name: Download iOS XCFramework
        uses: actions/download-artifact@v8.0.1
        with:
          name: dart-ios-xcframework
          path: dart-ios-xcframework

      - name: Download server natives (linux-x86_64)
        uses: actions/download-artifact@v8.0.1
        with:
          name: dart-server-linux-x86_64
          path: dart-server-artifacts/linux-x86_64

      - name: Download server natives (linux-aarch64)
        uses: actions/download-artifact@v8.0.1
        with:
          name: dart-server-linux-aarch64
          path: dart-server-artifacts/linux-aarch64

      - name: Download server natives (macos-arm64)
        uses: actions/download-artifact@v8.0.1
        with:
          name: dart-server-macos-arm64
          path: dart-server-artifacts/macos-arm64

      - name: Download server natives (windows-x86_64)
        uses: actions/download-artifact@v8.0.1
        with:
          name: dart-server-windows-x86_64
          path: dart-server-artifacts/windows-x86_64

      - name: Stage native libraries into Dart package
        shell: bash
        run: |
          set -euo pipefail
          # Android JNI libs
          for abi in arm64-v8a x86_64; do
            dest="packages/dart/android/src/main/jniLibs/${abi}"
            mkdir -p "$dest"
            src="dart-android-artifacts/${abi}/libkreuzberg_dart.so"
            [[ -f "$src" ]] && cp -v "$src" "$dest/"
          done
          # iOS XCFramework
          mkdir -p packages/dart/ios/Frameworks
          for xcf in dart-ios-xcframework/*.xcframework; do
            [[ -d "$xcf" ]] && cp -r "$xcf" packages/dart/ios/Frameworks/
          done
          # Server blobs
          for entry in "linux-x86_64:libkreuzberg_dart.so" "linux-aarch64:libkreuzberg_dart.so" "macos-arm64:libkreuzberg_dart.dylib" "windows-x86_64:kreuzberg_dart.dll"; do
            blob="${entry%%:*}"
            lib="${entry##*:}"
            dest="packages/dart/blobs/${blob}"
            mkdir -p "$dest"
            src="dart-server-artifacts/${blob}/${lib}"
            [[ -f "$src" ]] && cp -v "$src" "$dest/"
          done

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: dart-package-assembled
          path: packages/dart
          retention-days: 7

  trigger-pubdev:
    name: Trigger pub.dev publish workflow
    needs: [prepare, check-pub, assemble-dart-bundle]
    # pub.dev OIDC rejects tokens from `release` events. We dispatch a separate
    # workflow whose `workflow_dispatch` event produces an accepted token, then
    # have it download the assembled Dart package artifact from this run.
    if: ${{ needs.prepare.outputs.release_dart == 'true' && needs.prepare.outputs.is_tag == 'true' && needs.prepare.outputs.dry_run != 'true' && (needs.check-pub.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    permissions:
      actions: write
    steps:
      - name: Dispatch publish-pubdev workflow
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GH_REPO: ${{ github.repository }}
        run: |
          gh workflow run publish-pubdev.yaml \
            --ref ${{ github.ref_name }} \
            -f run_id=${{ github.run_id }}

  publish-zig:
    name: Publish Zig package metadata
    needs: [prepare, finalize-github-release-after-uploads, check-zig]
    if: ${{ needs.prepare.outputs.release_zig == 'true' && needs.prepare.outputs.is_tag == 'true' && (needs.check-zig.outputs.exists != 'true' || needs.prepare.outputs.force_republish == 'true') }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          ref: ${{ needs.prepare.outputs.ref }}
          submodules: recursive

      - uses: kreuzberg-dev/actions/publish-zig@v1
        with:
          working-directory: packages/zig
          tag: ${{ needs.prepare.outputs.tag }}
          # Override the auto-detected package-name (`.kreuzberg` from
          # build.zig.zon) to match the alef-emitted test_app URL pattern
          # `{crate-name}-zig-v{version}.tar.gz`. Without this, the asset
          # uploads as `kreuzberg-v{version}.tar.gz` while test_apps/zig
          # fetches `kreuzberg-zig-v{version}.tar.gz` → 404 + consumer
          # build.zig.zon TODO hash.
          package-name: kreuzberg-zig
          update-release-notes: "true"
          dry-run: ${{ needs.prepare.outputs.dry_run }}
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  # ─── Homebrew (formula update + bottles) ──────────────────────────────
  #
  # Topology mirrors html-to-markdown:
  #   1. publish-homebrew-formula — update formula source URL/sha (no bottles)
  #   2. homebrew-bottles         — build bottle tarballs + JSON manifests
  #   3. publish-homebrew-bottles — merge bottle DSL into formula and push

  publish-homebrew-formula:
    name: Update Homebrew formula
    needs: [prepare, upload-cli-release, check-homebrew]
    if: ${{ needs.prepare.outputs.release_homebrew == 'true' && needs.prepare.outputs.is_tag == 'true' && needs.check-homebrew.outputs.exists != 'true' }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Checkout source repo
        uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}

      - name: Checkout homebrew-tap
        uses: actions/checkout@v6.0.2
        with:
          repository: kreuzberg-dev/homebrew-tap
          token: ${{ secrets.HOMEBREW_TOKEN }}
          path: homebrew-tap

      - name: Update formula files
        # Inline: bumps url/sha256/version in Formula/kreuzberg.rb. Equivalent
        # of html-to-markdown's scripts/publish/update-homebrew-formula.sh.
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TAG: ${{ needs.prepare.outputs.tag }}
          VERSION: ${{ needs.prepare.outputs.version }}
          TAP_DIR: ${{ github.workspace }}/homebrew-tap
          DRY_RUN: ${{ needs.prepare.outputs.dry_run }}
        run: scripts/publish/update-homebrew-formula.sh
        shell: bash

      - name: Commit and push to tap
        if: ${{ needs.prepare.outputs.dry_run != 'true' }}
        working-directory: homebrew-tap
        env:
          GH_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
        run: |
          git config user.name "kreuzberg-bot"
          git config user.email "bot@kreuzberg.dev"
          if git diff --quiet Formula/; then
            echo "No formula changes; skipping commit."
            exit 0
          fi
          git add Formula/kreuzberg.rb
          git commit -m "kreuzberg ${{ needs.prepare.outputs.version }}"
          git push origin HEAD
        shell: bash

  homebrew-bottles:
    name: Build Homebrew bottle (${{ matrix.bottle_tag }})
    needs: [prepare, publish-homebrew-formula]
    if: ${{ needs.prepare.outputs.release_homebrew == 'true' && needs.prepare.outputs.is_tag == 'true' }}
    runs-on: ${{ matrix.os }}
    permissions:
      contents: write
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: macos-latest, bottle_tag: arm64_sonoma, install_brew: false }
          # Intel macOS (sequoia, macos-15-intel) bottle dropped: ort-sys 2.0.0-rc.12 has no
          # prebuilt for x86_64-apple-darwin. Re-add once upstream ships it or we switch to
          # a no-ort feature set for Intel Macs.
          - { os: ubuntu-latest, bottle_tag: x86_64_linux, install_brew: true }
          - { os: ubuntu-24.04-arm, bottle_tag: arm64_linux, install_brew: true }
    steps:
      - uses: actions/checkout@v5
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}

          submodules: recursive
      - name: Install Homebrew (Linux)
        if: ${{ matrix.install_brew }}
        uses: kreuzberg-dev/actions/install-homebrew-linux@v1

      - name: Build bottles
        uses: kreuzberg-dev/actions/homebrew-build-bottles@v1
        with:
          tag: ${{ needs.prepare.outputs.tag }}
          version: ${{ needs.prepare.outputs.version }}
          tap: kreuzberg-dev/homebrew-tap
          formulas: |
            kreuzberg
          out-dir: ${{ github.workspace }}/bottle-json
          github-repo: kreuzberg-dev/kreuzberg
          token: ${{ secrets.GITHUB_TOKEN }}

      - uses: actions/upload-artifact@v7.0.1
        with:
          name: homebrew-bottle-json-${{ matrix.bottle_tag }}
          path: ${{ github.workspace }}/bottle-json/*.json
          if-no-files-found: error
          retention-days: 14

  publish-homebrew-bottles:
    name: Merge Homebrew bottle DSL
    needs: [prepare, publish-homebrew-formula, homebrew-bottles]
    if: ${{ needs.prepare.outputs.release_homebrew == 'true' && needs.prepare.outputs.is_tag == 'true' }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: ${{ needs.prepare.outputs.checkout_ref }}

          submodules: recursive
      - name: Checkout homebrew-tap
        uses: actions/checkout@v6.0.2
        with:
          repository: kreuzberg-dev/homebrew-tap
          token: ${{ secrets.HOMEBREW_TOKEN }}
          path: homebrew-tap

      - uses: actions/download-artifact@v8.0.1
        with:
          path: ${{ github.workspace }}/bottle-json
          pattern: homebrew-bottle-json-*
          merge-multiple: true

      - uses: kreuzberg-dev/actions/homebrew-merge-bottles@v1
        with:
          tag: ${{ needs.prepare.outputs.tag }}
          version: ${{ needs.prepare.outputs.version }}
          tap-dir: ${{ github.workspace }}/homebrew-tap
          json-dir: ${{ github.workspace }}/bottle-json
          formulas: |
            kreuzberg
          github-repo: kreuzberg-dev/kreuzberg

      - name: Commit and push bottle DSL
        if: ${{ needs.prepare.outputs.dry_run != 'true' }}
        # Inline: tap-side commit/push of the regenerated formula. No shared
        # action covers tap commits — each repo's bot identity is unique.
        working-directory: homebrew-tap
        env:
          GH_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
        run: |
          git config user.name "kreuzberg-bot"
          git config user.email "bot@kreuzberg.dev"
          if git diff --quiet Formula/; then
            echo "No bottle DSL changes; skipping commit."
            exit 0
          fi
          git add Formula/kreuzberg.rb
          git commit -m "kreuzberg ${{ needs.prepare.outputs.version }}: add bottle DSL"
          git push origin HEAD
        shell: bash

  # ─── Finalization ─────────────────────────────────────────────────────

  release-finalize:
    name: Finalize GitHub Release (post-publish validation)
    needs:
      - prepare
      - finalize-github-release-after-uploads
      - publish-crates
      - publish-pypi
      - publish-rubygems
      - publish-node
      - publish-wasm
      - publish-packagist
      - publish-maven
      - publish-kotlin-android
      - publish-nuget
      - publish-hex
      - trigger-pubdev
      - publish-zig
      - publish-homebrew-formula
      - publish-homebrew-bottles
    if: |
      always() &&
      !cancelled() &&
      needs.prepare.outputs.is_tag == 'true' &&
      needs.prepare.outputs.dry_run != 'true' &&
      !contains(needs.*.result, 'failure')
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: kreuzberg-dev/actions/finalize-release@v1
        with:
          tag: ${{ needs.prepare.outputs.tag }}
          is-prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' && 'true' || 'false' }}
          go-module-path: packages/go/v5

  announce-discord:
    name: Announce release on Discord
    needs:
      - prepare
      - publish-crates
      - publish-pypi
      - publish-rubygems
      - publish-node
      - publish-wasm
      - publish-packagist
      - publish-maven
      - publish-kotlin-android
      - publish-nuget
      - publish-hex
      - trigger-pubdev
      - publish-zig
      - publish-homebrew-formula
      - publish-homebrew-bottles
      - upload-cli-release
      - upload-go-release
      - upload-c-ffi-release
      - upload-elixir-release
      - upload-php-pie-release
      - upload-swift-bundle
      - release-finalize
    if: |
      always() &&
      !cancelled() &&
      needs.prepare.outputs.is_tag == 'true' &&
      needs.prepare.outputs.dry_run != 'true' &&
      needs.prepare.outputs.is_prerelease != 'true' &&
      !contains(needs.*.result, 'failure')
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: kreuzberg-dev/actions/announce-release-discord@v1
        with:
          tag: ${{ needs.prepare.outputs.tag }}
          webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
          project-name: kreuzberg
