Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ prompt.txt
# Pre-built binary
transcriber

# Docs / CI not needed in build context
# Docs / CI / deploy config not needed in build context
DEPLOY.md
COMPATIBILITY.md
IMPROVEMENTS.md
.github
docker-compose*.yml
.env.example
46 changes: 46 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Copy to .env and edit. `docker compose` reads .env automatically.
# Every value here is optional — the defaults in docker-compose.yml match these.
#
# Requires an NVIDIA GPU + driver >= 560.28.03. There is no CPU-only mode for
# this image; see DEPLOY.md.

# ---- Image ----
# Pull a CI-built image instead of building on every host.
# Pin a tag (e.g. :sha-abc1234) in production so you can roll back.
IMAGE=ghcr.io/bcc-code/transcriber:latest

# Prebuilt whisper.cpp + CUDA base, used only by `docker compose build`. Bump to
# change the whisper.cpp or CUDA version; build it with
# .github/workflows/whisper-base.yml. The tag encodes both versions.
BASE_IMAGE=ghcr.io/bcc-code/whisper-cuda:v1.8.6-cuda12.6.3

# ---- Storage ----
# Must already exist on the host, and must be the same path the caller puts in
# the `path` / `output_path` fields — they are resolved inside the container.
STORAGE_PATH=/mnt/storage

# ---- Network ----
# The API is unauthenticated. Set this to an internal interface, or firewall
# the port, so only the workflow engine can reach it.
BIND_ADDR=0.0.0.0
PORT=8888

# ---- Runtime ----
# Concurrent transcription jobs. Each one runs its own whisper-cli process
# holding a ~3 GB FP16 large-v3 model on the GPU; 2 fits comfortably in the
# 3090's 24 GB, but raising this risks CUDA OOM (which surfaces as a failed job).
# Each job also uses scratch disk for chunk extraction (~115 MB per hour of
# audio), under the OS temp dir unless -scratch-dir is set.
WORKERS=2
CALLBACK_WORKERS=2
DEFAULT_MODEL=whisper-cpp-large-v3
# ISO 639-1 code to skip whisper's language auto-detection (faster and more
# reliable for a mono-lingual corpus). Empty = auto-detect. Requests can
# override per job.
DEFAULT_LANGUAGE=no
JOB_TIMEOUT=30m
# Finished jobs retained in memory. The store is in-memory only, so a completed
# job evicted before the caller polls it becomes a 404 — keep this comfortably
# above the largest burst the pipeline will submit.
MAX_TERMINAL_JOBS=200
LOG_FORMAT=json
116 changes: 116 additions & 0 deletions .github/workflows/image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
name: image

# Builds the app image (Go binary + embedded SPA) on top of the prebuilt
# whisper.cpp/CUDA base, and pushes it to GHCR so on-prem hosts `docker compose
# pull` instead of building. Nothing here compiles CUDA — that lives in
# whisper-base.yml — so this is a fast build.
#
# The base image must exist before this can run. On a fresh repo, dispatch the
# whisper-base workflow first.

on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
workflow_dispatch:

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# Keep in sync with docker-compose.yml / .env.example.
BASE_IMAGE: ghcr.io/${{ github.repository_owner }}/whisper-cuda:v1.8.6-cuda12.6.3

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true

# internal/web/dist is gitignored, so //go:embed has nothing to embed in
# a fresh checkout and the package fails to compile. The real SPA is
# built inside the image; a placeholder is enough for vet/test.
- name: Stage embed placeholder
run: |
mkdir -p internal/web/dist
echo '<!doctype html><title>placeholder</title>' > internal/web/dist/index.html

- run: go vet ./...
- run: go test -race ./...

image:
runs-on: ubuntu-latest
needs: test
env:
# Same-repo events (push, tag, branch PR) get a write-scoped
# GITHUB_TOKEN and can authenticate to GHCR. Fork PRs get a read-only
# token, so they build without touching the registry — but they also
# can't pull the private base image, so the build is skipped entirely.
CAN_PUSH: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v5

- uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
# Required even on PRs: the FROM in Dockerfile needs to pull the base
# image, and the PR build is the test run for the app image.
if: env.CAN_PUSH == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Verify the base image exists
# Without this, a missing base fails deep inside buildx as
# "failed to solve: ...: not found" pointing at a FROM line, which
# doesn't hint at the actual fix.
if: env.CAN_PUSH == 'true'
run: |
if docker buildx imagetools inspect "${BASE_IMAGE}" >/dev/null 2>&1; then
echo "base image ok: ${BASE_IMAGE}"
else
echo "::error::Base image ${BASE_IMAGE} not found. It must be built and pushed once before the app image can build. Push a change to Dockerfile.whisper, or run the whisper-base workflow (available in the Actions UI only once that workflow is on the default branch)."
exit 1
fi

- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# sha- tags are the ones to pin in .env for a rollback-able deploy.
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push
if: env.CAN_PUSH == 'true'
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE }}
# No build cache, deliberately. Measured on the first green run: the
# cacheable work is pnpm install (8.9s) + generate (8.2s) + go build
# (19.8s) = ~37s, while `cache-to: type=gha,mode=max` spent 271s
# exporting — 79% of a 342s build. mode=max exports *every* stage,
# which here includes the ~3 GB CUDA base image's layers, so it also
# blows GHA's 10 GB per-repo cache limit. Paying 271s to save 37s is
# a bad trade; the rest of the build is registry pull/push, which a
# build cache doesn't help with anyway.
136 changes: 136 additions & 0 deletions .github/workflows/whisper-base.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: whisper-base

# Builds the whisper.cpp + CUDA base image that the app image FROMs. This is the
# expensive build (15-30 min of nvcc), so it is heavily gated:
#
# - Triggers on `push` (any branch) only when Dockerfile.whisper or this file
# changes. `push` evaluates `paths` against that push's own diff, so an
# unrelated commit will not fire it.
# - Deliberately NOT on `pull_request`: for PR events `paths` is evaluated
# against the whole PR diff, so it re-fires on *every* push to a PR branch,
# and PR runs can't publish anyway — which would leave the app image build
# permanently unable to resolve its base.
# - Skips the build entirely when the target tag already exists, so an
# accidental re-trigger costs seconds instead of half an hour. Pass
# `force: true` (or bump a version) to republish.
#
# The tag encodes both versions, e.g. v1.8.6-cuda12.6.3, so bumping either one
# produces a new image; BASE_IMAGE in .env / docker-compose.yml / image.yml
# selects it. Pin the versioned tag — nothing should depend on `latest`.
#
# NOTE: because the tag is shared, changing *how* the base is built without
# changing a version will not republish unless you pass force: true. That is
# intentional — it stops an in-progress branch from silently replacing a base
# image the deployed app is running on.

on:
push:
paths:
- Dockerfile.whisper
- .github/workflows/whisper-base.yml
workflow_dispatch:
inputs:
whisper_ref:
description: whisper.cpp git ref (tag or 40-char SHA)
default: v1.8.6
cuda_version:
description: CUDA version (must match a nvidia/cuda image tag)
default: 12.6.3
cuda_archs:
description: "SM arch: 86=RTX 3090, 89=L4/RTX 40xx, 90=H100"
default: "86"
force:
description: Rebuild and republish even if the tag already exists
type: boolean
default: false

env:
REGISTRY: ghcr.io
IMAGE: ghcr.io/${{ github.repository_owner }}/whisper-cuda

jobs:
build:
runs-on: ubuntu-latest
env:
WHISPER_REF: ${{ inputs.whisper_ref || 'v1.8.6' }}
CUDA_VERSION: ${{ inputs.cuda_version || '12.6.3' }}
CUDA_ARCHS: ${{ inputs.cuda_archs || '86' }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v5

- uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Resolve tag and check whether it already exists
id: check
run: |
tag="${IMAGE}:${WHISPER_REF}-cuda${CUDA_VERSION}"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
if docker buildx imagetools inspect "${tag}" >/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "${tag} already exists" >> "$GITHUB_STEP_SUMMARY"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Skip notice
if: steps.check.outputs.exists == 'true' && !inputs.force
run: |
echo "Skipping the ~20 min CUDA build: ${{ steps.check.outputs.tag }} is already published." \
>> "$GITHUB_STEP_SUMMARY"
echo "Re-run this workflow with force: true to republish." >> "$GITHUB_STEP_SUMMARY"

- name: Free up disk space
if: steps.check.outputs.exists != 'true' || inputs.force
# ubuntu-latest ships ~14 GB free on /. The CUDA devel image alone is
# ~8 GB extracted, plus the whisper build tree and the runtime image —
# it does not fit. Drop preinstalled toolchains this build never uses.
run: |
df -h /
sudo rm -rf \
/usr/local/lib/android \
/usr/share/dotnet \
/opt/ghc \
/usr/local/share/boost \
/opt/hostedtoolcache/CodeQL
sudo docker image prune -af
df -h /

- name: Build and push
if: steps.check.outputs.exists != 'true' || inputs.force
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.whisper
platforms: linux/amd64
push: true
# `latest` only from the default branch: a feature branch may publish
# the versioned tag to bootstrap itself, but must not move `latest`.
tags: |
${{ steps.check.outputs.tag }}
${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && format('{0}:latest', env.IMAGE) || '' }}
build-args: |
WHISPER_CPP_REF=${{ env.WHISPER_REF }}
CUDA_VERSION=${{ env.CUDA_VERSION }}
CUDA_ARCHS=${{ env.CUDA_ARCHS }}

- name: Summary
if: steps.check.outputs.exists != 'true' || inputs.force
run: |
{
echo "Published \`${{ steps.check.outputs.tag }}\`"
echo ""
echo "Set this as BASE_IMAGE in \`.env\`, \`docker-compose.yml\`, and \`.github/workflows/image.yml\`:"
echo '```'
echo "BASE_IMAGE=${{ steps.check.outputs.tag }}"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ docker-compose.override.yml

# Default prompt file (deployment-specific vocabulary)
prompt.txt

# Compiled binary from `make build`
/transcriber
Loading
Loading