Skip to content

Live agent MCP tools + Firecrawl search (with Windows test fix) — sup… #931

Live agent MCP tools + Firecrawl search (with Windows test fix) — sup…

Live agent MCP tools + Firecrawl search (with Windows test fix) — sup… #931

Workflow file for this run

name: CI
on:
pull_request:
branches: [main]
types: [opened, reopened, ready_for_review, synchronize]
merge_group: # Merge-queue runs: validate the queued merge result so two
# PRs that are each green against an older main can't land a
# semantic conflict together.
push:
branches: [main] # PRs are covered by pull_request (incl. synchronize);
# scoping push to main avoids double-running every PR commit.
# Least privilege: CI only needs to read the repo. Actions are pinned to commit
# SHAs (a moved tag can't silently change what runs); Dependabot keeps them current.
permissions:
contents: read
# Cancel superseded runs when new commits land on a PR/branch, but never cancel a
# main run (don't kill an in-flight merge build).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
check:
name: lint + typecheck + tests (py${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 20
# Test both ends of the supported range: 3.12 is the floor (requires-python),
# 3.13 is what the Homebrew formula ships. fail-fast off so one version's
# failure doesn't mask the other's.
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13"]
# Pin the interpreter every `uv run`/`uv build` in check.sh resolves to, so the
# matrix actually exercises each version rather than whatever uv would pick.
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false # no job pushes; don't leave the token in .git/config
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
cache: pip
# PortAudio backs sounddevice; ffmpeg decodes non-WAV/URL audio (the `--sample`
# stream tests build a FileSource for the hosted sample, which needs ffmpeg).
# Slow-mirror resilience (bounded retry + trimmed payload) lives in the script.
- name: System deps (PortAudio + ffmpeg)
run: ./scripts/ci_install_audio_deps.sh
# check.sh lints Markdown and template JS/CSS via Node CLIs; versions are
# pinned in scripts/gate_tool_pins.sh (shared with the web session-start
# hook). The runner ships Node, so a global npm install suffices.
- name: Node lint CLIs
run: |
source scripts/gate_tool_pins.sh
npm install -g "markdownlint-cli@${MARKDOWNLINT_VERSION}" "prettier@${PRETTIER_VERSION}"
# check.sh runs every tool through `uv run` / `uv build` for a locked,
# reproducible env, so only uv must be on PATH. setup-uv caches the uv
# download cache (~/.cache/uv) keyed on uv.lock, so the locked env — incl.
# the Rust-backed sdists (pydantic-core/jiter/cryptography) — isn't
# re-downloaded/rebuilt every run. `uv run` itself syncs the project + dev
# group into .venv, so no `pip install -e .` is needed here.
- name: Install uv (cached)
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
# actionlint and gitleaks are Go binaries (no PyPI wheel), so check.sh self-skips
# them locally like shellcheck. Build them here with the runner's preinstalled Go,
# pinned via scripts/gate_tool_pins.sh (shared with the web session-start hook),
# and put GOPATH/bin on PATH so check.sh enforces them.
# (gitleaks v8's Go module path is still github.com/zricethezav/gitleaks/v8.)
# Cache the built binaries keyed on the pin file so a cache hit skips the
# from-source `go install` compile entirely.
- name: Cache Go gate binaries (actionlint, gitleaks)
id: cache-go-bin
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/go/bin
key: go-gate-bins-${{ runner.os }}-${{ hashFiles('scripts/gate_tool_pins.sh') }}
- name: Workflow + secret scanners (actionlint, gitleaks)
env:
# Map the cache-hit output to an env var rather than expanding the
# `${{ }}` directly into the script (zizmor template-injection rule).
CACHE_HIT: ${{ steps.cache-go-bin.outputs.cache-hit }}
run: |
source scripts/gate_tool_pins.sh
if [ "$CACHE_HIT" != "true" ]; then
go install "$ACTIONLINT_MODULE"
go install "$GITLEAKS_MODULE"
fi
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Lint, typecheck, test
run: ./scripts/check.sh
# Branch protection requires a check literally named "lint + typecheck + tests",
# but `check` is a matrix, so its contexts are suffixed "(py3.12)" / "(py3.13)".
# Re-publish the un-suffixed name here, green only when every matrix cell passed
# (if: always() + an explicit result check, so a failed/skipped/cancelled matrix
# can't satisfy the required check). Point branch protection at this one stable
# name and matrix changes never break the required check again.
check-result:
name: lint + typecheck + tests
needs: [check]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require every py-version matrix cell to have passed
run: |
if [ "${{ needs.check.result }}" != "success" ]; then
echo "check matrix result: ${{ needs.check.result }}"
exit 1
fi
echo "all py-version matrix cells passed"
windows:
name: tests (windows, py${{ matrix.python-version }})
runs-on: windows-latest
timeout-minutes: 20
# Windows can't run scripts/check.sh (it's bash plus Go/Homebrew/shell tooling), so
# this job runs only the pytest suite — enough to catch Windows-specific regressions
# (path handling, subprocess/encoding, POSIX-only assumptions). The lint/type/security
# gates stay on the Linux `check` job. Same Python ends as that matrix: 3.12 floor,
# 3.13 shipped; fail-fast off so one version's failure doesn't mask the other's.
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13"]
# Pin the interpreter every `uv run` resolves to, so the matrix exercises each version.
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false # no job pushes; don't leave the token in .git/config
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
cache: pip
# ffmpeg must be on PATH: the `stream --sample`/`clip`/`caption` paths probe for it
# (require_ffmpeg) before doing their work, so without it those tests fail at the
# probe rather than exercising the mocked run. PortAudio needs no install — the
# sounddevice wheel bundles it on Windows. choco ships on the runner but its download
# from community.chocolatey.org doesn't just flake — it sometimes *hangs* for the whole
# job timeout, and a plain retry loop never gets to retry because the stuck attempt
# never returns. So bound each attempt with a hard timeout (Start-Job + Wait-Job): a
# hung download is killed and the next attempt retries, instead of wedging the cell
# until it's cancelled. The shim lands in choco's bin dir (machine-wide, already on the
# runner PATH), so the parent shell and later steps pick it up.
#
# During a sustained community.chocolatey.org outage the feed returns 503s *quickly*,
# so every bounded attempt fails fast and the retry loop exhausts with no ffmpeg. Fall
# back to a static build off GitHub's release CDN (a different, far more reliable origin)
# and prepend its dir to GITHUB_PATH so later steps see it.
- name: System deps (ffmpeg)
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$env:PATH = "C:\ProgramData\chocolatey\bin;$env:PATH"
for ($i = 1; $i -le 3; $i++) {
$job = Start-Job { choco install ffmpeg --no-progress -y }
if (Wait-Job $job -Timeout 240) { Receive-Job $job } else {
Stop-Job $job
Write-Host "choco install ffmpeg hung (attempt $i); killing and retrying…"
}
Remove-Job $job -Force
if (Get-Command ffmpeg -ErrorAction SilentlyContinue) { break }
Start-Sleep -Seconds 5
}
if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
Write-Host "choco couldn't provide ffmpeg; downloading a static build from GitHub…"
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
$zip = Join-Path $env:RUNNER_TEMP "ffmpeg.zip"
$dest = Join-Path $env:RUNNER_TEMP "ffmpeg"
Invoke-WebRequest -Uri $url -OutFile $zip
Expand-Archive -Path $zip -DestinationPath $dest -Force
$bin = (Get-ChildItem -Path $dest -Recurse -Filter ffmpeg.exe | Select-Object -First 1).DirectoryName
$env:PATH = "$bin;$env:PATH"
Add-Content -Path $env:GITHUB_PATH -Value $bin
}
ffmpeg -version
- name: Install uv (cached)
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
# `uv run` syncs the locked project + dev group into .venv, then runs the default
# suite (e2e/install excluded via addopts).
- name: Run test suite
run: uv run pytest -q
# Stable, un-suffixed name for branch protection, mirroring `check-result`: green only
# when every Windows matrix cell passed (a failed/skipped/cancelled matrix can't satisfy
# it). Point branch protection at this one name and matrix changes won't break it.
windows-result:
name: tests (windows)
needs: [windows]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require every Windows matrix cell to have passed
run: |
if [ "${{ needs.windows.result }}" != "success" ]; then
echo "windows matrix result: ${{ needs.windows.result }}"
exit 1
fi
echo "all windows matrix cells passed"
lint-formula:
name: brew style (Homebrew formula)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false # no job pushes; don't leave the token in .git/config
# Homebrew's formula linters live inside `brew`, so set it up on the runner.
# Homebrew/actions is a monorepo (setup-homebrew is a subpath); pin it to a
# commit SHA like every other action here — Dependabot keeps it current.
- uses: Homebrew/actions/setup-homebrew@2ebcf16054461267868620b1414507f3ccc765c1
# `brew style` is the RuboCop-based formula linter and runs fully offline, so
# it lints idioms (resource/depends_on ordering, on_linux scoping) on every PR.
# A real `brew install --build-bottle` of the formula runs in release.yml when
# a tag is cut; doing it per-PR was dropped — the from-source macOS build
# (rust + cryptography) took too long for PR feedback. The stricter
# `brew audit --strict --online` still belongs in a release job.
- name: Lint the formula
run: brew style ./Formula/assembly.rb
pre-commit:
name: pre-commit
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false # no job pushes; don't leave the token in .git/config
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: pip
# PortAudio backs sounddevice; ffmpeg decodes the `--sample` stream source.
- name: System deps (PortAudio + ffmpeg)
run: ./scripts/ci_install_audio_deps.sh
# The local pytest hook runs `uv run --frozen python -m pytest`, so the tests
# resolve the LOCKED dependency versions (uv.lock) rather than the newest
# release `pip install` would pull — which is what keeps the byte-exact
# `--help` snapshots stable. Install uv and materialize the frozen env here.
- name: Install uv (cached)
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
- name: Sync frozen env
run: uv sync --frozen
- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
build:
name: build + twine check
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false # no job pushes; don't leave the token in .git/config
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: pip
- name: Build wheel + sdist
run: |
python -m pip install build twine
python -m build
- name: Validate metadata
run: twine check dist/*
audit:
name: pip-audit (dependency CVEs)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false # no job pushes; don't leave the token in .git/config
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: pip
- name: Audit runtime dependencies for known CVEs
run: |
# Keep build tooling current first: pip-audit scans the whole environment,
# so a pip/setuptools advisory that a one-line upgrade fixes would otherwise
# fail the gate on something that isn't one of our runtime dependencies.
python -m pip install --upgrade pip setuptools
python -m pip install -e . pip-audit
# Append `--ignore-vuln <ID>` to accept an unfixable transitive advisory.
python -m pip_audit
# End-to-end check that install.sh actually installs a working `assembly`. Runs
# the script in dev mode (--install-method git) so it installs *this* checkout
# editable via uv — exercising both the installer and the PR's own code — then
# smoke-tests the resulting CLI. Catches install.sh regressions (arg parsing,
# the uv/pipx selection, the editable path) that shellcheck alone can't.
install-script:
name: install script smoke
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false # no job pushes; don't leave the token in .git/config
fetch-depth: 0 # hatch-vcs derives the version from git history for the editable build
# Provide uv so install.sh takes its preferred (uv) path rather than
# bootstrapping it over the network.
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
# PortAudio + ffmpeg so `assembly --help` (which imports the full command
# tree) loads cleanly; also lets install.sh's dep check find them present.
- name: System deps (PortAudio + ffmpeg)
run: ./scripts/ci_install_audio_deps.sh
- name: Run install.sh (editable, from this checkout)
run: ./install.sh --install-method git
- name: Smoke-test the installed CLI
run: |
# uv tool installs land in ~/.local/bin; put it on PATH for this step.
export PATH="$HOME/.local/bin:$PATH"
assembly --version
help_out="$(assembly --help)"
echo "$help_out" | grep -q transcribe