Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions .github/workflows/build-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,20 +169,62 @@ jobs:
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks"
export ANDROID_KEYSTORE="$RUNNER_TEMP/release.jks"
task=assembleRelease out=release name="openclapper-$version-$GIT_COMMIT.apk"
# The staging app: its own name, package id and icon (app.config.ts), the same key.
if [ "$VARIANT" = staging ]; then export APP_VARIANT=staging name="openclapper-staging-$version-$GIT_COMMIT.apk"; fi
# The staging app: its own name, package id and icon (app.config.ts). Its version code is the
# commit count, so every build of main is a new version for Play (deploy#10): it needs the full
# history, which a run with `ref` checks out; a shallow checkout would count 1.
if [ "$VARIANT" = staging ]; then
export APP_VARIANT=staging name="openclapper-staging-$version-$GIT_COMMIT.apk"
export GIT_COMMIT_COUNT=$(git rev-list --count HEAD)
[ "$GIT_COMMIT_COUNT" -gt 1 ] || { echo "::error::A staging build needs the full history for its version code: run it with \`ref\` (a branch or main)"; exit 1; }
echo "Version code: $GIT_COMMIT_COUNT (commit count)"
fi
# The App Bundle for Play next to the APK for sideloading, from the same build and key.
task="$task bundleRelease"
fi
pnpm exec expo prebuild --platform android --no-install
(cd android && ./gradlew $task --no-daemon -PreactNativeArchitectures="$ANDROID_ABIS")
mv "android/app/build/outputs/apk/$out/app-$out.apk" "$RUNNER_TEMP/$name"
if [ "$VARIANT" != dev ]; then
aab="${name%.apk}.aab"
mv android/app/build/outputs/bundle/release/app-release.aab "$RUNNER_TEMP/$aab"
# Never hand out a release signed with the debug key (it would not update a real install).
signer=$(ls "$ANDROID_HOME"/build-tools/*/apksigner | sort -V | tail -1)
if "$signer" verify --print-certs "$RUNNER_TEMP/$name" | grep -q "CN=Android Debug"; then echo "::error::The APK is debug-signed"; exit 1; fi
if jarsigner -verify -verbose -certs "$RUNNER_TEMP/$aab" | grep -q "CN=Android Debug"; then echo "::error::The App Bundle is debug-signed"; exit 1; fi
rm "$RUNNER_TEMP/release.jks"
echo "AAB_NAME=$aab" >> "$GITHUB_ENV"
fi
echo "APK_NAME=$name" >> "$GITHUB_ENV"

# What Google Play requires of an upload (docs/self-hosting.md, Play Store). The target API level
# is a hard gate: Play rejects new apps and updates below API 36 since 31 August 2026. The 16 KB
# page size (native libraries' LOAD segments aligned to 16 KB) is reported for now.
- name: Play requirements
if: env.AAB_NAME != ''
working-directory: .
run: |
apk="$RUNNER_TEMP/$APK_NAME"
tools=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1)
target=$("$tools/aapt2" dump badging "$apk" | sed -n "s/^targetSdkVersion:'\([0-9]*\)'.*/\1/p")
code=$("$tools/aapt2" dump badging "$apk" | sed -n "s/^package: .*versionCode='\([0-9]*\)'.*/\1/p")
echo "Target API level $target, version code $code" | tee -a "$GITHUB_STEP_SUMMARY"
[ "${target:-0}" -ge 36 ] || { echo "::error::Google Play needs target API level 36 or higher; this build targets ${target:-?}"; exit 1; }
readelf=$(find "$ANDROID_HOME/ndk" -name llvm-readelf -path '*prebuilt*' 2>/dev/null | sort -V | tail -1)
[ -n "$readelf" ] || { echo "::warning::No llvm-readelf in the NDK: 16 KB page size not checked"; exit 0; }
libs=$(mktemp -d) && unzip -q -o "$apk" 'lib/*' -d "$libs" || true
bad=""
for so in $(find "$libs" -name '*.so'); do
for a in $("$readelf" -lW "$so" | awk '$1 == "LOAD" { print $NF }'); do
[ $((a)) -ge 16384 ] || { bad="$bad $(basename "$so")"; break; }
done
done
if [ -n "$bad" ]; then
echo "::warning::Native libraries not aligned to 16 KB:$bad"
echo "Not aligned to 16 KB:$bad" >> "$GITHUB_STEP_SUMMARY"
else
echo "Every native library is aligned to 16 KB." | tee -a "$GITHUB_STEP_SUMMARY"
fi

- name: Disk
if: always()
working-directory: .
Expand All @@ -194,3 +236,12 @@ jobs:
path: ${{ runner.temp }}/${{ env.APK_NAME }}
retention-days: 3
compression-level: 0

# The App Bundle (release and staging): what goes to Google Play (scripts/play-upload.mjs, deploy#11).
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: env.AAB_NAME != ''
with:
name: ${{ env.AAB_NAME }}
path: ${{ runner.temp }}/${{ env.AAB_NAME }}
retention-days: 3
compression-level: 0
11 changes: 11 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ function versionCode(v: string): number {
return major * 1_000_000 + minor * 1_000 + patch;
}

/**
* The staging app is built from main between releases, and Play takes each version code once, so it
* counts commits instead (GIT_COMMIT_COUNT, set by the Build APK workflow from a full checkout). Without
* the count (a local config run) it falls back to the release scheme.
*/
function stagingVersionCode(): number {
const count = Number.parseInt(process.env.GIT_COMMIT_COUNT ?? "", 10);
return count > 1 ? count : versionCode(version);
}

/**
* The prebuild template signs release builds with the debug key. With ANDROID_KEYSTORE (an absolute
* path), ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD in Gradle's
Expand Down Expand Up @@ -89,6 +99,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
icon: `${icons}/app-icon-1024.png`,
android: {
...app.android,
...(name === "staging" ? { versionCode: stagingVersionCode() } : {}),
package: v.package,
adaptiveIcon: {
backgroundColor: v.background,
Expand Down
3 changes: 2 additions & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ OpenClapper has **one version for the whole product**: the Worker and the Studio
- Studio: Settings › General › About, next to the server's version. They differ while a tab still has the bundle from before a deploy.
- Admin board: its header.
- Phone: the Setup footer and the debug log.
- **Android**: `versionName` is the version and `versionCode` is derived from it (`major·1 000 000 + minor·1 000 + patch`: 0.1.0 → 1000), so it grows with every release (`apps/mobile/app.config.ts`). Builds between releases carry the same code as the last release; Android installs a same-code update over it.
- **Android**: `versionName` is the version and `versionCode` is derived from it (`major·1 000 000 + minor·1 000 + patch`: 0.1.0 → 1000), so it grows with every release (`apps/mobile/app.config.ts`). Builds between releases carry the same code as the last release; Android installs a same-code update over it. Google Play takes each code once, so production goes to Play once per release.
- **The staging app** (`APP_VARIANT=staging`) counts commits instead (`git rev-list --count HEAD`, Build APK sets it from a full checkout), so every build of `main` is a new version for Play. A branch build has a higher count than `main`: sideloaded, it blocks Play's next update of the staging app (Android refuses a lower code) until it is uninstalled. The same holds once for a staging app sideloaded before Play: its release-based code (`10000` for 0.10.0) is higher than the count.

## Cutting a release

Expand Down
14 changes: 13 additions & 1 deletion docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,22 @@ Once:
2. **Back up both files** (a password manager or an encrypted drive). Lose them and nobody can update the app without uninstalling it, which deletes whatever is still queued on the phones. The GitHub secrets and the backup are the key's home: nothing reads it from a checkout.
3. Add the repository variable `APP_URL`: your app's address, `https://<APP_DOMAIN>` or `https://<APP_WORKER_NAME>.<your-subdomain>.workers.dev`.

Then Actions → **Build APK** → Run workflow (`variant: release`, the default; `staging` builds "OpenClapper Staging", `org.openclapper.staging` with the clay icon and the same key, which installs next to the production app, for a [second instance](#a-second-instance-staging); `dev` builds the development client for the phone dev loop instead, [development](development.md)). The build runs on GitHub's runner. It compiles only for `arm64-v8a`, which every current Android phone runs; for older 32-bit phones set the variable `ANDROID_ABIS=arm64-v8a,armeabi-v7a` (each architecture adds build time, disk and APK size). The job first deletes preinstalled tools it does not use, since a standard runner has only about 14 GB free. The APK is the run's artifact and is **deleted after 3 days** (`retention-days`: GitHub removes it, nothing to clean up). Download it, open it on the phone and allow installs from that source. A private repository on GitHub's Free plan has 500 MB of artifact storage; one APK is about 50 MB.
Then Actions → **Build APK** → Run workflow (`variant: release`, the default; `staging` builds "OpenClapper Staging", `org.openclapper.staging` with the clay icon and the same key, which installs next to the production app, for a [second instance](#a-second-instance-staging); `dev` builds the development client for the phone dev loop instead, [development](development.md)). The build runs on GitHub's runner. It compiles only for `arm64-v8a`, which every current Android phone runs; for older 32-bit phones set the variable `ANDROID_ABIS=arm64-v8a,armeabi-v7a` (each architecture adds build time, disk and APK size). The job first deletes preinstalled tools it does not use, since a standard runner has only about 14 GB free. The APK is the run's artifact and is **deleted after 3 days** (`retention-days`: GitHub removes it, nothing to clean up). Download it, open it on the phone and allow installs from that source. Release and staging runs also make the App Bundle for [Google Play](#play-store). A private repository on GitHub's Free plan has 500 MB of artifact storage; one APK is about 80 MB, a bundle about as much.

Without GitHub, a release build is your own setup: export the key's four values (with `ANDROID_KEYSTORE` as the path to the `.jks`) and `EXPO_PUBLIC_API_URL`, then run the workflow's build step. `make apk-local` is no release: it builds "OpenClapper Local", debug-signed, for trying the app ([development](development.md#apk-builds)).

## Play Store

Google Play delivers the app instead of sideloading, starting with the **internal testing** track: up to 100 testers by email, no review, no store listing, updates on the phones within minutes. No Expo account and no third-party service is involved: Build APK makes the bundle, and Google's own API takes it (the upload, deploy#11).

- **The bundle.** Release and staging runs of Build APK produce an Android App Bundle (`.aab`) next to the APK, from the same build and key, as a second artifact. Play takes only bundles; the APK stays for sideloading. `ANDROID_ABIS` limits which devices Play can serve.
- **Requirements**, checked by the workflow's "Play requirements" step:
- Target API level 36 or higher: Play has rejected new apps and updates below it since 31 August 2026. The step fails below.
- 16 KB page size: native libraries' load segments aligned to 16 KB. The step reports any library that is not, as a warning for now.
- **Version codes.** Play takes each version code once per app. Production's grows with each release (`0.10.0` → `10000`, [releasing](releasing.md)), so production uploads once per release. The staging app counts commits instead, so every build of `main` is a new version (it needs the full history, which a run with `ref` has).
- **Signing.** Play signs what it delivers (Play App Signing). On the first upload of each app, choose to upload your own key (the Console's "Export and upload a key from a Java keystore", with the PEPK tool it links), so an APK from Build APK and the app from Play carry the same signature and update each other. With Google's key instead, a sideloaded install has to be removed once before Play can update it.
- **The first bundle of each app** is uploaded by hand in the Play Console: the API can neither create an app nor choose its signing key. Play Console → Create app → the package (`org.openclapper`, `org.openclapper.staging`) → Testing → Internal testing → Create release → the `.aab` from Build APK. Then add the testers' emails and open the opt-in link on each phone once.

## The free plan

The Workers free plan allows 10 ms of CPU per request. Checking a password with 100 000 PBKDF2 iterations takes about 16 ms, so a sign-in may fail there. Set `PBKDF2_ITERATIONS` lower, e.g. `50000`; stored passwords are rehashed to the new count on their next sign-in. Everything else stays well within the free limits for a small crew.
Expand Down
Loading