Files
orca/.github/workflows/mobile-android-release.yml
Kelvin Amoaba 9acf309dbe ci(mobile): pin the Android release certificate in the workflow
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.
2026-11-13 16:33:58 +00:00

465 lines
20 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"