The extra removal left CI, the Docker image and the nix package still
requesting `hindsight`. Once the extra is gone, `--extra hindsight` and
extraDependencyGroups = [ "hindsight" ] ask for something that no longer
exists. Drop them the same way 73c598e319 originally did: remove it from
the CI extras lists, the Docker sealed-venv build and the nix default
groups, and point the nix examples/check at honcho. Also remove the
stray blank line left in the exclude-newer table.
310 lines
15 KiB
YAML
310 lines
15 KiB
YAML
name: Tests
|
||
|
||
on:
|
||
workflow_call:
|
||
|
||
permissions:
|
||
contents: read
|
||
|
||
# Cancel in-progress runs for the same ref
|
||
concurrency:
|
||
group: tests-${{ github.ref_type == 'tag' && github.run_id || github.ref }}
|
||
cancel-in-progress: ${{ github.ref_type != 'tag' }}
|
||
|
||
jobs:
|
||
test:
|
||
name: Run tests
|
||
# One 96-core runner for the whole suite. There is no slicing. Slicing
|
||
# existed to spread the suite over 4-core runners. It cost a matrix job, a
|
||
# duration cache, a per-slice artifact and a merge job to do it.
|
||
#
|
||
# 96 cores clear the floor that the slowest single test file sets (about
|
||
# 82s). A second slice divides work that is already at that floor, and
|
||
# adds a second setup.
|
||
runs-on: ubuntu-latest-96-core
|
||
timeout-minutes: 30
|
||
steps:
|
||
- name: Checkout code
|
||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||
|
||
- name: Install ripgrep (prebuilt binary)
|
||
run: |
|
||
set -euo pipefail
|
||
RG_VERSION=15.1.0
|
||
RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599
|
||
RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz
|
||
curl -sSfL --retry 3 --retry-delay 5 -o "$RG_TARBALL" \
|
||
"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}"
|
||
echo "${RG_SHA256} ${RG_TARBALL}" | sha256sum -c -
|
||
tar -xzf "$RG_TARBALL"
|
||
sudo mv "ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl/rg" /usr/local/bin/rg
|
||
rm -rf "$RG_TARBALL" "ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl"
|
||
rg --version
|
||
|
||
- name: Set up locked Python and test dependencies
|
||
uses: ./.github/actions/setup-pm
|
||
with:
|
||
extras: '["all", "anthropic", "bedrock", "mistral", "fal", "modal", "daytona", "parallel-web"]'
|
||
test-environment: 'true'
|
||
prune-python-cache: true
|
||
|
||
- name: Check SQLite runs WAL
|
||
# Hermes picks DELETE journal mode on a SQLite with the WAL-reset
|
||
# bug. If the PM-pinned interpreter links one, every WAL test silently
|
||
# runs DELETE (or skips), so fail loudly instead. Tests that need DELETE
|
||
# select it explicitly (journal_mode config, a pinned vulnerable
|
||
# version), so they run on any interpreter.
|
||
run: |
|
||
"$HERMES_PYTHON" -c "import sqlite3, sys, hermes_state_wal as w; v = w.is_sqlite_wal_reset_vulnerable(); print(f'Python {sys.version.split()[0]}, SQLite {sqlite3.sqlite_version}, WAL-reset vulnerable: {v}'); sys.exit(1 if v else 0)"
|
||
|
||
- name: Restore per-file duration cache
|
||
# scripts/run_tests_parallel.py raises a file's timeout to
|
||
# 3x its last healthy duration (_effective_file_timeout) so a
|
||
# known-slow file dilated by load is not SIGKILL'd at the flat cap
|
||
# and laundered into a FLAKY retry. The scaler reads
|
||
# test_durations.json from the checkout; without this restore the
|
||
# file is absent on a fresh runner and the scaler is inert.
|
||
# Exact key never matches (run_id differs); restore-keys picks the
|
||
# most recent cache saved by a main push. PRs read, only main writes.
|
||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||
with:
|
||
path: test_durations.json
|
||
key: test-durations-never-exact
|
||
restore-keys: |
|
||
test-durations-
|
||
|
||
- name: Run tests
|
||
# Per-file isolation via scripts/run_tests.sh: each test file runs
|
||
# in its own freshly-spawned `python -m pytest <file>` subprocess
|
||
# with bounded parallelism. No xdist, no shared workers, no
|
||
# module-level state leakage between files.
|
||
#
|
||
# No --files: the runner discovers the suite itself. The discovered
|
||
# set is identical to the list the removed matrix job used to pass in.
|
||
run: |
|
||
scripts/run_tests.sh
|
||
env:
|
||
# This is the maximum number of test FILES that run together.
|
||
# run_tests_parallel.py starts one pytest subprocess for each file
|
||
# from a single ThreadPoolExecutor, so this value IS the limit. The
|
||
# default is cpu_count*2, which is 192 here.
|
||
#
|
||
# Measured on this runner (96-core EPYC 7763, 377GB). Whole suite,
|
||
# two repetitions for each value. See run 32549672063:
|
||
#
|
||
# workers x cores mean
|
||
# 48 0.5x 138s
|
||
# 96 1.0x 126s <- fastest
|
||
# 144 1.5x 132s
|
||
# 192 2.0x 132s
|
||
# 240 2.5x 140s
|
||
# 288 3.0x 142s
|
||
#
|
||
# One worker for each core wins. The curve is shallow: 126s to 142s
|
||
# across a 6x range. The suite has sufficient concurrency at this
|
||
# size. The remaining time is the slowest files plus the setup.
|
||
# Workers above the core count only add contention.
|
||
HERMES_TEST_WORKERS: 96
|
||
# A healthy 44-test compression file takes ~197s alone and can exceed
|
||
# the 300s default under 96-worker load before its duration is cached.
|
||
# Match the runner's 3x known-slow headroom on the first PR run too.
|
||
HERMES_TEST_FILE_TIMEOUT: 600
|
||
# Ensure tests don't accidentally call real APIs
|
||
OPENROUTER_API_KEY: ""
|
||
OPENAI_API_KEY: ""
|
||
NOUS_API_KEY: ""
|
||
|
||
- name: Save per-file duration cache (main only)
|
||
# Only green first-attempt durations are written by the runner, so
|
||
# a hang on main cannot ratchet its own bound upward.
|
||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('test_durations.json') != ''
|
||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||
with:
|
||
path: test_durations.json
|
||
key: test-durations-${{ github.run_id }}
|
||
|
||
e2e:
|
||
# The core suites spawn real serve / gateway / tui_gateway / MCP / SQLite
|
||
# writer processes per test; a 4-vCPU runner serialises them into timeouts.
|
||
runs-on: ubuntu-latest-32-core
|
||
timeout-minutes: 30
|
||
steps:
|
||
- name: Checkout code
|
||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||
|
||
- name: Set up locked Python, Node and test dependencies
|
||
# toolchain: all puts PM's locked Node in the runner's PM store, not
|
||
# only on PATH. The E2E sandboxes copy node from that store
|
||
# (tests/e2e/core/_pm_dependencies.py) and forbid lazy installs, and
|
||
# the dashboard's /api/pty resolves the TUI's node through PM only.
|
||
# A setup-node binary on PATH is invisible to PM, so every /api/pty
|
||
# chat refused to start. The locked Node also satisfies the
|
||
# boot-contract suite (backend-ready.ts needs Node >= 22.18).
|
||
uses: ./.github/actions/setup-pm
|
||
with:
|
||
toolchain: all
|
||
extras: '["all", "anthropic", "bedrock", "mistral", "fal", "modal", "daytona", "parallel-web"]'
|
||
test-environment: 'true'
|
||
prune-python-cache: true
|
||
|
||
- name: Install Node deps and build the Ink TUI
|
||
# tests/e2e/core/terminal drives the real `hermes --tui` over a PTY;
|
||
# HERMES_E2E_REQUIRE_TUI=1 below turns a missing build into a failure.
|
||
uses: ./.github/actions/retry
|
||
with:
|
||
command: npm ci --workspace ui-tui --ignore-scripts --no-audit --no-fund && npm run build --workspace ui-tui
|
||
|
||
- name: Install ripgrep (prebuilt binary)
|
||
run: |
|
||
set -euo pipefail
|
||
RG_VERSION=15.1.0
|
||
RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599
|
||
RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz
|
||
curl -sSfL --retry 3 --retry-delay 5 -o "$RG_TARBALL" \
|
||
"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}"
|
||
echo "${RG_SHA256} ${RG_TARBALL}" | sha256sum -c -
|
||
tar -xzf "$RG_TARBALL"
|
||
sudo mv "ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl/rg" /usr/local/bin/rg
|
||
rm -rf "$RG_TARBALL" "ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl"
|
||
rg --version
|
||
|
||
- name: Require a WAL-capable SQLite
|
||
# The state.db suites skip on a WAL-reset-vulnerable SQLite; fail
|
||
# instead of reporting green over zero coverage.
|
||
run: |
|
||
"$HERMES_PYTHON" -c "import sqlite3, hermes_state_wal as w; print('sqlite', sqlite3.sqlite_version); assert not w.is_sqlite_wal_reset_vulnerable()"
|
||
|
||
- name: Run e2e tests
|
||
# One subprocess per file, in parallel: the core suites spawn real
|
||
# processes (serve, gateway, tui_gateway, MCP servers, SQLite writers)
|
||
# and must not share interpreter state.
|
||
# tests/e2e/core/upgrade runs in its own job (e2e-upgrade) below.
|
||
run: |
|
||
scripts/run_tests.sh --include-integration tests/e2e
|
||
env:
|
||
# An e2e file is a process tree, not one CPU-bound pytest worker. With
|
||
# 32 files at once the hosted run starved live children before their
|
||
# ready frames (empty stderr, still alive) and hit 60–120s deadlines
|
||
# across unrelated SQLite, tenancy, provider and terminal suites.
|
||
# Bound the trees, not the child readiness/turn timeouts. At 4 the
|
||
# PTY turn and serve-SIGTERM deadlines still starved intermittently.
|
||
HERMES_TEST_WORKERS: "3"
|
||
# Multi-process episodes (torture chamber, compaction kill -9,
|
||
# gateway liveness, delivery exactly-once through a real gateway,
|
||
# the cron virtual-clock soak) legitimately run past the 300 s default.
|
||
HERMES_TEST_FILE_TIMEOUT: "900"
|
||
# No automatic re-run of a failed file: the torture chamber and the
|
||
# exactly-once/compaction suites are race detectors, and a rare
|
||
# corruption that passes on retry is still a corruption.
|
||
HERMES_TEST_FILE_RETRIES: "0"
|
||
HERMES_E2E_REQUIRE_TUI: "1"
|
||
OPENROUTER_API_KEY: ""
|
||
OPENAI_API_KEY: ""
|
||
NOUS_API_KEY: ""
|
||
|
||
e2e-upgrade:
|
||
# tests/e2e/core/upgrade: a real N-1 -> HEAD `hermes update` (clean,
|
||
# autostash, killed mid-pull / mid-deps, offline), fresh-process import and
|
||
# entrypoint smoke, and the config round-trip property matrix. Its own job:
|
||
# it needs full history + tags, bubblewrap, and one file runs ~5-15 min.
|
||
runs-on: ubuntu-latest-32-core
|
||
timeout-minutes: 60
|
||
steps:
|
||
- name: Checkout code
|
||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||
with:
|
||
# N-1 = `git describe --tags --abbrev=0 HEAD~1`.
|
||
fetch-depth: 0
|
||
fetch-tags: true
|
||
|
||
- name: Set up Node
|
||
# `hermes update` builds the web UI / TUI workspaces.
|
||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||
with:
|
||
node-version: 26
|
||
|
||
- name: Install ripgrep (prebuilt binary)
|
||
run: |
|
||
set -euo pipefail
|
||
RG_VERSION=15.1.0
|
||
RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599
|
||
RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz
|
||
curl -sSfL --retry 3 --retry-delay 5 -o "$RG_TARBALL" \
|
||
"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}"
|
||
echo "${RG_SHA256} ${RG_TARBALL}" | sha256sum -c -
|
||
tar -xzf "$RG_TARBALL"
|
||
sudo mv "ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl/rg" /usr/local/bin/rg
|
||
rm -rf "$RG_TARBALL" "ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl"
|
||
rg --version
|
||
|
||
- name: Install uv
|
||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||
with:
|
||
# Pin the uv version: unpinned, setup-uv resolves "latest" by
|
||
# fetching a manifest from raw.githubusercontent.com on EVERY job —
|
||
# a transient fetch failure fails the whole job (2026-07-28 slice-5
|
||
# incident). Pinned, the binary downloads directly; no manifest hop.
|
||
# Newer than the unit job's pin on purpose: 0.9.28 only knows CPython
|
||
# 3.11.14, whose bundled SQLite has the WAL-reset bug, so Hermes runs
|
||
# state.db in DELETE mode and the WAL torture chamber would skip.
|
||
version: "0.12.13"
|
||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||
# Keyed on the dependency manifests, so the cache is reused until
|
||
# pyproject.toml or uv.lock changes. `uv sync` still runs every
|
||
# time, but resolves from the warm cache instead of re-downloading
|
||
# and re-building wheels.
|
||
enable-cache: true
|
||
cache-dependency-glob: |
|
||
pyproject.toml
|
||
uv.lock
|
||
|
||
- name: Set up Python 3.14
|
||
run: uv python install 3.14
|
||
|
||
- name: Install dependencies
|
||
# `uv sync --locked` installs the exact pinned set from uv.lock (and
|
||
# fails if the lock is out of sync with pyproject.toml), giving a
|
||
# reproducible env. It also creates .venv itself, so no separate
|
||
# `uv venv` step is needed.
|
||
#
|
||
# Same extras as the test job's sync above: the hermetic test env
|
||
# forbids mid-run pip installs (HERMES_DISABLE_LAZY_INSTALLS=1 in
|
||
# tests/conftest.py), so lazy-install SDKs exercised by tests must be
|
||
# in the venv up front.
|
||
uses: ./.github/actions/retry
|
||
with:
|
||
command: uv sync --locked --python 3.14 --extra all --group dev --extra anthropic --extra mistral --extra fal --extra modal --extra daytona --extra parallel-web
|
||
|
||
- name: Install bubblewrap
|
||
# Every spawned updater runs in bwrap (own PID namespace, no user
|
||
# systemd bus, only the test tmp writable). Ubuntu 24.04 restricts
|
||
# unprivileged user namespaces, which bwrap needs.
|
||
run: |
|
||
sudo apt-get update -qq
|
||
sudo apt-get install -y -qq bubblewrap
|
||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||
# The suite's own probe (_bwrap_usable, same flags as every sandboxed
|
||
# run): without it the suite silently runs the updater unsandboxed.
|
||
source .venv/bin/activate
|
||
python -c "from tests.e2e.core.upgrade._helpers import BWRAP_OK; assert BWRAP_OK, 'bwrap unusable'" && echo "bwrap ok"
|
||
|
||
- name: Require a WAL-capable SQLite
|
||
# The state.db suites skip on a WAL-reset-vulnerable SQLite; fail
|
||
# instead of reporting green over zero coverage.
|
||
run: |
|
||
source .venv/bin/activate
|
||
python -c "import sqlite3, hermes_state_wal as w; print('sqlite', sqlite3.sqlite_version); assert not w.is_sqlite_wal_reset_vulnerable()"
|
||
|
||
- name: Run upgrade e2e tests
|
||
run: |
|
||
source .venv/bin/activate
|
||
scripts/run_tests.sh --include-integration tests/e2e/core/upgrade
|
||
env:
|
||
# Each file drives real installs/updaters (process trees plus uv and
|
||
# git I/O); cpu_count of them at once starves their deadlines.
|
||
HERMES_TEST_WORKERS: "4"
|
||
HERMES_TEST_FILE_TIMEOUT: "3000"
|
||
OPENROUTER_API_KEY: ""
|
||
OPENAI_API_KEY: ""
|
||
NOUS_API_KEY: ""
|