11name : Container
22
33# Building the image compiles whisper.cpp from source, which is by far the most
4- # expensive thing in CI. Nothing under app/ can break that build — the runtime
5- # image only copies the package, it never imports it — so this runs on the files
6- # that actually decide whether the image assembles: the Dockerfiles and the
7- # dependency set they install.
4+ # expensive thing in CI. Application behavior is covered by the Quality
5+ # workflow; this one runs on the container definition and dependency inputs
6+ # that decide whether the image itself assembles. Its CPU smoke test then proves
7+ # the installed package starts and serves from that image.
8+ #
9+ # One Dockerfile now builds all three accelerator images, so a change to it can
10+ # break cuda or vulkan while cpu still succeeds. The matrix below builds each
11+ # one. They are the reason this belongs in CI rather than on a laptop. A
12+ # portable cuda image compiles kernels for a broad GPU architecture spread; the
13+ # matrix narrows its compile-only validation to one architecture because no GPU
14+ # executes the cache-only result. Release/operator builds keep the portable
15+ # Dockerfile default.
816on :
917 push :
1018 branches : [main]
11- paths :
12- - ' Dockerfile* '
19+ paths : &container-paths
20+ - ' Dockerfile'
1321 - ' .dockerignore'
22+ - ' compose.yaml'
1423 - ' pyproject.toml'
1524 - ' uv.lock'
1625 - ' .github/workflows/container.yml'
1726 pull_request :
1827 branches : [main]
1928 types : [opened, synchronize, reopened, ready_for_review]
20- paths :
21- - ' Dockerfile*'
22- - ' .dockerignore'
23- - ' pyproject.toml'
24- - ' uv.lock'
25- - ' .github/workflows/container.yml'
29+ paths : *container-paths
2630 workflow_dispatch :
2731
2832permissions :
@@ -33,28 +37,171 @@ concurrency:
3337 cancel-in-progress : ${{ github.event_name == 'pull_request' }}
3438
3539jobs :
36- container :
40+ build :
3741 if : github.event_name != 'pull_request' || !github.event.pull_request.draft
3842 runs-on : ubuntu-24.04
39- timeout-minutes : 30
43+ timeout-minutes : ${{ matrix.timeout }}
44+ strategy :
45+ # One accelerator failing says nothing about the others, and the cuda job
46+ # is long enough that cancelling it on a fast vulkan failure wastes the
47+ # whole run.
48+ fail-fast : false
49+ matrix :
50+ include :
51+ # The cpu image is the one every deployment gets by default, so it is
52+ # also the one that is loaded and actually run below.
53+ - accel : cpu
54+ timeout : 40
55+ smoke : true
56+ reclaim-disk : false
57+ build_jobs : 4
58+ cmake_extra : ' '
59+ - accel : vulkan
60+ timeout : 40
61+ smoke : false
62+ reclaim-disk : false
63+ build_jobs : 4
64+ cmake_extra : ' '
65+ # The CUDA devel/runtime bases and Python engine layers are still much
66+ # larger than the other variants, so reclaim the runner's unused SDKs.
67+ - accel : cuda
68+ timeout : 40
69+ smoke : false
70+ reclaim-disk : true
71+ build_jobs : 3
72+ # Compiling every CUDA architecture dominates the job despite the
73+ # image never running on a GPU. Ada is a representative real-code
74+ # compile; CPU dispatch is already built and exercised above.
75+ cmake_extra : >-
76+ -DCMAKE_CUDA_ARCHITECTURES=89-real
77+ -DGGML_CPU_ALL_VARIANTS=OFF
78+ name : build (${{ matrix.accel }})
4079 steps :
4180 - uses : actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
4281 with :
4382 persist-credentials : false
83+
84+ - name : Reclaim runner disk space
85+ if : matrix.reclaim-disk
86+ run : |
87+ # Preinstalled SDKs this build never touches. Worth ~25 GB, which is
88+ # the difference between the cuda image building and the runner
89+ # filling up partway through.
90+ sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
91+ sudo rm -rf /usr/local/share/boost "${AGENT_TOOLSDIRECTORY}"
92+ df -h /
93+
4494 - uses : docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
45- - name : Build gateway image
95+
96+ - name : Build ${{ matrix.accel }} image
4697 run : |
47- # cacheonly skips exporting the assembled image to the local daemon.
48- # The question this job answers is whether the build succeeds, and
49- # nothing downstream runs the image.
98+ # A smoke-tested image has to reach the local daemon; the others only
99+ # need to prove they assemble, so they stop at the cache.
100+ if [ "${SMOKE}" = "true" ]; then
101+ output="type=docker"
102+ else
103+ output="type=cacheonly"
104+ fi
50105 # Only the sha is stamped here: a commit subject is attacker-supplied
51106 # text on a pull request, and expanding it into this shell would be an
52107 # injection. Release builds that publish an image add the subject from
53108 # a checkout instead.
54109 docker buildx build \
55- --tag vocagateway:test \
110+ --tag "vocagateway:test-${ACCEL}" \
111+ --build-arg "ACCEL=${ACCEL}" \
112+ --build-arg "BUILD_JOBS=${BUILD_JOBS}" \
113+ --build-arg "WHISPER_CMAKE_EXTRA=${CMAKE_EXTRA}" \
56114 --build-arg VOCAGATEWAY_GIT_COMMIT="${GITHUB_SHA}" \
57- --cache-from type=gha,scope=gateway \
58- --cache-to type=gha,mode=max,scope=gateway \
59- --output type=cacheonly \
115+ --cache-from " type=gha,scope=gateway-${ACCEL}" \
116+ --cache-to " type=gha,mode=max,scope=gateway-${ACCEL}" \
117+ --output "${output}" \
60118 .
119+ env :
120+ ACCEL : ${{ matrix.accel }}
121+ BUILD_JOBS : ${{ matrix.build_jobs }}
122+ CMAKE_EXTRA : ${{ matrix.cmake_extra }}
123+ SMOKE : ${{ matrix.smoke }}
124+
125+ # Everything below is the cpu image only. It is what a laptop build was
126+ # being used to check by hand, and it catches the failures a successful
127+ # `docker build` does not: a backend that is present but never loaded, a
128+ # wheel that is missing its templates, a container that cannot start under
129+ # the hardening compose applies.
130+ - name : Check ggml loads a CPU backend variant
131+ if : matrix.smoke
132+ run : |
133+ set -euo pipefail
134+ curl --fail --location --silent --show-error \
135+ --output jfk.wav \
136+ https://raw.githubusercontent.com/ggml-org/whisper.cpp/v1.9.1/samples/jfk.wav
137+ curl --fail --location --silent --show-error \
138+ --output ggml-tiny.en.bin \
139+ https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin
140+
141+ docker run --rm --volume "${PWD}:/bench:ro" \
142+ --entrypoint whisper-cli vocagateway:test-cpu \
143+ -m /bench/ggml-tiny.en.bin -f /bench/jfk.wav -nt -t 2 -bs 2 -bo 2 \
144+ > transcript.txt 2> backend.log || { cat backend.log; exit 1; }
145+
146+ grep 'load_backend: loaded CPU backend' backend.log
147+
148+ # GGML_CPU_ALL_VARIANTS exists so a portable image still runs the
149+ # host's instruction set. A GitHub runner is well past Haswell, so
150+ # loading the x64 baseline means dispatch silently regressed and every
151+ # deployment is running scalar kernels.
152+ if grep -q 'libggml-cpu-x64\.so' backend.log; then
153+ echo "::error::ggml fell back to the baseline x64 CPU backend" >&2
154+ cat backend.log >&2
155+ exit 1
156+ fi
157+
158+ grep -qi 'ask not what your country can do for you' transcript.txt
159+
160+ - name : Check the container serves under compose's hardening
161+ if : matrix.smoke
162+ run : |
163+ set -euo pipefail
164+ # The same restrictions compose.yaml applies, so a capability the
165+ # gateway turns out to need fails here rather than on a deployment.
166+ docker run --detach --name vg-smoke --publish 18765:8765 \
167+ --cap-drop ALL \
168+ --security-opt no-new-privileges:true \
169+ --tmpfs /tmp:size=64m,mode=1777 \
170+ vocagateway:test-cpu
171+
172+ for _ in $(seq 1 30); do
173+ status="$(docker inspect --format '{{.State.Health.Status}}' vg-smoke)"
174+ [ "${status}" = "healthy" ] && break
175+ [ "${status}" = "unhealthy" ] && break
176+ sleep 2
177+ done
178+
179+ if [ "${status}" != "healthy" ]; then
180+ echo "::error::container never became healthy (${status})" >&2
181+ docker logs vg-smoke >&2
182+ exit 1
183+ fi
184+
185+ curl --fail --silent --show-error --output /dev/null http://127.0.0.1:18765/health/live
186+ # The WebUI renders Jinja templates from the installed wheel, so a 200
187+ # here is what proves the non-editable install carried app/templates.
188+ curl --fail --silent --show-error --output /dev/null http://127.0.0.1:18765/
189+
190+ - name : Container logs
191+ if : failure() && matrix.smoke
192+ run : docker logs vg-smoke || true
193+
194+ compose :
195+ if : github.event_name != 'pull_request' || !github.event.pull_request.draft
196+ runs-on : ubuntu-24.04
197+ timeout-minutes : 5
198+ steps :
199+ - uses : actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
200+ with :
201+ persist-credentials : false
202+ - name : Validate every profile interpolates
203+ run : |
204+ # `just compose` checks the default service. The profile services carry
205+ # the device wiring, so they need naming explicitly to be parsed.
206+ VOCAGATEWAY_TOKEN=test-token-with-at-least-thirty-two-characters \
207+ docker compose --profile cuda --profile vulkan config --quiet
0 commit comments