# 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-host>/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 <user>@<server>:/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-host>/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="<store-pass>" \
  -e ANDROID_KEY_ALIAS="wisp" \
  -e ANDROID_KEY_PASSWORD="<key-pass>" \
  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
```
