ci(install): actually run the PowerShell installer tests

scripts/tests/ has held three PowerShell suites that no workflow ever
invoked -- there is no Windows runner in CI, so they have been inert since
they landed. A regression test nothing executes is worse than none: it
reads as coverage.

Adds a windows-latest job, gated on a new `installer` lane so it only fires
for PRs touching install.ps1 or its tests. The 8.3 suite runs under both
pwsh 7 and Windows PowerShell 5.1, since install.ps1 arrives via `irm | iex`
into whichever shell the user already has and 5.1 is what ships with Windows.

Only the 8.3 suite is wired up. The other two fail on main today for
unrelated reasons; they can join once they are fixed.
This commit is contained in:
Brooklyn Nicholson
2026-08-04 13:35:44 -06:00
parent dae7e5477e
commit 34833303f5
5 changed files with 73 additions and 1 deletions

View File

@@ -36,6 +36,9 @@ outputs:
npm_lock:
description: Post/update the semantic package-lock.json diff PR comment.
value: ${{ steps.classify.outputs.npm_lock }}
installer:
description: Run the PowerShell installer tests on a Windows runner.
value: ${{ steps.classify.outputs.installer }}
mcp_catalog:
description: Require MCP catalog security review label.
value: ${{ steps.classify.outputs.mcp_catalog }}

View File

@@ -48,6 +48,7 @@ jobs:
scan: ${{ steps.classify.outputs.scan }}
deps: ${{ steps.classify.outputs.deps }}
npm_lock: ${{ steps.classify.outputs.npm_lock }}
installer: ${{ steps.classify.outputs.installer }}
docker_meta: ${{ steps.classify.outputs.docker_meta }}
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
ci_review: ${{ steps.classify.outputs.ci_review }}
@@ -87,6 +88,13 @@ jobs:
if: needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/js-tests.yml
installer-tests:
name: Installer tests
needs: detect
# Windows-only, and only for PRs that touch install.ps1 or its tests.
if: needs.detect.outputs.installer == 'true'
uses: ./.github/workflows/installer-tests.yml
e2e-desktop:
name: Desktop E2E
needs: detect
@@ -275,6 +283,7 @@ jobs:
- tests
- lint
- js-tests
- installer-tests
- e2e-desktop
- docs-site
- history-check

38
.github/workflows/installer-tests.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Installer tests
# scripts/install.ps1's PowerShell tests. They exercise the installer as a real
# subprocess, and every path contract they assert (8.3 short-name aliases,
# Git Bash layouts, provider-cmdlet behavior) is Windows-specific — so they need
# a Windows runner. Before this workflow existed the files were in the tree but
# nothing ever ran them.
on:
workflow_call:
permissions:
contents: read
concurrency:
group: installer-tests-${{ github.ref }}
cancel-in-progress: true
jobs:
powershell:
name: PowerShell installer tests
runs-on: windows-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Windows PowerShell 5.1 as well as pwsh 7: install.ps1 is delivered via
# `irm | iex` into whatever shell the user already has, and 5.1 is what
# ships with Windows. A construct that only parses under 7 is a broken
# installer for most of the people hitting it.
- name: 8.3 short-path normalization (pwsh 7)
shell: pwsh
run: pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-longpath.ps1
- name: 8.3 short-path normalization (Windows PowerShell 5.1)
shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-longpath.ps1

View File

@@ -19,6 +19,7 @@ Lanes:
* ``scan`` — supply-chain scan (Python files, .pth, setup hooks).
* ``deps`` — pyproject.toml dependency bounds check.
* ``npm_lock`` — semantic package-lock.json diff PR comment.
* ``installer`` — PowerShell installer tests (Windows runner).
* ``mcp_catalog`` — bundled MCP catalog / installer review.
Docker is not a lane — it builds on push-to-main and release only,
@@ -68,6 +69,11 @@ _SCAN_FILES = {"setup.cfg", "pyproject.toml"}
_MCP_CATALOG_PATHS = ("optional-mcps/",)
_MCP_CATALOG_FILES = {"hermes_cli/mcp_catalog.py"}
# Windows installer + its PowerShell tests. These only run on a Windows runner,
# so they get their own lane rather than riding along with ``python``.
_INSTALLER_PATHS = ("scripts/tests/",)
_INSTALLER_FILES = {"scripts/install.ps1", "scripts/install.cmd"}
def _is_docs(p: str) -> bool:
if p.startswith(("skills/", "optional-skills/")):
return False
@@ -98,6 +104,10 @@ def _is_mcp_catalog(p: str) -> bool:
return p.startswith(_MCP_CATALOG_PATHS) or p in _MCP_CATALOG_FILES
def _is_installer(p: str) -> bool:
return p.startswith(_INSTALLER_PATHS) or p in _INSTALLER_FILES
def _is_ci_review(p: str) -> bool:
if p in _CI_REVIEW_FILES or p.startswith(_CI_REVIEW_PATHS):
return True
@@ -123,6 +133,7 @@ def classify(files: list[str]) -> dict[str, bool]:
"scan": any(_is_scan(f) for f in files),
"deps": any(f == "pyproject.toml" for f in files),
"npm_lock": any(f.split("/")[-1] == "package-lock.json" for f in files),
"installer": any(_is_installer(f) for f in files),
"mcp_catalog": any(_is_mcp_catalog(f) for f in files),
"ci_review": any(_is_ci_review(f) for f in files),
}
@@ -135,6 +146,7 @@ def classify(files: list[str]) -> dict[str, bool]:
ret["scan"] = True
ret["deps"] = True
ret["npm_lock"] = True
ret["installer"] = True
ret["ci_review"] = True
# explicitly skip mcp catalog here. it's not needed unless those files are modified.

View File

@@ -30,12 +30,13 @@ DEFAULT = {
"scan": True,
"deps": True,
"npm_lock": True,
"installer": True,
"mcp_catalog": False,
"ci_review": True,
}
def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, npm_lock=False, mcp_catalog=False, docker_meta=False, ci_review=False, python_prod=None) -> dict[str, bool]:
def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, npm_lock=False, installer=False, mcp_catalog=False, docker_meta=False, ci_review=False, python_prod=None) -> dict[str, bool]:
# python_prod tracks python except for tests-only diffs; default it to
# python so the majority of cases don't need to spell it out.
return {
@@ -47,6 +48,7 @@ def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, npm
"scan": scan,
"deps": deps,
"npm_lock": npm_lock,
"installer": installer,
"mcp_catalog": mcp_catalog,
"ci_review": ci_review,
}
@@ -67,6 +69,14 @@ CASES = {
# skill edit must still run Python.
"skill md → python + site": (["skills/github/SKILL.md"], _lanes(python=True, site=True)),
"dockerfile → docker meta": (["Dockerfile"], _lanes(docker_meta=True)),
# install.ps1 is a shell script Python never imports, but it's also not
# provably prose, so python stays on (fail-open) alongside the Windows lane.
"install.ps1 → installer": (["scripts/install.ps1"], _lanes(python=True, installer=True)),
"installer test → installer": (
["scripts/tests/test-install-ps1-longpath.ps1"],
_lanes(python=True, installer=True),
),
"python source alone → no installer lane": (["run_agent.py"], _lanes(python=True, scan=True)),
# Unknown top-level file keeps Python on rather than risk a silent skip.
"unknown toplevel → python": (["Makefile"], _lanes(python=True)),
"mixed docs+python → python": (["README.md", "agent/x.py"], _lanes(python=True, scan=True)),