release #27
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 | |
| # 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" |