159 lines
7.4 KiB
Python
159 lines
7.4 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-", ignore_cleanup_errors=True) 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")
|
|
# Files, not pipes: on Windows a timed-out run() kills bash and then calls communicate()
|
|
# with no timeout, which blocks for good while any descendant (Git's bash.exe launcher
|
|
# spawns usr/bin/bash.exe) still holds the pipe; the test file then died at the runner's
|
|
# 300 s cap with no output. With files the 30 s timeout raises and names the step.
|
|
out_path, err_path = root / "stdout", root / "stderr"
|
|
with out_path.open("wb") as out_f, err_path.open("wb") as err_f:
|
|
try:
|
|
returncode = subprocess.run(
|
|
[bash, "--noprofile", "--norc", "-eo", "pipefail", str(script)],
|
|
cwd=cwd or root, env=env, stdin=subprocess.DEVNULL,
|
|
stdout=out_f, stderr=err_f, timeout=30,
|
|
check=False,
|
|
).returncode
|
|
except subprocess.TimeoutExpired:
|
|
returncode = None
|
|
stdout = out_path.read_text(encoding="utf-8", errors="replace")
|
|
stderr = err_path.read_text(encoding="utf-8", errors="replace")
|
|
# 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 returncode is not None, (
|
|
f"replayed step timed out after 30 s under {bash}\n--- stdout ---\n{stdout}--- stderr ---\n{stderr}")
|
|
assert returncode == 0, (
|
|
f"replayed step exited {returncode} (0x{returncode & 0xFFFFFFFF:08X}) "
|
|
f"under {bash}\n--- stdout ---\n{stdout}--- stderr ---\n{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
|