name: Android Release Build

# Builds the Android release artifacts (APK + AAB) and makes them available as
# workflow artifacts. When triggered by a version tag, it can additionally
# attach the built artifacts to a matching GitHub Release. It never publishes to
# a store or submits to F-Droid, and it never writes production release notes.
#
# Triggers:
#
#   * workflow_dispatch (manual): produces test builds. The `signed` input
#     chooses real release signing vs. a debug-key build, exactly as before.
#     Manual runs without a `release_tag` input never touch any GitHub Release.
#
#     With the optional `release_tag` input set to an existing tag (e.g.
#     v0.1.0-alpha.39), the run behaves like the tag build below: it uses the
#     canonical -release-signed asset names for that tag, runs the same pubspec
#     preflight and APK version verification against the tag, and uploads
#     (--clobber) the built universal APK, per-ABI APKs, and AAB to the existing
#     GitHub Release for the tag. The git tag itself is not moved or recreated,
#     and pubspec.yaml is not modified. This lets us rebuild the assets for an
#     existing release using the workflow as it exists on `main`, instead of the
#     workflow snapshot at the tag commit.
#
#   * push of a tag matching `v*` (e.g. v0.1.0-alpha.1): an automatic release
#     build. There is exactly one build per tag (we listen only to the tag push,
#     not to `release: published`, so the same commit is never built twice).
#
# Tag behavior (alpha/pre-release vs. stable):
#
#   * Pre-release tags — any tag containing `alpha`, `beta`, or `rc`
#     (e.g. v0.1.0-alpha.1): attach the built APK/AAB to a GitHub *pre-release*.
#     If no Release exists for the tag yet, one is CREATED as a pre-release.
#     These tags may attach release-signed OR clearly-labeled debug-signed
#     artifacts — debug-signed builds are for TESTING ONLY and are named and
#     described as such (never passed off as a production release).
#
#   * Stable tags — e.g. v1.0.0 (no alpha/beta/rc): REQUIRE release signing.
#     If the LINTHRA_* signing secrets are missing the run FAILS fast rather
#     than attaching a debug-signed build. Stable assets are only attached to a
#     Release that already exists (notes stay manually authored); the workflow
#     does not auto-create stable Releases.
#
# Signing:
#
#   * Manual run, signed = false (default): no signing secrets are read; the
#     build falls back to the debug signing config in android/app/build.gradle.
#     Artifacts are DEBUG-KEY SIGNED — fine for previews, NOT for distribution —
#     and are labeled `-debug-signed`.
#
#   * Manual run, signed = true: decodes the keystore from the LINTHRA_KEYSTORE_*
#     secrets and signs with it. If any required secret is missing the run fails
#     fast (it does NOT silently fall back to the debug key).
#
#   * Tag push: signed if the LINTHRA_* secrets are configured (-> release-signed).
#     If the secrets are missing: a pre-release tag degrades to clearly-labeled
#     debug-signed artifacts (for testing only); a stable tag fails fast.
#
# Artifact naming:
#
#   * Tag builds — always upload the public Release asset under the canonical
#     names. The file names must be stable across releases because the F-Droid
#     per-Build `binary:` URLs in metadata/io.github.thezupzup.linthra.yml
#     point at them with `%v` expanding to the versionName. This is regardless
#     of whether the underlying build happened to fall back to a debug key
#     (pre-release tags only). The ACTUAL signing mode is still reported
#     authoritatively in the GitHub Release notes and the workflow's build
#     summary (debug-signed fallbacks are flagged there as testing-only), so a
#     debug-signed pre-release is never silently passed off as a production
#     build. The names produced:
#
#       Universal APK   linthra-<tag>-release-signed.apk
#       Universal AAB   linthra-<tag>-release-signed.aab
#       Per-ABI APKs    linthra-<tag>-armeabi-v7a-release-signed.apk
#                       linthra-<tag>-arm64-v8a-release-signed.apk
#                       linthra-<tag>-x86_64-release-signed.apk
#
#     The per-ABI APKs are added so F-Droid can reference reproducible upstream
#     binaries for each ABI build, as requested by the F-Droid maintainer on
#     MR !39329 ("Please host per-abi apks in github release and add binary to
#     every version"). The universal APK/AAB stay so the existing sideload URL
#     and AAB pipeline are unchanged.
#
#   * Manual (workflow_dispatch) builds — keep the signing label in the
#     workflow-artifact name (`linthra-debug-signed` / `linthra-release-signed`)
#     because these never reach a public Release and the label is useful for
#     telling local artifacts apart at a glance. Per-ABI APKs get the ABI
#     appended (`linthra-<signing>-<abi>.apk`).
#
# Version derivation (tag builds):
#
#   * pubspec.yaml is the single source of truth for the release version
#     (`version: <name>+<code>`), kept in lockstep with the release tag. A "Verify
#     the tag matches pubspec.yaml" step runs `tool/version_from_tag.dart` on
#     GITHUB_REF_NAME to compute the canonical LINTHRA_VERSION_NAME (e.g.
#     0.1.0-alpha.30) and strictly monotonic LINTHRA_VERSION_CODE (e.g. 100030),
#     then FAILS FAST if pubspec.yaml does not match it — so a tag can never be
#     built against a stale pubspec, and a malformed tag stops the run before any
#     build.
#   * Both manual and tag builds are a plain `flutter build` that reads
#     versionName/versionCode from pubspec.yaml — exactly what the F-Droid build
#     does — so the APK/AAB metadata and the in-app version (AppInfo.version) all
#     agree without any build-time injection. A verification step then confirms
#     the built APK carries the verified version. Manual `workflow_dispatch` runs
#     are named without a tag (never passed off as a tagged release). See
#     docs/release-process.md §1 for the full strategy.
#
# See docs/release-signing.md for the required secrets and keystore handling,
# and docs/release-process.md for the overall release flow.
on:
  workflow_dispatch:
    inputs:
      signed:
        description: "Sign with the release keystore (requires LINTHRA_* secrets). If false, builds are debug-signed and labeled as such."
        type: boolean
        default: false
      release_tag:
        description: "Optional. Existing release tag (e.g. v0.1.0-alpha.39) to build against and attach assets to. When set, the run uses this tag's canonical asset names, runs the same pubspec preflight + APK version verification as a tag build, and uploads/clobbers the built APK/AAB onto the existing GitHub Release for the tag. The git tag is not moved or recreated. Leave empty for a normal manual artifact build."
        type: string
        default: ""
  push:
    tags:
      - "v*"

# Default to read-only. Building and sharing artifacts via upload-artifact needs
# nothing more. The separate attach-release job below requests `contents: write`
# only for itself, and only to create a pre-release / upload Release assets.
permissions:
  contents: read

jobs:
  build-release:
    name: Build release APK + AAB
    runs-on: ubuntu-latest
    # These are harmless when empty (unsigned builds): the Gradle config treats
    # absent/empty values as "no release signing" and falls back to the debug
    # key. The keystore file path (LINTHRA_KEYSTORE_PATH) is only exported by the
    # decode step below, and only when signing is requested.
    env:
      LINTHRA_KEYSTORE_PASSWORD: ${{ secrets.LINTHRA_KEYSTORE_PASSWORD }}
      LINTHRA_KEY_ALIAS: ${{ secrets.LINTHRA_KEY_ALIAS }}
      LINTHRA_KEY_PASSWORD: ${{ secrets.LINTHRA_KEY_PASSWORD }}
    outputs:
      signing: ${{ steps.mode.outputs.signing }}
      trigger: ${{ steps.mode.outputs.trigger }}
      prerelease: ${{ steps.mode.outputs.prerelease }}
      asset_base: ${{ steps.mode.outputs.asset_base }}
      target_tag: ${{ steps.mode.outputs.target_tag }}
      version_name: ${{ steps.version.outputs.LINTHRA_VERSION_NAME }}
      version_code: ${{ steps.version.outputs.LINTHRA_VERSION_CODE }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # Decide, in one place, how this run should sign, whether the tag is a
      # pre-release, and how to label/name the artifacts.
      #   trigger:    manual | tag
      #   signing:    release-signed | debug-signed
      #   prerelease: true | false  (only meaningful for tag builds)
      #   asset_base: file-name stem for the produced APK/AAB
      #
      # Manual signed=true with missing secrets fails fast (preserves the old
      # behavior). A STABLE tag with missing secrets also fails fast. A
      # PRE-RELEASE tag (alpha/beta/rc) with missing secrets degrades to
      # clearly-labeled debug-signed artifacts (never passed off as a real
      # production release).
      - name: Determine build mode
        id: mode
        env:
          LINTHRA_KEYSTORE_BASE64: ${{ secrets.LINTHRA_KEYSTORE_BASE64 }}
          RELEASE_TAG_INPUT: ${{ inputs.release_tag }}
        run: |
          have_secrets=true
          for v in "$LINTHRA_KEYSTORE_BASE64" "$LINTHRA_KEYSTORE_PASSWORD" "$LINTHRA_KEY_ALIAS" "$LINTHRA_KEY_PASSWORD"; do
            [ -z "$v" ] && have_secrets=false
          done

          target_tag=""
          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            if [ -n "$RELEASE_TAG_INPUT" ]; then
              # Manual run targeting an existing release tag: behave like a tag
              # build (canonical asset names, pubspec preflight, APK version
              # check, attach to the GitHub Release for the tag), but use the
              # provided tag instead of github.ref_name (which is the branch
              # the workflow was dispatched from, not a tag).
              trigger=tag
              want_signed=true
              target_tag="$RELEASE_TAG_INPUT"
            else
              trigger=manual
              want_signed="${{ inputs.signed }}"
            fi
          else
            trigger=tag
            want_signed=true
            target_tag="${{ github.ref_name }}"
          fi

          prerelease=false
          if [ "$trigger" = "tag" ]; then
            # A SemVer pre-release: any tag containing alpha, beta, or rc.
            if printf '%s' "$target_tag" | grep -qiE '(alpha|beta|rc)'; then
              prerelease=true
            fi
          fi

          if [ "$want_signed" = "true" ]; then
            if [ "$have_secrets" = "true" ]; then
              signing=release-signed
            elif [ "$trigger" = "manual" ]; then
              echo "::error::Signed release requested but signing secrets are missing (need LINTHRA_KEYSTORE_BASE64, LINTHRA_KEYSTORE_PASSWORD, LINTHRA_KEY_ALIAS, LINTHRA_KEY_PASSWORD). See docs/release-signing.md."
              exit 1
            elif [ "$prerelease" = "true" ]; then
              echo "::warning::Pre-release tag but signing secrets are missing; producing DEBUG-KEY signed artifacts. These are FOR TESTING ONLY, not a production release. The Release asset is uploaded under the canonical -release-signed file name (kept stable for F-Droid), and the GitHub PRE-RELEASE notes flag the testing-only signing explicitly. Configure the LINTHRA_* secrets to get a truly release-signed build. See docs/release-signing.md."
              signing=debug-signed
            else
              echo "::error::Stable tag '${target_tag}' requires release signing, but the signing secrets are missing (need LINTHRA_KEYSTORE_BASE64, LINTHRA_KEYSTORE_PASSWORD, LINTHRA_KEY_ALIAS, LINTHRA_KEY_PASSWORD). Stable releases must not ship debug-signed artifacts. Configure the secrets, or use an alpha/beta/rc tag for a testing pre-release. See docs/release-signing.md."
              exit 1
            fi
          else
            signing=debug-signed
          fi

          # Tag builds always publish the Release asset under the canonical
          # `-release-signed` name so the F-Droid `Binaries:` URL stays stable
          # across releases. The actual signing mode is still surfaced in the
          # Release notes and build summary below — see the header comment.
          if [ "$trigger" = "tag" ]; then
            asset_base="linthra-${target_tag}-release-signed"
          else
            asset_base="linthra-${signing}"
          fi

          echo "trigger=$trigger" >> "$GITHUB_OUTPUT"
          echo "signing=$signing" >> "$GITHUB_OUTPUT"
          echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT"
          echo "asset_base=$asset_base" >> "$GITHUB_OUTPUT"
          echo "target_tag=$target_tag" >> "$GITHUB_OUTPUT"
          echo "Trigger: $trigger | Signing: $signing | Pre-release: $prerelease | Asset base: $asset_base | Target tag: $target_tag"

      - name: Decode release keystore
        if: ${{ steps.mode.outputs.signing == 'release-signed' }}
        env:
          LINTHRA_KEYSTORE_BASE64: ${{ secrets.LINTHRA_KEYSTORE_BASE64 }}
        run: |
          keystore_path="$RUNNER_TEMP/linthra-release.keystore"
          echo "$LINTHRA_KEYSTORE_BASE64" | base64 -d > "$keystore_path"
          echo "LINTHRA_KEYSTORE_PATH=$keystore_path" >> "$GITHUB_ENV"
          echo "Release keystore decoded to a temporary path."

      # Pin to Temurin JDK 17 — the version this Android toolchain
      # (AGP 8.2.1 / Gradle 8.7 / Kotlin 1.9.24) is built and tested against.
      # Newer JDKs on the GitHub runner have caused the lint/Jetifier step to
      # die on byte-buddy's multi-release classes ("Unsupported class file
      # major version 68"); JDK 17 keeps Gradle's bundled ASM compatible with
      # every class file it has to scan. setup-java exports JAVA_HOME, which
      # Flutter and the Gradle daemon pick up as the JDK for the build.
      - name: Set up Java
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"

      # Quick sanity check so a future JDK drift shows up directly in the log
      # instead of as a confusing lint/Jetifier crash.
      - name: Show Java environment
        run: |
          java -version
          echo "JAVA_HOME=$JAVA_HOME"

      # Keep this Flutter version in sync with .github/workflows/ci.yml.
      - name: Set up Flutter
        uses: subosito/flutter-action@v2
        with:
          channel: stable
          flutter-version: 3.27.4
          cache: true

      - name: Install dependencies
        run: flutter pub get

      # pubspec.yaml is the source of truth for the release version; each release
      # bumps `version: <name>+<code>` to match the tag (docs/release-process.md
      # §1 & §3). scripts/release_preflight.sh re-derives the canonical
      # versionName/versionCode from the pushed tag (same rules as
      # tool/version_from_tag.dart, kept in lockstep by
      # test/tooling/release_preflight_test.dart) and FAILS FAST if pubspec.yaml
      # does not match it — so a tag is never built against a stale pubspec, and
      # the F-Droid build (which reads pubspec.yaml) always agrees with this one.
      # A malformed tag also stops the run here, before any expensive build.
      #
      # On a tag/pubspec mismatch we ALSO write a clearly-labeled
      # "Version mismatch: release was not built." entry to the job summary so
      # the GitHub Actions run page reads as a release that did not build —
      # never confused with an APK build failure (the build never ran). The
      # contributor sees the same wording locally because they ran the same
      # script (see docs/release-process.md §3 step 9). We pass --no-app-info
      # here because the AppInfo drift is already covered by the CI workflow's
      # `flutter test` step (test/core/app_info_version_test.dart).
      - name: Verify the tag matches pubspec.yaml
        if: ${{ steps.mode.outputs.trigger == 'tag' }}
        id: version
        env:
          TARGET_TAG: ${{ steps.mode.outputs.target_tag }}
        run: |
          set -euo pipefail
          out_file="$RUNNER_TEMP/release-preflight.out"
          status=0
          scripts/release_preflight.sh "$TARGET_TAG" --no-app-info > "$out_file" 2>&1 || status=$?
          cat "$out_file"
          if [ "$status" -ne 0 ]; then
            # printf with single-quoted format strings: backslash is preserved
            # literally inside single quotes, so do not escape the backticks
            # (they're not metacharacters here either).
            {
              printf '### Version mismatch: release was not built.\n\n'
              printf 'The pushed tag did not match `pubspec.yaml` (or was malformed). '
              printf 'This is **not** an APK build failure — `flutter build` never ran.\n\n'
              printf 'See the workflow log for the exact tag/pubspec mismatch and the fix '
              printf '(usually: bump `pubspec.yaml` in a PR, merge it, then create the next '
              printf 'release tag from `main` — do not move the existing tag). '
              printf 'See docs/release-process.md §3.\n'
            } >> "$GITHUB_STEP_SUMMARY"
            echo "::error::Version mismatch: release was not built. See log above for the exact tag/pubspec mismatch and fix."
            exit "$status"
          fi
          grep -E '^LINTHRA_VERSION_(NAME|CODE)=' "$out_file" \
            | tee -a "$GITHUB_ENV" >> "$GITHUB_OUTPUT"

      # Plain release builds: both manual and tag builds read
      # versionName/versionCode (and the in-app version) from pubspec.yaml —
      # exactly as the F-Droid build does. For a tag build the step above has
      # already verified pubspec.yaml matches the tag, and the verification step
      # below confirms the built APKs carry that version.
      #
      # We do THREE kinds of build and stash each before the next runs so they
      # cannot clobber one another in build/app/outputs/:
      #   1. universal APK (`flutter build apk --release`)
      #   2. per-ABI APKs — built ONE ABI at a time, each in its own clean
      #      `flutter build apk --release --split-per-abi
      #      --target-platform=android-<arch>` invocation, exactly as F-Droid
      #      builds each versionCode. versionCodeOverride from
      #      android/app/build.gradle (base * 10 + abi rank) makes each per-ABI
      #      APK match the corresponding `versionCode` (…1 / …2 / …3) in
      #      metadata/io.github.thezupzup.linthra.yml. Building per-target (not
      #      all ABIs in one --split-per-abi pass) keeps the 64-bit split
      #      manifests byte-identical to F-Droid's — see the per-ABI step below
      #      and docs/fdroid-reproducibility-arm64.md.
      #   3. AAB (`flutter build appbundle --release`) — still universal; AAB
      #      already provides per-ABI delivery on its own.
      #
      # The per-ABI APKs are uploaded to the GitHub Release so F-Droid can use
      # them as reproducible upstream binaries (`binary:` URL per Build entry).
      # See the "Artifact naming" comment block at the top of this file.
      - name: Build release APK (universal)
        run: flutter build apk --release

      # Stash the universal APK immediately — the next `flutter build apk` run
      # uses the same gradle output directory and could clobber it.
      - name: Stash universal APK
        run: |
          mkdir -p dist
          base="${{ steps.mode.outputs.asset_base }}"
          cp build/app/outputs/flutter-apk/app-release.apk "dist/${base}.apk"

      # Build the per-ABI APKs the SAME way F-Droid builds each one: a single
      # `flutter build apk --release --split-per-abi --target-platform=android-<arch>`
      # in a clean tree, ONE ABI at a time. F-Droid's metadata builds each
      # versionCode (1059991/1059992/1059993) in its own fresh checkout with
      # exactly that command (see metadata/io.github.thezupzup.linthra.yml).
      #
      # This workflow used to build all three ABIs together in a single
      # `flutter build apk --release --split-per-abi` pass. That produced 64-bit
      # (arm64-v8a / x86_64) split APKs whose compiled AndroidManifest.xml
      # carried one extra source line before <application> compared to F-Droid's
      # single-ABI build (last manifest line 266 vs 265). The difference is
      # purely cosmetic line-number metadata — string pools, resources.arsc,
      # classes.dex and every lib/*.so are byte-identical — but it shifts the
      # manifest bytes, so F-Droid's reproducible-build verification could not
      # transplant the upstream signature onto its rebuild and failed with
      # "signature copying failed: APK Signing Block offset < central directory
      # offset" for arm64-v8a/x86_64 (armeabi-v7a happened to match, so it
      # verified). Building each ABI in isolation, exactly like F-Droid, makes
      # the uploaded reference APK byte-identical to F-Droid's rebuild.
      # See docs/fdroid-reproducibility-arm64.md.
      - name: Build per-ABI release APKs (isolated, matching F-Droid)
        run: |
          set -euo pipefail
          base="${{ steps.mode.outputs.asset_base }}"
          tmp="$RUNNER_TEMP/perabi"
          mkdir -p "$tmp"
          # ABI -> Flutter --target-platform, matching F-Droid's per-versionCode
          # build commands in metadata/io.github.thezupzup.linthra.yml.
          for entry in "armeabi-v7a:android-arm" "arm64-v8a:android-arm64" "x86_64:android-x64"; do
            abi="${entry%%:*}"
            plat="${entry##*:}"
            flutter clean
            flutter pub get
            flutter build apk --release --split-per-abi --target-platform="$plat"
            src="build/app/outputs/flutter-apk/app-${abi}-release.apk"
            if [ ! -f "$src" ]; then
              echo "::error::Per-ABI APK missing after build: $src"
              exit 1
            fi
            cp "$src" "$tmp/app-${abi}-release.apk"
            echo "Built $abi ($plat) in its own clean invocation."
          done
          # `flutter clean` above wiped build/app/outputs, so restore the
          # universal APK (already stashed) and all three per-ABI APKs to where
          # the existing Stash / Verify steps below expect them.
          mkdir -p build/app/outputs/flutter-apk
          cp "$tmp"/app-*-release.apk build/app/outputs/flutter-apk/
          cp "dist/${base}.apk" build/app/outputs/flutter-apk/app-release.apk

      # Stash the per-ABI APKs under their public/canonical names. The naming
      # MUST stay aligned with the per-Build `binary:` URLs in
      # metadata/io.github.thezupzup.linthra.yml — F-Droid downloads exactly
      # these file names from the GitHub Release as reference binaries.
      - name: Stash per-ABI APKs
        env:
          TARGET_TAG: ${{ steps.mode.outputs.target_tag }}
        run: |
          set -euo pipefail
          trigger="${{ steps.mode.outputs.trigger }}"
          signing="${{ steps.mode.outputs.signing }}"
          tag="$TARGET_TAG"
          for abi in armeabi-v7a arm64-v8a x86_64; do
            src="build/app/outputs/flutter-apk/app-${abi}-release.apk"
            if [ ! -f "$src" ]; then
              echo "::error::Per-ABI APK missing: $src"
              exit 1
            fi
            if [ "$trigger" = "tag" ]; then
              name="linthra-${tag}-${abi}-release-signed"
            else
              name="linthra-${signing}-${abi}"
            fi
            cp "$src" "dist/${name}.apk"
            echo "Staged dist/${name}.apk"
          done

      - name: Build release AAB
        run: flutter build appbundle --release

      - name: Stash AAB
        run: |
          base="${{ steps.mode.outputs.asset_base }}"
          cp build/app/outputs/bundle/release/app-release.aab "dist/${base}.aab"
          ls -l dist

      # Confirm the built APKs actually carry the tag-derived version, so a
      # release can never ship stale metadata. Uses aapt from the Android SDK
      # the build just used; if it cannot be located the check warns rather
      # than blocks (a missing tool is not a version mismatch).
      #
      # The universal APK must match versionName/versionCode exactly. Each
      # per-ABI APK must match versionName, and its versionCode must equal
      # `base * 10 + abi_rank` (armeabi-v7a=1, arm64-v8a=2, x86_64=3) — the
      # same rule encoded in android/app/build.gradle's versionCodeOverride
      # and in metadata/io.github.thezupzup.linthra.yml's VercodeOperation.
      - name: Verify APK versions match the tag
        if: ${{ steps.mode.outputs.trigger == 'tag' }}
        run: |
          set -euo pipefail
          sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
          aapt=""
          if [ -n "$sdk" ]; then
            aapt="$(find "$sdk"/build-tools -name aapt -type f 2>/dev/null | sort -V | tail -n1 || true)"
          fi
          if [ -z "$aapt" ]; then
            echo "::warning::aapt not found (ANDROID_HOME/ANDROID_SDK_ROOT); skipping APK version verification."
            exit 0
          fi

          check_apk() {
            local apk="$1"
            local expected_name="$2"
            local expected_code="$3"
            local badging got_name got_code
            badging="$("$aapt" dump badging "$apk")"
            got_name="$(printf '%s\n' "$badging" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p")"
            got_code="$(printf '%s\n' "$badging" | sed -n "s/.*versionCode='\([^']*\)'.*/\1/p")"
            echo "  $apk reports versionName=$got_name versionCode=$got_code (expected $expected_name / $expected_code)"
            if [ "$got_name" != "$expected_name" ] || [ "$got_code" != "$expected_code" ]; then
              echo "::error::APK $apk version ($got_name/$got_code) does not match expected ($expected_name/$expected_code)."
              return 1
            fi
          }

          echo "Verifying universal APK against tag-derived version..."
          check_apk "build/app/outputs/flutter-apk/app-release.apk" \
            "$LINTHRA_VERSION_NAME" "$LINTHRA_VERSION_CODE"

          echo "Verifying per-ABI APKs against base * 10 + abi rank..."
          for pair in armeabi-v7a:1 arm64-v8a:2 x86_64:3; do
            abi="${pair%%:*}"
            rank="${pair##*:}"
            expected_code=$((LINTHRA_VERSION_CODE * 10 + rank))
            check_apk "build/app/outputs/flutter-apk/app-${abi}-release.apk" \
              "$LINTHRA_VERSION_NAME" "$expected_code"
          done

          echo "Verified: all APK versions match the tag-derived version."

      - name: Upload release APK
        uses: actions/upload-artifact@v4
        with:
          name: linthra-${{ steps.mode.outputs.signing }}-apk
          path: dist/${{ steps.mode.outputs.asset_base }}.apk
          if-no-files-found: error

      - name: Upload release AAB
        uses: actions/upload-artifact@v4
        with:
          name: linthra-${{ steps.mode.outputs.signing }}-aab
          path: dist/${{ steps.mode.outputs.asset_base }}.aab
          if-no-files-found: error

      # One zipped artifact for all three per-ABI APKs, downloaded together by
      # the attach-release job. The names inside the zip stay canonical so
      # they upload to the GitHub Release verbatim.
      - name: Upload per-ABI APKs
        uses: actions/upload-artifact@v4
        with:
          name: linthra-${{ steps.mode.outputs.signing }}-apk-per-abi
          path: |
            dist/*-armeabi-v7a*.apk
            dist/*-arm64-v8a*.apk
            dist/*-x86_64*.apk
          if-no-files-found: error

      - name: Build summary
        run: |
          {
            echo "## Linthra release build"
            echo ""
            echo "**Trigger:** ${{ steps.mode.outputs.trigger }}${{ steps.mode.outputs.target_tag != '' && format(' (tag {0}{1})', steps.mode.outputs.target_tag, github.event_name == 'workflow_dispatch' && ', manual dispatch' || '') || '' }}"
            echo ""
            if [ "${{ steps.mode.outputs.trigger }}" = "tag" ]; then
              echo "**Version:** \`${LINTHRA_VERSION_NAME}\` (universal versionName) / \`${LINTHRA_VERSION_CODE}\` (universal versionCode), from \`pubspec.yaml\` and verified to match the tag. The APK/AAB metadata and the in-app Settings/About version all use this value. The per-ABI APKs share the versionName and override the versionCode to \`${LINTHRA_VERSION_CODE}1\` / \`${LINTHRA_VERSION_CODE}2\` / \`${LINTHRA_VERSION_CODE}3\` (armeabi-v7a / arm64-v8a / x86_64), matching \`metadata/io.github.thezupzup.linthra.yml\`."
              echo ""
            else
              echo "**Version:** from \`pubspec.yaml\` (manual build — not a tagged release)."
              echo ""
            fi
            echo "**Artifacts:**"
            echo ""
            for f in dist/*.apk dist/*.aab; do
              [ -f "$f" ] || continue
              echo "- \`$(basename "$f")\`"
            done
            echo ""
            if [ "${{ steps.mode.outputs.signing }}" = "release-signed" ]; then
              echo "**Signing:** release-signed with the configured keystore."
              if [ "${{ steps.mode.outputs.trigger }}" = "tag" ]; then
                echo ""
                if [ "${{ steps.mode.outputs.prerelease }}" = "true" ]; then
                  echo "These release-signed artifacts will be attached to a GitHub **pre-release** for this tag (created if it does not exist)."
                else
                  echo "These release-signed artifacts will be attached to the GitHub Release for this tag if one exists."
                fi
              fi
            else
              echo "**Signing:** DEBUG-KEY signed — FOR TESTING ONLY, NOT a production release."
              echo ""
              if [ "${{ steps.mode.outputs.trigger }}" = "tag" ] && [ "${{ steps.mode.outputs.prerelease }}" = "true" ]; then
                echo "These debug-signed artifacts will be attached to a GitHub **pre-release** for this tag (created if it does not exist), clearly labeled as testing-only builds."
                echo ""
              fi
              echo "Configure the LINTHRA_* secrets (and, for manual runs, set \`signed = true\`) to"
              echo "produce a release-signed build. See docs/release-signing.md."
            fi
          } >> "$GITHUB_STEP_SUMMARY"

  # Attaches the built artifacts to a GitHub Release for the pushed tag. This is
  # the only job that needs write access, so it is the only one granted
  # `contents: write`.
  #
  #   * Pre-release tags (alpha/beta/rc): CREATE a pre-release if none exists,
  #     then upload/replace the APK/AAB. Debug-signed artifacts are attached with
  #     a clear "testing only" label and the Release is marked as a pre-release.
  #
  #   * Stable tags: only attach (upload/replace) to a Release that already
  #     exists. The workflow never auto-creates a stable Release or writes its
  #     production notes.
  attach-release:
    name: Attach artifacts to GitHub Release
    needs: build-release
    if: ${{ needs.build-release.outputs.target_tag != '' && (needs.build-release.outputs.signing == 'release-signed' || needs.build-release.outputs.prerelease == 'true') }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          path: artifacts

      - name: Attach to GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
          TAG: ${{ needs.build-release.outputs.target_tag }}
          SIGNING: ${{ needs.build-release.outputs.signing }}
          PRERELEASE: ${{ needs.build-release.outputs.prerelease }}
        run: |
          set -euo pipefail

          # Collect every APK and the AAB. The universal APK, the three
          # per-ABI APKs, and the AAB all need to land on the Release — the
          # per-ABI APKs are what F-Droid's `binary:` URLs point at (see
          # metadata/io.github.thezupzup.linthra.yml).
          mapfile -t assets < <(find artifacts -type f \( -name '*.apk' -o -name '*.aab' \) | sort)
          if [ "${#assets[@]}" -eq 0 ]; then
            echo "::error::Could not locate any APK/AAB among downloaded artifacts."
            exit 1
          fi
          aab_count=0
          apk_count=0
          per_abi_count=0
          for f in "${assets[@]}"; do
            case "$f" in
              *.aab) aab_count=$((aab_count + 1));;
              *armeabi-v7a*.apk|*arm64-v8a*.apk|*x86_64*.apk) per_abi_count=$((per_abi_count + 1));;
              *.apk) apk_count=$((apk_count + 1));;
            esac
            echo "  - $f"
          done
          echo "Found $apk_count universal APK(s), $per_abi_count per-ABI APK(s), $aab_count AAB(s)."
          if [ "$apk_count" -lt 1 ] || [ "$aab_count" -lt 1 ] || [ "$per_abi_count" -lt 3 ]; then
            echo "::error::Expected at least one universal APK, three per-ABI APKs, and one AAB."
            exit 1
          fi

          release_exists=false
          if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
            release_exists=true
          fi

          if [ "$release_exists" = "true" ]; then
            echo "GitHub Release '$TAG' exists; uploading/replacing assets."
            gh release upload "$TAG" "${assets[@]}" --clobber --repo "$GITHUB_REPOSITORY"
            echo "Attached $SIGNING universal + per-ABI APKs and AAB to Release '$TAG'."
            exit 0
          fi

          if [ "$PRERELEASE" != "true" ]; then
            # Stable tag with no Release yet: do not auto-create. Notes are manual.
            echo "::notice::No GitHub Release exists for stable tag '$TAG' yet, so nothing was attached. The release-signed APK/AAB are available as workflow artifacts. Create the Release (with your own notes) and re-run this workflow, or attach the artifacts manually."
            exit 0
          fi

          # Pre-release tag with no Release yet: create one (marked pre-release)
          # with a clear, honest body and attach the artifacts. Notes are
          # written to a file (no heredocs, to keep this YAML block valid).
          notes_file="$RUNNER_TEMP/release-notes.md"
          if [ "$SIGNING" = "debug-signed" ]; then
            {
              printf '%s\n\n' '> **Pre-release / testing build — DEBUG-SIGNED.**'
              printf '%s\n' 'The attached APK/AAB are **DEBUG-KEY signed** and are **for testing only**.'
              printf '%s\n\n' 'They are **not** a production release, **not** release-signed, and must **not** be distributed as such.'
            } > "$notes_file"
          else
            {
              printf '%s\n\n' '**Pre-release build — release-signed.**'
              printf '%s\n\n' 'The attached APK/AAB are release-signed with the project keystore. This is a pre-release for testing / early access.'
            } > "$notes_file"
          fi
          {
            printf '%s\n' 'Per-ABI APKs are attached alongside the universal APK and the AAB so F-Droid can reference reproducible upstream binaries for each ABI build (MR !39329).'
            printf '%s\n' 'This Release was created automatically by the Android Release Build workflow on a pre-release tag. Edit these notes to describe the build.'
            printf '%s\n' 'Nothing here is published to the Play Store or F-Droid (F-Droid builds and signs separately).'
          } >> "$notes_file"

          echo "No GitHub Release for pre-release tag '$TAG'; creating one (pre-release)."
          gh release create "$TAG" "${assets[@]}" \
            --repo "$GITHUB_REPOSITORY" \
            --title "$TAG" \
            --prerelease \
            --notes-file "$notes_file"
          echo "Created pre-release '$TAG' and attached $SIGNING universal + per-ABI APKs and AAB."
