diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml new file mode 100644 index 0000000..b76a53c --- /dev/null +++ b/.github/workflows/release-apk.yml @@ -0,0 +1,152 @@ +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 github.run_number, which is per-workflow-FILE. Never +# rename or replace this file — the counter resets to 1, versionCode goes +# backwards, and Android silently refuses every future update. +# +# 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 + defaults: + run: + working-directory: stronglift_replacement/workout_app + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - uses: subosito/flutter-action@v2 + with: + flutter-version: 3.44.6 + channel: stable + cache: true + + - 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/stronglift_replacement/workout_app/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/stronglift_replacement/workout_app/android/key.properties" + ls -l "$ks" + + - run: flutter pub get + + - name: Build signed APK + run: | + flutter build apk --release \ + --build-name="${{ steps.ver.outputs.semver }}" \ + --build-number="${{ github.run_number }}" + + # 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/stronglift_replacement/workout_app/android/key.properties" \ + "$GITHUB_WORKSPACE/stronglift_replacement/workout_app/android/app/kuhy-release.jks" + + - name: Name the asset + id: art + run: | + set -euo pipefail + out="workout-app-${{ steps.ver.outputs.semver }}-build.${{ github.run_number }}.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.${{ github.run_number }}" + gh release create "$tag" "${{ steps.art.outputs.path }}" \ + --prerelease \ + --target "${{ github.sha }}" \ + --title "$tag" \ + --notes "Automated build of ${{ github.sha }} (versionCode ${{ github.run_number }})." + + # 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 ${{ github.run_number }})." diff --git a/stronglift_replacement/workout_app/android/app/build.gradle.kts b/stronglift_replacement/workout_app/android/app/build.gradle.kts index ef334e4..d693069 100644 --- a/stronglift_replacement/workout_app/android/app/build.gradle.kts +++ b/stronglift_replacement/workout_app/android/app/build.gradle.kts @@ -1,9 +1,21 @@ +import java.util.Properties + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } +// Release signing key, kept out of the repo. CI writes key.properties from +// repository secrets; locally the file is absent and the build falls back to +// the debug key so `flutter run --release` still works. Every release APK is +// signed with the one shared key, so updates install over each other instead +// of forcing an uninstall. +val keystoreProperties = Properties().apply { + val f = rootProject.file("key.properties") + if (f.exists()) f.inputStream().use { load(it) } +} + android { namespace = "com.kuhy.workout_app" compileSdk = flutter.compileSdkVersion @@ -25,11 +37,23 @@ android { versionName = flutter.versionName } + signingConfigs { + if (keystoreProperties.getProperty("storeFile") != null) { + create("release") { + storeFile = file(keystoreProperties.getProperty("storeFile")) + storePassword = keystoreProperties.getProperty("storePassword") + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Falls back to the debug key only when key.properties is absent + // (a fresh clone or a local run), so `flutter run --release` works. + signingConfig = signingConfigs.findByName("release") + ?: signingConfigs.getByName("debug") } } }