name: release

on:
  push:
    tags:
      - 'v*'

permissions:
  contents: write

# Runner strategy:
#   - Linux + Android + mipsel:       self-hosted (mhrv-hetzner-*, Hetzner
#                                     8-core / 31 GB Ubuntu 24.04 box with
#                                     Rust, Android SDK+NDK, Docker, all
#                                     cross-compile toolchains pre-installed).
#                                     Two runners registered for parallelism.
#   - macOS arm64 + amd64, Windows:   GitHub-hosted (we don't self-host those
#                                     OSes; the free minutes on a public repo
#                                     are plenty for those two platforms).
#
# Why self-hosted: GH-hosted 2-core runners were spending ~13 min cold per
# release; on the Hetzner box a cold linux-amd64 build is 1m9s, and warm
# builds with Swatinem/rust-cache are sub-minute. Keeps the toolchain warm,
# and more importantly keeps target/ warm via the rust-cache action.

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          # Pin to Ubuntu 22.04 GLIBC target (GLIBC 2.35) so the glibc builds
          # load on any distro ≥ Ubuntu 22.04 / Debian 12 / Mint 21 / Fedora 36.
          # On self-hosted this is a Rust-side choice (cargo target triple),
          # not an OS-of-the-runner choice — the runner itself is Ubuntu 24.04
          # (GLIBC 2.39), but we link against the 2.35-era glibc via the
          # x86_64-unknown-linux-gnu target triple which pins to the oldest
          # GLIBC symbol version rustc is willing to emit. Users behind tight
          # internet who can't dist-upgrade keep working.
          - target: x86_64-unknown-linux-gnu
            os: [self-hosted, linux, x64, mhrv-build]
            name: mhrv-rs-linux-amd64
          - target: aarch64-unknown-linux-gnu
            os: [self-hosted, linux, x64, mhrv-build]
            name: mhrv-rs-linux-arm64
          - target: arm-unknown-linux-gnueabihf
            os: [self-hosted, linux, x64, mhrv-build]
            name: mhrv-rs-raspbian-armhf
          - target: x86_64-apple-darwin
            os: macos-latest
            name: mhrv-rs-macos-amd64
          - target: aarch64-apple-darwin
            os: macos-latest
            name: mhrv-rs-macos-arm64
          - target: x86_64-pc-windows-gnu
            os: windows-latest
            name: mhrv-rs-windows-amd64
          - target: x86_64-unknown-linux-musl
            os: [self-hosted, linux, x64, mhrv-build]
            name: mhrv-rs-linux-musl-amd64
          - target: aarch64-unknown-linux-musl
            os: [self-hosted, linux, x64, mhrv-build]
            name: mhrv-rs-linux-musl-arm64
          # OpenWRT MT7621 (soft-float mipsel 32-bit). Dozens of cheap
          # home routers run this chipset and they *specifically* need
          # the soft-float variant — MT7621 has no hardware FPU and a
          # hard-float binary segfaults on the first fp op. Tier-3 in
          # Rust since 1.72; we build it via messense's musl-cross
          # docker image which still has a mipsel-softfloat toolchain.
          # `continue-on-error: true` so a regression here doesn't block
          # the rest of the release. Issue #26.
          - target: mipsel-unknown-linux-musl
            os: [self-hosted, linux, x64, mhrv-build]
            name: mhrv-rs-openwrt-mipsel-softfloat
            mipsel_softfloat: true

    runs-on: ${{ matrix.os }}
    # mipsel-softfloat is best-effort: the Rust tier-3 target occasionally
    # regresses. Letting it fail keeps the main release going so
    # desktop/Android users aren't blocked by MT7621 router support.
    continue-on-error: ${{ matrix.mipsel_softfloat == true }}

    steps:
      - uses: actions/checkout@v4

      # Skip the host-level rustup install for mipsel-softfloat — that
      # target is tier-3 in stable Rust (no prebuilt stdlib available
      # via rustup), and the docker image we use for this build ships
      # its own Rust toolchain + std. Trying to pass
      # `targets: mipsel-unknown-linux-musl` to dtolnay/rust-toolchain
      # errors out with "error: component 'rust-std' for target
      # 'mipsel-unknown-linux-musl' is unavailable for download", which
      # fails the job before the docker step ever runs.
      #
      # On self-hosted this action is mostly a no-op: rustup is already
      # installed and the standard target triples are pre-added. It
      # still verifies the target is present and is cheap enough to keep
      # as a safety net.
      - uses: dtolnay/rust-toolchain@stable
        if: matrix.mipsel_softfloat != true
        with:
          targets: ${{ matrix.target }}

      # Cache target/ + cargo registry across runs — this is the big
      # self-hosted speedup. Without it, actions/checkout@v4's default
      # `git clean -ffdx` wipes target/ between runs and every build is
      # cold. With it, warm builds are sub-minute even for the full
      # release profile.
      #
      # cache-bin: false is MANDATORY on our self-hosted runners. With
      # the default (true), rust-cache aggressively prunes $CARGO_HOME/bin
      # of binaries it didn't install via `cargo install`, including the
      # `rustup` binary that cargo/rustc/etc. are symlinked to. The next
      # job then hits "command not found" or a broken-symlink TOML parse
      # error from a stale cargo. We want target/ + registry caching, NOT
      # bin pruning. rustup is pre-installed on the runners anyway.
      - uses: Swatinem/rust-cache@v2
        if: matrix.mipsel_softfloat != true
        with:
          key: ${{ matrix.target }}
          cache-bin: "false"

      # eframe needs a few system libs on Linux for window management, keyboard,
      # and OpenGL/X11/Wayland. Gated to GitHub-hosted runners only — the
      # self-hosted runners pre-install all of these once at setup time, and
      # letting multiple parallel matrix jobs race on `sudo apt-get install`
      # fights over /var/lib/apt/lists/lock and fails them all.
      - name: Install Linux eframe system deps
        if: runner.os == 'Linux' && runner.environment == 'github-hosted'
        run: |
          sudo apt-get update
          sudo apt-get install -y \
            libxkbcommon-dev \
            libwayland-dev \
            libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
            libx11-dev \
            libgl1-mesa-dev libglib2.0-dev libgtk-3-dev

      # Cross-compile toolchains. Same story as above — gated to hosted
      # runners; self-hosted has gcc-aarch64-linux-gnu + gcc-arm-linux-gnueabihf
      # pre-installed, and the linker entries live in
      # /home/ghrunner/cargo-{01,02}/config.toml (seeded once at runner
      # setup time, picked up via CARGO_HOME env).
      - name: Install aarch64 cross-compile toolchain (Linux only)
        if: matrix.target == 'aarch64-unknown-linux-gnu' && runner.environment == 'github-hosted'
        run: |
          sudo apt-get update
          sudo apt-get install -y gcc-aarch64-linux-gnu
          echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config.toml
          echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml

      - name: Install armhf cross-compile toolchain (Linux only)
        if: matrix.target == 'arm-unknown-linux-gnueabihf' && runner.environment == 'github-hosted'
        run: |
          sudo apt-get update
          sudo apt-get install -y gcc-arm-linux-gnueabihf
          echo '[target.arm-unknown-linux-gnueabihf]' >> ~/.cargo/config.toml
          echo 'linker = "arm-linux-gnueabihf-gcc"' >> ~/.cargo/config.toml

      - name: Install Windows MinGW toolchain
        if: matrix.target == 'x86_64-pc-windows-gnu'
        id: msys2
        uses: msys2/setup-msys2@v2
        with:
          msystem: MINGW64
          update: true
          install: mingw-w64-x86_64-gcc

      - name: Configure Windows GNU linker
        if: matrix.target == 'x86_64-pc-windows-gnu'
        shell: pwsh
        run: |
          $gcc = "${{ steps.msys2.outputs.msys2-location }}\mingw64\bin\gcc.exe" -replace '\\','/'
          New-Item -ItemType Directory -Force -Path $env:USERPROFILE/.cargo | Out-Null
          Add-Content -Path $env:USERPROFILE/.cargo/config.toml -Value '[target.x86_64-pc-windows-gnu]'
          Add-Content -Path $env:USERPROFILE/.cargo/config.toml -Value "linker = '$gcc'"

      - name: Build CLI
        if: "!endsWith(matrix.target, '-linux-musl')"
        run: cargo build --release --target ${{ matrix.target }} --bin mhrv-rs

      # Fully-static musl builds for OpenWRT / Alpine / libc-less systems.
      # messense/rust-musl-cross ships a pre-built musl toolchain so `ring`
      # (rustls' crypto backend) cross-compiles cleanly on both archs.
      - name: Build CLI (musl via docker)
        if: matrix.target == 'x86_64-unknown-linux-musl'
        run: |
          docker run --rm -v "$PWD":/src -w /src \
            messense/rust-musl-cross:x86_64-musl \
            cargo build --release --target x86_64-unknown-linux-musl --bin mhrv-rs
          sudo chown -R "$(id -u):$(id -g)" target

      - name: Build CLI (musl via docker, arm64)
        if: matrix.target == 'aarch64-unknown-linux-musl'
        run: |
          docker run --rm -v "$PWD":/src -w /src \
            messense/rust-musl-cross:aarch64-musl \
            cargo build --release --target aarch64-unknown-linux-musl --bin mhrv-rs
          sudo chown -R "$(id -u):$(id -g)" target

      # OpenWRT MT7621 / mipsel-softfloat. messense doesn't publish a
      # `:mipsel-musl-softfloat` tag — the mipsel-musl image is
      # hardfloat. We build soft-float anyway via
      # `RUSTFLAGS=-C target-feature=+soft-float` + `-Z build-std` so
      # libstd itself is recompiled to emit soft-float code. The
      # gcc/musl shipping in the image is hardfloat but we never link
      # anything more than libc (`ring` is pure asm for the crypto
      # that matters), so musl's lack of softfloat libm doesn't bite.
      # Requires nightly Rust since mipsel is Rust tier 3 in the
      # stable channel — no prebuilt std.
      - name: Build CLI (mipsel-softfloat via docker)
        if: matrix.target == 'mipsel-unknown-linux-musl' && matrix.mipsel_softfloat == true
        # The inner script is single-quoted so the `#` lines stay as
        # real comments. An earlier version of this step used
        # `sh -c "... \` (backslash-continuation inside a
        # double-quoted YAML folded string) which collapsed into one
        # line — the first `#` then commented out everything after it,
        # reducing the whole docker payload to `set -eux;` and failing
        # silently at the post-docker chown. Heredoc-style single
        # quotes preserve newlines verbatim; no comment collapse.
        run: |
          docker run --rm -v "$PWD":/src -w /src \
            -e RUSTFLAGS='-C target-feature=+soft-float' \
            messense/rust-musl-cross:mipsel-musl \
            bash -c '
              set -eux
              # The image ships a pre-installed nightly that rustup
              # cannot upgrade in place — `clippy-preview/share/doc/clippy/README.md`
              # is missing from the pre-bake, and rustup errors with
              # "failure removing component clippy-preview". Nuke it
              # first, then install fresh.
              rustup toolchain uninstall nightly 2>/dev/null || true
              rustup toolchain install nightly --profile minimal
              rustup component add rust-src --toolchain nightly
              cargo +nightly build --release \
                -Z build-std=std,panic_abort \
                --target mipsel-unknown-linux-musl \
                --bin mhrv-rs
            '
          sudo chown -R "$(id -u):$(id -g)" target

      # UI build: we try to build the UI binary on every platform. If it fails
      # on cross-compile for linux-arm64 (missing arm64 system libs cross),
      # we still ship the CLI. We also skip the UI on musl targets (OpenWRT etc.
      # are headless, bundling X11 makes no sense).
      - name: Build UI
        if: matrix.target != 'aarch64-unknown-linux-gnu' && matrix.target != 'arm-unknown-linux-gnueabihf' && !endsWith(matrix.target, '-linux-musl')
        run: cargo build --release --target ${{ matrix.target }} --features ui --bin mhrv-rs-ui

      - name: Package (unix)
        if: runner.os != 'Windows'
        run: |
          mkdir -p dist
          cp target/${{ matrix.target }}/release/mhrv-rs dist/mhrv-rs
          chmod +x dist/mhrv-rs
          if [ -f target/${{ matrix.target }}/release/mhrv-rs-ui ]; then
            cp target/${{ matrix.target }}/release/mhrv-rs-ui dist/mhrv-rs-ui
            chmod +x dist/mhrv-rs-ui
          fi
          # OpenWRT / musl archives get the procd init script instead of run.sh,
          # since routers don't have a CA to install and run headless via procd.
          case "${{ matrix.target }}" in
            *-linux-musl)
              cp assets/openwrt/mhrv-rs.init dist/mhrv-rs.init
              chmod +x dist/mhrv-rs.init
              ;;
            *)
              cp assets/launchers/run.sh dist/run.sh
              chmod +x dist/run.sh
              if [ "${{ runner.os }}" = "macOS" ]; then
                cp assets/launchers/run.command dist/run.command
                chmod +x dist/run.command
              fi
              ;;
          esac

      - name: Build macOS .app bundle
        if: runner.os == 'macOS'
        run: |
          VER="${GITHUB_REF#refs/tags/v}"
          ./assets/macos/build-app.sh dist/mhrv-rs-ui "$VER" dist
          # Make a clean zip of just the .app for the release
          cd dist
          zip -qry "${{ matrix.name }}-app.zip" mhrv-rs.app

      - name: Package (windows)
        if: runner.os == 'Windows'
        shell: pwsh
        run: |
          New-Item -ItemType Directory -Force -Path dist | Out-Null
          Copy-Item target/${{ matrix.target }}/release/mhrv-rs.exe dist/mhrv-rs.exe
          if (Test-Path target/${{ matrix.target }}/release/mhrv-rs-ui.exe) {
            Copy-Item target/${{ matrix.target }}/release/mhrv-rs-ui.exe dist/mhrv-rs-ui.exe
          }
          Copy-Item assets/launchers/run.bat dist/run.bat

      - name: Make archive
        shell: bash
        run: |
          cd dist
          case "${{ matrix.target }}" in
            *-pc-windows-*)
              7z a -tzip "${{ matrix.name }}.zip" mhrv-rs.exe mhrv-rs-ui.exe run.bat
              ;;
            *-apple-darwin)
              tar czf "${{ matrix.name }}.tar.gz" mhrv-rs mhrv-rs-ui run.sh run.command
              ;;
            *-linux-musl)
              tar czf "${{ matrix.name }}.tar.gz" mhrv-rs mhrv-rs.init
              ;;
            *)
              tar czf "${{ matrix.name }}.tar.gz" mhrv-rs mhrv-rs-ui run.sh 2>/dev/null || tar czf "${{ matrix.name }}.tar.gz" mhrv-rs run.sh
              ;;
          esac

      - uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.name }}
          path: |
            dist/${{ matrix.name }}.tar.gz
            dist/${{ matrix.name }}.zip
            dist/${{ matrix.name }}-app.zip
          if-no-files-found: ignore

  # Android build — separate job so it doesn't inflate the matrix. The
  # Rust side here cross-compiles to FOUR ABIs (arm64-v8a, armeabi-v7a,
  # x86_64, x86) via cargo-ndk and drops the .so files into the Gradle
  # project's jniLibs/ tree, which then packages them into a single
  # universal APK. Users pick it once, no per-ABI split.
  #
  # Runs on self-hosted. The runner has Android SDK + NDK r26c + cargo-ndk
  # pre-installed under /opt/android-sdk; the env block below points Gradle
  # at those paths so we don't re-download ~1 GB of SDK per release.
  android:
    runs-on: [self-hosted, linux, x64, mhrv-build]
    env:
      ANDROID_SDK_ROOT: /opt/android-sdk
      ANDROID_HOME: /opt/android-sdk
      ANDROID_NDK_HOME: /opt/android-sdk/ndk/26.2.11394342
      ANDROID_NDK_ROOT: /opt/android-sdk/ndk/26.2.11394342
      JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
    steps:
      - uses: actions/checkout@v4

      # Rust toolchain: idempotent on self-hosted (targets already present),
      # kept here so the workflow still works if we ever run it on a GH-hosted
      # fallback.
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,i686-linux-android

      # Cache cargo + target/ across Android release builds. Four cargo-ndk
      # release builds back-to-back with LTO is where the cold cost comes
      # from; rust-cache brings warm runs down to ~3–4 min from ~9 min cold.
      # cache-bin: false — see the rationale on the matrix build job above.
      # On top of that, `cargo-ndk` lives in /usr/local/bin/ on our runners
      # (not $CARGO_HOME/bin), specifically so rust-cache's default bin
      # pruning can't delete it.
      - uses: Swatinem/rust-cache@v2
        with:
          key: android-universal
          cache-bin: "false"
          # cargo-ndk writes into `target/<android-triple>/release/`, all
          # four of which we want to cache.
          workspaces: |
            . -> target

      # `./gradlew :app:assembleRelease` triggers cargoBuildRelease first
      # which invokes cargo-ndk with all four targets, then Gradle packages
      # the APK (release buildType signed with the committed release.jks —
      # see android/app/build.gradle.kts comment explaining why).
      - name: Build release APK
        working-directory: android
        run: |
          chmod +x ./gradlew
          ./gradlew :app:assembleRelease --no-daemon --stacktrace

      - name: Rename APK with version
        working-directory: android
        run: |
          VER="${GITHUB_REF#refs/tags/v}"
          SRC="app/build/outputs/apk/release/app-release.apk"
          if [ ! -f "$SRC" ]; then
            # Some AGP versions name it differently when the release config
            # can't be auto-signed. Catch that up front with a clear error
            # instead of a silent missing-artifact later.
            echo "::error::expected $SRC to exist; actual outputs:"
            find app/build/outputs/apk -type f -name '*.apk' -print
            exit 1
          fi
          mkdir -p ../dist
          cp "$SRC" "../dist/mhrv-rs-android-universal-v${VER}.apk"

      - uses: actions/upload-artifact@v4
        with:
          name: mhrv-rs-android-universal
          path: dist/*.apk
          if-no-files-found: error

  # release + telegram: lightweight aggregation jobs kept on GH-hosted
  # ubuntu-latest. They only download artifacts and call APIs — no build
  # tooling needed, no benefit from moving to self-hosted, and keeping them
  # off the self-hosted runners avoids contention with Linux build jobs from
  # the next tag if two releases overlap.
  release:
    needs: [build, android]
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/download-artifact@v4
        with:
          path: dist
          merge-multiple: true

      - name: Release
        uses: softprops/action-gh-release@v2
        with:
          files: dist/*
          generate_release_notes: true

  # Notify the Persian-speaking Telegram channel with the CI-built
  # Android APK + its sha256 + the per-version changelog from
  # `docs/changelog/v<tag>.md`.
  #
  # Two Telegram API calls:
  #   1. sendDocument — APK file + a short caption (Telegram caps
  #      captions at 1024 chars, and we have bigger changelogs than
  #      that).
  #   2. sendMessage — full changelog as a reply to #1, Persian
  #      quote-block first then English, same pattern as the
  #      previous manual post. No emojis, as the user asked.
  #
  # Needs two repo secrets:
  #   TELEGRAM_BOT_TOKEN  — bot the channel admits as poster
  #   TELEGRAM_CHAT_ID    — numeric chat id (starts with -100...)
  # Missing either => the whole job is skipped (not failed) so a
  # forker who hasn't set up a Telegram channel gets a clean release.
  telegram:
    needs: [android, release]
    runs-on: ubuntu-latest
    # Gated on the repo variable `TELEGRAM_NOTIFY_ENABLED`. Default is
    # OFF — the job skips silently unless the variable is set to the
    # literal string "true". Toggle via:
    #
    #     gh variable set TELEGRAM_NOTIFY_ENABLED --body true
    #     gh variable set TELEGRAM_NOTIFY_ENABLED --body false
    #
    # Keeping the machinery (script + secrets) in place so flipping
    # the switch back on is a one-liner, not a workflow edit.
    if: ${{ vars.TELEGRAM_NOTIFY_ENABLED == 'true' && needs.android.result == 'success' }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/download-artifact@v4
        with:
          name: mhrv-rs-android-universal
          path: apk

      - name: Post to Telegram
        env:
          BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
          CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
          INCLUDE_CHANGELOG: ${{ vars.TELEGRAM_INCLUDE_CHANGELOG }}
        # Python over curl/bash so we don't have to fight curl's -F
        # value-interpretation rules. curl treats `-F "caption=<..."`
        # as "read the caption from file named ..." when the value
        # starts with `<`, which matches our `<b>` HTML-bold tags and
        # silently turns the whole job into a "file not found" exit
        # 26. Python stdlib has no such wart.
        run: |
          set -euo pipefail
          VER="${GITHUB_REF#refs/tags/v}"
          APK="apk/mhrv-rs-android-universal-v${VER}.apk"

          if [ -z "${BOT_TOKEN:-}" ] || [ -z "${CHAT_ID:-}" ]; then
            echo "::notice::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set, skipping Telegram post"
            exit 0
          fi

          if [ ! -f "$APK" ]; then
            echo "::error::expected $APK to exist; got:"
            ls -la apk/
            exit 1
          fi

          # --with-changelog is opt-in. Default post is just the APK
          # plus a short caption with the SHA-256, repo URL, and release
          # URL — no long body. To include the Persian/English bullets
          # for a specific tag, set the repo variable
          # TELEGRAM_INCLUDE_CHANGELOG=true before pushing that tag.
          INCLUDE_CHANGELOG_FLAG=""
          if [ "${INCLUDE_CHANGELOG:-}" = "true" ]; then
            INCLUDE_CHANGELOG_FLAG="--with-changelog"
          fi
          python3 .github/scripts/telegram_release_notify.py \
            --apk "$APK" \
            --version "$VER" \
            --repo "$GITHUB_REPOSITORY" \
            --changelog "docs/changelog/v${VER}.md" \
            $INCLUDE_CHANGELOG_FLAG
