Build Wisp: on-device transcription studio (web + native, one codebase)
CI / test (push) Has been cancelled
CI / deploy-web (push) Has been cancelled
CI / build-apk (push) Has been cancelled

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:
2026-06-13 17:54:21 +02:00
parent 97996c9846
commit 9f42ee2460
72 changed files with 7293 additions and 421 deletions
+220
View File
@@ -0,0 +1,220 @@
# .gitea/workflows/ci.yml
# ---------------------------------------------------------------------------
# Wisp CI/CD for Gitea Actions (GitHub-Actions-compatible syntax).
#
# Triggers:
# - push to master -> test, then (on success) deploy-web + build-apk
# - pull_request -> test only (no deploys)
#
# Jobs:
# test Lint-gate: typecheck + vitest in the Bun container.
# deploy-web Build the nginx web image and ship it to briggen.dev WITHOUT a
# registry (docker save | gzip | scp | docker load | compose up).
# build-apk Build the Android toolchain image, prebuild + sign + assemble a
# release APK, scp it to the server as /srv/wisp/web/wisp.apk, and
# upload it as a CI artifact.
#
# Required repo secrets (Settings -> Actions -> Secrets):
# SSH_HOST, SSH_USER, SSH_KEY (deploy + apk upload)
# ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD,
# ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD (apk signing)
# See docs/DEPLOY.md and scripts/gen-keystore.sh.
#
# Runner assumptions: a Gitea Actions runner with Docker available on the host
# (the deploy/apk jobs run docker build/save/load and ssh/scp). The `test` job
# runs inside the oven/bun:1 container.
# ---------------------------------------------------------------------------
name: CI
on:
push:
branches: [master]
pull_request:
# Cancel superseded runs on the same ref to save runner time.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# =========================================================================
# test — typecheck + unit tests. Gates both deploy jobs.
# =========================================================================
test:
runs-on: ubuntu-latest
# Run the test job directly inside the Bun image so bun is preinstalled and
# the environment matches the web build's builder stage.
container:
image: oven/bun:1
steps:
# Check out the repository at the triggering commit.
- name: Checkout
uses: actions/checkout@v4
# Install dependencies exactly as locked (fails on lockfile drift).
- name: Install dependencies
run: bun install --frozen-lockfile
# TypeScript strict typecheck (tsc --noEmit).
- name: Typecheck
run: bun run typecheck
# Vitest unit/property tests (vitest run).
- name: Test
run: bun run test
# =========================================================================
# deploy-web — build image, transfer registry-free, compose up on server.
# Runs only on push to master, after `test` passes.
# =========================================================================
deploy-web:
needs: test
# Guard: only deploy for pushes to master (never on PRs or other branches).
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Build the production web image on the runner. Build context is the repo
# root so the Dockerfile can install deps and run the Expo export.
- name: Build web image
run: docker build -f docker/web.Dockerfile -t wisp-web:latest .
# Make the server's host key known so the subsequent ssh/scp don't fail on
# host-key verification and we are not vulnerable to a blind MITM. We key
# off SSH_HOST; this writes the server's public host keys into known_hosts.
- name: Add server to known_hosts
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keyscan -H "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
# Install the deploy SSH private key from the secret into the agent.
- name: Configure SSH key
run: |
install -m 600 /dev/null ~/.ssh/id_deploy
printf '%s\n' "${{ secrets.SSH_KEY }}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
# Registry-free image transfer: stream `docker save | gzip` straight over
# ssh to `gunzip | docker load` on the server. Avoids writing a big tarball
# to disk and avoids needing a Docker registry entirely.
- name: Ship image to server (save | gzip | ssh docker load)
run: |
docker save wisp-web:latest | gzip \
| ssh -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
'gunzip | docker load'
# Recreate the service from the freshly loaded image. The compose file must
# already exist at /srv/wisp/docker-compose.yml (one-time server setup).
# `pull_policy: never` in the compose file keeps it from trying a registry pull.
- name: Restart service via docker compose
run: |
ssh -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
'docker compose -f /srv/wisp/docker-compose.yml up -d --remove-orphans'
# Optional: prune dangling images left behind by repeated loads so the
# server disk doesn't fill up with old wisp-web layers.
- name: Prune dangling images on server
run: |
ssh -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
'docker image prune -f'
# =========================================================================
# build-apk — reproducible signed release APK, shipped to server + artifact.
# Runs only on push to master, after `test` passes.
# =========================================================================
build-apk:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Build the Android toolchain image (JDK 17 + Android SDK + Bun). This is
# the reproducible environment described in docker/android.Dockerfile.
- name: Build Android builder image
run: docker build -f docker/android.Dockerfile -t wisp-android:latest .
# Run the full Android build INSIDE the toolchain container with the repo
# bind-mounted at /workspace. All secrets are passed via -e so they live
# only in the container's environment (not on the command line / git).
#
# Steps inside the container:
# 1. bun install (frozen) — JS deps for prebuild + autolinking.
# 2. expo prebuild --platform android --no-install — generate android/.
# 3. scripts/ci-android-sign.sh — decode keystore + patch build.gradle.
# 4. ./gradlew assembleRelease — produce the signed release APK.
#
# The bind mount means the generated android/ + APK are visible on the
# runner afterwards (for scp + artifact upload below).
- name: Build signed release APK
run: |
docker run --rm \
-v "${{ github.workspace }}:/workspace" \
-w /workspace \
-e ANDROID_KEYSTORE_BASE64="${{ secrets.ANDROID_KEYSTORE_BASE64 }}" \
-e ANDROID_KEYSTORE_PASSWORD="${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \
-e ANDROID_KEY_ALIAS="${{ secrets.ANDROID_KEY_ALIAS }}" \
-e ANDROID_KEY_PASSWORD="${{ secrets.ANDROID_KEY_PASSWORD }}" \
wisp-android:latest \
bash -lc '
set -euo pipefail
bun install --frozen-lockfile
bunx expo prebuild --platform android --no-install
bash scripts/ci-android-sign.sh
cd android
./gradlew assembleRelease --no-daemon
'
# Locate the assembled APK. The default Gradle output for the release variant
# is android/app/build/outputs/apk/release/app-release.apk. Capture the path
# for the next steps and fail loudly if it's missing.
- name: Locate APK
id: apk
run: |
APK_PATH="android/app/build/outputs/apk/release/app-release.apk"
if [[ ! -f "$APK_PATH" ]]; then
echo "APK not found at $APK_PATH" >&2
find android/app/build/outputs -name '*.apk' -print >&2 || true
exit 1
fi
echo "path=$APK_PATH" >> "$GITHUB_OUTPUT"
# Known_hosts + SSH key for the scp upload (same pattern as deploy-web).
- name: Add server to known_hosts
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keyscan -H "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Configure SSH key
run: |
install -m 600 /dev/null ~/.ssh/id_deploy
printf '%s\n' "${{ secrets.SSH_KEY }}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
# Copy the signed APK to the server where nginx serves it at /wisp.apk.
# docker-compose mounts /srv/wisp/web/wisp.apk into the container's web root.
# Ensure the target dir exists, then scp the APK into place.
- name: Upload APK to server (/srv/wisp/web/wisp.apk)
run: |
ssh -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
'mkdir -p /srv/wisp/web'
scp -i ~/.ssh/id_deploy \
"${{ steps.apk.outputs.path }}" \
"${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/srv/wisp/web/wisp.apk"
# Also publish the APK as a CI artifact so it can be downloaded from the
# run's summary page even without server access.
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: wisp-release-apk
path: ${{ steps.apk.outputs.path }}
if-no-files-found: error