mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
* 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.
390 lines
18 KiB
YAML
390 lines
18 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 }}
|
||
|
||
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"
|