# Wisp — Deployment Runbook CI/CD for Wisp runs on **Gitea Actions**. A push to `master` will: 1. **Deploy the web app** to the `briggen.dev` server as a Docker container (nginx serving the Expo/Metro static export, with cross-origin isolation headers for multi-threaded WASM). 2. **Build a signed Android APK** and publish it both to the server (`https:///wisp.apk`) and as a downloadable CI artifact. Pipeline: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). --- ## 1. Required Gitea secrets Set these in **Gitea → Wisp repo → Settings → Actions → Secrets**. | Secret | Used by | What it is | | --- | --- | --- | | `SSH_HOST` | deploy-web, build-apk | Hostname/IP of the briggen.dev server (SSH target). | | `SSH_USER` | deploy-web, build-apk | SSH user with permission to run `docker` and write `/srv/wisp`. | | `SSH_KEY` | deploy-web, build-apk | **Private** SSH key (PEM) whose public half is in the server's `authorized_keys`. | | `ANDROID_KEYSTORE_BASE64` | build-apk | Base64 of the Wisp release keystore (see §3). | | `ANDROID_KEYSTORE_PASSWORD` | build-apk | Keystore (store) password. | | `ANDROID_KEY_ALIAS` | build-apk | Key alias — `wisp` if you used `scripts/gen-keystore.sh` defaults. | | `ANDROID_KEY_PASSWORD` | build-apk | Key (alias) password (may equal the store password). | > The SSH key should be a **dedicated deploy key** with the minimum access > needed (docker + write to `/srv/wisp`), not your personal key. --- ## 2. One-time server setup (briggen.dev) 1. **Install Docker + Compose plugin** on the server (the runner uses `docker compose` over SSH). 2. **Create the deploy directory and place the compose file:** ```bash sudo mkdir -p /srv/wisp/web # Copy this repo's docker-compose.yml to the server: scp docker-compose.yml @:/srv/wisp/docker-compose.yml ``` - `/srv/wisp/docker-compose.yml` — the service definition CI runs (`docker compose -f /srv/wisp/docker-compose.yml up -d`). - `/srv/wisp/web/wisp.apk` — where CI drops the signed APK; it is mounted read-only into nginx's web root by the compose file. (It's fine if it doesn't exist before the first APK build — nginx 404s `/wisp.apk` until then.) 3. **Create the proxy network** the compose file attaches to (or reuse your existing reverse-proxy network and rename it in `docker-compose.yml`): ```bash docker network create proxy ``` 4. **Wire your reverse proxy + TLS** for the Wisp host on `briggen.dev` (e.g. `wisp.briggen.dev`). The container listens on **port 8080** and is reachable as `wisp-web:8080` on the `proxy` network. Point your proxy at it and terminate TLS there. Examples: - **Caddy:** ``` wisp.briggen.dev { reverse_proxy wisp-web:8080 } ``` - **Traefik:** add router/service labels for `wisp-web` (host rule `Host(\`wisp.briggen.dev\`)`, service port `8080`, your TLS cert resolver). - **Host nginx (proxy not in Docker):** use **Option B** in `docker-compose.yml` (publish `127.0.0.1:8080:8080`) and `proxy_pass http://127.0.0.1:8080;`. > **Do not strip or override** the `Cross-Origin-Opener-Policy` / > `Cross-Origin-Embedder-Policy` headers at the proxy — they must reach the > browser intact (see §4). 5. **Ensure the Gitea Actions runner host has Docker.** The `deploy-web` and `build-apk` jobs run `docker build`, `docker save/load`, `ssh`, and `scp` on the runner. The `test` job runs inside the `oven/bun:1` container. The first push to `master` after this is set up will deploy. --- ## 3. Generate the Android release keystore Run **once** on a trusted machine, then back it up forever: ```bash ./scripts/gen-keystore.sh ``` This creates a new RSA-2048 keystore (alias `wisp`, valid 10000 days) and prints the exact `base64` command plus the list of secrets to set. Then: ```bash base64 -w0 wisp-release.keystore # Linux (macOS: base64 -b0 ...) ``` Paste that single line into `ANDROID_KEYSTORE_BASE64`, and set the password / alias secrets to match what you chose. > **Critical:** the keystore + passwords are the *only* way to ship updates to an > already-installed APK. Lose them and users must uninstall/reinstall. Back them > up in a password manager / offline. Never commit them (`.gitignore` excludes > `*.keystore` / `*.jks`). How signing is wired at build time: `scripts/ci-android-sign.sh` base64-decodes the keystore into `android/app/wisp.keystore`, writes the passwords into `android/gradle.properties`, and idempotently patches `android/app/build.gradle` to add `signingConfigs.release` and point `buildTypes.release` at it. --- ## 4. How COOP/COEP cross-origin isolation works (and why it matters) The web app runs Whisper as **multi-threaded WASM**, which needs `SharedArrayBuffer`. Browsers only expose `SharedArrayBuffer` to pages that are **cross-origin isolated**, which requires both response headers on the document: - `Cross-Origin-Opener-Policy: same-origin` - `Cross-Origin-Embedder-Policy: require-corp` When both are present, `self.crossOriginIsolated === true` and threads/SAB work. These headers are set in [`docker/nginx.conf`](../docker/nginx.conf) and served by our nginx container. ### Why GitHub Pages can't host this Cross-origin isolation requires **setting custom response headers** (COOP/COEP). **GitHub Pages does not let you set arbitrary response headers**, so you cannot make a Pages-hosted site cross-origin isolated, and threaded WASM will not run there. That's precisely why Wisp's web app is self-hosted behind our own nginx (which can send the headers) instead of on GitHub Pages. ### Model weights from Hugging Face + COEP The app fetches Whisper model weights at runtime from the **Hugging Face Hub**, a cross-origin host. Under `COEP: require-corp`, every cross-origin subresource must satisfy either a `Cross-Origin-Resource-Policy` header **or** valid CORS. HF generally serves permissive CORS, so CORS-mode fetches work. **If** a future HF / CDN response lacks the needed CORP/CORS, the download is blocked. The fallback is `Cross-Origin-Embedder-Policy: credentialless` (keeps isolation, drops the hard CORP requirement by sending no-CORS cross-origin requests without credentials). A ready-to-enable commented alternative line is in `docker/nginx.conf`. (Caveat: `credentialless` isn't supported by Safari, hence `require-corp` default.) --- ## 5. Android APK download After a successful `master` build: - **Direct download:** `https:///wisp.apk` (served by nginx from `/srv/wisp/web/wisp.apk`). - **CI artifact:** the workflow run's summary page → artifact `wisp-release-apk`. --- ## 6. Build the APK locally via Docker Reproduce the CI Android build on your machine (no Android Studio needed): ```bash # 1. Build the toolchain image (JDK 17 + Android SDK 36 + Bun). docker build -f docker/android.Dockerfile -t wisp-android:latest . # 2. Run the build inside it, repo bind-mounted, secrets via env. # (Use a LOCAL test keystore or your real secrets.) docker run --rm \ -v "$PWD":/workspace -w /workspace \ -e ANDROID_KEYSTORE_BASE64="$(base64 -w0 wisp-release.keystore)" \ -e ANDROID_KEYSTORE_PASSWORD="" \ -e ANDROID_KEY_ALIAS="wisp" \ -e ANDROID_KEY_PASSWORD="" \ wisp-android:latest \ bash -lc ' bun install --frozen-lockfile bunx expo prebuild --platform android --no-install bash scripts/ci-android-sign.sh cd android && ./gradlew assembleRelease --no-daemon ' # 3. The signed APK is at: # android/app/build/outputs/apk/release/app-release.apk ``` > **JDK version:** the dev machine may have **JDK 26**, but AGP 8.x targets > **JDK 17**. CI and `docker/android.Dockerfile` both pin **JDK 17** so builds are > reproducible and supported. Build through the Docker image to avoid local-JDK > surprises; don't run `./gradlew` against a host JDK 26. ### Pinned Android toolchain (Expo SDK 56 / RN 0.85) From `node_modules/react-native/gradle/libs.versions.toml`: `compileSdk 36`, `targetSdk 36`, `minSdk 24`, `build-tools 36.0.0`, `NDK 27.1.12297006`, `AGP 8.12.0`, `Kotlin 2.1.20`, Gradle wrapper `9.3.1`. > **Heads-up:** `app.json` does not set `android.package`. Vanilla > `expo prebuild` will derive a package name (from the `scheme`/slug) — set an > explicit `expo.android.package` (e.g. `dev.briggen.wisp`) before the first > store-bound build so the application ID is stable across releases. --- ## 7. Deploy flow at a glance ``` push master └─ test (bun: typecheck + vitest) ├─ deploy-web docker build → save|gzip → ssh docker load → compose up └─ build-apk docker build android image → prebuild → ci-android-sign.sh → gradlew assembleRelease → scp app-release.apk → /srv/wisp/web/wisp.apk → upload-artifact wisp-release-apk ```