mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
An APK that fails to install with a missing certificate or a package-parse error is usually a download that died near the end: the signature block sits in the last ~100 KB of a 133 MB file, so a truncated APK looks complete and carries no signature at all. The release published neither a size nor a digest, so there was no way to tell that apart from a bad build without deriving both from the asset by hand. The release now uploads app-release.apk.sha256 next to the APK in `sha256sum -c` format (binary marker, so Git Bash cannot translate line endings while hashing) and puts the exact byte size and digest in the release body, naming `shasum -a 256 -c` for readers on macOS. The upload path rewrites the body too: --clobber replaces the APK, so a digest left over from the previous build would describe a file nobody can download, and a reader comparing against it would reject a good APK. Both paths reserve the section's own length out of the release-body cap before truncating, so the section always survives and the body always fits; MAX_RELEASE_BODY_LENGTH is exported from the desktop release script rather than restated. Refs #24011, #12248, #11444.
234 lines
9.8 KiB
YAML
234 lines
9.8 KiB
YAML
name: Mobile Android Release
|
|
|
|
# Why a separate workflow from iOS: iOS releases go through App Store review,
|
|
# which can take days. Decoupling the triggers lets an Android release ship
|
|
# immediately without waiting on iOS, and vice versa.
|
|
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
|
|
shell:
|
|
description: 'Which shell the binary mounts: native screens, or the web page delivered over the air. Default native; `ota` is the only value that changes it.'
|
|
required: false
|
|
default: native
|
|
type: choice
|
|
options:
|
|
- native
|
|
- ota
|
|
|
|
jobs:
|
|
android-build:
|
|
runs-on: ubuntu-latest
|
|
|
|
# Why: the "Create GitHub Release" step below uses the default GITHUB_TOKEN
|
|
# to call `gh release create`, which requires contents:write. Without this,
|
|
# the job builds the APK fine but fails at release time with
|
|
# "HTTP 403: Resource not accessible by integration".
|
|
permissions:
|
|
contents: write
|
|
|
|
defaults:
|
|
run:
|
|
working-directory: mobile
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version: 24
|
|
|
|
- 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: Build Android release APK
|
|
env:
|
|
# The one build-time constant that decides whether this binary mounts the web page or
|
|
# the native screens. A tag push and a schedule carry no inputs, so both read native.
|
|
EXPO_PUBLIC_MOBILE_SHELL: ${{ inputs.shell || 'native' }}
|
|
run: |
|
|
set -euo pipefail
|
|
echo "Mobile shell: $EXPO_PUBLIC_MOBILE_SHELL"
|
|
cd android && ./gradlew assembleRelease
|
|
|
|
# Why: an install that fails with a missing certificate or a package-parse error is
|
|
# usually a download that died near the end, and the signature block sits in the last
|
|
# ~100 KB. Publishing the size and digest is what lets a reporter tell a truncated
|
|
# download apart from a bad build without anyone re-deriving them from the asset.
|
|
- 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)"
|
|
# A sibling file in `sha256sum -c` format, so verifying a download is one command
|
|
# and no retyped digest. The asset globs below match *.apk and skip it. The ` *`
|
|
# marker is binary mode: Git Bash's sha256sum reads text mode as a licence to
|
|
# translate line endings while hashing, which would fail on 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.
|
|
verification_section() {
|
|
printf '\n### Verify your download\n\n- Size: `%s` bytes\n- SHA-256: `%s`\n\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 }}'
|
|
}
|
|
|
|
# The section is always last, so dropping from its heading to EOF leaves the
|
|
# generated notes intact. \r* because a body round-tripped through the API has CRLFs.
|
|
drop_verification_section() {
|
|
sed '/^### Verify your download\r*$/,$d'
|
|
}
|
|
|
|
# Why: reuse the desktop release path's character-safe truncation so a multi-byte
|
|
# character cannot be split at the cap. The section's own length is reserved out of
|
|
# that cap, because appending after truncating would push a near-limit body past
|
|
# GitHub's API limit and fail the call — on the upload path, after --clobber has
|
|
# already replaced the assets. `wc -c` counts bytes, so the section's multi-byte
|
|
# characters over-reserve, which errs toward a shorter body.
|
|
write_notes_with_verification() {
|
|
NOTES_FILE="$notes_file" \
|
|
NOTES_RESERVE="$(verification_section | wc -c | tr -d ' ')" \
|
|
NOTES_MODULE="$GITHUB_WORKSPACE/config/scripts/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 }}"
|
|
# Why the body is rewritten too: --clobber replaced the APK, so a digest left
|
|
# over from the previous build now describes a file nobody can download, and a
|
|
# reader comparing against it would reject a good APK.
|
|
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
|
|
# Why: release tags live on side branches, so GitHub's automatic
|
|
# previous-tag detection reaches back several releases; that body
|
|
# already exceeds the 125000-character API limit and grows each
|
|
# release. Pin the comparison base and cap the size.
|
|
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
|
|
# Why: gh writes the JSON error body to stdout on an HTTP error, so a
|
|
# non-empty file is not proof of success — gate on exit status.
|
|
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
|