release #9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: release | |
| # Releases used to be built and uploaded from a maintainer laptop, which was the | |
| # only machine holding the keystore. This does the same steps in CI: | |
| # 1. build the signed lite and pro bundles and apks with gradle | |
| # 2. hand the bundles to fastlane, which uploads them to the play store | |
| # 3. on a tag, attach the signed pro apk to that tag's github release, which | |
| # every release up to v4.6 carried and the laptop build produced by hand. | |
| # the release has to exist already. this is a second job, so that it can | |
| # be re-run without re-running the upload in step 2 | |
| # | |
| # Both flavors go out together, the way they always have: they share a version | |
| # and release notes, and a release of one without the other is not a thing that | |
| # has ever been wanted. | |
| # | |
| # Requires these repository secrets (see the "Release signing" section in the | |
| # README for what they mean): | |
| # ODR_KEYSTORE_BASE64 base64 of google_play.keystore | |
| # ODR_KEYSTORE_PASSWORD keystore password | |
| # ODR_KEY_PASSWORD_PRO key password for the reader-pro alias, optional: | |
| # an unset one falls back to the store password | |
| # ODR_KEY_PASSWORD_LITE key password for the reader alias, same fallback | |
| # GOOGLE_PLAY_SERVICE_ACCOUNT json key of the play console service account | |
| # | |
| # Without them the workflow fails fast in the "check secrets" step instead of | |
| # producing an unsigned bundle and trying to upload it, and the two that are more | |
| # than a password - the keystore and the service account key - are opened and | |
| # checked before the build rather than after it. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| track: | |
| description: play store track | |
| type: choice | |
| options: [internal, alpha, beta, production] | |
| default: internal | |
| # both flavors are always built and archived - this is only about what | |
| # leaves the run. none is the dry run: build everything, publish nothing. | |
| # pro or lite finishes a half uploaded release, which is otherwise a dead | |
| # end, since play refuses a version code it has already accepted and the | |
| # run therefore cannot simply be repeated | |
| uploads: | |
| description: what to publish - none builds and archives only | |
| type: choice | |
| options: [both, pro, lite, none] | |
| default: both | |
| push: | |
| tags: | |
| - 'v*' | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| env: | |
| ndk_version: 28.2.13676358 | |
| # tag pushes go to the internal track, same as the manual lanes always did. | |
| # anything wider has to be dispatched deliberately. | |
| track: ${{ inputs.track || 'internal' }} | |
| uploads: ${{ inputs.uploads || 'both' }} | |
| jobs: | |
| release: | |
| runs-on: ubuntu-24.04 | |
| steps: | |
| - name: check secrets | |
| env: | |
| keystore: ${{ secrets.ODR_KEYSTORE_BASE64 }} | |
| keystore_password: ${{ secrets.ODR_KEYSTORE_PASSWORD }} | |
| service_account: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} | |
| run: | | |
| missing="" | |
| [ -n "$keystore" ] || missing="$missing ODR_KEYSTORE_BASE64" | |
| [ -n "$keystore_password" ] || missing="$missing ODR_KEYSTORE_PASSWORD" | |
| if [ "${{ env.uploads }}" != "none" ]; then | |
| [ -n "$service_account" ] || missing="$missing GOOGLE_PLAY_SERVICE_ACCOUNT" | |
| fi | |
| if [ -n "$missing" ]; then | |
| echo "::error::missing repository secrets:$missing" | |
| exit 1 | |
| fi | |
| - name: checkout | |
| uses: actions/checkout@v4 | |
| - name: install ninja | |
| run: sudo apt-get install -y ninja-build | |
| - name: setup java | |
| uses: actions/setup-java@v5 | |
| with: | |
| distribution: 'zulu' | |
| java-version: 21 | |
| # the version has to be spelled out here: setup-ruby only infers one from a | |
| # .ruby-version / .tool-versions file, neither of which this repo has, and it | |
| # does not read the `ruby ">= 3.2"` constraint in the Gemfile. 3.4 is what | |
| # Gemfile.lock was resolved with. | |
| # the keystore is decoded and checked up front, before the conan and gradle | |
| # setup: signing is the very last thing the build does, so a bad password | |
| # otherwise only surfaces as a signProReleaseBundle failure six minutes in | |
| - name: decode keystore | |
| run: echo "${{ secrets.ODR_KEYSTORE_BASE64 }}" | base64 -d > "${RUNNER_TEMP}/google_play.keystore" | |
| # keytool reports the same two failures in a second, and tells them apart, which | |
| # gradle does not: it wraps both in "Failed to read key <alias> from store". A | |
| # -list only proves the store password, so the key passwords get their own check | |
| # via -certreq, which needs the private key itself. Neither writes to the keystore. | |
| - name: verify keystore | |
| env: | |
| keystore_password: ${{ secrets.ODR_KEYSTORE_PASSWORD }} | |
| key_password_pro: ${{ secrets.ODR_KEY_PASSWORD_PRO }} | |
| key_password_lite: ${{ secrets.ODR_KEY_PASSWORD_LITE }} | |
| run: | | |
| keystore="${RUNNER_TEMP}/google_play.keystore" | |
| if ! keytool -list -keystore "$keystore" -storepass "$keystore_password" > /dev/null; then | |
| echo "::error::ODR_KEYSTORE_PASSWORD does not open the keystore, or ODR_KEYSTORE_BASE64 did not decode to it. A trailing newline in either secret is enough to do this." | |
| exit 1 | |
| fi | |
| # an unset key password falls back to the store one, the same way | |
| # app/build.gradle resolves it | |
| verify_key() { | |
| if ! keytool -certreq -alias "$1" -keystore "$keystore" \ | |
| -storepass "$keystore_password" -keypass "${2:-$keystore_password}" > /dev/null; then | |
| echo "::error::the $1 key cannot be used - check its key password secret" | |
| exit 1 | |
| fi | |
| } | |
| verify_key reader-pro "$key_password_pro" | |
| verify_key reader "$key_password_lite" | |
| # written here rather than next to the upload, for the same reason the | |
| # keystore is decoded up front: a key the play store cannot be opened with | |
| # should fail the run in seconds, not once the build is done. it lands in | |
| # RUNNER_TEMP and is handed to fastlane by absolute path - a relative one is | |
| # resolved against whatever directory the action happens to run in, and this | |
| # keeps the credentials out of the checkout the build reads from | |
| - name: play store credentials | |
| if: ${{ env.uploads != 'none' }} | |
| env: | |
| GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} | |
| run: .github/scripts/play-service-account-key.py "${RUNNER_TEMP}/fastlane_google_play.json" | |
| - name: setup ruby | |
| uses: ruby/setup-ruby@v1 | |
| with: | |
| ruby-version: '3.4' | |
| bundler-cache: true | |
| - name: setup python 3.12 | |
| uses: actions/setup-python@v7 | |
| with: | |
| python-version: 3.12 | |
| - name: install python dependencies | |
| run: pip install conan | |
| - name: install ndk | |
| run: yes | ${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager --install "ndk;${{ env.ndk_version }}" | |
| - name: conan profile | |
| run: conan profile detect | |
| - name: checkout conan-index | |
| run: git submodule update --init --depth 1 conan-odr-index | |
| # same cache the build workflow populates, so a release does not have to | |
| # rebuild odrcore from source. the key has to be spelled the same way | |
| # build_test spells it, or only the restore-keys prefix can ever match and | |
| # the index revision stops being part of what is looked up | |
| - name: conan cache key | |
| id: conan-cache-key | |
| run: echo "key=conan2-${{ runner.os }}-ndk${{ env.ndk_version }}-index$(git rev-parse HEAD:conan-odr-index)-${{ hashFiles('app/conanfile.txt', 'app/conanprofile.txt') }}" >> "$GITHUB_OUTPUT" | |
| - name: conan cache | |
| uses: actions/cache/restore@v6 | |
| with: | |
| path: ~/.conan2/p | |
| key: ${{ steps.conan-cache-key.outputs.key }} | |
| restore-keys: | | |
| conan2-${{ runner.os }}-ndk${{ env.ndk_version }}- | |
| - name: export conan-odr-index | |
| run: python conan-odr-index/scripts/conan_export_all_packages.py | |
| - name: Gradle cache | |
| uses: gradle/actions/setup-gradle@v4 | |
| # the apks are the sideloadable copies of what the bundles ship. both get | |
| # archived on the run; only pro goes onto the github release, the way it | |
| # always has - lite is the ad supported play build, and an apk of it | |
| # outside the store has no audience | |
| - name: build bundles and apks | |
| env: | |
| ODR_KEYSTORE: ${{ runner.temp }}/google_play.keystore | |
| ODR_KEYSTORE_PASSWORD: ${{ secrets.ODR_KEYSTORE_PASSWORD }} | |
| ODR_KEY_PASSWORD_PRO: ${{ secrets.ODR_KEY_PASSWORD_PRO }} | |
| ODR_KEY_PASSWORD_LITE: ${{ secrets.ODR_KEY_PASSWORD_LITE }} | |
| run: | | |
| ./gradlew bundleProRelease bundleLiteRelease \ | |
| assembleProRelease assembleLiteRelease --stacktrace | |
| # a release that silently produced an unsigned bundle would be rejected by | |
| # the play store with a much less obvious error, and an unsigned apk on the | |
| # release page would not install at all | |
| - name: verify bundles and apks are signed | |
| run: | | |
| for aab in app/build/outputs/bundle/*/*.aab; do | |
| if unzip -l "$aab" | grep -qE " META-INF/[^/]+\.(RSA|DSA)$"; then | |
| echo "signed: $aab" | |
| else | |
| echo "::error::$aab is not signed" | |
| exit 1 | |
| fi | |
| done | |
| # the apks take apksigner rather than the same grep: from minSdk 24 up | |
| # agp leaves v1 signing off, so there is no META-INF/*.RSA to find and | |
| # the signature sits in a block the zip listing does not show | |
| apksigner=$(ls -1 "${ANDROID_HOME}"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1) | |
| if [ -z "$apksigner" ]; then | |
| echo "::error::found no apksigner under ${ANDROID_HOME}/build-tools" | |
| exit 1 | |
| fi | |
| for apk in app/build/outputs/apk/*/release/*.apk; do | |
| if ! "$apksigner" verify "$apk" > /dev/null; then | |
| echo "::error::$apk is not signed" | |
| exit 1 | |
| fi | |
| echo "signed: $apk" | |
| done | |
| # ndk.debugSymbolLevel puts the symbols inside the bundle itself, under | |
| # BUNDLE-METADATA/com.android.tools.build.debugsymbols, so the play store | |
| # gets them from the upload and anyone who needs them can unzip the aab | |
| # archived here. build/outputs/native-debug-symbols is only written on the | |
| # apk path, by mergeNativeDebugMetadata, which a bundle build never runs | |
| - name: Artifact bundles | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: bundles | |
| path: app/build/outputs/bundle/*/*.aab | |
| if-no-files-found: error | |
| # both, and not only on the release: a dispatched run has no tag to attach | |
| # anything to, and this is how a release gets test flown - including lite, | |
| # which is otherwise only installable once it is live in the store | |
| - name: Artifact apks | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: apks | |
| path: app/build/outputs/apk/*/release/*.apk | |
| if-no-files-found: error | |
| compression-level: 0 | |
| - name: upload to play store | |
| if: ${{ env.uploads != 'none' }} | |
| env: | |
| ODR_PLAY_JSON_KEY: ${{ runner.temp }}/fastlane_google_play.json | |
| run: | | |
| case "${{ env.uploads }}" in | |
| pro) lanes="uploadPro" ;; | |
| lite) lanes="uploadLite" ;; | |
| *) lanes="uploadPro uploadLite" ;; | |
| esac | |
| for lane in $lanes; do | |
| bundle exec fastlane android "$lane" track:"${{ env.track }}" | |
| done | |
| - name: drop credentials | |
| if: always() | |
| run: rm -f "${RUNNER_TEMP}/fastlane_google_play.json" "${RUNNER_TEMP}/google_play.keystore" | |
| # needs: release, so the release page never offers an apk for a version that | |
| # never reached play - and a job of its own, so that a failure here can be | |
| # re-run on its own. re-running the release job is not an option once | |
| # fastlane has been through it: play rejects a second upload of a version | |
| # code it has already seen, so the retry would die before ever getting here | |
| attach: | |
| needs: release | |
| # inputs, not env: a job level if cannot see the env context, and an unset | |
| # input on a tag push is not 'none' either way | |
| if: ${{ github.ref_type == 'tag' && inputs.uploads != 'none' }} | |
| runs-on: ubuntu-24.04 | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: fetch the apks | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: apks | |
| # pro alone, the way the release page has always had it. the release itself | |
| # stays a human decision - this only fills in its apk, and says so rather | |
| # than inventing one | |
| - name: attach the pro apk to the github release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| tag: ${{ github.ref_name }} | |
| run: | | |
| if ! gh release view "$tag" > /dev/null 2>&1; then | |
| echo "::error::$tag has no github release to attach the apk to. create it, then re-run this job." | |
| exit 1 | |
| fi | |
| gh release upload "$tag" pro/release/app-pro-release.apk --clobber |