Files
NilsBriggen 9f42ee2460
CI / test (push) Has been cancelled
CI / deploy-web (push) Has been cancelled
CI / build-apk (push) Has been cancelled
Build Wisp: on-device transcription studio (web + native, one codebase)
Private, offline speech-to-text that runs Whisper on the user's own device —
free, no account, no per-minute fees. Replaces Otter.ai / Rev.

- Pure, tested engine: chunking, overlap timestamp-stitching, exports
  (SRT/VTT/TXT/MD/JSON), WAV codec, resampler, job queue, model catalog (142 tests).
- Platform-abstracted TranscriptionEngine: transformers.js on web (loaded from
  CDN at runtime to dodge Metro's onnxruntime-web bundling limits), whisper.rn
  on native. Shared pipeline orchestrates decode -> chunk -> transcribe -> stitch.
- Cross-platform StorageRepo (Dexie web / expo-sqlite native), Zod-validated.
- UI: library + search, import, live-progress transcription, synced click-to-seek
  editor, multi-format export; model picker + privacy in settings.
- Web ships as a single-page PWA with COOP/COEP isolation for threaded WASM;
  Docker (nginx) image + Traefik compose for wisp.briggen.dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 17:54:21 +02:00

221 lines
8.8 KiB
Markdown

# 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
```