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" + } + } + }