name: release

# Builds and pushes the minstrel container image to the Gitea registry.
#
#   push to main             → :main and :latest (no APK bundled)
#   push tag vYYYY.MM.DD     → :vYYYY.MM.DD and :latest (APK bundled)
#   workflow_dispatch        → manual trigger (same rules based on the ref)
#
# Release model: per-day CalVer tags (no trailing patch digit). The day's
# tag is intentionally mutable — if a second release happens the same day,
# move the tag with `git push -f origin vYYYY.MM.DD` and the image tag of
# the same name gets overwritten. :latest is updated by every main push
# AND every tag push, so it always reflects the newest blessed image.
#
# APK pipeline: on tag pushes the android-release job builds + signs the
# Android APK and uploads it as a workflow artifact. The image-release
# job declares `needs: android-release`, so the docker image cannot
# start building until the APK is guaranteed-ready — no polling, no
# race, no silent-failure mode. Asset attachment to the gitea Release
# happens in the same android-release job, so the Release-page download
# link and the in-image bundled APK are both populated atomically.
#
# Android testing (lint + detekt + unit tests, debug APK upload on main)
# lives in android.yml and runs independently on every push.

on:
  push:
    branches: [main]
    tags: ['v*']
    paths-ignore:
      - 'docs/**'
      - '**/*.md'
  workflow_dispatch:

# Force-moving the per-day tag (or rapidly re-pushing to main) should
# supersede the in-flight build — the operator explicitly wants the
# later commit to win.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  android-release:
    name: Build signed APK (tag releases only)
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: flutter-ci
    container:
      image: git.fabledsword.com/bvandeusen/ci-android:36

    defaults:
      run:
        working-directory: android

    env:
      JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
      # PKCS12 keystores collapse store + key password into a single
      # value; both env vars map to one secret. build.gradle reads them
      # separately to stay format-agnostic.
      ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
      ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
      ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}

    # Job outputs propagate the computed release version to image-release
    # so the bundled sidecar file matches what's baked into the APK —
    # otherwise the server would report a different version string than
    # the installed client and the update banner could thrash.
    outputs:
      version_name: ${{ steps.ver.outputs.name }}
      version_code: ${{ steps.ver.outputs.code }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          # fetch-depth: 0 retrieves full history; default shallow clone
          # would return 1 for `git rev-list --count HEAD`, breaking the
          # iteration suffix.
          fetch-depth: 0

      - name: Compute release version
        id: ver
        shell: bash
        working-directory: ${{ github.workspace }}
        run: |
          set -euo pipefail
          TAG="${GITHUB_REF#refs/tags/v}"
          COMMIT_COUNT=$(git rev-list --count HEAD)
          VERSION_NAME="${TAG}.${COMMIT_COUNT}"
          echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
          echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
          echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"

      - name: Cache Gradle dirs
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
            ~/.kotlin
          key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts') }}
          restore-keys: |
            gradle-${{ runner.os }}-

      - name: Make gradlew executable
        run: chmod +x ./gradlew

      - name: Decode signing keystore
        env:
          ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
        shell: bash
        run: |
          if [ -z "${ANDROID_KEYSTORE_B64}" ]; then
            echo "::error::ANDROID_KEYSTORE_B64 missing"; exit 1
          fi
          KEYSTORE_PATH="${RUNNER_TEMP}/minstrel-release.keystore"
          echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE_PATH}"
          echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"

      - name: Build release APK
        run: |
          ./gradlew assembleRelease \
            -PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
            -PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}

      - name: Upload APK as workflow artifact
        # @v3 because Gitea Actions emulates GHES and the v2 artifact
        # backend used by upload-artifact@v4 errors with GHESNotSupportedError.
        uses: actions/upload-artifact@v3
        with:
          name: minstrel-apk
          path: android/app/build/outputs/apk/release/app-release.apk

      - name: Attach APK to gitea Release
        shell: bash
        env:
          CI_TOKEN: ${{ secrets.CI_TOKEN }}
        run: |
          set -euxo pipefail
          TAG="${GITHUB_REF#refs/tags/}"
          REPO="${GITHUB_REPOSITORY}"
          APK_PATH="app/build/outputs/apk/release/app-release.apk"
          ls -lh "${APK_PATH}"

          RELEASE_JSON="$(curl -fsSL \
            -H "Authorization: token ${CI_TOKEN}" \
            "https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")"
          RELEASE_ID="$(printf '%s' "${RELEASE_JSON}" | grep -oP '"id":\s*\K[0-9]+' | head -1)"
          if [ -z "${RELEASE_ID}" ]; then
            echo "::error::release for ${TAG} not found"; exit 1
          fi
          echo "release_id=${RELEASE_ID}"

          UPLOAD_HTTP=$(curl -sS -L -o /tmp/upload.out -w '%{http_code}' \
            -H "Authorization: token ${CI_TOKEN}" \
            -F "attachment=@${APK_PATH}" \
            "https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk")
          echo "upload_http=${UPLOAD_HTTP}"
          cat /tmp/upload.out || true
          echo
          if [ "${UPLOAD_HTTP}" -lt 200 ] || [ "${UPLOAD_HTTP}" -ge 300 ]; then
            echo "::error::APK upload returned HTTP ${UPLOAD_HTTP}"
            exit 1
          fi

  image-release:
    name: Build + push container image
    # `needs:` waits for android-release. For tag pushes android-release
    # runs and must succeed before this job starts — guaranteeing the
    # APK artifact is present. For main pushes android-release is
    # skipped; the `if: ...` below lets this job run anyway and the
    # download/copy steps gate themselves on the tag context.
    needs: [android-release]
    if: ${{ !failure() && !cancelled() }}
    runs-on: go-ci
    container:
      image: git.fabledsword.com/bvandeusen/ci-go:1.26

    env:
      IMAGE: git.fabledsword.com/bvandeusen/minstrel

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Detect buildable project
        id: guard
        shell: bash
        run: |
          if [ -f Dockerfile ] && [ -f go.mod ]; then
            echo "ready=true" >> "$GITHUB_OUTPUT"
          else
            echo "ready=false" >> "$GITHUB_OUTPUT"
            echo "::notice::No Dockerfile + go.mod yet — release build skipped"
          fi

      - name: Compute image tags
        id: tags
        if: steps.guard.outputs.ready == 'true'
        shell: bash
        run: |
          if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
            VERSION="${GITHUB_REF#refs/tags/}"
            echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
            echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
            echo "::notice::Release build: ${VERSION} + latest"
          else
            # Main is the protected, post-PR-merge branch. Treat it as the
            # rolling stable channel — every main push moves :latest.
            # Pinned consumers can target :vYYYY.MM.DD; everyone else
            # gets the newest main.
            echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
            echo "version=main" >> "$GITHUB_OUTPUT"
            echo "::notice::Main-branch build: :main + :latest"
          fi

      - name: Registry login
        if: steps.guard.outputs.ready == 'true'
        shell: bash
        run: |
          echo "${{ secrets.CI_TOKEN }}" \
            | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin

      - name: Download signed APK artifact
        # Tag pushes only — android-release just produced this. Main
        # pushes skip and the image ships with empty client/ (the
        # /api/client/version endpoint then returns 404 by design).
        if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
        uses: actions/download-artifact@v3
        with:
          name: minstrel-apk
          path: client/

      - name: Stage bundled APK + version sidecar
        if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
        shell: bash
        env:
          # Pulled from android-release.outputs.version_name so the
          # sidecar string the server hands clients matches the
          # versionName baked into the APK they're comparing against.
          APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
        run: |
          set -euxo pipefail
          # The artifact lands as `app-release.apk` (the original Gradle
          # output name). The Dockerfile COPYs client/* into /app/client/
          # and the server reads minstrel.apk + minstrel.apk.version.
          mv client/app-release.apk client/minstrel.apk
          echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
          ls -lh client/

      - name: Build and push
        if: steps.guard.outputs.ready == 'true'
        run: |
          docker buildx build \
            --build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
            --push ${{ steps.tags.outputs.args }} .
