From 0cfbc3f436f88bbd9878ede2eafb455191bd43d9 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Thu, 23 Apr 2026 16:14:32 -0500 Subject: [PATCH] ci: add init-native-e2e workflow across linux / macos / windows Existing Docker init-e2e runs on ubuntu only. Platform-specific bugs (Windows path separators in written hook commands, PowerShell-vs-bash matcher strings, macOS keychain prompts, shutil.which PATHEXT quirks) slip past it. Add a matrix workflow that drops a noop shim for each target agent and runs ``headroom init -g `` on each of the three supported OSes, then asserts the settings file was written to the platform-correct location. Matrix: [ubuntu-latest, macos-latest, windows-latest] x [claude, codex, copilot]. ``openclaw`` is excluded because it delegates to ``headroom wrap openclaw`` which needs a real OpenClaw CLI and can't be stubbed with a noop shim; the Docker suite already covers its negative path. Common setup (Python install, editable headroom install, shim drop, PATH wiring) is factored into a composite action at .github/actions/headroom-e2e-setup so follow-up per-command workflows (install-native-e2e, wrap-native-e2e) can be near-copies that only supply their matrix and assertion blocks. The composite action uses the cross-platform shim scripts from e2e/_lib/make_shim.{sh,ps1} that landed with the harness refactor. Scoped trigger: pull_request touching init code OR the harness, plus pushes to main and manual dispatch. This avoids burning CI minutes on every push to unrelated feature branches while still gating every PR that could regress init behavior. Not verified locally: Windows runner behavior. Reviewer should watch the first matrix run on PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/actions/headroom-e2e-setup/action.yml | 66 ++++++++++ .github/workflows/init-native-e2e.yml | 121 ++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 .github/actions/headroom-e2e-setup/action.yml create mode 100644 .github/workflows/init-native-e2e.yml diff --git a/.github/actions/headroom-e2e-setup/action.yml b/.github/actions/headroom-e2e-setup/action.yml new file mode 100644 index 000000000..2faf79c64 --- /dev/null +++ b/.github/actions/headroom-e2e-setup/action.yml @@ -0,0 +1,66 @@ +name: Headroom e2e setup +description: >- + Checkout-agnostic setup shared by native e2e workflows (init, install, wrap). + Installs Python, installs headroom in editable mode, and (optionally) drops + a noop shim onto PATH so ``headroom init -g `` can detect a tool + that isn't actually installed on the runner. +inputs: + python-version: + description: Python version to install + required: false + default: "3.11" + shim-target: + description: >- + Name of the shim to drop on PATH (e.g. ``claude``, ``codex``). Leave + empty to skip shim creation. + required: false + default: "" +outputs: + shim-dir: + description: Absolute path to the directory containing the dropped shim + value: ${{ steps.shim.outputs.shim-dir }} +runs: + using: composite + steps: + - name: Set up Python ${{ inputs.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install headroom (editable, core deps only) + shell: bash + run: | + python -m pip install --upgrade pip + # ``init`` doesn't need the proxy extras; install the base package. + pip install -e . + + - name: Drop shim (POSIX) + if: ${{ inputs.shim-target != '' && runner.os != 'Windows' }} + id: shim-posix + shell: bash + run: | + shim_dir="${RUNNER_TEMP}/headroom-e2e-shims" + bash e2e/_lib/make_shim.sh "${{ inputs.shim-target }}" "$shim_dir" + echo "$shim_dir" >> "$GITHUB_PATH" + echo "shim-dir=$shim_dir" >> "$GITHUB_OUTPUT" + + - name: Drop shim (Windows) + if: ${{ inputs.shim-target != '' && runner.os == 'Windows' }} + id: shim-windows + shell: pwsh + run: | + $shimDir = Join-Path $env:RUNNER_TEMP "headroom-e2e-shims" + & pwsh -File e2e/_lib/make_shim.ps1 -Name "${{ inputs.shim-target }}" -Dir $shimDir + Add-Content -Path $env:GITHUB_PATH -Value $shimDir + "shim-dir=$shimDir" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + - name: Export shim dir to job output + if: ${{ inputs.shim-target != '' }} + id: shim + shell: bash + run: | + if [ "${{ runner.os }}" = "Windows" ]; then + echo "shim-dir=${{ steps.shim-windows.outputs.shim-dir }}" >> "$GITHUB_OUTPUT" + else + echo "shim-dir=${{ steps.shim-posix.outputs.shim-dir }}" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/workflows/init-native-e2e.yml b/.github/workflows/init-native-e2e.yml new file mode 100644 index 000000000..a355b4f70 --- /dev/null +++ b/.github/workflows/init-native-e2e.yml @@ -0,0 +1,121 @@ +name: Init Native E2E + +# Cross-platform (linux / macos / windows) smoke tests for the per-subcommand +# ``headroom init -g `` flows. Each matrix cell drops a noop shim for +# the target agent onto PATH and asserts ``headroom init -g `` +# succeeds, writes the expected settings file, and (for claude/codex) places +# hooks in the right place. +# +# Deliberately scoped to pull_request + push-to-main + workflow_dispatch to +# avoid bloating CI minutes on every push to every feature branch. The Docker +# init-e2e.yml still runs on every PR and provides the deeper functional +# coverage; this workflow exists to catch platform-specific bugs (Windows +# path separators, macos keychain prompts, PowerShell-vs-bash hook matchers) +# that the single-platform Docker suite can miss. +# +# Extending to other commands (``headroom install``, ``headroom wrap``) is +# expected to be a near-copy of this file. The shared composite action at +# ``.github/actions/headroom-e2e-setup`` absorbs the Python + shim setup so +# each per-command workflow only supplies its matrix and assertion steps. + +on: + pull_request: + branches: [main] + paths: + - "headroom/cli/init.py" + - "headroom/install/**" + - "e2e/_lib/**" + - "e2e/init/**" + - ".github/actions/headroom-e2e-setup/**" + - ".github/workflows/init-native-e2e.yml" + push: + branches: [main] + workflow_dispatch: + +jobs: + init-native: + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + target: [claude, codex, copilot, openclaw] + exclude: + # openclaw delegates to ``headroom wrap openclaw`` which needs a + # running OpenClaw CLI; it can't be shimmed cheaply, so it's + # covered by the bundled Docker e2e instead. + - target: openclaw + + steps: + - uses: actions/checkout@v4 + + - name: Setup (shim=${{ matrix.target }}) + uses: ./.github/actions/headroom-e2e-setup + with: + python-version: "3.11" + shim-target: ${{ matrix.target }} + + - name: Verify shim is on PATH + shell: bash + run: | + which "${{ matrix.target }}" + + - name: Run headroom init -g ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + headroom init -g "${{ matrix.target }}" + + - name: Assert settings file (POSIX) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + case "${{ matrix.target }}" in + claude) + test -f "$HOME/.claude/settings.json" + grep -q "ANTHROPIC_BASE_URL" "$HOME/.claude/settings.json" + ;; + codex) + test -f "$HOME/.codex/config.toml" + test -f "$HOME/.codex/hooks.json" + grep -q "headroom" "$HOME/.codex/config.toml" + ;; + copilot) + test -f "$HOME/.copilot/config.json" + grep -q "SessionStart" "$HOME/.copilot/config.json" + ;; + esac + + - name: Assert settings file (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $home_ = $env:USERPROFILE + switch ("${{ matrix.target }}") { + "claude" { + $p = Join-Path $home_ ".claude\settings.json" + if (-not (Test-Path $p)) { throw "Missing $p" } + if (-not ((Get-Content $p -Raw) -match "ANTHROPIC_BASE_URL")) { + throw "settings.json missing ANTHROPIC_BASE_URL" + } + } + "codex" { + $c = Join-Path $home_ ".codex\config.toml" + $h = Join-Path $home_ ".codex\hooks.json" + if (-not (Test-Path $c)) { throw "Missing $c" } + if (-not (Test-Path $h)) { throw "Missing $h" } + if (-not ((Get-Content $c -Raw) -match "headroom")) { + throw "config.toml missing headroom provider" + } + } + "copilot" { + $p = Join-Path $home_ ".copilot\config.json" + if (-not (Test-Path $p)) { throw "Missing $p" } + if (-not ((Get-Content $p -Raw) -match "SessionStart")) { + throw "copilot config missing SessionStart hooks" + } + } + }