Files
orca/.github/workflows/mobile-android-release.yml
T
Neil fc67787090 feat(mobile): sign Android release builds with a dedicated release key (#38924)
* feat(mobile): sign Android release builds with a dedicated release key

Add an Expo config plugin that gives the generated app/build.gradle an
env-driven release signing config, and have the Android release workflow
require the keystore secrets, fail before building when they are missing,
and check the APK's signing certificate against a pinned fingerprint.

* fix(mobile): keep the signing setup hint intact in the workflow error

* test(mobile): type the plugin import without a cast

* feat(mobile): keep debug signing until release signing is enabled

Release signing now turns on when the ORCA_ANDROID_RELEASE_CERT_SHA256
(or ORCA_ANDROID_REQUIRE_RELEASE_SIGNING) repository variable is set.
Until then the workflow keeps today's debug-signed build and adds a
warning and job summary note; once enabled, missing secrets fail.

* feat(mobile): enable v2 and v3 APK signing for the release key
2026-11-06 14:33:18 -08:00

378 lines
17 KiB
YAML
Raw 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 }}
defaults:
run:
working-directory: mobile
steps:
- name: Checkout
uses: actions/checkout@v6
# Release signing is enforced once ORCA_ANDROID_RELEASE_CERT_SHA256 (or ORCA_ANDROID_REQUIRE_RELEASE_SIGNING=true)
# is set as a repository variable. Until then the build keeps the Expo debug key, with a warning.
- name: Check release signing configuration
id: signing
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 }}
ORCA_ANDROID_RELEASE_CERT_SHA256: ${{ vars.ORCA_ANDROID_RELEASE_CERT_SHA256 }}
ORCA_ANDROID_REQUIRE_RELEASE_SIGNING: ${{ vars.ORCA_ANDROID_REQUIRE_RELEASE_SIGNING }}
run: |
set -euo pipefail
if [ -z "${ORCA_ANDROID_RELEASE_CERT_SHA256:-}" ] && [ "${ORCA_ANDROID_REQUIRE_RELEASE_SIGNING:-}" != 'true' ]; then
message="Android release signing is not enabled, so this APK is signed with the Expo debug key as before. Set the ORCA_ANDROID_RELEASE_CERT_SHA256 repository variable to enable it; see the Android Release Signing section of mobile/README.md."
echo "::warning::$message"
printf '### Android release signing not enabled\n\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY"
echo "mode=debug" >> "$GITHUB_OUTPUT"
exit 0
fi
missing=()
for name in ORCA_ANDROID_KEYSTORE_BASE64 ORCA_ANDROID_KEYSTORE_PASSWORD ORCA_ANDROID_KEY_ALIAS ORCA_ANDROID_KEY_PASSWORD ORCA_ANDROID_RELEASE_CERT_SHA256; do
if [ -z "${!name:-}" ]; then
missing+=("$name")
fi
done
if [ "${#missing[@]}" -ne 0 ]; then
echo "::error::Android release signing is enabled but not fully configured. Missing secrets or variables: ${missing[*]}. See the Android Release Signing section of mobile/README.md."
exit 1
fi
echo "mode=release" >> "$GITHUB_OUTPUT"
- 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
- 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
if: steps.signing.outputs.mode == 'release'
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:
# Empty in debug mode, which leaves Gradle on the debug key exactly as before.
ORCA_ANDROID_REQUIRE_RELEASE_SIGNING: ${{ steps.signing.outputs.mode == 'release' && 'true' || '' }}
ORCA_ANDROID_KEYSTORE_PATH: ${{ steps.signing.outputs.mode == 'release' && format('{0}/orca-release.keystore', runner.temp) || '' }}
ORCA_ANDROID_KEYSTORE_PASSWORD: ${{ steps.signing.outputs.mode == 'release' && secrets.ORCA_ANDROID_KEYSTORE_PASSWORD || '' }}
ORCA_ANDROID_KEY_ALIAS: ${{ steps.signing.outputs.mode == 'release' && secrets.ORCA_ANDROID_KEY_ALIAS || '' }}
ORCA_ANDROID_KEY_PASSWORD: ${{ steps.signing.outputs.mode == 'release' && secrets.ORCA_ANDROID_KEY_PASSWORD || '' }}
run: cd android && ./gradlew assembleRelease
- 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 signing certificate
id: signer
if: steps.signing.outputs.mode == 'release'
env:
EXPECTED_CERT_SHA256: ${{ vars.ORCA_ANDROID_RELEASE_CERT_SHA256 }}
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)
certs="$("$apksigner" verify --print-certs "${apks[0]}" | sed -n 's/^Signer #[0-9]* 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 ORCA_ANDROID_RELEASE_CERT_SHA256 ($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: 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
# Keep site bump advisory after publication; failure only leaves its download link on the prior APK.
bump-apk-download:
needs: android-build
if: 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"