-
Notifications
You must be signed in to change notification settings - Fork 22
273 lines (246 loc) · 11.8 KB
/
Copy pathandroid-build.yml
File metadata and controls
273 lines (246 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# Build the CatGo Android app on a GitHub-hosted Linux runner.
#
# Android (unlike iOS) can be built on Linux, and its .apk can be installed
# directly (sideloaded) — so this uploads the signed APK straight to a GitHub
# release. Triggered manually.
#
# • No keystore secrets set -> builds an UNSIGNED universal APK as a smoke
# test that the frontend + Rust lib compile and bundle for Android.
# • Keystore secrets set -> signs the APK with apksigner (your upload key)
# and, if `release_tag` is given, uploads it to that GitHub release.
#
# Add these repo secrets (Settings -> Secrets and variables -> Actions) to sign:
# ANDROID_KEYSTORE_BASE64 base64 of your upload keystore (.jks):
# base64 -w0 ~/.catgo-keys/catgo-upload.jks
# ANDROID_KEYSTORE_PASSWORD the keystore (store) password
# ANDROID_KEY_ALIAS the key alias, e.g. catgo-upload
# ANDROID_KEY_PASSWORD the key password
#
# IMPORTANT: Android requires the SAME upload key across updates. Use the exact
# keystore that signed previous CatGo APKs, or existing users can't update.
name: Android build
on:
workflow_dispatch:
inputs:
release_tag:
description: 'Upload the signed APK to this GitHub release tag (e.g. v1.1.16). Leave empty to only keep the workflow artifact.'
type: string
required: false
default: ''
# Needed for `gh release upload` to attach the APK to a release.
permissions:
contents: write
jobs:
android:
runs-on: ubuntu-22.04
name: Build Android app
env:
NODE_OPTIONS: --max-old-space-size=6144
NDK_VERSION: 26.1.10909125
# Threaded-ferrox nightly pin (read by the rustup step AND build-wasm.mjs,
# which the Tauri beforeBuildCommand invokes). Bump only after
# `pnpm build:wasm && pnpm verify:wasm` passes on the new nightly; keep
# the pin identical across all workflows that define it.
CATGO_WASM_NIGHTLY_TOOLCHAIN: "nightly-2026-07-16"
steps:
- name: Checkout repository
# A release upload must build the exact requested tag. An unversioned
# smoke build continues to build the dispatched ref.
uses: actions/checkout@v4
with:
ref: ${{ inputs.release_tag != '' && inputs.release_tag || github.ref }}
fetch-depth: 0
- name: Verify release rights
run: node scripts/verify-release-rights.mjs
- name: Verify release version
env:
RELEASE_VERSION_TAG: ${{ inputs.release_tag }}
RELEASE_VERSION_REQUIRE_TAG: ${{ inputs.release_tag != '' }}
run: node scripts/verify-release-version.mjs
- name: Verify release source
env:
RELEASE_SOURCE_TAG: ${{ inputs.release_tag }}
RELEASE_SOURCE_REQUIRE_TAG: ${{ inputs.release_tag != '' }}
run: node scripts/verify-release-source.mjs
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Setup JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Install Android NDK + build-tools
run: |
set -euo pipefail
yes | sdkmanager --licenses >/dev/null 2>&1 || true
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" "ndk;${NDK_VERSION}"
echo "NDK_HOME=${ANDROID_HOME}/ndk/${NDK_VERSION}" >> "$GITHUB_ENV"
echo "ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${NDK_VERSION}" >> "$GITHUB_ENV"
echo "ANDROID_HOME=${ANDROID_HOME}"; ls "${ANDROID_HOME}/ndk/${NDK_VERSION}" >/dev/null && echo "NDK ok"
- name: Setup Rust + Android targets
run: |
rustup toolchain install stable --profile minimal
rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
rustup target add wasm32-unknown-unknown
# Pinned nightly (with rust-src) for the THREADED ferrox WASM
# artifact — build-wasm.mjs (via the Tauri beforeBuildCommand)
# rebuilds std with atomics on it; stable stays the default.
rustup toolchain install "$CATGO_WASM_NIGHTLY_TOOLCHAIN" --profile minimal \
--component rust-src --target wasm32-unknown-unknown
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: |
src-tauri -> target
extensions/rust-wasm -> target
cache-on-failure: true
- name: Install wasm-pack
# The Tauri beforeBuildCommand (pnpm desktop:build -> build:wasm) compiles
# the ferrox/chgdiff/catrender WASM crates and hard-fails if wasm-pack is
# missing. Mirror desktop / iOS CI.
run: |
if ! command -v wasm-pack >/dev/null 2>&1; then
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
fi
wasm-pack --version
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
- name: Generate documentation chunks
# vite build imports src/lib/chat/rag.ts -> ./docs-chunks.json. Without it
# the frontend build fails with "Could not resolve ./docs-chunks.json".
run: pnpm run build:doc-chunks
- name: Initialise the Android project
# Generates src-tauri/gen/android (gitignored, regenerated). Idempotent.
# Android keeps the tauri.conf identifier (com.catgo.app). tauri.android.conf.json
# sets externalBin:[] so NO catgo-server sidecar is bundled — the mobile app
# is backend-free.
run: pnpm tauri android init
- name: Re-apply Android native inset patches
# `tauri android init` regenerates gen/android from stock templates,
# DROPPING MainActivity.kt's window-inset listener and the manifest's
# windowSoftInputMode. Without this the released APK draws under the
# status bar (clock/battery overlap the header) and the soft keyboard
# covers the terminal keybar / AI-chat composer. Idempotent.
# See deploy/android/README.md and plan/mobile-input-issues.md.
run: bash deploy/android/apply-native-patches.sh
- name: Generate app icons (CatGo cat)
run: pnpm tauri icon desktop/logo.png
- name: Build the universal release APK (unsigned)
# VITE_STATIC_ONLY=1: no Python sidecar / LAN backend on mobile, so stray
# backend fetches return a friendly 503 instead of hanging on localhost.
# Mobile-native paths (local view/edit, DB import via isMobile() direct
# fetch, SSH terminal/files over russh) still work; backend-only UI is
# already hidden on mobile.
env:
VITE_STATIC_ONLY: '1'
run: pnpm tauri android build --apk true
- name: Verify legal bundle in APK/AAB
run: |
set -euo pipefail
mapfile -t artifacts < <(find src-tauri/gen/android -type f \( -name '*.apk' -o -name '*.aab' \))
if [ "${#artifacts[@]}" -eq 0 ]; then
echo "::error::No Android APK/AAB found for legal-bundle inspection"
exit 1
fi
for artifact in "${artifacts[@]}"; do
unzip -Z1 "$artifact" > android-archive.lst
while IFS= read -r file; do
rel="${file#build/legal-bundle/}"
if ! grep -Fq "/legal/$rel" android-archive.lst; then
echo "::error::$artifact is missing legal/$rel"
exit 1
fi
done < <(
find build/legal-bundle -type f \
! -name '.catgo-legal-bundle-owned' |
sort
)
done
- name: Sign the APK (apksigner)
id: sign
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
# Repository variable containing the approved signing certificate's
# SHA-256 digest (hex, with or without colon separators).
ANDROID_EXPECTED_SIGNER_SHA256: ${{ vars.ANDROID_SIGNING_CERT_SHA256 }}
run: |
set -euo pipefail
APK_DIR="src-tauri/gen/android/app/build/outputs/apk/universal/release"
UNSIGNED=$(find "$APK_DIR" -name 'app-universal-release*.apk' -print -quit)
if [ -z "${UNSIGNED}" ]; then echo "::error::No universal release APK found under $APK_DIR"; exit 1; fi
echo "Built APK: ${UNSIGNED}"
VERSION=$(node -p "require('./package.json').version")
OUT="CatGo-v${VERSION}-android-universal.apk"
if [ -z "${ANDROID_KEYSTORE_BASE64:-}" ]; then
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret — leaving the APK UNSIGNED (smoke build, not installable for updates)."
cp "${UNSIGNED}" "${OUT}"
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "apk=${OUT}" >> "$GITHUB_OUTPUT"
exit 0
fi
BT=$(ls -d "${ANDROID_HOME}/build-tools/"*/ | sort -V | tail -1)
echo "Using build-tools: ${BT}"
echo "${ANDROID_KEYSTORE_BASE64}" | base64 -d > upload.jks
# zipalign first (required for apksigner v2+), then sign.
"${BT}zipalign" -p -f 4 "${UNSIGNED}" aligned.apk
"${BT}apksigner" sign \
--ks upload.jks \
--ks-pass "pass:${ANDROID_KEYSTORE_PASSWORD}" \
--ks-key-alias "${ANDROID_KEY_ALIAS}" \
--key-pass "pass:${ANDROID_KEY_PASSWORD}" \
--out "${OUT}" aligned.apk
if [ -z "${ANDROID_EXPECTED_SIGNER_SHA256:-}" ]; then
echo "::error::Repository variable ANDROID_SIGNING_CERT_SHA256 is required for signed Android releases"
exit 1
fi
VERIFY_OUTPUT=$("${BT}apksigner" verify --verbose --print-certs "${OUT}")
printf '%s\n' "$VERIFY_OUTPUT"
mapfile -t SIGNER_SHA256_DIGESTS < <(
printf '%s\n' "$VERIFY_OUTPUT" |
awk -F': ' '/certificate SHA-256 digest:/ {
digest = $NF
gsub(/[[:space:]:]/, "", digest)
print tolower(digest)
}' |
sort -u
)
if [ "${#SIGNER_SHA256_DIGESTS[@]}" -ne 1 ] ||
! [[ "${SIGNER_SHA256_DIGESTS[0]:-}" =~ ^[0-9a-f]{64}$ ]]; then
echo "::error::apksigner must report exactly one signer certificate SHA-256 digest"
exit 1
fi
ACTUAL_SIGNER_SHA256="${SIGNER_SHA256_DIGESTS[0]}"
EXPECTED_SIGNER_SHA256=$(printf '%s' "$ANDROID_EXPECTED_SIGNER_SHA256" | tr -d '[:space:]:' | tr '[:upper:]' '[:lower:]')
if [ "$ACTUAL_SIGNER_SHA256" != "$EXPECTED_SIGNER_SHA256" ]; then
echo "::error::Android signer certificate SHA-256 does not match repository policy"
exit 1
fi
rm -f upload.jks aligned.apk
echo "Signed APK: ${OUT}"
echo "signed=true" >> "$GITHUB_OUTPUT"
echo "apk=${OUT}" >> "$GITHUB_OUTPUT"
- name: Upload APK to the GitHub release
if: ${{ inputs.release_tag != '' && steps.sign.outputs.signed == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release upload "${{ inputs.release_tag }}" "${{ steps.sign.outputs.apk }}" --clobber
echo "Uploaded ${{ steps.sign.outputs.apk }} to release ${{ inputs.release_tag }}"
- name: Upload workflow artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: catgo-android
path: CatGo-v*-android-universal.apk
if-no-files-found: warn