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>
This commit is contained in:
+220
@@ -0,0 +1,220 @@
|
||||
# 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
|
||||
```
|
||||
Reference in New Issue
Block a user