Files
orca/.github/workflows/mobile-android-release.yml
Neil b9e3912ff8 Call orca.dev for feedback, plugin lists and update notes; accept orca.dev share and download links (#41380)
* chore(endpoints): call orca.dev for feedback, plugin lists and update notes

The site moved to orca.dev. Feedback, connection diagnostics, plugin install
stats, the plugin kill list, update nudges and the changelog feed now use
orca.dev, as do the diagnostics token URL baked into builds and the release
download mirror (downloads.orca.dev). The old hosts serve the same paths,
so released builds keep working.

Skill share links on share.orca.dev are accepted, and the mobile scanner
recognizes the orca.dev Android download code. The old hosts stay accepted.

* chore(ci): leave the release JavaScript benchmark workflow unchanged

Editing it triggers the benchmark, whose parity check currently fails on
arm64 because max-quality brotli output for the mobile page differs by
architecture. The benchmark only needs some diagnostics URL, and the old
host still serves it.
2026-11-11 21:50:31 -08:00

390 lines
18 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 }}
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
# 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"