Skip to content

release

release #9

Workflow file for this run

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