Files
wisp/docs/DEPLOY.md
T
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

8.8 KiB

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.


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:

    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):

    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:

./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:

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 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):

# 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