name: release-apk on: push: branches: [main] # Lets a release be rebuilt from the current main without a dummy commit. workflow_dispatch: # gh release create needs write access to the repo's contents. permissions: contents: write # Releases are tag-anchored; two concurrent runs would race on the same tag. concurrency: group: release-apk-${{ github.ref }} cancel-in-progress: false # NOTE: versionCode is the commit count (`git rev-list --count HEAD`), which # only ever increases and tracks the code rather than the CI file. It replaced # github.run_number on 2026-08-09: run_number counts workflow RUNS, so it # resets to 1 if this file is renamed AND it can fall behind a build made # outside this workflow — which happened, leaving CI unable to install over a # manually sideloaded APK (INSTALL_FAILED_VERSION_DOWNGRADE). The checkout # below therefore needs fetch-depth: 0; a shallow clone counts 1 commit. # # This job is NOT gated on a test suite: this repo has no Flutter analyze/test # job to depend on, so a broken commit still publishes. The build failing is # the only backstop. Add `needs:` here if a Flutter CI job is ever added. jobs: release: name: release (APK) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: # versionCode is the commit count; a shallow clone would report 1. fetch-depth: 0 - name: Derive versionCode from the commit count id: vc run: echo "code=$(git rev-list --count HEAD)" >> "$GITHUB_OUTPUT" - uses: actions/setup-java@v4 with: distribution: temurin java-version: "17" - uses: subosito/flutter-action@v2 with: flutter-version: 3.47.6 channel: stable cache: true # Caches Gradle's dependency + build cache (not covered by # flutter-action's cache, which only covers the Flutter SDK/pub # packages). This is what the ~5min "Build signed APK" step was mostly # spending on a cold runner: every run re-downloaded the Gradle # wrapper and every AGP/Kotlin/dependency artifact from scratch. - uses: gradle/actions/setup-gradle@v4 - name: Read version from pubspec id: ver run: | set -euo pipefail # 1.0.0+1 -> 1.0.0; the +build part is replaced by the run number. raw=$(grep -m1 -E '^version:' pubspec.yaml | sed -E 's/^version:[[:space:]]*//') semver=${raw%%+*} if ! echo "$semver" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then echo "::error::pubspec version '$raw' is not X.Y.Z"; exit 1 fi echo "semver=$semver" >> "$GITHUB_OUTPUT" # One step, no echoes, printf-into-file rather than echo: the key must # never reach the log. storeFile is ABSOLUTE because key.properties is # read from android/ while file() resolves against android/app/. - name: Materialise the release keystore env: KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} STORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | set -euo pipefail if [ -z "$KEYSTORE_B64" ]; then echo "::error::ANDROID_KEYSTORE_BASE64 is not set; refusing to publish." exit 1 fi umask 077 ks="$GITHUB_WORKSPACE/android/app/kuhy-release.jks" printf '%s' "$KEYSTORE_B64" | base64 -d > "$ks" { printf 'storeFile=%s\n' "$ks" printf 'storePassword=%s\n' "$STORE_PASS" printf 'keyAlias=%s\n' "$KEY_ALIAS" printf 'keyPassword=%s\n' "$KEY_PASS" } > "$GITHUB_WORKSPACE/android/key.properties" ls -l "$ks" - run: flutter pub get # arm64-v8a only: this is a personal single-device (BL9000) sideload # distribution, not a Play Store multi-device release, and every # device it has ever installed on is arm64-v8a (`adb shell getprop # ro.product.cpu.abi`). A fat APK spent most of its build time # compiling/dexing/packaging armeabi-v7a and x86_64 native code that # is never installed anywhere. If this ever needs to run on a # non-arm64 device, drop --target-platform to rebuild the fat APK. - name: Build signed APK run: | flutter build apk --release \ --target-platform android-arm64 \ --build-name="${{ steps.ver.outputs.semver }}" \ --build-number="${{ steps.vc.outputs.code }}" # Fail closed. `findByName("release") ?: debug` silently falls back to # the debug key if key.properties is misplaced, producing a green build # that can never be installed over. This is the check that catches it. - name: Verify the APK is release-signed run: | set -euo pipefail apk=build/app/outputs/flutter-apk/app-release.apk signer=$(ls -d "$ANDROID_SDK_ROOT"/build-tools/* | sort -V | tail -1)/apksigner "$signer" verify --print-certs "$apk" | tee /tmp/certs.txt if grep -qi 'CN=Android Debug' /tmp/certs.txt; then echo "::error::APK is debug-signed; not publishing."; exit 1 fi if ! grep -q 'CN=kuhy' /tmp/certs.txt; then echo "::error::APK is not signed with the kuhy release key."; exit 1 fi - name: Shred the signing material if: always() run: | rm -f "$GITHUB_WORKSPACE/android/key.properties" \ "$GITHUB_WORKSPACE/android/app/kuhy-release.jks" - name: Name the asset id: art run: | set -euo pipefail out="todo-${{ steps.ver.outputs.semver }}-build.${{ steps.vc.outputs.code }}.apk" cp build/app/outputs/flutter-apk/app-release.apk "$out" echo "path=$out" >> "$GITHUB_OUTPUT" - name: Publish the per-commit prerelease env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail tag="v${{ steps.ver.outputs.semver }}-build.${{ steps.vc.outputs.code }}" gh release create "$tag" "${{ steps.art.outputs.path }}" \ --prerelease \ --target "${{ github.sha }}" \ --title "$tag" \ --notes "Automated build of ${{ github.sha }} (versionCode ${{ steps.vc.outputs.code }})." # Cut a clean vX.Y.Z the first time a pubspec version is seen. A failing # `gh release view` is the "not published yet" signal, which also covers # the first-ever run on a repo with no releases. - name: Publish the clean version release env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail tag="v${{ steps.ver.outputs.semver }}" if gh release view "$tag" >/dev/null 2>&1; then echo "$tag already exists; leaving it as-is."; exit 0 fi if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then echo "Tag $tag exists without a release; refusing to clobber."; exit 0 fi gh release create "$tag" "${{ steps.art.outputs.path }}" \ --target "${{ github.sha }}" \ --title "$tag" \ --notes "Release $tag (versionCode ${{ steps.vc.outputs.code }})."