Skip to content

release

release #27

Workflow file for this run

name: release
# Builds three signed flavors once, photographs the store screenshots beside it, uploads
# the two play bundles to the internal track, writes their listings and records what went
# out; foss rides on the github release. Split into jobs so that a half uploaded release
# is repairable: "Re-run failed jobs" retries one upload against the bundle already built
# and signed, and a wedged emulator costs the release its pictures and nothing else.
#
# No tag triggers anything and none is written before an upload; build/<version> is
# written afterwards, and the v<version> tag only when the drafted release is published,
# which is what makes f-droid ship. See the README's "Tags" section.
on:
workflow_dispatch:
inputs:
version:
description: version to build, e.g. v4.8.0 - required unless this is a dry run
type: string
dry_run:
description: build and archive only, do not upload
type: boolean
default: false
concurrency:
# two overlapping releases would race for the same tag and draft
group: ${{ github.workflow }}
cancel-in-progress: false
permissions:
contents: read
env:
dry_run: ${{ inputs.dry_run }}
jobs:
build:
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"
# the upload job's secret, checked here so a release that cannot finish
# never builds
if [ "$dry_run" != "true" ]; 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@v7
- name: resolve version
id: version
# through the environment: a run: line is the one place where an outside
# string would be a shell injection
env:
given: ${{ inputs.version }}
run: .github/scripts/resolve-version.py --input "$given" --dry-run "$dry_run"
# play refuses a version code twice, so learning this later costs a version
- name: check the version has not gone out
if: ${{ env.dry_run != 'true' }}
env:
version: ${{ steps.version.outputs.version }}
run: |
tag="build/v${version#v}"
if git ls-remote --exit-code --tags origin "$tag" > /dev/null 2>&1; then
echo "::error::$tag exists, so $version has already been uploaded"
exit 1
fi
# dry runs included - they are the rehearsal for the release body
- name: check the changelog names this version
if: ${{ steps.version.outputs.version != '' }}
env:
version: ${{ steps.version.outputs.version }}
run: .github/scripts/changelog-section.py --version "$version" > /dev/null
# before the build, so a locale with no release notes costs seconds rather than
# being found once both bundles are already on the store
- name: check the store copy is written in every locale
if: ${{ steps.version.outputs.version != '' }}
env:
version: ${{ steps.version.outputs.version }}
run: python3 scripts/store-listing.py --version "$version"
- name: setup java
uses: actions/setup-java@v5.7.0
with:
distribution: 'zulu'
java-version: 21
# up front: signing is the last thing the build does, so a bad password would
# otherwise surface six minutes in
- name: decode keystore
env:
keystore: ${{ secrets.ODR_KEYSTORE_BASE64 }}
run: printf '%s' "$keystore" | base64 -d > "${RUNNER_TEMP}/google_play.keystore"
- name: verify keystore
env:
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: .github/scripts/verify-keystore.sh "${RUNNER_TEMP}/google_play.keystore"
- name: Gradle cache
uses: gradle/actions/setup-gradle@v6
- 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 }}
version: ${{ steps.version.outputs.version }}
run: |
# no bundleFossRelease: foss never goes to the play store, only to github
# left out rather than passed as 0.0.0: gradle's fallback pairs that name
# with version code 1, since AGP refuses the 0 it would derive from it
./gradlew bundleProRelease bundleLiteRelease \
assembleProRelease assembleLiteRelease assembleFossRelease \
${version:+-Podr.version=$version} --stacktrace
# release variants build unsigned rather than failing, so this is worth asking
- name: verify bundles and apks are signed
run: .github/scripts/verify-signed.sh
- name: drop the keystore
if: always()
run: rm -f "${RUNNER_TEMP}/google_play.keystore"
# how f-droid notices a release: it can only read a number it is given. Taken from
# what the build wrote, so the version code formula stays in app/build.gradle alone.
- name: write the version manifest
if: ${{ steps.version.outputs.version != '' }}
env:
version: ${{ steps.version.outputs.version }}
run: |
code=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["elements"][0]["versionCode"])' \
app/build/outputs/apk/foss/release/output-metadata.json)
printf '{"versionName":"%s","versionCode":%s}\n' "${version#v}" "$code" \
> app/build/outputs/apk/version.json
cat app/build/outputs/apk/version.json
# what the upload job works from. debug symbols ride inside the aab, under
# BUNDLE-METADATA/com.android.tools.build.debugsymbols
- name: Artifact bundles
uses: actions/upload-artifact@v7
with:
name: bundles
path: app/build/outputs/bundle/*/*.aab
if-no-files-found: error
# how a release gets test flown, lite included; record puts the foss one on the draft
- name: Artifact apks
uses: actions/upload-artifact@v7
with:
name: apks
path: |
app/build/outputs/apk/*/release/*.apk
app/build/outputs/apk/version.json
if-no-files-found: error
compression-level: 0
# Beside the build rather than behind it: it signs nothing and uploads nothing, it
# just drives an emulator, and it takes about as long. On a dry run too - the
# artifact is the only way to look at the pictures before the store does.
#
# A runner per device, because a runner has one emulator's worth of memory and
# because it halves the wall clock: the two halves photograph at once.
screenshots:
runs-on: ubuntu-24.04
# long by nature - ninety launches per device - but the default is six hours for
# a wedged emulator to sit in
timeout-minutes: 180
strategy:
# one device failing should not throw away the other's hour of work
fail-fast: false
matrix:
device: [phone, tablet]
steps:
- name: checkout
uses: actions/checkout@v7
- name: setup java
uses: actions/setup-java@v5.7.0
with:
distribution: 'zulu'
java-version: 21
- name: Gradle cache
uses: gradle/actions/setup-gradle@v6
# spelled out, as in the upload job: setup-ruby reads no .ruby-version here
- name: setup ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
- uses: actions/setup-python@v6
with:
python-version: "3.13"
# Pillow draws the frames. The fonts are for the three locales Nunito cannot
# set - hindi, japanese and chinese - which frame-screenshots.py refuses to
# draw as tofu, so a runner without them fails rather than shipping squares.
- name: python and fonts
run: |
python3 -m pip install --quiet Pillow
sudo apt-get update -qq
sudo apt-get install -y -qq fonts-noto-core fonts-noto-cjk
# newest first, so a runner with a newer image uses it - see the script
- name: pick the ${{ matrix.device }} to photograph
id: profile
run: |
profile=$(.github/scripts/pick-avd-profile.sh "${{ matrix.device }}")
echo "profile=$profile" >> "$GITHUB_OUTPUT"
echo "photographing $profile" >> "$GITHUB_STEP_SUMMARY"
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
# detached, for the reason build_test.yml gives: the step that hangs is the one
# running the emulator, so nothing after it in this job would get to run
- name: Reap the emulator's crash reporter
run: nohup setsid bash .github/scripts/reap-crashpad.sh > /dev/null 2>&1 &
# api 36, and not the floor the test matrix covers: the app only tells the
# system bars to follow a light theme from api 35 on (values-v35/themes.xml),
# and below that every picture has a white clock on a white bar. ScreenshotTests
# refuses to run there rather than photograph it.
- name: photograph the ${{ matrix.device }} in every locale
uses: reactivecircus/android-emulator-runner@v2
env:
ODR_SCREENSHOT_DEVICE: ${{ matrix.device }}
with:
api-level: 36
arch: x86_64
target: google_apis
profile: ${{ steps.profile.outputs.profile }}
force-avd-creation: false
ram-size: 4096M
emulator-options: -no-snapshot-save -no-snapshot-load -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-metrics
disable-animations: true
script: bash .github/scripts/take-screenshots.sh
# what the store is given
- name: archive the framed screenshots
uses: actions/upload-artifact@v7
with:
name: framed-${{ matrix.device }}
path: fastlane/framed
if-no-files-found: error
# png, so there is nothing left to squeeze out of them
compression-level: 0
# and what they were framed from, which is where to look when a picture comes
# out wrong. also when the lane failed: a half finished set is what says which
# language it got to
- name: archive the raw captures
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: screenshots-${{ matrix.device }}
path: fastlane/screenshots
if-no-files-found: warn
compression-level: 0
- name: archive the logs
if: failure()
uses: actions/upload-artifact@v7
with:
name: screenshot-logs-${{ matrix.device }}
path: |
logcat.txt
anr-traces.txt
processes.txt
app/build/reports/androidTests/
if-no-files-found: warn
# The two halves put back together, and only now checked: a set is both devices
# in every locale, plus the feature graphic the phone's half draws, and neither
# runner above can see the other's. Republished under the name the listing job
# reads.
screenshot-set:
needs: screenshots
runs-on: ubuntu-24.04
steps:
- name: checkout
uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: fetch both halves
uses: actions/download-artifact@v8
with:
pattern: framed-*
merge-multiple: true
path: fastlane/framed
# the check the lane cannot do on half a set: every locale, both devices, at
# the size the framing draws
- name: check the set
run: python3 scripts/store_screenshots.py --screenshots fastlane/framed
- name: archive the framed screenshots
uses: actions/upload-artifact@v7
with:
name: framed
path: fastlane/framed
if-no-files-found: error
compression-level: 0
# The bundles alone. What the store says about them is the listing job below, so
# that a screenshot run that wedged an emulator costs the release its pictures
# and neither its binary nor its copy.
#
# A job per flavor rather than a loop, so one half can be re-run alone. fail-fast
# off for the same reason
upload:
needs: build
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- flavor: pro
lane: uploadPro
- flavor: lite
lane: uploadLite
steps:
# for the Gemfile and the lanes
- name: checkout
uses: actions/checkout@v7
# back where gradle put them: the Fastfile reads a fixed path under here
- name: fetch the bundles
uses: actions/download-artifact@v8
with:
name: bundles
path: app/build/outputs/bundle
# absolute, outside the checkout. no keystore here - the bundle arrives signed
- name: play store credentials
env:
GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
run: .github/scripts/play-service-account-key.py "${RUNNER_TEMP}/fastlane_google_play.json"
# spelled out: setup-ruby reads no .ruby-version here and ignores the Gemfile's
# constraint. 3.4 is what Gemfile.lock was resolved with
- name: setup ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
# no track: the Fastfile's DEFAULT_TRACK is internal, and wider is a promotion
- name: upload the ${{ matrix.flavor }} bundle to play store
env:
ODR_PLAY_JSON_KEY: ${{ runner.temp }}/fastlane_google_play.json
run: bundle exec fastlane android ${{ matrix.lane }}
- name: drop credentials
if: always()
run: rm -f "${RUNNER_TEMP}/fastlane_google_play.json"
# Its own job because the listing is editable for as long as the release is on
# the internal track, while a bundle cannot be uploaded twice - and because it
# is the half that waits on the emulators.
#
# `screenshot-set` supplies this job, it does not gate it: without the pictures
# the lane writes the listing text and this version's notes and leaves what the
# store has where it is, which is what `stage_screenshots` is written to do.
# Behind a plain `needs:` that never got to run - a wedged emulator skipped the
# job outright and cost the release its copy along with its pictures.
#
# Finishing without them does not spend the release's one chance at them.
# "Re-run failed jobs" re-runs what failed and everything downstream, so a
# repaired capture brings `screenshot-set` and this job with it and the pictures
# go up in a second edit - which is the whole reason the listing is its own job.
# The device that did pass is not run again; its artifact carries over from the
# attempt before, and the two halves merge as they would have.
listing:
needs: [upload, screenshot-set]
if: ${{ !cancelled() && !inputs.dry_run && needs.upload.result == 'success' }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- flavor: pro
lane: listingPro
- flavor: lite
lane: listingLite
steps:
- name: checkout
uses: actions/checkout@v7
# re-resolved rather than carried as a job output, as in the record job below:
# the listing needs it to name the release its notes belong to
- name: resolve version
id: version
env:
given: ${{ inputs.version }}
run: .github/scripts/resolve-version.py --input "$given" --dry-run "$dry_run"
# where the lane looks for them, and the same set both apps are given. Only
# when there is a set: the artifact is written with if-no-files-found: error,
# so a screenshot-set that passed is the same answer as one that exists
- name: fetch the screenshots
if: ${{ needs.screenshot-set.result == 'success' }}
uses: actions/download-artifact@v8
with:
name: framed
path: fastlane/framed
# said out loud, since the release otherwise finishes green over a listing
# still showing the pictures of some earlier version
- name: say there are no pictures this time
if: ${{ needs.screenshot-set.result != 'success' }}
run: |
echo "::warning::no screenshots for this release - the listing goes up with its text alone, and the store keeps the pictures it already has"
- name: play store credentials
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
- uses: actions/setup-python@v6
with:
python-version: "3.13"
# ODR_VERSION rather than a lane argument: the Fastfile falls back to it, and it
# is what tells the staging scripts which release the notes and pictures are for
- name: write the ${{ matrix.flavor }} listing to play store
env:
ODR_PLAY_JSON_KEY: ${{ runner.temp }}/fastlane_google_play.json
ODR_VERSION: ${{ steps.version.outputs.version }}
run: bundle exec fastlane android ${{ matrix.lane }}
- name: drop credentials
if: always()
run: rm -f "${RUNNER_TEMP}/fastlane_google_play.json"
# only once both flavors are up, so a half uploaded release is not recorded at all
record:
needs: upload
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: checkout
uses: actions/checkout@v7
# re-resolved rather than carried as a job output: a re-run need not repeat the
# job that produced it, while the input is the same on every attempt
- name: resolve version
id: version
env:
given: ${{ inputs.version }}
run: .github/scripts/resolve-version.py --input "$given" --dry-run "$dry_run"
- name: tag the commit that went out
env:
version: ${{ steps.version.outputs.version }}
run: |
tag="build/v${version#v}"
# re-running behind a repaired upload is expected, so an existing tag is
# only wrong when it names a different commit
if git ls-remote --exit-code --tags origin "$tag" > /dev/null 2>&1; then
git fetch --no-tags origin "refs/tags/$tag:refs/tags/$tag"
already=$(git rev-list -n1 "$tag")
if [ "$already" != "$GITHUB_SHA" ]; then
echo "::error::$tag already names $already, not $GITHUB_SHA"
exit 1
fi
echo "$tag was already written by an earlier attempt"
else
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$tag" \
-m "v${version#v} uploaded to the play store internal track" \
-m "run: $GITHUB_RUN_ID"
git push origin "$tag"
fi
echo "\`$GITHUB_SHA\` is \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
- name: fetch the apks
uses: actions/download-artifact@v8
with:
name: apks
# a draft creates no tag; publishing it does. --target takes the sha rather than
# a branch, which would resolve to whatever main had become by then
- name: draft the github release
env:
GH_TOKEN: ${{ github.token }}
version: ${{ steps.version.outputs.version }}
run: |
.github/scripts/changelog-section.py --version "$version" > "${RUNNER_TEMP}/notes.md"
tag="v${version#v}"
if gh release view "$tag" > /dev/null 2>&1; then
gh release edit "$tag" --target "$GITHUB_SHA" --notes-file "${RUNNER_TEMP}/notes.md"
gh release upload "$tag" foss/release/app-foss-release.apk version.json --clobber
else
# --generate-notes appends the pull requests below the changelog section
gh release create "$tag" \
--draft \
--target "$GITHUB_SHA" \
--title "$tag" \
--notes-file "${RUNNER_TEMP}/notes.md" \
--generate-notes \
foss/release/app-foss-release.apk version.json
fi
echo "drafted \`$tag\`. publishing it writes the tag and lets f-droid pick it up - do that once it is live." >> "$GITHUB_STEP_SUMMARY"