Under both pwsh 7.6 and Windows PowerShell 5.1 on the lane `& python.exe ...` returns with $LASTEXITCODE unset and no side effect while Start-Process sees a real exit code — the shape PowerShell gives a non-console image. Narrate what the venv launcher is and whether the base interpreter it points at behaves, so the next red run names the cause.
171 lines
9.6 KiB
Python
171 lines
9.6 KiB
Python
"""Source E2E children stamp their own checkout, not the workflow's NEW ref."""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
ASSETS = ROOT / "tests/install/e2e-assets"
|
|
|
|
|
|
def _stamp_probe(tmp_path, shell):
|
|
repo = tmp_path / "installed source"
|
|
for relative in (
|
|
"scripts/write_install_stamp.py", "hermes_cli/__init__.py",
|
|
"hermes_cli/update_channel.py", "hermes_cli/release_channels.py",
|
|
"pm/paths.py", "pm/environments.py",
|
|
"hermes_cli/steward.py", "hermes_constants.py",
|
|
):
|
|
dest = repo / relative
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(ROOT / relative, dest)
|
|
env = dict(os.environ, HOME=str(tmp_path), HERMES_HOME=str(tmp_path / "home"),
|
|
GIT_CONFIG_GLOBAL=str(tmp_path / "gitconfig"), GIT_CONFIG_NOSYSTEM="1")
|
|
(tmp_path / "gitconfig").write_text('[url "file:///staged/serve.git"]\n'
|
|
'\tinsteadOf = https://github.com/NousResearch/hermes-agent.git\n',
|
|
encoding="utf-8")
|
|
|
|
def git(*args):
|
|
return subprocess.run(["git", *args], cwd=repo, env=env, check=True,
|
|
capture_output=True, text=True, timeout=30).stdout.strip()
|
|
|
|
git("init", "-q", "-b", "installed")
|
|
git("config", "user.name", "Fixture")
|
|
git("config", "user.email", "fixture@example.invalid")
|
|
git("add", ".")
|
|
git("commit", "-qm", "OLD")
|
|
old = git("rev-parse", "HEAD")
|
|
git("commit", "--allow-empty", "-qm", "NEW")
|
|
new = git("rev-parse", "HEAD")
|
|
# Control: this is the real stamp writer's CI preference, not a source-text assertion.
|
|
stamp_command = [sys.executable, "-I", "-S", str(repo / "scripts/write_install_stamp.py"),
|
|
"--output", str(tmp_path / "contaminated.json"), "--update-mechanism", "self"]
|
|
git("checkout", "-q", "-B", "installed", old)
|
|
subprocess.run(stamp_command, env={**env, "GITHUB_SHA": new}, check=True,
|
|
capture_output=True, text=True, timeout=30)
|
|
assert json.loads((tmp_path / "contaminated.json").read_text(encoding="utf-8-sig"))["commit"] == new
|
|
overrides = dict(GITHUB_SHA=new, GITHUB_REF="refs/heads/workflow", GITHUB_REF_NAME="workflow",
|
|
GITHUB_HEAD_REF="workflow", GITHUB_BASE_REF="main", HERMES_BUILD_COMMIT=new,
|
|
HERMES_PAYLOAD_TAG="v1.2.3", HERMES_PAYLOAD_VERSION="1.2.3",
|
|
HERMES_DESKTOP_VARIANT="bundled")
|
|
env.update(overrides, ASSETS=str(ASSETS), PROBE_PYTHON=sys.executable,
|
|
PROBE_ROOT=str(repo), PROBE_OUT=str(tmp_path / "stamp.json"),
|
|
PROBE_ENV=str(tmp_path / "child-env.json"))
|
|
probe = tmp_path / "probe.py"
|
|
probe.write_text('''import json, os, runpy
|
|
from pathlib import Path
|
|
stamp = runpy.run_path(str(Path(os.environ['PROBE_ROOT']) / 'scripts/write_install_stamp.py'))
|
|
stamp['write_stamp'](os.environ['PROBE_OUT'], update_mechanism='self')
|
|
Path(os.environ['PROBE_ENV']).write_text(json.dumps(dict(os.environ)), encoding='utf-8')
|
|
''', encoding="utf-8")
|
|
env["PROBE_SCRIPT"] = str(probe)
|
|
# A file, not `-c 'import json, ...'`: pwsh 7.6 on the Windows lane re-quotes native
|
|
# argv and the venv launcher receives a truncated -c body (`import` → SyntaxError).
|
|
dump = tmp_path / "dump_env.py"
|
|
dump.write_text("import json, os; print(json.dumps(dict(os.environ)))\n", encoding="utf-8")
|
|
env["PROBE_DUMP"] = str(dump)
|
|
if shell == "bash":
|
|
command = [shell, "-euc", '''
|
|
source "$ASSETS/source-build-env.sh"
|
|
source_build_env "$PROBE_PYTHON" -I -S "$PROBE_SCRIPT"
|
|
if source_build_env "$PROBE_PYTHON" -I -S -c 'raise SystemExit(23)'; then exit 99; else test "$?" = 23; fi
|
|
"$PROBE_PYTHON" -I -S -c 'import json, os; print(json.dumps(dict(os.environ)))'
|
|
''']
|
|
elif shell == "node":
|
|
env["HERMES_DESKTOP_USER_DATA_DIR"] = str(tmp_path / "user-data")
|
|
env["HERMES_PYTHON_SRC_ROOT"] = str(repo)
|
|
command = [shell, "--input-type=module", "-e", '''
|
|
import { pathToFileURL } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
const { updateWindowEnvironment } = await import(pathToFileURL(process.env.ASSETS + '/smoke-env.mjs'));
|
|
const env = updateWindowEnvironment(process.env, process.env.PROBE_ROOT, 'source');
|
|
const result = spawnSync(process.env.PROBE_PYTHON, ['-I', '-S', process.env.PROBE_SCRIPT], { env, stdio: 'inherit' });
|
|
if (result.error || result.status !== 0) throw new Error(`stamp child failed: ${result.error || result.status}`);
|
|
console.log(JSON.stringify(process.env));
|
|
''']
|
|
else:
|
|
# -File, not -Command: Windows PowerShell 5.1 reads a multi-line -Command argument as
|
|
# far as the first line break and exits 0 having run only the preference line.
|
|
script = tmp_path / "probe.ps1"
|
|
script.write_text('''$ErrorActionPreference = 'Stop'
|
|
[Console]::Error.WriteLine("probe start: $($PSVersionTable.PSVersion) assets=$env:ASSETS python=$env:PROBE_PYTHON")
|
|
trap { [Console]::Error.WriteLine("probe trap: $_"); [Console]::Error.WriteLine($_.ScriptStackTrace); exit 97 }
|
|
. (Join-Path $env:ASSETS 'source-build-env.ps1')
|
|
if ($env:OS -eq 'Windows_NT') {
|
|
# Diagnostics (stderr, shown only on failure): `&` on the lane returns at once with $LASTEXITCODE
|
|
# unset and no output — PowerShell's behaviour for a non-console image — while Start-Process
|
|
# gets a real exit code. Ask what image the launcher is and whether a known console exe behaves.
|
|
$subsystem = try {
|
|
$bytes = [System.IO.File]::ReadAllBytes($env:PROBE_PYTHON)
|
|
$pe = [BitConverter]::ToInt32($bytes, 0x3C)
|
|
[BitConverter]::ToUInt16($bytes, $pe + 24 + 68)
|
|
} catch { "unreadable: $_" }
|
|
[Console]::Error.WriteLine("probe: launcher subsystem=$subsystem (2=GUI 3=console) passing=$PSNativeCommandArgumentPassing")
|
|
& "$env:SystemRoot\System32\cmd.exe" /c "exit 7"
|
|
[Console]::Error.WriteLine("probe: control cmd.exe exit=$LASTEXITCODE")
|
|
$home_ = (Get-Content -LiteralPath (Join-Path (Split-Path -Parent (Split-Path -Parent $env:PROBE_PYTHON)) 'pyvenv.cfg') |
|
|
Where-Object { $_ -match '^home\s*=' }) -replace '^home\s*=\s*', ''
|
|
[Console]::Error.WriteLine("probe: pyvenv home=$home_ base exists=$(Test-Path -LiteralPath (Join-Path $home_ 'python.exe'))")
|
|
& (Join-Path $home_ 'python.exe') -I -S -c "import sys; sys.exit(5)"
|
|
[Console]::Error.WriteLine("probe: base interpreter exit=$LASTEXITCODE")
|
|
& $env:PROBE_PYTHON -I -S -c "import sys; sys.exit(6)"
|
|
[Console]::Error.WriteLine("probe: launcher exit=$LASTEXITCODE")
|
|
}
|
|
Invoke-SourceBuild {
|
|
& $env:PROBE_PYTHON -I -S $env:PROBE_SCRIPT
|
|
[Console]::Error.WriteLine("probe: child dollar-question=$? exit=$LASTEXITCODE out-exists=$(Test-Path -LiteralPath $env:PROBE_OUT)")
|
|
if ($LASTEXITCODE) { throw 'stamp failed' }
|
|
}
|
|
try { Invoke-SourceBuild { throw 'child failure' }; throw 'lost exception' }
|
|
catch { if ($_.Exception.Message -ne 'child failure') { throw } }
|
|
& $env:PROBE_PYTHON -I -S $env:PROBE_DUMP
|
|
if ($LASTEXITCODE) { exit $LASTEXITCODE }
|
|
''', encoding="utf-8-sig")
|
|
command = [shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)]
|
|
print("powershell probe:", shell, file=sys.stderr)
|
|
for sha in (old, new):
|
|
git("checkout", "-q", "-B", "installed", sha)
|
|
result = subprocess.run(command, env=env, cwd=tmp_path, capture_output=True, stdin=subprocess.DEVNULL,
|
|
text=True, encoding="utf-8", errors="replace", timeout=30)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert Path(env["PROBE_OUT"]).is_file(), (
|
|
f"the build child wrote no stamp (shell={command[0]} rc={result.returncode})\n--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}")
|
|
stamp = json.loads(Path(env["PROBE_OUT"]).read_text(encoding="utf-8-sig"))
|
|
assert (stamp["commit"], stamp["branch"], stamp["source"], stamp["payload"]) == (
|
|
sha, "installed", "local", "bootstrap")
|
|
child = json.loads(Path(env["PROBE_ENV"]).read_text(encoding="utf-8-sig"))
|
|
assert not overrides.keys() & child.keys()
|
|
assert child["GIT_CONFIG_GLOBAL"] == env["GIT_CONFIG_GLOBAL"]
|
|
redirect = subprocess.run(["git", "ls-remote", "--get-url",
|
|
"https://github.com/NousResearch/hermes-agent.git"], env=child,
|
|
cwd=tmp_path, check=True, capture_output=True, text=True, timeout=30)
|
|
assert redirect.stdout.strip() == "file:///staged/serve.git"
|
|
assert child["HERMES_HOME"] == env["HERMES_HOME"]
|
|
parent = json.loads(result.stdout.splitlines()[-1])
|
|
assert {key: parent[key] for key in overrides} == overrides
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.parametrize("shell", ["bash", "node"])
|
|
def test_posix_build_children_stamp_old_and_new_without_changing_parent(tmp_path, shell):
|
|
_stamp_probe(tmp_path, shell)
|
|
|
|
|
|
@pytest.mark.platforms("windows", "posix")
|
|
def test_powershell_build_children_stamp_old_and_new_without_changing_parent(tmp_path):
|
|
path = os.pathsep.join(p for p in os.get_exec_path() if ".hermes" not in Path(p).parts)
|
|
# Windows PowerShell 5.1 is what install-e2e-windows-run.yml drives windows-e2e.ps1 (the
|
|
# asset's real consumer) with; pwsh is the only PowerShell a POSIX host offers.
|
|
order = ("powershell", "pwsh") if os.name == "nt" else ("pwsh", "powershell")
|
|
shell = next((found for name in order if (found := shutil.which(name, path=path))), None)
|
|
if not shell:
|
|
if os.name == "nt":
|
|
pytest.fail("native Windows acceptance requires PowerShell")
|
|
pytest.skip("PowerShell is not available on this host")
|
|
_stamp_probe(tmp_path, shell)
|