mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
A repository variable would also switch main's com.stably.orca.mobile builds to the new key and strand current installs. The fingerprint is public, so pinning it in the file is reviewable and safe.
465 lines
20 KiB
YAML
465 lines
20 KiB
YAML
name: Mobile Android Release
|
||
|
||
# Release Android independently so App Store review cannot block its shipping cadence.
|
||
on:
|
||
push:
|
||
tags:
|
||
- 'mobile-android-v*'
|
||
workflow_dispatch:
|
||
inputs:
|
||
release_version:
|
||
description: 'Optional exact mobile marketing version assertion, e.g. 0.0.22'
|
||
required: false
|
||
type: string
|
||
publish_github_release:
|
||
description: 'Create or update the mobile-android-v<version> GitHub Release'
|
||
required: false
|
||
default: true
|
||
type: boolean
|
||
|
||
jobs:
|
||
android-build:
|
||
runs-on: ubuntu-latest
|
||
|
||
# Grant contents:write for gh release create; build success alone does not authorize publication.
|
||
permissions:
|
||
contents: write
|
||
|
||
outputs:
|
||
publish_release: ${{ steps.release.outputs.publish_release }}
|
||
version: ${{ steps.release.outputs.version }}
|
||
play_upload: ${{ steps.play.outputs.upload }}
|
||
|
||
defaults:
|
||
run:
|
||
working-directory: mobile
|
||
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v6
|
||
|
||
# dev.orca.app is never built with the public Expo debug key: Play and GitHub installs must share the release key.
|
||
- name: Check release signing configuration
|
||
env:
|
||
ORCA_ANDROID_KEYSTORE_BASE64: ${{ secrets.ORCA_ANDROID_KEYSTORE_BASE64 }}
|
||
ORCA_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ORCA_ANDROID_KEYSTORE_PASSWORD }}
|
||
ORCA_ANDROID_KEY_ALIAS: ${{ secrets.ORCA_ANDROID_KEY_ALIAS }}
|
||
ORCA_ANDROID_KEY_PASSWORD: ${{ secrets.ORCA_ANDROID_KEY_PASSWORD }}
|
||
run: |
|
||
set -euo pipefail
|
||
missing=()
|
||
for name in ORCA_ANDROID_KEYSTORE_BASE64 ORCA_ANDROID_KEYSTORE_PASSWORD ORCA_ANDROID_KEY_ALIAS ORCA_ANDROID_KEY_PASSWORD; do
|
||
if [ -z "${!name:-}" ]; then
|
||
missing+=("$name")
|
||
fi
|
||
done
|
||
if [ "${#missing[@]}" -ne 0 ]; then
|
||
echo "::error::Android release signing is not fully configured. Missing secrets: ${missing[*]}. See the Android Release Signing section of mobile/README.md."
|
||
exit 1
|
||
fi
|
||
|
||
- name: Setup Node.js
|
||
uses: actions/setup-node@v6
|
||
with:
|
||
node-version: 24
|
||
check-latest: true
|
||
|
||
- name: Setup pnpm
|
||
uses: pnpm/setup@v2
|
||
with:
|
||
install: false
|
||
|
||
- name: Install dependencies
|
||
run: pnpm install --frozen-lockfile
|
||
|
||
- name: Resolve release version and version code
|
||
id: release
|
||
env:
|
||
MOBILE_ANDROID_RELEASE_VERSION: ${{ github.event.inputs.release_version }}
|
||
MOBILE_ANDROID_PUBLISH_RELEASE: ${{ github.event.inputs.publish_github_release }}
|
||
run: node scripts/prepare-android-release.mjs
|
||
|
||
# Published builds also go to Play once its service account exists; until then this skips.
|
||
- name: Check Play upload configuration
|
||
id: play
|
||
if: steps.release.outputs.publish_release == 'true'
|
||
env:
|
||
ORCA_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.ORCA_PLAY_SERVICE_ACCOUNT_JSON }}
|
||
run: |
|
||
if [ -n "${ORCA_PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
|
||
echo "upload=true" >> "$GITHUB_OUTPUT"
|
||
fi
|
||
|
||
- name: Setup JDK 17
|
||
uses: actions/setup-java@v5
|
||
with:
|
||
distribution: temurin
|
||
java-version: 17
|
||
|
||
- name: Expo prebuild
|
||
run: npx expo prebuild --platform android --no-install
|
||
|
||
- name: Decode release keystore
|
||
env:
|
||
ORCA_ANDROID_KEYSTORE_BASE64: ${{ secrets.ORCA_ANDROID_KEYSTORE_BASE64 }}
|
||
run: |
|
||
set -euo pipefail
|
||
umask 077
|
||
printf '%s' "$ORCA_ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/orca-release.keystore"
|
||
|
||
- name: Build Android release APK
|
||
env:
|
||
ORCA_ANDROID_REQUIRE_RELEASE_SIGNING: 'true'
|
||
ORCA_ANDROID_KEYSTORE_PATH: ${{ runner.temp }}/orca-release.keystore
|
||
ORCA_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ORCA_ANDROID_KEYSTORE_PASSWORD }}
|
||
ORCA_ANDROID_KEY_ALIAS: ${{ secrets.ORCA_ANDROID_KEY_ALIAS }}
|
||
ORCA_ANDROID_KEY_PASSWORD: ${{ secrets.ORCA_ANDROID_KEY_PASSWORD }}
|
||
run: cd android && ./gradlew assembleRelease
|
||
|
||
# Same key, version and JS bundle as the APK; only the embedded app config differs, and Gradle
|
||
# regenerates it each run, so the APK above keeps the GitHub update check.
|
||
- name: Build Play app bundle
|
||
env:
|
||
ORCA_ANDROID_DISTRIBUTION: play
|
||
ORCA_ANDROID_REQUIRE_RELEASE_SIGNING: 'true'
|
||
ORCA_ANDROID_KEYSTORE_PATH: ${{ runner.temp }}/orca-release.keystore
|
||
ORCA_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ORCA_ANDROID_KEYSTORE_PASSWORD }}
|
||
ORCA_ANDROID_KEY_ALIAS: ${{ secrets.ORCA_ANDROID_KEY_ALIAS }}
|
||
ORCA_ANDROID_KEY_PASSWORD: ${{ secrets.ORCA_ANDROID_KEY_PASSWORD }}
|
||
run: |
|
||
set -euo pipefail
|
||
(cd android && ./gradlew bundleRelease)
|
||
# Fail rather than ship a Play bundle that sends people to GitHub, or an APK that never checks it.
|
||
aab_config="$(unzip -p android/app/build/outputs/bundle/release/app-release.aab base/assets/app.config)"
|
||
apk_config="$(unzip -p android/app/build/outputs/apk/release/app-release.apk assets/app.config)"
|
||
if [[ "$aab_config" != *'"androidDistribution":"play"'* || "$apk_config" == *androidDistribution* ]]; then
|
||
echo "::error::The AAB must embed androidDistribution=play and the APK must not."
|
||
exit 1
|
||
fi
|
||
|
||
- name: Remove release keystore
|
||
if: always()
|
||
run: rm -f "$RUNNER_TEMP/orca-release.keystore"
|
||
|
||
# Why pin the certificate: a regenerated or swapped key would make every installed copy refuse the update.
|
||
- name: Verify APK and AAB signing certificates
|
||
id: signer
|
||
env:
|
||
EXPECTED_CERT_SHA256: '19:68:CC:3A:F4:90:97:24:F7:FA:AC:8D:4E:3C:5E:F9:16:97:AE:FB:69:05:E2:EA:65:0D:19:1B:B3:9F:AD:BC'
|
||
run: |
|
||
set -euo pipefail
|
||
# Expo template debug keystore, public in expo-template-bare-minimum.
|
||
debug_cert="fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c"
|
||
apksigner="$(ls -d "$ANDROID_HOME"/build-tools/*/apksigner | sort -V | tail -1)"
|
||
apks=(android/app/build/outputs/apk/release/*.apk)
|
||
# build-tools 37 apksigner prints "V3.0 Signer:" lines for minSdk 24+ APKs; older ones print "Signer #1".
|
||
certs="$("$apksigner" verify --print-certs "${apks[0]}" | sed -n -E 's/^(Signer #[0-9]+|V[0-9.]+ Signer:) certificate SHA-256 digest: //p' | sort -u)"
|
||
expected="$(printf '%s' "$EXPECTED_CERT_SHA256" | tr -d ': ' | tr 'A-F' 'a-f')"
|
||
if [ "$certs" = "$debug_cert" ]; then
|
||
echo "::error::The APK is signed with the public Expo debug key."
|
||
exit 1
|
||
fi
|
||
if [ "$certs" != "$expected" ]; then
|
||
echo "::error::The APK signing certificate ($certs) does not match the pinned release certificate ($expected)."
|
||
exit 1
|
||
fi
|
||
# Play App Signing re-signs with this same key, which keeps Play and GitHub installs updatable from each other.
|
||
aab_certs="$(keytool -printcert -jarfile android/app/build/outputs/bundle/release/app-release.aab | sed -n 's/^[[:space:]]*SHA256: //p' | tr -d ':' | tr 'A-F' 'a-f' | sort -u)"
|
||
if [ "$aab_certs" != "$expected" ]; then
|
||
echo "::error::The AAB signing certificate ($aab_certs) does not match the pinned release certificate ($expected)."
|
||
exit 1
|
||
fi
|
||
echo "cert_sha256=$(printf '%s' "$certs" | tr 'a-f' 'A-F' | sed 's/../&:/g; s/:$//')" >> "$GITHUB_OUTPUT"
|
||
echo "APK signing certificate SHA-256: $certs"
|
||
|
||
# Publish size/digest to distinguish truncated downloads (missing tail signature block) from bad APK builds.
|
||
- name: Checksum the APK
|
||
id: apk
|
||
run: |
|
||
set -euo pipefail
|
||
shopt -s nullglob
|
||
apks=(android/app/build/outputs/apk/release/*.apk)
|
||
if [ "${#apks[@]}" -ne 1 ]; then
|
||
echo "Expected exactly one release APK, found ${#apks[@]}" >&2
|
||
exit 1
|
||
fi
|
||
|
||
apk="${apks[0]}"
|
||
bytes="$(wc -c < "$apk" | tr -d ' ')"
|
||
sha256="$(sha256sum "$apk" | cut -d ' ' -f 1)"
|
||
# Publish sha256sum -c sidecars, excluded by *.apk globs. Use binary “ *” mode:
|
||
# Git Bash text-mode newline translation can reject a valid APK.
|
||
printf '%s *%s\n' "$sha256" "$(basename "$apk")" > "$apk.sha256"
|
||
|
||
{
|
||
echo "checksum=$apk.sha256"
|
||
echo "bytes=$bytes"
|
||
echo "sha256=$sha256"
|
||
} >> "$GITHUB_OUTPUT"
|
||
echo "APK is $bytes bytes, sha256 $sha256"
|
||
|
||
- name: Upload APK artifact
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: orca-mobile-apk
|
||
path: |
|
||
mobile/android/app/build/outputs/apk/release/*.apk
|
||
mobile/android/app/build/outputs/apk/release/*.apk.sha256
|
||
|
||
- name: Upload AAB artifact
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: orca-mobile-aab
|
||
path: mobile/android/app/build/outputs/bundle/release/app-release.aab
|
||
if-no-files-found: error
|
||
|
||
- name: Ensure GitHub release tag
|
||
if: steps.release.outputs.publish_release == 'true' && !startsWith(github.ref, 'refs/tags/mobile-android-v')
|
||
run: |
|
||
set -euo pipefail
|
||
tag="${{ steps.release.outputs.tag }}"
|
||
|
||
if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then
|
||
echo "Tag $tag already exists"
|
||
exit 0
|
||
fi
|
||
|
||
git tag "$tag" "$GITHUB_SHA"
|
||
git push origin "refs/tags/$tag"
|
||
|
||
- name: Create GitHub Release
|
||
if: steps.release.outputs.publish_release == 'true'
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
run: |
|
||
set -euo pipefail
|
||
tag="${{ steps.release.outputs.tag }}"
|
||
notes_file="$RUNNER_TEMP/android-release-notes.md"
|
||
|
||
# printf rather than a heredoc: YAML block indentation would leak into the Markdown.
|
||
# Both platforms' commands are named because a reader on macOS has no sha256sum.
|
||
# Only release-signed builds list a certificate, since only they are pinned.
|
||
signer_line=''
|
||
if [ -n '${{ steps.signer.outputs.cert_sha256 }}' ]; then
|
||
signer_line='- Signing certificate SHA-256: `${{ steps.signer.outputs.cert_sha256 }}`\n'
|
||
fi
|
||
verification_section() {
|
||
printf '\n### Verify your download\n\n- Size: `%s` bytes\n- SHA-256: `%s`\n%b\nA "no certificate" or "problem parsing the package" install failure is usually a truncated download — check the size first, then `sha256sum -c app-release.apk.sha256` (`shasum -a 256 -c` on macOS).\n' \
|
||
'${{ steps.apk.outputs.bytes }}' '${{ steps.apk.outputs.sha256 }}' "$signer_line"
|
||
}
|
||
|
||
# Verification is last; replace from heading to EOF, accepting API-roundtripped CRLFs.
|
||
drop_verification_section() {
|
||
sed '/^### Verify your download\r*$/,$d'
|
||
}
|
||
|
||
# Truncate by characters like desktop releases and reserve the appended section inside GitHub’s cap,
|
||
# or --clobber could replace assets before notes fail. wc -c over-reserves multibyte section text safely.
|
||
write_notes_with_verification() {
|
||
NOTES_FILE="$notes_file" \
|
||
NOTES_RESERVE="$(verification_section | wc -c | tr -d ' ')" \
|
||
NOTES_MODULE="$GITHUB_WORKSPACE/config/scripts/release/publication/create-draft-release.mjs" \
|
||
node --input-type=module -e '
|
||
const { readFileSync, writeFileSync } = await import("node:fs")
|
||
const { pathToFileURL } = await import("node:url")
|
||
const { MAX_RELEASE_BODY_LENGTH, truncateReleaseBody } = await import(
|
||
pathToFileURL(process.env.NOTES_MODULE).href
|
||
)
|
||
const file = process.env.NOTES_FILE
|
||
const max = MAX_RELEASE_BODY_LENGTH - Number(process.env.NOTES_RESERVE)
|
||
writeFileSync(file, truncateReleaseBody(readFileSync(file, "utf8"), max))
|
||
'
|
||
verification_section >> "$notes_file"
|
||
}
|
||
|
||
if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||
gh release upload "$tag" \
|
||
--repo "$GITHUB_REPOSITORY" \
|
||
--clobber \
|
||
android/app/build/outputs/apk/release/*.apk \
|
||
"${{ steps.apk.outputs.checksum }}"
|
||
# --clobber replaced the APK; rewrite its digest so users cannot compare against obsolete bytes.
|
||
gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json body --jq .body \
|
||
| drop_verification_section > "$notes_file"
|
||
write_notes_with_verification
|
||
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --notes-file "$notes_file"
|
||
else
|
||
# Side-branch release tags make automatic previous-tag detection span multiple releases and exceed
|
||
# the 125000-character API cap; pin the comparison base and truncate.
|
||
previous_tag="$(
|
||
gh release list --repo "$GITHUB_REPOSITORY" --limit 200 --json tagName --jq '.[].tagName' \
|
||
| grep '^mobile-android-v' | grep -Fxv "$tag" | sort -V | tail -1 || true
|
||
)"
|
||
|
||
if [ -n "$previous_tag" ]; then
|
||
# gh writes HTTP error JSON to stdout too; require exit success, not merely a nonempty file.
|
||
if ! gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" -X POST \
|
||
-f tag_name="$tag" \
|
||
-f target_commitish="$GITHUB_SHA" \
|
||
-f previous_tag_name="$previous_tag" \
|
||
--jq .body > "$notes_file"; then
|
||
: > "$notes_file"
|
||
fi
|
||
fi
|
||
if [ ! -s "$notes_file" ]; then
|
||
printf 'Orca Mobile Android %s\n' "$tag" > "$notes_file"
|
||
fi
|
||
write_notes_with_verification
|
||
|
||
gh release create "$tag" \
|
||
--repo "$GITHUB_REPOSITORY" \
|
||
--title "Orca Mobile Android $tag" \
|
||
--prerelease \
|
||
--latest=false \
|
||
--notes-file "$notes_file" \
|
||
android/app/build/outputs/apk/release/*.apk \
|
||
"${{ steps.apk.outputs.checksum }}"
|
||
fi
|
||
|
||
# Its own job, after the GitHub release: a Play failure never holds back the APK and can be rerun alone.
|
||
# Both channels ship the committed versionCode, and Play rejects a reused one, so a missed bump fails here.
|
||
play-internal-upload:
|
||
needs: android-build
|
||
if: needs.android-build.outputs.play_upload == 'true'
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 20
|
||
permissions:
|
||
contents: read
|
||
|
||
env:
|
||
BUNDLE_FROZEN: 'true'
|
||
|
||
defaults:
|
||
run:
|
||
working-directory: mobile
|
||
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v6
|
||
|
||
- name: Download AAB artifact
|
||
uses: actions/download-artifact@v8
|
||
with:
|
||
name: orca-mobile-aab
|
||
path: mobile/build/play
|
||
|
||
- name: Setup Ruby and fastlane
|
||
uses: ruby/setup-ruby@v1
|
||
with:
|
||
ruby-version: '3.3'
|
||
bundler-cache: true
|
||
working-directory: mobile
|
||
|
||
- name: Upload to the internal testing track
|
||
env:
|
||
ORCA_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.ORCA_PLAY_SERVICE_ACCOUNT_JSON }}
|
||
ORCA_PLAY_RELEASE_STATUS: ${{ vars.ORCA_PLAY_RELEASE_STATUS }}
|
||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||
FASTLANE_HIDE_CHANGELOG: '1'
|
||
FASTLANE_OPT_OUT_USAGE: '1'
|
||
run: bundle exec fastlane android upload_internal aab:"$PWD/build/play/app-release.aab"
|
||
|
||
# Advisory like the site bump: a failed mirror only leaves orca.dev's "mirror link" on the prior APK.
|
||
release-mirror-r2:
|
||
needs: android-build
|
||
if: needs.android-build.outputs.publish_release == 'true'
|
||
uses: ./.github/workflows/release-mirror-r2.yml
|
||
with:
|
||
tag: mobile-android-v${{ needs.android-build.outputs.version }}
|
||
assets: app-release.apk
|
||
secrets: inherit
|
||
|
||
# Keep site bump advisory after publication; failure only leaves its download link on the prior APK.
|
||
# Why after the mirror: the site's mirror link uses the bumped version, so upload the APK first
|
||
# (success not required, so a mirror outage never blocks the bump).
|
||
bump-apk-download:
|
||
needs: [android-build, release-mirror-r2]
|
||
if: ${{ !cancelled() && needs.android-build.outputs.publish_release == 'true' }}
|
||
runs-on: ubuntu-latest
|
||
continue-on-error: true
|
||
permissions:
|
||
contents: read
|
||
env:
|
||
VERSION: ${{ needs.android-build.outputs.version }}
|
||
SITE_REPO: stablyai/orca-marketing-website
|
||
BRANCH: bump/android-apk-${{ needs.android-build.outputs.version }}
|
||
steps:
|
||
- name: Checkout orca
|
||
uses: actions/checkout@v6
|
||
with:
|
||
sparse-checkout: config/scripts
|
||
|
||
# Why the buf0-bot app, as in homebrew-bump.yml: GITHUB_TOKEN cannot push to another
|
||
# repo, and a PR it opened would not run the site's checks.
|
||
- name: Generate buf0-bot token
|
||
id: app-token
|
||
uses: actions/create-github-app-token@v3
|
||
with:
|
||
app-id: 2590194
|
||
private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }}
|
||
owner: stablyai
|
||
repositories: orca-marketing-website
|
||
permission-contents: write
|
||
permission-pull-requests: write
|
||
|
||
- name: Checkout marketing site
|
||
uses: actions/checkout@v6
|
||
with:
|
||
repository: stablyai/orca-marketing-website
|
||
path: site
|
||
token: ${{ steps.app-token.outputs.token }}
|
||
|
||
- name: Open and merge the version bump PR
|
||
working-directory: site
|
||
env:
|
||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||
run: |
|
||
set -euo pipefail
|
||
node ../config/scripts/release/versioning/bump-android-apk-download-version.mjs src/lib/downloadTargets.ts "$VERSION"
|
||
if git diff --quiet; then
|
||
echo "The site's main branch already has Android APK $VERSION or newer." >> "$GITHUB_STEP_SUMMARY"
|
||
exit 0
|
||
fi
|
||
|
||
title="Point the Android APK download at $VERSION"
|
||
git config user.name "buf0-bot[bot]"
|
||
git config user.email "252831055+buf0-bot[bot]@users.noreply.github.com"
|
||
git checkout -b "$BRANCH"
|
||
git commit -am "$title"
|
||
# Why --force: a rerun rebuilds the branch on the site's current main.
|
||
git push --force origin "$BRANCH"
|
||
|
||
# Why: a rerun of the same release reuses its open PR instead of opening another.
|
||
pr="$(gh pr list --repo "$SITE_REPO" --head "$BRANCH" --state open --json url --jq '.[].url')"
|
||
if [ -z "$pr" ]; then
|
||
pr="$(gh pr create --repo "$SITE_REPO" --base main --head "$BRANCH" --title "$title" \
|
||
--body "Opened by the [Android release run]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID) for mobile-android-v$VERSION. Merging points /download/android and every APK link on the site at the new APK.")"
|
||
fi
|
||
echo "Android APK download bump: $pr" >> "$GITHUB_STEP_SUMMARY"
|
||
|
||
# Why wait for checks: they push a deploy commit onto the branch (merging first fails with
|
||
# "Head branch was modified"), and a failed site build must not merge. Main's push deploys.
|
||
sleep 20
|
||
gh pr checks "$pr" --repo "$SITE_REPO" --watch --interval 15 || true
|
||
failed="$(gh pr checks "$pr" --repo "$SITE_REPO" --json bucket --jq 'map(select(.bucket == "fail")) | length' 2>/dev/null || true)"
|
||
# The deploy commit is [skip ci], so the final head may report no checks at all.
|
||
if [ "${failed:-0}" != "0" ]; then
|
||
echo "::warning::The site's checks failed on $pr; not merging. Fix and merge it by hand."
|
||
exit 1
|
||
fi
|
||
for _ in 1 2 3; do
|
||
if gh pr merge "$pr" --repo "$SITE_REPO" --squash --delete-branch; then
|
||
echo "Merged; the site deploys from main." >> "$GITHUB_STEP_SUMMARY"
|
||
exit 0
|
||
fi
|
||
sleep 30
|
||
done
|
||
echo "::warning::Could not merge $pr; merge it by hand to update /download/android."
|
||
exit 1
|
||
|
||
- name: Report a failed bump
|
||
if: failure()
|
||
run: |
|
||
echo "::warning::The release is published, but the site's Android APK download was not bumped to $VERSION."
|
||
printf '### Android APK download not bumped\n\nThe release is published, but /download/android still serves the previous APK. Rerun this job, or set `ANDROID_RELEASE_VERSION` in `src/lib/downloadTargets.ts` of %s to `%s` by hand.\n' "$SITE_REPO" "$VERSION" >> "$GITHUB_STEP_SUMMARY"
|