Files
hermes-agent/tests/ci/workflow_steps.py
teknium1 effe2ffa14 test(ci): the CI replay names a silent step's exit code
workflow_steps.py is a helper module, not a test module, so pytest does not
rewrite its asserts: a replayed bash step that exits non-zero with empty
stdout/stderr fails as a bare 'AssertionError' with no exit code. Two
Windows arm64 os-tests runs (jobs 112635777217, 112764629268) failed this way
on unmodified baseline replays, and the NTSTATUS needed to tell a crashed
child (0xC0000142 / 0xC0000005) from a bash failure was lost. Put rc (decimal
and hex), the bash used, and both streams in the message.
2026-10-07 07:49:08 -07:00

145 lines
6.5 KiB
Python

"""Local receipts for the few safe routing steps, NOT a hosted workflow runner.
Execute lane/selection scripts; replace only pytest/Playwright launch boundaries
with argv receipts. No setup actions, installs, E2E tests or native hosts run here.
"""
from __future__ import annotations
import fnmatch
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from hermes_platform.resolver import LookupContext, locate_command
from tests.ci import _gha_expr as gha
# Intercept launch/activation boundaries, not selection logic. The unit lane
# need not have the upgrade job's venv; the Desktop monitor must not outlive us.
_RECEIPTS = r'''
record() {
"$REPLAY_PYTHON" -c 'import json, os, sys; open(os.environ["REPLAY_CALLS"], "a").write(json.dumps(sys.argv[1:]) + "\n")' "$@"
}
source() { [[ "$1" == .venv/bin/activate ]]; }
scripts/run_tests.sh() { record pytest "$@"; }
npx() { record npx "$@"; }
xvfb-run() { shift 2; "$@"; }
sleep() { return 1; }
'''
def required(step: dict, ctx: dict) -> None:
label = step.get("name", step.get("id", "required step"))
assert gha.condition(step.get("if"), ctx), f"{label}: disabled"
assert not gha.truthy(gha.render(step.get("continue-on-error", False), ctx)), f"{label}: advisory"
def _interpreter_dirs(root: Path) -> tuple[str, ...]:
"""The replay interpreter's dir, plus shims for the names setup-python puts on PATH.
Steps call ``python3`` (Linux runners) or ``python`` (Windows runners); a
Windows venv only ships ``python.exe``, so a missing name would otherwise
hit an unrelated host interpreter on os.defpath, or none at all.
"""
interpreter = Path(sys.executable).parent
shims = root / "bin"
shims.mkdir()
for name in ("python", "python3"):
if not locate_command(name, LookupContext(path=str(interpreter))).found:
shim = shims / name
shim.write_text('#!/bin/sh\nexec "$REPLAY_PYTHON" "$@"\n', encoding="utf-8")
shim.chmod(0o755)
return str(interpreter), str(shims)
def _run(step: dict, ctx: dict, cwd: Path | None = None, *, receipt: bool = False) -> tuple[dict, list]:
required(step, ctx)
assert step.get("shell", "bash") == "bash", "only safe Bash selection steps are replayed"
with tempfile.TemporaryDirectory(prefix="workflow-replay-") as directory:
root = Path(directory)
output, calls = root / "outputs", root / "calls"
output.touch()
calls.touch()
bash = locate_command("bash").command[0]
# Steps call git too. Linux reaches it through os.defpath; on Windows the bash found
# first may be Git's usr\bin (no git.exe), so git's own dir rides along like bash's.
git = locate_command("git").command[0]
env = {
"PATH": os.pathsep.join((*_interpreter_dirs(root), os.defpath, str(Path(bash).parent),
str(Path(git).parent))),
"HOME": directory, "RUNNER_TEMP": directory, "GITHUB_OUTPUT": str(output),
"REPLAY_PYTHON": sys.executable, "REPLAY_CALLS": str(calls),
**{k: gha.to_string(gha.render(v, ctx)) for k, v in step.get("env", {}).items()},
}
script = root / "step.sh"
script.write_text((_RECEIPTS if receipt else "") + gha.render(step["run"], ctx), encoding="utf-8")
result = subprocess.run(
[bash, "--noprofile", "--norc", "-eo", "pipefail", str(script)],
cwd=cwd or root, env=env, stdin=subprocess.DEVNULL,
capture_output=True, text=True, timeout=30,
)
# workflow_steps is not a test module, so pytest does not rewrite this assert: the
# message is all a CI failure shows. A child that dies without a word (a Windows
# NTSTATUS exit, a process killed from outside) must still name its exit code.
assert result.returncode == 0, (
f"replayed step exited {result.returncode} (0x{result.returncode & 0xFFFFFFFF:08X}) "
f"under {bash}\n--- stdout ---\n{result.stdout}--- stderr ---\n{result.stderr}")
return (dict(line.split("=", 1) for line in output.read_text(encoding="utf-8-sig").splitlines()),
[json.loads(line) for line in calls.read_text(encoding="utf-8-sig").splitlines()])
def outputs(step: dict, ctx: dict, cwd: Path | None = None) -> dict:
return _run(step, ctx, cwd)[0]
def selected_files(step: dict, ctx: dict, repo: Path) -> set[str]:
"""Receipt the real command and nonempty selected files, not native execution."""
cwd = repo / step.get("working-directory", ".")
_, calls = _run(step, ctx, cwd, receipt=True)
assert len(calls) == 1, f"expected one test invocation, got {calls}"
command, *args = calls[0]
if command == "npx":
assert args[:3] == ["playwright", "test", "-c"], args
assert (cwd / args[3]).is_file(), args
paths = args[4:]
assert paths and all(p.endswith(".spec.ts") for p in paths), args
ignores = []
else:
assert command == "pytest", calls
# Only the runner options used by these steps are supported; fail closed
# when a new filter could change the selected files.
paths, ignores = [], []
i = 0
while i < len(args):
arg = args[i]
if arg == "--files":
i += 1
paths.extend(args[i].split(os.pathsep))
elif arg == "-m":
i += 1 # native marker evaluation belongs to the native runner
assert args[i] in ("platforms and integration", "platforms and not integration"), args
elif arg.startswith("--ignore-glob="):
ignores.append(arg.split("=", 1)[1])
elif arg in ("--", "--include-integration", "-v", "--tb=short", "-rA"):
pass
else:
assert arg.startswith("tests/"), f"unsupported runner argument: {arg}"
paths.append(arg)
i += 1
assert paths, "test command selected no paths"
files = set()
for rel in paths:
path = cwd / rel
assert path.exists(), f"selected test path does not exist: {path}"
candidates = path.rglob("test_*.py") if path.is_dir() else [path]
for candidate in candidates:
name = candidate.relative_to(repo).as_posix()
if not any(fnmatch.fnmatch(name, pattern) for pattern in ignores):
assert candidate.stat().st_size > 0, f"empty test file: {name}"
files.add(name)
assert files, "test command selected no files"
return files