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 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"