From 34833303f55984a3ad96baeca47b227f4aadc8ff Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 13:35:44 -0600 Subject: [PATCH] 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. --- .github/actions/detect-changes/action.yml | 3 ++ .github/workflows/ci.yml | 9 ++++++ .github/workflows/installer-tests.yml | 38 +++++++++++++++++++++++ scripts/ci/classify_changes.py | 12 +++++++ tests/ci/test_classify_changes.py | 12 ++++++- 5 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/installer-tests.yml diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index cecbd4515c..a5c0b6f9ba 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -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 }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index beb9c41f6a..9b6129d225 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/installer-tests.yml b/.github/workflows/installer-tests.yml new file mode 100644 index 0000000000..66f249ffa2 --- /dev/null +++ b/.github/workflows/installer-tests.yml @@ -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 diff --git a/scripts/ci/classify_changes.py b/scripts/ci/classify_changes.py index d3c915d642..4428a5822f 100644 --- a/scripts/ci/classify_changes.py +++ b/scripts/ci/classify_changes.py @@ -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. diff --git a/tests/ci/test_classify_changes.py b/tests/ci/test_classify_changes.py index 48dc8b10e3..ee5cb8eb10 100644 --- a/tests/ci/test_classify_changes.py +++ b/tests/ci/test_classify_changes.py @@ -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)),