diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index fc58d39f..9e7a3f02 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -1,19 +1,14 @@ name: android # Native Android (Kotlin/Compose/Media3) — M8 rewrite, now the only client. -# At cutover (M8 phase 14.4) the asset name flipped from -# minstrel-android-.apk to minstrel-.apk so the server's bundled -# in-app-update channel (release.yml polls this name) resolves to the -# native APK with no further changes. -# -# Tag format: vYYYY.MM.DD (per-day CalVer, mutable within the day). If a -# tag is force-moved within the day, this workflow re-runs and overwrites -# the existing release asset — same name, new content. +# This workflow is testing only — lint + detekt + unit tests on every push +# to dev/main, plus a debug APK artifact for main. The signed-release +# build + asset attach + image-bundling lives in release.yml under a +# `needs:` chain so the docker image cannot ship without the APK. on: push: branches: [main, dev] - tags: ['v*'] paths: - 'android/**' - '.gitea/workflows/android.yml' @@ -92,90 +87,3 @@ jobs: with: name: minstrel-android-debug-${{ github.sha }} path: android/app/build/outputs/apk/debug/app-debug.apk - - release: - name: Build signed release APK - needs: build - 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: - # PKCS12 keystores collapse store + key password into a single - # value, so both env vars map to one secret. build.gradle still - # 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 }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Make gradlew executable - run: chmod +x ./gradlew - - - name: Decode signing keystore - # Reuses the same keystore env-var contract as flutter.yml so the - # native APK is signed with the same key — Android will accept - # the upgrade in place at cutover without uninstall. - 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 - - - name: Attach APK to release - # M8 phase 14.4 cutover: the asset is now minstrel-.apk so - # release.yml's bundled in-app-update fetcher (which polls this - # exact filename) resolves to the native APK with no further - # workflow plumbing. - # - # Previous version of this step silently exited 0 on a failed - # upload because the curl exit code was being swallowed by the - # implicit shell. set -euxo pipefail surfaces it, and -w "...\n" - # forces curl to print the HTTP status of the upload so a 4xx - # is visible in the log. - 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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 9257c253..83789234 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -2,8 +2,8 @@ name: release # Builds and pushes the minstrel container image to the Gitea registry. # -# push to main → :main and :latest -# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest +# 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 @@ -11,17 +11,23 @@ name: release # 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*'] - # Tag pushes still build (tag releases are intentional, server + Flutter - # share the version). Branch pushes skip when the diff is Flutter-only — - # rebuilding the Go image for a Flutter-only merge wastes CI and churns - # the registry. paths-ignore: - - 'flutter_client/**' - 'docs/**' - '**/*.md' workflow_dispatch: @@ -34,7 +40,108 @@ concurrency: cancel-in-progress: true jobs: - release: + 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 }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - 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 + + - 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 @@ -84,33 +191,28 @@ jobs: echo "${{ secrets.CI_TOKEN }}" \ | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin - # In-app update flow (#397): on tag pushes only, fetch the APK - # that flutter.yml is attaching to this same release. flutter.yml - # runs in parallel; poll up to 15 min for the asset to appear. - # On main pushes (or if the APK never lands), client/ stays empty - # and the endpoints return 404 — graceful degradation. - - name: Fetch APK for bundled in-app update channel + - 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: - CI_TOKEN: ${{ secrets.CI_TOKEN }} run: | + set -euxo pipefail TAG="${GITHUB_REF#refs/tags/}" - REPO="${GITHUB_REPOSITORY}" - APK_URL="https://git.fabledsword.com/${REPO}/releases/download/${TAG}/minstrel-${TAG}.apk" - echo "Polling ${APK_URL} (up to 15 min for flutter.yml to attach)..." - for i in $(seq 1 30); do - if curl -fsSL -H "Authorization: token ${CI_TOKEN}" \ - -o client/minstrel.apk "$APK_URL"; then - echo "Got APK on attempt $i" - echo "${TAG}" > client/minstrel.apk.version - ls -lh client/ - exit 0 - fi - echo "APK not ready yet (attempt $i/30), sleeping 30s..." - sleep 30 - done - echo "::warning::APK never appeared at ${APK_URL} after 15 min — image will ship without bundled update channel" + # 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 "${TAG}" > client/minstrel.apk.version + ls -lh client/ - name: Build and push if: steps.guard.outputs.ready == 'true'