Skip to content

flake: add packages.outloud-cuda for whisper.cpp CUDA on x86_64-linux #172

flake: add packages.outloud-cuda for whisper.cpp CUDA on x86_64-linux

flake: add packages.outloud-cuda for whisper.cpp CUDA on x86_64-linux #172

Workflow file for this run

# PR / push CI: correctness, compliance, MSRV, headless, and a cheap build of
# every shipped target so cross-platform breakage is caught per-PR instead of
# at release time.
#
# Design decisions (full rationale in docs/build-and-release.md#ci):
# - Scripts, not inline run-blocks, for anything longer than two lines:
# contributors run scripts/ci-*.sh locally and get the identical result.
# - Swatinem/rust-cache over manual actions/cache: it keys on the lockfile
# AND toolchain AND target list, and prunes stale artifacts, which manual
# cache keys always eventually get wrong.
# - Every cargo invocation is --locked: CI must build what the lockfile
# says, or the lockfile is fiction.
name: ci
on:
push:
branches: [main]
pull_request:
# A superseded push to the same PR cancels the older run; saves the finite
# macOS/Windows runner minutes for the commit that matters.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
# Incremental compilation is a lose on CI: the cache restore already covers
# warm starts, and incremental artifacts bloat the cache by gigabytes.
CARGO_INCREMENTAL: "0"
jobs:
# Lint + test on the two OSes with OS-specific code paths. Linux covers the
# pure crates; macOS covers ax-edit's real FFI. Windows joins this list the
# day a Windows backend exists.
check:
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, macos-15]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
# rustup reads rust-toolchain.toml automatically; no toolchain action
# needed for the default case, which is one less pinned dependency.
# The app icon, on macOS only (qlmanage/sips/iconutil are macOS tools).
#
# Nothing else in normal CI looks at it, so a broken logo.svg would
# have surfaced at RELEASE time, which is the worst moment to learn
# your app icon is blank. That is not hypothetical: a double hyphen in
# the SVG's leading comment is illegal XML, and Quick Look answers by
# rendering nothing, writing a PNG anyway, and exiting 0. The bundle
# shipped an empty icon and every check downstream agreed with it.
#
# Renders to a temp path and asserts the raster is not blank; it does
# not touch the repo.
- name: App icon renders (not blank)
if: runner.os == 'macOS'
run: scripts/make-icon.sh "$RUNNER_TEMP/ci-icon.icns"
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: scripts/ci-install-linux-deps.sh
- uses: Swatinem/rust-cache@v2
with:
# One shared cache per OS for check+test; the build matrix below
# keys separately per target.
shared-key: check
- run: scripts/ci-check.sh
shell: bash
- name: Attribute failure
if: failure()
run: >-
echo "::error title=check failed on ${{ matrix.os }}::scripts/ci-check.sh (fmt/clippy/test) failed on ${{ matrix.os }}. Search the step log above for the first 'error' line."
# MSRV is validated with its own toolchain and WITHOUT --locked semantics
# being violated: same lockfile, older compiler. If this fails, either fix
# the code or bump the documented MSRV in a deliberate PR.
#
# MSRV is 1.86: the icu_* crates (transitive via idna/url) declare
# rust-version 1.86 and refuse to build on 1.85. Full rationale in
# docs/build-and-release.md#msrv-policy. Keep this version in sync with
# that doc and the rust-toolchain.toml comment.
msrv:
runs-on: ubuntu-24.04
env:
MSRV: "1.86.0"
steps:
- uses: actions/checkout@v4
- name: Install Linux system dependencies
run: scripts/ci-install-linux-deps.sh
- name: Install MSRV toolchain
run: rustup toolchain install "$MSRV" --profile minimal
- uses: Swatinem/rust-cache@v2
with:
shared-key: msrv
- name: Build with MSRV
run: cargo +"$MSRV" build --workspace --locked
- name: Attribute failure
if: failure()
run: >-
echo "::error title=MSRV build failed::Workspace does not build with Rust $MSRV. Either fix the code or bump the documented MSRV (docs/build-and-release.md#msrv-policy) in a deliberate PR."
# Licence + CVE gate. Runs on PRs (catches a bad dep at review time) and
# is additionally scheduled nightly by audit-nightly below, because new
# CVEs arrive without any commit happening.
compliance:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install Linux system dependencies
run: scripts/ci-install-linux-deps.sh
- uses: Swatinem/rust-cache@v2
with:
shared-key: compliance
# cargo-install'd tools live in ~/.cargo/bin and are cached too.
cache-all-crates: "true"
- run: scripts/ci-compliance.sh
shell: bash
- name: Upload SBOM
uses: actions/upload-artifact@v4
with:
name: sbom
path: dist/sbom/
# Cross-target build check: every triple we ship must at least COMPILE on
# every PR. Cheap (check-only where possible, no packaging) and catches
# cfg() breakage the day it is written, not at release tag time.
build-matrix:
strategy:
fail-fast: false
matrix:
include:
- os: macos-15
target: aarch64-apple-darwin
- os: macos-15
target: x86_64-apple-darwin
- os: windows-2025
target: x86_64-pc-windows-msvc
- os: windows-2025
# aarch64 Windows cross-compiles from the x86_64 runner; MSVC
# ships the cross linker so no extra toolchain is required.
target: aarch64-pc-windows-msvc
- os: ubuntu-24.04
target: x86_64-unknown-linux-gnu
- os: ubuntu-24.04
target: x86_64-unknown-linux-musl
- os: ubuntu-24.04
target: aarch64-unknown-linux-gnu
use-cross: true
- os: ubuntu-24.04
target: aarch64-unknown-linux-musl
use-cross: true
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
with:
# Per-target cache key: artifacts for different triples never
# share object files, so sharing a cache only causes eviction.
key: ${{ matrix.target }}
- name: Add target
if: ${{ !matrix.use-cross }}
run: rustup target add ${{ matrix.target }}
- name: Install Linux system dependencies
if: runner.os == 'Linux' && !matrix.use-cross
run: scripts/ci-install-linux-deps.sh
- name: Install musl tools
if: contains(matrix.target, 'musl') && !matrix.use-cross
run: sudo apt-get update && sudo apt-get install -y musl-tools
# Prebuilt binary instead of `cargo install cross`: same pinned version,
# seconds instead of ~2 minutes of compiling cross from source per run.
- name: Install cross
if: matrix.use-cross
uses: taiki-e/install-action@v2
with:
tool: cross@0.2.5
- name: Build (${{ matrix.target }})
shell: bash
run: |
FLAGS=(--workspace --locked --target ${{ matrix.target }})
# musl targets build the shape we actually ship for them: the
# static headless daemon (docs/build-and-release.md#matrix). The
# default feature set pulls cpal -> alsa-sys, and there is no musl
# ALSA on the runner or in the cross image, so a default-features
# musl build can only ever fail in alsa-sys's pkg-config probe.
# This mirrors scripts/build-headless.sh, not a weakening: every
# crate still compiles for the triple, minus the audio backend
# that a static server binary must not link anyway.
case "${{ matrix.target }}" in
*musl*) FLAGS+=(--no-default-features) ;;
esac
if [ "${{ matrix.use-cross }}" = "true" ]; then
# alsa-sys uses the pkg-config crate, which refuses to run when
# host != target unless explicitly allowed. The arm64 .pc files
# are installed by the Cross.toml pre-build hook.
export PKG_CONFIG_ALLOW_CROSS=1
export PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig
cross build "${FLAGS[@]}"
else
cargo build "${FLAGS[@]}"
fi
- name: Attribute failure
if: failure()
shell: bash
run: >-
echo "::error title=build failed for ${{ matrix.target }}::Workspace does not compile for ${{ matrix.target }} on ${{ matrix.os }} (cross: ${{ matrix.use-cross || 'false' }}). The first 'error' line in the Build step above names the crate at fault."
# Windows runtime smoke test: the build-matrix job above proves the
# Direct2D overlay renderer COMPILES AND LINKS on Windows, but link
# success says nothing about whether D2D1CreateFactory, BindDC, or
# UpdateLayeredWindow actually succeed at runtime — that is exactly the
# class of bug this project has hit before (see docs/plans/
# windows-overlay.md's own "cannot verify without hardware" caveats).
# This runs the real overlay-demo binary's Windows path
# (crates/overlay/src/bin/overlay-demo.rs's `windemo::run()`) on a real
# windows-2025 GitHub-hosted runner, which is CI infrastructure, not the
# user's machine, so it does not touch anything this task was told to
# stay away from. It cycles every OverlayState (including the Direct2D
# skull paint path added by this change) for a few seconds and asserts a
# clean exit: any HRESULT failure inside WinOverlay::new()/render()
# (factory creation, DC render target creation, BindDC, geometry/brush
# creation, UpdateLayeredWindow) surfaces as `anyhow::Result` propagating
# up through `?` and returning a non-zero exit code, which this step
# catches. What this still cannot prove: whether the pixels it pushed to
# the layered window are visually correct (right colors, right alpha,
# right position) — that needs a screenshot and a human, which is the
# one gap this job does not close.
windows-overlay-runtime-smoke:
runs-on: windows-2025
# GHA-native timeout instead of the `timeout` coreutil: git-bash on the
# Windows runner is not guaranteed to ship it, and a job-level timeout
# is the same backstop without depending on one.
timeout-minutes: 3
steps:
- uses: actions/checkout@v4
# Parse every PowerShell script. Nothing else in CI reads them, and
# macOS has no pwsh, so a syntax error in a .ps1 was previously found
# by a human running it on the Windows box: the slowest possible
# feedback, on the machine least convenient to debug on.
#
# First step in the job, and before the toolchain cache, because it
# needs neither and takes about a second.
#
# Parsing ONLY. These scripts press keys and type into windows, so a
# CI runner must never execute them. `Parser::ParseFile` gives the
# syntax check without running a single statement.
- name: Parse the PowerShell verification scripts
shell: pwsh
run: |
$bad = 0
Get-ChildItem -Path scripts -Filter *.ps1 -Recurse | ForEach-Object {
$errs = $null
[void][System.Management.Automation.Language.Parser]::ParseFile(
$_.FullName, [ref]$null, [ref]$errs)
if ($errs -and $errs.Count -gt 0) {
$bad = 1
foreach ($e in $errs) {
$line = $e.Extent.StartLineNumber
Write-Output "::error file=$($_.FullName),line=$line::$($e.Message)"
}
} else {
Write-Output " ok $($_.Name)"
}
}
exit $bad
- uses: Swatinem/rust-cache@v2
with:
shared-key: windows-overlay-smoke
- name: Run overlay-demo (exercises WinOverlay::new/render for real)
id: overlay_smoke
shell: bash
run: |
# Exits 0 on its own after cycling 8 states x2 at 2.5s each
# (~40s); the job-level timeout-minutes above is the backstop
# against a real hang (e.g. a message-pump deadlock), not the
# expected path.
cargo run -p overlay --bin overlay-demo --features display
# Only attribute a failure to the overlay if the overlay step is what
# failed. This job now has two independent steps, and a blanket
# `if: failure()` blamed the Direct2D renderer for a PowerShell syntax
# error -- a confident, specific, wrong diagnosis, which is worse than
# none because it sends the reader to the wrong file.
- name: Attribute failure
if: failure() && steps.overlay_smoke.outcome == 'failure'
shell: bash
run: >-
echo "::error title=windows overlay runtime smoke failed::overlay-demo's Windows path panicked, returned an error, or hung on a real windows-2025 runner. This means the Direct2D renderer built cleanly but failed at runtime (factory/render-target/BindDC/UpdateLayeredWindow) -- check the step output above for the HRESULT or panic message."
# Headless build: proves the daemon path stays display-free. Runs on a
# plain ubuntu container with no X/Wayland present, which IS the deployment
# environment being claimed.
headless:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
with:
shared-key: headless
- name: Install musl tools
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build static headless daemon
run: |
rustup target add x86_64-unknown-linux-musl
scripts/build-headless.sh x86_64-unknown-linux-musl
- name: Smoke test with no display server
# Explicitly unset every display variable; if the binary needed a
# display to even start, this is where it dies.
run: |
env -u DISPLAY -u WAYLAND_DISPLAY dist/headless/outloud-spiked-x86_64-unknown-linux-musl dry-run "change hello to goodbye"
# The whisper backend, against a real model. Every other job builds the
# workspace with default features, where whisper is compiled out entirely,
# and the transcription test skips when no weights are present. So without
# this job a broken whisper backend passes CI in two different ways at
# once: not compiled, and not asserted.
#
# macOS because whisper-rs enables Metal there automatically; a Linux
# runner would run the same test 20-30x slower on CPU for no extra signal.
# Kept out of ci-check.sh because it is the expensive job: whisper.cpp is
# compiled from source and the model is a 142MiB download, cached here
# between runs and verified against the registry pin every time.
whisper:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
with:
shared-key: whisper
# Keyed by the pinned hash, not by a date: a re-pin must miss the cache
# and fetch the new file, and an unchanged pin must never re-download.
- name: Cache the ggml model
uses: actions/cache@v4
with:
path: ~/.outloud/models/whisper-base.en
key: whisper-base-en-a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002
- run: scripts/ci-whisper.sh
shell: bash
- name: Attribute failure
if: failure()
run: >-
echo "::error title=whisper backend::scripts/ci-whisper.sh failed. Either the backend broke, or the pinned model hash no longer matches what the upstream URL serves (which is the check working, not a flake)."
# Performance tripwire. Every other job answers "is it correct"; this one
# answers "is it still fast", which no correctness test can see: a change
# that makes the overlay ten times slower produces exactly the same text
# and passes every assertion in the workspace.
#
# It measures only PURE paths (the rolling-window animation, hypothesis
# ingest, command parsing) precisely so it can run here. The richer gate in
# crates/ax-edit/benches/gate.rs measures the real accessibility path and
# correctly skips without a focused text field, which means on a CI runner
# it protects nothing. This one cannot skip.
#
# Budgets carry ~10x headroom over measured cost, so a shared runner being
# slow does not fail the build. See the bench's own comment for numbers.
perf:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
with:
shared-key: perf
- name: Perf gate
run: cargo bench -p overlay --bench perf_gate
- name: Attribute failure
if: failure()
run: |
echo "::error title=perf gate::A hot path got materially slower. This is not a correctness failure: the app still produces the right text, only later. See crates/overlay/benches/perf_gate.rs for the budgets and the reasoning."
# Reproducibility tripwire: double-build on Linux and require identical
# hashes. Only x86_64-linux (the cheapest runner) because determinism bugs
# are almost never target-specific; the flags are shared by all targets.
repro:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
# build-repro.sh derives SOURCE_DATE_EPOCH from git history.
fetch-depth: 1
- name: Install Linux system dependencies
run: scripts/ci-install-linux-deps.sh
# NOTE: no rust-cache here on purpose. A cache-restored target dir
# defeats the point of proving a clean double-build is deterministic.
- name: Double build and compare hashes
run: REPRO_VERIFY=1 scripts/build-repro.sh
- name: Attribute failure
if: failure()
run: >-
echo "::error title=repro job failed::Either the workspace failed to compile on x86_64 Linux (see the first 'error' line above) or the double build produced different hashes; the script prints both hashes and suggests diffoscope."
# The installer is the first thing a stranger runs and, until now, the only
# public interface with no check at all. The previous one lived solely on a
# feature branch, so the published one-liner would 404 the moment that
# branch was deleted, and nothing in CI could have noticed.
install:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Install from the live release into a temp directory
run: scripts/test-install.sh
- name: Attribute failure
if: failure()
run: >-
echo "::error title=install job failed::scripts/install.sh could not install the published release, or the app it installed was not the outloud daemon with its speech helper. Run scripts/test-install.sh locally; it installs into a temp dir and never touches /Applications."