diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bd4bdfb..140b871 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,14 @@ name: Build +# One workflow for every event. The build job runs for pushes, pull requests and +# version tags and always produces the unsigned release APK. On a vMAJOR.MINOR.PATCH +# tag the release job takes that exact artifact, signs it with the keystore held in +# repository secrets, and attaches it to a GitHub Release. The key is only ever +# decoded inside the release job. on: push: branches: [main] + tags: ["v[0-9]+.[0-9]+.[0-9]+"] pull_request: workflow_dispatch: @@ -11,7 +17,7 @@ permissions: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} jobs: build: @@ -37,13 +43,22 @@ jobs: - name: Lint run: ./gradlew lintDebug + # A version tag stamps versionName/versionCode into the build (see + # app/build.gradle.kts); every other event builds with the in-repo defaults. - name: Build release APK - run: ./gradlew assembleRelease + run: | + set -euo pipefail + args=() + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + args+=("-PreleaseVersion=${GITHUB_REF_NAME#v}") + fi + ./gradlew assembleRelease "${args[@]}" - uses: actions/upload-artifact@v7 with: name: RemoteSync path: app/build/outputs/apk/release/*.apk + if-no-files-found: error - uses: actions/upload-artifact@v7 if: always() @@ -52,3 +67,53 @@ jobs: path: | **/build/reports/lint-results-*.html **/build/reports/tests/ + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v7 + with: + name: RemoteSync + path: apk + + - uses: android-actions/setup-android@v3 + with: + packages: build-tools;36.0.0 + + # apksigner is the SDK's own signing tool + # (https://developer.android.com/tools/apksigner); it re-signs the artifact the + # build job produced, so what ships is byte-for-byte what was tested. + - name: Sign the build artifact + env: + SIGNING_KEYSTORE_BASE64: ${{ secrets.SIGNING_KEYSTORE_BASE64 }} + SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} + SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} + SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} + run: | + set -euo pipefail + umask 077 + printf '%s' "$SIGNING_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks" + unsigned=$(ls apk/*.apk) + "$ANDROID_HOME/build-tools/36.0.0/apksigner" sign \ + --ks "$RUNNER_TEMP/release.jks" \ + --ks-key-alias "$SIGNING_KEY_ALIAS" \ + --ks-pass env:SIGNING_STORE_PASSWORD \ + --key-pass env:SIGNING_KEY_PASSWORD \ + --out "RemoteSync-${GITHUB_REF_NAME}.apk" \ + "$unsigned" + "$ANDROID_HOME/build-tools/36.0.0/apksigner" verify --print-certs "RemoteSync-${GITHUB_REF_NAME}.apk" + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: gh release create "$GITHUB_REF_NAME" "RemoteSync-${GITHUB_REF_NAME}.apk" --title "$GITHUB_REF_NAME" --generate-notes + + - name: Remove signing key + if: always() + run: rm -f "$RUNNER_TEMP/release.jks" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index c840379..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Release - -# Tag a commit on main as vMAJOR.MINOR.PATCH and push the tag; this signs the release -# APK with the keystore held in repository secrets and attaches it to a GitHub Release. -on: - push: - tags: ["v[0-9]+.[0-9]+.[0-9]+"] - -permissions: - contents: write - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - -jobs: - release: - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 21 - - - uses: android-actions/setup-android@v3 - with: - packages: "" - - - uses: gradle/actions/setup-gradle@v5 - - # keystore.properties in the project root is what app/build.gradle.kts loads - # (https://developer.android.com/studio/publish/app-signing). The keystore itself - # is decoded outside the workspace and removed again in the last step. - - name: Install signing key - env: - SIGNING_KEYSTORE_BASE64: ${{ secrets.SIGNING_KEYSTORE_BASE64 }} - SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} - SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} - SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} - run: | - set -euo pipefail - umask 077 - printf '%s' "$SIGNING_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks" - { - echo "storeFile=$RUNNER_TEMP/release.jks" - echo "storePassword=$SIGNING_STORE_PASSWORD" - echo "keyAlias=$SIGNING_KEY_ALIAS" - echo "keyPassword=$SIGNING_KEY_PASSWORD" - } > keystore.properties - - - name: Unit tests - run: ./gradlew testDebugUnitTest - - - name: Lint - run: ./gradlew lintDebug - - - name: Build signed release APK - run: ./gradlew assembleRelease -PreleaseVersion="${GITHUB_REF_NAME#v}" - - - name: Verify signature and name the APK - run: | - set -euo pipefail - apk=$(ls app/build/outputs/apk/release/*.apk) - "$ANDROID_HOME"/build-tools/*/apksigner verify --print-certs "$apk" - mv "$apk" "RemoteSync-${GITHUB_REF_NAME}.apk" - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ github.token }} - run: gh release create "$GITHUB_REF_NAME" "RemoteSync-${GITHUB_REF_NAME}.apk" --title "$GITHUB_REF_NAME" --generate-notes - - - name: Remove signing key - if: always() - run: rm -f "$RUNNER_TEMP/release.jks" keystore.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8ad42b3..17be411 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,10 +8,11 @@ plugins { alias(libs.plugins.hilt) } -// Release signing, per https://developer.android.com/studio/publish/app-signing: +// Local release signing, per https://developer.android.com/studio/publish/app-signing: // keystore.properties (storeFile, storePassword, keyAlias, keyPassword) is loaded // from the project root, or from ~/.android/release/ on a developer machine. Neither -// file is committed; without one the release build is produced unsigned. +// file is committed; without one the release build is produced unsigned, which is what +// CI does (the workflow signs the artifact afterwards with apksigner). val keystorePropertiesFile = listOf( rootProject.file("keystore.properties"), File(System.getProperty("user.home"), ".android/release/keystore.properties"), @@ -20,7 +21,7 @@ val keystoreProperties = Properties().apply { keystorePropertiesFile?.inputStream()?.use(::load) } -// Release builds take their version from the git tag: release.yml passes +// Release builds take their version from the git tag: the workflow passes // -PreleaseVersion=1.2.3 for tag v1.2.3. versionCode is derived so it stays monotonic. val releaseVersion = providers.gradleProperty("releaseVersion").orNull fun versionCodeOf(version: String): Int {