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)),