Merge pull request #3212 from nesquena/release/stage-batch60

Release stage-batch60 → v0.51.178 (parallel sharded CI + shard-safe test suite, completes #3197)
This commit is contained in:
nesquena-hermes
2026-05-30 14:12:18 -07:00
committed by GitHub
7 changed files with 112 additions and 14 deletions
+19 -3
View File
@@ -10,8 +10,19 @@ jobs:
test:
runs-on: ubuntu-latest
strategy:
# Don't cancel the other shards/versions when one fails — we want the full
# failure picture across the matrix in a single run.
fail-fast: false
matrix:
python-version: ['3.11', '3.12', '3.13']
# Split the suite across 3 parallel shards per Python version. pytest-shard
# partitions tests deterministically by test-id hash; the suite was made
# shard-safe (no cross-test state leakage) so every shard passes
# independently. See docs/agent-memory note on test-suite shard-safety.
# NOTE: pytest-shard is 0-indexed — shard ids must be 0..num_shards-1.
# Using 1-based ids would crash the out-of-range job AND silently skip
# shard 0's tests.
shard: [0, 1, 2]
steps:
- uses: actions/checkout@v4
@@ -20,11 +31,16 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: |
**/setup.cfg
**/requirements*.txt
**/pyproject.toml
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install "pyyaml>=6.0" pytest pytest-timeout pytest-asyncio
pip install "pyyaml>=6.0" pytest pytest-timeout pytest-asyncio pytest-shard
# Install the `mcp` package so tests/test_mcp_server.py runs in CI.
# The package is an optional runtime dep of mcp_server.py — users
# who run the MCP integration install it themselves; CI installs
@@ -33,5 +49,5 @@ jobs:
# importorskip and the matrix stays green.
pip install mcp || echo "mcp install failed — test_mcp_server.py will importorskip"
- name: Run tests
run: pytest tests/ -v --timeout=60
- name: Run tests (shard ${{ matrix.shard }} of 3)
run: pytest tests/ -v --timeout=60 --shard-id=${{ matrix.shard }} --num-shards=3
+6
View File
@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.178] — 2026-05-30 — Release EX (stage-batch60 — parallel sharded CI test runs)
### Changed
- CI: the test suite now runs in 3 parallel shards per Python version (9 jobs total) via `pytest-shard`, cutting wall-clock test time roughly in half (slowest shard ~70s vs ~180s sequential). To make sharding safe, several tests that asserted a pristine default while a sibling test mutated shared process/server state were fixed to establish their own preconditions: onboarding-completed flag reset (`test_onboarding_mvp`), password-hash cache invalidation (`test_issue693_system_health_panel`), authoritative sessions-file path (`test_auth_session_persistence`), and — the root cause of the worst leak — `test_profile_env_isolation` no longer deletes + re-imports `api.profiles` (which poisoned the module's cached base-home global for every later test); it now points the cached path via `monkeypatch.setattr`. A conftest fixture also restores `HERMES_HOME`/`HERMES_BASE_HOME` after each test as defense-in-depth. Completes the test-sharding half of #3197 (the Docker-cache half shipped in v0.51.177).
## [v0.51.177] — 2026-05-30 — Release EW (stage-batch59 — Docker smoke-test layer caching)
### Changed
+49
View File
@@ -18,6 +18,7 @@ import os
import pathlib
import shutil
import subprocess
import sys
import time
import urllib.request
import urllib.error
@@ -83,6 +84,54 @@ def _isolate_hermes_config_path():
yield
os.environ['HERMES_CONFIG_PATH'] = isolated_config_path
@pytest.fixture(autouse=True)
def _restore_profile_home_globals():
"""Restore HERMES_HOME / HERMES_BASE_HOME after every test.
Several tests call ``api.profiles.switch_profile()`` (or set HERMES_HOME
directly) which mutates ``os.environ['HERMES_HOME']`` IN PLACE — not via
monkeypatch — so the change is not auto-reverted at test teardown. In the
normal sequential run the next test usually re-establishes its own profile so
the leak is masked, but under pytest-shard (or pytest-randomly) the leaked
HERMES_HOME points at a deleted tmpdir and breaks any later test whose
config/profile resolution reads it (e.g. test_title_aux_routing's
background-worker profile routing, which then falls back to DEFAULT_CONFIG
where ``model`` is an empty string). Snapshotting at the conftest level fixes
the whole class at once, regardless of which test does the leaking.
"""
saved_home = os.environ.get('HERMES_HOME')
saved_base = os.environ.get('HERMES_BASE_HOME')
# Re-derive the cached base-home global BEFORE the test runs too: a prior
# test's teardown ordering (monkeypatch restoring sys.modules['api.profiles']
# after this fixture's teardown) can leave the live module's
# _DEFAULT_HERMES_HOME stale. Fixing it at setup time guarantees each test
# starts from a base root that matches the current (restored) env.
_rederive_default_hermes_home()
yield
for key, val in (('HERMES_HOME', saved_home), ('HERMES_BASE_HOME', saved_base)):
if val is None:
os.environ.pop(key, None)
else:
os.environ[key] = val
_rederive_default_hermes_home()
def _rederive_default_hermes_home():
"""Recompute api.profiles._DEFAULT_HERMES_HOME from the current env.
api.profiles caches the base home at import time. A test that re-imports
api.profiles under a temporary HERMES_BASE_HOME (e.g. test_profile_env_isolation)
corrupts that global to a now-deleted tmpdir, making get_hermes_home_for_profile
resolve later tests' profiles under the dead path. Re-deriving keeps it honest.
"""
prof_mod = sys.modules.get('api.profiles')
if prof_mod is not None and hasattr(prof_mod, '_resolve_base_hermes_home'):
try:
prof_mod._DEFAULT_HERMES_HOME = prof_mod._resolve_base_hermes_home()
except Exception:
pass
# ── Server script: always relative to repo root ───────────────────────────
SERVER_SCRIPT = REPO_ROOT / 'server.py'
if not SERVER_SCRIPT.exists():
+5 -1
View File
@@ -81,7 +81,11 @@ class TestSessionPersistence(unittest.TestCase):
def test_sessions_file_permissions(self) -> None:
"""Sessions file must be owner-read-only (0600)."""
auth.create_session()
sessions_file = _TEST_STATE / '.sessions.json'
# Check the path auth actually writes to (auth._SESSIONS_FILE is computed
# from api.config.STATE_DIR at import time). Asserting against a local
# _TEST_STATE assumption is fragile under sharded / reordered runs where
# this module may import before api.config.STATE_DIR resolves to _TEST_STATE.
sessions_file = auth._SESSIONS_FILE
self.assertTrue(sessions_file.exists(), ".sessions.json was not created")
mode = oct(sessions_file.stat().st_mode & 0o777)
self.assertEqual(mode, oct(0o600),
+14 -2
View File
@@ -108,11 +108,23 @@ def test_system_health_route_registered_and_auth_gated(monkeypatch):
assert '"/api/system/health"' not in AUTH_PY, "system metrics must not be public"
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
from api import auth as _auth
from api.auth import check_auth
# The password hash is cached process-wide (PBKDF2 is ~1s). A prior test may
# have populated the cache with "no password" (None), so the env var we just
# set would be ignored on the fast path. Invalidate before AND after so this
# test sees its own password and doesn't leak the test-password cache to the
# next test — required for order-independence under sharded/random runs.
_auth._invalidate_password_hash_cache()
handler = _FakeHandler()
assert check_auth(handler, SimpleNamespace(path="/api/system/health", query="")) is False
assert handler.status in (302, 401)
try:
assert check_auth(handler, SimpleNamespace(path="/api/system/health", query="")) is False
assert handler.status in (302, 401)
finally:
monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False)
_auth._invalidate_password_hash_cache()
def test_system_health_route_returns_only_sanitized_payload(monkeypatch):
+7
View File
@@ -64,9 +64,16 @@ def clean_hermes_config_files():
hermes_home = _server_hermes_home()
for rel in ("config.yaml", ".env"):
(hermes_home / rel).unlink(missing_ok=True)
# onboarding_completed lives in settings.json (not config.yaml/.env), so the
# unlinks above don't reset it. A prior test that completes onboarding would
# otherwise leak the flag and make tests asserting the pristine "incomplete"
# default fail under sharded/reordered runs. Reset it via the settings API
# (the source of truth the server reads) before AND after each test.
post("/api/settings", {"onboarding_completed": False})
yield
for rel in ("config.yaml", ".env"):
(hermes_home / rel).unlink(missing_ok=True)
post("/api/settings", {"onboarding_completed": False})
+12 -8
View File
@@ -18,10 +18,12 @@ def test_profile_switch_clears_previous_profile_env_vars(monkeypatch, tmp_path):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("CUSTOM_TOKEN", raising=False)
# Use monkeypatch so sys.modules is restored after the test, preventing
# api.profiles from being permanently removed and poisoning subsequent tests.
monkeypatch.delitem(sys.modules, "api.profiles", raising=False)
profiles = importlib.import_module("api.profiles")
# Point the module's cached base-home at our temp base via monkeypatch
# (auto-restored at teardown) instead of deleting + re-importing api.profiles.
# The delitem+import_module approach swapped the module object and poisoned
# dependent modules' cached references, breaking later tests under sharding.
import api.profiles as profiles
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base)
profiles.init_profile_state()
profiles.switch_profile("p1")
@@ -53,10 +55,12 @@ def test_profile_switch_replaces_overlapping_keys(monkeypatch, tmp_path):
monkeypatch.delenv("ONLY_P1", raising=False)
monkeypatch.delenv("ONLY_P2", raising=False)
# Use monkeypatch so sys.modules is restored after the test, preventing
# api.profiles from being permanently removed and poisoning subsequent tests.
monkeypatch.delitem(sys.modules, "api.profiles", raising=False)
profiles = importlib.import_module("api.profiles")
# Point the module's cached base-home at our temp base via monkeypatch
# (auto-restored at teardown) instead of deleting + re-importing api.profiles.
# The delitem+import_module approach swapped the module object and poisoned
# dependent modules' cached references, breaking later tests under sharding.
import api.profiles as profiles
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base)
profiles.init_profile_state()
profiles.switch_profile("p1")